diff --git a/assets/blender/scripts/gen_vehicle.py b/assets/blender/scripts/gen_vehicle.py
index 9a5be14..3fed4d6 100644
--- a/assets/blender/scripts/gen_vehicle.py
+++ b/assets/blender/scripts/gen_vehicle.py
@@ -275,14 +275,17 @@ for m in used_mats:
print("gen_vehicle: unpacked texture '%s' -> %s" %
(img.name, tex_name))
-# --- merged single material + ID map (CAR_MODELING.md) ---
-# Every mesh part is re-materialized onto a single material named
-# so blender2ogre emits ONE submesh per part, and the exporter's
-# fixed-function .material is replaced with the VehicleIdMap shader
-# materials after the export. The ID map texture shares the color
-# atlas UV space (all parts MUST use that one UV space with no texel
-# shared between materials; fix the blend if not) and marks each
-# fragment: red = paint (atlas color), green = bloom (lights),
+# --- merged paint material + separate glass material (CAR_MODELING.md) ---
+# Every mesh part is re-materialized onto TWO materials: for
+# paint/bloom faces and -glass for glass faces, so blender2ogre
+# emits one submesh per part (glass-less parts) or two (glass last).
+# A single merged transparent submesh forced the WHOLE part (interior
+# included) into the transparent render queue, where the depth-writing
+# window glass culled interior parts that sorted behind it ("holes" in
+# the cabin with the road showing through). The ID map texture shares
+# the color atlas UV space (all parts MUST use that one UV space with
+# no texel shared between materials; fix the blend if not) and marks
+# each fragment: red = paint (atlas color), green = bloom (lights),
# blue = glass.
IDMAP_SIZE = 512
@@ -367,10 +370,10 @@ def bake_idmap(mesh_datas, path):
def move_glass_faces_last():
- """Re-order each mesh so glass faces export last: with the merged
- transparent material (depth_write on) the cabin stays visible
- through the windows only if glass triangles draw after the
- interior."""
+ """Re-order each mesh so glass faces export last. With glass on
+ its own material slot (see the consolidation below) blender2ogre
+ groups faces into submeshes by material anyway; the sort just
+ keeps the glass submesh's triangles contiguous at the end."""
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
@@ -410,13 +413,35 @@ for obj in bpy.data.objects:
move_glass_faces_last()
bake_idmap(all_mesh_datas, os.path.join(outdir, base + "-idmap.png"))
-merged_mat = bpy.data.materials.new(base)
+merged_mat = bpy.data.materials.get(base)
+if merged_mat is None:
+ merged_mat = bpy.data.materials.new(base)
merged_mat.use_nodes = True
+# The blend already HAS a "-glass" material (source materials are
+# renamed to -); reuse it — materials.new() would
+# silently create "-glass.001", and the exported submesh material
+# name must match the .material template exactly.
+glass_mat = bpy.data.materials.get(base + "-glass")
+if glass_mat is None:
+ glass_mat = bpy.data.materials.new(base + "-glass")
+glass_mat.use_nodes = True
for me in all_mesh_datas:
+ # materials.clear() zeroes every polygon's material_index, so
+ # classify faces against the original slots first.
+ glass_face = [
+ p.material_index < len(me.materials) and
+ me.materials[p.material_index] is not None and
+ "glass" in me.materials[p.material_index].name.lower()
+ for p in me.polygons
+ ]
me.materials.clear()
me.materials.append(merged_mat)
-print("gen_vehicle: consolidated %d mesh data blocks onto material "
- "'%s'" % (len(all_mesh_datas), base))
+ if any(glass_face):
+ me.materials.append(glass_mat)
+ for p, g in zip(me.polygons, glass_face):
+ p.material_index = 1 if g else 0
+print("gen_vehicle: consolidated %d mesh data blocks onto materials "
+ "'%s'/'%s-glass'" % (len(all_mesh_datas), base, base))
# --- JSON fragment (Ogre coordinates) ---
smn, smx = mesh_bbox(root.data)
@@ -664,6 +689,18 @@ bpy.ops.ogre.export(filepath=scene_file,
IDMAP_MATERIAL_TEMPLATE = """// written by gen_vehicle.py — merged ID-map vehicle material
// (CAR_MODELING.md). Not a blender2ogre product; hand edits are
// overwritten by every export.
+//
+// Three materials per car:
+// @BASE@ paint/bloom submeshes, fully opaque.
+// @BASE@-glass near glass submesh (<10 m): alpha-blended,
+// depth_write OFF so it lands in OGRE's
+// transparent collection and can never
+// depth-block the interior/characters behind it
+// (they render first, the glass blends over).
+// @BASE@-glass-opaque far glass submesh (>=10 m): solid.
+// The demo's SedanGlassLodListener swaps only the -glass submeshes
+// between the near and far variants; paint submeshes always keep
+// @BASE@.
material @BASE@
{
receive_shadows on
@@ -672,7 +709,42 @@ material @BASE@
pass
{
cull_hardware clockwise
- depth_write on
+
+ vertex_program_ref VehicleIdMapVP
+ {
+ }
+
+ fragment_program_ref VehicleIdMapFP
+ {
+ param_named glassAlpha float 1.0
+ }
+
+ texture_unit
+ {
+ texture @BASE@-atlas.png
+ tex_address_mode wrap
+ filtering linear linear none
+ }
+
+ texture_unit
+ {
+ texture @BASE@-idmap.png
+ tex_address_mode clamp
+ filtering point point none
+ }
+ }
+ }
+}
+
+material @BASE@-glass
+{
+ receive_shadows on
+ technique
+ {
+ pass
+ {
+ cull_hardware clockwise
+ depth_write off
scene_blend alpha_blend
vertex_program_ref VehicleIdMapVP
@@ -701,7 +773,7 @@ material @BASE@
}
}
-material @BASE@-opaque
+material @BASE@-glass-opaque
{
receive_shadows on
technique
@@ -742,8 +814,11 @@ with open(mat_path, "w") as f:
f.write(IDMAP_MATERIAL_TEMPLATE.replace("@BASE@", base))
print("gen_vehicle: wrote merged material %s" % mat_path)
-# Drop the stale split-material files from pre-ID-map exports so the
-# resource dir does not accumulate them.
+# blender2ogre also writes a fixed-function -glass.material for
+# the glass material slot; drop it (and the stale pre-ID-map split
+# files) — the template above defines every * material and a
+# duplicate definition from another script would shadow it depending
+# on resource parse order.
for suffix in ("-color", "-color-bloom", "-glass"):
stale = os.path.join(outdir, base + suffix + ".material")
if os.path.exists(stale):
diff --git a/assets/blender/vehicles/CAR_MODELING.md b/assets/blender/vehicles/CAR_MODELING.md
new file mode 100644
index 0000000..ebd1b5e
--- /dev/null
+++ b/assets/blender/vehicles/CAR_MODELING.md
@@ -0,0 +1,301 @@
+# Creating a Car Model for the Game (Blender -> Engine)
+
+This is the contract a car model must satisfy to work with the vehicle
+pipeline: drivable via `VehicleComponent`/`VehicleSystem`, enterable
+seats via `VehicleControllerSystem`, openable doors/hood/trunk, and a
+seated character that actually fits the cabin.
+
+The reference implementation is `sedan-v2.blend` in this directory — a
+hand-modeled car processed by the convention-based generator
+`assets/blender/scripts/gen_vehicle.py`. This is the recommended
+route: model the car once following the naming conventions below and
+the generator produces the meshes, materials, textures AND a
+ready-to-merge scene-entity JSON fragment.
+
+(The older fully-procedural route, `assets/blender/scripts/
+gen_taxi_assets.py` -> `sedan-a.blend` / `sedan-taxi.blend`, still
+works and follows the same engine-side conventions, but its
+`sitting-chair`-at-0.85-scale cabin numbers are superseded by the
+`sitting-vehicle` animation below.)
+
+## 1. Export pipeline (gen_vehicle.py)
+
+- Put the `.blend` in `assets/blender/vehicles/` and add it to
+ `VEHICLES_GEN_SRC` in `assets/blender/vehicles/CMakeLists.txt`.
+- Build target `import_vehicles` runs:
+
+ ```
+ blender .blend -b -Y -P gen_vehicle.py -- .glb .vehicle.json
+ ```
+
+ which does everything `export_vehicles.py` does (blender2ogre
+ `.scene` + one `.mesh` per mesh data block + one `.material` per
+ material, 3 LOD levels, plus a `.glb` preview) and additionally:
+ - classifies parts by naming convention (see §2),
+ - renames mesh data blocks to `-` (`sedan-v2`,
+ `sedan-v2-door-fl`, `sedan-v2-wheel`, `sedan-v2-steering-wheel`,
+ ...),
+ - renames materials to `-` and unpacks packed
+ textures to `-atlas.png` next to the output,
+ - normalizes wheel/steering mesh axes (see §3),
+ - emits `.vehicle.json`: a complete scene entity (collider
+ from the shell bounding box, wheels, seats, steering, door/hood/
+ trunk children with seat actuators).
+- Objects named `*-col` are deleted before export (collision helpers).
+- After (re)export, artifacts land in `build-vscode/resources/vehicles/`.
+ One target re-exports changed blends, re-stages the editScene runtime
+ resources and re-merges the generated vehicle JSONs into the demo-taxi
+ scenes (this edits the source scene JSONs — merge_vehicle.py preserves
+ entity id/name/transform, so placement edits survive):
+
+ ```bash
+ cmake --build build-vscode --target update_vehicles
+ ```
+
+## 2. Naming conventions (what the generator picks up)
+
+- **root**: the single parentless MESH object is the body shell; its
+ mesh DATA frame is the chassis frame (object locations are NOT baked
+ into the `.mesh`, so author shell vertex data relative to the
+ intended chassis origin).
+- **`* Tire`** (the word "tire" or "wheel" in the name): wheel objects.
+ All wheels may share one mesh data block. Wheel radius/width are
+ measured from the mesh bounds. No mirrored copy is needed for the
+ right side: Jolt reports every wheel with the same axle direction, so
+ `VehicleSystem` flips right-side (chassis -X) wheel visuals 180 deg
+ about the local forward axis at runtime — model one wheel and reuse it
+ for all four corners.
+- **`* Door`**: door child entities; origin on the hinge line (model
+ the door CLOSED, then Object -> Set Origin -> Origin to 3D Cursor at
+ the hinge). Each door gets a seat actuator for the seat behind it
+ (same side + row).
+- **`* hood` / `* bonnet`**, **`* trunk`**: hood/trunk child entities,
+ origin on the hinge line.
+- **`* Steering`**: the steering wheel.
+- **`seat_*`**: EMPTY objects marking character seat pins. Exactly one
+ must have "driver" in its name (e.g. `seat_driver`); the front
+ non-driver seat becomes `passenger`, rear seats become `rl`/`rr` by
+ side. The empty's position is where the character's ROOT (base) is
+ pinned — see §5.
+- Everything else is exported as an extra mesh named
+ `-` but gets no child entity.
+
+## 3. Axis and origin conventions
+
+The exporter swaps axes `EX_SWAP_AXIS='xz-y'`: Blender `(x, y, z)`
+becomes Ogre `(x, z, -y)`.
+
+| Blender | Ogre / engine | Meaning |
+|---------|---------------|---------|
+| `-Y` | `+Z` | vehicle forward (Jolt chassis +Z) |
+| `+Z` | `+Y` | up |
+| `+X` | `+X` | vehicle LEFT side |
+
+- **Chassis origin**: pick it once (sedan-v2: axle-box center, ~0.6 m
+ above ground contact) and model the shell data around it. The
+ collider, wheels, seats and part pivots in the generated JSON are all
+ relative to it automatically.
+- **Wheels**: model them as they look (axle along Blender X). The
+ generator rotates the wheel mesh DATA +90 deg about Blender Y so the
+ axle lands on Blender Z (Ogre Y, which `getWheelWorldTransform`
+ assumes) and counter-rotates the objects, so the exported scene keeps
+ the modeled look. Don't pre-rotate.
+- **Steering wheel**: model it tilted as installed; the generator
+ rotates the mesh DATA +90 deg about Blender X so the disc normal
+ lands on Blender Z (Ogre Y, the spin axis `VehicleSystem` uses) and
+ counter-rotates the object.
+- **blender2ogre names `.mesh` files after the mesh DATA name** — the
+ generator handles the renaming, but never redefine another blend's
+ mesh data names (all blends export into the same
+ `resources/vehicles/` directory and silently overwrite).
+
+## 4. Materials and textures (merged ID-map material)
+
+- The generator consolidates EVERY mesh part (shell, doors, hood,
+ trunk, wheels, steering, extras) onto **two materials**: ``
+ for paint/bloom faces and `-glass` for glass faces — so
+ blender2ogre emits **one submesh per part**, or two (paint first,
+ glass last) for glass-bearing parts. The blender2ogre `.material`
+ output is replaced by a hand-written one referencing the shared GLSL
+ programs `VehicleIdMapVP`/`VehicleIdMapFP` (`vehicle_idmap.vert/.frag/.program`,
+ staged into `resources/vehicles/` by CMake; OpenGL ES 2.0 compatible
+ via `OgreUnifiedShader.h`).
+ **Why not one merged submesh:** a single transparent submesh forces
+ the WHOLE part (interior included) into OGRE's transparent render
+ queue, where renderables sort back-to-front by node distance — the
+ depth-writing window glass then culls interior parts whose nodes
+ sort closer to the camera ("holes" punched in the cabin with the
+ road showing through, 2026-10-21/22). Opaque paint + separate
+ depth-write-off glass is the only arrangement that stays correct
+ under per-object transparency sorting.
+- Per-fragment behavior comes from an **auto-generated ID map**
+ (`-idmap.png`, 512x512) that shares the color-atlas UV space:
+ red = paint (atlas color, multiplied by the shader's `vehicleColor`
+ uniform — the future per-car registry color hook), green = bloom
+ (lights; optional `bloomEmissive` term, 0 by default), blue = glass
+ (`glassColor` tint, alpha `glassAlpha`).
+- The car UVs are degenerate by design: every face collapses onto a
+ single color-atlas texel (the atlas is a palette). The ID map simply
+ paints the same texels with the ID color, so **no UV texel may be
+ shared between two different materials** — the generator warns when
+ that happens; fix the blend (give one of them its own texel) rather
+ than working around it.
+- Source-material roles are recognized by name: `*glass*` -> glass,
+ `*bloom*` -> lights, everything else -> paint.
+- **Filtering**: because the atlas is a palette, mipmaps are poison —
+ minification averages neighboring palette texels and distant cars
+ bleed wrong colors (found 2026-10-16: blue headlights at range). The
+ generated `.material` therefore samples the atlas with
+ `filtering linear linear none` (no mips) and the ID map with
+ `filtering point point none`. Never "upgrade" these to mipmapped
+ filtering.
+- **Sampler binding**: `vehicle_idmap.program` binds the samplers
+ explicitly (`param_named uAtlas int 0` / `param_named uIdMap int 1`).
+ Without this the two samplers are auto-assigned in driver
+ uniform-enumeration order, which is not stable — `uIdMap` can end up
+ reading the ATLAS (unit 0), and then every paint texel's blue channel
+ leaks into `glassAlpha` and the whole car goes semi-transparent
+ (found 2026-10-16: see-through doors on llvmpipe while another
+ machine rendered fine). Keep the explicit `int` params in sync with
+ the texture_unit order in the generated `.material`.
+- **Near/far glass**: the generated `.material` defines ``
+ (opaque paint, `glassAlpha 1.0`), `-glass` (near glass:
+ alpha-blended, `depth_write off`, `glassAlpha 0.25`) and
+ `-glass-opaque` (far glass: solid). The demo's
+ `SedanGlassLodListener` swaps ONLY the glass submeshes between
+ `-glass` and `-glass-opaque` at 10 m from the camera
+ (Ogre 14 ignores `Material::setLodLevels` for entities); paint
+ submeshes keep `` at all distances. `depth_write off` is what
+ routes the near glass into the transparent collection — do not add
+ depth write back to it (that is what depth-blocked the driver's
+ face/hair behind the window, 2026-10-21). No per-car setup is needed
+ for new families beyond adding the base name to
+ `kMergedCarMaterials` in the demo's `demo_main.cpp`.
+- Glass faces are sorted to the END of each mesh during generation so
+ the glass submesh's triangles stay contiguous (cosmetic; the submesh
+ split is what carries the rendering correctness).
+- For color variants of the same geometry, duplicate the mesh data and
+ move the paint faces to a different atlas texel — or (later) drive
+ `vehicleColor` per entity.
+
+## 5. Matching the seated/driving character
+
+The character is pinned to the seat point with its root node
+(`VehicleControllerSystem::pinCharacterToSeat`) and plays the
+locomotion state machine's `driving` state, mapped to the
+`sitting-vehicle` animation in the demo's `animation_tree.json`.
+
+**With `sitting-vehicle`, the seat pin IS the character's base
+position** — the pose is authored so the butt, hands and feet land
+correctly relative to the root, so no offsets and no scaling are
+applied (`seatCharacterScale 1.0` in the generated JSON). Place each
+`seat_*` empty exactly where the character's feet-between point should
+sit: roughly on the floor pan at the cushion front edge.
+
+- sedan-v2 seat pins (chassis space): driver/passenger
+ (±0.48, -0.28, 0.201), rl/rr (±0.48, -0.28, -0.603); driver is
+ front-LEFT (+X).
+- The generator takes the wheel centers as the intended REST position
+ and lifts the suspension attach points by
+ `L_rest = maxLength - g/(2*pi*freq)^2` (0.35 - 0.147 = 0.203 at the
+ defaults min/max/freq/damping 0.1/0.35/1.3/0.6), so the car rides at
+ the modeled stance. The suspension is deliberately soft-ish —
+ stiffer springs make the wheels pogo and cost grip.
+- Tire friction: the JSON carries `tireFrictionLongitudinal` /
+ `tireFrictionLateral` (1.3 / 3.0 for sedans), multipliers over the
+ Jolt default tire curves (peak 1.2) which are then combined with the
+ ground friction via `sqrt(tire * ground)` — at the stock 1.0 the car
+ drives like it is on wet ice (full lock at ~15 m/s turns only ~44
+ deg in 2.5 s; 3.0 lateral turns ~88 deg). More longitudinal grip
+ adds understeer on a RWD car, so keep it near stock.
+- Anti-roll bars: `antiRollBarStiffness` (2000 for sedans) adds a Jolt
+ `VehicleAntiRollBar` per axle pair to limit body roll. The engine
+ also rightens a car stuck past ~35 deg of tilt automatically
+ (VehicleSystem), so two-wheel balancing acts recover on their own.
+- Center of mass: `centerOfMassOffset` shifts the physics COM in
+ chassis space (Jolt `OffsetCenterOfMassShape` — only the COM and the
+ inertia about it move; the collider, wheel attachments and the
+ render node stay put). A box collider puts the COM at the box
+ center — for a sedan ~1 m above the tire contact patches, twice the
+ real value — and the car trips onto its side in hard corners (the
+ taxi's taller box makes it worse). The generator places the COM
+ just above wheel-hub height; tweak the chassis-space offset in the
+ editor Vehicle panel (applied live) if a new model still feels
+ tippy.
+- Downforce: `downforceCoefficient` (35 plain sedan, 45 taxi — the
+ CMake invocation passes it per model) applies a straight down force
+ of coefficient * speed^2 at the COM while moving — less bounce and,
+ because the tire normal load (and with it the lateral impulse
+ budget) grows with speed^2, the turn radius stops growing with
+ speed: this is the high-speed cornering grip lever. Raise it when a
+ model understeers at speed, lower it if the car feels glued.
+- Default chassis tuning in the JSON: mass 1200, maxTorque 300,
+ engineBrake 0 (cars coast when the accelerator is released — braking
+ is the driver's job: S while rolling forward brakes via the
+ brake-before-reverse rule, Space is the handbrake; slow yard vehicles
+ like the forklift use ~0.5 so they do not roll forever),
+ front steer 35 deg, rear driven + handbrake 300,
+ steeringRatio 4 — tweak per vehicle after merging.
+
+## 6. Scene integration
+
+The generated `.vehicle.json` is a complete entity fragment.
+Merge it into a scene JSON with the helper
+`src/features/editScene/demos/demo-taxi/merge_vehicle.py`:
+
+```bash
+python3 merge_vehicle.py \
+ /resources/vehicles/.vehicle.json
+```
+
+It replaces the named scene entity (see `sedan1` in
+`demo_scene_vehicletest.json`) with the generated one, renaming the
+entity and children and preserving the scene entity's id and
+transform. **The scene copy does NOT track the blend** — after every
+blend edit re-run the `update_vehicles` target (see §1), which
+re-exports, re-stages resources and re-merges both demo-taxi scenes in
+one step. The door
+children carry `actuator`s with the generic per-seat actions
+`vehicle_seat_driver` / `_passenger` / `_rl` / `_rr` (defined in
+`lua-scripts/data2.lua`), which send the `vehicle_enter` event; the
+vehicle is resolved by proximity, so the same actions work for every
+car.
+
+## 7. Minimal-effort multi-look strategy
+
+- **Color variants**: duplicate shell + door mesh data, swap the paint
+ material (atlas UVs make this cheap).
+- **Silhouette variants** (e.g. the taxi): keep the hardpoints fixed —
+ wheel positions, `seat_*` empties, hinge lines, floor pan — and vary
+ the outer panels (roof sign, livery, bumpers). The generated JSON
+ then carries over nearly unchanged.
+- **Kitbashing**: keep a parts-library blend with the wheel, steering
+ wheel, seats and dash; append into new car blends. Never reuse
+ another blend's mesh DATA names (export clash, §3).
+
+## 8. Verifying a new car
+
+From the demo-taxi build directory
+(`build-vscode/src/features/editScene/demos/demo-taxi`):
+
+```bash
+# Sit the player in a seat and dump a framed screenshot:
+xvfb-run -a -s "-screen 0 1920x1080x24" ./demoTaxi \
+ demo_scene_vehicletest.json --force-pos 4.5 1.5 0.3 90 -15 \
+ --debug-enter-vehicle \
+ --debug-enter-seat driver --screenshot 700 /tmp/seat.png
+```
+
+Check: butt on the cushion, head under the headliner, feet inside the
+body, glass transparent near / opaque past ~10 m, steering wheel in
+front of the driver. The `--debug-enter-*` run also prints a bone
+probe (head bone y should match the exported animation, ~0.75 for
+sitting-vehicle). Then run the headless contract:
+
+```bash
+./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json
+```
+
+(always with `--headless`: without it the frame delta is wall time and
+the fixed-length W-hold drives the car off the test floor, failing the
+exit-spot raycast for timing reasons, not because the car is broken).
diff --git a/assets/blender/vehicles/forklift.blend b/assets/blender/vehicles/forklift.blend
new file mode 100644
index 0000000..2f1b787
--- /dev/null
+++ b/assets/blender/vehicles/forklift.blend
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4bc124fa44cc76d3ccc2426139a8b44d5a0dd8c6c4e3e8c4fb0f05fb2893d693
+size 103021
diff --git a/assets/blender/vehicles/vehicle_idmap.frag b/assets/blender/vehicles/vehicle_idmap.frag
new file mode 100644
index 0000000..4a395fd
--- /dev/null
+++ b/assets/blender/vehicles/vehicle_idmap.frag
@@ -0,0 +1,72 @@
+/*
+ * Merged vehicle material — fragment stage.
+ *
+ * Per fragment the ID map (same UV space as the color atlas) selects
+ * which parameter set applies, by multiplying each candidate value by
+ * its ID channel weight:
+ *
+ * idmap.r = paint — atlas color, tinted by vehicleColor (the future
+ * per-car registry color hook; white for now)
+ * idmap.g = bloom — lights; shaded like paint today, plus an
+ * optional emissive term (bloomEmissive, off by
+ * default to keep the current look)
+ * idmap.b = glass — glassColor tint, alpha = glassAlpha; glass
+ * faces live on their own submesh whose material
+ * the demo swaps by camera distance: "-glass"
+ * (near, alpha 0.25, depth_write off) /
+ * "-glass-opaque" (far, alpha 1.0) —
+ * SedanGlassLodListener
+ *
+ * Lighting is a single directional light (the sun) + scene ambient,
+ * Blinn specular on paint and glass. OpenGL ES 2.0 compatible via
+ * OgreUnifiedShader.h.
+ */
+OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
+#include
+
+OGRE_UNIFORMS(
+ uniform vec4 lightDirView;
+ uniform vec4 lightDiffuse;
+ uniform vec4 ambient;
+ uniform vec4 vehicleColor;
+ uniform vec4 glassColor;
+ uniform float glassAlpha;
+ uniform float bloomEmissive;
+ uniform float specularStrength;
+ uniform float shininess;
+)
+
+SAMPLER2D(uAtlas, 0);
+SAMPLER2D(uIdMap, 1);
+
+MAIN_PARAMETERS
+IN(vec2 vUV, TEXCOORD0)
+IN(vec3 vNormal, TEXCOORD1)
+IN(vec3 vPosView, TEXCOORD2)
+MAIN_DECLARATION
+{
+ vec4 atlas = texture2D(uAtlas, vUV);
+ vec3 idm = texture2D(uIdMap, vUV).rgb;
+ float wPaint = idm.r;
+ float wBloom = idm.g;
+ float wGlass = idm.b;
+
+ vec3 base = atlas.rgb * vehicleColor.rgb * wPaint +
+ atlas.rgb * wBloom +
+ glassColor.rgb * wGlass;
+
+ vec3 n = normalize(vNormal);
+ vec3 l = normalize(-lightDirView.xyz);
+ vec3 v = normalize(-vPosView);
+ vec3 h = normalize(l + v);
+ float diff = max(dot(n, l), 0.0);
+ float spec = pow(max(dot(n, h), 0.0), shininess) *
+ specularStrength * (wPaint + wGlass);
+
+ vec3 lit = base * (ambient.rgb + diff * lightDiffuse.rgb) +
+ spec * lightDiffuse.rgb;
+ lit += atlas.rgb * bloomEmissive * wBloom;
+
+ float alpha = mix(1.0, glassAlpha, wGlass);
+ gl_FragColor = vec4(lit, alpha);
+}
diff --git a/assets/blender/vehicles/vehicle_idmap.program b/assets/blender/vehicles/vehicle_idmap.program
new file mode 100644
index 0000000..6a7b0a7
--- /dev/null
+++ b/assets/blender/vehicles/vehicle_idmap.program
@@ -0,0 +1,40 @@
+/*
+ * Merged single-material vehicle shader (paint / bloom / glass selected
+ * per fragment from an ID map texture sharing the color atlas UV space).
+ * Used by the .material files written by gen_vehicle.py; see
+ * assets/blender/vehicles/CAR_MODELING.md. OpenGL ES 2.0 compatible
+ * (OgreUnifiedShader.h).
+ */
+vertex_program VehicleIdMapVP glsl glsles glslang hlsl
+{
+ source vehicle_idmap.vert
+ default_params
+ {
+ param_named_auto worldViewProj worldviewproj_matrix
+ param_named_auto worldView worldview_matrix
+ }
+}
+
+fragment_program VehicleIdMapFP glsl glsles glslang hlsl
+{
+ source vehicle_idmap.frag
+ default_params
+ {
+ param_named_auto lightDirView light_direction_view_space 0
+ param_named_auto lightDiffuse light_diffuse_colour 0
+ param_named_auto ambient ambient_light_colour
+ param_named vehicleColor float4 1.0 1.0 1.0 1.0
+ param_named glassColor float4 0.8 0.8 0.8 1.0
+ param_named glassAlpha float 0.25
+ param_named bloomEmissive float 0.0
+ param_named specularStrength float 0.5
+ param_named shininess float 32.0
+ // explicit texture-unit bindings: without these the samplers
+ // are auto-assigned in driver uniform-enumeration order and
+ // uIdMap can end up reading the atlas (unit 0) — every paint
+ // texel's blue channel then leaks into glassAlpha and the
+ // whole car goes semi-transparent (2026-10-16)
+ param_named uAtlas int 0
+ param_named uIdMap int 1
+ }
+}
diff --git a/assets/blender/vehicles/vehicle_idmap.vert b/assets/blender/vehicles/vehicle_idmap.vert
new file mode 100644
index 0000000..1ac52c9
--- /dev/null
+++ b/assets/blender/vehicles/vehicle_idmap.vert
@@ -0,0 +1,34 @@
+/*
+ * Merged vehicle material — vertex stage.
+ *
+ * One material for the whole car (see assets/blender/vehicles/
+ * CAR_MODELING.md): the fragment stage selects paint / bloom (lights) /
+ * glass behaviour per fragment from an ID map texture that shares the
+ * color atlas UV space. OpenGL ES 2.0 compatible via
+ * OgreUnifiedShader.h.
+ */
+OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
+#include
+
+OGRE_UNIFORMS(
+ uniform mat4 worldViewProj;
+ uniform mat4 worldView;
+)
+
+OUT(vec2 vUV, TEXCOORD0)
+OUT(vec3 vNormal, TEXCOORD1)
+OUT(vec3 vPosView, TEXCOORD2)
+
+MAIN_PARAMETERS
+IN(vec4 vertex, POSITION)
+IN(vec3 normal, NORMAL)
+IN(vec2 uv0, TEXCOORD0)
+MAIN_DECLARATION
+{
+ gl_Position = mul(worldViewProj, vertex);
+ vUV = uv0;
+ /* Vehicles are never non-uniformly scaled, so the plain
+ * world-view 3x3 is a fine normal transform. */
+ vNormal = normalize(mul(mat3(worldView), normal));
+ vPosView = mul(worldView, vertex).xyz;
+}
diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md
index c1f867a..20a93f2 100644
--- a/src/features/editScene/AGENTS.md
+++ b/src/features/editScene/AGENTS.md
@@ -394,28 +394,30 @@ EDITSCENE_PROFILE=1 ./demoTaxi --headless --benchmark
# assets/blender/vehicles/CAR_MODELING.md. After editing a vehicle
# blend run `cmake --build build-vscode --target update_vehicles` (one
# step: re-export, re-stage resources, re-merge both demo scenes).
-# Rendering (phase 5.5): every part (body, doors, hood, trunk, wheels,
-# steering) exports with exactly ONE submesh on ONE shared material
-# (`sedan-v2` / `sedan-v2-taxi`) using the VehicleIdMap shader
-# (resources/vehicles/vehicle_idmap.*, GLES2-compatible,
-# OgreUnifiedShader.h) — a baked ID-map texture (red = paint, green =
-# bloom/lights, blue = glass, sharing the cars' palette-degenerate UV
-# space) selects paint/bloom/glass behavior per fragment; the
-# `vehicleColor` shader param is the hook for future per-car colors.
-# Two variants swap by camera distance (`` alpha-blended glass
-# within 10 m, `-opaque` beyond). The near variant carries
-# `transparent_sorting force`: `alpha_blend` + `depth_write on`
-# otherwise lands in OGRE's SOLID pass-group collection (ordered by
-# Pass* address, arbitrary), and the window glass depth-blocked the
-# driver's face/hair behind it (2026-10-21); forcing transparent
-# sorting keeps `depth_write on` for car self-occlusion while
-# rendering the car after opaque characters. gen_vehicle.py bakes the ID
+# Rendering (phase 5.5, glass split 2026-10-22): every part (body,
+# doors, hood, trunk, wheels, steering) exports with ONE paint submesh
+# on the opaque `` material plus, for glass-bearing parts, a
+# trailing glass submesh on `-glass` — both using the
+# VehicleIdMap shader (resources/vehicles/vehicle_idmap.*,
+# GLES2-compatible, OgreUnifiedShader.h) — a baked ID-map texture
+# (red = paint, green = bloom/lights, blue = glass, sharing the cars'
+# palette-degenerate UV space) selects paint/bloom/glass behavior per
+# fragment; the `vehicleColor` shader param is the hook for future
+# per-car colors. Do NOT merge glass back into the paint submesh: a
+# single transparent submesh puts the whole part into OGRE's
+# transparent queue, where per-renderable distance sorting lets the
+# depth-writing windows cull interior parts behind them (holes in the
+# cabin) and earlier depth-blocked the driver's face/hair. The near
+# glass material (`-glass`, within 10 m) is alpha-blended with
+# `depth_write off` — that is what routes it to the transparent
+# collection after the opaque interior; the far variant
+# (`-glass-opaque`) is solid. SedanGlassLodListener swaps only
+# the glass submeshes between the two. gen_vehicle.py bakes the ID
# map (warning on palette texels shared between materials — fix the
# blend), sorts glass faces last per mesh and writes the `.material`
# itself (the palette atlas is sampled with mipmaps OFF — `linear
# linear none` — because minification averages neighboring palette
-# texels and distant cars bled wrong colors); the demo's
-# SedanGlassLodListener does the near/far swap. The shader's samplers
+# texels and distant cars bled wrong colors). The shader's samplers
# are bound explicitly (`param_named uAtlas int 0` / `param_named
# uIdMap int 1` in vehicle_idmap.program) — OGRE's sampler
# auto-assignment follows driver uniform-enumeration order and let
diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md
index dac7625..c83c96b 100644
--- a/src/features/editScene/demos/demo-taxi/PLAN.md
+++ b/src/features/editScene/demos/demo-taxi/PLAN.md
@@ -301,10 +301,12 @@ cabin completely). `fixupSedanGlassMaterial()` (demo_main.cpp) makes
`sedan-glass-opaque`; `SedanGlassLodListener` swaps every vehicle's
glass submeshes between the two by camera distance (10 m threshold) —
Ogre 14 no longer applies `Material::setLodLevels` to entities, so a
-real LOD material never switches. (Superseded in phase 5.5: the
-listener now swaps the single merged VehicleIdMap material
-`` <-> `-opaque`; `fixupSedanGlassMaterial()` only remains
-for the legacy `sedan-glass`.)
+real LOD material never switches. (Superseded in phase 5.5, then
+again on 2026-10-22: convention-built cars now export an opaque
+`` paint submesh plus a separate `-glass` submesh per
+glass-bearing part, and the listener swaps only the glass submeshes
+`-glass` <-> `-glass-opaque`; `fixupSedanGlassMaterial()`
+only remains for the legacy `sedan-glass`.)
**sedan-v2 (hand-modeled base sedan, current reference).** The
hand-modeled `assets/blender/vehicles/sedan-v2.blend` replaced the
@@ -328,9 +330,10 @@ offsets, `seatCharacterScale` 1.0). `sedan-v2-glass` was added to
`kGlassMaterialNames` for the near/far glass swap. The full contract
is `assets/blender/vehicles/CAR_MODELING.md`; the taxi variant of
sedan-v2 is next. (Superseded in phase 5.5: the three
-color/color-bloom/glass materials are now ONE merged VehicleIdMap
-material per car with the near/far swap done on it — see the 5.5
-results.) One engine fix this exposed:
+color/color-bloom/glass materials became ONE merged VehicleIdMap
+material per car — then on 2026-10-22 glass split back out into its
+own submesh/material per glass-bearing part because the merged
+transparent submesh broke cabin occlusion; see the 5.5 follow-up.) One engine fix this exposed:
`AnimationTreeSystem` now disables animations that fall out of the
evaluated set in a single frame (a large-deltaTime fade-out step used
to leave the old animation enabled at its last weight forever — the
@@ -2961,14 +2964,32 @@ taxi's lights should look distinct.
pass happened to sort before the face/hair RTSS passes, the window
glass wrote closer depth first, and the character fragments behind
it failed the depth test (HW vs SW skinning differed only because
- the generated passes' addresses shifted). Fix: `transparent_sorting
- force` in the near variant's pass in `gen_vehicle.py`'s
- IDMAP_MATERIAL_TEMPLATE — routes the car to the transparent
- collection (after opaque characters, back-to-front) while keeping
- `depth_write on` for per-pixel car self-occlusion. The `-opaque`
- far variant is unchanged (stays solid by design). A proper
- glass-submesh split remains the structural alternative if this ever
- regresses.
+ the generated passes' addresses shifted). First fix:
+ `transparent_sorting force` in the near variant's pass — routes the
+ car to the transparent collection (after opaque characters,
+ back-to-front) while keeping `depth_write on`.
+- **Holes punched in the cabin through the windows (fixed 2026-10-22,
+ supersedes `transparent_sorting force`).** With the whole part in
+ the transparent collection, per-renderable back-to-front sorting by
+ node distance let the depth-writing window glass cull OTHER PARTS of
+ the same car whose nodes sorted closer to the camera — door cards /
+ seats / dashboard went missing with the road and its markings
+ showing through, always reproducible looking through the front/rear
+ windows with the car over the centerline. Root fix: glass is its
+ own submesh again — `gen_vehicle.py` consolidates each part onto
+ `` (paint/bloom faces, fully opaque) plus `-glass`
+ (glass faces, alpha-blended with `depth_write OFF`, so it auto-lands
+ in the transparent collection and blends over the already-rendered
+ interior without ever depth-blocking it); `-glass-opaque` is
+ the far variant, and `SedanGlassLodListener` now swaps only glass
+ submeshes (paint keeps `` at all distances). The whole-car
+ `-opaque` material is gone. Batch cost: +1 batch per
+ glass-bearing part for NEAR cars only (sedans: body, 4 doors, hood,
+ trunk — the latter two carry small glass faces). Verified with
+ `--debug-enter-vehicle` + `--free-camera` screenshots: driver fully
+ visible through side and front glass, cabin intact from every angle.
+ Post-split benchmark-drive: PASS, wall avg 13.4 ms, batches avg 53
+ (+3 vs the merged-submesh build — the near-car glass submeshes).
Post-fix benchmark-drive (llvmpipe): PASS, find split avg objs
0.31 ms (was 13.7), physics split avg step 0.96 ms with zero
physics spikes >8 ms. Remaining spikes for phase 11.5: `water`
diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp
index ed0f21d..df23ab0 100644
--- a/src/features/editScene/demos/demo-taxi/demo_main.cpp
+++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp
@@ -2013,14 +2013,17 @@ static void createFloorMesh(const Ogre::String &meshName,
}
/*
- * The convention-built cars (gen_vehicle.py, CAR_MODELING.md) export a
- * single merged ID-map material per car family: "" is the
- * transparent near variant, "-opaque" the far variant (glass
- * renders solid), both shipped in the generated .material.
- * SedanGlassLodListener swaps every part's submeshes between the two
- * depending on camera distance (Ogre 14 no longer applies
- * Material::setLodLevels to entities, so a real LOD material never
- * switches technique).
+ * The convention-built cars (gen_vehicle.py, CAR_MODELING.md) export
+ * one paint submesh per part (material "", always opaque) plus,
+ * for glass-bearing parts, a trailing glass submesh (material
+ * "-glass", alpha-blended with depth_write OFF — it lands in
+ * OGRE's transparent collection and blends over the already-rendered
+ * interior; keeping glass in the paint submesh made the depth-writing
+ * windows cull interior parts that sorted behind them). The far
+ * variant "-glass-opaque" renders the glass solid past 10 m;
+ * SedanGlassLodListener swaps only the glass submeshes between the two
+ * (Ogre 14 no longer applies Material::setLodLevels to entities, so a
+ * real LOD material never switches technique).
*
* The legacy export_vehicles.py cars still split glass into its own
* fixed-function material; for those the same swap is faked here:
@@ -2058,11 +2061,12 @@ static void fixupSedanGlassMaterial()
}
/*
- * Per-vehicle near/far material swap: the plain material (transparent
- * glass) within 10 m of the camera, its "-opaque" variant beyond — see
+ * Per-vehicle near/far glass swap: "-glass" (transparent) within
+ * 10 m of the camera, "-glass-opaque" beyond — see
* fixupSedanGlassMaterial. Walks the whole node subtree of each
- * vehicle, so child entities (doors, wheels, steering) are covered
- * too; with the merged materials this switches every part at once.
+ * vehicle so child entities (doors, wheels, steering) are covered too.
+ * Paint submeshes ("") are opaque at every distance and are left
+ * alone.
*/
struct SedanGlassLodListener : public Ogre::FrameListener {
EditorApp *app;
@@ -2113,17 +2117,21 @@ struct SedanGlassLodListener : public Ogre::FrameListener {
for (const char *base :
kMergedCarMaterials) {
const Ogre::String
- opaque =
+ glassNear =
Ogre::String(base) +
+ "-glass";
+ const Ogre::String
+ glassFar =
+ glassNear +
"-opaque";
- if (cur2 != base &&
- cur2 != opaque)
+ if (cur2 != glassNear &&
+ cur2 != glassFar)
continue;
const Ogre::String
want =
near ?
- base :
- opaque;
+ glassNear :
+ glassFar;
if (cur2 != want)
sub->setMaterialName(
want);