Hardweware skinning works!

This commit is contained in:
2026-10-21 12:21:58 +03:00
parent 136013440e
commit 73e1cef832
16 changed files with 3568 additions and 22 deletions
+77 -1
View File
@@ -338,7 +338,47 @@ cd demos/demo-taxi
# 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.
# EditorApp::frameRenderingQueued every 600 frames, plus the full
# frame split: renderPass (frameStarted -> frameRenderingQueued =
# scene graph update + cull + draw submission), systems (the
# profiled steps), swapWait (frameRenderingQueued end -> frameEnded
# = the vsync/GPU-bound wait) and OGRE tris/batches averages.
./demoTaxi --headless --benchmark-drive [--drive-speed mps]
[--drive-frames N] [--drive-csv path]
# (phase 5.5) Continuous road-tour probe: builds an edge-coverage
# tour of the town road graph (DFS over edges, RoadPath::findPath
# reconnects at dead ends), pins the player camera on it at 2.2 m
# eye height AND teleports the player character along (the NPC
# spawn tiers measure against the player camera, which
# PlayerControllerSystem re-derives from the player before the NPC
# systems run — pinning only the camera leaves the tiers anchored
# at the spawn point), and sets the game clock to noon so NPCs are
# outdoors-eligible (the default ~2 AM start makes almost everyone
# sleep indoors). The cursor advances at the drive speed (default
# 13 m/s), one fixed 1/60 s step per frame in BOTH headless and
# windowed runs so the ground covered per frame is identical
# everywhere (default cap 6000 recorded frames). Forces the
# exterior scene like the other town probes (the demo otherwise
# starts in the interior). Every frame appends a CSV row
# (default benchmark_drive.csv): wall ms, the frame split
# (renderPass / systems / swapWait), the render-pass sub-split
# (viewport = draw submission, cull = scene graph update + cull),
# the viewport sub-sub-split (find = findVisibleObjects cull/queue
# fill, queueMain/queueOverlay = per-queue-group render execution,
# renderables = visible renderables queued — SceneManager::Listener
# + RenderQueueListener + RenderQueue::RenderableListener, only
# registered in --benchmark-drive mode), the systems spike
# attribution (spike_step_ms/spike_step = slowest profiled
# EditorApp step of the frame),
# batches, tris, terrain pages loaded, high-detail NPCs and active
# traffic cars — FPS drops correlate with the recorded state. The
# summary prints wall-ms percentiles, span averages, a histogram of
# the systems-spike steps (>8 ms frames) and the top-10 worst
# frames with full state, then
# "[benchmark-drive] PASS" (wireable into the test battery once
# baseline thresholds are known). Headless wall times are
# llvmpipe-relative; run windowed on real hardware for absolute
# numbers.
# 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 =
@@ -845,6 +885,42 @@ symbolic restore; the snapshot settles two frames first because the
population seeds `rec->position` with the home doorstep and the
first pure evaluation overwrites it).
Character rendering performance (phase 5.5, 2026-10-18): two OGRE
vertex-animation traps made every visible NPC cost ~1.1-1.5 ms of CPU
in the find window before the fix — (1) exported meshes carry Blender
shape keys, and an always-created "ShapeKeys" pose animation marked
them VAT_POSE, which OGRE cannot do in hardware with RTSS materials,
forcing per-frame CPU morph blending; `buildCharacter` now strips
vertex animations + poses from meshes of characters whose registry
record has no non-zero `inlineShapeKeyWeights` (meshes are shared;
if a shape-keyed character ever needs an already stripped mesh it
must be reloaded from disk), and (2) the blender2ogre materials never
requested RTSS hardware skinning, so every skeletal entity was
CPU-skinned every frame; `buildCharacter` now injects the
`SGX_HardwareSkinning` sub-render state into each character mesh's
materials (linear, weight_count 4, max_bone_count = largest compact
palette in the mesh) and invalidates the material. Note: RTSS HW
skinning silently disables itself on glsles without GLSL 300 es.
Follow-up (2026-10-21, "floating head" regression): the generated
shader reads `blendWeights` as vec4 (weight_count 4) while OGRE sizes
the mesh's blend-weight element by actual influences (VET_FLOAT1 for
single-bone meshes like the face); GL pads missing vec4 attribute
components with (0,0,1), adding a phantom second bone-matrix term
that double-transformed every sub-4-weight part (face/hair rendered
at ~2x bind height above the body). `ensureHardwareSkinning` now
repacks every character mesh's blend-weight element to a full
zero-padded float4 at load (`normalizeVertexDataBlendWeights`), and
`finalizeHardwareSkinning` force-generates the RTSS technique +
clears the entity's stale software/hardware vertex-processing cache
(the RTSS HS sub-render state never marks the generated program
skeletal). `EDITSCENE_NO_HWSKIN=1` restores full software skinning.
`CharacterSlotSystem::prewarmShaders()` compiles all catalog skinning
shaders at startup (windowed runs only, gated on `isHeadless()`) so
the first NPC spawn does not hitch on glCompileShader.
Details in demo-taxi/PLAN.md phase 5.5 follow-up; per-step find-walk
attribution is available via the temporary `OGRE_FIND_DEBUG` patch in
the vendored OGRE repo (see PLAN.md for usage).
### NpcTaxiSystem (demo-taxi phase 4.7.7)
`NpcTaxiSystem` (EditorApp-owned singleton `getInstance()`, game mode only,
+15
View File
@@ -523,6 +523,11 @@ void EditorApp::setup()
Ogre::RTShader::ShaderGenerator *shadergen =
Ogre::RTShader::ShaderGenerator::getSingletonPtr();
if (shadergen) {
/* Debug aid: EDITSCENE_RTSS_CACHE=dir dumps every
* generated shader source to <dir>/ShaderCache. */
const char *rtssCache = std::getenv("EDITSCENE_RTSS_CACHE");
if (rtssCache && rtssCache[0])
shadergen->setShaderCachePath(rtssCache);
shadergen->addSceneManager(m_sceneMgr);
}
@@ -2359,11 +2364,17 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_profRenderAccum += m_lastRenderPassMs;
}
m_lastSystemsMs = 0.0f;
m_lastSpikeStepMs = 0.0f;
m_lastSpikeStep.clear();
auto stepProfile = [&stepTimer, this](const char *name) {
float ms = (float)stepTimer.getMilliseconds();
stepTimer.reset();
m_lastSystemsMs += ms;
if (ms > m_lastSpikeStepMs) {
m_lastSpikeStepMs = ms;
m_lastSpikeStep = name;
}
if (ms > 50.0f)
Ogre::LogManager::getSingleton().logMessage(
"FrameProfile: " + Ogre::String(name) +
@@ -2619,6 +2630,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
if (m_characterSystem) {
m_characterSystem->update(fixedEvt.timeSinceLastFrame);
}
stepProfile("characterSystem");
/* --- Buoyancy system (before physics so impulse is integrated) --- */
if (m_buoyancySystem) {
@@ -2652,6 +2664,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
m_buoyancySystem->update(fixedEvt.timeSinceLastFrame);
}
stepProfile("buoyancy");
/* --- Hair physics root sync (before physics step) --- */
if (m_hairPhysicsSystem) {
@@ -2663,12 +2676,14 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
if (m_aiDriverSystem) {
m_aiDriverSystem->update(fixedEvt.timeSinceLastFrame);
}
stepProfile("aiDriver");
/* --- F10 vehicle inputs (before the physics step) --- */
if (m_vehicleSystem) {
m_vehicleSystem->prePhysicsUpdate(
fixedEvt.timeSinceLastFrame);
}
stepProfile("vehiclePre");
/* --- Main physics step --- */
if (m_physicsSystem) {
+15 -4
View File
@@ -323,6 +323,16 @@ public:
{
return m_sceneMgr;
}
/* Per-frame time split (see the m_last*Ms members): the render
* pass (scene graph update, cull, submit), the CPU systems in
* frameRenderingQueued, and the swap/vsync wait. */
float getLastRenderPassMs() const { return m_lastRenderPassMs; }
float getLastSystemsMs() const { return m_lastSystemsMs; }
float getLastSwapWaitMs() const { return m_lastSwapWaitMs; }
/* Slowest profiled step of the last frame (see m_lastSpikeStep*). */
float getLastSpikeStepMs() const { return m_lastSpikeStepMs; }
const std::string &getLastSpikeStep() const { return m_lastSpikeStep; }
flecs::world *getWorld()
{
return &m_world;
@@ -610,15 +620,16 @@ private:
float m_lastRenderPassMs = 0.0f;
float m_lastSystemsMs = 0.0f;
float m_lastSwapWaitMs = 0.0f;
/* Per-frame spike attribution: the slowest profiled step of the
* last frameRenderingQueued (benchmark CSVs correlate FPS drops
* with the responsible system). */
float m_lastSpikeStepMs = 0.0f;
std::string m_lastSpikeStep;
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);
@@ -0,0 +1,65 @@
#ifndef EDITSCENE_AIDRIVER_HPP
#define EDITSCENE_AIDRIVER_HPP
#pragma once
#include <vector>
#include <OgreVector3.h>
/**
* One waypoint of an AiDriverComponent route (demo-taxi phase 5.1,
* junction fields phase 5.3).
*/
struct AiRoutePoint {
/* Route position (world/render space matching the scene). */
Ogre::Vector3 position;
/* Road node id of the junction (degree >= 3 road node) this
* point passes through, -1 when the point is not a junction.
* Junction points make the AI slow, yield to cross traffic and
* reserve the junction before crossing. */
int junctionNodeId = -1;
/* Junction occupancy center — the road node's position (the
* route point itself is lane-offset). Only meaningful when
* junctionNodeId >= 0. */
Ogre::Vector3 junctionCenter = Ogre::Vector3::ZERO;
};
/**
* AI driver component (demo-taxi phase 5.1).
*
* Turns an entity with a VehicleComponent into an AI-driven vehicle:
* AiDriverSystem follows `route` (a world-space polyline, e.g. built
* from the road graph by RoadPath::findPath plus a right-lane offset)
* with pure-pursuit steering and a throttle/brake speed controller,
* writing the same VehicleComponent::inputForward/inputRight/inputBrake
* fields the player controller writes — VehicleSystem stays the single
* consumer of those fields. A vehicle carrying the player
* (PlayerControlledComponent) is never touched.
*
* The route polyline is XZ-driven: waypoint Y is ignored for steering
* and arrival (the physics keeps the car on the ground), it only
* carries the road surface height for callers that want it.
*/
struct AiDriverComponent {
/* Route polyline (world/render space matching the scene). */
std::vector<AiRoutePoint> route;
/* Index of the waypoint the car currently drives toward. */
size_t routeIndex = 0;
/* true: wrap to route[0] after the last waypoint (circuit);
* false: brake to a stop at the last waypoint and set arrived. */
bool loop = false;
/* Cruise speed in m/s (the per-edge speed limit folds into this
* when routes carry limits — phase 5.1 keeps a single value). */
float cruiseSpeed = 11.0f;
/* Runtime: the car reached the end of a non-looping route. */
bool arrived = false;
/* Runtime: number of completed route wraps (loop laps) — used by
* the headless traffic test. */
int lapsCompleted = 0;
/* Runtime: junction waypoints were classified against the road
* graph (AiDriverSystem::classifyJunctions). Route builders that
* flag junctions themselves set this to skip the auto pass. */
bool junctionsClassified = false;
};
#endif /* EDITSCENE_AIDRIVER_HPP */
+163 -3
View File
@@ -2813,11 +2813,150 @@ taxi's lights should look distinct.
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.
- **Full-frame profiler split + drive benchmark (added).** The second
playtest measured 40 FPS at only ~50 batches when just the road was
visible — batch count alone does not explain the frame time, and the
5.5 system profiler only covers `frameRenderingQueued`. EditorApp now
records the full frame split every frame (a few timer reads):
renderPass (frameStarted -> frameRenderingQueued = scene graph update
+ cull + draw submission), systems (the profiled steps), swapWait
(frameRenderingQueued end -> frameEnded = vsync/GPU-bound wait);
`EDITSCENE_PROFILE=1` dumps 600-frame averages of all three plus
tris/batches, and per-frame getters feed the new probe.
`--benchmark-drive [--drive-speed mps] [--drive-frames N]
[--drive-csv path]` drives a camera at 2.2 m eye height along a
continuous edge-coverage tour of the road graph (fixed 1/60 s steps
in both headless and windowed runs) and logs every frame to CSV
(wall ms, frame split, batches/tris, terrain pages, high-detail NPCs,
active cars) with a top-10 worst-frames report — FPS drops become
correlatable with scene state, and the run doubles as a regression
harness once baseline thresholds are chosen. Next step: a windowed
on-hardware run to localize the 25 ms frames.
- **On-hardware drive result + benchmark anchoring fix.** First
windowed `--benchmark-drive` run (real GPU): wall avg 18.25 ms
(p50 16.8 = vsync period, p95 32.3 = doubled vsync), spans
renderPass 10.0 / systems 3.6 / swapWait 2.8 ms, batches avg 61
max 132, tris avg 213 K. Conclusions: NOT GPU-bound (swapWait 2.8
ms) and NOT batch-count-bound — the render pass's CPU side (scene
graph update / cull / submission) is the biggest average share and
spikes to 26 ms; systems spikes (~13 ms, synchronous terrain page
loads) tip frames over the 16.6 ms vsync boundary into 33 ms. The
run also exposed a benchmark artifact: npcs/cars stayed 0 because
the NPC spawn tiers measure against the player camera, which
PlayerControllerSystem re-derives from the player BEFORE the NPC
systems run — the camera pin only won at render time. The
benchmark now teleports the player character along the tour (spawn
tiers + character physics/animation follow the drive) and sets the
clock to noon (the default ~2 AM start makes almost every NPC
spawn-ineligible — asleep indoors). CSV gained viewport_ms (draw
submission) / cull_ms (scene graph update + cull) columns splitting
the render pass.
- **Per-NPC find-window cost root-caused and fixed (2026-10-18).**
The second windowed drive run showed find = 13.7 ms (the whole
viewport cost), bimodal on visible NPC count (~1.1-1.5 ms per
NPC), and per-material attribution pinned it on character-part
renderables. A temporary env-gated instrumentation patch
(`OGRE_FIND_DEBUG=1` per-type, `=2` adds per-entity names) in the
VENDORED OGRE repo `/media/slapin/library/ogre3/ogre`
(OgreFindDbg.h new; `_findVisibleObjects`, `processVisibleObject`,
`Entity::updateAnimation`, `calcVertexProcessing` wrapped; run with
`LD_LIBRARY_PATH` pointing at a dir containing only the repo-built
`libOgreMain.so.14.6` — the repo build lacks the SDK's ImGui
component, so do NOT point it at the whole repo `lib/` dir) split
the find walk per step: `Entity::_updateRenderQueue` was ~99% of
the walk, and inside it `updateAnimation()` — concretely
`applyVertexAnimation` (CPU morph blending) and
`softwareVertexBlend` (CPU skinning) — at ~250-820 us per skeletal
entity per frame (Debug build). Two independent causes, both in
`CharacterSlotSystem::buildCharacter`:
1. Exported character meshes carry Blender shape keys, and the slot
system always created a "ShapeKeys" pose animation, marking every
such mesh VAT_POSE. OGRE only does hardware POSE animation with
a custom shader (RTSS has none), so VAT_POSE disabled hardware
vertex animation for the whole entity -> per-frame CPU morph
blend. No registry record uses non-zero shape-key weights, so
this was pure waste: when the character record has no non-zero
weights the meshes now get `removeAllAnimations()` +
`removeAllPoses()` once at load (meshes are shared resources;
idempotent, tracked in a static set). Characters WITH shape keys
keep the old path (and still pay CPU morph blending — bake into
per-weight-set mesh clones if that ever matters).
2. The blender2ogre materials' `rtshader_system` blocks never
requested skinning, so the RTSS-generated vertex programs had
`isSkeletalAnimationIncluded()==false` and EVERY skeletal entity
(all NPCs + the player) was skinned on the CPU every frame
(positions AND normals). Fix: per character-mesh material,
inject the `SGX_HardwareSkinning` sub-render state (linear,
weight_count 4, max_bone_count = skeleton bone count, factory
maxCalculableBoneCount raised to match) into the
ShaderGeneratorDefaultScheme render state and invalidate the
material so the technique regenerates. hwcheck probe confirms
`skel=1` on all character materials afterwards.
Result (headless Debug, llvmpipe): find walk 861 -> 282 us;
`Entity::updateRQ` 31.8 -> 5.2 us/call; vtxAnim/swBlend gone.
Release headless find_objs avg 2.7 -> 0.58 ms. Caveats: on glsles
without GLSL 300 es RTSS hardware skinning disables itself
(`doBoneCalculations=false`) and silently falls back to software
skinning; a 139-bone skeleton needs 417 vec4 uniforms, over the
GLES2-era 256-vec4 minimum — if GLES2 becomes the target, prune
bones or segment meshes. Part entities already share one
`SkeletonInstance` via `shareSkeletonInstanceWith` (skeleton pose
evaluated once per NPC per frame, ~35 us Debug); hair with its own
armature still pays its own small skeleton. Verified: taxi battery
17/17 PASS. Remaining find cost is now small; the physics step
(avg ~3 ms windowed, spikes >8 ms on ~8% of frames near
NPCs+cars) is the next target.
- **"Floating head" regression root-caused and fixed (2026-10-21).**
Enabling RTSS hardware skinning made every NPC render a rigid
face+hair head hovering ~2x bind height above a correct animated
headless body. Per-draw GL/RTSS forensics (temporary env-gated
hooks in the vendored OGRE, since removed) proved the uploaded bone
palettes, world matrices and generated vertex shaders were all
correct — the corruption happened inside the vertex shader:
`SGX_HardwareSkinning` is generated with `weight_count 4`, so the
shader reads `blendWeights` as **vec4**, but OGRE sizes the mesh's
blend-weight element by the actual influences per vertex
(`Mesh::compileBoneAssignments`: VET_FLOAT1 for a single-bone mesh
like the face, FLOAT3 for the hair). GL pads the missing
components of a vec4 attribute read with `(0,0,1)`, so every
sub-4-weight vertex gained a **phantom 4th bone-matrix term with
weight 1.0** — a second application of the bone transform
(bind-pos -> ~2x bind-pos for the rigidly head-bound face, while
FLOAT4 body parts were unaffected and the FLOAT2 feet only shifted
centimeters, which is why it went unnoticed there). Two earlier
"did not move" observations that sent the hunt down wrong paths
were measurement errors: the RTSS disk shader cache is
content-addressed (`generateHash(source, defines)`) so it cannot
serve stale sources, and an SGXLib sink patch DID move the floater
(~150 px), confirming it went through `blendBonesMat3x4` after all.
Fix in `CharacterSlotSystem.cpp`: `normalizeVertexDataBlendWeights`
repacks every character mesh's blend-weight element to a full
float4 (zero-padded) once at load in `ensureHardwareSkinning`
(shared + dedicated vertex data), so the attribute read matches the
shader. `finalizeHardwareSkinning` is kept too: the RTSS HS
sub-render state never sets `skeletalAnimationIncluded` on the
generated program, and without the forced re-evaluation the entity
cached a "software" vertex-processing decision and uploaded no
palette (a real second bug, masked by the first). Kill switch:
`EDITSCENE_NO_HWSKIN=1` restores full software skinning. Verified:
xvfb screenshot (head attached on both characters), taxi battery
17/17 PASS.
- **Shader prewarm + post-fix benchmark (2026-10-21).**
`CharacterSlotSystem::prewarmShaders()` compiles every catalog
skinning shader (VS+FS `load()` per material pass) at startup in
windowed runs (gated on `EditorApp::isHeadless()` so tests do not
pay for it), removing the first-NPC-spawn glCompileShader hitch.
benchmark-drive on the fixed build: PASS, wall avg 5.66 ms
(p50 4.86, p95 9.24, p99 11.8), physics spikes >8 ms down from
x460 to x1, find_objs cost collapsed (0.34 ms avg) — the previous
per-material `find` hot spots (male_face-atlas etc.) are gone.
### Phase 5.9 — town improvements before foot traffic
- In building DSL we need to identify house types, as a list of tag keywords.
- The template layouts should define score which should prioritize layout selection if more than one is possible.
- 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.
- Weighted/scored Monte-Carlo selection should be used. Low-scored templates still should be able to show up but under lower frequency.
- 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
@@ -2829,7 +2968,7 @@ taxi's lights should look distinct.
- 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.
- 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. Hair physics disabled.
- 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.
@@ -2867,14 +3006,15 @@ taxi's lights should look distinct.
### 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
- all furniture assets should use the same atlas material, which should be generated by Blender's export script (the one which generates jsons too)
- as with node version, building layout DSL should be able to 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.
- some room layouts need special furniture placement, that should be supported via Lua events. This is about unique buildings and stores/shops for special room layouts, probably another S-expression based DSL would be very useful here in addition to Lua.
- 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.
@@ -2925,6 +3065,20 @@ taxi-specific):
produce identical interiors (determinism check); a multi-storey
building's upper floor is reachable by stairs.
### Phase 8.5 car effects
- With help of asset pipeline car effects need to be implemented
- Doors should be opened before NPC or player enters vehicle
- door handle should be used
- Smoke should be generated on exhaust
- skid marks should be left on intense braking and sidehifting on intense turns, also smoke from under wheels.
- stop lights, backwards motion lights, turn indicators should be working automatically depending on driver actions.
- it should be possible to turn on lights
- implement in-car first person view
- hands on steering wheel - implement IK to put arms on stair wheel properly.
- engine sounds
- horn
### Phase 9 — Taxi passengers
- Passenger NPCs waiting at curbs/POIs; hail interaction (E prompt via
@@ -2936,11 +3090,13 @@ taxi-specific):
- Destination assignment: passenger names a POI/curb from the
generated town (any settlement in the world); route distance
computed over the road graph (drives the fare timer).
- Should be controlled by Lua code.
- Headless: scripted passenger boards, rides, alights at destination;
vehicle carries passenger through a scene-physics run.
### Phase 10 — Fare chain gameplay (the taxi game)
- Should be implemented as Lua and C++ functionality with system-first principle.
- `lua-scripts/taxi.lua` (pattern of `sokoban.lua`): fare state
machine (awaiting fare -> passenger aboard -> delivered/failed),
price doubling per completed fare (fare 1 = base price, fare 10 =
@@ -2953,6 +3109,10 @@ taxi-specific):
after fare 10.
- Persistence in `GlobalStateStore` under `taxi.*` so the chain
survives scene switches and save/load.
- Should implement mission-type behaviors:
- package delivery
- person delivery (normal taxi)
- easy to add other missions.
- Headless: scripted 3-fare run asserting prices double, timer scales
with distance, a timeout resets the chain to fare 1, completion
events fire, HUD state updates.
@@ -1,4 +1,5 @@
#include <iostream>
#include <fstream>
#include <cstdlib>
#include "EditorApp.hpp"
#include "ProjectConfig.hpp"
@@ -37,6 +38,7 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <functional>
#include <map>
#include <set>
#include <Jolt/Physics/PhysicsSystem.h>
@@ -5391,17 +5393,25 @@ struct BenchmarkListener : public Ogre::FrameListener,
* both headless and windowed runs so the ground covered per frame is
* identical everywhere. Every frame appends one CSV row
* (benchmark_drive.csv or --drive-csv): wall ms, the EditorApp frame
* split (render pass / systems / swap wait), OGRE batches/tris and
* split (render pass / systems / swap wait), the viewport sub-split
* (find = cull/queue fill, queueMain/queueOverlay = per-queue-group
* render execution, renderables = visible renderables queued), the
* systems spike attribution (slowest profiled step of the frame),
* OGRE batches/tris and
* the scene state (terrain pages loaded, high-detail NPCs, active
* traffic cars) so FPS drops can be correlated with what was on
* screen. The summary prints percentiles and the top-10 worst frames
* screen. The summary prints percentiles, the span averages, a
* histogram of the systems-spike steps and the top-10 worst frames
* with their full state. Headless wall times are llvmpipe-relative;
* run windowed on real hardware for absolute numbers. Exits with
* "[benchmark-drive] PASS" on completion so it can be wired into the
* test battery once baseline thresholds are known.
*/
struct DriveBenchmarkListener : public Ogre::FrameListener,
public Ogre::RenderTargetListener {
public Ogre::RenderTargetListener,
public Ogre::SceneManager::Listener,
public Ogre::RenderQueueListener,
public Ogre::RenderQueue::RenderableListener {
EditorApp *app;
int frame = 0;
int phase = 0; /* 0 = wait for the town, 1 = settle, 2 = driving */
@@ -5414,8 +5424,8 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
int maxFrames = 6000; /* recorded drive frames cap */
Ogre::String csvPath = "benchmark_drive.csv";
static const int settleFrames = 60;
static const float fixedDt = 1.0f / 60.0f;
static const float eyeHeight = 2.2f;
static constexpr float fixedDt = 1.0f / 60.0f;
static constexpr float eyeHeight = 2.2f;
std::vector<Ogre::Vector3> tour; /* world-space polyline */
float tourLength = 0.0f;
@@ -5423,9 +5433,49 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
Ogre::Vector3 camPos = Ogre::Vector3::ZERO; /* world */
float camYawDeg = 0.0f;
int recorded = 0;
int settle = 0;
std::ofstream csv;
std::chrono::steady_clock::time_point lastTick;
std::chrono::steady_clock::time_point vpTick;
double viewportMs = 0.0; /* render submission inside the window */
/* Viewport sub-split (SceneManager::Listener + RenderQueueListener
* + RenderableListener, registered only in --benchmark-drive mode):
* findMs = findVisibleObjects (cull + queue fill), queueMainMs /
* queueOverlayMs = per-queue-group render execution, renderables =
* visible renderables queued this frame. */
std::chrono::steady_clock::time_point findTick;
std::chrono::steady_clock::time_point queueTick;
double findMs = 0.0;
/* find-window sub-split: findPreMs = preFind -> first renderable
* queued (listeners + graph walk to the first visible object),
* findObjsMs = first -> last renderable (per-object processing),
* findPostMs = last renderable -> postFind (tail listeners). */
std::chrono::steady_clock::time_point firstRendTick;
std::chrono::steady_clock::time_point lastRendTick;
double findPreMs = 0.0, findObjsMs = 0.0, findPostMs = 0.0;
bool hadRenderable = false;
/* True only between preFindVisibleObjects and
* postFindVisibleObjects of the MAIN WINDOW's IRS_NONE pass.
* The water reflection RTT (and shadow) passes also queue
* renderables through the same RenderableListener; without this
* gate the gap from the last main-pass renderable to the first
* RTT renderable of the next frame (the entire rest of the
* frame, ~20 ms) got attributed to whatever material was queued
* last — bogus ~1 ms averages on character parts (2026-10-16). */
bool inMainFind = false;
/* Per-material attribution of the per-renderable find_objs cost:
* the gap between two renderableQueued calls is the previous
* renderable's share (bbox/sphere derive + _updateRenderQueue +
* addRenderable), keyed by its material name — that identifies the
* object class (terrain pages, sedan idmap, TownGen settlement,
* character parts, ...). Aggregated over the whole run. */
Ogre::String prevRendKey;
std::map<Ogre::String, std::pair<double, size_t> > rendAttr;
size_t dbgRendCalls = 0, dbgPreFind = 0, dbgPostFind = 0;
double queueMainMs = 0.0;
double queueOverlayMs = 0.0;
size_t renderables = 0;
std::vector<double> wallTimes;
struct Worst {
double ms;
@@ -5433,6 +5483,8 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
float dist;
Ogre::Vector3 pos;
double renderMs, systemsMs, swapMs;
Ogre::String spikeStep;
double spikeMs;
size_t batches, tris;
int pages;
size_t npcs, cars;
@@ -5440,6 +5492,14 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
std::vector<Worst> worst; /* top-10, descending */
double sumMs = 0.0, maxMs = 0.0;
double sumRender = 0.0, sumSystems = 0.0, sumSwap = 0.0;
double sumViewport = 0.0, sumCull = 0.0;
double sumFind = 0.0, sumQueueMain = 0.0, sumQueueOverlay = 0.0;
double sumFindPre = 0.0, sumFindObjs = 0.0, sumFindPost = 0.0;
double sumPhysSync = 0.0, sumPhysStep = 0.0, sumPhysBodies = 0.0;
size_t sumRenderables = 0;
/* Histogram of the slowest systems step on frames whose systems
* span exceeded 8 ms (what the streaming/generation spikes are). */
std::map<Ogre::String, int> spikeSteps;
size_t sumBatches = 0, maxBatches = 0, sumTris = 0, maxTris = 0;
DriveBenchmarkListener(EditorApp *a)
@@ -5451,6 +5511,7 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
{
failed = true;
failReason = reason;
std::cout << "[benchmark-drive] FAIL: " << reason << std::endl;
app->getRoot()->queueEndRendering();
return true;
}
@@ -5563,6 +5624,218 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
Ogre::Vector3::UNIT_X));
}
/* Render-pass sub-split: the time between preViewportUpdate and
* postViewportUpdate on the main window is the draw submission
* (culling happened just before); renderPass - viewport is the
* scene-graph update + cull share. */
void preViewportUpdate(
const Ogre::RenderTargetViewportEvent &evt) override
{
if (evt.source->getTarget() != app->getRenderWindow())
return;
vpTick = std::chrono::steady_clock::now();
/* Probe from HERE, not frameRenderingQueued: OGRE 1.x keeps
* frame listeners in a std::set ordered by POINTER, and this
* stack-allocated listener fires BEFORE EditorApp's, so a
* probe there sees last frame's post-render clean state and
* measures nothing (all transforms already derived). Here
* we are after the systems (dirty state) and right before
* the real find. */
/* Probe when NPCs are actually spawned: the drive reaches
* the town (npcs_high > 0) only after ~120 recorded frames;
* probing at 30 measured a no-NPC scene and missed the
* entire character cost (2026-10-17). */
if (recorded == 200) {
probeFindCosts();
probeEntityCosts();
}
if (recorded >= 180 && recorded <= 200)
probeColdWalkFrame();
if (recorded == 201 && coldFrames > 0) {
std::cout << "[benchmark-drive] cold walk avg "
<< coldWalkUs / coldFrames
<< " us | hot second walk "
<< coldWalkNoBoundsUs / coldFrames
<< " us (" << coldFrames << " frames)"
<< std::endl;
std::vector<std::pair<Ogre::String,
std::pair<double, int> > >
srows(coldSubtree.begin(), coldSubtree.end());
std::sort(srows.begin(), srows.end(),
[](const auto &a, const auto &b) {
return a.second.first > b.second.first;
});
for (size_t i = 0; i < srows.size() && i < 12; i++)
std::cout << " cold subtree "
<< srows[i].first << " "
<< srows[i].second.first /
srows[i].second.second
<< " us avg ("
<< srows[i].second.second
<< " samples)" << std::endl;
}
}
void postViewportUpdate(
const Ogre::RenderTargetViewportEvent &evt) override
{
if (evt.source->getTarget() == app->getRenderWindow())
viewportMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() -
vpTick)
.count();
}
/* Cull span: findVisibleObjects for the main window's render.
* Gated on the viewport target (not just IRS_NONE): the water
* reflection RTT pass re-enters with its own viewport and must
* not reset or pollute the main-pass accounting. */
void preFindVisibleObjects(Ogre::SceneManager *,
Ogre::SceneManager::IlluminationRenderStage irs,
Ogre::Viewport *vp) override
{
if (irs == Ogre::SceneManager::IRS_NONE && vp &&
vp->getTarget() == app->getRenderWindow()) {
if (dbgPreFind == 0)
std::cout << "[benchmark-drive] find ctx: vp "
<< vp->getTarget()->getName() << "/"
<< (vp->getCamera()
? vp->getCamera()->getName()
: "?")
<< " resolvedCam "
<< (resolveCamera()
? resolveCamera()->getName()
: "?")
<< " displayNodes "
<< app->getSceneManager()
->getDisplaySceneNodes()
<< std::endl;
inMainFind = true;
findTick = std::chrono::steady_clock::now();
/* Per-frame counters reset: the main render's cull
* is the first scene activity of the render pass. */
renderables = 0;
hadRenderable = false;
prevRendKey.clear();
findPreMs = findObjsMs = findPostMs = 0.0;
queueMainMs = 0.0;
queueOverlayMs = 0.0;
++dbgPreFind;
}
}
void postFindVisibleObjects(
Ogre::SceneManager *,
Ogre::SceneManager::IlluminationRenderStage irs,
Ogre::Viewport *vp) override
{
if (irs == Ogre::SceneManager::IRS_NONE && vp &&
vp->getTarget() == app->getRenderWindow()) {
inMainFind = false;
auto now = std::chrono::steady_clock::now();
findMs = std::chrono::duration<double, std::milli>(
now - findTick)
.count();
if (hadRenderable) {
findObjsMs =
std::chrono::duration<double, std::milli>(
lastRendTick - firstRendTick)
.count();
findPostMs =
std::chrono::duration<double, std::milli>(
now - lastRendTick)
.count();
if (!prevRendKey.empty()) {
rendAttr[prevRendKey].first += findPostMs;
rendAttr[prevRendKey].second += 1;
prevRendKey.clear();
}
}
++dbgPostFind;
}
}
/* Per-queue-group render execution span. Queues render in order,
* so a single tick works; only the main and overlay groups are
* interesting (geometry vs ImGui/HUD). */
void renderQueueStarted(uint8_t queueGroupId, const Ogre::String &,
bool &) override
{
if (queueGroupId == Ogre::RENDER_QUEUE_MAIN ||
queueGroupId == Ogre::RENDER_QUEUE_OVERLAY)
queueTick = std::chrono::steady_clock::now();
}
void renderQueueEnded(uint8_t queueGroupId, const Ogre::String &,
bool &) override
{
if (queueGroupId != Ogre::RENDER_QUEUE_MAIN &&
queueGroupId != Ogre::RENDER_QUEUE_OVERLAY)
return;
double ms = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - queueTick)
.count();
if (queueGroupId == Ogre::RENDER_QUEUE_MAIN)
queueMainMs = ms;
else
queueOverlayMs = ms;
}
/* Visible renderable count (queued this frame) plus the find-window
* sub-split timestamps. */
bool renderableQueued(Ogre::Renderable *rend, uint8_t, uint16_t,
Ogre::Technique **, Ogre::RenderQueue *) override
{
/* Ignore shadow/RTT passes: only the main window's find
* window is measured (see inMainFind). */
if (!inMainFind)
return true;
auto now = std::chrono::steady_clock::now();
if (renderables == 0) {
firstRendTick = now;
findPreMs = std::chrono::duration<double, std::milli>(
now - findTick)
.count();
} else if (!prevRendKey.empty()) {
rendAttr[prevRendKey].first +=
std::chrono::duration<double, std::milli>(
now - lastRendTick)
.count();
rendAttr[prevRendKey].second += 1;
}
lastRendTick = now;
prevRendKey = rend->getMaterial()
? rend->getMaterial()->getName()
: "<none>";
hadRenderable = true;
++renderables;
++dbgRendCalls;
return true;
}
/* Keep the player character on the tour so the spawn tiers
* (NpcDetailSystem/NpcTrafficSystem measure against the player
* camera, which PlayerControllerSystem re-derives from the player
* before the NPC systems run — pinning only the camera leaves the
* tiers anchored at the spawn point) and the character's own
* physics/animation cost follow the drive. */
void teleportPlayerOntoTour()
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return;
TransformComponent &t = player.get_mut<TransformComponent>();
if (!t.node)
return;
Ogre::Vector3 p = RoadPath::polylineAt(tour, dist);
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
if (ro)
p = ro->worldToRender(p.x, p.y, p.z);
t.node->setPosition(p + Ogre::Vector3(0.0f, 0.1f, 0.0f));
t.node->setOrientation(Ogre::Quaternion(
Ogre::Degree(camYawDeg), Ogre::Vector3::UNIT_Y));
}
int loadedPageCount() const
{
TerrainSystem *ts = TerrainSystem::getInstance();
@@ -5577,6 +5850,245 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
return n;
}
/* One-time census: the default scene manager's _findVisibleObjects
* walks the ENTIRE scene graph every frame (SceneNode::
* _findVisibleObjects recurses from the root), so the node and
* attached-object counts directly predict the find_ms cost. */
void printSceneGraphStats() const
{
Ogre::SceneManager *sm = app->getSceneManager();
if (!sm || !sm->getRootSceneNode())
return;
size_t nodes = 0;
std::map<Ogre::String, size_t> byType;
std::function<void(Ogre::SceneNode *)> walk =
[&](Ogre::SceneNode *n) {
++nodes;
auto it = n->getAttachedObjectIterator();
while (it.hasMoreElements())
++byType[it.getNext()->getMovableType()];
for (Ogre::Node *child : n->getChildren())
walk(static_cast<Ogre::SceneNode *>(child));
};
walk(sm->getRootSceneNode());
std::cout << "[benchmark-drive] scene graph: " << nodes
<< " nodes |";
for (const auto &kv : byType)
std::cout << " " << kv.first << " x" << kv.second;
std::cout << std::endl;
}
/* Cold-walk probe state: the real find pays once-per-frame,
* post-systems state (dirty transforms, cold caches); the hot-loop
* replication measures ~0.33 ms while the real find window takes
* ~2.7/13.7 ms. Walk once per frame over a span of frames with a
* dummy queue + real VisibleObjectsBoundsInfo and average. */
Ogre::VisibleObjectsBoundsInfo coldBounds;
int coldFrames = 0;
double coldWalkUs = 0.0;
double coldWalkNoBoundsUs = 0.0;
/* Per-root-child cold walk times: one subtree measured FIRST each
* probe frame (rotating) while the frame's data is still cold,
* then the full walk. */
std::map<Ogre::String, std::pair<double, int> > coldSubtree;
void probeColdWalkFrame()
{
Ogre::SceneManager *sm = app->getSceneManager();
Ogre::Camera *cam = resolveCamera();
if (!sm || !cam || !sm->getRootSceneNode())
return;
/* The REAL render queue: prepareRenderQueue() clears it at
* the start of _renderScene, so anything we queue here is
* wiped before the real find fills it — safe. */
Ogre::RenderQueue *rq = sm->getRenderQueue();
Ogre::SceneNode *root = sm->getRootSceneNode();
/* One rotating subtree, cold. */
const auto &children = root->getChildren();
if (!children.empty()) {
size_t idx = (size_t)coldFrames % children.size();
Ogre::SceneNode *sn = static_cast<Ogre::SceneNode *>(
children[idx]);
Ogre::String key =
(sn->getName().empty()
? "<unnamed>"
: sn->getName()) +
" #" + std::to_string(idx) + " x" +
std::to_string(sn->numAttachedObjects());
auto t0 = std::chrono::steady_clock::now();
sn->_findVisibleObjects(cam, rq, &coldBounds, true,
false, false);
auto t1 = std::chrono::steady_clock::now();
coldBounds.reset();
coldSubtree[key].first +=
std::chrono::duration<double, std::micro>(t1 - t0)
.count();
coldSubtree[key].second += 1;
}
auto t0 = std::chrono::steady_clock::now();
root->_findVisibleObjects(cam, rq, &coldBounds, true, false,
false);
auto t1 = std::chrono::steady_clock::now();
coldWalkUs +=
std::chrono::duration<double, std::micro>(t1 - t0).count();
coldBounds.reset();
t0 = std::chrono::steady_clock::now();
root->_findVisibleObjects(cam, rq, &coldBounds, true, false,
false);
t1 = std::chrono::steady_clock::now();
coldWalkNoBoundsUs +=
std::chrono::duration<double, std::micro>(t1 - t0).count();
++coldFrames;
}
/* One-shot replication of the whole findVisibleObjects work
* against a dummy render queue: total walk cost, per root-child
* subtree cost, and per-entity _updateRenderQueue cost. This
* decomposes find_objs into walk vs per-object queueing. */
void probeFindCosts()
{
Ogre::SceneManager *sm = app->getSceneManager();
Ogre::Camera *cam = resolveCamera();
if (!sm || !cam || !sm->getRootSceneNode())
return;
static const int iters = 20;
Ogre::RenderQueue rq; /* dummy sink, never rendered */
Ogre::SceneNode *root = sm->getRootSceneNode();
std::cout << "[benchmark-drive] find cost probe (us over "
<< iters << " iters):" << std::endl;
auto timeWalk = [&](Ogre::SceneNode *n) {
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < iters; i++) {
n->_findVisibleObjects(cam, &rq, nullptr, true,
false, false);
rq.clear();
}
return std::chrono::duration<double, std::micro>(
std::chrono::steady_clock::now() - t0)
.count() /
iters;
};
double total = timeWalk(root);
std::cout << " total walk " << total << " us" << std::endl;
std::vector<std::pair<Ogre::String, double> > subs;
for (Ogre::Node *child : root->getChildren()) {
Ogre::SceneNode *sn =
static_cast<Ogre::SceneNode *>(child);
subs.push_back(std::make_pair(
sn->getName() + " x" +
std::to_string(
sn->numAttachedObjects()),
timeWalk(sn)));
}
std::sort(subs.begin(), subs.end(),
[](const auto &a, const auto &b) {
return a.second > b.second;
});
for (size_t i = 0; i < subs.size() && i < 15; i++)
std::cout << " subtree " << subs[i].first << " "
<< subs[i].second << " us" << std::endl;
/* Per-entity _updateRenderQueue cost. */
std::vector<std::pair<Ogre::String, double> > ents;
auto it = sm->getMovableObjectIterator("Entity");
while (it.hasMoreElements()) {
Ogre::Entity *ent =
static_cast<Ogre::Entity *>(it.getNext());
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 50; i++) {
ent->_updateRenderQueue(&rq);
rq.clear();
}
double us =
std::chrono::duration<double, std::micro>(
std::chrono::steady_clock::now() - t0)
.count() /
50;
if (us > 5.0) {
Ogre::String mat =
ent->getNumSubEntities()
? ent->getSubEntity(0)
->getMaterialName()
: "<none>";
ents.push_back(std::make_pair(
mat + " (" + ent->getName() + ")", us));
}
}
std::sort(ents.begin(), ents.end(),
[](const auto &a, const auto &b) {
return a.second > b.second;
});
for (size_t i = 0; i < ents.size() && i < 15; i++)
std::cout << " entity _updateRenderQueue "
<< ents[i].first << " " << ents[i].second
<< " us" << std::endl;
}
/* One-shot decomposition of the per-renderable queue cost: times
* the three public steps of RenderQueue::processVisibleObject on
* every Entity in the scene (bbox derive, sphere derive, camera
* notify) so we can see which step the ~1 ms/character-part cost
* lives in. Runs once shortly after recording starts. */
void probeEntityCosts()
{
Ogre::SceneManager *sm = app->getSceneManager();
Ogre::Camera *cam = resolveCamera();
if (!sm || !cam)
return;
static const int iters = 50;
std::cout << "[benchmark-drive] entity cost probe "
"(us/call over "
<< iters << " iters):" << std::endl;
auto it = sm->getMovableObjectIterator("Entity");
while (it.hasMoreElements()) {
Ogre::Entity *ent =
static_cast<Ogre::Entity *>(it.getNext());
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < iters; i++)
ent->getWorldBoundingBox(true);
auto t1 = std::chrono::steady_clock::now();
for (int i = 0; i < iters; i++)
ent->getWorldBoundingSphere(true);
auto t2 = std::chrono::steady_clock::now();
for (int i = 0; i < iters; i++)
ent->_notifyCurrentCamera(cam);
auto t3 = std::chrono::steady_clock::now();
double bboxUs =
std::chrono::duration<double, std::micro>(t1 - t0)
.count() /
iters;
double sphereUs =
std::chrono::duration<double, std::micro>(t2 - t1)
.count() /
iters;
double camUs =
std::chrono::duration<double, std::micro>(t3 - t2)
.count() /
iters;
Ogre::String mat =
ent->getNumSubEntities()
? ent->getSubEntity(0)->getMaterialName()
: "<none>";
std::cout << " " << ent->getName() << " mat " << mat
<< " skel "
<< (ent->hasSkeleton()
? std::to_string(
ent->getSkeleton()
->getNumBones())
: "no")
<< " sub " << ent->getNumSubEntities()
<< " lods " << ent->getMesh()->getNumLodLevels()
<< (ent->getMesh()->hasManualLodLevel()
? " manualLOD"
: "")
<< (ent->getAlwaysUpdateMainSkeleton()
? " alwaysUpdSkel"
: "")
<< " | bbox " << bboxUs << " sphere "
<< sphereUs << " cam " << camUs
<< std::endl;
}
}
void noteWorst(const Worst &w)
{
worst.push_back(w);
@@ -5623,6 +6135,11 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
return fail("cannot open CSV: " + csvPath);
csv << "frame,dist_m,x,y,z,yaw_deg,wall_ms,"
"render_pass_ms,systems_ms,swap_wait_ms,"
"viewport_ms,cull_ms,"
"find_ms,find_pre_ms,find_objs_ms,"
"find_post_ms,queue_main_ms,queue_overlay_ms,"
"renderables,spike_step_ms,spike_step,"
"phys_sync_ms,phys_step_ms,phys_bodies_ms,"
"batches,tris,terrain_pages,npcs_high,"
"cars_active\n";
std::cout << "[benchmark-drive] town ready: tour "
@@ -5631,7 +6148,13 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
<< pop->countPopulationNpcs() << ", fleet "
<< traffic->carCount() << " car(s)"
<< std::endl;
/* Noon: NPCs are out on errands/work instead of
* asleep indoors (the default ~2 AM start makes
* nearly everyone spawn-ineligible). */
if (GameClockSystem::getInstance())
GameClockSystem::getInstance()->setTime(0, 12.0f);
updateCameraPose();
teleportPlayerOntoTour();
phase = 1;
lastTick = std::chrono::steady_clock::now();
return true;
@@ -5644,11 +6167,9 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
lastTick = now;
if (phase == 1) { /* settle at the tour start */
if (frame > settleFrames + 100000)
return fail("settle watchdog");
static int settle = 0;
if (++settle < settleFrames)
return true;
printSceneGraphStats();
phase = 2;
lastTick = std::chrono::steady_clock::now();
return true;
@@ -5661,6 +6182,7 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
if (dist > tourLength)
dist = tourLength;
updateCameraPose();
teleportPlayerOntoTour();
const Ogre::RenderTarget::FrameStats &st =
app->getRenderWindow()->getStatistics();
@@ -5672,6 +6194,15 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
double renderMs = app->getLastRenderPassMs();
double systemsMs = app->getLastSystemsMs();
double swapMs = app->getLastSwapWaitMs();
double cullMs = renderMs - viewportMs;
if (cullMs < 0.0)
cullMs = 0.0;
double spikeMs = app->getLastSpikeStepMs();
Ogre::String spikeStep = app->getLastSpikeStep();
EditorPhysicsSystem *physSys = app->getEditorPhysicsSystem();
double physSyncMs = physSys ? physSys->getLastSyncMs() : 0.0;
double physStepMs = physSys ? physSys->getLastStepMs() : 0.0;
double physBodiesMs = physSys ? physSys->getLastBodiesMs() : 0.0;
++recorded;
wallTimes.push_back(wallMs);
@@ -5681,6 +6212,20 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
sumRender += renderMs;
sumSystems += systemsMs;
sumSwap += swapMs;
sumViewport += viewportMs;
sumCull += cullMs;
sumFind += findMs;
sumQueueMain += queueMainMs;
sumQueueOverlay += queueOverlayMs;
sumFindPre += findPreMs;
sumFindObjs += findObjsMs;
sumFindPost += findPostMs;
sumPhysSync += physSyncMs;
sumPhysStep += physStepMs;
sumPhysBodies += physBodiesMs;
sumRenderables += renderables;
if (systemsMs > 8.0 && !spikeStep.empty())
++spikeSteps[spikeStep];
sumBatches += st.batchCount;
if (st.batchCount > maxBatches)
maxBatches = st.batchCount;
@@ -5690,16 +6235,27 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
csv << recorded << ',' << dist << ',' << camPos.x << ','
<< camPos.y << ',' << camPos.z << ',' << camYawDeg << ','
<< wallMs << ',' << renderMs << ',' << systemsMs << ','
<< swapMs << ',' << st.batchCount << ','
<< swapMs << ',' << viewportMs << ',' << cullMs << ','
<< findMs << ',' << findPreMs << ',' << findObjsMs
<< ',' << findPostMs << ',' << queueMainMs << ','
<< queueOverlayMs
<< ',' << renderables << ',' << spikeMs << ','
<< spikeStep << ','
<< physSyncMs << ',' << physStepMs << ',' << physBodiesMs
<< ','
<< st.batchCount << ','
<< st.triangleCount << ',' << pages << ',' << npcs << ','
<< cars << '\n';
Worst w{ wallMs, recorded, dist, camPos,
renderMs, systemsMs, swapMs,
spikeStep, spikeMs,
(size_t)st.batchCount, (size_t)st.triangleCount,
pages, npcs, cars };
if (worst.size() < 10 || wallMs > worst.back().ms)
noteWorst(w);
/* Probes run from preViewportUpdate (see comment there). */
if (recorded % 600 == 0)
std::cout << "[benchmark-drive] frame " << recorded
<< " dist " << dist << "/" << tourLength
@@ -5727,18 +6283,74 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
<< " | spans avg render " << sumRender / recorded
<< " systems " << sumSystems / recorded
<< " swap " << sumSwap / recorded
<< " | render split avg viewport "
<< sumViewport / recorded
<< " cull " << sumCull / recorded
<< " | viewport split avg find "
<< sumFind / recorded << " queueMain "
<< sumQueueMain / recorded << " queueOverlay "
<< sumQueueOverlay / recorded
<< " renderables " << sumRenderables / recorded
<< " | find split avg pre "
<< sumFindPre / recorded << " objs "
<< sumFindObjs / recorded << " post "
<< sumFindPost / recorded
<< " | physics split avg sync "
<< sumPhysSync / recorded << " step "
<< sumPhysStep / recorded << " bodies "
<< sumPhysBodies / recorded
<< " | batches avg " << sumBatches / recorded
<< " max " << maxBatches
<< " | tris avg " << sumTris / recorded
<< " max " << maxTris << std::endl;
std::cout << "[benchmark-drive] systems spikes (>8 ms)"
" by step:";
for (const auto &kv : spikeSteps)
std::cout << " " << kv.first << " x" << kv.second;
std::cout << std::endl;
/* Per-material find_objs attribution, top-10 by
* total ms over the run. */
{
std::cout << "[benchmark-drive] dbg: rendCalls "
<< dbgRendCalls << " preFind "
<< dbgPreFind << " postFind "
<< dbgPostFind << " rendAttr "
<< rendAttr.size() << std::endl;
std::vector<std::pair<Ogre::String,
std::pair<double, size_t> > >
rows(rendAttr.begin(), rendAttr.end());
std::sort(rows.begin(), rows.end(),
[](const auto &a, const auto &b) {
return a.second.first >
b.second.first;
});
std::cout << "[benchmark-drive] find_objs by "
"material (total ms, count, avg "
"us):"
<< std::endl;
for (size_t i = 0; i < rows.size() && i < 10;
++i) {
const auto &r = rows[i];
std::cout << " " << r.first << " "
<< r.second.first << " "
<< r.second.second << " "
<< (r.second.second
? r.second.first /
r.second.second *
1000.0
: 0.0)
<< std::endl;
}
}
std::cout << "[benchmark-drive] worst frames "
"(frame dist m | wall render systems swap | "
"batches tris pages npcs cars | pos):"
"(frame dist m | wall render systems swap "
"| spike | batches tris pages npcs cars | pos):"
<< std::endl;
for (const Worst &w : worst) {
std::cout << " #" << w.frame << " " << w.dist
<< "m | " << w.ms << " " << w.renderMs
<< " " << w.systemsMs << " " << w.swapMs
<< " | " << w.spikeStep << " " << w.spikeMs
<< " | " << w.batches << " " << w.tris
<< " " << w.pages << " " << w.npcs << " "
<< w.cars << " | (" << w.pos.x << ", "
@@ -5747,6 +6359,7 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
}
std::cout << "[benchmark-drive] CSV: " << csvPath
<< std::endl;
printSceneGraphStats();
std::cout << "[benchmark-drive] PASS" << std::endl;
done = true;
app->getRoot()->queueEndRendering();
@@ -5937,6 +6550,10 @@ int main(int argc, char *argv[])
bool testTrafficYield = false;
bool testTrafficTown = false;
bool benchmark = false;
bool benchmarkDrive = false;
float driveSpeed = 13.0f;
int driveFrames = 6000;
Ogre::String driveCsv = "benchmark_drive.csv";
bool sceneArgGiven = false;
Ogre::String shotFile;
int shotFrame = -1;
@@ -5998,6 +6615,17 @@ int main(int argc, char *argv[])
testTrafficTown = true;
} else if (arg == "--benchmark") {
benchmark = true;
} else if (arg == "--benchmark-drive") {
benchmarkDrive = true;
} else if (arg == "--drive-speed" && i + 1 < argc) {
driveSpeed = (float)atof(argv[i + 1]);
i += 1;
} else if (arg == "--drive-frames" && i + 1 < argc) {
driveFrames = atoi(argv[i + 1]);
i += 1;
} else if (arg == "--drive-csv" && i + 1 < argc) {
driveCsv = argv[i + 1];
i += 1;
} else if (arg == "--debug-buoyancy") {
app.setDebugBuoyancy(true);
} else if (arg == "--auto-comply") {
@@ -6108,6 +6736,9 @@ int main(int argc, char *argv[])
/* The benchmark measures the generated town. */
if (benchmark && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The drive benchmark tours the town road graph. */
if (benchmarkDrive && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
app.setHeadless(headless);
if (headless) {
/* Headless frames take ~1ms of wall time; pin a 60Hz
@@ -6158,6 +6789,13 @@ int main(int argc, char *argv[])
traffic->spawnDistanceSq = 110.0f * 110.0f;
traffic->despawnDistanceSq = 165.0f * 165.0f;
}
/* Compile all catalog character skinning shaders now
* (startup) instead of hitching on the first NPC spawn.
* Skip in headless runs: tests/benchmarks would pay the
* prewarm cost for shaders they never present. */
if (!app.isHeadless())
CharacterSlotSystem::prewarmShaders();
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
"Shift = run, E = use door / drive a sedan, "
"hold E = exit the vehicle, Space = handbrake "
@@ -6247,6 +6885,25 @@ int main(int argc, char *argv[])
&benchmarkListener.batchCounter);
}
DriveBenchmarkListener driveBenchmarkListener(&app);
if (benchmarkDrive) {
driveBenchmarkListener.speed = driveSpeed;
driveBenchmarkListener.maxFrames = driveFrames;
driveBenchmarkListener.csvPath = driveCsv;
app.getRoot()->addFrameListener(&driveBenchmarkListener);
app.getRenderWindow()->addListener(
&driveBenchmarkListener);
/* Viewport sub-split hooks (cull span, per-queue-group
* execution, visible renderable count) — only in
* benchmark mode so normal play pays nothing. */
app.getSceneManager()->addListener(
&driveBenchmarkListener);
app.getSceneManager()->addRenderQueueListener(
&driveBenchmarkListener);
app.getSceneManager()->getRenderQueue()
->setRenderableListener(&driveBenchmarkListener);
}
/* Window glass near/far swap (always on). */
SedanGlassLodListener glassListener(&app);
app.getRoot()->addFrameListener(&glassListener);
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Extract the sedan1 vehicle subtree from demo_scene_vehicletest.json
into prefabs/sedan-traffic.json (demo-taxi phase 5.4).
The traffic system instantiates this prefab for population-owned cars
(NpcTrafficSystem). Re-run after tuning sedan1 in the vehicletest
scene:
python3 extract_sedan_prefab.py
The extraction renames the subtree (sedan1 -> sedan-traffic) and zeros
the root transform so PrefabSystem::createInstance places the car
exactly at the requested spawn position. Everything else (vehicle,
collider, rigidBody, doors/hood/trunk children with their seat
actuators) carries over verbatim.
"""
import json
import os
HERE = os.path.dirname(os.path.abspath(__file__))
SCENE = os.path.join(HERE, "demo_scene_vehicletest.json")
OUT = os.path.join(HERE, "prefabs", "sedan-traffic.json")
def entity_name(e):
n = e.get("name")
return n.get("name") if isinstance(n, dict) else n
def rename(e, old, new):
n = e.get("name")
if isinstance(n, dict):
n["name"] = n["name"].replace(old, new)
for c in e.get("children", []):
rename(c, old, new)
def main():
scene = json.load(open(SCENE))
sedan = None
for e in scene["entities"]:
if entity_name(e) == "sedan1":
sedan = e
break
if sedan is None:
raise SystemExit("sedan1 not found in " + SCENE)
prefab = json.loads(json.dumps(sedan)) # deep copy
rename(prefab, "sedan1", "sedan-traffic")
prefab["transform"]["position"] = {"x": 0.0, "y": 0.0, "z": 0.0}
prefab["transform"]["rotation"] = {"w": 1.0, "x": 0.0, "y": 0.0,
"z": 0.0}
prefab["transform"]["scale"] = {"x": 1.0, "y": 1.0, "z": 1.0}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w") as f:
json.dump(prefab, f, indent=1, sort_keys=True)
f.write("\n")
print("wrote", OUT)
if __name__ == "__main__":
main()
@@ -0,0 +1,392 @@
{
"children": [
{
"actuator": {
"actionNames": [
"vehicle_seat_rl"
],
"height": 2.0,
"radius": 1.2
},
"children": [],
"name": {
"name": "sedan-traffic_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_rr"
],
"height": 2.0,
"radius": 1.2
},
"children": [],
"name": {
"name": "sedan-traffic_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_driver"
],
"height": 2.0,
"radius": 1.2
},
"children": [],
"name": {
"name": "sedan-traffic_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_passenger"
],
"height": 2.0,
"radius": 1.2
},
"children": [],
"name": {
"name": "sedan-traffic_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
}
}
},
{
"children": [],
"name": {
"name": "sedan-traffic_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": "sedan-traffic_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"
},
"id": 500,
"name": {
"name": "sedan-traffic"
},
"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": 0.0,
"y": 0.0,
"z": 0.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": {
"antiRollBarStiffness": 2000.0,
"cameraDistance": 6.0,
"cameraHeight": 1.6,
"centerOfMassOffset": {
"x": 0.0,
"y": -0.45,
"z": 0.0
},
"downforceCoefficient": 35.0,
"engineBrake": 0.0,
"maxPitchRollAngleDeg": 60.0,
"maxTorque": 300.0,
"rearSteer": false,
"seatCharacterScale": 1.0,
"seatOffset": {
"x": 0.48,
"y": -0.28,
"z": 0.2012
},
"seats": [
{
"driver": true,
"name": "driver",
"offset": {
"x": 0.48,
"y": -0.28,
"z": 0.2012
}
},
{
"driver": false,
"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
}
}
],
"steeringRatio": 4.0,
"steeringWheelMeshName": "sedan-v2-steering-wheel.mesh",
"steeringWheelOffset": {
"x": 0.4915,
"y": 0.0807,
"z": 0.601
},
"steeringWheelTiltDeg": 25.0,
"tireFrictionLateral": 3.0,
"tireFrictionLongitudinal": 1.3,
"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
}
]
}
}
@@ -0,0 +1,500 @@
#include "AiDriverSystem.hpp"
#include <algorithm>
#include <cmath>
#include <OgreSceneNode.h>
#include "../EditorApp.hpp"
#include "../GameMode.hpp"
#include "PhysicsSystem.hpp"
#include "RoadSystem.hpp"
#include "TerrainSystem.hpp"
#include "../components/AiDriver.hpp"
#include "../components/PlayerController.hpp"
#include "../components/RigidBody.hpp"
#include "../components/RoadGraph.hpp"
#include "../components/Transform.hpp"
#include "../components/Vehicle.hpp"
#include "../physics/physics.h"
#include "../roadlib/RoadPath.hpp"
AiDriverSystem *AiDriverSystem::ms_instance = nullptr;
AiDriverSystem::AiDriverSystem(flecs::world &world, EditorApp *editorApp)
: m_world(world)
, m_editorApp(editorApp)
{
ms_instance = this;
}
AiDriverSystem::~AiDriverSystem()
{
ms_instance = nullptr;
}
void AiDriverSystem::classifyJunctions(AiDriverComponent &ai,
const RoadGraph &graph, float snapDist)
{
float snapSq = snapDist * snapDist;
for (AiRoutePoint &p : ai.route) {
if (p.junctionNodeId >= 0)
continue;
int idx = RoadPath::nearestNode(graph, p.position.x,
p.position.z);
if (idx < 0)
continue;
const RoadNode &node = graph.nodes[idx];
float dx = p.position.x - node.position.x;
float dz = p.position.z - node.position.z;
if (dx * dx + dz * dz > snapSq)
continue;
if (graph.getNeighborIds(node.id).size() < 3)
continue;
p.junctionNodeId = node.id;
p.junctionCenter = node.position;
}
}
void AiDriverSystem::update(float deltaTime)
{
if (!editScene::isGameMode())
return;
JoltPhysicsWrapper *physics =
m_editorApp && m_editorApp->getEditorPhysicsSystem() ?
m_editorApp->getEditorPhysicsSystem()
->getPhysicsWrapper() :
nullptr;
auto distXZ = [](const Ogre::Vector3 &a, const Ogre::Vector3 &b) {
float dx = a.x - b.x;
float dz = a.z - b.z;
return std::sqrt(dx * dx + dz * dz);
};
/* Drop the drive state of despawned or player-taken cars so a
* stale junction reservation cannot block an intersection
* forever. */
for (auto it = m_states.begin(); it != m_states.end();) {
flecs::entity e = m_world.entity(it->first);
if (!e.is_alive() || !e.has<AiDriverComponent>() ||
e.has<PlayerControlledComponent>())
it = m_states.erase(it);
else
++it;
}
/* Snapshot every vehicle once per frame for the junction
* occupancy check (no nested queries inside the driver loop). */
struct VehicleSnapshot {
flecs::entity_t id;
float x, z;
};
std::vector<VehicleSnapshot> vehicles;
m_world.query<VehicleComponent, TransformComponent>().each(
[&](flecs::entity e, VehicleComponent &, TransformComponent &t) {
if (!t.node)
return;
Ogre::Vector3 p = t.node->_getDerivedPosition();
vehicles.push_back({ e.id(), p.x, p.z });
});
m_world.query<AiDriverComponent, VehicleComponent,
TransformComponent, RigidBodyComponent>()
.each([&](flecs::entity entity, AiDriverComponent &ai,
VehicleComponent &vehicle, TransformComponent &t,
RigidBodyComponent &rigidBody) {
/* Never fight the player for the wheel. */
if (entity.has<PlayerControlledComponent>())
return;
if (ai.route.empty() || !t.node) {
vehicle.inputForward = 0.0f;
vehicle.inputBrake = 1.0f;
return;
}
/* Lazy junction classification against the terrain
* road graph (exterior scenes); flat test scenes
* pre-flag their junction points instead. */
if (!ai.junctionsClassified) {
ai.junctionsClassified = true;
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->getRoadSystem()) {
flecs::entity terrain =
ts->getRoadSystem()
->getTerrainEntity();
if (terrain.is_alive() &&
terrain.has<TerrainComponent>())
classifyJunctions(
ai,
terrain.get<TerrainComponent>()
.roadGraph,
junctionSnapDist);
}
}
const size_t n = ai.route.size();
Ogre::Vector3 pos = t.node->_getDerivedPosition();
Ogre::Vector3 fwd =
t.node->_getDerivedOrientation() *
Ogre::Vector3::UNIT_Z;
fwd.y = 0.0f;
if (fwd.squaredLength() < 1.0e-8f)
fwd = Ogre::Vector3::UNIT_Z;
fwd.normalise();
float speed = 0.0f;
if (physics && vehicle.constraintCreated &&
vehicle.constraint)
speed = physics->getVehicleForwardSpeed(
vehicle.constraint);
if (speed < 0.0f)
speed = 0.0f;
/* Traffic whisker (phase 5.2): three forward rays
* (nose, +/- whiskerSideAngleDeg) at bumper height,
* latched per car. Characters count (pedestrians
* brake cars), the own chassis is skipped and
* walkable surfaces (hill crests, aprons) are
* filtered by normal. */
DriveState &st = m_states[entity.id()];
if (physics && st.framesUntilWhisker-- <= 0) {
st.framesUntilWhisker = whiskerIntervalFrames;
float stopDist =
speed * speed / (2.0f * brakeDecel) +
followGap + 4.0f;
float side = Ogre::Degree(whiskerSideAngleDeg)
.valueRadians();
const float angs[3] = { 0.0f, side, -side };
float best = 1.0e9f;
for (int d = 0; d < 3; ++d) {
Ogre::Vector3 dir = fwd;
if (angs[d] != 0.0f)
dir = Ogre::Quaternion(
Ogre::Radian(angs[d]),
Ogre::Vector3::UNIT_Y) *
fwd;
Ogre::Vector3 from =
pos + dir *
whiskerForwardOffset +
Ogre::Vector3(0.0f,
whiskerHeight, 0.0f);
Ogre::Vector3 hitPos;
Ogre::Vector3 hitNormal;
JPH::BodyID hitBody;
if (physics->raycastQueryTraffic(
from,
from + dir * stopDist,
hitPos, hitNormal, hitBody,
rigidBody.bodyID,
walkableNormalY)) {
float dist =
(hitPos - from).length();
if (dist < best)
best = dist;
}
}
st.obstacleDistance = best;
}
/* Advance the waypoint cursor. The radius scales
* with speed so a fast car cuts the corner instead
* of orbiting the exact waypoint. */
float advanceR =
std::max(waypointRadius, speed * 0.5f);
for (size_t guard = 0; guard < n; ++guard) {
if (distXZ(pos, ai.route[ai.routeIndex].position) >=
advanceR)
break;
if (ai.routeIndex + 1 >= n) {
if (!ai.loop)
break;
ai.routeIndex = 0;
++ai.lapsCompleted;
} else {
++ai.routeIndex;
}
}
/* End of a non-looping route: brake to a stop and
* park (the handbrake engages VehicleSystem's parked
* damping). */
if (!ai.loop && ai.routeIndex == n - 1 &&
distXZ(pos, ai.route[n - 1].position) < arriveRadius) {
ai.arrived = true;
vehicle.inputForward = 0.0f;
vehicle.inputRight = 0.0f;
vehicle.inputBrake = 1.0f;
vehicle.inputHandBrake = 1.0f;
return;
}
ai.arrived = false;
vehicle.inputHandBrake = 0.0f;
/* Pure pursuit: the lookahead point rides the
* polyline L meters ahead of the car. */
float L = std::max(lookaheadMin,
speed * lookaheadPerSpeed);
Ogre::Vector3 target = ai.route[ai.routeIndex].position;
float accum = distXZ(pos, ai.route[ai.routeIndex].position);
size_t idx = ai.routeIndex;
for (size_t guard = 0; guard < n && accum < L;
++guard) {
size_t next = idx + 1;
if (next >= n) {
if (!ai.loop)
break;
next = 0;
}
float seg =
distXZ(ai.route[idx].position,
ai.route[next].position);
if (seg > 1.0e-4f && accum + seg >= L) {
float f = (L - accum) / seg;
target = ai.route[idx].position +
(ai.route[next].position -
ai.route[idx].position) *
f;
break;
}
accum += seg;
idx = next;
target = ai.route[idx].position;
}
/* Signed heading error: crossY > 0 means the target
* sits at +X of the nose — the car's LEFT (the sedan
* doors name +X "front left") — and inputRight is
* positive for a nose-RIGHT turn, hence the sign. */
Ogre::Vector3 toT = target - pos;
toT.y = 0.0f;
float angleErr = 0.0f;
if (toT.squaredLength() > 1.0e-6f) {
toT.normalise();
float dot = std::max(-1.0f,
std::min(1.0f,
fwd.dotProduct(toT)));
float crossY =
fwd.z * toT.x - fwd.x * toT.z;
angleErr = std::atan2(crossY, dot);
}
float full =
Ogre::Degree(steerFullAngleDeg).valueRadians();
float steer = -angleErr / full;
steer = std::max(-1.0f, std::min(1.0f, steer));
/* Speed controller: slow for curvature, slow for the
* end of a non-looping route. */
float errDeg = Ogre::Radian(std::fabs(angleErr))
.valueDegrees();
float cornerFactor =
1.0f - 0.75f * std::min(1.0f,
errDeg /
cornerFullAngleDeg);
float targetSpeed = std::max(
minCornerSpeed, ai.cruiseSpeed * cornerFactor);
if (!ai.loop) {
float rem = distXZ(pos, ai.route[n - 1].position);
float stopDist =
std::max(arriveRadius * 2.0f,
speed * speed * 0.35f);
if (rem < stopDist)
targetSpeed = std::min(
targetSpeed,
std::max(1.2f, rem * 0.4f));
}
/* Junction priority (phase 5.3): slow to
* junctionSpeed inside the approach zone, glide to
* a stop at the stop line while the junction is
* not clear, take the reservation and cross when
* it is. Clear means: no other AI car holds the
* reservation for this junction and no vehicle
* (AI or player) sits within junctionRadius of
* the center — cars stopped at the stop line
* (unreserved yielders latched to this junction)
* do not count as occupants. */
float junctionCap = 1.0e9f;
if (st.junctionReserved) {
st.junctionHoldTime += deltaTime;
Ogre::Vector3 toC = st.junctionCenter - pos;
float ahead = fwd.x * toC.x + fwd.z * toC.z;
float dCenter = distXZ(pos, st.junctionCenter);
/* Release only once the center is BEHIND the car
* and junctionReleaseDist away — a plain distance
* check would drop the reservation on the
* approach leg (the car reserves from the
* approach-zone edge, farther out than the
* release distance). */
if ((ahead < -0.5f && dCenter > junctionReleaseDist) ||
st.junctionHoldTime > junctionHoldTimeout) {
st.junctionReserved = false;
st.junctionNodeId = -1;
} else if (dCenter < junctionApproachDist) {
junctionCap = junctionSpeed;
}
}
if (!st.junctionReserved) {
if (st.junctionNodeId < 0) {
/* Latch the next junction point ahead
* on the route. */
for (size_t k = 0; k < n; ++k) {
size_t j = ai.routeIndex + k;
if (j >= n) {
if (!ai.loop)
break;
j -= n;
}
if (ai.route[j].junctionNodeId >=
0) {
st.junctionNodeId =
ai.route[j].junctionNodeId;
st.junctionCenter =
ai.route[j].junctionCenter;
break;
}
}
}
if (st.junctionNodeId >= 0) {
float dCenter =
distXZ(pos, st.junctionCenter);
Ogre::Vector3 toC =
st.junctionCenter - pos;
float ahead = fwd.x * toC.x +
fwd.z * toC.z;
if (ahead < -0.5f) {
/* Passed the center without
* reserving (e.g. spawned past
* it): forget this junction so
* the next one can latch. */
st.junctionNodeId = -1;
} else if (dCenter <
junctionApproachDist) {
/* Clear means: no other AI car
* holds the reservation for this
* junction and no vehicle (AI or
* player) sits within
* junctionRadius of the center —
* cars stopped at the stop line
* (unreserved yielders latched to
* this junction) are not
* occupants. */
bool clear = true;
for (const auto &kv :
m_states) {
if (kv.first ==
entity.id())
continue;
if (kv.second.junctionReserved &&
kv.second.junctionNodeId ==
st.junctionNodeId) {
clear = false;
break;
}
}
float radSq =
junctionRadius *
junctionRadius;
for (const auto &v :
vehicles) {
if (!clear ||
v.id == entity.id())
continue;
float dx =
v.x - st.junctionCenter.x;
float dz =
v.z - st.junctionCenter.z;
if (dx * dx +
dz * dz >=
radSq)
continue;
/* A yielder stopped at
* this junction's stop
* line is not an
* occupant. */
auto sit =
m_states.find(v.id);
if (sit != m_states.end() &&
!sit->second.junctionReserved &&
sit->second.junctionNodeId ==
st.junctionNodeId)
continue;
clear = false;
}
if (clear) {
/* Reserve at the
* approach-zone edge,
* not the stop line:
* with two cars
* arriving at once the
* first one in the
* iteration order wins
* from the full zone
* out, so the loser has
* room to brake in. */
st.junctionReserved =
true;
st.junctionHoldTime =
0.0f;
junctionCap =
junctionSpeed;
} else {
/* Yield: brake to a stop
* at the stop line (sqrt
* profile = constant-
* deceleration stop, like
* the whisker clamp). */
float stopMargin =
dCenter -
junctionStopDist;
if (stopMargin < 0.0f)
stopMargin = 0.0f;
float allowed =
std::sqrt(2.0f * brakeDecel *
stopMargin) *
0.8f;
junctionCap = std::min(
junctionSpeed,
allowed);
}
}
}
}
targetSpeed = std::min(targetSpeed, junctionCap);
/* Whisker braking: glide to a stop followGap meters
* off the obstacle (stop-and-go gap keeping falls out
* of the clamp — the car creeps forward as the gap
* opens and holds when it closes). */
if (st.obstacleDistance < 1.0e8f) {
float allowed =
(st.obstacleDistance - followGap) *
0.8f;
if (allowed < 0.0f)
allowed = 0.0f;
targetSpeed = std::min(targetSpeed, allowed);
}
if (targetSpeed <= 0.0f && speed < 0.5f) {
/* Holding at a standstill (obstacle, a
* stopped car ahead or a junction yield):
* keep the brake on, the speed controller
* would otherwise coast. */
vehicle.inputForward = 0.0f;
vehicle.inputBrake = 1.0f;
} else if (speed < targetSpeed - 0.3f) {
vehicle.inputForward = std::min(
1.0f, 0.3f + (targetSpeed - speed) *
0.2f);
vehicle.inputBrake = 0.0f;
} else if (speed > targetSpeed + 0.3f) {
vehicle.inputForward = 0.0f;
vehicle.inputBrake = std::min(
1.0f, (speed - targetSpeed) * 0.4f);
} else {
vehicle.inputForward = 0.0f;
vehicle.inputBrake = 0.0f;
}
vehicle.inputRight = steer;
});
}
@@ -0,0 +1,179 @@
#ifndef EDITSCENE_AIDRIVERSYSTEM_HPP
#define EDITSCENE_AIDRIVERSYSTEM_HPP
#pragma once
#include <flecs.h>
#include <map>
#include <OgreVector3.h>
class EditorApp;
struct AiDriverComponent;
struct RoadGraph;
/**
* AI driver system (demo-taxi phase 5.1).
*
* Drives every entity with an AiDriverComponent along its route
* polyline: pure-pursuit steering (a speed-scaled lookahead point on
* the polyline) plus a proportional throttle/brake speed controller
* with curvature slowdown. A staggered three-ray traffic whisker
* (phase 5.2, raycastQueryTraffic — sees vehicles AND pedestrians,
* skips the own chassis, filters walkable surfaces by normal) clamps
* the target speed so the car glides to a stop followGap meters off
* whatever blocks the nose; stop-and-go gap keeping falls out of the
* clamp. The system writes the SAME
* VehicleComponent::inputForward/inputRight/inputBrake fields the
* player's VehicleControllerSystem writes — VehicleSystem stays the
* single consumer, so AI cars get identical brake/reverse protection,
* parked damping and righting behavior for free.
*
* Junction priority (phase 5.3): route points flagged with a
* junctionNodeId (see classifyJunctions) make the car slow to
* junctionSpeed inside junctionApproachDist and, at the approach-zone
* edge, either take the junction reservation (first-come-first-served
* in flecs iteration order, deterministic for a given world state) or
* yield with a constant-deceleration brake profile to a stop line
* junctionStopDist before the junction center. Clear means: no other
* AI car currently holds the junction reservation, and no vehicle (AI
* or player) sits within junctionRadius of the junction center — cars
* stopped AT the stop line are yielders and do not count as occupants.
* The reservation releases once the center is behind the car and
* junctionReleaseDist away, with a hold timeout breaking stale
* reservations; the whisker stays active throughout as the collision
* safety net.
*
* A vehicle carrying the player (PlayerControlledComponent) is never
* touched, so carjacking a traffic car just works. Steering is
* XZ-only; waypoint Y carries the road surface height for route
* builders but the physics keeps the car grounded.
*
* update() must run BEFORE VehicleSystem::prePhysicsUpdate so the
* inputs land in the same physics step.
*/
class AiDriverSystem
{
public:
AiDriverSystem(flecs::world &world, EditorApp *editorApp);
~AiDriverSystem();
/** Singleton access (same pattern as NpcMotionSystem). */
static AiDriverSystem *getInstance()
{
return ms_instance;
}
/** Per-frame update; game mode only. */
void update(float deltaTime);
/** Pure-pursuit lookahead: max(lookaheadMin, speed *
* lookaheadPerSpeed) meters along the route. */
float lookaheadMin = 5.0f;
float lookaheadPerSpeed = 0.8f;
/** A waypoint counts as reached this many meters before it
* (scaled up with speed so a fast car does not orbit the
* waypoint). */
float waypointRadius = 3.0f;
/** End-of-route stop distance for non-looping routes (m). */
float arriveRadius = 2.5f;
/** Heading error that maps to full steering input (degrees). */
float steerFullAngleDeg = 30.0f;
/** Heading error at which the speed controller bottoms out at
* minCornerSpeed (degrees). */
float cornerFullAngleDeg = 60.0f;
/** Slowest the speed controller drives in a corner (m/s). */
float minCornerSpeed = 3.0f;
/** Traffic whisker (phase 5.2): three forward rays (nose, +/-
* whiskerSideAngleDeg) at bumper height, cast every
* whiskerIntervalFrames (staggered by entity id) with the length
* of the stopping-distance estimate. A hit clamps the target
* speed so the car glides to a stop followGap meters off the
* obstacle — vehicles, the player's car AND pedestrians
* (raycastQueryTraffic sees character bodies; the own chassis is
* skipped; walkable surfaces like hill crests are filtered by
* normal). */
int whiskerIntervalFrames = 3;
/** Whisker ray height above the ground (m). */
float whiskerHeight = 0.55f;
/** Whisker start: this far ahead of the chassis origin along the
* nose (past the front bumper). */
float whiskerForwardOffset = 2.8f;
/** Side whiskers fan out by this angle (degrees). */
float whiskerSideAngleDeg = 18.0f;
/** Comfortable braking deceleration behind the stopping-distance
* estimate (m/s^2). */
float brakeDecel = 4.0f;
/** Bumper-to-obstacle gap the car glides to (m). */
float followGap = 4.5f;
/** Whisker hits with normal.y >= this are ground (hill crests,
* aprons), not obstacles. */
float walkableNormalY = 0.7f;
/** Junction priority (phase 5.3): a car slows to junctionSpeed
* once its next junction waypoint is within junctionApproachDist,
* and glides to a stop at a stop line junctionStopDist before the
* junction center while the junction is not clear. */
float junctionApproachDist = 30.0f;
/** Approach/crossing speed cap near a junction (m/s). */
float junctionSpeed = 7.0f;
/** Stop line: this many meters before the junction center. Must
* exceed junctionRadius so a waiting yielder's chassis stays out
* of the occupancy zone. */
float junctionStopDist = 9.0f;
/** Occupancy zone: any vehicle (AI or player) with its chassis
* center this close to the junction center blocks entry —
* cars stopped at a stop line (yielders) excepted. */
float junctionRadius = 7.5f;
/** The reservation releases once the holder is this far from the
* junction center with the center behind it (a plain distance
* check would drop the reservation during the approach leg, which
* starts farther out than this). */
float junctionReleaseDist = 14.0f;
/** Stale-reservation breaker: a reservation held longer than this
* many seconds is dropped (sim seconds). */
float junctionHoldTimeout = 8.0f;
/** classifyJunctions(): a route point within this distance of a
* degree >= 3 road node becomes a junction point. */
float junctionSnapDist = 6.0f;
/**
* Flag route points that pass a junction (degree >= 3 road node
* within snapDist) with the node id and center. Only unflagged
* points (junctionNodeId < 0) are considered; already-flagged
* points keep their data, so callers may pre-flag junctions (the
* headless tests do — no road graph exists on the flat test
* scene). Pure function of the graph; also called automatically
* on the first update of an unclassified route when a terrain
* road graph is available.
*/
static void classifyJunctions(AiDriverComponent &ai,
const RoadGraph &graph, float snapDist);
private:
/* Per-car latched whisker + junction state (whisker refreshed
* every whiskerIntervalFrames). */
struct DriveState {
int framesUntilWhisker = 0;
/* Bumper-relative distance to the nearest whisker
* obstacle (1.0e9 = clear). */
float obstacleDistance = 1.0e9f;
/* Junction node id this car is approaching/yielding at
* or crossing (-1 = none). */
int junctionNodeId = -1;
/* Occupancy center of the latched junction. */
Ogre::Vector3 junctionCenter = Ogre::Vector3::ZERO;
/* This car holds the junction reservation. */
bool junctionReserved = false;
/* Seconds the reservation has been held (timeout). */
float junctionHoldTime = 0.0f;
};
std::map<flecs::entity_t, DriveState> m_states;
flecs::world &m_world;
EditorApp *m_editorApp;
static AiDriverSystem *ms_instance;
};
#endif /* EDITSCENE_AIDRIVERSYSTEM_HPP */
@@ -14,8 +14,11 @@
#include <OgreLogManager.h>
#include <OgreMesh.h>
#include <OgreResourceGroupManager.h>
#include <OgreRTShaderSystem.h>
#include <OgreShaderExHardwareSkinning.h>
#include <OgreSceneNode.h>
#include <algorithm>
#include <map>
CharacterSlotSystem *CharacterSlotSystem::ms_singleton = nullptr;
bool CharacterSlotSystem::s_catalogLoaded = false;
@@ -479,6 +482,360 @@ static void ensureMeshPoseAnimation(const Ogre::String &meshName)
}
}
/* True when the character record actually uses shape keys (any non-zero
* weight). When false, pose animation is stripped from the meshes at
* load time instead: exported Blender meshes carry poses (and OGRE then
* marks them VAT_POSE), which disables hardware vertex animation and
* forces OGRE to run applyVertexAnimation + softwareVertexBlend on the
* CPU for every visible entity every frame -- several hundred
* microseconds per NPC per frame (demo-taxi phase 5.5 profiling). */
static bool
recordNeedsShapeKeys(const CharacterRegistry::CharacterRecord *rec)
{
if (!rec)
return false;
for (const auto &pair : rec->inlineShapeKeyWeights) {
if (pair.second != 0.0f)
return true;
}
return false;
}
/* Remove all vertex animations and poses from a character mesh that no
* character shapes. Mesh animations only cover vertex tracks (skeletal
* animations live on the Skeleton), so this is safe for skinned parts.
* Idempotent per mesh resource. NOTE: if a future character DOES use
* shape keys on an already stripped mesh, the poses are gone and
* applyShapeKeys() silently no-ops; the mesh must be reloaded from disk
* in that case. */
static std::set<Ogre::String> s_poseStrippedMeshes;
static void stripMeshPoseAnimation(const Ogre::MeshPtr &mesh)
{
if (!mesh || s_poseStrippedMeshes.count(mesh->getName()))
return;
if (mesh->getPoseCount() == 0 && !mesh->hasVertexAnimation())
return;
mesh->removeAllAnimations();
mesh->removeAllPoses();
s_poseStrippedMeshes.insert(mesh->getName());
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] stripped unused pose animation from "
"mesh '" + mesh->getName() + "'");
}
/* Add the RTSS hardware-skinning sub-render state to every material of
* a skinned character mesh. The blender2ogre materials only declare a
* plain rtshader_system block, so the generated vertex programs do not
* include skeletal animation and OGRE silently falls back to blending
* every vertex on the CPU every frame -- the dominant per-NPC frame
* cost (demo-taxi phase 5.5 profiling: ~0.3-0.8 ms per visible NPC
* entity in the find window). Idempotent per material.
*
* NOTE: on glsles targets without GLSL 300 es the RTSS hardware
* skinning disables itself (doBoneCalculations=false) and OGRE falls
* back to software skinning again. */
/* Tracks the bone-array size each material's RTSS hardware-skinning
* program was generated with. Several meshes can share one material
* (e.g. face variants sharing 'male_face-atlas') while their submeshes
* need different compact palette sizes; the generated program must fit
* the largest palette ever seen, otherwise the extra bone matrices
* overflow the uniform array and the affected submesh renders garbage. */
static std::map<Ogre::String, unsigned short> s_hwSkinMaterials;
/* The RTSS hardware-skinning vertex program is generated with
* weight_count 4, so it reads blendWeights as a vec4 attribute. OGRE
* sizes the mesh's blend-weight element by the actual influences per
* vertex (VET_FLOAT1 for a single-bone mesh like the face), and GL
* pads the missing components of a vec4 attribute read with (0,0,1):
* the phantom 4th weight of 1.0 adds a second bone matrix term on top
* of the real one, effectively applying the bone transform twice.
* This was the actual mechanism of the phase 5.5 "floating head"
* regression: the face (FLOAT1) and hair strands (FLOAT3) rendered at
* ~2x their bind height above the body, while parts with real FLOAT4
* weights (body, clothes) were unaffected and single-weight parts near
* the origin (feet, FLOAT2) only gained a barely visible offset.
* Repack the weights element to a full float4 with zero padding so the
* attribute read matches the shader. Idempotent. */
static void normalizeVertexDataBlendWeights(Ogre::VertexData *vd)
{
if (!vd)
return;
const Ogre::VertexElement *wgtElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_BLEND_WEIGHTS);
if (!wgtElem)
return;
const Ogre::VertexElementType type = wgtElem->getType();
if (type != Ogre::VET_FLOAT1 && type != Ogre::VET_FLOAT2 &&
type != Ogre::VET_FLOAT3)
return; /* already 4 components, or a non-float layout we
* do not produce (MeshManager base type is FLOAT) */
const unsigned short src = wgtElem->getSource();
const size_t wgtOfs = wgtElem->getOffset();
const size_t oldWgtSize = Ogre::VertexElement::getTypeSize(type);
const size_t wgtCount = Ogre::VertexElement::getTypeCount(type);
auto oldBuf = vd->vertexBufferBinding->getBuffer(src);
const size_t oldStride = oldBuf->getVertexSize();
const size_t newStride = oldStride - oldWgtSize + sizeof(float) * 4;
auto newBuf =
Ogre::HardwareBufferManager::getSingleton().createVertexBuffer(
newStride, vd->vertexCount,
Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY, true);
const unsigned char *oldData = static_cast<const unsigned char *>(
oldBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
unsigned char *newData = static_cast<unsigned char *>(
newBuf->lock(Ogre::HardwareBuffer::HBL_DISCARD));
for (size_t v = 0; v < vd->vertexCount; ++v) {
const unsigned char *o = oldData + v * oldStride;
unsigned char *n = newData + v * newStride;
memcpy(n, o, wgtOfs);
float *w = reinterpret_cast<float *>(n + wgtOfs);
const float *ow = reinterpret_cast<const float *>(o + wgtOfs);
for (size_t k = 0; k < 4; ++k)
w[k] = k < wgtCount ? ow[k] : 0.0f;
memcpy(n + wgtOfs + sizeof(float) * 4,
o + wgtOfs + oldWgtSize,
oldStride - wgtOfs - oldWgtSize);
}
oldBuf->unlock();
newBuf->unlock();
/* Fix the declaration: the weights element becomes float4 and
* every later element in the same source shifts by the growth. */
const size_t grow = sizeof(float) * 4 - oldWgtSize;
const auto &elems = vd->vertexDeclaration->getElements();
size_t elemIdx = 0;
for (const auto &el : elems) {
if (el.getSource() == src) {
if (el.getSemantic() == Ogre::VES_BLEND_WEIGHTS &&
el.getOffset() == wgtOfs)
vd->vertexDeclaration->modifyElement(
elemIdx, src, wgtOfs, Ogre::VET_FLOAT4,
Ogre::VES_BLEND_WEIGHTS, el.getIndex());
else if (el.getOffset() > wgtOfs)
vd->vertexDeclaration->modifyElement(
elemIdx, src, el.getOffset() + grow,
el.getType(), el.getSemantic(),
el.getIndex());
}
++elemIdx;
}
vd->vertexBufferBinding->setBinding(src, newBuf);
}
static void ensureHardwareSkinning(const Ogre::MeshPtr &mesh)
{
if (!mesh || !mesh->getSkeleton())
return;
static const bool disabled =
std::getenv("EDITSCENE_NO_HWSKIN") != NULL;
if (disabled)
return;
using namespace Ogre::RTShader;
ShaderGenerator *gen = ShaderGenerator::getSingletonPtr();
if (!gen)
return;
unsigned short bones = mesh->getSkeleton()->getNumBones();
if (bones > HardwareSkinningFactory::getMaxCalculableBoneCount())
HardwareSkinningFactory::setMaxCalculableBoneCount(bones);
/* The compiled bone assignment maps are only refreshed lazily
* (normally at entity creation), force them now so the palette
* size below is meaningful. */
mesh->_updateCompiledBoneAssignments();
/* Match the blend-weight buffers to the vec4 the shader reads
* (see normalizeVertexDataBlendWeights). */
normalizeVertexDataBlendWeights(mesh->sharedVertexData);
for (auto *sm : mesh->getSubMeshes()) {
if (!sm->useSharedVertices)
normalizeVertexDataBlendWeights(sm->vertexData);
}
/* The generated shader declares the bone matrix array with
* BONE_COUNT entries, but OGRE only ever uploads the compact
* blend-index palette of each submesh (node transform excluded).
* Size the array to the largest compact palette in the mesh
* instead of the full skeleton: avoids a huge mostly-unwritten
* uniform array (139+ entries for a 3-bone face mesh) which is
* both wasteful and mis-rendered on some drivers. */
size_t palette = 1;
if (mesh->sharedBlendIndexToBoneIndexMap.size() > palette)
palette = mesh->sharedBlendIndexToBoneIndexMap.size();
for (auto *sm : mesh->getSubMeshes()) {
if (!sm->useSharedVertices &&
sm->blendIndexToBoneIndexMap.size() > palette)
palette = sm->blendIndexToBoneIndexMap.size();
}
unsigned short boneCount =
static_cast<unsigned short>(std::min<size_t>(palette, bones));
for (auto *sm : mesh->getSubMeshes()) {
Ogre::MaterialPtr mat = sm->getMaterial();
if (!mat)
continue;
Ogre::String key =
mat->getGroup() + "/" + mat->getName();
auto it = s_hwSkinMaterials.find(key);
if (it != s_hwSkinMaterials.end() && it->second >= boneCount)
continue;
bool touched = false;
unsigned short passes = 0;
if (mat->getNumTechniques() > 0 &&
mat->getTechnique(0))
passes = mat->getTechnique(0)->getNumPasses();
for (unsigned short pi = 0; pi < passes; ++pi) {
RenderState *rs = gen->getRenderState(
ShaderGenerator::DEFAULT_SCHEME_NAME,
mat->getName(), mat->getGroup(), pi);
if (!rs)
continue;
SubRenderState *srs =
rs->getSubRenderState(SRS_HARDWARE_SKINNING);
if (!srs) {
srs = gen->createSubRenderState(
SRS_HARDWARE_SKINNING);
srs->setParameter("type", "linear");
srs->setParameter("weight_count", "4");
rs->addTemplateSubRenderState(srs);
}
srs->setParameter("max_bone_count",
Ogre::StringConverter::toString(boneCount));
touched = true;
}
if (touched) {
gen->invalidateMaterial(
ShaderGenerator::DEFAULT_SCHEME_NAME,
mat->getName(), mat->getGroup());
s_hwSkinMaterials[key] = boneCount;
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] enabled RTSS hardware "
"skinning (" +
Ogre::StringConverter::toString(boneCount) +
"/" + Ogre::StringConverter::toString(bones) +
" bones) for material '" + mat->getName() +
"'");
}
}
}
/* ensureHardwareSkinning() only invalidates the material; the RTSS
* technique with the skinning vertex program is (re)generated lazily
* at first render. The entity caches its "hardware animation enabled"
* decision (Entity::mSchemeHardwareAnim) the first time it is asked --
* before the skinned vertex program exists -- and would keep answering
* "software" forever, so SubEntity::getWorldTransforms() would return
* only the node transform and the GPU-side blendBonesMat3x4() would
* run on stale uniform garbage. Force-generate the RTSS technique now
* and clear the entity cache so the hardware-animation decision is
* re-evaluated with the skinned vertex program actually present. */
static void finalizeHardwareSkinning(Ogre::Entity *ent)
{
if (!ent)
return;
static const bool disabled =
std::getenv("EDITSCENE_NO_HWSKIN") != NULL;
if (disabled)
return;
using namespace Ogre::RTShader;
ShaderGenerator *gen = ShaderGenerator::getSingletonPtr();
if (!gen)
return;
for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) {
Ogre::SubEntity *sub = ent->getSubEntity(i);
Ogre::MaterialPtr mat = sub->getMaterial();
if (!mat)
continue;
gen->validateMaterial(
ShaderGenerator::DEFAULT_SCHEME_NAME,
mat->getName(), mat->getGroup());
/* The RTSS hardware-skinning sub-render state never
* marks the generated vertex program as skeletal
* (only the explicit vertex_program_ref technique
* path does), so Entity::calcVertexProcessing()
* would keep deciding on software skinning. Set
* the flag on the generated program ourselves. */
for (auto *tech : mat->getTechniques()) {
for (auto *pass : tech->getPasses()) {
if (pass->hasVertexProgram())
pass->getVertexProgram()
->setSkeletalAnimationIncluded(
true);
}
}
/* SubEntity::setMaterial() is the public hook that
* clears the entity's cached hardware-animation
* decision (Entity::reevaluateVertexProcessing()
* is private); re-assigning the same material is
* a cheap no-op besides that. */
sub->setMaterial(mat);
}
}
void CharacterSlotSystem::prewarmShaders()
{
static const bool disabled =
std::getenv("EDITSCENE_NO_HWSKIN") != NULL;
if (disabled)
return;
loadCatalog();
using namespace Ogre::RTShader;
ShaderGenerator *gen = ShaderGenerator::getSingletonPtr();
if (!gen)
return;
size_t warmed = 0;
for (const auto &meshName : s_meshNames) {
Ogre::MeshPtr mesh;
try {
mesh = Ogre::MeshManager::getSingleton().load(
meshName, "Characters");
} catch (...) {
continue;
}
if (!mesh || !mesh->getSkeleton())
continue;
ensureHardwareSkinning(mesh);
for (auto *sm : mesh->getSubMeshes()) {
Ogre::MaterialPtr mat = sm->getMaterial();
if (!mat)
continue;
gen->validateMaterial(
ShaderGenerator::DEFAULT_SCHEME_NAME,
mat->getName(), mat->getGroup());
/* GpuProgram::load() runs the GL compile now
* (glShaderSource + glCompileShader) instead of at
* first use mid-game. */
for (auto *tech : mat->getTechniques()) {
for (auto *pass : tech->getPasses()) {
if (pass->hasVertexProgram())
pass->getVertexProgram()->load();
if (pass->hasFragmentProgram())
pass->getFragmentProgram()
->load();
++warmed;
}
}
}
}
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] prewarmed character shaders (" +
Ogre::StringConverter::toString(warmed) + " passes, " +
Ogre::StringConverter::toString(s_meshNames.size()) +
" catalog meshes)");
}
/**
* Helper: retrieve the character's registry record.
* Returns nullptr if the entity is not a registered character.
@@ -560,17 +917,24 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e)
if (masterMesh.empty())
return;
/* Pre-create pose animation on mesh so entity knows about it */
ensureMeshPoseAnimation(masterMesh);
/* Pre-create pose animation on mesh so entity knows about it --
* but only when the character actually uses shape keys. */
const bool needsShapeKeys = recordNeedsShapeKeys(rec);
if (needsShapeKeys)
ensureMeshPoseAnimation(masterMesh);
Ogre::Entity *masterEnt = nullptr;
try {
Ogre::MeshPtr meshPtr = Ogre::MeshManager::getSingleton().load(
masterMesh, "Characters");
if (!needsShapeKeys)
stripMeshPoseAnimation(meshPtr);
ensureHardwareSkinning(meshPtr);
masterEnt = m_sceneMgr->createEntity(meshPtr);
transform.node->attachObject(masterEnt);
m_entities[e.id()].parts[masterSlot] = masterEnt;
m_entities[e.id()].masterEntity = masterEnt;
finalizeHardwareSkinning(masterEnt);
/* Setup pose animation for shape keys */
const nlohmann::json *entry =
@@ -603,10 +967,14 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e)
continue;
try {
ensureMeshPoseAnimation(mesh);
if (needsShapeKeys)
ensureMeshPoseAnimation(mesh);
Ogre::MeshPtr partMesh =
Ogre::MeshManager::getSingleton().load(
mesh, "Characters");
if (!needsShapeKeys)
stripMeshPoseAnimation(partMesh);
ensureHardwareSkinning(partMesh);
Ogre::Entity *partEnt =
m_sceneMgr->createEntity(partMesh);
@@ -647,6 +1015,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e)
transform.node->attachObject(partEnt);
}
m_entities[e.id()].parts[slot] = partEnt;
finalizeHardwareSkinning(partEnt);
applyShapeKeys(e, partEnt, entry);
@@ -46,6 +46,12 @@ public:
/* Static catalog access */
static void loadCatalog();
static bool isCatalogLoaded();
/* Generate + compile the RTSS hardware-skinning programs for every
* catalog mesh up front (call at game start, behind the loading
* cover) so the first NPC spawn does not hitch on GL shader
* compilation. No-op when EDITSCENE_NO_HWSKIN is set. */
static void prewarmShaders();
static std::vector<Ogre::String> getAges();
static std::vector<Ogre::String> getSexes(const Ogre::String &age);
static std::vector<Ogre::String> getSlots(const Ogre::String &age,
@@ -0,0 +1,842 @@
#include "NpcTrafficSystem.hpp"
#include <Ogre.h>
#include <cfloat>
#include <cmath>
#include <iostream>
#include "AiDriverSystem.hpp"
#include "AnimationTreeSystem.hpp"
#include "CharacterRegistry.hpp"
#include "CharacterSpawnerSystem.hpp"
#include "CharacterSystem.hpp"
#include "NpcDetailSystem.hpp"
#include "NpcMotionSystem.hpp"
#include "NpcPopulationSystem.hpp"
#include "PhysicsSystem.hpp"
#include "PrefabSystem.hpp"
#include "RenderOriginSystem.hpp"
#include "RoadSystem.hpp"
#include "TerrainSystem.hpp"
#include "../EditorApp.hpp"
#include "../GameMode.hpp"
#include "../camera/EditorCamera.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/CharacterSpawner.hpp"
#include "../components/EditorMarker.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/PlayerController.hpp"
#include "../components/RigidBody.hpp"
#include "../components/RoadGraph.hpp"
#include "../components/RuntimeMarker.hpp"
#include "../components/Transform.hpp"
#include "../components/Vehicle.hpp"
#include "../roadlib/RoadPath.hpp"
#include "../roadlib/TownLocations.hpp"
/* Same per-lot hashing discipline as TownLocations: no shared RNG
* stream, every roll is a hash of the parking name, so editing one lot
* never shifts another lot's fleet. */
static uint32_t fnv1a(const std::string &s)
{
uint32_t h = 2166136261u;
for (char c : s) {
h ^= (uint8_t)c;
h *= 16777619u;
}
return h;
}
NpcTrafficSystem *NpcTrafficSystem::ms_instance = nullptr;
NpcTrafficSystem::NpcTrafficSystem(flecs::world &world,
EditorApp *editorApp)
: m_world(world),
m_editorApp(editorApp)
{
ms_instance = this;
}
NpcTrafficSystem::~NpcTrafficSystem()
{
ms_instance = nullptr;
}
bool NpcTrafficSystem::isDriving(uint64_t registryId) const
{
auto it = m_cars.find(registryId);
return it != m_cars.end() && it->second.cruising;
}
bool NpcTrafficSystem::trafficInfoOf(uint64_t driverId,
TrafficInfo &out) const
{
auto it = m_cars.find(driverId);
if (it == m_cars.end())
return false;
out.cruising = it->second.cruising;
out.highDetail = it->second.highDetail;
out.progress = it->second.progress;
out.routeLength = it->second.routeLength;
out.laps = it->second.laps;
return true;
}
bool NpcTrafficSystem::carWorldPosition(uint64_t driverId,
Ogre::Vector3 &out) const
{
auto it = m_cars.find(driverId);
if (it == m_cars.end())
return false;
const TrafficCar &car = it->second;
if (car.highDetail && car.vehicle.is_alive() &&
car.vehicle.has<TransformComponent>()) {
const TransformComponent &t =
car.vehicle.get<TransformComponent>();
if (t.node) {
out = t.node->_getDerivedPosition();
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
if (ro) {
JPH::DVec3 w = ro->renderToWorld(out);
out = Ogre::Vector3((float)w.GetX(),
(float)w.GetY(),
(float)w.GetZ());
}
return true;
}
}
if (car.cruising && !car.routePos.empty())
out = RoadPath::polylineAt(car.routePos, car.progress);
else
out = car.parkedPos;
return true;
}
Ogre::Vector3 NpcTrafficSystem::cameraPosition() const
{
/* Same camera resolution as NpcDetailSystem: the player camera
* first, then the editor camera. */
Ogre::Camera *cam = nullptr;
Ogre::SceneManager *sm = m_editorApp->getSceneManager();
if (sm && sm->hasCamera("PlayerCamera"))
cam = sm->getCamera("PlayerCamera");
if (!cam && m_editorApp->getEditorCamera())
cam = m_editorApp->getEditorCamera()->getCamera();
if (!cam)
return Ogre::Vector3::ZERO;
/* The car positions are world space; the camera lives in render
* space — convert (identity while no rebase happened). */
Ogre::Vector3 pos = cam->getDerivedPosition();
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
if (ro) {
JPH::DVec3 w = ro->renderToWorld(pos);
pos = Ogre::Vector3((float)w.GetX(), (float)w.GetY(),
(float)w.GetZ());
}
return pos;
}
void NpcTrafficSystem::seedArea(int areaIdx)
{
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
if (!pop)
return;
const TownLocations::LocationSet *locs = pop->areaLocations(areaIdx);
if (!locs)
return;
std::vector<int> parkingIdx =
locs->indicesOfKind(TownLocations::LocationKind::Parking);
if (parkingIdx.empty())
/* No layout carries parking rects (the common case until
* layouts are authored with them): on-street parking at
* the curb POIs. */
parkingIdx =
locs->indicesOfKind(TownLocations::LocationKind::Curb);
if (parkingIdx.empty())
return;
/* The area's adult population, in deterministic id order. */
CharacterRegistry &reg = CharacterRegistry::getSingleton();
std::vector<uint64_t> adults;
for (uint64_t id : pop->populationCharacterIds()) {
if (pop->areaIndexOf(id) != areaIdx)
continue;
CharacterRegistry::CharacterRecord *rec =
reg.findCharacter(id);
if (rec && rec->ageYears >= 18)
adults.push_back(id);
}
if (adults.empty())
return;
/* The road graph orients the parked cars (nose along the
* nearest edge). */
const RoadGraph *graph = nullptr;
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->getRoadSystem()) {
flecs::entity terrain =
ts->getRoadSystem()->getTerrainEntity();
if (terrain.is_alive() && terrain.has<TerrainComponent>())
graph = &terrain.get<TerrainComponent>().roadGraph;
}
int seeded = 0;
for (int li : parkingIdx) {
if (seeded >= maxCarsPerArea)
break;
const TownLocations::NpcLocation &park = locs->locations[li];
uint32_t h = fnv1a(park.name);
if ((double)(h % 10000) / 10000.0 >= carOwnershipRatio)
continue;
/* Driver: the first adult whose home address matches the
* parking spot's owning building; fallback to the area's
* first adult. */
uint64_t driver = 0;
for (uint64_t id : adults) {
CharacterRegistry::CharacterRecord *rec =
reg.findCharacter(id);
auto it = rec->stringColumns.find("npcHome");
if (it != rec->stringColumns.end() &&
it->second == park.address) {
driver = id;
break;
}
}
if (!driver)
driver = adults[0];
/* One car per driver. */
if (m_cars.count(driver))
continue;
TrafficCar car;
car.driverId = driver;
car.areaIdx = areaIdx;
car.homeParking = park.name;
car.parkedPos = park.position;
car.parkedRot = Ogre::Quaternion::IDENTITY;
if (graph) {
Ogre::Vector3 proj, edgeDir;
float halfW = 0.0f;
if (RoadPath::nearestEdgeInfo(*graph, park.position,
proj, edgeDir, halfW)) {
/* Deterministic direction along the
* edge. */
if ((h & 0x10000u) == 0)
edgeDir = -edgeDir;
edgeDir.y = 0.0f;
if (edgeDir.squaredLength() > 1e-8f) {
edgeDir.normalise();
/* Vehicle nose is +Z. */
car.parkedRot =
Ogre::Vector3::UNIT_Z
.getRotationTo(edgeDir);
}
}
}
car.cruising =
(double)((h >> 8) % 10000) / 10000.0 < cruisingRatio;
m_cars[driver] = car;
++seeded;
}
}
bool NpcTrafficSystem::buildRoute(TrafficCar &car, const RoadGraph &graph)
{
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
const TownLocations::LocationSet *locs =
pop ? pop->areaLocations(car.areaIdx) : nullptr;
if (!locs)
return false;
/* Destination: a deterministic other Parking-or-Curb location. */
std::vector<int> dest =
locs->indicesOfKind(TownLocations::LocationKind::Parking);
std::vector<int> curbs =
locs->indicesOfKind(TownLocations::LocationKind::Curb);
dest.insert(dest.end(), curbs.begin(), curbs.end());
const TownLocations::NpcLocation *home =
locs->findByName(car.homeParking);
if (!home)
return false;
std::vector<int> choices;
for (int i : dest) {
if (locs->locations[i].name != car.homeParking)
choices.push_back(i);
}
if (choices.empty())
return false;
uint32_t h = fnv1a(car.homeParking + "#dest");
const TownLocations::NpcLocation &far =
locs->locations[choices[h % choices.size()]];
std::vector<Ogre::Vector3> out, back;
if (!RoadPath::findPath(graph, home->position, far.position, out))
return false;
if (!RoadPath::findPath(graph, far.position, home->position, back))
return false;
/* One loop: home -> far -> home. Drop the duplicate joint
* points. */
if (!back.empty())
out.insert(out.end(), back.begin() + 1, back.end());
if (out.size() < 3)
return false;
RoadPath::offsetToRightLane(graph, out);
car.route.clear();
car.routePos.clear();
for (const Ogre::Vector3 &p : out) {
AiRoutePoint rp;
rp.position = p;
car.route.push_back(rp);
car.routePos.push_back(p);
}
AiDriverComponent tmp;
tmp.route = car.route;
AiDriverSystem::classifyJunctions(tmp, graph, 6.0f);
car.route = tmp.route;
car.routeLength = RoadPath::polylineLength(car.routePos);
return car.routeLength > 1.0f;
}
void NpcTrafficSystem::advanceSymbolic(TrafficCar &car, float dt)
{
if (!car.cruising || car.routeLength <= 0.0f)
return;
car.progress += cruiseSpeed * dt;
while (car.progress >= car.routeLength) {
car.progress -= car.routeLength;
++car.laps;
}
/* The symbolic registry position follows the car (same contract
* as NpcTaxiSystem's Riding state). */
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(car.driverId);
if (rec)
rec->position =
RoadPath::polylineAt(car.routePos, car.progress);
}
size_t NpcTrafficSystem::routeIndexAt(const TrafficCar &car,
float progress) const
{
float acc = 0.0f;
for (size_t i = 0; i + 1 < car.routePos.size(); ++i) {
float seg = car.routePos[i].distance(car.routePos[i + 1]);
if (progress < acc + seg)
return i + 1;
acc += seg;
}
return 0;
}
float NpcTrafficSystem::progressAt(const TrafficCar &car,
const Ogre::Vector3 &pos) const
{
float best = 0.0f;
float bestD = FLT_MAX;
float acc = 0.0f;
for (size_t i = 0; i + 1 < car.routePos.size(); ++i) {
Ogre::Vector3 a = car.routePos[i];
Ogre::Vector3 b = car.routePos[i + 1];
Ogre::Vector3 ab = b - a;
float len2 = ab.squaredLength();
float t = 0.0f;
if (len2 > 1e-12f) {
t = (pos - a).dotProduct(ab) / len2;
t = std::max(0.0f, std::min(1.0f, t));
}
Ogre::Vector3 p = a + ab * t;
float d = (pos - p).squaredLength();
float seg = std::sqrt(len2);
if (d < bestD) {
bestD = d;
best = acc + t * seg;
}
acc += seg;
}
return best;
}
flecs::entity NpcTrafficSystem::createDriverSpawner(uint64_t registryId)
{
/* Same manual-spawner pattern as NpcDetailSystem: the automatic
* distance gates never fire, all transitions go through this
* system; scene switches destroy the runtime entity and the map
* entry recreates it lazily. */
flecs::entity e = m_world.entity();
e.add<RuntimeMarkerComponent>();
TransformComponent t;
t.node = m_editorApp->getSceneManager()
->getRootSceneNode()
->createChildSceneNode();
e.set<TransformComponent>(t);
CharacterSpawnerComponent sp;
sp.registryId = registryId;
sp.spawnDistanceSq = -1.0f;
sp.despawnDistanceSq = FLT_MAX;
e.set<CharacterSpawnerComponent>(sp);
return e;
}
void NpcTrafficSystem::seatDriver(TrafficCar &car, flecs::entity character)
{
if (!car.vehicle.is_alive() ||
!car.vehicle.has<VehicleComponent>() || !character.is_alive())
return;
/* The driver seat. */
auto &vc = car.vehicle.get_mut<VehicleComponent>();
int seatIdx = -1;
for (size_t i = 0; i < vc.seats.size(); ++i) {
if (vc.seats[i].driver) {
seatIdx = (int)i;
break;
}
}
if (seatIdx < 0 && !vc.seats.empty())
seatIdx = 0;
if (seatIdx < 0)
return;
CharacterSystem *cs = m_editorApp->getCharacterSystem();
if (cs)
cs->disablePhysics(character);
/* Inline-spawned population NPCs have no prefab, so the animation
* tree a prefab would carry has to be added (same as
* NpcDetailSystem::spawnHighDetail) — without it setState is a
* no-op and the driver T-poses. */
if (!character.has<AnimationTreeComponent>()) {
AnimationTreeComponent at;
NpcDetailSystem *detail = m_editorApp->getNpcDetailSystem();
at.treeName = detail ? detail->animationTreeName :
Ogre::String("male1");
at.useRootMotion = true;
character.set<AnimationTreeComponent>(at);
}
if (character.has<PathFollowingComponent>()) {
auto &pf = character.get_mut<PathFollowingComponent>();
pf.hasTarget = false;
pf.path.clear();
pf.pathIndex = 0;
}
AnimationTreeSystem *ats = m_editorApp->getAnimationTreeSystem();
if (ats)
ats->setState(character, "locomotion", "driving");
auto &ct = character.get_mut<TransformComponent>();
if (ct.node && vc.seatCharacterScale != 1.0f)
ct.node->setScale(Ogre::Vector3(vc.seatCharacterScale));
vc.seats[seatIdx].occupant = character.id();
}
void NpcTrafficSystem::releaseDriver(TrafficCar &car, bool despawnCharacter)
{
CharacterSpawnerSystem *spawnerSys =
m_editorApp->getCharacterSpawnerSystem();
if (car.vehicle.is_alive() &&
car.vehicle.has<VehicleComponent>()) {
auto &vc = car.vehicle.get_mut<VehicleComponent>();
flecs::entity ch =
spawnerSys && car.driverSpawner.is_alive() ?
spawnerSys->getSpawnedCharacter(
car.driverSpawner) :
flecs::entity::null();
for (auto &seat : vc.seats) {
if (!ch.is_alive() ||
seat.occupant == (uint64_t)ch.id())
seat.occupant = 0;
}
}
if (despawnCharacter && spawnerSys && car.driverSpawner.is_alive())
spawnerSys->despawn(car.driverSpawner);
}
bool NpcTrafficSystem::spawnHighDetail(TrafficCar &car)
{
PrefabSystem prefabSys(m_world, m_editorApp->getSceneManager());
/* Spawn position/heading (world space): on the route for
* cruisers, at the parking spot otherwise. */
Ogre::Vector3 pos = car.parkedPos;
Ogre::Quaternion rot = car.parkedRot;
if (car.cruising && car.routeLength > 0.0f) {
pos = RoadPath::polylineAt(car.routePos, car.progress);
Ogre::Vector3 ahead = RoadPath::polylineAt(
car.routePos, car.progress + 2.0f);
Ogre::Vector3 dir = ahead - pos;
dir.y = 0.0f;
if (dir.squaredLength() > 1e-8f) {
dir.normalise();
rot = Ogre::Vector3::UNIT_Z.getRotationTo(dir);
}
}
/* createInstance works in render space (the instance node hangs
* under the root scene node) — convert. */
Ogre::Vector3 posR = pos;
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
if (ro)
posR = ro->worldToRender(pos.x, pos.y, pos.z);
/* Drop in a little above the ground; the suspension settles. */
posR.y += 0.5f;
flecs::entity instance = prefabSys.createInstance(
vehiclePrefab, flecs::entity::null(), posR,
"traffic_" + std::to_string((unsigned long long)car.driverId));
if (!instance.is_alive() || !instance.has<VehicleComponent>())
return false;
/* Runtime-only: keep the car out of the editor UI caches and
* scene saves (createInstance marks it editable), and make
* scene switches destroy it. */
instance.remove<EditorMarkerComponent>();
instance.add<RuntimeMarkerComponent>();
/* PrefabSystem places the prefab root rotation on the
* instance's TransformComponent — apply our heading. */
auto &t = instance.get_mut<TransformComponent>();
t.rotation = rot;
t.applyToNode();
auto &vc = instance.get_mut<VehicleComponent>();
if (car.cruising) {
AiDriverComponent ai;
ai.route = car.route;
ai.routeIndex = routeIndexAt(car, car.progress);
ai.loop = true;
ai.cruiseSpeed = cruiseSpeed;
ai.junctionsClassified = true;
instance.set<AiDriverComponent>(ai);
} else {
/* Parked: handbrake on, no driver. */
vc.inputHandBrake = 1.0f;
}
car.vehicle = instance;
/* Cruisers carry their driver, seated like NpcTaxiSystem seats
* passengers. */
if (car.cruising) {
CharacterSpawnerSystem *spawnerSys =
m_editorApp->getCharacterSpawnerSystem();
if (spawnerSys) {
if (!car.driverSpawner.is_alive())
car.driverSpawner =
createDriverSpawner(car.driverId);
/* Plant the spawner at the seat (the no-pop seam
* is cosmetic here — the pin takes over the same
* frame). */
auto &st =
car.driverSpawner.get_mut<TransformComponent>();
st.position = posR;
st.rotation = rot;
st.applyToNode();
spawnerSys->spawn(car.driverSpawner);
flecs::entity ch = spawnerSys->getSpawnedCharacter(
car.driverSpawner);
if (ch.is_alive())
seatDriver(car, ch);
}
}
car.highDetail = true;
return true;
}
void NpcTrafficSystem::despawnHighDetail(TrafficCar &car)
{
/* Persist the live position back into the symbolic record
* (cruisers: route progress; parked cars stay at their spot). */
if (car.cruising && car.vehicle.is_alive() &&
car.vehicle.has<TransformComponent>() && !car.routePos.empty()) {
const TransformComponent &t =
car.vehicle.get<TransformComponent>();
if (t.node) {
Ogre::Vector3 pos = t.node->_getDerivedPosition();
RenderOriginSystem *ro =
RenderOriginSystem::getInstance();
if (ro) {
JPH::DVec3 w = ro->renderToWorld(pos);
pos = Ogre::Vector3((float)w.GetX(),
(float)w.GetY(),
(float)w.GetZ());
}
car.progress = progressAt(car, pos);
}
}
releaseDriver(car, true);
if (car.driverSpawner.is_alive()) {
car.driverSpawner.destruct();
car.driverSpawner = flecs::entity();
}
if (car.vehicle.is_alive()) {
PrefabSystem prefabSys(m_world,
m_editorApp->getSceneManager());
JoltPhysicsWrapper *physics =
m_editorApp->getEditorPhysicsSystem() ?
m_editorApp->getEditorPhysicsSystem()
->getPhysicsWrapper() :
nullptr;
prefabSys.destroyInstance(car.vehicle, physics);
car.vehicle = flecs::entity();
}
car.highDetail = false;
}
void NpcTrafficSystem::update(float dt)
{
if (!editScene::isGameMode())
return;
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
if (!pop || !motion)
return;
/* --- Seeding: areas with a location set get their fleet once. --- */
const std::vector<uint64_t> ids = pop->populationCharacterIds();
std::set<int> areas;
for (uint64_t id : ids) {
int a = pop->areaIndexOf(id);
if (a >= 0)
areas.insert(a);
}
for (int a : areas) {
if (m_seededAreas.count(a))
continue;
if (!pop->areaLocations(a))
continue;
seedArea(a);
m_seededAreas.insert(a);
/* Suspension + routes for the fresh fleet. */
TerrainSystem *ts = TerrainSystem::getInstance();
const RoadGraph *graph = nullptr;
if (ts && ts->getRoadSystem()) {
flecs::entity terrain =
ts->getRoadSystem()->getTerrainEntity();
if (terrain.is_alive() &&
terrain.has<TerrainComponent>())
graph =
&terrain.get<TerrainComponent>()
.roadGraph;
}
for (auto &kv : m_cars) {
TrafficCar &car = kv.second;
if (car.areaIdx != a)
continue;
if (car.cruising &&
(!graph || !buildRoute(car, *graph)))
car.cruising = false;
if (car.cruising) {
motion->setSuspended(car.driverId, true);
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(car.driverId);
if (rec)
rec->position = car.parkedPos;
}
}
}
/* --- Prune cars whose driver or home parking vanished (town
* regen). --- */
for (auto it = m_cars.begin(); it != m_cars.end();) {
bool dead =
std::find(ids.begin(), ids.end(), it->first) ==
ids.end();
if (!dead) {
const TownLocations::LocationSet *locs =
pop->areaLocations(it->second.areaIdx);
dead = !locs ||
!locs->findByName(it->second.homeParking);
}
if (dead) {
if (it->second.highDetail)
despawnHighDetail(it->second);
if (it->second.cruising)
motion->setSuspended(it->first, false);
m_seededAreas.erase(it->second.areaIdx);
it = m_cars.erase(it);
} else {
++it;
}
}
const Ogre::Vector3 camPos = cameraPosition();
int spawnBudget = spawnBudgetPerFrame;
int activeCount = 0;
for (const auto &kv : m_cars)
if (kv.second.highDetail)
++activeCount;
for (auto &kv : m_cars) {
TrafficCar &car = kv.second;
/* Scene switches destroy the runtime entities; notice the
* dead handles and start LOW again (the driver spawner is
* recreated lazily on the next spawn). */
if (car.highDetail &&
(!car.vehicle.is_alive() ||
!car.vehicle.has<VehicleComponent>())) {
if (car.driverSpawner.is_alive()) {
CharacterSpawnerSystem *spawnerSys =
m_editorApp
->getCharacterSpawnerSystem();
if (spawnerSys)
spawnerSys->despawn(car.driverSpawner);
car.driverSpawner.destruct();
car.driverSpawner = flecs::entity();
}
car.vehicle = flecs::entity();
car.highDetail = false;
car.dwell = 0.0f;
}
/* Carjacking: the player took the car — it leaves the
* fleet, the driver alights in place and returns to its
* schedule. */
if (car.highDetail && car.vehicle.is_alive() &&
car.vehicle.has<PlayerControlledComponent>()) {
releaseDriver(car, true);
if (car.driverSpawner.is_alive()) {
car.driverSpawner.destruct();
car.driverSpawner = flecs::entity();
}
if (car.cruising) {
motion->setSuspended(car.driverId, false);
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(car.driverId);
if (rec && car.vehicle.is_alive() &&
car.vehicle.has<TransformComponent>()) {
const TransformComponent &t =
car.vehicle
.get<TransformComponent>();
if (t.node) {
rec->position =
t.node->_getDerivedPosition();
RenderOriginSystem *ro =
RenderOriginSystem::
getInstance();
if (ro) {
JPH::DVec3 w =
ro->renderToWorld(
rec->position);
rec->position = Ogre::Vector3(
(float)w.GetX(),
(float)w.GetY(),
(float)w.GetZ());
}
}
}
}
car.vehicle.remove<AiDriverComponent>();
car.vehicle = flecs::entity(); /* the player owns it now */
kv.second.highDetail = false;
m_cars.erase(kv.first);
break; /* map changed; fresh iteration next frame */
}
if (!car.highDetail)
advanceSymbolic(car, dt);
car.dwell += dt;
/* Distance gates; while spawned measure against the live
* vehicle (it legitimately moves on its own). */
Ogre::Vector3 carPos;
if (!carWorldPosition(car.driverId, carPos))
continue;
const float distSq = (carPos - camPos).squaredLength();
if (!car.highDetail) {
if (distSq <= spawnDistanceSq && spawnBudget > 0 &&
activeCount < maxActiveVehicles &&
car.dwell >= minDwellSeconds &&
spawnHighDetail(car)) {
--spawnBudget;
++activeCount;
car.dwell = 0.0f;
}
} else {
if (car.dwell >= minDwellSeconds &&
distSq > despawnDistanceSq) {
despawnHighDetail(car);
--activeCount;
car.dwell = 0.0f;
}
}
}
}
void NpcTrafficSystem::postPhysicsUpdate()
{
if (!editScene::isGameMode())
return;
CharacterSpawnerSystem *spawnerSys =
m_editorApp->getCharacterSpawnerSystem();
if (!spawnerSys)
return;
for (auto &kv : m_cars) {
TrafficCar &car = kv.second;
if (!car.highDetail || !car.vehicle.is_alive() ||
!car.driverSpawner.is_alive() ||
!car.vehicle.has<VehicleComponent>() ||
!car.vehicle.has<TransformComponent>())
continue;
flecs::entity character =
spawnerSys->getSpawnedCharacter(car.driverSpawner);
if (!character.is_alive() ||
!character.has<TransformComponent>())
continue;
const VehicleComponent &vc =
car.vehicle.get<VehicleComponent>();
int seatIdx = -1;
for (size_t i = 0; i < vc.seats.size(); ++i) {
if (vc.seats[i].occupant ==
(uint64_t)character.id()) {
seatIdx = (int)i;
break;
}
}
if (seatIdx < 0)
continue;
/* Pin to the seat (same approach as
* NpcTaxiSystem::postPhysicsUpdate): nodes are root
* children, so derived == render world. */
const TransformComponent &vt =
car.vehicle.get<TransformComponent>();
auto &ct = character.get_mut<TransformComponent>();
if (!vt.node || !ct.node)
continue;
Ogre::Vector3 seat =
vt.node->_getDerivedOrientation() *
vc.seats[seatIdx].offset +
vt.node->_getDerivedPosition();
ct.node->setPosition(seat);
ct.node->setOrientation(vt.node->_getDerivedOrientation());
ct.position = seat;
/* Re-assert the driving state: the setState in seatDriver
* runs on the spawn frame, when AnimationTreeSystem has no
* per-entity state for the fresh character yet and the
* call no-ops (the driver T-poses). Once the tree state
* exists this is an early-out no-op. */
AnimationTreeSystem *ats =
m_editorApp->getAnimationTreeSystem();
if (ats)
ats->setState(character, "locomotion", "driving");
}
}
@@ -0,0 +1,190 @@
#ifndef EDITSCENE_NPCTRAFFICSYSTEM_HPP
#define EDITSCENE_NPCTRAFFICSYSTEM_HPP
#pragma once
#include <cstdint>
#include <map>
#include <set>
#include <string>
#include <vector>
#include <OgreVector3.h>
#include <OgreQuaternion.h>
#include <flecs.h>
#include "../components/AiDriver.hpp"
class EditorApp;
struct RoadGraph;
/**
* NPC vehicle traffic (demo-taxi phase 5.4).
*
* Owns the population's car fleet: when a town area's population and
* location set exist (NpcPopulationSystem), a deterministic subset of
* the area's Parking locations gets a car (hash of the parking name vs
* carOwnershipRatio, same per-lot hashing discipline as TownLocations —
* editing one lot never shifts another lot's fleet; areas without
* parking rects fall back to the Curb locations as on-street parking). Each car belongs
* to a driver: the first adult population NPC whose home address
* matches the parking spot's owning building (fallback: the area's
* first adult).
*
* A deterministic subset of the fleet (cruisingRatio) drives an endless
* loop — home parking -> a deterministic other Parking/Curb location ->
* back — built from RoadPath::findPath + offsetToRightLane and
* junction-classified against the road graph. The rest of the fleet
* stays parked at its Parking location (handbrake on, no driver).
* Cruising drivers are suspended from the schedule layer
* (NpcMotionSystem::setSuspended) and their registry position follows
* the car, like NpcTaxiSystem's Riding state; the walk-vs-drive choice
* is deferred to phase 8, so a cruising driver effectively lives in its
* car for now.
*
* 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; a high-detail vehicle
* entity (PrefabSystem::createInstance of vehiclePrefab, an
* AiDriverComponent for cruisers, and a driver character seated through
* the same manual-spawner + seat-pinning approach as NpcTaxiSystem)
* exists only while the camera is within spawnDistanceSq. Tier changes
* use hysteresis (despawn > spawn), a per-car dwell timer, a per-frame
* spawn budget (prefab instantiation + vehicle constraint setup is a
* frame hitch) and an active-vehicle cap.
*
* NpcDetailSystem consults isDriving() so a suspended cruising driver
* never materializes as a pedestrian. A vehicle carrying the player
* (PlayerControlledComponent — carjacking) leaves the fleet: the record
* is erased, the driver alights in place and returns to its schedule.
*/
class NpcTrafficSystem
{
public:
NpcTrafficSystem(flecs::world &world, EditorApp *editorApp);
~NpcTrafficSystem();
/** Singleton access (same pattern as NpcMotionSystem). */
static NpcTrafficSystem *getInstance()
{
return ms_instance;
}
/** Read-out for debug UIs and the headless town traffic test. */
struct TrafficInfo {
bool cruising = false;
bool highDetail = false;
float progress = 0.0f;
float routeLength = 0.0f;
int laps = 0;
};
/** True while the NPC drives one of the fleet's cars (suspended
* from the schedule layer; must never spawn as a pedestrian). */
bool isDriving(uint64_t registryId) const;
bool trafficInfoOf(uint64_t driverId, TrafficInfo &out) const;
/** Current symbolic (world-space) position of a car. */
bool carWorldPosition(uint64_t driverId, Ogre::Vector3 &out) const;
/** Fleet size (debug UI / tests). */
size_t carCount() const
{
return m_cars.size();
}
/** Cars currently in the high-detail tier (benchmark telemetry). */
size_t activeVehicleCount() const
{
size_t n = 0;
for (const auto &kv : m_cars)
if (kv.second.highDetail)
++n;
return n;
}
/** Driver registry ids of the whole fleet (debug UI / tests). */
std::vector<uint64_t> carDriverIds() const
{
std::vector<uint64_t> out;
out.reserve(m_cars.size());
for (const auto &kv : m_cars)
out.push_back(kv.first);
return out;
}
/** Per-frame update; game mode only. Runs after the NPC detail
* tier so the population and locations are fresh. */
void update(float dt);
/** Driver seat pinning; runs after the physics step and the
* rigid-body -> scene node sync (right after
* NpcTaxiSystem::postPhysicsUpdate). */
void postPhysicsUpdate();
/** Chance a Parking location gets a car (deterministic hash). */
float carOwnershipRatio = 0.5f;
/** Chance a fleet car cruises instead of parking (deterministic
* hash). */
float cruisingRatio = 0.5f;
/** Fleet size cap per town area. */
int maxCarsPerArea = 6;
/** Spawn/despawn distance (squared, world units) camera<->car;
* larger than the pedestrian tier — cars are fast and big. */
float spawnDistanceSq = 150.0f * 150.0f;
float despawnDistanceSq = 220.0f * 220.0f;
/** Minimum real seconds between tier changes of one car. */
float minDwellSeconds = 3.0f;
/** Max vehicle spawns per update (spawn cost is a frame hitch). */
int spawnBudgetPerFrame = 1;
/** Max simultaneously high-detail traffic vehicles. */
int maxActiveVehicles = 8;
/** Cruiser speed (m/s); also the symbolic tier's advance rate. */
float cruiseSpeed = 11.0f;
/** Vehicle prefab instantiated for high-detail cars (relative to
* the CWD). */
std::string vehiclePrefab = "prefabs/sedan-traffic.json";
private:
struct TrafficCar {
uint64_t driverId = 0;
int areaIdx = -1;
/** Home Parking location name (TownLocations). */
std::string homeParking;
/* Parked placement (world space; nose along the road
* edge). */
Ogre::Vector3 parkedPos = Ogre::Vector3::ZERO;
Ogre::Quaternion parkedRot = Ogre::Quaternion::IDENTITY;
bool cruising = false;
/* Cruising loop: world-space, right-lane-offset and
* junction-classified; routePos mirrors the point
* positions for the polyline ops. */
std::vector<AiRoutePoint> route;
std::vector<Ogre::Vector3> routePos;
float routeLength = 0.0f;
/* Meters along the loop (symbolic ground truth). */
float progress = 0.0f;
int laps = 0;
/* High-detail tier. */
flecs::entity vehicle;
flecs::entity driverSpawner;
bool highDetail = false;
float dwell = 0.0f;
};
void seedArea(int areaIdx);
bool buildRoute(TrafficCar &car, const RoadGraph &graph);
void advanceSymbolic(TrafficCar &car, float dt);
flecs::entity createDriverSpawner(uint64_t registryId);
bool spawnHighDetail(TrafficCar &car);
void despawnHighDetail(TrafficCar &car);
void seatDriver(TrafficCar &car, flecs::entity character);
void releaseDriver(TrafficCar &car, bool despawnCharacter);
size_t routeIndexAt(const TrafficCar &car, float progress) const;
float progressAt(const TrafficCar &car,
const Ogre::Vector3 &pos) const;
Ogre::Vector3 cameraPosition() const;
flecs::world &m_world;
EditorApp *m_editorApp;
std::map<uint64_t, TrafficCar> m_cars; /* key: driver registry id */
std::set<int> m_seededAreas;
static NpcTrafficSystem *ms_instance;
};
#endif /* EDITSCENE_NPCTRAFFICSYSTEM_HPP */
@@ -1,6 +1,7 @@
#include "PhysicsSystem.hpp"
#include "../components/Transform.hpp"
#include <OgreLogManager.h>
#include <OgreTimer.h>
EditorPhysicsSystem::EditorPhysicsSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
@@ -43,16 +44,24 @@ void EditorPhysicsSystem::update(float deltaTime)
if (deltaTime > 0.1f)
deltaTime = 0.1f;
/* Sub-split timing for the benchmark CSV (a few timer reads). */
Ogre::Timer timer;
// Sync bodies before simulation
syncBodies();
m_lastSyncMs = (float)timer.getMilliseconds();
timer.reset();
// Step physics (skip if physics stepping is disabled)
if (m_physicsEnabled) {
m_physics->update(deltaTime);
}
m_lastStepMs = (float)timer.getMilliseconds();
timer.reset();
// Update SceneNodes from physics for dynamic bodies
updateDynamicBodies();
m_lastBodiesMs = (float)timer.getMilliseconds();
}
void EditorPhysicsSystem::syncBodies()
@@ -52,6 +52,13 @@ public:
return m_initialized;
}
/* Last-frame sub-split of update(): body sync (SceneNode -> Jolt),
* the Jolt step itself, and the dynamic-body readback. Benchmarks
* read these to attribute the "physics" frame span. */
float getLastSyncMs() const { return m_lastSyncMs; }
float getLastStepMs() const { return m_lastStepMs; }
float getLastBodiesMs() const { return m_lastBodiesMs; }
// Remove a rigid body (public for external cleanup)
void removeRigidBody(RigidBodyComponent &rigidBody);
@@ -83,6 +90,11 @@ private:
// Query for entities with RigidBody
flecs::query<RigidBodyComponent, TransformComponent> m_rigidBodyQuery;
/* See getLastSyncMs()/getLastStepMs()/getLastBodiesMs(). */
float m_lastSyncMs = 0.0f;
float m_lastStepMs = 0.0f;
float m_lastBodiesMs = 0.0f;
};
#endif // EDITSCENE_PHYSICSSYSTEM_HPP