diff --git a/assets/blender/scripts/gen_vehicle.py b/assets/blender/scripts/gen_vehicle.py
index f0d2446..9a5be14 100644
--- a/assets/blender/scripts/gen_vehicle.py
+++ b/assets/blender/scripts/gen_vehicle.py
@@ -275,6 +275,149 @@ 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),
+# blue = glass.
+IDMAP_SIZE = 512
+
+
+def idmap_color(mat):
+ ln = mat.name.lower() if mat else ""
+ if "glass" in ln:
+ return (0, 0, 255)
+ if "bloom" in ln:
+ return (0, 255, 0)
+ return (255, 0, 0)
+
+
+def bake_idmap(mesh_datas, path):
+ """Paint per-face material IDs into a texture in UV space.
+
+ The car UVs are degenerate: every polygon collapses onto a single
+ color-atlas texel (the atlas is a palette, not a surface unwrap),
+ so the ID map only needs that texel plus a small margin painted.
+ A texel shared by two different materials is unresolvable and is
+ warned about (fix the blend: give one of them its own texel)."""
+ size = IDMAP_SIZE
+ buf = bytearray(size * size * 3)
+ owner = {}
+ warned = set()
+
+ def paint(u, v, col, matname):
+ u = u - math.floor(u)
+ v = v - math.floor(v)
+ cx, cy = int(u * (size - 1)), int(v * (size - 1))
+ for dy in range(-2, 3):
+ for dx in range(-2, 3):
+ if dx * dx + dy * dy > 5:
+ continue
+ px, py = cx + dx, cy + dy
+ if not (0 <= px < size and 0 <= py < size):
+ continue
+ idx = py * size + px
+ prev = owner.get(idx)
+ if prev is not None and prev != matname:
+ pair = (prev, matname)
+ if pair not in warned:
+ warned.add(pair)
+ print("gen_vehicle: WARNING - UV texel (%d,%d) "
+ "shared by materials '%s' and '%s'; the "
+ "ID map cannot tell them apart (fix the "
+ "blend)" % (px, py, prev, matname))
+ continue
+ owner[idx] = matname
+ buf[idx * 3] = col[0]
+ buf[idx * 3 + 1] = col[1]
+ buf[idx * 3 + 2] = col[2]
+
+ for me in mesh_datas:
+ uv_layer = me.uv_layers.active
+ if not uv_layer:
+ continue
+ uv = uv_layer.data
+ for poly in me.polygons:
+ mat = (me.materials[poly.material_index]
+ if poly.material_index < len(me.materials) else None)
+ puv = uv[poly.loop_indices[0]].uv
+ paint(puv.x, puv.y, idmap_color(mat),
+ mat.name if mat else "")
+
+ img = bpy.data.images.new(
+ os.path.splitext(os.path.basename(path))[0],
+ width=size, height=size, alpha=False)
+ px = [0.0] * (size * size * 4)
+ for i in range(size * size):
+ px[i * 4] = buf[i * 3] / 255.0
+ px[i * 4 + 1] = buf[i * 3 + 1] / 255.0
+ px[i * 4 + 2] = buf[i * 3 + 2] / 255.0
+ px[i * 4 + 3] = 1.0
+ img.pixels = px
+ img.filepath_raw = path
+ img.file_format = 'PNG'
+ img.save()
+ bpy.data.images.remove(img)
+ print("gen_vehicle: wrote ID map %s (%d texels)" %
+ (path, len(owner)))
+
+
+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."""
+ for obj in bpy.data.objects:
+ if obj.type != 'MESH':
+ continue
+ me = obj.data
+ glass_slots = [i for i, m in enumerate(me.materials)
+ if m and "glass" in m.name.lower()]
+ if not glass_slots:
+ continue
+ order = [i for i in range(len(me.materials))
+ if i not in glass_slots] + glass_slots
+ mats = [me.materials[i] for i in order]
+ remap = dict((old, new) for new, old in enumerate(order))
+ # materials.clear() zeroes every polygon's material_index, so
+ # save them first
+ saved = [p.material_index for p in me.polygons]
+ me.materials.clear()
+ for m in mats:
+ me.materials.append(m)
+ for poly, old_idx in zip(me.polygons, saved):
+ poly.material_index = remap[old_idx]
+ bpy.ops.object.select_all(action='DESELECT')
+ obj.select_set(True)
+ bpy.context.view_layer.objects.active = obj
+ bpy.ops.object.mode_set(mode='EDIT')
+ bpy.ops.mesh.select_all(action='SELECT')
+ bpy.ops.mesh.sort_elements(type='MATERIAL', elements={'FACE'})
+ bpy.ops.object.mode_set(mode='OBJECT')
+ obj.select_set(False)
+ print("gen_vehicle: sorted glass faces last on '%s'" % me.name)
+
+
+all_mesh_datas = []
+for obj in bpy.data.objects:
+ if obj.type == 'MESH' and obj.data not in all_mesh_datas:
+ all_mesh_datas.append(obj.data)
+
+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.use_nodes = True
+for me in all_mesh_datas:
+ me.materials.clear()
+ me.materials.append(merged_mat)
+print("gen_vehicle: consolidated %d mesh data blocks onto material "
+ "'%s'" % (len(all_mesh_datas), base))
+
# --- JSON fragment (Ogre coordinates) ---
smn, smx = mesh_bbox(root.data)
collider = {
@@ -511,6 +654,101 @@ bpy.ops.ogre.export(filepath=scene_file,
EX_LOD_PERCENT=40
)
+# --- replace the exporter's fixed-function .material with the merged
+# VehicleIdMap shader materials (CAR_MODELING.md): "" is the
+# transparent near variant, "-opaque" the far variant; the
+# runtime swaps them by camera distance (SedanGlassLodListener).
+# Both reference the VehicleIdMapVP/FP programs staged in the same
+# resource dir (vehicle_idmap.program, CMake-staged from
+# assets/blender/vehicles). ---
+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.
+material @BASE@
+{
+ receive_shadows on
+ technique
+ {
+ pass
+ {
+ cull_hardware clockwise
+ depth_write on
+ scene_blend alpha_blend
+
+ vertex_program_ref VehicleIdMapVP
+ {
+ }
+
+ fragment_program_ref VehicleIdMapFP
+ {
+ param_named glassAlpha float 0.25
+ }
+
+ 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@-opaque
+{
+ receive_shadows on
+ technique
+ {
+ pass
+ {
+ cull_hardware clockwise
+
+ 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
+ }
+ }
+ }
+}
+"""
+
+mat_path = os.path.join(outdir, base + ".material")
+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.
+for suffix in ("-color", "-color-bloom", "-glass"):
+ stale = os.path.join(outdir, base + suffix + ".material")
+ if os.path.exists(stale):
+ os.remove(stale)
+
bpy.ops.wm.read_homefile(use_empty=True)
import time
time.sleep(2)
diff --git a/assets/blender/vehicles/CMakeLists.txt b/assets/blender/vehicles/CMakeLists.txt
index 702b24b..7f7092b 100644
--- a/assets/blender/vehicles/CMakeLists.txt
+++ b/assets/blender/vehicles/CMakeLists.txt
@@ -51,6 +51,13 @@ foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE})
endforeach()
+# Shared merged-material vehicle shader (one ID-map material per car;
+# gen_vehicle.py writes the per-car .material referencing these
+# programs; see vehicles/CAR_MODELING.md). Staged next to the
+# generated .material files.
+file(COPY vehicle_idmap.vert vehicle_idmap.frag vehicle_idmap.program
+ DESTINATION ${CMAKE_BINARY_DIR}/resources/vehicles)
+
add_custom_target(import_vehicles ALL DEPENDS ${VEHICLE_OUTPUT_FILES})
# One-step vehicle refresh: re-export changed .blend files (import_vehicles),
diff --git a/assets/blender/vehicles/sedan-v2.blend b/assets/blender/vehicles/sedan-v2.blend
index 8af8ff6..408157d 100644
--- a/assets/blender/vehicles/sedan-v2.blend
+++ b/assets/blender/vehicles/sedan-v2.blend
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2a017ceeb809a662b92cfbb8f6fa9577e4a95bfc860164433cd5a4c38feeca4b
-size 1753376
+oid sha256:3d9f90c9e9ed3bc1119dc149f251b3fb2a757a42bb850e3efd8ca77266a1cf31
+size 1752584
diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md
index 909fc65..02ed75b 100644
--- a/src/features/editScene/AGENTS.md
+++ b/src/features/editScene/AGENTS.md
@@ -328,6 +328,17 @@ cd demos/demo-taxi
# delta is wall time (a few FPS under xvfb), so the fixed-length
# W-hold drives the sedan clear off the test floor and the exit-spot
# raycast legitimately fails.
+# Performance probes (phase 5.5):
+./demoTaxi --headless --benchmark
+# waits for town population + traffic, then measures 6 fixed views
+# (empty-horizon control, four street-level cardinal directions at
+# the town center, overview from +80 m): wall-clock ms (llvmpipe
+# under xvfb — relative only), OGRE batches/tris, frustum-visible
+# entity groups, per-material render-queue batch counts and
+# BaseWhite (no-material fallback) offenders.
+EDITSCENE_PROFILE=1 ./demoTaxi --headless --benchmark
+# env var: dump per-step ms/frame averages of
+# EditorApp::frameRenderingQueued every 600 frames.
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door /
# sit in the seat of the sedan door you used (driver = front-left,
# only the driver seat drives), hold E = exit the vehicle, Space =
@@ -343,6 +354,27 @@ cd demos/demo-taxi
# 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). 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
+# 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
+# uIdMap read the atlas on some machines, making the whole car
+# semi-transparent (2026-10-16).
# The exterior
# scene parks sedan_taxi and sedan_plain near the spawn point; each
# door child has an actuator with a vehicle_seat_* action (driver,
@@ -482,8 +514,9 @@ binary + its staged project directory as the distributable.
18. `EventHandlerSystem`
19. `CharacterSystem`
20. `BuoyancySystem`
-21. `PhysicsSystem` (pre: `VehicleSystem::prePhysicsUpdate`; post:
- `VehicleSystem::postPhysicsUpdate` wheel/fork/steering visuals)
+21. `PhysicsSystem` (pre: `AiDriverSystem` writes traffic-car
+ `VehicleComponent` inputs, then `VehicleSystem::prePhysicsUpdate`;
+ post: `VehicleSystem::postPhysicsUpdate` wheel/fork/steering visuals)
21a. `VehicleControllerSystem::postPhysicsUpdate` pins the seated
character to the freshly synced vehicle node, then
`PlayerControllerSystem::postPhysicsUpdate` runs the driving-mode
@@ -721,9 +754,10 @@ position reproducibility at a revisited hour).
`NpcDetailSystem` (EditorApp-owned singleton `getInstance()`, game mode
only, `systems/NpcDetailSystem.hpp/.cpp`) is the high-detail tier over the
-symbolic motion layer: population NPCs within `spawnDistanceSq` (80 m) of
+symbolic motion layer: population NPCs within `spawnDistanceSq` (80 m —
+the taxi demo tunes it to 55 m in demo_main.cpp for batch budget) of
the camera become real walking entities and drop back to symbolic beyond
-`despawnDistanceSq` (120 m) — hysteresis plus a `minDwellSeconds` (3 s)
+`despawnDistanceSq` (120 m — demo: 85 m) — hysteresis plus a `minDwellSeconds` (3 s)
dwell and a `spawnBudgetPerFrame` (2) cap, all public config fields. Spawn
eligibility (phase 4.9.1) gates the distance rule — `isSpawnEligible(id)`:
an NPC AT an INDOOR location (`TownLocations::isIndoorKind` — Home, Work,
@@ -915,6 +949,142 @@ inside (a curb-side stand must not oscillate). Tens of NPCs make this
well under a millisecond per frame; threads are a phase-6 crowd question
if profiling ever says so.
+### AiDriverSystem (demo-taxi phase 5.1/5.2/5.3)
+
+`AiDriverSystem` (EditorApp-owned singleton `getInstance()`, game mode
+only, `systems/AiDriverSystem.hpp/.cpp`, runs right before
+`VehicleSystem::prePhysicsUpdate`) drives every entity with an
+`AiDriverComponent` along its `route` polyline: pure-pursuit steering
+(speed-scaled lookahead, max 5 m or 0.8 s of travel) plus a
+proportional throttle/brake speed controller with curvature slowdown
+(full-speed heading error up to `cornerFullAngleDeg` 60, bottoming at
+`minCornerSpeed` 3 m/s). It writes the SAME
+`VehicleComponent::inputForward/inputRight/inputBrake/inputHandBrake`
+fields the player's VehicleControllerSystem writes, so VehicleSystem
+stays the single consumer (brake/reverse protection, parked damping and
+righting come for free); a vehicle carrying the player
+(PlayerControlledComponent) is never touched, so entering a traffic car
+just works. Steering is XZ-only; waypoint Y is informational.
+Non-looping routes brake to a stop at the last waypoint and park
+(`arrived` + handbrake); looping routes wrap and count
+`lapsCompleted`. Sign convention: the sedan
+nose is +Z, its LEFT is +X (door naming), so a target at +X of the nose
+(crossY > 0) means steer LEFT = negative `inputRight`.
+
+Whisker braking (5.2): a 3-ray fan (nose, +/-18 deg) is cast at bumper
+height 0.55 m, starting 2.8 m ahead of the origin, every 3 frames
+staggered by entity id, via
+`JoltPhysicsWrapper::raycastQueryTraffic()` — the
+`raycastQueryWalkable` AllHit-collector pattern (per-hit surface normal
+via BodyLockRead, hits with `normal.y >= walkableNormalY` skipped so
+road and ground never brake the car) with two differences: character
+bodies are NOT skipped (pedestrians must brake cars) and the car's own
+chassis bodyID is excluded by `SkipOneBodyFilter`. The fan length is
+`speed^2/(2*4.0) + followGap + 4` (4 m/s^2 braking assumption,
+followGap 4.5 m). A hit clamps the speed target to
+`(obstacleDistance - followGap) * 0.8`, which glides the car to a stop
+4.5 m short of the obstacle; at standstill the brake is held while the
+path stays blocked, and the target releases the moment the obstacle
+clears (stop-and-go). The query needs `RigidBodyComponent` (bodyID) on
+the vehicle in addition to `VehicleComponent` + `AiDriverComponent`.
+Headless probes: `--test-traffic-loop` (vehicletest scene sedan1+sedan2,
+3 laps each on an 80x50 m rectangle, fails on contact, NaN or leaving
+the loop area) and `--test-traffic-follow` (parked broadside blocker at
+x=20, sedan2 must stop in -10 < x < 15 — the near-blocker window keeps
+the test from passing vacuously on a stationary car — hold 120 frames,
+resume after the blocker teleports away, arrive at x=60).
+
+Junction priority (5.3): the route is a list of `AiRoutePoint`
+(position + junctionNodeId + junctionCenter);
+`classifyJunctions()` flags points within `junctionSnapDist` of a
+degree >= 3 road node (auto-runs once per route on the first update
+against the terrain road graph — the same
+`TerrainSystem::getInstance()->getRoadSystem()->getTerrainEntity()`
+pattern as NpcTaxiSystem — and keeps pre-flagged points). A car
+latches the next flagged point ahead, slows to `junctionSpeed` inside
+`junctionApproachDist`, and at the approach-zone edge either takes the
+junction reservation (first-come-first-served in flecs iteration order)
+or yields with a constant-deceleration sqrt brake profile to a stop
+line `junctionStopDist` before the center. Clear = no other AI car
+holds the reservation AND no vehicle (AI or player, snapshotted once
+per frame) has its chassis within `junctionRadius` of the center;
+yielders stopped at the stop line do not count as occupants. Release:
+the center behind the car and `junctionReleaseDist` away (a plain
+distance check would drop the reservation during the approach leg,
+which starts farther out), plus a `junctionHoldTimeout` stale breaker;
+a per-frame purge drops drive state (and reservations) of despawned or
+player-taken cars. Gotcha: reserve at the approach-zone edge, not the
+stop line — two simultaneously arriving cars both fit inside a stop-line
+window before either can brake. Headless `--test-traffic-yield` (flat
+vehicletest scene, hand-flagged junction node — the priority logic is
+graph-independent): two sedans cross the origin junction from 60 m at
+12 m/s; the loser must stop in the 7.5-30 m approach band, hold, then
+cross. The AI components are attached 3 frames AFTER the teleport so
+the waypoint cursor cannot skip the junction point from the stale
+pre-teleport position.
+
+### NpcTrafficSystem (demo-taxi phase 5.4)
+
+`NpcTrafficSystem` (EditorApp-owned singleton `getInstance()`, game mode
+only, `systems/NpcTrafficSystem.hpp/.cpp`, update after
+NpcAvoidanceSystem, `postPhysicsUpdate` right after NpcTaxiSystem's)
+owns the population's car fleet. When an area's population and
+`TownLocations::LocationSet` exist, each Parking location rolls a
+deterministic hash (fnv1a of the location name, the TownLocations
+per-lot discipline) against `carOwnershipRatio` for one car, capped at
+`maxCarsPerArea`; areas without parking rects (only the layout DSL
+`parking` verb creates Parking POIs) fall back to the Curb locations as
+on-street parking. The driver is the first adult (ageYears >= 18)
+whose `npcHome` string column matches the parking spot's address.
+Parked cars sit nose along the nearest road edge
+(`RoadPath::nearestEdgeInfo`, deterministic direction bit) with the
+handbrake on and no driver; a deterministic `cruisingRatio` subset
+drives an endless loop (home -> deterministic other Parking/Curb ->
+home) built from `RoadPath::findPath` + `RoadPath::offsetToRightLane`
+(rightmost-lane offset: carriageway half width minus half a lane,
+RIGHT = forward x up in OGRE's right-handed +Y-up world; off-road curb
+endpoints are first snapped onto the nearest carriageway point so the
+car never leaves the road to touch the exact curb) and
+junction-classified via `AiDriverSystem::classifyJunctions`. Cruising
+drivers are suspended from NpcMotionSystem and their registry position
+follows the car (the walk-vs-drive choice is deferred to phase 6, so a
+cruising driver effectively lives in its car).
+
+Two tiers, symbolic ground truth (same design as NpcDetailSystem): the
+`TrafficCar` record (route + progress in meters) is authoritative and
+survives spawn/despawn and scene switches; the high-detail tier (camera
+within 150 m, hysteresis 220 m — the taxi demo tunes these to 110/165 m
+in demo_main.cpp for batch budget —, dwell 3 s, spawn budget 1/frame, cap
+`maxActiveVehicles` 8) instantiates `prefabs/sedan-traffic.json` (demo
+source tree, extracted from the vehicletest scene's sedan1 by
+`extract_sedan_prefab.py`, configure_file-staged like the heightmap)
+via a locally constructed `PrefabSystem`, removes the
+EditorMarkerComponent `createInstance` adds (runtime-only: not
+selectable, never serialized), attaches the junction-classified route
+as an AiDriverComponent with `routeIndex` from the symbolic progress,
+and seats the driver through the same manual-spawner +
+`postPhysicsUpdate` seat-pinning approach as NpcTaxiSystem — plus an
+AnimationTreeComponent added at seat time when the inline-spawned
+character lacks one (tree name from
+`NpcDetailSystem::animationTreeName`), with the "driving" locomotion
+state re-asserted every `postPhysicsUpdate` frame (the spawn-frame
+setState no-ops before AnimationTreeSystem has per-entity state; the
+re-assert is an early-out once latched). On despawn
+the live vehicle position projects back onto the route progress.
+`NpcDetailSystem::isSpawnEligible` consults
+`NpcTrafficSystem::isDriving` so a suspended cruising driver never
+materializes as a pedestrian (suspension erases the motion state, which
+would otherwise read as eligible). A car the player enters
+(PlayerControlledComponent — carjacking) leaves the fleet: the record
+is erased, the driver alights in place and returns to its schedule.
+Symbolic cars are invisible and cannot collide — junction priority only
+exists in the high-detail tier, so a transient overlap at the tier
+boundary is accepted. Headless probe: `--test-traffic-town` (exterior
+scene): waits for the fleet, teleports the player next to the first
+cruiser, the car must spawn and drive, a far teleport must despawn it
+while the symbolic progress keeps advancing, and teleporting back must
+respawn it.
+
### EditorSkyboxSystem / EditorSunSystem
The procedural sky (`Skybox/Dynamic` material) is rendered as a fullscreen
@@ -1614,6 +1784,15 @@ the frame and the tunnel into the void — keep the darkness hole-free. The shar
all-black colours (diffuse/ambient/specular/emissive) — with lighting
disabled RTSS ignores material colours and renders the pass white.
+Grid rebuilds (e.g. the texture-ready rebuild right after scene load)
+recreate the door entities; `CellGridSystem` stashes any in-flight
+swing state (`isOpen`/`currentAngle`/`toggleRequested`/
+`sceneSwitchPending`, keyed by `doorId`, in
+`MeshData::pendingDoorStates`) before destroying the old doors and
+re-applies it one-shot to the rebuilt ones, so a toggle or
+scene-switch request landing just before a rebuild is not lost.
+Persisted (F6) doors keep using `GlobalStateStore` as before.
+
New `CellGridComponent` fields (serialized in the scene JSON and exposed to
Lua): `doorsEnabled`, `doorRectName`, `doorMeshName`, `doorUseMeshMaterial`,
`doorOpenAngle`, `doorOpenSpeed`, `doorSwingReversed` (F3: negates the
diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt
index f14c0b1..1b65c21 100644
--- a/src/features/editScene/CMakeLists.txt
+++ b/src/features/editScene/CMakeLists.txt
@@ -37,6 +37,8 @@ set(EDITSCENE_SOURCES
systems/NpcDetailSystem.cpp
systems/NpcTaxiSystem.cpp
systems/NpcAvoidanceSystem.cpp
+ systems/NpcTrafficSystem.cpp
+ systems/AiDriverSystem.cpp
systems/GameClockSystem.cpp
systems/NpcScheduleDebugOverlay.cpp
systems/LightSystem.cpp
diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp
index 7573379..4579a77 100644
--- a/src/features/editScene/EditorApp.cpp
+++ b/src/features/editScene/EditorApp.cpp
@@ -8,6 +8,7 @@
#include
#include "systems/EditorUISystem.hpp"
#include "systems/VehicleSystem.hpp"
+#include "systems/AiDriverSystem.hpp"
#include "systems/ZoneSystem.hpp"
#include "systems/GameHudSystem.hpp"
#include "systems/PhysicsSystem.hpp"
@@ -32,6 +33,7 @@
#include "systems/NpcScheduleDebugOverlay.hpp"
#include "systems/NpcDetailSystem.hpp"
#include "systems/NpcTaxiSystem.hpp"
+#include "systems/NpcTrafficSystem.hpp"
#include "systems/NpcAvoidanceSystem.hpp"
#include "components/CharacterSpawner.hpp"
#include "systems/TerrainPrefabSpawnerSystem.hpp"
@@ -440,6 +442,7 @@ void EditorApp::destroyEditorSystems()
m_characterSpawnerSystem.reset();
m_npcDetailSystem.reset();
m_npcAvoidanceSystem.reset();
+ m_npcTrafficSystem.reset();
m_npcMotionSystem.reset();
m_npcPopulationSystem.reset();
/* Despawn prefab instances before terrain/physics teardown. */
@@ -461,6 +464,7 @@ void EditorApp::destroyEditorSystems()
/* Vehicles hold physics step listeners; tear down before physics. */
m_gameHudSystem.reset();
m_zoneSystem.reset();
+ m_aiDriverSystem.reset();
m_vehicleSystem.reset();
m_physicsSystem.reset();
@@ -569,6 +573,10 @@ void EditorApp::setup()
m_vehicleSystem = std::make_unique(
m_world, m_physicsSystem->getPhysicsWrapper());
+ /* AI-driven vehicles (demo-taxi phase 5.1). */
+ m_aiDriverSystem =
+ std::make_unique(m_world, this);
+
/* F11 target zones (needs the physics wrapper). */
m_zoneSystem = std::make_unique(
m_world, m_physicsSystem->getPhysicsWrapper());
@@ -697,6 +705,12 @@ void EditorApp::setup()
m_npcTaxiSystem =
std::make_unique(m_world, this);
+ /* NPC vehicle traffic (demo-taxi phase 5.4): the
+ * population's car fleet — parked cars at Parking
+ * locations, cruisers on road-graph loops. */
+ m_npcTrafficSystem =
+ std::make_unique(m_world, this);
+
/* NPC local avoidance (demo-taxi phase 4.9.3): halt-based
* separation + obstacle whiskers for the high-detail
* tier. */
@@ -2294,8 +2308,30 @@ void EditorApp::createAxes()
}
}
+bool EditorApp::frameStarted(const Ogre::FrameEvent &evt)
+{
+ bool ret = OgreBites::ApplicationContext::frameStarted(evt);
+ m_profFrameStartUs = m_profTimer.getMicroseconds();
+ return ret;
+}
+
bool EditorApp::frameEnded(const Ogre::FrameEvent & /*evt*/)
{
+ /* Swap/vsync wait span (frameRenderingQueued end -> frameEnded)
+ * plus OGRE render stats for the profile dump. */
+ uint64_t nowUs = m_profTimer.getMicroseconds();
+ m_lastSwapWaitMs = (float)(nowUs - m_profQueuedEndUs) / 1000.0f;
+ static const bool s_profileDump =
+ getenv("EDITSCENE_PROFILE") != nullptr;
+ if (s_profileDump) {
+ m_profSwapAccum += m_lastSwapWaitMs;
+ if (getRenderWindow()) {
+ const Ogre::RenderTarget::FrameStats &st =
+ getRenderWindow()->getStatistics();
+ m_profTrisAccum += (double)st.triangleCount;
+ m_profBatchesAccum += (double)st.batchCount;
+ }
+ }
return true;
}
@@ -2304,17 +2340,41 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
bool paused = (m_gamePlayState == GamePlayState::Paused);
/* Lightweight frame profiler: logs any profiled step that takes
- * longer than 50 ms. Used to find load/switch stalls. */
+ * longer than 50 ms. Used to find load/switch stalls. With
+ * EDITSCENE_PROFILE=1 in the environment it also accumulates every
+ * step and dumps per-step averages to stdout every 600 frames
+ * (phase 5.5 CPU hot-spot hunting). */
Ogre::Timer stepTimer;
- auto stepProfile = [&stepTimer](const char *name) {
+ static const bool s_profileDump = getenv("EDITSCENE_PROFILE") != nullptr;
+
+ /* Render-pass span: frameStarted -> frameRenderingQueued entry
+ * covers the scene graph update, culling and draw submission of
+ * all render targets (Root::_updateAllRenderTargets renders
+ * first, then fires frameRenderingQueued). */
+ {
+ uint64_t nowUs = m_profTimer.getMicroseconds();
+ m_lastRenderPassMs =
+ (float)(nowUs - m_profFrameStartUs) / 1000.0f;
+ if (s_profileDump)
+ m_profRenderAccum += m_lastRenderPassMs;
+ }
+ m_lastSystemsMs = 0.0f;
+
+ auto stepProfile = [&stepTimer, this](const char *name) {
float ms = (float)stepTimer.getMilliseconds();
stepTimer.reset();
+ m_lastSystemsMs += ms;
if (ms > 50.0f)
Ogre::LogManager::getSingleton().logMessage(
"FrameProfile: " + Ogre::String(name) +
" took " +
Ogre::StringConverter::toString(ms / 1000.0f) +
"s");
+ if (s_profileDump) {
+ auto &acc = m_profileAccum[name];
+ acc.first += ms;
+ acc.second += 1;
+ }
};
/* Headless test runs can pin the frame delta so physics and
@@ -2483,6 +2543,15 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
stepProfile("npcAvoidance");
+ /* --- NPC vehicle traffic (demo-taxi phase 5.4): fleet
+ * seeding, symbolic cruise advance, distance-gated
+ * spawn/despawn. Runs after the population/motion/detail
+ * systems so the locations and registry are fresh --- */
+ if (m_npcTrafficSystem) {
+ m_npcTrafficSystem->update(fixedEvt.timeSinceLastFrame);
+ }
+ stepProfile("npcTraffic");
+
/* --- Terrain prefab spawners (after terrain so spawn Y-snap
* and compliance see fresh terrain data) --- */
if (m_terrainPrefabSpawnerSystem) {
@@ -2589,6 +2658,12 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_hairPhysicsSystem->prePhysicsUpdate();
}
+ /* --- AI vehicle drivers (before the vehicle input feed, so
+ * the computed inputs land in the same physics step) --- */
+ if (m_aiDriverSystem) {
+ m_aiDriverSystem->update(fixedEvt.timeSinceLastFrame);
+ }
+
/* --- F10 vehicle inputs (before the physics step) --- */
if (m_vehicleSystem) {
m_vehicleSystem->prePhysicsUpdate(
@@ -2619,6 +2694,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
if (m_npcTaxiSystem) {
m_npcTaxiSystem->postPhysicsUpdate();
}
+ /* NPC traffic drivers pinned to their seats (demo-taxi
+ * phase 5.4) — same post-physics-sync timing. */
+ if (m_npcTrafficSystem) {
+ m_npcTrafficSystem->postPhysicsUpdate();
+ }
if (m_playerControllerSystem) {
m_playerControllerSystem->postPhysicsUpdate(
fixedEvt.timeSinceLastFrame);
@@ -2628,6 +2708,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
if (m_hairPhysicsSystem) {
m_hairPhysicsSystem->postPhysicsUpdate();
}
+ stepProfile("postPhysicsPins");
}
/* --- Rendering support systems --- */
@@ -2642,6 +2723,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
cam = m_camera->getCamera();
m_sunSystem->update(fixedEvt.timeSinceLastFrame, cam);
}
+ stepProfile("sun");
if (m_skyboxSystem) {
Ogre::Camera *cam = nullptr;
if (m_sceneMgr->hasCamera("PlayerCamera"))
@@ -2650,6 +2732,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
cam = m_camera->getCamera();
m_skyboxSystem->update(cam);
}
+ stepProfile("skybox");
if (m_waterPlaneSystem) {
Ogre::Camera *cam = nullptr;
if (m_sceneMgr->hasCamera("PlayerCamera"))
@@ -2658,12 +2741,14 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
cam = m_camera->getCamera();
m_waterPlaneSystem->update(fixedEvt.timeSinceLastFrame, cam);
}
+ stepProfile("water");
if (m_lightSystem) {
m_lightSystem->update();
}
if (m_cameraSystem) {
m_cameraSystem->update();
}
+ stepProfile("light+camera");
if (m_lodSystem) {
m_lodSystem->update();
}
@@ -2726,6 +2811,53 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
// Reset per-frame input state
m_gameInput.resetPerFrame();
+ /* EDITSCENE_PROFILE dump: per-step averages over the last 600
+ * frames, sorted by total cost. */
+ if (s_profileDump) {
+ ++m_profileFrames;
+ if (m_profileFrames % 600 == 0) {
+ std::vector<
+ std::pair > >
+ sorted(m_profileAccum.begin(),
+ m_profileAccum.end());
+ std::sort(sorted.begin(), sorted.end(),
+ [](const auto &a, const auto &b) {
+ return a.second.first > b.second.first;
+ });
+ double total = 0.0;
+ for (const auto &kv : sorted)
+ total += kv.second.first;
+ std::cout << "[profile] frames " << m_profileFrames
+ << ": frame total "
+ << (total + m_profRenderAccum +
+ m_profSwapAccum) / 600.0
+ << " ms = renderPass "
+ << m_profRenderAccum / 600.0
+ << " + systems " << total / 600.0
+ << " + swapWait "
+ << m_profSwapAccum / 600.0
+ << " | tris " << m_profTrisAccum / 600.0
+ << " batches "
+ << m_profBatchesAccum / 600.0;
+ for (const auto &kv : sorted) {
+ if (kv.second.first / 600.0 < 0.05)
+ continue;
+ std::cout << " | " << kv.first << " "
+ << kv.second.first / 600.0;
+ }
+ std::cout << std::endl;
+ m_profileAccum.clear();
+ m_profRenderAccum = 0.0;
+ m_profSwapAccum = 0.0;
+ m_profTrisAccum = 0.0;
+ m_profBatchesAccum = 0.0;
+ }
+ }
+
+ /* End of the systems span — frameEnded measures the swap wait
+ * from here. */
+ m_profQueuedEndUs = m_profTimer.getMicroseconds();
+
// Don't call base class - it crashes when iterating input listeners
return true;
}
diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp
index bb1a6f1..303861c 100644
--- a/src/features/editScene/EditorApp.hpp
+++ b/src/features/editScene/EditorApp.hpp
@@ -8,13 +8,17 @@
#include
#include
#include
+#include