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);