Fixed vehicle rendering

This commit is contained in:
2026-10-22 10:14:44 +03:00
parent ff81a9a657
commit c561fad79e
9 changed files with 625 additions and 69 deletions
+301
View File
@@ -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 <name>.blend -b -Y -P gen_vehicle.py -- <out>.glb <out>.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 `<base>-<role>` (`sedan-v2`,
`sedan-v2-door-fl`, `sedan-v2-wheel`, `sedan-v2-steering-wheel`,
...),
- renames materials to `<base>-<material>` and unpacks packed
textures to `<base>-atlas.png` next to the output,
- normalizes wheel/steering mesh axes (see §3),
- emits `<name>.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
`<base>-<sanitized mesh name>` 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**: `<base>`
for paint/bloom faces and `<base>-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**
(`<base>-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 `<base>.material` defines `<base>`
(opaque paint, `glassAlpha 1.0`), `<base>-glass` (near glass:
alpha-blended, `depth_write off`, `glassAlpha 0.25`) and
`<base>-glass-opaque` (far glass: solid). The demo's
`SedanGlassLodListener` swaps ONLY the glass submeshes between
`<base>-glass` and `<base>-glass-opaque` at 10 m from the camera
(Ogre 14 ignores `Material::setLodLevels` for entities); paint
submeshes keep `<base>` 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 `<name>.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 <scene.json> \
<builddir>/resources/vehicles/<name>.vehicle.json <entityName>
```
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 <vehicleEntityName> \
--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).
Binary file not shown.
@@ -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: "<base>-glass"
* (near, alpha 0.25, depth_write off) /
* "<base>-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 <OgreUnifiedShader.h>
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);
}
@@ -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 <car>.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
}
}
@@ -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 <OgreUnifiedShader.h>
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;
}