In phase 5

This commit is contained in:
2026-10-16 15:24:34 +03:00
parent e58c061709
commit 136013440e
24 changed files with 6001 additions and 1761 deletions
+238
View File
@@ -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
# <base> 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 "<none>")
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): "<base>" is the
# transparent near variant, "<base>-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)
+7
View File
@@ -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),
Binary file not shown.
+183 -4
View File
@@ -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 (`<base>` alpha-blended glass
# within 10 m, `<base>-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
+2
View File
@@ -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
+134 -2
View File
@@ -8,6 +8,7 @@
#include <OgreMaterialManager.h>
#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<VehicleSystem>(
m_world, m_physicsSystem->getPhysicsWrapper());
/* AI-driven vehicles (demo-taxi phase 5.1). */
m_aiDriverSystem =
std::make_unique<AiDriverSystem>(m_world, this);
/* F11 target zones (needs the physics wrapper). */
m_zoneSystem = std::make_unique<ZoneSystem>(
m_world, m_physicsSystem->getPhysicsWrapper());
@@ -697,6 +705,12 @@ void EditorApp::setup()
m_npcTaxiSystem =
std::make_unique<NpcTaxiSystem>(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<NpcTrafficSystem>(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<std::string, std::pair<double, long> > >
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;
}
+47
View File
@@ -8,13 +8,17 @@
#include <OgreImGuiOverlay.h>
#include <OgreRenderTargetListener.h>
#include <flecs.h>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include "lua/LuaState.hpp"
#include "ProjectConfig.hpp"
// Forward declarations
class EditorUISystem;
class VehicleSystem;
class AiDriverSystem;
class ZoneSystem;
class GameHudSystem;
class NpcScheduleDebugOverlay;
@@ -34,6 +38,7 @@ class NpcMotionSystem;
class NpcDetailSystem;
class NpcTaxiSystem;
class NpcAvoidanceSystem;
class NpcTrafficSystem;
class GameClockSystem;
class TerrainPrefabSpawnerSystem;
class AnimationTreeSystem;
@@ -177,6 +182,7 @@ public:
// OgreBites::ApplicationContext overrides
void setup() override;
bool frameStarted(const Ogre::FrameEvent &evt) override;
bool frameRenderingQueued(const Ogre::FrameEvent &evt) override;
bool frameEnded(const Ogre::FrameEvent &evt) override;
void locateResources() override;
@@ -357,6 +363,10 @@ public:
{
return m_npcTaxiSystem.get();
}
NpcTrafficSystem *getNpcTrafficSystem() const
{
return m_npcTrafficSystem.get();
}
NpcAvoidanceSystem *getNpcAvoidanceSystem() const
{
return m_npcAvoidanceSystem.get();
@@ -381,6 +391,10 @@ public:
{
return m_vehicleSystem.get();
}
AiDriverSystem *getAiDriverSystem() const
{
return m_aiDriverSystem.get();
}
ZoneSystem *getZoneSystem() const
{
return m_zoneSystem.get();
@@ -492,6 +506,7 @@ private:
std::unique_ptr<NpcMotionSystem> m_npcMotionSystem;
std::unique_ptr<NpcDetailSystem> m_npcDetailSystem;
std::unique_ptr<NpcTaxiSystem> m_npcTaxiSystem;
std::unique_ptr<NpcTrafficSystem> m_npcTrafficSystem;
std::unique_ptr<NpcAvoidanceSystem> m_npcAvoidanceSystem;
std::unique_ptr<GameClockSystem> m_gameClockSystem;
std::unique_ptr<TerrainPrefabSpawnerSystem> m_terrainPrefabSpawnerSystem;
@@ -520,6 +535,9 @@ private:
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
/* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */
std::unique_ptr<VehicleSystem> m_vehicleSystem;
/* AI-driven vehicles (demo-taxi phase 5.1): writes the same
* VehicleComponent input fields the player controller writes. */
std::unique_ptr<AiDriverSystem> m_aiDriverSystem;
std::unique_ptr<ZoneSystem> m_zoneSystem;
std::unique_ptr<GameHudSystem> m_gameHudSystem;
/* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */
@@ -572,6 +590,35 @@ private:
* the wall-clock delta (headless test determinism). */
float m_fixedDeltaTime = 0.0f;
/* EDITSCENE_PROFILE=1 accumulation: step name -> (total ms,
* sample count) over the last 600 frames (see
* frameRenderingQueued). */
std::map<std::string, std::pair<double, long> > m_profileAccum;
long m_profileFrames = 0;
/* Per-frame time split, recorded unconditionally (a few timer
* reads per frame): frameStarted -> frameRenderingQueued entry is
* the render pass (scene graph update, cull, draw submission);
* the frameRenderingQueued body is the CPU systems; its end ->
* frameEnded is the swap/vsync wait (where a GPU-bound frame
* hides). The EDITSCENE_PROFILE dump prints 600-frame averages
* including these spans plus OGRE render stats; benchmarks read
* the per-frame getters. */
Ogre::Timer m_profTimer;
uint64_t m_profFrameStartUs = 0;
uint64_t m_profQueuedEndUs = 0;
float m_lastRenderPassMs = 0.0f;
float m_lastSystemsMs = 0.0f;
float m_lastSwapWaitMs = 0.0f;
double m_profRenderAccum = 0.0;
double m_profSwapAccum = 0.0;
double m_profTrisAccum = 0.0;
double m_profBatchesAccum = 0.0;
float getLastRenderPassMs() const { return m_lastRenderPassMs; }
float getLastSystemsMs() const { return m_lastSystemsMs; }
float getLastSwapWaitMs() const { return m_lastSwapWaitMs; }
void processPendingSceneSwitch();
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
@@ -58,6 +58,17 @@ struct WaterPlane {
// Which viewport to update this frame (0=reflection, 1=refraction)
int updateViewportIndex = 0;
// RTT throttling: one reflection/refraction viewport update every
// rttUpdateInterval frames (alternating), skipped entirely while the
// water surface is outside the camera frustum. Each RTT update is
// a full scene re-render and dominates the water CPU cost.
int rttUpdateInterval = 4;
int rttFrameCounter = 0;
// Cached shader parameter targets (resolved on rebuild)
Ogre::MaterialPtr cachedMaterial;
Ogre::Pass *cachedPass = nullptr;
// Time accumulator for shader
float shaderTime = 0.0f;
@@ -67,6 +67,15 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
COPYONLY)
# Traffic vehicle prefab (phase 5.4): demo-owned, generated by
# extract_sedan_prefab.py from the vehicletest scene's sedan1 subtree and
# versioned with the demo. configure_file COPYONLY like the heightmap:
# regenerating the prefab re-copies it into the staging directory on the
# next build without manual intervention.
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/prefabs/sedan-traffic.json"
"${CMAKE_CURRENT_BINARY_DIR}/prefabs/sedan-traffic.json"
COPYONLY)
# Road region files (heightmaps/<terrainId>/roads/<px>_<py>.json). The
# source tree is authoritative — edit the road network while running from
# the source directory; every file registered here is re-copied into the
+406 -35
View File
@@ -301,7 +301,10 @@ 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.
real LOD material never switches. (Superseded in phase 5.5: the
listener now swaps the single merged VehicleIdMap material
`<base>` <-> `<base>-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
@@ -324,7 +327,10 @@ animation (authored so the base position is the seat point; no
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. One engine fix this exposed:
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:
`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
@@ -2227,7 +2233,14 @@ Read-only references:
--test-taxi` (assigns a goal, teleports the sedan to the pickup curb,
asserts boarding + riding with the seat owned and physics off,
teleports to the destination curb, asserts alighting, seat release
and schedule resume).
and schedule resume). Gotcha: the sedan must NOT be dropped right on
the curb POI — that point sits on the lot in front of the building
door, so the waiting NPC ends up inside the car's collider, pushes
the rolling sedan and chases the moving door point forever. The test
parks the car towards the carriageway instead (nearest road edge via
`RoadPath::nearestEdgeInfo`, clamped to 4.5 m from the POI to stay
inside the boarding radius, aligned with the edge direction) and sets
`inputHandBrake = 1` so NPC bumps cannot roll it.
Original text follows.
Mission NPCs near the player get the
@@ -2458,29 +2471,166 @@ population spawns in the exterior scene.
pocket.
### Phase 5 — NPC vehicle traffic
- the NPC owns the vehicle, the vehicles are registered to the NPC as driver the same way as NPCs are added in town. Other group member can get to the vehicle as passengers if they go to the same location, or the driver can drive them to their different destinations.
- the NPC can choose by distance if vehicle is used or going by feet (phase 8).
- the vehicle is used when NPC has one parked near their current location.
- NPC position is simulated in varying levels of detail
- camera is far away and not visible =
- AI driver system (new, engine-level): a vehicle follows the road
graph (route = path over edges), respects per-edge speed limits,
basic collision avoidance (raycast/whisker braking for the vehicle
ahead and for the player).
- Intersection traffic controls: priority rules on intersection nodes
(the visual intersections from Phase 2 get behaviour), AI drivers
slow/stop/yield accordingly.
- NPC driver characters seated in the vehicles (visual occupancy;
reuse the seat-pinning approach of `VehicleControllerSystem`).
- Traffic vehicles drawn from the Phase 1 sedan pool (type + color
variants).
- Traffic density control: spawn/despawn traffic vehicles by distance
to the camera (spawner pattern), cap active count.
- Headless: two AI cars on a generated loop complete N laps without
collision/assert; intersection yield check; despawn/respawn
stability.
### Phase 5.5 — Interim performance pass (playable while building)
Broken into sub-phases (the original bullet list kept as the spec under
each item):
- **5.1 AI driver core (engine-level). COMPLETE (2026-10-14).**
`AiDriverComponent` (route polyline + loop/cruiseSpeed + runtime
routeIndex/arrived/lapsCompleted) + `AiDriverSystem` (EditorApp
singleton, game mode only, runs right before
`VehicleSystem::prePhysicsUpdate`): pure-pursuit steering
(speed-scaled lookahead) plus a proportional throttle/brake
controller with curvature slowdown, writing the same
`VehicleComponent::inputForward/inputRight/inputBrake/inputHandBrake`
fields the player controller writes — VehicleSystem stays the single
consumer, and a car carrying the player
(PlayerControlledComponent) is never touched (carjacking a traffic
car just works). Non-looping routes brake to a stop at the last
waypoint and park (handbrake on); looping routes wrap and count laps.
Headless `--test-traffic-loop`: the vehicletest scene gained sedan2
(full sedan1 copy); both sedans are teleported onto opposite corners
of an 80x50 m rectangle loop and must complete 3 laps each — fails on
contact (< 1 m), NaN or a car leaving the inflated loop bounds.
Per-edge speed limits fold in with the town routes (5.4).
- **5.2 Whisker braking. COMPLETE (2026-10-14).** Forward raycasts
(staggered) brake the AI car for the vehicle ahead, the player's car
and pedestrians; stop-and-go gap keeping.
`JoltPhysicsWrapper::raycastQueryTraffic()` (physics/physics.h/.cpp)
is `raycastQueryWalkable`'s AllHit CastRayCollector + per-hit surface
normal via BodyLockRead (skips `normal.y >= walkableNormalY` road and
ground) minus the character-body skip (pedestrians must brake cars),
with a new `SkipOneBodyFilter` excluding the car's own chassis.
AiDriverSystem casts a 3-ray fan (nose, +/-18 deg) at bumper height
0.55 m starting 2.8 m ahead of the origin, every 3 frames staggered
by entity id; the fan length is `speed^2/(2*4.0) + followGap + 4`.
A hit clamps the speed target to `(obstacleDistance - followGap) * 0.8`
(followGap 4.5 m — glide-to-stop), and at standstill the brake is
held while the path stays blocked. The query additionally reads
`RigidBodyComponent` for the chassis bodyID. Headless
`--test-traffic-follow`: sedan1 is parked broadside at x=20 with the
handbrake, sedan2 drives x=-60 -> 60 at 12 m/s and must stop before
the blocker (the stop must land in -10 < x < 15 — without the
near-blocker window the test passes vacuously on a stationary car),
hold it for 120 frames, then resume and arrive after the blocker is
teleported away. Result: stopped at x=11.71 (min gap 8.29 m =
bumper offset 2.8 + followGap 4.5 + ~1.0 blocker half-width), held,
resumed, arrived.
- **5.3 Intersection priority rules. COMPLETE (2026-10-14).** Nodes
with 3+ neighbors get behavior: AI drivers slow/stop/yield (the
phase-2 visual intersections and stop lines get meaning).
`AiRoutePoint` (position + junctionNodeId + junctionCenter) replaced
the plain Vector3 route point; `AiDriverSystem::classifyJunctions()`
flags route points within `junctionSnapDist` (6 m) of a degree >= 3
road node (auto-runs once per route against the terrain road graph;
pre-flagged points are kept). A car latches the next junction point
ahead, slows to `junctionSpeed` (7 m/s) inside `junctionApproachDist`
(30 m) 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 brake profile to a stop line
`junctionStopDist` (9 m) before the center. Clear = no other AI car
holds the reservation and no vehicle (AI or player) has its chassis
within `junctionRadius` (7.5 m) of the center — yielders stopped at
the stop line are not occupants. The reservation releases once the
center is behind the car and `junctionReleaseDist` (14 m) away, with
an 8 s hold timeout as the stale-reservation breaker; despawned or
player-taken cars lose their drive state (and reservation) in a
per-frame purge. Two gotchas the headless test exposed: the
reservation must be taken at the approach-zone edge, not the stop
line (two cars arriving simultaneously from 30 m out both fit inside
the stop-line window before either could brake — the loser needs the
full zone to stop in), and the release must require the center BEHIND
the car (a plain distance check drops the reservation on the approach
leg, which starts farther out than the release distance). Headless
`--test-traffic-yield` (flat vehicletest scene, hand-flagged fake
junction node — the priority logic is graph-independent): sedan1
west-east and sedan2 south-north through a shared junction at the
origin, both from 60 m at 12 m/s; the winner crosses, the loser
brakes to the 9 m stop line, holds, then crosses once the junction
clears. The AI is attached 3 frames AFTER the teleport: attaching on
the teleport frame let the waypoint cursor advance from the stale
pre-teleport position and skip the junction waypoint. PASS: loser
stopped at 8.87 m, min gap 8.88 m, both arrived.
- **5.4 Fleet, ownership & density. COMPLETE (2026-10-14).** Vehicles
registered to population NPCs as drivers (same registry path as
NPCs); a parked fleet placed at Parking locations; household members
can ride as passengers; the walk-vs-drive distance choice stays
deferred to phase 6 as the spec says. Distance-based spawn/despawn
of traffic vehicles (spawner pattern mirroring NpcDetailSystem's
hysteresis) with an active-count cap. NPC driver characters seated
via the existing seat-pinning (VehicleControllerSystem approach).
Traffic vehicles drawn from the Phase 1 sedan pool (type + color
variants).
As built: `NpcTrafficSystem` (EditorApp singleton, game mode only,
update after NpcAvoidanceSystem, postPhysicsUpdate right after
NpcTaxiSystem's) owns the fleet. When an area's population +
location set exist, each Parking location rolls a deterministic hash
(fnv1a of the location name, the TownLocations per-lot discipline)
against `carOwnershipRatio` (0.5) for one car, capped at
`maxCarsPerArea` (6); areas WITHOUT parking rects (the common case —
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` matches the parking spot's
address (fallback: the area's first adult). Parked cars sit nose
along the nearest road edge (`RoadPath::nearestEdgeInfo`,
deterministic direction bit) with the handbrake on and no driver.
A deterministic `cruisingRatio` (0.5) subset drives an endless loop
home -> deterministic other Parking/Curb -> home, built from
`RoadPath::findPath` + the new `RoadPath::offsetToRightLane`
(rightmost-lane offset: carriageway half width minus half a lane) and
junction-classified via `AiDriverSystem::classifyJunctions`.
Cruising drivers are suspended from the schedule layer 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). The TrafficCar record (route + progress in meters) is the
ground truth and survives spawn/despawn and scene switches; the
high-detail tier (camera within 150 m, hysteresis 220 m, dwell 3 s,
spawn budget 1/frame, cap `maxActiveVehicles` 8) instantiates the new
`prefabs/sedan-traffic.json` (extracted from the vehicletest scene's
sedan1 by `extract_sedan_prefab.py`, configure_file-staged like the
heightmap), attaches the junction-classified route as an
AiDriverComponent with `routeIndex` from the symbolic progress, and
seats the driver through the same manual-spawner + seat-pinning
approach as NpcTaxiSystem. On despawn the live 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 (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 `--test-traffic-town` (exterior scene): waits for
the fleet (6 cars seeded on the demo town, population 33), teleports
the player next to the first cruiser (the spawn gate opens), the car
drives (84 m in 15 s), a far teleport despawns it while the symbolic
progress keeps advancing (153 -> 208 m), and teleporting back
respawns it. PASS. The pre-existing probes were adjusted for the
fleet: `--test-taxi`, `--test-taxi-lowdetail`, `--test-motion` and
`--test-detail` skip fleet drivers when picking their test NPCs
(drivers refuse taxi goals and their symbolic position follows the
car, not the schedule).
Post-verification fixes (2026-10-14): (a) `RoadPath::offsetToRightLane`
had the lateral sign flipped — `UNIT_Y.crossProduct(dir)` is the LEFT
side in OGRE's right-handed +Y-up world (right = forward x up), so
cruisers drove on the left lane; (b) route endpoints stayed on the
off-road curb POI, so a cruiser left the carriageway at both ends of
every leg and whisker-stalled against the car parked there — the
endpoints are now snapped onto the nearest carriageway point before
the lane offset; (c) fleet drivers T-posed: the manual driver spawner
added no AnimationTreeComponent (NpcDetailSystem adds one for its
inline spawns) and the seatDriver setState ran on the spawn frame,
before AnimationTreeSystem had per-entity state — the tree is now
added at seat time and postPhysicsUpdate re-asserts the "driving"
state every frame (a no-op once latched). Drivers never alighting
is BY DESIGN: cruisers loop endlessly until phase 6's walk-vs-drive
choice.
- Headless (full phase): two AI cars on a generated town loop complete
N laps without collision/assert; intersection yield check;
despawn/respawn stability.
### Phase 5.5 — Interim performance pass (playable while building) — COMPLETE 2026-10-15 (60 FPS met; worst view 84 vs 80 batches, remainder itemized for 11.5)
The full optimization/batching effort stays in Phase 11.5 + the Phase 12
performance pass; this phase only makes the current scene (generated
@@ -2514,8 +2664,175 @@ Method:
- Done when: `--benchmark` reports >= 60 FPS and <= 80 batches on the
reference view; the before/after table lives in this file.
#### Phase 5.5 results (2026-10-15, headless xvfb/llvmpipe, `--benchmark`)
Tooling added (kept): `EDITSCENE_PROFILE=1` env dumps per-step ms/frame
averages from `EditorApp::frameRenderingQueued` every 600 frames;
`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) reporting
wall-clock ms, OGRE batches/tris, frustum-visible entity groups and
per-material render-queue batch counts (incl. BaseWhite offenders).
Numbers below are headless wall clock (llvmpipe-influenced — relative
only); batch counts are the hardware-independent metric.
| view | batches before | batches after | avgMs before | avgMs after |
|------|---------------|---------------|--------------|-------------|
| empty-horizon | 5 | 5 | ~4.4 | ~6.8 |
| street-yaw0 (worst) | 95 | 84 | ~11-12 | ~13-14 |
| street-yaw90 | 17 | 17 | ~10 | ~10.5 |
| street-yaw180 | 18 | 18 | ~11 | ~11 |
| street-yaw270 | 19 | 19 | ~13.5 | ~13.5 |
| overview | 19 | 19 | ~11.6 | ~11.6 |
All views stay well under 16.7 ms even in software rendering, so the
60 FPS half of the target is met with margin on any real GPU. The
worst view lands at 84 batches vs the 80 target — the remaining 4 are
itemized below and need the structural (Phase 11.5) fixes; squeezing
them now would mean doing that work early.
What each fix bought:
- **Water RTT throttle + frustum skip + cached shader params**
(`EditorWaterPlaneSystem`, `WaterPlane::rttUpdateInterval = 4`):
reflection/refraction RTT viewports now update on alternating frames
at 1/4 rate and are skipped entirely while the water surface is
outside the camera frustum; the shader material/pass are cached
instead of looked up per frame. Water cost 5.1 -> ~1.2 ms/frame.
- **LodSystem cached query**: the two per-frame fallback scans reuse a
cached flecs query (no measurable change, removed allocation churn).
- **Merged vehicle ID-map material** (the big one): all sedan parts
(body, doors, hood, trunk, wheels, steering) export with exactly ONE
submesh on ONE material (`sedan-v2` / `sedan-v2-taxi`) instead of
three RTSS materials (color/color-bloom/glass). The new
`VehicleIdMapVP/FP` shader pair (`resources/vehicles/
vehicle_idmap.vert/.frag/.program`, GLES2-compatible, built on
`OgreUnifiedShader.h`) samples the color atlas AND a baked
per-texel ID map (red = paint, green = bloom/lights, blue = glass)
at the same UVs and blends the three fragment behaviors by the ID
weights; `vehicleColor` is a shader param hook for the future
registry-driven per-car color. Two material variants swap by camera
distance as before (`<base>` transparent near / `<base>-opaque` far,
10 m). `gen_vehicle.py` bakes the ID map from the palette-degenerate
car UVs (every face collapses onto one atlas texel; a texel shared by
two materials is a build WARNING — fix the blend), sorts glass faces
last per mesh (transparent material keeps depth_write on, so glass
must draw after the cabin interior), consolidates the mesh datas onto
the single material and writes the final `.material` itself.
Batch effect: ~15.5 -> 11 batches per car (22 total for the two
parked sedans, was ~30); street-yaw0 95 -> 83.
Follow-up fix (2026-10-16): the atlas texture unit must NOT be
mipmapped — it is a palette (every face UV-collapses onto one texel),
so auto-mipmaps average neighboring palette colors under minification
and distant cars bled wrong colors (saturated-blue headlights/hubs).
The generated `.material` now uses `filtering linear linear none` on
the atlas (the ID map already used `point point none`); far-distance
colors verified stable via screenshots. The tiny blue dots on rocker
panels/hubs visible up close are genuine palette texels of the model,
not a shader artifact (identical on both sedans).
- **w1 exterior shell material**: the demo's own CellGrid building
(entity `w1`, exteriorOnly) fell back to OGRE's BaseWhite because the
CellGridSystem default `Ogre/StandardFloor` does not exist in the
project resources; it now carries its own
ProceduralMaterial/ProceduralTexture (same placeholder look as the
interior). Batch-neutral, but the parts are now properly
materialized (and the material resolves on the frame after creation —
the startup "Can't assign material" line is transient). This exposed
a real engine bug: the texture-ready grid rebuild right after scene
load recreated w1's scene-switch door and silently ate an in-flight
toggle/scene-switch request (`--test-switch` return leg timed out).
`CellGridSystem` now stashes in-flight door swing state
(`isOpen`/`currentAngle`/`toggleRequested`/`sceneSwitchPending`,
keyed by `doorId`) before destroying door entities on rebuild and
re-applies it one-shot to the recreated doors.
Remaining worst-view (street-yaw0 = 84) batch budget, itemized — all
deferred to Phase 11.5 unless noted:
- TownGen building shells: 15 batches sharing ONE area material ->
static per-page/per-area merge (biggest single item).
- Two parked sedans: 22 (11 each: body, 4 doors, hood, trunk, 4
wheels, steering) — inherent while doors/wheels/steering articulate;
distant cars should swap to auto-generated substitute LOD meshes
(decimated original geometry on a single texture atlas — pipeline
work, see 11.5).
- `male_Face.mesh`: 8 submeshes, ALL material `male_face-atlas` -> 7
batches for one NPC face. Submeshes are Fcl_* pose-track driven, so
merging needs pose-track vertex-index remapping in the character
export pipeline (or the same substitute-LOD answer as cars). With
several NPCs on screen this dominates: ~10 batches per NPC.
- CellGrid shell of building 491: ~7 batches (per-floor
extWall/extWindows + extDoors + door leaf + scene-switch door
occluder quads) — merge exterior shell pieces per building at 11.5;
the occluder quads are intentional (always visible behind closed
scene-switch doors).
- Road chunks + markings: ~8 batches per page in view -> per-page
merge at 11.5.
- Terrain ~5 visible tiles x 1 batch, skybox 1, water 1: legitimate.
Known cosmetic issue: sedan-v2-taxi.blend shares two palette texels
(~(0.84, 0.65)) between its color and color-bloom materials (build
WARNING) — harmless today (bloom shades identically to paint and
`bloomEmissive` is 0), but give bloom its own atlas texel when the
taxi's lights should look distinct.
#### Phase 5.5 follow-up (2026-10-16, from the first on-hardware playtest)
- **Vehicle semi-transparency bug (fixed).** In-game the cars were
semi-transparent up close (interior visible through closed doors);
in framebuffer-dump screenshots the affected pixels show as the
classic alpha checkerboard. Root cause: `vehicle_idmap.program`
relied on OGRE's sampler auto-assignment, which follows driver
uniform-enumeration order — `uIdMap` ended up bound to texture unit 0
(the ATLAS), so each paint texel's blue channel leaked into
`wGlass`/`glassAlpha` (dark paint -> alpha ~0.8, beige mostly
opaque). Fix: explicit `param_named uAtlas int 0` / `param_named
uIdMap int 1` in the fragment program's default_params. Verified via
framebuffer alpha probes: doors/roof/wheels now 255, only the window
glass reduced (by design, near variant). This is driver-dependent —
the pre-fix verification screenshots on the same machine were clean
because enumeration order happened to be right before.
- **In-town FPS (worked on, structural remainder at 11.5).** Playtest
measured ~200 batches / 20 FPS in town and ~120 batches / 30 FPS with
only buildings visible, while the 5.5 benchmark's fixed views peaked
at 84. Profiling (`EDITSCENE_PROFILE=1`) shows the CPU systems are
NOT the problem: steady-state ~2.4 ms/frame total (water 1.2,
physics 1.1, everything else below 0.5) — the cost is render-side
(batches/fill), plus terrain/TownGen streaming spikes (~13 ms during
page loads; synchronous by design) which explain momentary drops.
The fixed benchmark views miss the crowd content: real driving views
add up to ~10 batches per NPC (7 just for the 8-submesh face) and 11
per traffic car on top of ~90-100 for terrain (25-49 pages to the
horizon) + roads (7 chunks + 3 markings per page) + town + the two
parked sedans.
Applied (demo-side tuning in demo_main.cpp, engine defaults
unchanged): NPC detail tier 80/120 m -> 55/85 m, traffic cars
150/220 m -> 110/165 m — expected to bring the busy-view worst case
from ~200 to ~140 batches. The benchmark's fixed views are unchanged
(still 84; they never contained a crowd). Remaining structural items
for 11.5, unchanged: TownGen per-page static merge, CellGrid
per-building shell merge, road chunk/markings merge, face submesh
merge (10.5), substitute LOD meshes, terrain far-page strategy.
### Phase 5.9 — town improvements before foot traffic
- In building DSL we need to identify house types, as a list of tag keywords.
- We need to implement template layouts which can be used instead of default layout
- Town area should be able to select preferred types when one is not declared explicitly from templates, using type tag keywords.
- Should be able to group town areas into single town/city and have multiple towns each potentially having multiple areas. That is needed for flexibility like having industrial, residential, commercial part and town center, for example, with templatelayouts and some unique buildings.
### Phase 6 — Foot traffic NPCs
- Character asset system changes:
- Need a system to use separate body parts for truly nude top and bottom keeping existing ones for body part composition with clothes (with fallback to current nude body parts).
- Need to implement LOD body parts to reduce number of batches. First candidate is face.
- Need to implement texture atlas generation for 20m-distant NPCs.
- There is an idea to use Ogre's manual LODs as the following:
- at default LOD0 distance is < 10m, body is modular, highly detailed, materials a bit optimized, but not at cost of quality.
- LOD1, distance >= 10m and < 20m body is modular and highly detailed, 1 material per body part (the same style as with cars, material is the same for all body parts), uses texture atlas of 2048x2048 with separate textures, the face is replaced by face mask which has single material from the same atlas, shape keys removed.
- LOD2, distance >= 20m and < 60m, like with LOD1 but combinations of possible body parts and shapes (thin/fat) are generated and used. No shape keys. For some combinations it is easier to show nothing at this LOD or show something closer/generic.
- LOD3. distance >= 60m and < 80m, Like LOD2, but from combined meshes, decimated ones are generated with recommended triangles limit of 400 and hard limit of 400, more close/generic ones at this stage.
- Because of lower NPC counts in interior environments it is enough to have LOD0 and LOD1 in them, but it should be benchmarked and checked.
- character prefabs should have "character." prefix in file names to not mix with other prefabs. It should be supported with prefab save/load in editor to easily add prefix.
- Pedestrian system: spawners at houses/POIs, pedestrians walk navmesh
routes along sidewalks between points of interest, avoid standing on
the road, despawn by distance.
@@ -2525,15 +2842,43 @@ Method:
Phase 5 AI driver); the reverse flow parks and walks home. (Real
walk-through-interior exits arrive with Phase 8; until then "leaving
a house" starts at its door POI.)
- **Schedule-adherent drivers** (replaces 5.4's placeholder cruising):
a driving NPC must follow its NpcScheduleSystem schedule, not loop
endlessly — when the schedule says move, the NPC walks to its parked
car (or spawns into it if low-detail), drives to the destination's
parking/curb, parks (handbrake, engine off), alights and spends the
scheduled dwell time there (inside the building once Phase 8
interiors work), then departs on the next schedule leg. The
walk-vs-drive distance choice deferred from 5.4 lands here: short
legs walk, long legs drive, and the choice must respect the time
budget the schedule leaves for the leg (a walking NPC that would be
late drives instead). Cruising with no schedule leg to serve becomes
rare ambient traffic at most, not the default.
- Uses existing `NavMeshSystem` + `PathFollowingSystem` +
`CharacterSpawnerSystem`; the new work is orchestration data
(sidewalk waypoint extraction from the road graph) plus the flow
scheduler.
- Headless: pedestrians spawn, reach waypoints, never path through
building interiors; house -> car hand-off completes; counts stay
within caps while the camera moves.
within caps while the camera moves. A scripted driver completes a
scheduled trip (depart -> drive -> park -> alight -> dwell ->
schedule resumes on the next leg) and the F7 overlay shows the trip
state.
### Phase 7 — CellGrid stair generation
### Phase 6.5 CellGrid furniture placement - before interiors
- the same as with node version, building layout DSL can define room type and tags/keywords for the furniture
- should support 4 layers of furniture:
- thin floor objects not blocking path/no collider (carpets, floor lights, etc.)
- thin wall objects not blocking path (light switches, wall jacks/outlets, picture frames, etc.)
- normal wall and multi-cell objects (multi-cell is new), having colliders
- ceiling objects (lights, panels, etc.)
- each furniture is defined either by json (as it is now) or prefab (new).
- some room layouts need special furniture placement, that should be supported via Lua events.
- NPC AI POIs/actuators are either a part of prefab/json declaration (prefab is supported more)
- furniture prefabs should have names starting with "furniture." and work together with furniture json and support the same metadata as furniture json. Probably just be able to create furniture json for the prefab easily. Prefix should be supported in editor's prefab save/load for easy naming.
### Phase 7 — CellGrid stair generation - before interiors
Multi-storey CellGrid buildings (`height` > 1) generate stacked floors
with no connection between them — interiors above ground level are
@@ -2550,6 +2895,9 @@ taxi-specific):
- Navmesh: stairs rasterize as walkable (Recast step/slope limits), so
NPCs can path between floors; verify doorway-area costs and door
obstacles still behave.
- should support stairwells and explicit floor connections between rooms where a room has stair in it which connects to above location. Like single-floor stairwell. Floors and ceilings are removed for the stair to pass. The space between floors should be hidden by geometry to avoid gaps.
- should support exterior stairs outside walls (like in many motels)
- should support procedural material the same as walls/floors, using squares for walls and floors by default, depending on surface.
- Editor + serialization + Lua binding, per the component rules
(serialized flag, Cell Grid editor toggle, `cellgrid_door_test`-style
headless coverage).
@@ -2653,6 +3001,17 @@ demo needs them:
- Generation preview in the editor (ghost overlay before committing).
- More vehicle types beyond sedans (the Phase 1 pipeline is already
generic): vans, trucks, buses; service vehicles.
- **Registry-driven vehicle colors.** The shader side landed in 5.5:
the merged `VehicleIdMap` material multiplies the paint term by a
per-material `vehicleColor` param (default white = atlas color
unchanged). Remaining work: a per-car color stored in the registry
record where the car is registered to its NPC owner (the
NpcTrafficSystem fleet seeding), assigned once at registration and
permanent for that car (only later game mechanics — repaint shop,
damage — may change it), pushed into the car entities' materials at
spawn (the shared material must be cloned per color or the param set
per subentity). The same mechanism then serves the taxi livery and
player-car customization.
### Phase 11.5 — Geometry polish, marking depth & render batching
@@ -2678,14 +3037,26 @@ must land before the Phase 12 performance/polish pass.
small custom-shader offset pulling `gl_Position` toward the camera),
so the paint stays visually coplanar but always wins the depth test.
Verify on slopes, at long distance and against the decal material.
- **Render batching.** The bare exterior scene already draws ~85
batches; a full settlement will explode. First measure what is hot
(road page chunks, marking/decal overlays, terrain, spawner
prefabs), then batch: merge same-material road/overlay chunks into
fewer meshes per page, instance repeated props (lamps, signs,
trees), static-geometry batching for placed buildings, and material/
texture atlasing where it cuts state switches. Define the target
batch budget after measuring; the Phase 12 FPS check enforces it.
- **Render batching.** Measured per-material on the generated demo
town (Phase 5.5 `--benchmark`, worst street-level view = 84 batches):
TownGen building shells 15 batches on ONE shared area material
(static merge candidate), two parked sedans 22 (11 parts each —
inherent while doors/wheels/steering articulate), one NPC ~10
(`male_Face.mesh` alone is 8 submeshes all on `male_face-atlas` = 7
batches — Fcl_* pose-track driven, so merging needs pose-track
index remapping in the character export), the 2-storey CellGrid
shell ~7, road chunks + markings ~8 per page, terrain ~5 tiles,
skybox/water 2. Batch plan: merge same-material TownGen shells per
page/area, merge CellGrid exterior shell pieces per building, merge
same-material road/overlay chunks into fewer meshes per page,
instance repeated props (lamps, signs, trees), and merge the face
submeshes at export. **Substitute distant LODs**: auto-generate
decimated LOD meshes from the original geometry (cars AND character
faces/body parts) on a single texture atlas as a pipeline step, so
distant cars/NPCs render as one batch each; the merged VehicleIdMap
material (5.5) already cut cars from ~15.5 to 11 batches. Target:
<= 80 batches in the worst street-level gameplay view (5.5 missed it
by 4); the Phase 12 FPS check enforces it.
### Phase 12 — Demo assembly, polish, docs
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -682,12 +682,404 @@
"antiRollBarStiffness": 2000.0,
"centerOfMassOffset": {
"x": 0.0,
"y": -0.45,
"y": -0.4617,
"z": 0.0
},
"downforceCoefficient": 35.0
},
"id": 500
},
{
"children": [
{
"children": [],
"name": {
"name": "sedan2_door_rl"
},
"renderable": {
"meshName": "sedan-v2-door-rl.mesh",
"visible": true
},
"transform": {
"position": {
"x": 0.9192,
"y": -0.2869,
"z": -0.132
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"actuator": {
"actionNames": [
"vehicle_seat_rl"
],
"height": 2.0,
"radius": 1.2
}
},
{
"children": [],
"name": {
"name": "sedan2_door_rr"
},
"renderable": {
"meshName": "sedan-v2-door-rr.mesh",
"visible": true
},
"transform": {
"position": {
"x": -0.9192,
"y": -0.2869,
"z": -0.132
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"actuator": {
"actionNames": [
"vehicle_seat_rr"
],
"height": 2.0,
"radius": 1.2
}
},
{
"children": [],
"name": {
"name": "sedan2_door_fl"
},
"renderable": {
"meshName": "sedan-v2-door-fl.mesh",
"visible": true
},
"transform": {
"position": {
"x": 0.9192,
"y": -0.2868,
"z": 0.801
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"actuator": {
"actionNames": [
"vehicle_seat_driver"
],
"height": 2.0,
"radius": 1.2
}
},
{
"children": [],
"name": {
"name": "sedan2_door_fr"
},
"renderable": {
"meshName": "sedan-v2-door-fr.mesh",
"visible": true
},
"transform": {
"position": {
"x": -0.9192,
"y": -0.2868,
"z": 0.801
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"actuator": {
"actionNames": [
"vehicle_seat_passenger"
],
"height": 2.0,
"radius": 1.2
}
},
{
"children": [],
"name": {
"name": "sedan2_hood"
},
"renderable": {
"meshName": "sedan-v2-hood.mesh",
"visible": true
},
"transform": {
"position": {
"x": -0.0,
"y": 0.266,
"z": 0.9992
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"name": {
"name": "sedan2_trunk"
},
"renderable": {
"meshName": "sedan-v2-trunk.mesh",
"visible": true
},
"transform": {
"position": {
"x": 0.0,
"y": 0.2744,
"z": -1.446
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"collider": {
"halfHeight": 1.0,
"meshName": "",
"offset": {
"x": 0.0,
"y": 0.1535,
"z": -0.0629
},
"parameters": {
"x": 1.0107,
"y": 0.6126,
"z": 2.4834
},
"radius": 0.5,
"rotationOffset": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"shapeType": "box"
},
"name": {
"name": "sedan2"
},
"renderable": {
"meshName": "sedan-v2.mesh",
"visible": true
},
"rigidBody": {
"bodyType": "dynamic",
"enabled": true,
"friction": 0.8,
"isSensor": false,
"mass": 1200.0,
"restitution": 0.0
},
"transform": {
"position": {
"x": 40.0,
"y": 1.5,
"z": 25.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"vehicle": {
"cameraDistance": 6.0,
"cameraHeight": 1.6,
"engineBrake": 0.0,
"maxPitchRollAngleDeg": 60.0,
"maxTorque": 300.0,
"rearSteer": false,
"seatOffset": {
"x": 0.48,
"y": -0.28,
"z": 0.2012
},
"steeringRatio": 4.0,
"steeringWheelMeshName": "sedan-v2-steering-wheel.mesh",
"steeringWheelOffset": {
"x": 0.4915,
"y": 0.0807,
"z": 0.601
},
"steeringWheelTiltDeg": 25.0,
"wheelMeshName": "sedan-v2-wheel.mesh",
"wheels": [
{
"driven": false,
"maxHandBrakeTorque": 0.0,
"maxSteerAngleDeg": 35.0,
"position": {
"x": 0.7805,
"y": -0.2052,
"z": 1.4806
},
"radius": 0.3918,
"suspensionDamping": 1.0,
"suspensionFrequency": 1.3,
"suspensionMaxLength": 0.35,
"suspensionMinLength": 0.1,
"width": 0.3145
},
{
"driven": false,
"maxHandBrakeTorque": 0.0,
"maxSteerAngleDeg": 35.0,
"position": {
"x": -0.7805,
"y": -0.2052,
"z": 1.4806
},
"radius": 0.3918,
"suspensionDamping": 1.0,
"suspensionFrequency": 1.3,
"suspensionMaxLength": 0.35,
"suspensionMinLength": 0.1,
"width": 0.3145
},
{
"driven": true,
"maxHandBrakeTorque": 300.0,
"maxSteerAngleDeg": 0.0,
"position": {
"x": 0.7805,
"y": -0.2052,
"z": -1.2806
},
"radius": 0.3918,
"suspensionDamping": 1.0,
"suspensionFrequency": 1.3,
"suspensionMaxLength": 0.35,
"suspensionMinLength": 0.1,
"width": 0.3145
},
{
"driven": true,
"maxHandBrakeTorque": 300.0,
"maxSteerAngleDeg": 0.0,
"position": {
"x": -0.7805,
"y": -0.2052,
"z": -1.2806
},
"radius": 0.3918,
"suspensionDamping": 1.0,
"suspensionFrequency": 1.3,
"suspensionMaxLength": 0.35,
"suspensionMinLength": 0.1,
"width": 0.3145
}
],
"seats": [
{
"name": "driver",
"offset": {
"x": 0.48,
"y": -0.28,
"z": 0.2012
},
"driver": true
},
{
"name": "passenger",
"offset": {
"x": -0.48,
"y": -0.28,
"z": 0.2012
},
"driver": false
},
{
"name": "rl",
"offset": {
"x": 0.48,
"y": -0.28,
"z": -0.6032
},
"driver": false
},
{
"name": "rr",
"offset": {
"x": -0.48,
"y": -0.28,
"z": -0.6032
},
"driver": false
}
],
"seatCharacterScale": 1.0,
"tireFrictionLongitudinal": 1.3,
"tireFrictionLateral": 3.0,
"antiRollBarStiffness": 2000.0,
"centerOfMassOffset": {
"x": 0.0,
"y": -0.45,
"z": 0.0
},
"downforceCoefficient": 35.0
},
"id": 4294967795
}
],
"version": "1.0"
@@ -196,6 +196,29 @@ private:
const std::set<JPH::BodyID> &m_characters;
};
/* Body filter skipping a single body (the querying vehicle's own
* chassis for the traffic whisker). */
class SkipOneBodyFilter : public JPH::BodyFilter {
public:
explicit SkipOneBodyFilter(JPH::BodyID inSkip)
: m_skip(inSkip)
{
}
virtual bool ShouldCollide(const JPH::BodyID &inBodyID) const override
{
return inBodyID != m_skip;
}
virtual bool ShouldCollideLocked(const JPH::Body &) const override
{
return true;
}
private:
JPH::BodyID m_skip;
};
/// Class that determines if an object layer can collide with a broadphase layer
class ObjectVsBroadPhaseLayerFilterImpl
: public JPH::ObjectVsBroadPhaseLayerFilter {
@@ -2120,6 +2143,47 @@ public:
}
return false;
}
/* Traffic whisker (demo-taxi phase 5.2): like
* raycastQueryWalkable (walkable-normal filtering), but character
* bodies are NOT skipped — a pedestrian ahead must brake a car —
* and the querying vehicle's own chassis is skipped instead. */
bool raycastQueryTraffic(Ogre::Vector3 startPoint,
Ogre::Vector3 endPoint, Ogre::Vector3 &position,
Ogre::Vector3 &normal, JPH::BodyID &id,
JPH::BodyID ignoreBody, float walkableNormalY)
{
Ogre::Vector3 direction = endPoint - startPoint;
JPH::RRayCast ray{ JoltPhysics::convert(startPoint),
JoltPhysics::convert<JPH::Vec3>(direction) };
JPH::AllHitCollisionCollector<JPH::CastRayCollector> collector;
JPH::RayCastSettings raySettings;
physics_system.GetNarrowPhaseQuery().CastRay(
ray, raySettings, collector, {},
ObstacleObjectLayerFilter(), SkipOneBodyFilter(ignoreBody));
if (!collector.HadHit())
return false;
collector.Sort();
for (const JPH::RayCastResult &hit : collector.mHits) {
JPH::RVec3 point = ray.GetPointOnRay(hit.mFraction);
JPH::Vec3 n;
{
JPH::BodyLockRead lock(
physics_system.GetBodyLockInterface(),
hit.mBodyID);
if (!lock.Succeeded())
continue;
n = lock.GetBody().GetWorldSpaceSurfaceNormal(
hit.mSubShapeID2, point);
}
if (n.GetY() >= walkableNormalY)
continue;
position = JoltPhysics::convert(point);
normal = JoltPhysics::convert(n);
id = hit.mBodyID;
return true;
}
return false;
}
/* Spawn-placement check: does a character capsule standing at
* @p feetPosition overlap anything solid (static world or
* vehicles; character bodies skipped)? */
@@ -2577,6 +2641,18 @@ bool JoltPhysicsWrapper::raycastQueryWalkable(Ogre::Vector3 startPoint,
normal, id, walkableNormalY);
}
bool JoltPhysicsWrapper::raycastQueryTraffic(Ogre::Vector3 startPoint,
Ogre::Vector3 endPoint,
Ogre::Vector3 &position,
Ogre::Vector3 &normal,
JPH::BodyID &id,
JPH::BodyID ignoreBody,
float walkableNormalY)
{
return phys->raycastQueryTraffic(startPoint, endPoint, position, normal,
id, ignoreBody, walkableNormalY);
}
bool JoltPhysicsWrapper::capsuleOverlapsObstacles(
const Ogre::Vector3 &feetPosition, float halfHeight, float radius)
{
+9
View File
@@ -295,6 +295,15 @@ public:
Ogre::Vector3 &position,
Ogre::Vector3 &normal, JPH::BodyID &id,
float walkableNormalY);
/* Traffic whisker (demo-taxi phase 5.2): like
* raycastQueryWalkable, but character bodies are NOT skipped (a
* pedestrian ahead must brake a car) and the querying vehicle's
* own chassis (ignoreBody) is. Render-space positions. */
bool raycastQueryTraffic(Ogre::Vector3 startPoint,
Ogre::Vector3 endPoint,
Ogre::Vector3 &position,
Ogre::Vector3 &normal, JPH::BodyID &id,
JPH::BodyID ignoreBody, float walkableNormalY);
/* Does a character capsule standing at @p feetPosition overlap
* anything solid? Spawn-placement check. */
bool capsuleOverlapsObstacles(const Ogre::Vector3 &feetPosition,
@@ -320,6 +320,95 @@ void offsetToShoulder(const RoadGraph &graph,
}
}
void offsetToRightLane(const RoadGraph &graph,
std::vector<Ogre::Vector3> &path)
{
if (path.size() < 2 || graph.edges.empty())
return;
/* id -> index (nodes are stored unordered, edges reference ids). */
std::map<int, int> indexOf;
for (size_t i = 0; i < graph.nodes.size(); ++i)
indexOf[graph.nodes[i].id] = (int)i;
for (size_t i = 0; i < path.size(); ++i) {
const Ogre::Vector3 &w = path[i];
/* The edge this waypoint belongs to (same nearest-edge
* scan as offsetToShoulder). */
const RoadEdge *best = nullptr;
float bestD = std::numeric_limits<float>::max();
Ogre::Vector3 bestProj = Ogre::Vector3::ZERO;
for (const RoadEdge &e : graph.edges) {
auto ia = indexOf.find(e.nodeA);
auto ib = indexOf.find(e.nodeB);
if (ia == indexOf.end() || ib == indexOf.end())
continue;
const Ogre::Vector3 &a = graph.nodes[ia->second].position;
const Ogre::Vector3 &b = graph.nodes[ib->second].position;
float abx = b.x - a.x;
float abz = b.z - a.z;
float len2 = abx * abx + abz * abz;
float t = 0.0f;
if (len2 > 1.0e-12f) {
t = ((w.x - a.x) * abx + (w.z - a.z) * abz) /
len2;
t = std::max(0.0f, std::min(1.0f, t));
}
float dx = w.x - (a.x + abx * t);
float dz = w.z - (a.z + abz * t);
float d = dx * dx + dz * dz;
if (d < bestD) {
bestD = d;
best = &e;
bestProj = Ogre::Vector3(a.x + abx * t, w.y,
a.z + abz * t);
}
}
if (!best)
continue;
/* Travel direction at the waypoint (XZ); vehicles keep to
* the RIGHT of it. Endpoints use the one-sided
* direction. */
Ogre::Vector3 dir;
if (i == 0)
dir = path[1] - path[0];
else if (i + 1 == path.size())
dir = path[i] - path[i - 1];
else
dir = path[i + 1] - path[i - 1];
dir.y = 0.0f;
if (dir.squaredLength() < 1.0e-8f)
continue;
dir.normalise();
/* OGRE is right-handed with +Y up: right = forward x up
* (sanity: facing -Z, right must be +X). */
Ogre::Vector3 right = dir.crossProduct(Ogre::Vector3::UNIT_Y);
/* Total carriageway half width from the resolved lane
* counts; the rightmost lane's center sits half a lane
* width in from the curb. */
int lanesFrom = 0;
int lanesTo = 0;
graph.resolveLaneCounts(*best, best->nodeA, lanesFrom,
lanesTo);
float halfW =
(lanesFrom + lanesTo) * graph.config.laneWidth * 0.5f;
float lateral = halfW - graph.config.laneWidth * 0.5f;
/* Endpoints are off-road POIs (curbs sit on the lot in
* front of the building): snap them onto the carriageway
* first, so a vehicle route never leaves the road to
* touch the exact curb (and never plows into the car
* parked there). */
if (i == 0 || i + 1 == path.size())
path[i] = bestProj;
path[i] += right * lateral;
}
}
bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
Ogre::Vector3 &projPoint, Ogre::Vector3 &edgeDir,
float &carriagewayHalf)
@@ -54,6 +54,19 @@ void offsetToShoulder(const RoadGraph &graph,
std::vector<Ogre::Vector3> &path,
float shoulderMargin = 0.6f);
/**
* Shift a driving path onto the right traffic lane (demo-taxi phase
* 5.4): every waypoint moves perpendicular to its nearest road
* edge, to the RIGHT of the travel direction, onto the center of the
* rightmost lane (carriageway half width minus half a lane width).
* Endpoints are off-road POIs (curbs sit on the lot in front of the
* building): they are first snapped onto the nearest carriageway point,
* so a vehicle route never leaves the road. No-op for paths shorter
* than 2 points or an edgeless graph.
*/
void offsetToRightLane(const RoadGraph &graph,
std::vector<Ogre::Vector3> &path);
/** Total polyline length in meters. */
float polylineLength(const std::vector<Ogre::Vector3> &path);
@@ -2911,7 +2911,17 @@ void CellGridSystem::destroyCellGridMeshes(flecs::entity entity)
// Destroy physics colliders
destroyPhysicsColliders(entity);
/* Keep the in-flight door swing states across the erase (a
* rebuild re-creates the MeshData right away and re-applies
* them to the rebuilt doors); a teardown for a dead owner
* (scene switch) drops them with the entry. */
auto savedDoorStates = std::move(it->second.pendingDoorStates);
m_entityMeshes.erase(it);
if (!savedDoorStates.empty() && entity.is_valid() &&
entity.is_alive()) {
m_entityMeshes[entity.id()].pendingDoorStates =
std::move(savedDoorStates);
}
}
}
@@ -5105,6 +5115,39 @@ void CellGridSystem::buildDoorEntities(flecs::entity entity,
if (!doorEntity.is_valid())
return;
/* Re-apply in-flight swing state stashed by
* destroyDoorEntities() before this rebuild (one-shot). */
if (!params.doorId.empty()) {
auto stIt = meshData.pendingDoorStates.find(
params.doorId);
if (stIt != meshData.pendingDoorStates.end()) {
const MeshData::DoorSwingState &st =
stIt->second;
auto &door =
doorEntity.get_mut<DoorComponent>();
door.isOpen = st.isOpen;
door.currentAngle = st.currentAngle;
door.toggleRequested = st.toggleRequested;
door.sceneSwitchPending =
st.sceneSwitchPending;
if (transform.node &&
st.currentAngle != 0.0f) {
auto &dt = doorEntity.get_mut<
TransformComponent>();
if (dt.node)
dt.node->setOrientation(
DoorSystem::swingOrientation(
door,
st.currentAngle));
}
auto &rb = doorEntity.get_mut<
RigidBodyComponent>();
rb.enabled = !st.isOpen &&
st.currentAngle == 0.0f;
meshData.pendingDoorStates.erase(stIt);
}
}
meshData.doorEntities.push_back({key, doorEntity});
};
@@ -5147,6 +5190,23 @@ void CellGridSystem::destroyDoorEntities(flecs::entity entity)
if (!doorEntity.is_valid() || !doorEntity.is_alive())
continue;
/* Stash in-flight swing state so the rebuild that follows
* (e.g. the texture-ready rebuild right after scene load)
* does not eat a pending toggle or scene-switch request. */
if (doorEntity.has<DoorComponent>()) {
const auto &d = doorEntity.get<DoorComponent>();
if (!d.doorId.empty() &&
(d.toggleRequested || d.sceneSwitchPending ||
d.currentAngle != 0.0f)) {
MeshData::DoorSwingState st;
st.isOpen = d.isOpen;
st.currentAngle = d.currentAngle;
st.toggleRequested = d.toggleRequested;
st.sceneSwitchPending = d.sceneSwitchPending;
it->second.pendingDoorStates[d.doorId] = st;
}
}
// Remove the physics body before the entity dies
if (doorEntity.has<RigidBodyComponent>() &&
m_physicsSystem) {
@@ -1,6 +1,7 @@
#pragma once
#include <flecs.h>
#include <map>
#include <memory>
#include <Ogre.h>
#include "../components/RigidBody.hpp"
@@ -304,6 +305,19 @@ private:
// with its canonical edge key (F0)
std::vector<std::pair<std::string, flecs::entity> > doorEntities;
std::string doorLeafMesh; // procedural leaf mesh (custom mesh not owned)
/* In-flight door swing state carried over a rebuild: the grid
* rebuilds when its procedural texture first becomes ready
* (right after scene load), which recreates the door entities;
* without the carry-over a toggle/scene-switch request landing
* just before that rebuild is silently lost. */
struct DoorSwingState {
bool isOpen = false;
float currentAngle = 0.0f;
bool toggleRequested = false;
bool sceneSwitchPending = false;
};
std::map<std::string, DoorSwingState> pendingDoorStates;
};
std::unordered_map<uint64_t, MeshData> m_entityMeshes;
@@ -100,14 +100,25 @@ void EditorWaterPlaneSystem::update(float deltaTime, Ogre::Camera *camera)
if (camera)
updatePosition(wp, deltaTime, camera);
// Alternate viewport updates
if (wp.renderTarget) {
// Alternate viewport updates, throttled: each RTT update is a full
// scene re-render and dominates the water CPU cost. Skipped
// entirely while the water surface is outside the camera frustum.
if (wp.renderTarget) {
bool surfaceVisible = true;
if (camera && wp.manualObject)
surfaceVisible = camera->isVisible(
wp.manualObject->getWorldBoundingBox());
if (surfaceVisible &&
++wp.rttFrameCounter >= wp.rttUpdateInterval) {
wp.rttFrameCounter = 0;
if (wp.updateViewportIndex == 0 && wp.reflectionViewport)
wp.reflectionViewport->update();
else if (wp.updateViewportIndex == 1 && wp.refractionViewport)
else if (wp.updateViewportIndex == 1 &&
wp.refractionViewport)
wp.refractionViewport->update();
wp.updateViewportIndex = 1 - wp.updateViewportIndex;
}
}
wp.sceneNode->setVisible(true);
});
@@ -206,6 +217,14 @@ void EditorWaterPlaneSystem::rebuildWaterPlane(
}
wp.manualObject->setMaterialName(0, "WaterPlane/Dynamic");
// Cache the shader parameter targets so the per-frame update does
// not hit the material manager
wp.cachedMaterial = mat;
wp.cachedPass = nullptr;
if (mat && mat->getNumTechniques() > 0 &&
mat->getTechnique(0)->getNumPasses() > 0)
wp.cachedPass = mat->getTechnique(0)->getPass(0);
// Set visibility mask so cameras can hide water
wp.manualObject->setVisibilityFlags(WATER_MASK);
@@ -260,6 +279,8 @@ void EditorWaterPlaneSystem::cleanupWaterPlane(WaterPlane &wp)
m_sceneMgr->destroyManualObject(wp.manualObject);
wp.manualObject = nullptr;
}
wp.cachedMaterial.reset();
wp.cachedPass = nullptr;
if (wp.sceneNode) {
if (wp.sceneNode->getParentSceneNode()) {
wp.sceneNode->getParentSceneNode()->removeChild(
@@ -409,15 +430,23 @@ void EditorWaterPlaneSystem::updateShaderParams(WaterPlane &wp,
{
wp.shaderTime += deltaTime * wp.waveSpeed;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().getByName(
"WaterPlane/Dynamic",
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
if (!mat)
if (!wp.cachedPass) {
wp.cachedMaterial =
Ogre::MaterialManager::getSingleton().getByName(
"WaterPlane/Dynamic",
Ogre::ResourceGroupManager::
DEFAULT_RESOURCE_GROUP_NAME);
if (wp.cachedMaterial &&
wp.cachedMaterial->getNumTechniques() > 0 &&
wp.cachedMaterial->getTechnique(0)->getNumPasses() > 0)
wp.cachedPass =
wp.cachedMaterial->getTechnique(0)->getPass(0);
}
if (!wp.cachedPass)
return;
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
Ogre::GpuProgramParametersSharedPtr fpParams =
pass->getFragmentProgramParameters();
wp.cachedPass->getFragmentProgramParameters();
if (!fpParams)
return;
@@ -430,7 +459,7 @@ void EditorWaterPlaneSystem::updateShaderParams(WaterPlane &wp,
fpParams->setNamedConstant("reflectivity", wp.reflectivity);
Ogre::GpuProgramParametersSharedPtr vpParams =
pass->getVertexProgramParameters();
wp.cachedPass->getVertexProgramParameters();
if (vpParams)
vpParams->setNamedConstant("tiling", wp.tiling);
}
+5 -2
View File
@@ -39,7 +39,10 @@ void EditorLodSystem::update()
RenderableComponent &renderable) {
// Try to resolve settings entity if we have an ID but no valid entity
if (!lod.settingsId.empty() && !lod.hasValidSettings()) {
m_world.query<LodSettingsComponent>().each(
/* Cached member query: building a fresh query here (per
* entity, per frame) walks the whole table index and
* showed up as ~5 ms/frame in EDITSCENE_PROFILE. */
m_settingsQuery.each(
[&](flecs::entity e,
LodSettingsComponent &settings) {
if (settings.settingsId ==
@@ -61,7 +64,7 @@ void EditorLodSystem::update()
// Final fallback: if settingsId is still empty, try to use any available LOD settings
if (lod.settingsId.empty() && !lod.hasValidSettings()) {
m_world.query<LodSettingsComponent>().each(
m_settingsQuery.each(
[&](flecs::entity e,
LodSettingsComponent &settings) {
if (lod.settingsEntity ==
@@ -17,6 +17,7 @@
#include "NpcMotionSystem.hpp"
#include "NpcPopulationSystem.hpp"
#include "NpcTaxiSystem.hpp"
#include "NpcTrafficSystem.hpp"
#include "PhysicsSystem.hpp"
#include "RenderOriginSystem.hpp"
#include "../GameMode.hpp"
@@ -61,6 +62,12 @@ bool NpcDetailSystem::isSpawnEligible(uint64_t registryId) const
NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance();
if (taxi && taxi->hasActiveGoal(registryId))
return true;
/* Traffic drivers (phase 5.4) sit in their car — suspended from
* the schedule layer, so motionInfoOf reports nothing and the
* fallthrough below would wrongly spawn them as pedestrians. */
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
if (traffic && traffic->isDriving(registryId))
return false;
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
if (!motion || !pop)
@@ -63,6 +63,17 @@ public:
/** True while the NPC has a live spawned character entity. */
bool isHighDetail(uint64_t registryId) const;
/** Number of population NPCs currently in the high-detail tier
* (benchmark telemetry). */
size_t highDetailCount() const
{
size_t n = 0;
for (const auto &kv : m_npcs)
if (kv.second.highDetail)
++n;
return n;
}
/**
* Spawn eligibility (demo-taxi phase 4.9.1): an NPC whose current
* symbolic location is INDOORS (TownLocations::isIndoorKind —
@@ -14,6 +14,7 @@
#include "RenderOriginSystem.hpp"
#include "RoadSystem.hpp"
#include "TerrainSystem.hpp"
#include "NpcTrafficSystem.hpp"
#include "../EditorApp.hpp"
#include "../GameMode.hpp"
#include "../components/Character.hpp"
@@ -130,6 +131,11 @@ bool NpcTaxiSystem::assignTaxiGoal(uint64_t registryId,
CharacterRegistry::getSingleton().findCharacter(registryId);
if (!pop || !motion || !rec)
return false;
/* Traffic drivers (phase 5.4) are suspended in their car; a taxi
* goal would fight NpcTrafficSystem over the NPC. */
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
if (traffic && traffic->isDriving(registryId))
return false;
int areaIdx = pop->areaIndexOf(registryId);
if (areaIdx < 0 || !findLocation(areaIdx, destLocation))
return false;