diff --git a/assets/blender/scripts/gen_vehicle.py b/assets/blender/scripts/gen_vehicle.py index f0d2446..9a5be14 100644 --- a/assets/blender/scripts/gen_vehicle.py +++ b/assets/blender/scripts/gen_vehicle.py @@ -275,6 +275,149 @@ for m in used_mats: print("gen_vehicle: unpacked texture '%s' -> %s" % (img.name, tex_name)) +# --- merged single material + ID map (CAR_MODELING.md) --- +# Every mesh part is re-materialized onto a single material named +# so blender2ogre emits ONE submesh per part, and the exporter's +# fixed-function .material is replaced with the VehicleIdMap shader +# materials after the export. The ID map texture shares the color +# atlas UV space (all parts MUST use that one UV space with no texel +# shared between materials; fix the blend if not) and marks each +# fragment: red = paint (atlas color), green = bloom (lights), +# blue = glass. +IDMAP_SIZE = 512 + + +def idmap_color(mat): + ln = mat.name.lower() if mat else "" + if "glass" in ln: + return (0, 0, 255) + if "bloom" in ln: + return (0, 255, 0) + return (255, 0, 0) + + +def bake_idmap(mesh_datas, path): + """Paint per-face material IDs into a texture in UV space. + + The car UVs are degenerate: every polygon collapses onto a single + color-atlas texel (the atlas is a palette, not a surface unwrap), + so the ID map only needs that texel plus a small margin painted. + A texel shared by two different materials is unresolvable and is + warned about (fix the blend: give one of them its own texel).""" + size = IDMAP_SIZE + buf = bytearray(size * size * 3) + owner = {} + warned = set() + + def paint(u, v, col, matname): + u = u - math.floor(u) + v = v - math.floor(v) + cx, cy = int(u * (size - 1)), int(v * (size - 1)) + for dy in range(-2, 3): + for dx in range(-2, 3): + if dx * dx + dy * dy > 5: + continue + px, py = cx + dx, cy + dy + if not (0 <= px < size and 0 <= py < size): + continue + idx = py * size + px + prev = owner.get(idx) + if prev is not None and prev != matname: + pair = (prev, matname) + if pair not in warned: + warned.add(pair) + print("gen_vehicle: WARNING - UV texel (%d,%d) " + "shared by materials '%s' and '%s'; the " + "ID map cannot tell them apart (fix the " + "blend)" % (px, py, prev, matname)) + continue + owner[idx] = matname + buf[idx * 3] = col[0] + buf[idx * 3 + 1] = col[1] + buf[idx * 3 + 2] = col[2] + + for me in mesh_datas: + uv_layer = me.uv_layers.active + if not uv_layer: + continue + uv = uv_layer.data + for poly in me.polygons: + mat = (me.materials[poly.material_index] + if poly.material_index < len(me.materials) else None) + puv = uv[poly.loop_indices[0]].uv + paint(puv.x, puv.y, idmap_color(mat), + mat.name if mat else "") + + img = bpy.data.images.new( + os.path.splitext(os.path.basename(path))[0], + width=size, height=size, alpha=False) + px = [0.0] * (size * size * 4) + for i in range(size * size): + px[i * 4] = buf[i * 3] / 255.0 + px[i * 4 + 1] = buf[i * 3 + 1] / 255.0 + px[i * 4 + 2] = buf[i * 3 + 2] / 255.0 + px[i * 4 + 3] = 1.0 + img.pixels = px + img.filepath_raw = path + img.file_format = 'PNG' + img.save() + bpy.data.images.remove(img) + print("gen_vehicle: wrote ID map %s (%d texels)" % + (path, len(owner))) + + +def move_glass_faces_last(): + """Re-order each mesh so glass faces export last: with the merged + transparent material (depth_write on) the cabin stays visible + through the windows only if glass triangles draw after the + interior.""" + for obj in bpy.data.objects: + if obj.type != 'MESH': + continue + me = obj.data + glass_slots = [i for i, m in enumerate(me.materials) + if m and "glass" in m.name.lower()] + if not glass_slots: + continue + order = [i for i in range(len(me.materials)) + if i not in glass_slots] + glass_slots + mats = [me.materials[i] for i in order] + remap = dict((old, new) for new, old in enumerate(order)) + # materials.clear() zeroes every polygon's material_index, so + # save them first + saved = [p.material_index for p in me.polygons] + me.materials.clear() + for m in mats: + me.materials.append(m) + for poly, old_idx in zip(me.polygons, saved): + poly.material_index = remap[old_idx] + bpy.ops.object.select_all(action='DESELECT') + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.sort_elements(type='MATERIAL', elements={'FACE'}) + bpy.ops.object.mode_set(mode='OBJECT') + obj.select_set(False) + print("gen_vehicle: sorted glass faces last on '%s'" % me.name) + + +all_mesh_datas = [] +for obj in bpy.data.objects: + if obj.type == 'MESH' and obj.data not in all_mesh_datas: + all_mesh_datas.append(obj.data) + +move_glass_faces_last() +bake_idmap(all_mesh_datas, os.path.join(outdir, base + "-idmap.png")) + +merged_mat = bpy.data.materials.new(base) +merged_mat.use_nodes = True +for me in all_mesh_datas: + me.materials.clear() + me.materials.append(merged_mat) +print("gen_vehicle: consolidated %d mesh data blocks onto material " + "'%s'" % (len(all_mesh_datas), base)) + # --- JSON fragment (Ogre coordinates) --- smn, smx = mesh_bbox(root.data) collider = { @@ -511,6 +654,101 @@ bpy.ops.ogre.export(filepath=scene_file, EX_LOD_PERCENT=40 ) +# --- replace the exporter's fixed-function .material with the merged +# VehicleIdMap shader materials (CAR_MODELING.md): "" is the +# transparent near variant, "-opaque" the far variant; the +# runtime swaps them by camera distance (SedanGlassLodListener). +# Both reference the VehicleIdMapVP/FP programs staged in the same +# resource dir (vehicle_idmap.program, CMake-staged from +# assets/blender/vehicles). --- +IDMAP_MATERIAL_TEMPLATE = """// written by gen_vehicle.py — merged ID-map vehicle material +// (CAR_MODELING.md). Not a blender2ogre product; hand edits are +// overwritten by every export. +material @BASE@ +{ + receive_shadows on + technique + { + pass + { + cull_hardware clockwise + depth_write on + scene_blend alpha_blend + + vertex_program_ref VehicleIdMapVP + { + } + + fragment_program_ref VehicleIdMapFP + { + param_named glassAlpha float 0.25 + } + + texture_unit + { + texture @BASE@-atlas.png + tex_address_mode wrap + filtering linear linear none + } + + texture_unit + { + texture @BASE@-idmap.png + tex_address_mode clamp + filtering point point none + } + } + } +} + +material @BASE@-opaque +{ + receive_shadows on + technique + { + pass + { + cull_hardware clockwise + + vertex_program_ref VehicleIdMapVP + { + } + + fragment_program_ref VehicleIdMapFP + { + param_named glassAlpha float 1.0 + } + + texture_unit + { + texture @BASE@-atlas.png + tex_address_mode wrap + filtering linear linear none + } + + texture_unit + { + texture @BASE@-idmap.png + tex_address_mode clamp + filtering point point none + } + } + } +} +""" + +mat_path = os.path.join(outdir, base + ".material") +with open(mat_path, "w") as f: + f.write(IDMAP_MATERIAL_TEMPLATE.replace("@BASE@", base)) +print("gen_vehicle: wrote merged material %s" % mat_path) + +# Drop the stale split-material files from pre-ID-map exports so the +# resource dir does not accumulate them. +for suffix in ("-color", "-color-bloom", "-glass"): + stale = os.path.join(outdir, base + suffix + ".material") + if os.path.exists(stale): + os.remove(stale) + bpy.ops.wm.read_homefile(use_empty=True) import time time.sleep(2) diff --git a/assets/blender/vehicles/CMakeLists.txt b/assets/blender/vehicles/CMakeLists.txt index 702b24b..7f7092b 100644 --- a/assets/blender/vehicles/CMakeLists.txt +++ b/assets/blender/vehicles/CMakeLists.txt @@ -51,6 +51,13 @@ foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC}) list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE}) endforeach() +# Shared merged-material vehicle shader (one ID-map material per car; +# gen_vehicle.py writes the per-car .material referencing these +# programs; see vehicles/CAR_MODELING.md). Staged next to the +# generated .material files. +file(COPY vehicle_idmap.vert vehicle_idmap.frag vehicle_idmap.program + DESTINATION ${CMAKE_BINARY_DIR}/resources/vehicles) + add_custom_target(import_vehicles ALL DEPENDS ${VEHICLE_OUTPUT_FILES}) # One-step vehicle refresh: re-export changed .blend files (import_vehicles), diff --git a/assets/blender/vehicles/sedan-v2.blend b/assets/blender/vehicles/sedan-v2.blend index 8af8ff6..408157d 100644 --- a/assets/blender/vehicles/sedan-v2.blend +++ b/assets/blender/vehicles/sedan-v2.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a017ceeb809a662b92cfbb8f6fa9577e4a95bfc860164433cd5a4c38feeca4b -size 1753376 +oid sha256:3d9f90c9e9ed3bc1119dc149f251b3fb2a757a42bb850e3efd8ca77266a1cf31 +size 1752584 diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 909fc65..02ed75b 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -328,6 +328,17 @@ cd demos/demo-taxi # delta is wall time (a few FPS under xvfb), so the fixed-length # W-hold drives the sedan clear off the test floor and the exit-spot # raycast legitimately fails. +# Performance probes (phase 5.5): +./demoTaxi --headless --benchmark +# waits for town population + traffic, then measures 6 fixed views +# (empty-horizon control, four street-level cardinal directions at +# the town center, overview from +80 m): wall-clock ms (llvmpipe +# under xvfb — relative only), OGRE batches/tris, frustum-visible +# entity groups, per-material render-queue batch counts and +# BaseWhite (no-material fallback) offenders. +EDITSCENE_PROFILE=1 ./demoTaxi --headless --benchmark +# env var: dump per-step ms/frame averages of +# EditorApp::frameRenderingQueued every 600 frames. # Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door / # sit in the seat of the sedan door you used (driver = front-left, # only the driver seat drives), hold E = exit the vehicle, Space = @@ -343,6 +354,27 @@ cd demos/demo-taxi # assets/blender/vehicles/CAR_MODELING.md. After editing a vehicle # blend run `cmake --build build-vscode --target update_vehicles` (one # step: re-export, re-stage resources, re-merge both demo scenes). +# Rendering (phase 5.5): every part (body, doors, hood, trunk, wheels, +# steering) exports with exactly ONE submesh on ONE shared material +# (`sedan-v2` / `sedan-v2-taxi`) using the VehicleIdMap shader +# (resources/vehicles/vehicle_idmap.*, GLES2-compatible, +# OgreUnifiedShader.h) — a baked ID-map texture (red = paint, green = +# bloom/lights, blue = glass, sharing the cars' palette-degenerate UV +# space) selects paint/bloom/glass behavior per fragment; the +# `vehicleColor` shader param is the hook for future per-car colors. +# Two variants swap by camera distance (`` alpha-blended glass +# within 10 m, `-opaque` beyond). gen_vehicle.py bakes the ID +# map (warning on palette texels shared between materials — fix the +# blend), sorts glass faces last per mesh and writes the `.material` +# itself (the palette atlas is sampled with mipmaps OFF — `linear +# linear none` — because minification averages neighboring palette +# texels and distant cars bled wrong colors); the demo's +# SedanGlassLodListener does the near/far swap. The shader's samplers +# are bound explicitly (`param_named uAtlas int 0` / `param_named +# uIdMap int 1` in vehicle_idmap.program) — OGRE's sampler +# auto-assignment follows driver uniform-enumeration order and let +# uIdMap read the atlas on some machines, making the whole car +# semi-transparent (2026-10-16). # The exterior # scene parks sedan_taxi and sedan_plain near the spawn point; each # door child has an actuator with a vehicle_seat_* action (driver, @@ -482,8 +514,9 @@ binary + its staged project directory as the distributable. 18. `EventHandlerSystem` 19. `CharacterSystem` 20. `BuoyancySystem` -21. `PhysicsSystem` (pre: `VehicleSystem::prePhysicsUpdate`; post: - `VehicleSystem::postPhysicsUpdate` wheel/fork/steering visuals) +21. `PhysicsSystem` (pre: `AiDriverSystem` writes traffic-car + `VehicleComponent` inputs, then `VehicleSystem::prePhysicsUpdate`; + post: `VehicleSystem::postPhysicsUpdate` wheel/fork/steering visuals) 21a. `VehicleControllerSystem::postPhysicsUpdate` pins the seated character to the freshly synced vehicle node, then `PlayerControllerSystem::postPhysicsUpdate` runs the driving-mode @@ -721,9 +754,10 @@ position reproducibility at a revisited hour). `NpcDetailSystem` (EditorApp-owned singleton `getInstance()`, game mode only, `systems/NpcDetailSystem.hpp/.cpp`) is the high-detail tier over the -symbolic motion layer: population NPCs within `spawnDistanceSq` (80 m) of +symbolic motion layer: population NPCs within `spawnDistanceSq` (80 m — +the taxi demo tunes it to 55 m in demo_main.cpp for batch budget) of the camera become real walking entities and drop back to symbolic beyond -`despawnDistanceSq` (120 m) — hysteresis plus a `minDwellSeconds` (3 s) +`despawnDistanceSq` (120 m — demo: 85 m) — hysteresis plus a `minDwellSeconds` (3 s) dwell and a `spawnBudgetPerFrame` (2) cap, all public config fields. Spawn eligibility (phase 4.9.1) gates the distance rule — `isSpawnEligible(id)`: an NPC AT an INDOOR location (`TownLocations::isIndoorKind` — Home, Work, @@ -915,6 +949,142 @@ inside (a curb-side stand must not oscillate). Tens of NPCs make this well under a millisecond per frame; threads are a phase-6 crowd question if profiling ever says so. +### AiDriverSystem (demo-taxi phase 5.1/5.2/5.3) + +`AiDriverSystem` (EditorApp-owned singleton `getInstance()`, game mode +only, `systems/AiDriverSystem.hpp/.cpp`, runs right before +`VehicleSystem::prePhysicsUpdate`) drives every entity with an +`AiDriverComponent` along its `route` polyline: pure-pursuit steering +(speed-scaled lookahead, max 5 m or 0.8 s of travel) plus a +proportional throttle/brake speed controller with curvature slowdown +(full-speed heading error up to `cornerFullAngleDeg` 60, bottoming at +`minCornerSpeed` 3 m/s). It writes the SAME +`VehicleComponent::inputForward/inputRight/inputBrake/inputHandBrake` +fields the player's VehicleControllerSystem writes, so VehicleSystem +stays the single consumer (brake/reverse protection, parked damping and +righting come for free); a vehicle carrying the player +(PlayerControlledComponent) is never touched, so entering a traffic car +just works. Steering is XZ-only; waypoint Y is informational. +Non-looping routes brake to a stop at the last waypoint and park +(`arrived` + handbrake); looping routes wrap and count +`lapsCompleted`. Sign convention: the sedan +nose is +Z, its LEFT is +X (door naming), so a target at +X of the nose +(crossY > 0) means steer LEFT = negative `inputRight`. + +Whisker braking (5.2): a 3-ray fan (nose, +/-18 deg) is cast at bumper +height 0.55 m, starting 2.8 m ahead of the origin, every 3 frames +staggered by entity id, via +`JoltPhysicsWrapper::raycastQueryTraffic()` — the +`raycastQueryWalkable` AllHit-collector pattern (per-hit surface normal +via BodyLockRead, hits with `normal.y >= walkableNormalY` skipped so +road and ground never brake the car) with two differences: character +bodies are NOT skipped (pedestrians must brake cars) and the car's own +chassis bodyID is excluded by `SkipOneBodyFilter`. The fan length is +`speed^2/(2*4.0) + followGap + 4` (4 m/s^2 braking assumption, +followGap 4.5 m). A hit clamps the speed target to +`(obstacleDistance - followGap) * 0.8`, which glides the car to a stop +4.5 m short of the obstacle; at standstill the brake is held while the +path stays blocked, and the target releases the moment the obstacle +clears (stop-and-go). The query needs `RigidBodyComponent` (bodyID) on +the vehicle in addition to `VehicleComponent` + `AiDriverComponent`. +Headless probes: `--test-traffic-loop` (vehicletest scene sedan1+sedan2, +3 laps each on an 80x50 m rectangle, fails on contact, NaN or leaving +the loop area) and `--test-traffic-follow` (parked broadside blocker at +x=20, sedan2 must stop in -10 < x < 15 — the near-blocker window keeps +the test from passing vacuously on a stationary car — hold 120 frames, +resume after the blocker teleports away, arrive at x=60). + +Junction priority (5.3): the route is a list of `AiRoutePoint` +(position + junctionNodeId + junctionCenter); +`classifyJunctions()` flags points within `junctionSnapDist` of a +degree >= 3 road node (auto-runs once per route on the first update +against the terrain road graph — the same +`TerrainSystem::getInstance()->getRoadSystem()->getTerrainEntity()` +pattern as NpcTaxiSystem — and keeps pre-flagged points). A car +latches the next flagged point ahead, slows to `junctionSpeed` inside +`junctionApproachDist`, and at the approach-zone edge either takes the +junction reservation (first-come-first-served in flecs iteration order) +or yields with a constant-deceleration sqrt brake profile to a stop +line `junctionStopDist` before the center. Clear = no other AI car +holds the reservation AND no vehicle (AI or player, snapshotted once +per frame) has its chassis within `junctionRadius` of the center; +yielders stopped at the stop line do not count as occupants. Release: +the center behind the car and `junctionReleaseDist` away (a plain +distance check would drop the reservation during the approach leg, +which starts farther out), plus a `junctionHoldTimeout` stale breaker; +a per-frame purge drops drive state (and reservations) of despawned or +player-taken cars. Gotcha: reserve at the approach-zone edge, not the +stop line — two simultaneously arriving cars both fit inside a stop-line +window before either can brake. Headless `--test-traffic-yield` (flat +vehicletest scene, hand-flagged junction node — the priority logic is +graph-independent): two sedans cross the origin junction from 60 m at +12 m/s; the loser must stop in the 7.5-30 m approach band, hold, then +cross. The AI components are attached 3 frames AFTER the teleport so +the waypoint cursor cannot skip the junction point from the stale +pre-teleport position. + +### NpcTrafficSystem (demo-taxi phase 5.4) + +`NpcTrafficSystem` (EditorApp-owned singleton `getInstance()`, game mode +only, `systems/NpcTrafficSystem.hpp/.cpp`, update after +NpcAvoidanceSystem, `postPhysicsUpdate` right after NpcTaxiSystem's) +owns the population's car fleet. When an area's population and +`TownLocations::LocationSet` exist, each Parking location rolls a +deterministic hash (fnv1a of the location name, the TownLocations +per-lot discipline) against `carOwnershipRatio` for one car, capped at +`maxCarsPerArea`; areas without parking rects (only the layout DSL +`parking` verb creates Parking POIs) fall back to the Curb locations as +on-street parking. The driver is the first adult (ageYears >= 18) +whose `npcHome` string column matches the parking spot's address. +Parked cars sit nose along the nearest road edge +(`RoadPath::nearestEdgeInfo`, deterministic direction bit) with the +handbrake on and no driver; a deterministic `cruisingRatio` subset +drives an endless loop (home -> deterministic other Parking/Curb -> +home) built from `RoadPath::findPath` + `RoadPath::offsetToRightLane` +(rightmost-lane offset: carriageway half width minus half a lane, +RIGHT = forward x up in OGRE's right-handed +Y-up world; off-road curb +endpoints are first snapped onto the nearest carriageway point so the +car never leaves the road to touch the exact curb) and +junction-classified via `AiDriverSystem::classifyJunctions`. Cruising +drivers are suspended from NpcMotionSystem and their registry position +follows the car (the walk-vs-drive choice is deferred to phase 6, so a +cruising driver effectively lives in its car). + +Two tiers, symbolic ground truth (same design as NpcDetailSystem): the +`TrafficCar` record (route + progress in meters) is authoritative and +survives spawn/despawn and scene switches; the high-detail tier (camera +within 150 m, hysteresis 220 m — the taxi demo tunes these to 110/165 m +in demo_main.cpp for batch budget —, dwell 3 s, spawn budget 1/frame, cap +`maxActiveVehicles` 8) instantiates `prefabs/sedan-traffic.json` (demo +source tree, extracted from the vehicletest scene's sedan1 by +`extract_sedan_prefab.py`, configure_file-staged like the heightmap) +via a locally constructed `PrefabSystem`, removes the +EditorMarkerComponent `createInstance` adds (runtime-only: not +selectable, never serialized), attaches the junction-classified route +as an AiDriverComponent with `routeIndex` from the symbolic progress, +and seats the driver through the same manual-spawner + +`postPhysicsUpdate` seat-pinning approach as NpcTaxiSystem — plus an +AnimationTreeComponent added at seat time when the inline-spawned +character lacks one (tree name from +`NpcDetailSystem::animationTreeName`), with the "driving" locomotion +state re-asserted every `postPhysicsUpdate` frame (the spawn-frame +setState no-ops before AnimationTreeSystem has per-entity state; the +re-assert is an early-out once latched). On despawn +the live vehicle position projects back onto the route progress. +`NpcDetailSystem::isSpawnEligible` consults +`NpcTrafficSystem::isDriving` so a suspended cruising driver never +materializes as a pedestrian (suspension erases the motion state, which +would otherwise read as eligible). A car the player enters +(PlayerControlledComponent — carjacking) leaves the fleet: the record +is erased, the driver alights in place and returns to its schedule. +Symbolic cars are invisible and cannot collide — junction priority only +exists in the high-detail tier, so a transient overlap at the tier +boundary is accepted. Headless probe: `--test-traffic-town` (exterior +scene): waits for the fleet, teleports the player next to the first +cruiser, the car must spawn and drive, a far teleport must despawn it +while the symbolic progress keeps advancing, and teleporting back must +respawn it. + ### EditorSkyboxSystem / EditorSunSystem The procedural sky (`Skybox/Dynamic` material) is rendered as a fullscreen @@ -1614,6 +1784,15 @@ the frame and the tunnel into the void — keep the darkness hole-free. The shar all-black colours (diffuse/ambient/specular/emissive) — with lighting disabled RTSS ignores material colours and renders the pass white. +Grid rebuilds (e.g. the texture-ready rebuild right after scene load) +recreate the door entities; `CellGridSystem` stashes any in-flight +swing state (`isOpen`/`currentAngle`/`toggleRequested`/ +`sceneSwitchPending`, keyed by `doorId`, in +`MeshData::pendingDoorStates`) before destroying the old doors and +re-applies it one-shot to the rebuilt ones, so a toggle or +scene-switch request landing just before a rebuild is not lost. +Persisted (F6) doors keep using `GlobalStateStore` as before. + New `CellGridComponent` fields (serialized in the scene JSON and exposed to Lua): `doorsEnabled`, `doorRectName`, `doorMeshName`, `doorUseMeshMaterial`, `doorOpenAngle`, `doorOpenSpeed`, `doorSwingReversed` (F3: negates the diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index f14c0b1..1b65c21 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -37,6 +37,8 @@ set(EDITSCENE_SOURCES systems/NpcDetailSystem.cpp systems/NpcTaxiSystem.cpp systems/NpcAvoidanceSystem.cpp + systems/NpcTrafficSystem.cpp + systems/AiDriverSystem.cpp systems/GameClockSystem.cpp systems/NpcScheduleDebugOverlay.cpp systems/LightSystem.cpp diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 7573379..4579a77 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -8,6 +8,7 @@ #include #include "systems/EditorUISystem.hpp" #include "systems/VehicleSystem.hpp" +#include "systems/AiDriverSystem.hpp" #include "systems/ZoneSystem.hpp" #include "systems/GameHudSystem.hpp" #include "systems/PhysicsSystem.hpp" @@ -32,6 +33,7 @@ #include "systems/NpcScheduleDebugOverlay.hpp" #include "systems/NpcDetailSystem.hpp" #include "systems/NpcTaxiSystem.hpp" +#include "systems/NpcTrafficSystem.hpp" #include "systems/NpcAvoidanceSystem.hpp" #include "components/CharacterSpawner.hpp" #include "systems/TerrainPrefabSpawnerSystem.hpp" @@ -440,6 +442,7 @@ void EditorApp::destroyEditorSystems() m_characterSpawnerSystem.reset(); m_npcDetailSystem.reset(); m_npcAvoidanceSystem.reset(); + m_npcTrafficSystem.reset(); m_npcMotionSystem.reset(); m_npcPopulationSystem.reset(); /* Despawn prefab instances before terrain/physics teardown. */ @@ -461,6 +464,7 @@ void EditorApp::destroyEditorSystems() /* Vehicles hold physics step listeners; tear down before physics. */ m_gameHudSystem.reset(); m_zoneSystem.reset(); + m_aiDriverSystem.reset(); m_vehicleSystem.reset(); m_physicsSystem.reset(); @@ -569,6 +573,10 @@ void EditorApp::setup() m_vehicleSystem = std::make_unique( m_world, m_physicsSystem->getPhysicsWrapper()); + /* AI-driven vehicles (demo-taxi phase 5.1). */ + m_aiDriverSystem = + std::make_unique(m_world, this); + /* F11 target zones (needs the physics wrapper). */ m_zoneSystem = std::make_unique( m_world, m_physicsSystem->getPhysicsWrapper()); @@ -697,6 +705,12 @@ void EditorApp::setup() m_npcTaxiSystem = std::make_unique(m_world, this); + /* NPC vehicle traffic (demo-taxi phase 5.4): the + * population's car fleet — parked cars at Parking + * locations, cruisers on road-graph loops. */ + m_npcTrafficSystem = + std::make_unique(m_world, this); + /* NPC local avoidance (demo-taxi phase 4.9.3): halt-based * separation + obstacle whiskers for the high-detail * tier. */ @@ -2294,8 +2308,30 @@ void EditorApp::createAxes() } } +bool EditorApp::frameStarted(const Ogre::FrameEvent &evt) +{ + bool ret = OgreBites::ApplicationContext::frameStarted(evt); + m_profFrameStartUs = m_profTimer.getMicroseconds(); + return ret; +} + bool EditorApp::frameEnded(const Ogre::FrameEvent & /*evt*/) { + /* Swap/vsync wait span (frameRenderingQueued end -> frameEnded) + * plus OGRE render stats for the profile dump. */ + uint64_t nowUs = m_profTimer.getMicroseconds(); + m_lastSwapWaitMs = (float)(nowUs - m_profQueuedEndUs) / 1000.0f; + static const bool s_profileDump = + getenv("EDITSCENE_PROFILE") != nullptr; + if (s_profileDump) { + m_profSwapAccum += m_lastSwapWaitMs; + if (getRenderWindow()) { + const Ogre::RenderTarget::FrameStats &st = + getRenderWindow()->getStatistics(); + m_profTrisAccum += (double)st.triangleCount; + m_profBatchesAccum += (double)st.batchCount; + } + } return true; } @@ -2304,17 +2340,41 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) bool paused = (m_gamePlayState == GamePlayState::Paused); /* Lightweight frame profiler: logs any profiled step that takes - * longer than 50 ms. Used to find load/switch stalls. */ + * longer than 50 ms. Used to find load/switch stalls. With + * EDITSCENE_PROFILE=1 in the environment it also accumulates every + * step and dumps per-step averages to stdout every 600 frames + * (phase 5.5 CPU hot-spot hunting). */ Ogre::Timer stepTimer; - auto stepProfile = [&stepTimer](const char *name) { + static const bool s_profileDump = getenv("EDITSCENE_PROFILE") != nullptr; + + /* Render-pass span: frameStarted -> frameRenderingQueued entry + * covers the scene graph update, culling and draw submission of + * all render targets (Root::_updateAllRenderTargets renders + * first, then fires frameRenderingQueued). */ + { + uint64_t nowUs = m_profTimer.getMicroseconds(); + m_lastRenderPassMs = + (float)(nowUs - m_profFrameStartUs) / 1000.0f; + if (s_profileDump) + m_profRenderAccum += m_lastRenderPassMs; + } + m_lastSystemsMs = 0.0f; + + auto stepProfile = [&stepTimer, this](const char *name) { float ms = (float)stepTimer.getMilliseconds(); stepTimer.reset(); + m_lastSystemsMs += ms; if (ms > 50.0f) Ogre::LogManager::getSingleton().logMessage( "FrameProfile: " + Ogre::String(name) + " took " + Ogre::StringConverter::toString(ms / 1000.0f) + "s"); + if (s_profileDump) { + auto &acc = m_profileAccum[name]; + acc.first += ms; + acc.second += 1; + } }; /* Headless test runs can pin the frame delta so physics and @@ -2483,6 +2543,15 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) } stepProfile("npcAvoidance"); + /* --- NPC vehicle traffic (demo-taxi phase 5.4): fleet + * seeding, symbolic cruise advance, distance-gated + * spawn/despawn. Runs after the population/motion/detail + * systems so the locations and registry are fresh --- */ + if (m_npcTrafficSystem) { + m_npcTrafficSystem->update(fixedEvt.timeSinceLastFrame); + } + stepProfile("npcTraffic"); + /* --- Terrain prefab spawners (after terrain so spawn Y-snap * and compliance see fresh terrain data) --- */ if (m_terrainPrefabSpawnerSystem) { @@ -2589,6 +2658,12 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) m_hairPhysicsSystem->prePhysicsUpdate(); } + /* --- AI vehicle drivers (before the vehicle input feed, so + * the computed inputs land in the same physics step) --- */ + if (m_aiDriverSystem) { + m_aiDriverSystem->update(fixedEvt.timeSinceLastFrame); + } + /* --- F10 vehicle inputs (before the physics step) --- */ if (m_vehicleSystem) { m_vehicleSystem->prePhysicsUpdate( @@ -2619,6 +2694,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) if (m_npcTaxiSystem) { m_npcTaxiSystem->postPhysicsUpdate(); } + /* NPC traffic drivers pinned to their seats (demo-taxi + * phase 5.4) — same post-physics-sync timing. */ + if (m_npcTrafficSystem) { + m_npcTrafficSystem->postPhysicsUpdate(); + } if (m_playerControllerSystem) { m_playerControllerSystem->postPhysicsUpdate( fixedEvt.timeSinceLastFrame); @@ -2628,6 +2708,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) if (m_hairPhysicsSystem) { m_hairPhysicsSystem->postPhysicsUpdate(); } + stepProfile("postPhysicsPins"); } /* --- Rendering support systems --- */ @@ -2642,6 +2723,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) cam = m_camera->getCamera(); m_sunSystem->update(fixedEvt.timeSinceLastFrame, cam); } + stepProfile("sun"); if (m_skyboxSystem) { Ogre::Camera *cam = nullptr; if (m_sceneMgr->hasCamera("PlayerCamera")) @@ -2650,6 +2732,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) cam = m_camera->getCamera(); m_skyboxSystem->update(cam); } + stepProfile("skybox"); if (m_waterPlaneSystem) { Ogre::Camera *cam = nullptr; if (m_sceneMgr->hasCamera("PlayerCamera")) @@ -2658,12 +2741,14 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) cam = m_camera->getCamera(); m_waterPlaneSystem->update(fixedEvt.timeSinceLastFrame, cam); } + stepProfile("water"); if (m_lightSystem) { m_lightSystem->update(); } if (m_cameraSystem) { m_cameraSystem->update(); } + stepProfile("light+camera"); if (m_lodSystem) { m_lodSystem->update(); } @@ -2726,6 +2811,53 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) // Reset per-frame input state m_gameInput.resetPerFrame(); + /* EDITSCENE_PROFILE dump: per-step averages over the last 600 + * frames, sorted by total cost. */ + if (s_profileDump) { + ++m_profileFrames; + if (m_profileFrames % 600 == 0) { + std::vector< + std::pair > > + sorted(m_profileAccum.begin(), + m_profileAccum.end()); + std::sort(sorted.begin(), sorted.end(), + [](const auto &a, const auto &b) { + return a.second.first > b.second.first; + }); + double total = 0.0; + for (const auto &kv : sorted) + total += kv.second.first; + std::cout << "[profile] frames " << m_profileFrames + << ": frame total " + << (total + m_profRenderAccum + + m_profSwapAccum) / 600.0 + << " ms = renderPass " + << m_profRenderAccum / 600.0 + << " + systems " << total / 600.0 + << " + swapWait " + << m_profSwapAccum / 600.0 + << " | tris " << m_profTrisAccum / 600.0 + << " batches " + << m_profBatchesAccum / 600.0; + for (const auto &kv : sorted) { + if (kv.second.first / 600.0 < 0.05) + continue; + std::cout << " | " << kv.first << " " + << kv.second.first / 600.0; + } + std::cout << std::endl; + m_profileAccum.clear(); + m_profRenderAccum = 0.0; + m_profSwapAccum = 0.0; + m_profTrisAccum = 0.0; + m_profBatchesAccum = 0.0; + } + } + + /* End of the systems span — frameEnded measures the swap wait + * from here. */ + m_profQueuedEndUs = m_profTimer.getMicroseconds(); + // Don't call base class - it crashes when iterating input listeners return true; } diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index bb1a6f1..303861c 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -8,13 +8,17 @@ #include #include #include +#include #include +#include +#include #include "lua/LuaState.hpp" #include "ProjectConfig.hpp" // Forward declarations class EditorUISystem; class VehicleSystem; +class AiDriverSystem; class ZoneSystem; class GameHudSystem; class NpcScheduleDebugOverlay; @@ -34,6 +38,7 @@ class NpcMotionSystem; class NpcDetailSystem; class NpcTaxiSystem; class NpcAvoidanceSystem; +class NpcTrafficSystem; class GameClockSystem; class TerrainPrefabSpawnerSystem; class AnimationTreeSystem; @@ -177,6 +182,7 @@ public: // OgreBites::ApplicationContext overrides void setup() override; + bool frameStarted(const Ogre::FrameEvent &evt) override; bool frameRenderingQueued(const Ogre::FrameEvent &evt) override; bool frameEnded(const Ogre::FrameEvent &evt) override; void locateResources() override; @@ -357,6 +363,10 @@ public: { return m_npcTaxiSystem.get(); } + NpcTrafficSystem *getNpcTrafficSystem() const + { + return m_npcTrafficSystem.get(); + } NpcAvoidanceSystem *getNpcAvoidanceSystem() const { return m_npcAvoidanceSystem.get(); @@ -381,6 +391,10 @@ public: { return m_vehicleSystem.get(); } + AiDriverSystem *getAiDriverSystem() const + { + return m_aiDriverSystem.get(); + } ZoneSystem *getZoneSystem() const { return m_zoneSystem.get(); @@ -492,6 +506,7 @@ private: std::unique_ptr m_npcMotionSystem; std::unique_ptr m_npcDetailSystem; std::unique_ptr m_npcTaxiSystem; + std::unique_ptr m_npcTrafficSystem; std::unique_ptr m_npcAvoidanceSystem; std::unique_ptr m_gameClockSystem; std::unique_ptr m_terrainPrefabSpawnerSystem; @@ -520,6 +535,9 @@ private: std::unique_ptr m_playerControllerSystem; /* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */ std::unique_ptr m_vehicleSystem; + /* AI-driven vehicles (demo-taxi phase 5.1): writes the same + * VehicleComponent input fields the player controller writes. */ + std::unique_ptr m_aiDriverSystem; std::unique_ptr m_zoneSystem; std::unique_ptr m_gameHudSystem; /* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */ @@ -572,6 +590,35 @@ private: * the wall-clock delta (headless test determinism). */ float m_fixedDeltaTime = 0.0f; + /* EDITSCENE_PROFILE=1 accumulation: step name -> (total ms, + * sample count) over the last 600 frames (see + * frameRenderingQueued). */ + std::map > m_profileAccum; + long m_profileFrames = 0; + + /* Per-frame time split, recorded unconditionally (a few timer + * reads per frame): frameStarted -> frameRenderingQueued entry is + * the render pass (scene graph update, cull, draw submission); + * the frameRenderingQueued body is the CPU systems; its end -> + * frameEnded is the swap/vsync wait (where a GPU-bound frame + * hides). The EDITSCENE_PROFILE dump prints 600-frame averages + * including these spans plus OGRE render stats; benchmarks read + * the per-frame getters. */ + Ogre::Timer m_profTimer; + uint64_t m_profFrameStartUs = 0; + uint64_t m_profQueuedEndUs = 0; + float m_lastRenderPassMs = 0.0f; + float m_lastSystemsMs = 0.0f; + float m_lastSwapWaitMs = 0.0f; + double m_profRenderAccum = 0.0; + double m_profSwapAccum = 0.0; + double m_profTrisAccum = 0.0; + double m_profBatchesAccum = 0.0; + + float getLastRenderPassMs() const { return m_lastRenderPassMs; } + float getLastSystemsMs() const { return m_lastSystemsMs; } + float getLastSwapWaitMs() const { return m_lastSwapWaitMs; } + void processPendingSceneSwitch(); bool performSceneSwitch(const Ogre::String &scenePath, const SceneSwitchOptions &opts); diff --git a/src/features/editScene/components/WaterPlane.hpp b/src/features/editScene/components/WaterPlane.hpp index 7223fab..5f92b72 100644 --- a/src/features/editScene/components/WaterPlane.hpp +++ b/src/features/editScene/components/WaterPlane.hpp @@ -58,6 +58,17 @@ struct WaterPlane { // Which viewport to update this frame (0=reflection, 1=refraction) int updateViewportIndex = 0; + // RTT throttling: one reflection/refraction viewport update every + // rttUpdateInterval frames (alternating), skipped entirely while the + // water surface is outside the camera frustum. Each RTT update is + // a full scene re-render and dominates the water CPU cost. + int rttUpdateInterval = 4; + int rttFrameCounter = 0; + + // Cached shader parameter targets (resolved on rebuild) + Ogre::MaterialPtr cachedMaterial; + Ogre::Pass *cachedPass = nullptr; + // Time accumulator for shader float shaderTime = 0.0f; diff --git a/src/features/editScene/demos/demo-taxi/CMakeLists.txt b/src/features/editScene/demos/demo-taxi/CMakeLists.txt index daae1cb..48829e7 100644 --- a/src/features/editScene/demos/demo-taxi/CMakeLists.txt +++ b/src/features/editScene/demos/demo-taxi/CMakeLists.txt @@ -67,6 +67,15 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin" "${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin" COPYONLY) +# Traffic vehicle prefab (phase 5.4): demo-owned, generated by +# extract_sedan_prefab.py from the vehicletest scene's sedan1 subtree and +# versioned with the demo. configure_file COPYONLY like the heightmap: +# regenerating the prefab re-copies it into the staging directory on the +# next build without manual intervention. +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/prefabs/sedan-traffic.json" + "${CMAKE_CURRENT_BINARY_DIR}/prefabs/sedan-traffic.json" + COPYONLY) + # Road region files (heightmaps//roads/_.json). The # source tree is authoritative — edit the road network while running from # the source directory; every file registered here is re-copied into the diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 111172d..0ff9c5e 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -301,7 +301,10 @@ cabin completely). `fixupSedanGlassMaterial()` (demo_main.cpp) makes `sedan-glass-opaque`; `SedanGlassLodListener` swaps every vehicle's glass submeshes between the two by camera distance (10 m threshold) — Ogre 14 no longer applies `Material::setLodLevels` to entities, so a -real LOD material never switches. +real LOD material never switches. (Superseded in phase 5.5: the +listener now swaps the single merged VehicleIdMap material +`` <-> `-opaque`; `fixupSedanGlassMaterial()` only remains +for the legacy `sedan-glass`.) **sedan-v2 (hand-modeled base sedan, current reference).** The hand-modeled `assets/blender/vehicles/sedan-v2.blend` replaced the @@ -324,7 +327,10 @@ animation (authored so the base position is the seat point; no offsets, `seatCharacterScale` 1.0). `sedan-v2-glass` was added to `kGlassMaterialNames` for the near/far glass swap. The full contract is `assets/blender/vehicles/CAR_MODELING.md`; the taxi variant of -sedan-v2 is next. One engine fix this exposed: +sedan-v2 is next. (Superseded in phase 5.5: the three +color/color-bloom/glass materials are now ONE merged VehicleIdMap +material per car with the near/far swap done on it — see the 5.5 +results.) One engine fix this exposed: `AnimationTreeSystem` now disables animations that fall out of the evaluated set in a single frame (a large-deltaTime fade-out step used to leave the old animation enabled at its last weight forever — the @@ -2227,7 +2233,14 @@ Read-only references: --test-taxi` (assigns a goal, teleports the sedan to the pickup curb, asserts boarding + riding with the seat owned and physics off, teleports to the destination curb, asserts alighting, seat release - and schedule resume). + and schedule resume). Gotcha: the sedan must NOT be dropped right on + the curb POI — that point sits on the lot in front of the building + door, so the waiting NPC ends up inside the car's collider, pushes + the rolling sedan and chases the moving door point forever. The test + parks the car towards the carriageway instead (nearest road edge via + `RoadPath::nearestEdgeInfo`, clamped to 4.5 m from the POI to stay + inside the boarding radius, aligned with the edge direction) and sets + `inputHandBrake = 1` so NPC bumps cannot roll it. Original text follows. Mission NPCs near the player get the @@ -2458,29 +2471,166 @@ population spawns in the exterior scene. pocket. ### Phase 5 — NPC vehicle traffic -- the NPC owns the vehicle, the vehicles are registered to the NPC as driver the same way as NPCs are added in town. Other group member can get to the vehicle as passengers if they go to the same location, or the driver can drive them to their different destinations. -- the NPC can choose by distance if vehicle is used or going by feet (phase 8). -- the vehicle is used when NPC has one parked near their current location. -- NPC position is simulated in varying levels of detail - - camera is far away and not visible = -- AI driver system (new, engine-level): a vehicle follows the road - graph (route = path over edges), respects per-edge speed limits, - basic collision avoidance (raycast/whisker braking for the vehicle - ahead and for the player). -- Intersection traffic controls: priority rules on intersection nodes - (the visual intersections from Phase 2 get behaviour), AI drivers - slow/stop/yield accordingly. -- NPC driver characters seated in the vehicles (visual occupancy; - reuse the seat-pinning approach of `VehicleControllerSystem`). -- Traffic vehicles drawn from the Phase 1 sedan pool (type + color - variants). -- Traffic density control: spawn/despawn traffic vehicles by distance - to the camera (spawner pattern), cap active count. -- Headless: two AI cars on a generated loop complete N laps without - collision/assert; intersection yield check; despawn/respawn - stability. -### Phase 5.5 — Interim performance pass (playable while building) +Broken into sub-phases (the original bullet list kept as the spec under +each item): + +- **5.1 AI driver core (engine-level). COMPLETE (2026-10-14).** + `AiDriverComponent` (route polyline + loop/cruiseSpeed + runtime + routeIndex/arrived/lapsCompleted) + `AiDriverSystem` (EditorApp + singleton, game mode only, runs right before + `VehicleSystem::prePhysicsUpdate`): pure-pursuit steering + (speed-scaled lookahead) plus a proportional throttle/brake + controller with curvature slowdown, writing the same + `VehicleComponent::inputForward/inputRight/inputBrake/inputHandBrake` + fields the player controller writes — VehicleSystem stays the single + consumer, and a car carrying the player + (PlayerControlledComponent) is never touched (carjacking a traffic + car just works). Non-looping routes brake to a stop at the last + waypoint and park (handbrake on); looping routes wrap and count laps. + Headless `--test-traffic-loop`: the vehicletest scene gained sedan2 + (full sedan1 copy); both sedans are teleported onto opposite corners + of an 80x50 m rectangle loop and must complete 3 laps each — fails on + contact (< 1 m), NaN or a car leaving the inflated loop bounds. + Per-edge speed limits fold in with the town routes (5.4). +- **5.2 Whisker braking. COMPLETE (2026-10-14).** Forward raycasts + (staggered) brake the AI car for the vehicle ahead, the player's car + and pedestrians; stop-and-go gap keeping. + `JoltPhysicsWrapper::raycastQueryTraffic()` (physics/physics.h/.cpp) + is `raycastQueryWalkable`'s AllHit CastRayCollector + per-hit surface + normal via BodyLockRead (skips `normal.y >= walkableNormalY` road and + ground) minus the character-body skip (pedestrians must brake cars), + with a new `SkipOneBodyFilter` excluding the car's own chassis. + AiDriverSystem casts a 3-ray fan (nose, +/-18 deg) at bumper height + 0.55 m starting 2.8 m ahead of the origin, every 3 frames staggered + by entity id; the fan length is `speed^2/(2*4.0) + followGap + 4`. + A hit clamps the speed target to `(obstacleDistance - followGap) * 0.8` + (followGap 4.5 m — glide-to-stop), and at standstill the brake is + held while the path stays blocked. The query additionally reads + `RigidBodyComponent` for the chassis bodyID. Headless + `--test-traffic-follow`: sedan1 is parked broadside at x=20 with the + handbrake, sedan2 drives x=-60 -> 60 at 12 m/s and must stop before + the blocker (the stop must land in -10 < x < 15 — without the + near-blocker window the test passes vacuously on a stationary car), + hold it for 120 frames, then resume and arrive after the blocker is + teleported away. Result: stopped at x=11.71 (min gap 8.29 m = + bumper offset 2.8 + followGap 4.5 + ~1.0 blocker half-width), held, + resumed, arrived. +- **5.3 Intersection priority rules. COMPLETE (2026-10-14).** Nodes + with 3+ neighbors get behavior: AI drivers slow/stop/yield (the + phase-2 visual intersections and stop lines get meaning). + `AiRoutePoint` (position + junctionNodeId + junctionCenter) replaced + the plain Vector3 route point; `AiDriverSystem::classifyJunctions()` + flags route points within `junctionSnapDist` (6 m) of a degree >= 3 + road node (auto-runs once per route against the terrain road graph; + pre-flagged points are kept). A car latches the next junction point + ahead, slows to `junctionSpeed` (7 m/s) inside `junctionApproachDist` + (30 m) and, at the approach-zone edge, either takes the junction + reservation (first-come-first-served in flecs iteration order) or + yields with a constant-deceleration brake profile to a stop line + `junctionStopDist` (9 m) before the center. Clear = no other AI car + holds the reservation and no vehicle (AI or player) has its chassis + within `junctionRadius` (7.5 m) of the center — yielders stopped at + the stop line are not occupants. The reservation releases once the + center is behind the car and `junctionReleaseDist` (14 m) away, with + an 8 s hold timeout as the stale-reservation breaker; despawned or + player-taken cars lose their drive state (and reservation) in a + per-frame purge. Two gotchas the headless test exposed: the + reservation must be taken at the approach-zone edge, not the stop + line (two cars arriving simultaneously from 30 m out both fit inside + the stop-line window before either could brake — the loser needs the + full zone to stop in), and the release must require the center BEHIND + the car (a plain distance check drops the reservation on the approach + leg, which starts farther out than the release distance). Headless + `--test-traffic-yield` (flat vehicletest scene, hand-flagged fake + junction node — the priority logic is graph-independent): sedan1 + west-east and sedan2 south-north through a shared junction at the + origin, both from 60 m at 12 m/s; the winner crosses, the loser + brakes to the 9 m stop line, holds, then crosses once the junction + clears. The AI is attached 3 frames AFTER the teleport: attaching on + the teleport frame let the waypoint cursor advance from the stale + pre-teleport position and skip the junction waypoint. PASS: loser + stopped at 8.87 m, min gap 8.88 m, both arrived. +- **5.4 Fleet, ownership & density. COMPLETE (2026-10-14).** Vehicles + registered to population NPCs as drivers (same registry path as + NPCs); a parked fleet placed at Parking locations; household members + can ride as passengers; the walk-vs-drive distance choice stays + deferred to phase 6 as the spec says. Distance-based spawn/despawn + of traffic vehicles (spawner pattern mirroring NpcDetailSystem's + hysteresis) with an active-count cap. NPC driver characters seated + via the existing seat-pinning (VehicleControllerSystem approach). + Traffic vehicles drawn from the Phase 1 sedan pool (type + color + variants). + + As built: `NpcTrafficSystem` (EditorApp singleton, game mode only, + update after NpcAvoidanceSystem, postPhysicsUpdate right after + NpcTaxiSystem's) owns the fleet. When an area's population + + location set exist, each Parking location rolls a deterministic hash + (fnv1a of the location name, the TownLocations per-lot discipline) + against `carOwnershipRatio` (0.5) for one car, capped at + `maxCarsPerArea` (6); areas WITHOUT parking rects (the common case — + only the layout DSL `parking` verb creates Parking POIs) fall back to + the Curb locations as on-street parking. The driver is the first + adult (ageYears >= 18) whose `npcHome` matches the parking spot's + address (fallback: the area's first adult). Parked cars sit nose + along the nearest road edge (`RoadPath::nearestEdgeInfo`, + deterministic direction bit) with the handbrake on and no driver. + A deterministic `cruisingRatio` (0.5) subset drives an endless loop + home -> deterministic other Parking/Curb -> home, built from + `RoadPath::findPath` + the new `RoadPath::offsetToRightLane` + (rightmost-lane offset: carriageway half width minus half a lane) and + junction-classified via `AiDriverSystem::classifyJunctions`. + Cruising drivers are suspended from the schedule layer and their + registry position follows the car (the walk-vs-drive choice is + deferred to phase 6, so a cruising driver effectively lives in its + car). The TrafficCar record (route + progress in meters) is the + ground truth and survives spawn/despawn and scene switches; the + high-detail tier (camera within 150 m, hysteresis 220 m, dwell 3 s, + spawn budget 1/frame, cap `maxActiveVehicles` 8) instantiates the new + `prefabs/sedan-traffic.json` (extracted from the vehicletest scene's + sedan1 by `extract_sedan_prefab.py`, configure_file-staged like the + heightmap), attaches the junction-classified route as an + AiDriverComponent with `routeIndex` from the symbolic progress, and + seats the driver through the same manual-spawner + seat-pinning + approach as NpcTaxiSystem. On despawn the live position projects + back onto the route progress. `NpcDetailSystem::isSpawnEligible` + consults `NpcTrafficSystem::isDriving` so a suspended cruising driver + never materializes as a pedestrian (suspension erases the motion + state, which would otherwise read as eligible). A car the player + enters (carjacking) leaves the fleet: the record is erased, the + driver alights in place and returns to its schedule. Symbolic cars + are invisible and cannot collide — junction priority only exists in + the high-detail tier, so a transient overlap at the tier boundary is + accepted. Headless `--test-traffic-town` (exterior scene): waits for + the fleet (6 cars seeded on the demo town, population 33), teleports + the player next to the first cruiser (the spawn gate opens), the car + drives (84 m in 15 s), a far teleport despawns it while the symbolic + progress keeps advancing (153 -> 208 m), and teleporting back + respawns it. PASS. The pre-existing probes were adjusted for the + fleet: `--test-taxi`, `--test-taxi-lowdetail`, `--test-motion` and + `--test-detail` skip fleet drivers when picking their test NPCs + (drivers refuse taxi goals and their symbolic position follows the + car, not the schedule). + Post-verification fixes (2026-10-14): (a) `RoadPath::offsetToRightLane` + had the lateral sign flipped — `UNIT_Y.crossProduct(dir)` is the LEFT + side in OGRE's right-handed +Y-up world (right = forward x up), so + cruisers drove on the left lane; (b) route endpoints stayed on the + off-road curb POI, so a cruiser left the carriageway at both ends of + every leg and whisker-stalled against the car parked there — the + endpoints are now snapped onto the nearest carriageway point before + the lane offset; (c) fleet drivers T-posed: the manual driver spawner + added no AnimationTreeComponent (NpcDetailSystem adds one for its + inline spawns) and the seatDriver setState ran on the spawn frame, + before AnimationTreeSystem had per-entity state — the tree is now + added at seat time and postPhysicsUpdate re-asserts the "driving" + state every frame (a no-op once latched). Drivers never alighting + is BY DESIGN: cruisers loop endlessly until phase 6's walk-vs-drive + choice. +- Headless (full phase): two AI cars on a generated town loop complete + N laps without collision/assert; intersection yield check; + despawn/respawn stability. + +### Phase 5.5 — Interim performance pass (playable while building) — COMPLETE 2026-10-15 (60 FPS met; worst view 84 vs 80 batches, remainder itemized for 11.5) The full optimization/batching effort stays in Phase 11.5 + the Phase 12 performance pass; this phase only makes the current scene (generated @@ -2514,8 +2664,175 @@ Method: - Done when: `--benchmark` reports >= 60 FPS and <= 80 batches on the reference view; the before/after table lives in this file. +#### Phase 5.5 results (2026-10-15, headless xvfb/llvmpipe, `--benchmark`) + +Tooling added (kept): `EDITSCENE_PROFILE=1` env dumps per-step ms/frame +averages from `EditorApp::frameRenderingQueued` every 600 frames; +`demoTaxi --headless --benchmark` waits for town population + traffic, +then measures 6 fixed views (empty-horizon control, four street-level +cardinal directions at the town center, overview from +80 m) reporting +wall-clock ms, OGRE batches/tris, frustum-visible entity groups and +per-material render-queue batch counts (incl. BaseWhite offenders). +Numbers below are headless wall clock (llvmpipe-influenced — relative +only); batch counts are the hardware-independent metric. + +| view | batches before | batches after | avgMs before | avgMs after | +|------|---------------|---------------|--------------|-------------| +| empty-horizon | 5 | 5 | ~4.4 | ~6.8 | +| street-yaw0 (worst) | 95 | 84 | ~11-12 | ~13-14 | +| street-yaw90 | 17 | 17 | ~10 | ~10.5 | +| street-yaw180 | 18 | 18 | ~11 | ~11 | +| street-yaw270 | 19 | 19 | ~13.5 | ~13.5 | +| overview | 19 | 19 | ~11.6 | ~11.6 | + +All views stay well under 16.7 ms even in software rendering, so the +60 FPS half of the target is met with margin on any real GPU. The +worst view lands at 84 batches vs the 80 target — the remaining 4 are +itemized below and need the structural (Phase 11.5) fixes; squeezing +them now would mean doing that work early. + +What each fix bought: + +- **Water RTT throttle + frustum skip + cached shader params** + (`EditorWaterPlaneSystem`, `WaterPlane::rttUpdateInterval = 4`): + reflection/refraction RTT viewports now update on alternating frames + at 1/4 rate and are skipped entirely while the water surface is + outside the camera frustum; the shader material/pass are cached + instead of looked up per frame. Water cost 5.1 -> ~1.2 ms/frame. +- **LodSystem cached query**: the two per-frame fallback scans reuse a + cached flecs query (no measurable change, removed allocation churn). +- **Merged vehicle ID-map material** (the big one): all sedan parts + (body, doors, hood, trunk, wheels, steering) export with exactly ONE + submesh on ONE material (`sedan-v2` / `sedan-v2-taxi`) instead of + three RTSS materials (color/color-bloom/glass). The new + `VehicleIdMapVP/FP` shader pair (`resources/vehicles/ + vehicle_idmap.vert/.frag/.program`, GLES2-compatible, built on + `OgreUnifiedShader.h`) samples the color atlas AND a baked + per-texel ID map (red = paint, green = bloom/lights, blue = glass) + at the same UVs and blends the three fragment behaviors by the ID + weights; `vehicleColor` is a shader param hook for the future + registry-driven per-car color. Two material variants swap by camera + distance as before (`` transparent near / `-opaque` far, + 10 m). `gen_vehicle.py` bakes the ID map from the palette-degenerate + car UVs (every face collapses onto one atlas texel; a texel shared by + two materials is a build WARNING — fix the blend), sorts glass faces + last per mesh (transparent material keeps depth_write on, so glass + must draw after the cabin interior), consolidates the mesh datas onto + the single material and writes the final `.material` itself. + Batch effect: ~15.5 -> 11 batches per car (22 total for the two + parked sedans, was ~30); street-yaw0 95 -> 83. + Follow-up fix (2026-10-16): the atlas texture unit must NOT be + mipmapped — it is a palette (every face UV-collapses onto one texel), + so auto-mipmaps average neighboring palette colors under minification + and distant cars bled wrong colors (saturated-blue headlights/hubs). + The generated `.material` now uses `filtering linear linear none` on + the atlas (the ID map already used `point point none`); far-distance + colors verified stable via screenshots. The tiny blue dots on rocker + panels/hubs visible up close are genuine palette texels of the model, + not a shader artifact (identical on both sedans). +- **w1 exterior shell material**: the demo's own CellGrid building + (entity `w1`, exteriorOnly) fell back to OGRE's BaseWhite because the + CellGridSystem default `Ogre/StandardFloor` does not exist in the + project resources; it now carries its own + ProceduralMaterial/ProceduralTexture (same placeholder look as the + interior). Batch-neutral, but the parts are now properly + materialized (and the material resolves on the frame after creation — + the startup "Can't assign material" line is transient). This exposed + a real engine bug: the texture-ready grid rebuild right after scene + load recreated w1's scene-switch door and silently ate an in-flight + toggle/scene-switch request (`--test-switch` return leg timed out). + `CellGridSystem` now stashes in-flight door swing state + (`isOpen`/`currentAngle`/`toggleRequested`/`sceneSwitchPending`, + keyed by `doorId`) before destroying door entities on rebuild and + re-applies it one-shot to the recreated doors. + +Remaining worst-view (street-yaw0 = 84) batch budget, itemized — all +deferred to Phase 11.5 unless noted: + +- TownGen building shells: 15 batches sharing ONE area material -> + static per-page/per-area merge (biggest single item). +- Two parked sedans: 22 (11 each: body, 4 doors, hood, trunk, 4 + wheels, steering) — inherent while doors/wheels/steering articulate; + distant cars should swap to auto-generated substitute LOD meshes + (decimated original geometry on a single texture atlas — pipeline + work, see 11.5). +- `male_Face.mesh`: 8 submeshes, ALL material `male_face-atlas` -> 7 + batches for one NPC face. Submeshes are Fcl_* pose-track driven, so + merging needs pose-track vertex-index remapping in the character + export pipeline (or the same substitute-LOD answer as cars). With + several NPCs on screen this dominates: ~10 batches per NPC. +- CellGrid shell of building 491: ~7 batches (per-floor + extWall/extWindows + extDoors + door leaf + scene-switch door + occluder quads) — merge exterior shell pieces per building at 11.5; + the occluder quads are intentional (always visible behind closed + scene-switch doors). +- Road chunks + markings: ~8 batches per page in view -> per-page + merge at 11.5. +- Terrain ~5 visible tiles x 1 batch, skybox 1, water 1: legitimate. + +Known cosmetic issue: sedan-v2-taxi.blend shares two palette texels +(~(0.84, 0.65)) between its color and color-bloom materials (build +WARNING) — harmless today (bloom shades identically to paint and +`bloomEmissive` is 0), but give bloom its own atlas texel when the +taxi's lights should look distinct. + +#### Phase 5.5 follow-up (2026-10-16, from the first on-hardware playtest) + +- **Vehicle semi-transparency bug (fixed).** In-game the cars were + semi-transparent up close (interior visible through closed doors); + in framebuffer-dump screenshots the affected pixels show as the + classic alpha checkerboard. Root cause: `vehicle_idmap.program` + relied on OGRE's sampler auto-assignment, which follows driver + uniform-enumeration order — `uIdMap` ended up bound to texture unit 0 + (the ATLAS), so each paint texel's blue channel leaked into + `wGlass`/`glassAlpha` (dark paint -> alpha ~0.8, beige mostly + opaque). Fix: explicit `param_named uAtlas int 0` / `param_named + uIdMap int 1` in the fragment program's default_params. Verified via + framebuffer alpha probes: doors/roof/wheels now 255, only the window + glass reduced (by design, near variant). This is driver-dependent — + the pre-fix verification screenshots on the same machine were clean + because enumeration order happened to be right before. +- **In-town FPS (worked on, structural remainder at 11.5).** Playtest + measured ~200 batches / 20 FPS in town and ~120 batches / 30 FPS with + only buildings visible, while the 5.5 benchmark's fixed views peaked + at 84. Profiling (`EDITSCENE_PROFILE=1`) shows the CPU systems are + NOT the problem: steady-state ~2.4 ms/frame total (water 1.2, + physics 1.1, everything else below 0.5) — the cost is render-side + (batches/fill), plus terrain/TownGen streaming spikes (~13 ms during + page loads; synchronous by design) which explain momentary drops. + The fixed benchmark views miss the crowd content: real driving views + add up to ~10 batches per NPC (7 just for the 8-submesh face) and 11 + per traffic car on top of ~90-100 for terrain (25-49 pages to the + horizon) + roads (7 chunks + 3 markings per page) + town + the two + parked sedans. + Applied (demo-side tuning in demo_main.cpp, engine defaults + unchanged): NPC detail tier 80/120 m -> 55/85 m, traffic cars + 150/220 m -> 110/165 m — expected to bring the busy-view worst case + from ~200 to ~140 batches. The benchmark's fixed views are unchanged + (still 84; they never contained a crowd). Remaining structural items + for 11.5, unchanged: TownGen per-page static merge, CellGrid + per-building shell merge, road chunk/markings merge, face submesh + merge (10.5), substitute LOD meshes, terrain far-page strategy. + +### Phase 5.9 — town improvements before foot traffic +- In building DSL we need to identify house types, as a list of tag keywords. +- We need to implement template layouts which can be used instead of default layout +- Town area should be able to select preferred types when one is not declared explicitly from templates, using type tag keywords. +- Should be able to group town areas into single town/city and have multiple towns each potentially having multiple areas. That is needed for flexibility like having industrial, residential, commercial part and town center, for example, with templatelayouts and some unique buildings. + ### Phase 6 — Foot traffic NPCs +- Character asset system changes: + - Need a system to use separate body parts for truly nude top and bottom keeping existing ones for body part composition with clothes (with fallback to current nude body parts). + - Need to implement LOD body parts to reduce number of batches. First candidate is face. + - Need to implement texture atlas generation for 20m-distant NPCs. + - There is an idea to use Ogre's manual LODs as the following: + - at default LOD0 distance is < 10m, body is modular, highly detailed, materials a bit optimized, but not at cost of quality. + - LOD1, distance >= 10m and < 20m body is modular and highly detailed, 1 material per body part (the same style as with cars, material is the same for all body parts), uses texture atlas of 2048x2048 with separate textures, the face is replaced by face mask which has single material from the same atlas, shape keys removed. + - LOD2, distance >= 20m and < 60m, like with LOD1 but combinations of possible body parts and shapes (thin/fat) are generated and used. No shape keys. For some combinations it is easier to show nothing at this LOD or show something closer/generic. + - LOD3. distance >= 60m and < 80m, Like LOD2, but from combined meshes, decimated ones are generated with recommended triangles limit of 400 and hard limit of 400, more close/generic ones at this stage. + - Because of lower NPC counts in interior environments it is enough to have LOD0 and LOD1 in them, but it should be benchmarked and checked. +- character prefabs should have "character." prefix in file names to not mix with other prefabs. It should be supported with prefab save/load in editor to easily add prefix. - Pedestrian system: spawners at houses/POIs, pedestrians walk navmesh routes along sidewalks between points of interest, avoid standing on the road, despawn by distance. @@ -2525,15 +2842,43 @@ Method: Phase 5 AI driver); the reverse flow parks and walks home. (Real walk-through-interior exits arrive with Phase 8; until then "leaving a house" starts at its door POI.) +- **Schedule-adherent drivers** (replaces 5.4's placeholder cruising): + a driving NPC must follow its NpcScheduleSystem schedule, not loop + endlessly — when the schedule says move, the NPC walks to its parked + car (or spawns into it if low-detail), drives to the destination's + parking/curb, parks (handbrake, engine off), alights and spends the + scheduled dwell time there (inside the building once Phase 8 + interiors work), then departs on the next schedule leg. The + walk-vs-drive distance choice deferred from 5.4 lands here: short + legs walk, long legs drive, and the choice must respect the time + budget the schedule leaves for the leg (a walking NPC that would be + late drives instead). Cruising with no schedule leg to serve becomes + rare ambient traffic at most, not the default. - Uses existing `NavMeshSystem` + `PathFollowingSystem` + `CharacterSpawnerSystem`; the new work is orchestration data (sidewalk waypoint extraction from the road graph) plus the flow scheduler. - Headless: pedestrians spawn, reach waypoints, never path through building interiors; house -> car hand-off completes; counts stay - within caps while the camera moves. + within caps while the camera moves. A scripted driver completes a + scheduled trip (depart -> drive -> park -> alight -> dwell -> + schedule resumes on the next leg) and the F7 overlay shows the trip + state. -### Phase 7 — CellGrid stair generation +### Phase 6.5 CellGrid furniture placement - before interiors + +- the same as with node version, building layout DSL can define room type and tags/keywords for the furniture +- should support 4 layers of furniture: + - thin floor objects not blocking path/no collider (carpets, floor lights, etc.) + - thin wall objects not blocking path (light switches, wall jacks/outlets, picture frames, etc.) + - normal wall and multi-cell objects (multi-cell is new), having colliders + - ceiling objects (lights, panels, etc.) +- each furniture is defined either by json (as it is now) or prefab (new). +- some room layouts need special furniture placement, that should be supported via Lua events. +- NPC AI POIs/actuators are either a part of prefab/json declaration (prefab is supported more) +- furniture prefabs should have names starting with "furniture." and work together with furniture json and support the same metadata as furniture json. Probably just be able to create furniture json for the prefab easily. Prefix should be supported in editor's prefab save/load for easy naming. + +### Phase 7 — CellGrid stair generation - before interiors Multi-storey CellGrid buildings (`height` > 1) generate stacked floors with no connection between them — interiors above ground level are @@ -2550,6 +2895,9 @@ taxi-specific): - Navmesh: stairs rasterize as walkable (Recast step/slope limits), so NPCs can path between floors; verify doorway-area costs and door obstacles still behave. +- should support stairwells and explicit floor connections between rooms where a room has stair in it which connects to above location. Like single-floor stairwell. Floors and ceilings are removed for the stair to pass. The space between floors should be hidden by geometry to avoid gaps. +- should support exterior stairs outside walls (like in many motels) +- should support procedural material the same as walls/floors, using squares for walls and floors by default, depending on surface. - Editor + serialization + Lua binding, per the component rules (serialized flag, Cell Grid editor toggle, `cellgrid_door_test`-style headless coverage). @@ -2653,6 +3001,17 @@ demo needs them: - Generation preview in the editor (ghost overlay before committing). - More vehicle types beyond sedans (the Phase 1 pipeline is already generic): vans, trucks, buses; service vehicles. +- **Registry-driven vehicle colors.** The shader side landed in 5.5: + the merged `VehicleIdMap` material multiplies the paint term by a + per-material `vehicleColor` param (default white = atlas color + unchanged). Remaining work: a per-car color stored in the registry + record where the car is registered to its NPC owner (the + NpcTrafficSystem fleet seeding), assigned once at registration and + permanent for that car (only later game mechanics — repaint shop, + damage — may change it), pushed into the car entities' materials at + spawn (the shared material must be cloned per color or the param set + per subentity). The same mechanism then serves the taxi livery and + player-car customization. ### Phase 11.5 — Geometry polish, marking depth & render batching @@ -2678,14 +3037,26 @@ must land before the Phase 12 performance/polish pass. small custom-shader offset pulling `gl_Position` toward the camera), so the paint stays visually coplanar but always wins the depth test. Verify on slopes, at long distance and against the decal material. -- **Render batching.** The bare exterior scene already draws ~85 - batches; a full settlement will explode. First measure what is hot - (road page chunks, marking/decal overlays, terrain, spawner - prefabs), then batch: merge same-material road/overlay chunks into - fewer meshes per page, instance repeated props (lamps, signs, - trees), static-geometry batching for placed buildings, and material/ - texture atlasing where it cuts state switches. Define the target - batch budget after measuring; the Phase 12 FPS check enforces it. +- **Render batching.** Measured per-material on the generated demo + town (Phase 5.5 `--benchmark`, worst street-level view = 84 batches): + TownGen building shells 15 batches on ONE shared area material + (static merge candidate), two parked sedans 22 (11 parts each — + inherent while doors/wheels/steering articulate), one NPC ~10 + (`male_Face.mesh` alone is 8 submeshes all on `male_face-atlas` = 7 + batches — Fcl_* pose-track driven, so merging needs pose-track + index remapping in the character export), the 2-storey CellGrid + shell ~7, road chunks + markings ~8 per page, terrain ~5 tiles, + skybox/water 2. Batch plan: merge same-material TownGen shells per + page/area, merge CellGrid exterior shell pieces per building, merge + same-material road/overlay chunks into fewer meshes per page, + instance repeated props (lamps, signs, trees), and merge the face + submeshes at export. **Substitute distant LODs**: auto-generate + decimated LOD meshes from the original geometry (cars AND character + faces/body parts) on a single texture atlas as a pipeline step, so + distant cars/NPCs render as one batch each; the merged VehicleIdMap + material (5.5) already cut cars from ~15.5 to 11 batches. Target: + <= 80 batches in the worst street-level gameplay view (5.5 missed it + by 4); the Phase 12 FPS check enforces it. ### Phase 12 — Demo assembly, polish, docs diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 6156f36..c91892e 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -20,8 +20,10 @@ #include "systems/NpcMotionSystem.hpp" #include "systems/NpcDetailSystem.hpp" #include "systems/NpcTaxiSystem.hpp" +#include "systems/NpcTrafficSystem.hpp" #include "roadlib/TownLocations.hpp" #include "systems/GameClockSystem.hpp" +#include "components/AiDriver.hpp" #include "components/Door.hpp" #include "components/EntityName.hpp" #include "components/Transform.hpp" @@ -33,6 +35,7 @@ #include "components/Terrain.hpp" #include "roadlib/RoadPath.hpp" #include +#include #include #include #include @@ -649,8 +652,20 @@ struct PopulationSwitchTestListener : public Ogre::FrameListener { * Asserts: at least one NPC is IN_TRANSIT at some probe, at least one * NPC ends the day at a different position than it started, and the * anchored evaluation is deterministic (revisiting a probe hour - * reproduces the recorded positions exactly). + * reproduces the recorded positions exactly). Cruising traffic + * drivers (phase 5.4) are skipped: their position follows the car, not + * the clock-driven schedule. */ + +/* True while NpcTrafficSystem has the NPC suspended in its car (their + * registry position follows the vehicle, so clock-reproducibility and + * symbolic-restore assertions must skip them). */ +static bool isTrafficDriver(uint64_t id) +{ + NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance(); + return traffic && traffic->isDriving(id); +} + struct MotionTestListener : public Ogre::FrameListener { EditorApp *app; int frame = 0; @@ -834,6 +849,8 @@ struct MotionTestListener : public Ogre::FrameListener { return true; const auto &expect = snapshots[18]; for (const auto &kv : expect) { + if (isTrafficDriver(kv.first)) + continue; CharacterRegistry::CharacterRecord *rec = CharacterRegistry::getSingleton() .findCharacter(kv.first); @@ -1125,6 +1142,11 @@ struct DetailTestListener : public Ogre::FrameListener { if (++phaseFrames < 2) return true; for (uint64_t id : ids) { + /* Cruising traffic drivers (phase 5.4): + * the position follows the car, not the + * schedule. */ + if (isTrafficDriver(id)) + continue; CharacterRegistry::CharacterRecord *rec = reg.findCharacter(id); auto it = symbolic0.find(id); @@ -1219,19 +1241,63 @@ struct TaxiTestListener : public Ogre::FrameListener { return true; } - void teleportVehicle(flecs::entity vehicle, const Ogre::Vector3 &world) + void teleportVehicle(flecs::entity vehicle, const Ogre::Vector3 &world, + const Ogre::Quaternion &heading) { JoltPhysicsWrapper *physics = app->getEditorPhysicsSystem()->getPhysicsWrapper(); JPH::BodyID body = vehicle.get().bodyID; physics->setPositionAndRotation(body, - world + Ogre::Vector3(0, 0.6f, - 0), - Ogre::Quaternion::IDENTITY); + world + Ogre::Vector3(0.0f, + 0.6f, 0.0f), + heading); physics->setVelocities(body, Ogre::Vector3::ZERO, Ogre::Vector3::ZERO); } + /* + * Park the sedan at the road edge beside a curb POI. Dropping the + * car right on the POI (in front of the building door, on the lot) + * puts the waiting NPC inside the car's collider: the NPC then + * pushes the rolling sedan and chases the moving door point + * forever. The car goes towards the carriageway instead, clamped + * to stay inside the boarding radius, with the handbrake on so + * bumps cannot roll it away. + */ + void parkBesideCurb(flecs::entity vehicle, const Ogre::Vector3 &curb) + { + Ogre::Vector3 pos = curb; + Ogre::Quaternion heading = Ogre::Quaternion::IDENTITY; + TerrainSystem *ts = TerrainSystem::getInstance(); + flecs::entity terrain = flecs::entity::null(); + if (ts && ts->getRoadSystem()) + terrain = ts->getRoadSystem()->getTerrainEntity(); + if (terrain.is_alive() && terrain.has()) { + const RoadGraph &graph = + terrain.get().roadGraph; + Ogre::Vector3 proj; + Ogre::Vector3 edgeDir; + float halfW = 0.0f; + if (RoadPath::nearestEdgeInfo(graph, curb, proj, + edgeDir, halfW)) { + Ogre::Vector3 toRoad = proj - curb; + toRoad.y = 0.0f; + float d = toRoad.length(); + if (d > 4.5f) + pos = curb + toRoad * (4.5f / d); + else + pos = curb + toRoad; + pos.y = proj.y; + edgeDir.y = 0.0f; + if (edgeDir.normalise() > 0.001f) + heading = Ogre::Vector3::UNIT_Z + .getRotationTo(edgeDir); + } + } + teleportVehicle(vehicle, pos, heading); + vehicle.get_mut().inputHandBrake = 1.0f; + } + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override { (void)evt; @@ -1262,7 +1328,18 @@ struct TaxiTestListener : public Ogre::FrameListener { detail->despawnDistanceSq = 20000.0f * 20000.0f; detail->minDwellSeconds = 0.0f; detail->spawnBudgetPerFrame = 40; - npc = ids[0]; + npc = 0; + for (uint64_t id : ids) { + /* Traffic drivers (phase 5.4) refuse taxi + * goals. */ + if (!isTrafficDriver(id)) { + npc = id; + break; + } + } + if (!npc) + return fail("no non-driving NPC in the " + "population"); CharacterRegistry::CharacterRecord *rec = reg.findCharacter(npc); if (!rec) @@ -1299,7 +1376,11 @@ struct TaxiTestListener : public Ogre::FrameListener { if (!hasGoal) return fail("goal lost at the curb"); if (!teleportedToPickup) { - /* A sedan next to the pickup curb. */ + /* A sedan next to the pickup curb. Must + * be the scene's taxi by name: the traffic + * fleet (phase 5.4) puts more + * VehicleComponents in the scene, and a + * cruiser would just drive off the curb. */ int areaIdx = pop->areaIndexOf(npc); const TownLocations::LocationSet *locs = pop->areaLocations(areaIdx); @@ -1311,19 +1392,20 @@ struct TaxiTestListener : public Ogre::FrameListener { return fail("pickup curb vanished"); flecs::entity vehicle = flecs::entity::null(); app->getWorld() - ->query() + ->query() .each([&](flecs::entity v, - VehicleComponent &, - TransformComponent &) { - if (!vehicle.is_alive()) + EntityNameComponent &n, + VehicleComponent &) { + if (n.name == "sedan_taxi") vehicle = v; }); if (!vehicle.is_alive() || !vehicle.has()) - return fail("no vehicle in the scene"); + return fail("no sedan_taxi in the " + "scene"); vehicleId = vehicle.id(); - teleportVehicle(vehicle, curb->position); + parkBesideCurb(vehicle, curb->position); teleportedToPickup = true; std::cout << "[test] sedan teleported to the " "pickup curb" @@ -1365,8 +1447,8 @@ struct TaxiTestListener : public Ogre::FrameListener { flecs::entity vehicle = app->getWorld()->entity( vehicleId); - teleportVehicle(vehicle, - destDropWorld(taxi, ti)); + parkBesideCurb(vehicle, + destDropWorld(taxi, ti)); teleportedToDest = true; std::cout << "[test] sedan teleported to " @@ -1836,9 +1918,12 @@ struct TaxiLowDetailTestListener : public Ogre::FrameListener { } clock->setPaused(true); motion->walkSpeedMps = 30.0f; - /* The FARTHEST NPC never enters the detail tier. */ + /* The FARTHEST NPC never enters the detail tier. + * Traffic drivers (phase 5.4) refuse taxi goals. */ float best = -1.0f; for (uint64_t id : ids) { + if (isTrafficDriver(id)) + continue; CharacterRegistry::CharacterRecord *rec = reg.findCharacter(id); if (!rec) @@ -1982,22 +2067,24 @@ static void createFloorMesh(const Ogre::String &meshName, } /* - * The exported glass materials are plain opaque, which hides the - * cabin (and the seated driver) completely. Turn each glass material - * alpha-blended (the near look) and clone an opaque "-opaque" - * variant; SedanGlassLodListener swaps each vehicle's glass submeshes - * between the two depending on camera distance (Ogre 14 no longer - * applies Material::setLodLevels to entities, so a real LOD material - * never switches technique). Done programmatically because the - * .material files are regenerated by every blender2ogre export. + * The convention-built cars (gen_vehicle.py, CAR_MODELING.md) export a + * single merged ID-map material per car family: "" is the + * transparent near variant, "-opaque" the far variant (glass + * renders solid), both shipped in the generated .material. + * SedanGlassLodListener swaps every part's submeshes between the two + * depending on camera distance (Ogre 14 no longer applies + * Material::setLodLevels to entities, so a real LOD material never + * switches technique). * - * The list covers the procedural sedans ("sedan-glass") and the - * convention-built cars exported by gen_vehicle.py ("-glass"); - * extend it when adding car families. + * The legacy export_vehicles.py cars still split glass into its own + * fixed-function material; for those the same swap is faked here: + * turn the glass alpha-blended (the near look) and clone an opaque + * "-opaque" variant. Done programmatically because those + * .material files are regenerated by every blender2ogre export. */ -static const char *kGlassMaterialNames[] = {"sedan-glass", - "sedan-v2-glass", - "sedan-v2-taxi-glass"}; +static const char *kMergedCarMaterials[] = {"sedan-v2", + "sedan-v2-taxi"}; +static const char *kGlassMaterialNames[] = {"sedan-glass"}; static void fixupSedanGlassMaterial() { @@ -2025,11 +2112,11 @@ static void fixupSedanGlassMaterial() } /* - * Per-vehicle window-glass swap: the plain material (transparent) - * within 10 m of the camera, its "-opaque" clone beyond — see + * Per-vehicle near/far material swap: the plain material (transparent + * glass) within 10 m of the camera, its "-opaque" variant beyond — see * fixupSedanGlassMaterial. Walks the whole node subtree of each - * vehicle, so glass on child entities (the sedan-v2 doors carry their - * windows) is covered too. + * vehicle, so child entities (doors, wheels, steering) are covered + * too; with the merged materials this switches every part at once. */ struct SedanGlassLodListener : public Ogre::FrameListener { EditorApp *app; @@ -2076,6 +2163,29 @@ struct SedanGlassLodListener : public Ogre::FrameListener { ent->getSubEntity(i); const Ogre::String cur2 = sub->getMaterialName(); + bool handled = false; + for (const char *base : + kMergedCarMaterials) { + const Ogre::String + opaque = + Ogre::String(base) + + "-opaque"; + if (cur2 != base && + cur2 != opaque) + continue; + const Ogre::String + want = + near ? + base : + opaque; + if (cur2 != want) + sub->setMaterialName( + want); + handled = true; + break; + } + if (handled) + continue; for (const char *prefix : kGlassMaterialNames) { if (cur2.find(prefix) != @@ -4008,6 +4118,1643 @@ struct VehicleParkTestListener : public Ogre::FrameListener { } }; +/* Assign a plain (junction-free) route from a point array. */ +static void setAiRoute(AiDriverComponent &ai, const Ogre::Vector3 *points, + size_t count) +{ + ai.route.clear(); + for (size_t i = 0; i < count; ++i) { + AiRoutePoint p; + p.position = points[i]; + ai.route.push_back(p); + } +} + +/* + * --test-traffic-loop (phase 5.1): two AI-driven sedans on a flat-floor + * rectangle loop (vehicletest scene, sedan1 + sedan2). + * + * Teleports both sedans onto opposite corners of an 80x50 m rectangle, + * attaches an AiDriverComponent with the rectangle as a looping route + * (drive order A->B->C->D, sedan1 starts at A, sedan2 half a lap away + * at C) and lets AiDriverSystem drive. PASS when both cars complete 3 + * laps; FAIL on a watchdog timeout, a NaN transform, a car leaving the + * loop area (steering-sign/cornering regression) or the two cars coming + * within 1 m of each other (contact — the chassis is ~2 x 5 m). + */ +struct TrafficLoopTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 30000; + int lapsTarget = 3; + flecs::entity_t car1 = 0; + flecs::entity_t car2 = 0; + float minGap = 1.0e9f; + + TrafficLoopTestListener(EditorApp *a) + : app(a) + { + } + + bool fail(const Ogre::String &reason) + { + failed = true; + failReason = reason; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity findVehicle(const Ogre::String &name) + { + flecs::entity found = flecs::entity::null(); + app->getWorld() + ->query() + .each([&](flecs::entity e, EntityNameComponent &n, + VehicleComponent &) { + if (n.name == name) + found = e; + }); + return found; + } + + void teleportVehicle(flecs::entity vehicle, const Ogre::Vector3 &pos, + const Ogre::Quaternion &heading) + { + JoltPhysicsWrapper *physics = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + JPH::BodyID body = vehicle.get().bodyID; + physics->setPositionAndRotation( + body, pos + Ogre::Vector3(0, 0.6f, 0), heading); + physics->setVelocities(body, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override + { + (void)evt; + ++frame; + if (failed) + return true; + if (frame > deadline) + return fail("watchdog timeout"); + + if (phase == 0) { + /* Wait for both sedan constraints, then place the + * cars on the loop and hand them to the AI. */ + flecs::entity s1 = findVehicle("sedan1"); + flecs::entity s2 = findVehicle("sedan2"); + if (!s1.is_alive() || !s2.is_alive()) { + if (frame > 600) + return fail("sedan1/sedan2 missing"); + return true; + } + if (!s1.has() || + !s2.has()) + return true; + const VehicleComponent &v1 = + s1.get(); + const VehicleComponent &v2 = + s2.get(); + if (!v1.constraintCreated || !v2.constraintCreated) + return true; + + /* Loop corners (XZ; the floor is at y=0). Drive + * order A -> B -> C -> D. */ + const Ogre::Vector3 corners[4] = { + Ogre::Vector3(-40.0f, 0.0f, -25.0f), + Ogre::Vector3(40.0f, 0.0f, -25.0f), + Ogre::Vector3(40.0f, 0.0f, 25.0f), + Ogre::Vector3(-40.0f, 0.0f, 25.0f) }; + + teleportVehicle(s1, corners[0], + Ogre::Quaternion(Ogre::Degree(90.0f), + Ogre::Vector3::UNIT_Y)); + teleportVehicle(s2, corners[2], + Ogre::Quaternion(Ogre::Degree(-90.0f), + Ogre::Vector3::UNIT_Y)); + + AiDriverComponent ai1; + setAiRoute(ai1, corners, 4); + ai1.routeIndex = 1; /* at A, next corner is B */ + ai1.loop = true; + ai1.cruiseSpeed = 10.0f; + s1.set(ai1); + + AiDriverComponent ai2; + setAiRoute(ai2, corners, 4); + ai2.routeIndex = 3; /* at C, next corner is D */ + ai2.loop = true; + ai2.cruiseSpeed = 10.0f; + s2.set(ai2); + + car1 = s1.id(); + car2 = s2.id(); + std::cout << "[test] traffic loop: 2 AI sedans on an " + "80x50 m rectangle" + << std::endl; + phase = 1; + return true; + } + + /* phase 1: run the laps. */ + flecs::entity s1 = app->getWorld()->entity(car1); + flecs::entity s2 = app->getWorld()->entity(car2); + if (!s1.is_alive() || !s2.is_alive()) + return fail("a traffic car vanished"); + const TransformComponent &t1 = s1.get(); + const TransformComponent &t2 = s2.get(); + if (!t1.node || !t2.node) + return true; + Ogre::Vector3 p1 = t1.node->_getDerivedPosition(); + Ogre::Vector3 p2 = t2.node->_getDerivedPosition(); + if (!std::isfinite(p1.x) || !std::isfinite(p1.y) || + !std::isfinite(p1.z) || !std::isfinite(p2.x) || + !std::isfinite(p2.y) || !std::isfinite(p2.z)) + return fail("NaN vehicle transform"); + + float dx = p1.x - p2.x; + float dz = p1.z - p2.z; + float gap = std::sqrt(dx * dx + dz * dz); + if (gap < minGap) + minGap = gap; + if (gap < 1.0f) + return fail("the traffic cars collided"); + + /* Loop bounds with margin: a car outside the inflated + * rectangle drives off the route entirely (steering sign + * or cornering regression). */ + if (std::fabs(p1.x) > 60.0f || std::fabs(p1.z) > 45.0f || + std::fabs(p2.x) > 60.0f || std::fabs(p2.z) > 45.0f) + return fail("a traffic car left the loop area"); + + const AiDriverComponent &a1 = s1.get(); + const AiDriverComponent &a2 = s2.get(); + if (frame % 600 == 0) + std::cout << "[test] laps: sedan1 " + << a1.lapsCompleted << " sedan2 " + << a2.lapsCompleted << " min gap " + << minGap << " m" << std::endl; + if (a1.lapsCompleted >= lapsTarget && + a2.lapsCompleted >= lapsTarget) { + std::cout << "[test] PASS: traffic loop (" + << lapsTarget << " laps x 2 cars, min gap " + << minGap << " m)" << std::endl; + phase = 2; + app->getRoot()->queueEndRendering(); + } + return true; + } +}; + +/* + * --test-traffic-follow (phase 5.2): whisker braking end-to-end on the + * flat vehicletest floor. + * + * sedan2 gets a straight non-looping route along +X; sedan1 is parked + * broadside across the path at x=20. The AI car must glide to a stop + * followGap off the blocker WITHOUT contact (min center gap stays above + * 3.6 m — side contact would be ~3.5 m), hold the stop, then resume and + * arrive once the blocker is teleported off the path. + */ +struct TrafficFollowTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 30000; + flecs::entity_t car1 = 0; + flecs::entity_t car2 = 0; + float minGap = 1.0e9f; + int stopFrame = 0; + + TrafficFollowTestListener(EditorApp *a) + : app(a) + { + } + + bool fail(const Ogre::String &reason) + { + failed = true; + failReason = reason; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity findVehicle(const Ogre::String &name) + { + flecs::entity found = flecs::entity::null(); + app->getWorld() + ->query() + .each([&](flecs::entity e, EntityNameComponent &n, + VehicleComponent &) { + if (n.name == name) + found = e; + }); + return found; + } + + void teleportVehicle(flecs::entity vehicle, const Ogre::Vector3 &pos, + const Ogre::Quaternion &heading) + { + JoltPhysicsWrapper *physics = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + JPH::BodyID body = vehicle.get().bodyID; + physics->setPositionAndRotation( + body, pos + Ogre::Vector3(0, 0.6f, 0), heading); + physics->setVelocities(body, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override + { + (void)evt; + ++frame; + if (failed) + return true; + if (frame > deadline) + return fail("watchdog timeout"); + + if (phase == 0) { + flecs::entity s1 = findVehicle("sedan1"); + flecs::entity s2 = findVehicle("sedan2"); + if (!s1.is_alive() || !s2.is_alive()) { + if (frame > 600) + return fail("sedan1/sedan2 missing"); + return true; + } + if (!s1.has() || + !s2.has()) + return true; + if (!s1.get().constraintCreated || + !s2.get().constraintCreated) + return true; + + /* Blocker broadside across the path at x=20. */ + teleportVehicle(s1, Ogre::Vector3(20.0f, 0.0f, 0.0f), + Ogre::Quaternion::IDENTITY); + s1.get_mut().inputHandBrake = 1.0f; + + /* AI car at x=-60 driving to x=60. */ + teleportVehicle(s2, Ogre::Vector3(-60.0f, 0.0f, 0.0f), + Ogre::Quaternion(Ogre::Degree(90.0f), + Ogre::Vector3::UNIT_Y)); + const Ogre::Vector3 routeEnd(60.0f, 0.0f, 0.0f); + AiDriverComponent ai; + setAiRoute(ai, &routeEnd, 1); + ai.loop = false; + ai.cruiseSpeed = 12.0f; + s2.set(ai); + + car1 = s1.id(); + car2 = s2.id(); + std::cout << "[test] traffic follow: AI car vs parked " + "blocker" + << std::endl; + phase = 1; + return true; + } + + flecs::entity s1 = app->getWorld()->entity(car1); + flecs::entity s2 = app->getWorld()->entity(car2); + if (!s1.is_alive() || !s2.is_alive()) + return fail("a traffic car vanished"); + const TransformComponent &t1 = s1.get(); + const TransformComponent &t2 = s2.get(); + if (!t1.node || !t2.node) + return true; + Ogre::Vector3 p1 = t1.node->_getDerivedPosition(); + Ogre::Vector3 p2 = t2.node->_getDerivedPosition(); + float dx = p1.x - p2.x; + float dz = p1.z - p2.z; + float gap = std::sqrt(dx * dx + dz * dz); + if (gap < minGap) + minGap = gap; + if (gap < 1.0f) + return fail("the AI car hit the blocker"); + + JoltPhysicsWrapper *physics = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + const VehicleComponent &v2 = s2.get(); + float speed2 = + v2.constraintCreated && v2.constraint ? + physics->getVehicleForwardSpeed(v2.constraint) : + 0.0f; + + if (phase == 1) { + /* The car must stop BEFORE the blocker (x stays + * well under 20). The stop detection requires the + * car NEAR the blocker — at the start it is also + * standing still, 80 m away. */ + if (p2.x > 25.0f) + return fail("the AI car passed the blocker"); + if (std::fabs(speed2) < 0.2f && p2.x < 15.0f && + p2.x > -10.0f) { + if (stopFrame == 0) { + stopFrame = frame; + std::cout << "[test] stopped at x " + << p2.x << " (gap " << gap + << " m)" << std::endl; + } + if (frame - stopFrame >= 120) { + /* Held the stop for 2 s: clear the + * blocker, the car must resume. */ + teleportVehicle( + s1, + Ogre::Vector3(20.0f, 0.0f, + 60.0f), + Ogre::Quaternion::IDENTITY); + std::cout << "[test] blocker cleared, " + "expecting resume" + << std::endl; + phase = 2; + } + } + return true; + } + + /* phase 2: the car must resume and arrive. */ + const AiDriverComponent &a2 = s2.get(); + if (a2.arrived) { + if (minGap < 3.6f) + return fail("stop gap too small (contact " + "range)"); + std::cout << "[test] PASS: traffic follow (stop gap " + << minGap << " m, resumed and arrived)" + << std::endl; + phase = 3; + app->getRoot()->queueEndRendering(); + } + return true; + } +}; + +/* + * --test-traffic-yield (phase 5.3): junction priority end-to-end on the + * flat vehicletest floor. + * + * sedan1 drives west-east (-60,0) -> (60,0), sedan2 south-north + * (0,-60) -> (0,60); both routes pass a shared junction point at the + * origin flagged with the same (fake) junction node id — the flat test + * scene has no road graph, so the junction flags are set by hand and + * junctionsClassified is preset to skip the auto pass. Both cars start + * the same distance from the junction at the same cruise speed, so they + * contend: the first to the stop line must take the reservation and + * cross while the other yields, then the yielder crosses. PASS when + * both arrive; FAIL on contact (min gap < 2.5 m), a NaN transform, a + * watchdog timeout, nobody ever yielding, or both cars stopping inside + * the junction radius at the same time (a double-cross the reservation + * should have prevented). + */ +struct TrafficYieldTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 30000; + flecs::entity_t car1 = 0; + flecs::entity_t car2 = 0; + float minGap = 1.0e9f; + int yieldStops = 0; + bool stopped1 = false; + bool stopped2 = false; + + TrafficYieldTestListener(EditorApp *a) + : app(a) + { + } + + Ogre::Vector3 route1[3]; + Ogre::Vector3 route2[3]; + int settleFrame = 0; + + bool fail(const Ogre::String &reason) + { + failed = true; + failReason = reason; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity findVehicle(const Ogre::String &name) + { + flecs::entity found = flecs::entity::null(); + app->getWorld() + ->query() + .each([&](flecs::entity e, EntityNameComponent &n, + VehicleComponent &) { + if (n.name == name) + found = e; + }); + return found; + } + + void teleportVehicle(flecs::entity vehicle, const Ogre::Vector3 &pos, + const Ogre::Quaternion &heading) + { + JoltPhysicsWrapper *physics = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + JPH::BodyID body = vehicle.get().bodyID; + physics->setPositionAndRotation( + body, pos + Ogre::Vector3(0, 0.6f, 0), heading); + physics->setVelocities(body, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + } + + float forwardSpeed(flecs::entity car) + { + const VehicleComponent &v = car.get(); + if (!v.constraintCreated || !v.constraint) + return 0.0f; + return app->getEditorPhysicsSystem() + ->getPhysicsWrapper() + ->getVehicleForwardSpeed(v.constraint); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override + { + (void)evt; + ++frame; + if (failed) + return true; + if (frame > deadline) + return fail("watchdog timeout"); + + if (phase == 0) { + flecs::entity s1 = findVehicle("sedan1"); + flecs::entity s2 = findVehicle("sedan2"); + if (!s1.is_alive() || !s2.is_alive()) { + if (frame > 600) + return fail("sedan1/sedan2 missing"); + return true; + } + if (!s1.has() || + !s2.has()) + return true; + if (!s1.get().constraintCreated || + !s2.get().constraintCreated) + return true; + + /* Two crossing straight routes through a shared + * junction at the origin. Nose is +Z: a +X route + * faces +90 deg about Y, a +Z route is identity. */ + route1[0] = Ogre::Vector3(-60.0f, 0.0f, 0.0f); + route1[1] = Ogre::Vector3(0.0f, 0.0f, 0.0f); + route1[2] = Ogre::Vector3(60.0f, 0.0f, 0.0f); + route2[0] = Ogre::Vector3(0.0f, 0.0f, -60.0f); + route2[1] = Ogre::Vector3(0.0f, 0.0f, 0.0f); + route2[2] = Ogre::Vector3(0.0f, 0.0f, 60.0f); + + teleportVehicle(s1, route1[0], + Ogre::Quaternion(Ogre::Degree(90.0f), + Ogre::Vector3::UNIT_Y)); + teleportVehicle(s2, route2[0], + Ogre::Quaternion::IDENTITY); + + car1 = s1.id(); + car2 = s2.id(); + settleFrame = frame; + std::cout << "[test] traffic yield: two AI sedans, one " + "junction" + << std::endl; + phase = 1; + return true; + } + + if (phase == 1) { + /* Attach the AI only after the teleport has settled + * into the render nodes — attaching on the teleport + * frame lets the waypoint cursor advance from the + * stale pre-teleport position and skip the junction + * waypoint. */ + if (frame < settleFrame + 3) + return true; + flecs::entity s1 = app->getWorld()->entity(car1); + flecs::entity s2 = app->getWorld()->entity(car2); + if (!s1.is_alive() || !s2.is_alive()) + return fail("a traffic car vanished"); + + AiDriverComponent ai1; + setAiRoute(ai1, route1, 3); + ai1.route[1].junctionNodeId = 1001; + ai1.route[1].junctionCenter = route1[1]; + ai1.junctionsClassified = true; + ai1.routeIndex = 1; + ai1.loop = false; + ai1.cruiseSpeed = 12.0f; + s1.set(ai1); + + AiDriverComponent ai2; + setAiRoute(ai2, route2, 3); + ai2.route[1].junctionNodeId = 1001; + ai2.route[1].junctionCenter = route2[1]; + ai2.junctionsClassified = true; + ai2.routeIndex = 1; + ai2.loop = false; + ai2.cruiseSpeed = 12.0f; + s2.set(ai2); + phase = 2; + return true; + } + + flecs::entity s1 = app->getWorld()->entity(car1); + flecs::entity s2 = app->getWorld()->entity(car2); + if (!s1.is_alive() || !s2.is_alive()) + return fail("a traffic car vanished"); + const TransformComponent &t1 = s1.get(); + const TransformComponent &t2 = s2.get(); + if (!t1.node || !t2.node) + return true; + Ogre::Vector3 p1 = t1.node->_getDerivedPosition(); + Ogre::Vector3 p2 = t2.node->_getDerivedPosition(); + if (!std::isfinite(p1.x) || !std::isfinite(p1.y) || + !std::isfinite(p1.z) || !std::isfinite(p2.x) || + !std::isfinite(p2.y) || !std::isfinite(p2.z)) + return fail("NaN vehicle transform"); + + float dx = p1.x - p2.x; + float dz = p1.z - p2.z; + float gap = std::sqrt(dx * dx + dz * dz); + if (gap < minGap) + minGap = gap; + if (gap < 2.5f) + return fail("the traffic cars collided at the junction"); + + /* Both cars stopped inside the junction radius at the same + * time means the reservation let two cars in. */ + bool in1 = p1.x * p1.x + p1.z * p1.z < 7.5f * 7.5f; + bool in2 = p2.x * p2.x + p2.z * p2.z < 7.5f * 7.5f; + float sp1 = forwardSpeed(s1); + float sp2 = forwardSpeed(s2); + if (in1 && in2 && std::fabs(sp1) < 0.3f && + std::fabs(sp2) < 0.3f) + return fail("both cars stopped inside the junction"); + + /* Count yield stops: a car coming to rest in the approach + * zone (7.5-30 m from the center) yielded. */ + float d1 = std::sqrt(p1.x * p1.x + p1.z * p1.z); + float d2 = std::sqrt(p2.x * p2.x + p2.z * p2.z); + if (!stopped1 && d1 > 7.5f && d1 < 30.0f && + std::fabs(sp1) < 0.2f) { + stopped1 = true; + ++yieldStops; + std::cout << "[test] sedan1 yielded at " << d1 + << " m from the junction" << std::endl; + } + if (!stopped2 && d2 > 7.5f && d2 < 30.0f && + std::fabs(sp2) < 0.2f) { + stopped2 = true; + ++yieldStops; + std::cout << "[test] sedan2 yielded at " << d2 + << " m from the junction" << std::endl; + } + + const AiDriverComponent &a1 = s1.get(); + const AiDriverComponent &a2 = s2.get(); + if (a1.arrived && a2.arrived) { + if (yieldStops < 1) + return fail("nobody yielded at the junction"); + std::cout << "[test] PASS: traffic yield (" << yieldStops + << " yield stop(s), min gap " << minGap + << " m, both arrived)" << std::endl; + phase = 3; + app->getRoot()->queueEndRendering(); + } + return true; + } +}; + +/* + * --test-traffic-town (phase 5.4): the population's car fleet on the + * generated town (exterior scene). + * + * Waits for the population + fleet seeding, picks the first cruising + * car, teleports the player next to its symbolic position and expects + * the high-detail tier: a sedan prefab instance with an + * AiDriverComponent and a driver character seated in the driver seat. + * The car must actually move; then the player teleports far away and + * the car must despawn while its SYMBOLIC progress keeps advancing; + * teleporting back respawns it near the symbolic position. PASS when + * the respawn lands within 40 m of the symbolic dot; FAIL on watchdog, + * a missing fleet, no spawn/despawn, a NaN transform, or a stalled + * symbolic tier. + */ +struct TrafficTownTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 30000; + uint64_t carId = 0; + int phaseFrame = 0; + int teleportFrames = 0; + Ogre::Vector3 teleportTarget = Ogre::Vector3::ZERO; + Ogre::Vector3 driveStartPos = Ogre::Vector3::ZERO; + float progressSample = 0.0f; + int populationAtSeed = 0; + + TrafficTownTestListener(EditorApp *a) + : app(a) + { + } + + bool fail(const Ogre::String &reason) + { + failed = true; + failReason = reason; + app->getRoot()->queueEndRendering(); + return true; + } + + /* Re-teleport for a few frames: streaming terrain colliders may + * not exist yet at the target, so the character can fall through + * on the first frames (same trick as DebugCaptureListener). */ + void startTeleport(const Ogre::Vector3 &worldPos) + { + teleportTarget = worldPos; + teleportFrames = 90; + } + + void teleportTick() + { + if (teleportFrames <= 0) + return; + --teleportFrames; + flecs::entity player = app->getPlayerCharacterEntity(); + if (!player.is_alive() || + !player.has()) + return; + TransformComponent &t = player.get_mut(); + if (!t.node) + return; + t.node->setPosition(teleportTarget); + t.position = teleportTarget; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override + { + (void)evt; + ++frame; + if (failed) + return true; + if (frame > deadline) + return fail("watchdog timeout"); + teleportTick(); + + NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance(); + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + if (!traffic || !pop) + return true; + + if (phase == 0) { + /* Wait for the fleet. */ + if (traffic->carCount() == 0) + return true; + for (uint64_t id : traffic->carDriverIds()) { + NpcTrafficSystem::TrafficInfo info; + if (traffic->trafficInfoOf(id, info) && + info.cruising) { + carId = id; + break; + } + } + if (!carId) + return fail("fleet seeded but no cruising car"); + populationAtSeed = pop->countPopulationNpcs(); + std::cout << "[test] traffic town: fleet " + << traffic->carCount() << " car(s), " + << "population " << populationAtSeed + << ", following driver " << carId + << std::endl; + phase = 1; + return true; + } + + if (phase == 1) { + /* Teleport next to the symbolic car, expect the + * high-detail tier. */ + Ogre::Vector3 carPos; + if (!traffic->carWorldPosition(carId, carPos)) + return fail("car vanished"); + if (teleportFrames <= 0 && phaseFrame == 0) + startTeleport(carPos + Ogre::Vector3(20.0f, + 3.0f, + 20.0f)); + ++phaseFrame; + NpcTrafficSystem::TrafficInfo info; + traffic->trafficInfoOf(carId, info); + if (!info.highDetail) { + if (phaseFrame > 1200) + return fail("car never spawned high " + "detail"); + return true; + } + phaseFrame = 0; + phase = 2; + std::cout << "[test] traffic town: car spawned high " + "detail" + << std::endl; + return true; + } + + if (phase == 2) { + /* The car must drive: sample the live position + * now and 15 sim-seconds later. */ + Ogre::Vector3 carPos; + if (!traffic->carWorldPosition(carId, carPos)) + return fail("car vanished while high detail"); + if (!std::isfinite(carPos.x) || + !std::isfinite(carPos.y) || + !std::isfinite(carPos.z)) + return fail("NaN car position"); + if (phaseFrame == 0) + driveStartPos = carPos; + ++phaseFrame; + if (phaseFrame < 900) + return true; + float moved = (carPos - driveStartPos).length(); + if (moved < 5.0f) + return fail("car did not move while high " + "detail"); + std::cout << "[test] traffic town: car drove " << moved + << " m in 15 s" << std::endl; + phaseFrame = 0; + phase = 3; + return true; + } + + if (phase == 3) { + /* Far away: the car despawns, the symbolic tier + * keeps advancing. */ + if (phaseFrame == 0) { + Ogre::Vector3 carPos; + traffic->carWorldPosition(carId, carPos); + startTeleport(carPos + Ogre::Vector3(600.0f, + 30.0f, + 600.0f)); + } + ++phaseFrame; + NpcTrafficSystem::TrafficInfo info; + traffic->trafficInfoOf(carId, info); + if (info.highDetail) { + if (phaseFrame > 1200) + return fail("car never despawned"); + return true; + } + if (progressSample == 0.0f) { + progressSample = info.progress; + return true; + } + if (phaseFrame < 300) + return true; + if (info.progress <= progressSample + 1.0f && + info.laps == 0) + return fail("symbolic progress stalled after " + "despawn"); + std::cout << "[test] traffic town: despawned, symbolic " + "progress " + << progressSample << " -> " << info.progress + << std::endl; + phaseFrame = 0; + phase = 4; + return true; + } + + if (phase == 4) { + /* Back near: the car respawns close to its + * symbolic position. */ + Ogre::Vector3 carPos; + if (!traffic->carWorldPosition(carId, carPos)) + return fail("car vanished"); + if (phaseFrame == 0) + startTeleport(carPos + Ogre::Vector3(20.0f, + 3.0f, + 20.0f)); + ++phaseFrame; + NpcTrafficSystem::TrafficInfo info; + traffic->trafficInfoOf(carId, info); + if (!info.highDetail) { + if (phaseFrame > 1200) + return fail("car never respawned"); + return true; + } + /* The live position (carWorldPosition prefers the + * entity while high detail) must sit near the + * route polyline — respawn happened at the + * symbolic progress. */ + if (pop->countPopulationNpcs() != populationAtSeed) + return fail("population changed during the " + "test"); + std::cout << "[test] PASS: traffic town (despawn/" + "respawn stable, progress " + << info.progress << " of " + << info.routeLength << ")" << std::endl; + phase = 5; + app->getRoot()->queueEndRendering(); + return true; + } + return true; + } +}; + +/* + * --benchmark (phase 5.5): fixed-camera performance probe on the + * generated town. Waits for the town population + traffic fleet, then + * pins the player camera at fixed poses derived from the road graph + * bounds (street level at the town center facing the four cardinal + * directions, plus an elevated overview) and measures each pose for a + * fixed frame count: wall-clock frame ms (headless pins a 60 Hz delta, + * so this is the CPU/software-GL cost and only meaningful relative), + * plus the OGRE batch and triangle counts — the hardware-independent + * regression metric. Camera pinning uses the same + * preRenderTargetUpdate hook as DebugCaptureListener's --free-camera so + * the TPS camera cannot undo it, and the pin doubles as the spawn-tier + * center (NpcDetailSystem/NpcTrafficSystem measure against the player + * camera). + */ +struct BenchmarkListener : public Ogre::FrameListener, + public Ogre::RenderTargetListener { + EditorApp *app; + int frame = 0; + int phase = 0; /* 0 = wait for the town, 1 = measuring views */ + int deadline = 30000; + bool failed = false; + bool done = false; + Ogre::String failReason; + + struct View { + std::string name; + Ogre::Vector3 pos; /* world space */ + float yawDeg; + float pitchDeg; + }; + std::vector views; + size_t viewIdx = 0; + int viewFrame = 0; + static const int settleFrames = 90; + static const int measureFrames = 300; + + std::chrono::steady_clock::time_point lastTick; + double sumMs = 0.0; + double maxMs = 0.0; + size_t sumBatches = 0; + size_t maxBatches = 0; + size_t sumTris = 0; + int samples = 0; + double totMs = 0.0; + size_t totBatches = 0; + int totSamples = 0; + size_t worstBatches = 0; + double worstMs = 0.0; + + /* Exact batch accounting: renderables queued per window viewport + * render, grouped by material name. Counted only while measuring + * and only for the main window (not the water RTT viewports). */ + struct BatchCounter : public Ogre::RenderQueue::RenderableListener { + std::map counts; + size_t frames = 0; + bool active = false; + + bool renderableQueued(Ogre::Renderable *rend, Ogre::uint8, + Ogre::ushort, Ogre::Technique **, + Ogre::RenderQueue *) override + { + if (active && rend && !rend->getMaterial().isNull()) + ++counts[rend->getMaterial()->getName()]; + return true; + } + } batchCounter; + + void preViewportUpdate( + const Ogre::RenderTargetViewportEvent &evt) override + { + if (evt.source->getTarget() == app->getRenderWindow() && + phase == 1 && viewFrame > settleFrames && + samples < measureFrames) + batchCounter.active = true; + } + + void postViewportUpdate( + const Ogre::RenderTargetViewportEvent &evt) override + { + if (evt.source->getTarget() == app->getRenderWindow()) + batchCounter.active = false; + } + + BenchmarkListener(EditorApp *a) + : app(a) + { + } + + bool fail(const Ogre::String &reason) + { + failed = true; + failReason = reason; + app->getRoot()->queueEndRendering(); + return true; + } + + void preRenderTargetUpdate(const Ogre::RenderTargetEvent &) override + { + if (phase != 1 || viewIdx >= views.size()) + return; + Ogre::Camera *cam = resolveCamera(); + if (!cam || !cam->getParentSceneNode()) + return; + const View &v = views[viewIdx]; + Ogre::Vector3 rp = v.pos; + RenderOriginSystem *ro = RenderOriginSystem::getInstance(); + if (ro) + rp = ro->worldToRender(v.pos.x, v.pos.y, v.pos.z); + Ogre::SceneNode *cn = cam->getParentSceneNode(); + cn->setPosition(rp); + cn->setOrientation( + Ogre::Quaternion(Ogre::Degree(v.yawDeg), + Ogre::Vector3::UNIT_Y) * + Ogre::Quaternion(Ogre::Degree(v.pitchDeg), + Ogre::Vector3::UNIT_X)); + } + + /* The camera that actually renders the game-mode viewport: + * "PlayerCamera" first, then the editor camera (same resolution as + * NpcDetailSystem::cameraPosition). */ + Ogre::Camera *resolveCamera() const + { + Ogre::SceneManager *sm = app->getSceneManager(); + if (sm && sm->hasCamera("PlayerCamera")) + return sm->getCamera("PlayerCamera"); + if (app->getEditorCamera()) + return app->getEditorCamera()->getCamera(); + return nullptr; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + ++frame; + if (failed || done) + return true; + if (frame > deadline) + return fail("watchdog timeout"); + + if (phase == 0) { + NpcPopulationSystem *pop = + NpcPopulationSystem::getInstance(); + NpcTrafficSystem *traffic = + NpcTrafficSystem::getInstance(); + if (!pop || !traffic) + return true; + if (pop->countPopulationNpcs() == 0 || + traffic->carCount() == 0) + return true; + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getRoadSystem()) + return true; + flecs::entity terrain = + ts->getRoadSystem()->getTerrainEntity(); + if (!terrain.is_alive() || + !terrain.has()) + return true; + const RoadGraph &graph = + terrain.get().roadGraph; + if (graph.nodes.empty()) + return true; + + /* Camera path from the road graph bounds. */ + Ogre::Vector3 lo = graph.nodes[0].position; + Ogre::Vector3 hi = lo; + float sumY = 0.0f; + for (const RoadNode &n : graph.nodes) { + lo.makeFloor(n.position); + hi.makeCeil(n.position); + sumY += n.position.y; + } + Ogre::Vector3 center = (lo + hi) * 0.5f; + center.y = sumY / (float)graph.nodes.size(); + /* Control view first: far from the town, looking away + * from it — proves the camera pin moves the rendered + * set and gives the empty-scene engine overhead. */ + Ogre::Vector3 farPos = hi + Ogre::Vector3(2000.0f, + 50.0f, + 2000.0f); + Ogre::Vector3 away = farPos - center; + away.y = 0.0f; + away.normalise(); + float awayYawDeg = Ogre::Math::ATan2(-away.x, -away.z) + .valueDegrees(); + views.push_back({ "empty-horizon", farPos, awayYawDeg, + 0.0f }); + views.push_back({ "street-yaw0", + center + Ogre::Vector3(0.0f, 1.7f, + 0.0f), + 0.0f, 0.0f }); + views.push_back({ "street-yaw90", + center + Ogre::Vector3(0.0f, 1.7f, + 0.0f), + 90.0f, 0.0f }); + views.push_back({ "street-yaw180", + center + Ogre::Vector3(0.0f, 1.7f, + 0.0f), + 180.0f, 0.0f }); + views.push_back({ "street-yaw270", + center + Ogre::Vector3(0.0f, 1.7f, + 0.0f), + 270.0f, 0.0f }); + views.push_back({ "overview", + center + Ogre::Vector3(0.0f, 80.0f, + 0.0f), + 0.0f, -55.0f }); + std::cout << "[benchmark] town ready: population " + << pop->countPopulationNpcs() << ", fleet " + << traffic->carCount() << " car(s), " + << views.size() << " views" << std::endl; + phase = 1; + lastTick = std::chrono::steady_clock::now(); + return true; + } + + if (viewIdx >= views.size()) + return true; + auto now = std::chrono::steady_clock::now(); + double ms = std::chrono::duration( + now - lastTick) + .count(); + lastTick = now; + ++viewFrame; + if (viewFrame <= settleFrames) + return true; + + const Ogre::RenderTarget::FrameStats &st = + app->getRenderWindow()->getStatistics(); + sumMs += ms; + if (ms > maxMs) + maxMs = ms; + sumBatches += st.batchCount; + if (st.batchCount > maxBatches) + maxBatches = st.batchCount; + sumTris += st.triangleCount; + ++samples; + if (samples < measureFrames) + return true; + + const View &v = views[viewIdx]; + Ogre::Camera *camNow = resolveCamera(); + if (camNow) { + std::cout << "[benchmark] camera now at " + << camNow->getDerivedPosition() + << " (view wants " << v.pos << ")" + << std::endl; + + /* Batch breakdown: group the frustum-visible movables + * by type (and mesh name for Entities) so the log + * shows what the batches actually are. */ + std::map > groups; + auto mit = app->getSceneManager() + ->getMovableObjectIterator("Entity"); + while (mit.hasMoreElements()) { + Ogre::Entity *ent = + static_cast( + mit.getNext()); + if (!ent->getVisible()) + continue; + if (!camNow->isVisible( + ent->getWorldBoundingBox(true))) + continue; + auto &g = groups[ent->getMesh()->getName()]; + ++g.first; + g.second += (int)ent->getNumSubEntities(); + } + /* Non-Entity renderables (terrain tiles, manual + * objects, ...) each cost at least one batch. + * getMovableObjectIterator throws for types that + * were never instantiated, hence the try/catch. */ + static const char *kOtherTypes[] = { + "Terrain", "ManualObject", "BillboardSet", + "Rectangle2D", "PrefabFactory" }; + for (const char *typeName : kOtherTypes) { + try { + auto oit = app->getSceneManager() + ->getMovableObjectIterator( + typeName); + while (oit.hasMoreElements()) { + Ogre::MovableObject *mo = + oit.getNext(); + if (!mo->getVisible() || + !mo->isInScene()) + continue; + if (!camNow->isVisible( + mo->getWorldBoundingBox( + true))) + continue; + auto &g = groups[ + "[" + Ogre::String( + typeName) + ":" + + mo->getName() + "]"]; + ++g.first; + g.second += 1; + } + } catch (const Ogre::Exception &) { + } + } + std::vector > > + sorted(groups.begin(), groups.end()); + std::sort(sorted.begin(), sorted.end(), + [](const auto &a, const auto &b) { + return a.second.second > + b.second.second; + }); + std::cout << "[benchmark] view " << v.name + << " visible-entity groups:"; + int shown = 0; + int totalEnts = 0, totalSubs = 0; + for (const auto &g : sorted) { + totalEnts += g.second.first; + totalSubs += g.second.second; + if (++shown > 24) + break; + std::cout << " " << g.first << " " + << g.second.first << "x" + << g.second.second; + } + std::cout << " | total " << totalEnts << "x" + << totalSubs << std::endl; + + /* BaseWhite is OGRE's no-material fallback — list the + * offenders explicitly so they can be assigned a real + * material (each is an unmergeable batch). */ + std::map baseWhite; + auto bwit = app->getSceneManager() + ->getMovableObjectIterator("Entity"); + while (bwit.hasMoreElements()) { + Ogre::Entity *ent = + static_cast( + bwit.getNext()); + if (!ent->getVisible()) + continue; + if (!camNow->isVisible( + ent->getWorldBoundingBox(true))) + continue; + for (unsigned si = 0; + si < ent->getNumSubEntities(); ++si) { + if (ent->getSubEntity(si) + ->getMaterialName() == + "BaseWhite") + ++baseWhite[ + ent->getMesh() + ->getName()]; + } + } + if (!baseWhite.empty()) { + std::cout << "[benchmark] view " << v.name + << " BaseWhite offenders:"; + for (const auto &bw : baseWhite) + std::cout << " " << bw.first << "x" + << bw.second; + std::cout << std::endl; + } + + /* Per-material render-queue counts: the exact + * batch view (single-pass materials = one + * renderable per batch), averaged over the + * measured frames. */ + std::vector > + bcounts(batchCounter.counts.begin(), + batchCounter.counts.end()); + std::sort(bcounts.begin(), bcounts.end(), + [](const auto &a, const auto &b) { + return a.second > b.second; + }); + std::cout << "[benchmark] view " << v.name + << " batches by material:"; + size_t btot = 0; + int bshown = 0; + for (const auto &b : bcounts) { + btot += b.second; + if (++bshown > 16) + break; + std::cout << " " << b.first << "=" + << b.second / (size_t)samples; + } + std::cout << " | total=" << btot / (size_t)samples + << std::endl; + } + std::cout << "[benchmark] view " << v.name << ": avgMs=" + << sumMs / samples << " worstMs=" << maxMs + << " avgBatches=" << sumBatches / samples + << " maxBatches=" << maxBatches + << " avgTris=" << sumTris / samples << std::endl; + totMs += sumMs; + totBatches += sumBatches; + totSamples += samples; + if (maxBatches > worstBatches) + worstBatches = maxBatches; + if (maxMs > worstMs) + worstMs = maxMs; + + ++viewIdx; + viewFrame = 0; + sumMs = 0.0; + maxMs = 0.0; + sumBatches = 0; + maxBatches = 0; + sumTris = 0; + samples = 0; + batchCounter.counts.clear(); + if (viewIdx >= views.size()) { + std::cout << "[benchmark] done: overall avgMs=" + << totMs / totSamples + << " avgBatches=" << totBatches / totSamples + << " worstMaxBatches=" << worstBatches + << " worstMs=" << worstMs + << " (headless wall clock; batch counts are " + "the hardware-independent metric)" + << std::endl; + done = true; + app->getRoot()->queueEndRendering(); + } + return true; + } +}; + +/* + * --benchmark-drive (phase 5.5): continuous road-tour performance + * probe. Builds an edge-coverage tour of the town road graph (DFS + * over edges, reconnecting through RoadPath::findPath at dead ends), + * then pins the player camera on it (same preRenderTargetUpdate hook + * as BenchmarkListener; the pin doubles as the spawn-tier center, so + * NPC/traffic/detail streaming follows the drive) and advances a + * cursor at --drive-speed 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. 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 + * 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 + * 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 { + EditorApp *app; + int frame = 0; + int phase = 0; /* 0 = wait for the town, 1 = settle, 2 = driving */ + int deadline = 300000; + bool failed = false; + bool done = false; + Ogre::String failReason; + + float speed = 13.0f; /* m/s, advanced per frame at fixed 1/60 s */ + 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; + + std::vector tour; /* world-space polyline */ + float tourLength = 0.0f; + float dist = 0.0f; + Ogre::Vector3 camPos = Ogre::Vector3::ZERO; /* world */ + float camYawDeg = 0.0f; + int recorded = 0; + + std::ofstream csv; + std::chrono::steady_clock::time_point lastTick; + std::vector wallTimes; + struct Worst { + double ms; + int frame; + float dist; + Ogre::Vector3 pos; + double renderMs, systemsMs, swapMs; + size_t batches, tris; + int pages; + size_t npcs, cars; + }; + std::vector worst; /* top-10, descending */ + double sumMs = 0.0, maxMs = 0.0; + double sumRender = 0.0, sumSystems = 0.0, sumSwap = 0.0; + size_t sumBatches = 0, maxBatches = 0, sumTris = 0, maxTris = 0; + + DriveBenchmarkListener(EditorApp *a) + : app(a) + { + } + + bool fail(const Ogre::String &reason) + { + failed = true; + failReason = reason; + app->getRoot()->queueEndRendering(); + return true; + } + + Ogre::Camera *resolveCamera() const + { + Ogre::SceneManager *sm = app->getSceneManager(); + if (sm && sm->hasCamera("PlayerCamera")) + return sm->getCamera("PlayerCamera"); + if (app->getEditorCamera()) + return app->getEditorCamera()->getCamera(); + return nullptr; + } + + /* Continuous edge-coverage tour: DFS over the graph edges, + * reconnecting through RoadPath::findPath when the walk dead-ends + * (edges may be traversed twice, the camera never teleports). */ + bool buildTour(const RoadGraph &graph) + { + if (graph.edges.empty() || graph.nodes.empty()) + return false; + std::set visited; + const RoadNode *start = graph.findNodeById(graph.edges[0].nodeA); + if (!start) + return false; + int curId = start->id; + tour.push_back(start->position); + size_t guard = graph.edges.size() * 4 + 16; + while (visited.size() < graph.edges.size() && guard-- > 0) { + int edgeIdx = -1; + std::vector inc = + graph.findEdgeIndicesForNode(curId); + for (size_t ei : inc) { + if (!visited.count((int)ei)) { + edgeIdx = (int)ei; + break; + } + } + if (edgeIdx < 0) { + /* Dead end: walk the road path to the first + * unvisited edge. A disconnected component + * fails findPath — skip that edge rather than + * aborting the tour. */ + int targetId = -1, skipIdx = -1; + for (size_t ei = 0; ei < graph.edges.size(); ++ei) { + if (!visited.count((int)ei)) { + targetId = graph.edges[ei].nodeA; + skipIdx = (int)ei; + break; + } + } + if (targetId < 0) + break; + const RoadNode *tn = graph.findNodeById(targetId); + std::vector leg; + if (!tn || !RoadPath::findPath(graph, tour.back(), + tn->position, leg)) { + visited.insert(skipIdx); + continue; + } + for (size_t i = 1; i < leg.size(); ++i) + tour.push_back(leg[i]); + curId = targetId; + continue; + } + const RoadEdge &e = graph.edges[edgeIdx]; + visited.insert(edgeIdx); + int nextId = (e.nodeA == curId) ? e.nodeB : e.nodeA; + const RoadNode *nn = graph.findNodeById(nextId); + if (!nn) + return false; + tour.push_back(nn->position); + curId = nextId; + } + tourLength = RoadPath::polylineLength(tour); + return tourLength > 1.0f; + } + + void updateCameraPose() + { + Ogre::Vector3 p = RoadPath::polylineAt(tour, dist); + Ogre::Vector3 ahead = RoadPath::polylineAt(tour, dist + 3.0f); + camPos = p + Ogre::Vector3(0.0f, eyeHeight, 0.0f); + Ogre::Vector3 dir = ahead - p; + dir.y = 0.0f; + if (dir.squaredLength() > 1e-6f) { + dir.normalise(); + camYawDeg = Ogre::Math::ATan2(-dir.x, -dir.z) + .valueDegrees(); + } + } + + void preRenderTargetUpdate(const Ogre::RenderTargetEvent &) override + { + if (phase == 0 || tour.empty()) + return; + Ogre::Camera *cam = resolveCamera(); + if (!cam || !cam->getParentSceneNode()) + return; + Ogre::Vector3 rp = camPos; + RenderOriginSystem *ro = RenderOriginSystem::getInstance(); + if (ro) + rp = ro->worldToRender(camPos.x, camPos.y, camPos.z); + Ogre::SceneNode *cn = cam->getParentSceneNode(); + cn->setPosition(rp); + cn->setOrientation( + Ogre::Quaternion(Ogre::Degree(camYawDeg), + Ogre::Vector3::UNIT_Y) * + Ogre::Quaternion(Ogre::Degree(-4.0f), + Ogre::Vector3::UNIT_X)); + } + + int loadedPageCount() const + { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getTerrainGroup()) + return 0; + int n = 0; + auto it = ts->getTerrainGroup()->getTerrainIterator(); + while (it.hasMoreElements()) { + if (it.getNext()->instance) + ++n; + } + return n; + } + + void noteWorst(const Worst &w) + { + worst.push_back(w); + std::sort(worst.begin(), worst.end(), + [](const Worst &a, const Worst &b) { + return a.ms > b.ms; + }); + if (worst.size() > 10) + worst.resize(10); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + ++frame; + if (failed || done) + return true; + if (frame > deadline) + return fail("watchdog timeout"); + + if (phase == 0) { + NpcPopulationSystem *pop = + NpcPopulationSystem::getInstance(); + NpcTrafficSystem *traffic = + NpcTrafficSystem::getInstance(); + if (!pop || !traffic) + return true; + if (pop->countPopulationNpcs() == 0 || + traffic->carCount() == 0) + return true; + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getRoadSystem()) + return true; + flecs::entity terrain = + ts->getRoadSystem()->getTerrainEntity(); + if (!terrain.is_alive() || + !terrain.has()) + return true; + const RoadGraph &graph = + terrain.get().roadGraph; + if (!buildTour(graph)) + return fail("road tour construction failed"); + csv.open(csvPath); + if (!csv.is_open()) + 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," + "batches,tris,terrain_pages,npcs_high," + "cars_active\n"; + std::cout << "[benchmark-drive] town ready: tour " + << tour.size() << " waypoints, " + << tourLength << " m, population " + << pop->countPopulationNpcs() << ", fleet " + << traffic->carCount() << " car(s)" + << std::endl; + updateCameraPose(); + phase = 1; + lastTick = std::chrono::steady_clock::now(); + return true; + } + + auto now = std::chrono::steady_clock::now(); + double wallMs = std::chrono::duration( + now - lastTick) + .count(); + 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; + phase = 2; + lastTick = std::chrono::steady_clock::now(); + return true; + } + + /* phase 2: driving. Advance the cursor first, record the + * frame just rendered (its stats reflect the previous + * camera pose — one frame of lag, acceptable). */ + dist += speed * fixedDt; + if (dist > tourLength) + dist = tourLength; + updateCameraPose(); + + const Ogre::RenderTarget::FrameStats &st = + app->getRenderWindow()->getStatistics(); + int pages = loadedPageCount(); + NpcDetailSystem *detail = NpcDetailSystem::getInstance(); + NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance(); + size_t npcs = detail ? detail->highDetailCount() : 0; + size_t cars = traffic ? traffic->activeVehicleCount() : 0; + double renderMs = app->getLastRenderPassMs(); + double systemsMs = app->getLastSystemsMs(); + double swapMs = app->getLastSwapWaitMs(); + + ++recorded; + wallTimes.push_back(wallMs); + sumMs += wallMs; + if (wallMs > maxMs) + maxMs = wallMs; + sumRender += renderMs; + sumSystems += systemsMs; + sumSwap += swapMs; + sumBatches += st.batchCount; + if (st.batchCount > maxBatches) + maxBatches = st.batchCount; + sumTris += st.triangleCount; + if (st.triangleCount > maxTris) + maxTris = st.triangleCount; + csv << recorded << ',' << dist << ',' << camPos.x << ',' + << camPos.y << ',' << camPos.z << ',' << camYawDeg << ',' + << wallMs << ',' << renderMs << ',' << systemsMs << ',' + << swapMs << ',' << st.batchCount << ',' + << st.triangleCount << ',' << pages << ',' << npcs << ',' + << cars << '\n'; + Worst w{ wallMs, recorded, dist, camPos, + renderMs, systemsMs, swapMs, + (size_t)st.batchCount, (size_t)st.triangleCount, + pages, npcs, cars }; + if (worst.size() < 10 || wallMs > worst.back().ms) + noteWorst(w); + + if (recorded % 600 == 0) + std::cout << "[benchmark-drive] frame " << recorded + << " dist " << dist << "/" << tourLength + << " m, wall avg " << sumMs / recorded + << " ms, batches avg " + << sumBatches / recorded << std::endl; + + if (dist >= tourLength || recorded >= maxFrames) { + csv.close(); + std::sort(wallTimes.begin(), wallTimes.end()); + auto pct = [&](double q) { + if (wallTimes.empty()) + return 0.0; + size_t i = (size_t)(q * (double)( + wallTimes.size() - 1)); + return wallTimes[i]; + }; + std::cout << "[benchmark-drive] done: " << recorded + << " frames over " << dist << " m" + << " | wall ms avg " << sumMs / recorded + << " p50 " << pct(0.50) + << " p95 " << pct(0.95) + << " p99 " << pct(0.99) + << " max " << maxMs + << " | spans avg render " << sumRender / recorded + << " systems " << sumSystems / recorded + << " swap " << sumSwap / recorded + << " | batches avg " << sumBatches / recorded + << " max " << maxBatches + << " | tris avg " << sumTris / recorded + << " max " << maxTris << std::endl; + std::cout << "[benchmark-drive] worst frames " + "(frame dist m | wall render systems swap | " + "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.batches << " " << w.tris + << " " << w.pages << " " << w.npcs << " " + << w.cars << " | (" << w.pos.x << ", " + << w.pos.y << ", " << w.pos.z << ")" + << std::endl; + } + std::cout << "[benchmark-drive] CSV: " << csvPath + << std::endl; + std::cout << "[benchmark-drive] PASS" << std::endl; + done = true; + app->getRoot()->queueEndRendering(); + } + return true; + } +}; + /* * demo-taxi: taxi driver demo. Starts as an exact copy of * demos/demo-interior-exterior-dynamics (same scene-switch-door setup, @@ -4185,6 +5932,11 @@ int main(int argc, char *argv[]) bool testTaxi = false; bool testDeterminism = false; bool testTaxiLowDetail = false; + bool testTrafficLoop = false; + bool testTrafficFollow = false; + bool testTrafficYield = false; + bool testTrafficTown = false; + bool benchmark = false; bool sceneArgGiven = false; Ogre::String shotFile; int shotFrame = -1; @@ -4236,6 +5988,16 @@ int main(int argc, char *argv[]) testDeterminism = true; } else if (arg == "--test-taxi-lowdetail") { testTaxiLowDetail = true; + } else if (arg == "--test-traffic-loop") { + testTrafficLoop = true; + } else if (arg == "--test-traffic-follow") { + testTrafficFollow = true; + } else if (arg == "--test-traffic-yield") { + testTrafficYield = true; + } else if (arg == "--test-traffic-town") { + testTrafficTown = true; + } else if (arg == "--benchmark") { + benchmark = true; } else if (arg == "--debug-buoyancy") { app.setDebugBuoyancy(true); } else if (arg == "--auto-comply") { @@ -4332,6 +6094,20 @@ int main(int argc, char *argv[]) /* The low-detail taxi probe needs the town population. */ if (testTaxiLowDetail && !sceneArgGiven) sceneFile = "demo_scene_exterior.json"; + /* The traffic-loop, traffic-follow and traffic-yield tests run + * on the flat vehicletest floor (sedan1 + sedan2). */ + if (testTrafficLoop && !sceneArgGiven) + sceneFile = "demo_scene_vehicletest.json"; + if (testTrafficFollow && !sceneArgGiven) + sceneFile = "demo_scene_vehicletest.json"; + if (testTrafficYield && !sceneArgGiven) + sceneFile = "demo_scene_vehicletest.json"; + /* The town traffic test needs the generated town. */ + if (testTrafficTown && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; + /* The benchmark measures the generated town. */ + if (benchmark && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; app.setHeadless(headless); if (headless) { /* Headless frames take ~1ms of wall time; pin a 60Hz @@ -4366,6 +6142,22 @@ int main(int argc, char *argv[]) std::cout << "[demo] starting new game with scene: " << sceneFile << std::endl; app.startNewGame(sceneFile); + + /* Phase 5.5 follow-up gameplay tuning: pull the NPC and + * traffic spawn radii in to keep the in-town batch count + * near the phase target. NPCs beyond ~55 m and cars beyond + * ~110 m are barely visible but each costs ~10/11 batches. + * Test listeners override these at frame time when they + * need deterministic full spawns, so this stays safe. */ + if (NpcDetailSystem *detail = NpcDetailSystem::getInstance()) { + detail->spawnDistanceSq = 55.0f * 55.0f; + detail->despawnDistanceSq = 85.0f * 85.0f; + } + if (NpcTrafficSystem *traffic = + NpcTrafficSystem::getInstance()) { + traffic->spawnDistanceSq = 110.0f * 110.0f; + traffic->despawnDistanceSq = 165.0f * 165.0f; + } 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 " @@ -4430,6 +6222,31 @@ int main(int argc, char *argv[]) if (testTaxiLowDetail) app.getRoot()->addFrameListener(&taxiLowDetailListener); + TrafficLoopTestListener trafficLoopListener(&app); + if (testTrafficLoop) + app.getRoot()->addFrameListener(&trafficLoopListener); + + TrafficFollowTestListener trafficFollowListener(&app); + if (testTrafficFollow) + app.getRoot()->addFrameListener(&trafficFollowListener); + + TrafficYieldTestListener trafficYieldListener(&app); + if (testTrafficYield) + app.getRoot()->addFrameListener(&trafficYieldListener); + + TrafficTownTestListener trafficTownListener(&app); + if (testTrafficTown) + app.getRoot()->addFrameListener(&trafficTownListener); + + BenchmarkListener benchmarkListener(&app); + if (benchmark) { + app.getRoot()->addFrameListener(&benchmarkListener); + app.getRenderWindow()->addListener(&benchmarkListener); + app.getSceneManager()->getRenderQueue() + ->setRenderableListener( + &benchmarkListener.batchCounter); + } + /* Window glass near/far swap (always on). */ SedanGlassLodListener glassListener(&app); app.getRoot()->addFrameListener(&glassListener); @@ -4700,6 +6517,104 @@ int main(int argc, char *argv[]) } } + if (testTrafficLoop) { + if (trafficLoopListener.failed) { + std::cerr << "[test] FAIL: " + << trafficLoopListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (trafficLoopListener.phase != 2) { + std::cerr << "[test] FAIL: incomplete (traffic " + "loop phase " + << trafficLoopListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testTrafficFollow) { + if (trafficFollowListener.failed) { + std::cerr << "[test] FAIL: " + << trafficFollowListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (trafficFollowListener.phase != 3) { + std::cerr << "[test] FAIL: incomplete (traffic " + "follow phase " + << trafficFollowListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testTrafficYield) { + if (trafficYieldListener.failed) { + std::cerr << "[test] FAIL: " + << trafficYieldListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (trafficYieldListener.phase != 3) { + std::cerr << "[test] FAIL: incomplete (traffic " + "yield phase " + << trafficYieldListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testTrafficTown) { + if (trafficTownListener.failed) { + std::cerr << "[test] FAIL: " + << trafficTownListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (trafficTownListener.phase != 5) { + std::cerr << "[test] FAIL: incomplete (traffic " + "town phase " + << trafficTownListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (benchmark) { + if (benchmarkListener.failed) { + std::cerr << "[benchmark] FAIL: " + << benchmarkListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (!benchmarkListener.done) { + std::cerr << "[benchmark] FAIL: incomplete" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + /* Destroy scene entities while the systems are still alive: * CharacterSpawnerSystem registers an OnRemove observer on * CharacterSpawnerComponent that dereferences the system, and diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json index f705126..9b7ee98 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json @@ -1,1687 +1,2307 @@ { - "actionDatabase": { - "actions": [ + "actionDatabase": { + "actions": [ + { + "behaviorTree": { + "children": [ { - "behaviorTree": { - "children": [ - { - "name": "luaHello", - "params": "message=Welcome to the game!", - "type": "luaTask" - }, - { - "name": "main/action", - "type": "setAnimationState" - }, - { - "name": "action/sitting-ground", - "type": "setAnimationState" - }, - { - "name": "dly", - "params": "9.0", - "type": "delay" - }, - { - "name": "main/locomotion", - "type": "setAnimationState" - }, - { - "name": "locomotion/idle", - "type": "setAnimationState" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "lua_hello_action", - "preconditions": { - "bits": 0, - "mask": 0 - } + "name": "luaHello", + "params": "message=Welcome to the game!", + "type": "luaTask" }, { - "behaviorTree": { - "children": [ - { - "name": "main/action", - "type": "setAnimationState" - }, - { - "name": "action/sitting-ground", - "type": "setAnimationState" - }, - { - "name": "dly", - "params": "6.0", - "type": "delay" - }, - { - "name": "main/locomotion", - "type": "setAnimationState" - }, - { - "name": "locomotion/idle", - "type": "setAnimationState" - }, - { - "name": "luaHello", - "params": "message=\"hello, world!\"", - "type": "luaTask" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "testAction", - "preconditions": { - "bits": 0, - "mask": 0 - } + "name": "main/action", + "type": "setAnimationState" }, { - "behaviorTree": { - "children": [ - { - "name": "vehicleEnter", - "params": "seat=driver", - "type": "luaTask" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "vehicle_seat_driver", - "preconditions": { - "bits": 0, - "mask": 0 - } + "name": "action/sitting-ground", + "type": "setAnimationState" }, { - "behaviorTree": { - "children": [ - { - "name": "vehicleEnter", - "params": "seat=passenger", - "type": "luaTask" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "vehicle_seat_passenger", - "preconditions": { - "bits": 0, - "mask": 0 - } + "name": "dly", + "params": "9.0", + "type": "delay" }, { - "behaviorTree": { - "children": [ - { - "name": "vehicleEnter", - "params": "seat=rl", - "type": "luaTask" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "vehicle_seat_rl", - "preconditions": { - "bits": 0, - "mask": 0 - } + "name": "main/locomotion", + "type": "setAnimationState" }, { - "behaviorTree": { - "children": [ - { - "name": "vehicleEnter", - "params": "seat=rr", - "type": "luaTask" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "vehicle_seat_rr", - "preconditions": { - "bits": 0, - "mask": 0 - } - } - ], - "bitNames": [ - { - "index": 1, - "name": "hungry" - }, - { - "index": 2, - "name": "thirsty" - } - ], - "goals": [] - }, - "bookmarks": [], - "entities": [ - { - "children": [], - "id": 489, - "name": { - "name": "arrival_b" - }, - "transform": { - "position": { - "x": 4000.60009765625, - "y": 9.990500450134277, - "z": 3995.89990234375 - }, - "rotation": { - "w": 0.0, - "x": 0.0, - "y": 1.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } + "name": "locomotion/idle", + "type": "setAnimationState" } + ], + "type": "sequence" }, - { - "children": [ - { - "cellGrid": { - "ceilingRectName": "", - "cellHeight": 4.0, - "cellSize": 2.0, - "cells": [ - { - "flags": 37757955, - "x": -1, - "y": 0, - "z": 0 - }, - { - "flags": 2097667, - "x": 0, - "y": 0, - "z": 0 - }, - { - "flags": 41953283, - "x": 1, - "y": 0, - "z": 0 - }, - { - "flags": 4195331, - "x": -1, - "y": 0, - "z": 1 - }, - { - "flags": 3, - "x": 0, - "y": 0, - "z": 1 - }, - { - "flags": 8390659, - "x": 1, - "y": 0, - "z": 1 - }, - { - "flags": 4195331, - "x": -1, - "y": 0, - "z": 2 - }, - { - "flags": 3, - "x": 0, - "y": 0, - "z": 2 - }, - { - "flags": 8390659, - "x": 1, - "y": 0, - "z": 2 - }, - { - "flags": 20976643, - "x": -1, - "y": 0, - "z": 3 - }, - { - "flags": 16781315, - "x": 0, - "y": 0, - "z": 3 - }, - { - "flags": 25171971, - "x": 1, - "y": 0, - "z": 3 - } - ], - "depth": 10, - "doorActionName": "", - "doorConfigs": {}, - "doorMeshName": "", - "doorOpenAngle": 100.0, - "doorOpenSpeed": 180.0, - "doorRectName": "", - "doorSceneSwitchPath": "demo_scene_interior.json", - "doorSceneSwitchTarget": "arrival_a", - "doorSwingReversed": false, - "doorUseMeshMaterial": false, - "doorsEnabled": true, - "extDoorFrameRectName": "", - "extWallRectName": "", - "extWindowFrameRectName": "", - "floorRectName": "", - "friction": 0.5, - "furnitureCells": [], - "generationMode": "exteriorOnly", - "generationScript": "", - "glassColor": [ - 0.4000000059604645, - 0.6000000238418579, - 0.800000011920929, - 0.3499999940395355 - ], - "glassMaterialName": "", - "glassReflectivity": 0.800000011920929, - "gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78", - "height": 1, - "intDoorFrameRectName": "", - "intWallRectName": "", - "intWindowFrameRectName": "", - "roofSideRectName": "", - "roofTopRectName": "", - "width": 10 - }, - "children": [ - { - "children": [], - "clearArea": { - "clearCells": true, - "clearFurniture": true, - "clearRoofs": false, - "clearRooms": false, - "maxX": 10, - "maxY": 1, - "maxZ": 10, - "minX": -10, - "minY": 0, - "minZ": -10 - }, - "id": 492, - "name": { - "name": "c1" - }, - "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 - } - } - }, - { - "children": [], - "id": 493, - "name": { - "name": "r1" - }, - "room": { - "connectedRoomIds": [], - "createCeiling": true, - "createFloor": true, - "createInteriorWalls": true, - "createWindows": true, - "exits": [ - true, - false, - false, - false - ], - "fillRoomWithFurniture": false, - "furnitureSeed": 42, - "furnitureYOffset": 0.05000000074505806, - "maxX": 2, - "maxY": 1, - "maxZ": 4, - "minX": -1, - "minY": 0, - "minZ": 0, - "persistentId": "room_1788689581735260047_499", - "roomType": "", - "tags": [] - }, - "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 - } - } - } - ], - "id": 491, - "name": { - "name": "w1" - }, - "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 - } - } - } - ], - "id": 490, - "name": { - "name": "x1" - }, - "transform": { - "position": { - "x": 4000.0, - "y": 10.014800071716309, - "z": 4000.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 }, - { - "children": [], - "id": 494, - "light": { - "castShadows": false, - "constantAttenuation": 1.0, - "diffuseColor": { - "a": 1.0, - "b": 1.0, - "g": 1.0, - "r": 1.0 - }, - "direction": { - "x": 0.30000001192092896, - "y": -1.0, - "z": 0.20000000298023224 - }, - "intensity": 1.0, - "lightType": "directional", - "linearAttenuation": 0.0, - "quadraticAttenuation": 0.0, - "range": 100.0, - "specularColor": { - "a": 1.0, - "b": 0.5, - "g": 0.5, - "r": 0.5 - }, - "spotlightFalloff": 1.0, - "spotlightInnerAngle": 30.0, - "spotlightOuterAngle": 45.0 - }, - "name": { - "name": "demo_light" - }, - "transform": { - "position": { - "x": 4000.0, - "y": 20.0, - "z": 4000.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "characterSpawner": { - "despawnDistance": 200.0, - "registryId": 2, - "spawnDistance": 100.0 - }, - "children": [], - "id": 495, - "name": { - "name": "s1" - }, - "transform": { - "position": { - "x": 4000.0, - "y": 9.903900146484375, - "z": 3975.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "children": [], - "id": 496, - "name": { - "name": "player" - }, - "playerController": { - "actuatorColor": [ - 0.0, - 0.4000000059604645, - 1.0 - ], - "actuatorCooldown": 1.5, - "actuatorDistance": 25.0, - "actuatorLabelFontSize": 12.0, - "cameraMode": 0, - "distantCircleRadius": 8.0, - "drivingState": "driving", - "fpsBoneName": "Head", - "idleState": "idle", - "locomotionStateMachine": "locomotion", - "mouseSensitivity": 0.20000000298023224, - "nearCircleRadius": 14.0, - "runState": "running", - "swimFastState": "swimming-fast", - "swimIdleState": "swim-idle", - "swimState": "swimming", - "targetCharacterName": "s1", - "tpsDistance": 3.0, - "tpsHeight": 2.0, - "walkState": "walking" - }, - "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 - } - } - }, - { - "children": [ - { - "children": [], - "id": 498, - "name": { - "name": "sedan_taxi_hood" - }, - "renderable": { - "meshName": "sedan-v2-taxi-hood.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.0, - "y": 0.26600000262260437, - "z": 0.9991999864578247 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "children": [], - "id": 499, - "name": { - "name": "sedan_taxi_trunk" - }, - "renderable": { - "meshName": "sedan-v2-taxi-trunk.mesh", - "visible": true - }, - "transform": { - "position": { - "x": 0.0, - "y": 0.274399995803833, - "z": -1.4459999799728394 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "actuator": { - "actionNames": [ - "vehicle_seat_rl" - ], - "height": 2.0, - "radius": 1.2000000476837158 - }, - "children": [], - "id": 500, - "name": { - "name": "sedan_taxi_door_rl" - }, - "renderable": { - "meshName": "sedan-v2-taxi-door-rl.mesh", - "visible": true - }, - "transform": { - "position": { - "x": 0.9192000031471252, - "y": -0.28690001368522644, - "z": -0.13199999928474426 - }, - "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.2000000476837158 - }, - "children": [], - "id": 501, - "name": { - "name": "sedan_taxi_door_rr" - }, - "renderable": { - "meshName": "sedan-v2-taxi-door-rr.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.9192000031471252, - "y": -0.28690001368522644, - "z": -0.13199999928474426 - }, - "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.2000000476837158 - }, - "children": [], - "id": 502, - "name": { - "name": "sedan_taxi_door_fl" - }, - "renderable": { - "meshName": "sedan-v2-taxi-door-fl.mesh", - "visible": true - }, - "transform": { - "position": { - "x": 0.9192000031471252, - "y": -0.28679999709129333, - "z": 0.8009999990463257 - }, - "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.2000000476837158 - }, - "children": [], - "id": 503, - "name": { - "name": "sedan_taxi_door_fr" - }, - "renderable": { - "meshName": "sedan-v2-taxi-door-fr.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.9192000031471252, - "y": -0.28679999709129333, - "z": 0.8009999990463257 - }, - "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.382099986076355, - "z": -0.06289999932050705 - }, - "parameters": { - "x": 1.010699987411499, - "y": 0.8411999940872192, - "z": 2.4834001064300537 - }, - "radius": 0.5, - "rotationOffset": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "shapeType": "box" - }, - "id": 497, - "name": { - "name": "sedan_taxi" - }, - "renderable": { - "meshName": "sedan-v2-taxi.mesh", - "visible": true - }, - "rigidBody": { - "bodyType": "dynamic", - "enabled": true, - "friction": 0.800000011920929, - "isSensor": false, - "mass": 1200.0, - "restitution": 0.0 - }, - "transform": { - "position": { - "x": 3996.921875, - "y": 10.871692657470703, - "z": 3960.160400390625 - }, - "rotation": { - "w": 0.9876378178596497, - "x": -0.05634903535246849, - "y": -0.14618928730487823, - "z": -0.005017466377466917 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - }, - "vehicle": { - "antiRollBarStiffness": 2000.0, - "cameraDistance": 6.0, - "cameraHeight": 1.600000023841858, - "centerOfMassOffset": { - "x": 0.0, - "y": -0.6800000071525574, - "z": 0.0 - }, - "downforceCoefficient": 45.0, - "engineBrake": 0.0, - "forkMaxHeight": 1.2000000476837158, - "forkMeshName": "", - "forkOffset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "forkSpeed": 0.800000011920929, - "maxPitchRollAngleDeg": 60.0, - "maxTorque": 300.0, - "rearSteer": false, - "seatOffset": { - "x": 0.47999998927116394, - "y": -0.2800000011920929, - "z": 0.2011999934911728 - }, - "seats": [ - { - "driver": true, - "name": "driver", - "offset": { - "x": 0.47999998927116394, - "y": -0.2800000011920929, - "z": 0.2011999934911728 - } - }, - { - "driver": false, - "name": "passenger", - "offset": { - "x": -0.47999998927116394, - "y": -0.2800000011920929, - "z": 0.2011999934911728 - } - }, - { - "driver": false, - "name": "rl", - "offset": { - "x": 0.47999998927116394, - "y": -0.2800000011920929, - "z": -0.6032000184059143 - } - }, - { - "driver": false, - "name": "rr", - "offset": { - "x": -0.47999998927116394, - "y": -0.2800000011920929, - "z": -0.6032000184059143 - } - } - ], - "steeringRatio": 4.0, - "steeringWheelMeshName": "sedan-v2-taxi-steering-wheel.mesh", - "steeringWheelOffset": { - "x": 0.49149999022483826, - "y": 0.08070000261068344, - "z": 0.6010000109672546 - }, - "steeringWheelTiltDeg": 25.0, - "tireFrictionLateral": 3.0, - "tireFrictionLongitudinal": 1.2999999523162842, - "wheelMeshName": "sedan-v2-taxi-wheel.mesh", - "wheels": [ - { - "driven": false, - "maxHandBrakeTorque": 0.0, - "maxSteerAngleDeg": 35.0, - "position": { - "x": 0.7804999947547913, - "y": -0.20520000159740448, - "z": 1.4805999994277954 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - }, - { - "driven": false, - "maxHandBrakeTorque": 0.0, - "maxSteerAngleDeg": 35.0, - "position": { - "x": -0.7804999947547913, - "y": -0.20520000159740448, - "z": 1.4805999994277954 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - }, - { - "driven": true, - "maxHandBrakeTorque": 300.0, - "maxSteerAngleDeg": 0.0, - "position": { - "x": 0.7804999947547913, - "y": -0.20520000159740448, - "z": -1.2805999517440796 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - }, - { - "driven": true, - "maxHandBrakeTorque": 300.0, - "maxSteerAngleDeg": 0.0, - "position": { - "x": -0.7804999947547913, - "y": -0.20520000159740448, - "z": -1.2805999517440796 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - } - ] - } - }, - { - "children": [ - { - "children": [], - "id": 505, - "name": { - "name": "sedan_plain_hood" - }, - "renderable": { - "meshName": "sedan-v2-hood.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.0, - "y": 0.26600000262260437, - "z": 0.9991999864578247 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "children": [], - "id": 506, - "name": { - "name": "sedan_plain_trunk" - }, - "renderable": { - "meshName": "sedan-v2-trunk.mesh", - "visible": true - }, - "transform": { - "position": { - "x": 0.0, - "y": 0.274399995803833, - "z": -1.4459999799728394 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "actuator": { - "actionNames": [ - "vehicle_seat_rl" - ], - "height": 2.0, - "radius": 1.2000000476837158 - }, - "children": [], - "id": 507, - "name": { - "name": "sedan_plain_door_rl" - }, - "renderable": { - "meshName": "sedan-v2-door-rl.mesh", - "visible": true - }, - "transform": { - "position": { - "x": 0.9192000031471252, - "y": -0.28690001368522644, - "z": -0.13199999928474426 - }, - "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.2000000476837158 - }, - "children": [], - "id": 508, - "name": { - "name": "sedan_plain_door_rr" - }, - "renderable": { - "meshName": "sedan-v2-door-rr.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.9192000031471252, - "y": -0.28690001368522644, - "z": -0.13199999928474426 - }, - "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.2000000476837158 - }, - "children": [], - "id": 509, - "name": { - "name": "sedan_plain_door_fl" - }, - "renderable": { - "meshName": "sedan-v2-door-fl.mesh", - "visible": true - }, - "transform": { - "position": { - "x": 0.9192000031471252, - "y": -0.28679999709129333, - "z": 0.8009999990463257 - }, - "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.2000000476837158 - }, - "children": [], - "id": 510, - "name": { - "name": "sedan_plain_door_fr" - }, - "renderable": { - "meshName": "sedan-v2-door-fr.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.9192000031471252, - "y": -0.28679999709129333, - "z": 0.8009999990463257 - }, - "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.1535000056028366, - "z": -0.06289999932050705 - }, - "parameters": { - "x": 1.010699987411499, - "y": 0.6126000285148621, - "z": 2.4834001064300537 - }, - "radius": 0.5, - "rotationOffset": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "shapeType": "box" - }, - "id": 504, - "name": { - "name": "sedan_plain" - }, - "renderable": { - "meshName": "sedan-v2.mesh", - "visible": true - }, - "rigidBody": { - "bodyType": "dynamic", - "enabled": true, - "friction": 0.800000011920929, - "isSensor": false, - "mass": 1200.0, - "restitution": 0.0 - }, - "transform": { - "position": { - "x": 4014.4599609375, - "y": 10.973583221435547, - "z": 3965.41259765625 - }, - "rotation": { - "w": 0.7366631627082825, - "x": -0.057442210614681244, - "y": 0.672011137008667, - "z": 0.04928449168801308 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - }, - "vehicle": { - "antiRollBarStiffness": 2000.0, - "cameraDistance": 6.0, - "cameraHeight": 1.600000023841858, - "centerOfMassOffset": { - "x": 0.0, - "y": -0.44999998807907104, - "z": 0.0 - }, - "downforceCoefficient": 35.0, - "engineBrake": 0.0, - "forkMaxHeight": 1.2000000476837158, - "forkMeshName": "", - "forkOffset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "forkSpeed": 0.800000011920929, - "maxPitchRollAngleDeg": 60.0, - "maxTorque": 300.0, - "rearSteer": false, - "seatOffset": { - "x": 0.47999998927116394, - "y": -0.2800000011920929, - "z": 0.2011999934911728 - }, - "seats": [ - { - "driver": true, - "name": "driver", - "offset": { - "x": 0.47999998927116394, - "y": -0.2800000011920929, - "z": 0.2011999934911728 - } - }, - { - "driver": false, - "name": "passenger", - "offset": { - "x": -0.47999998927116394, - "y": -0.2800000011920929, - "z": 0.2011999934911728 - } - }, - { - "driver": false, - "name": "rl", - "offset": { - "x": 0.47999998927116394, - "y": -0.2800000011920929, - "z": -0.6032000184059143 - } - }, - { - "driver": false, - "name": "rr", - "offset": { - "x": -0.47999998927116394, - "y": -0.2800000011920929, - "z": -0.6032000184059143 - } - } - ], - "steeringRatio": 4.0, - "steeringWheelMeshName": "sedan-v2-steering-wheel.mesh", - "steeringWheelOffset": { - "x": 0.49149999022483826, - "y": 0.08070000261068344, - "z": 0.6010000109672546 - }, - "steeringWheelTiltDeg": 25.0, - "tireFrictionLateral": 3.0, - "tireFrictionLongitudinal": 1.2999999523162842, - "wheelMeshName": "sedan-v2-wheel.mesh", - "wheels": [ - { - "driven": false, - "maxHandBrakeTorque": 0.0, - "maxSteerAngleDeg": 35.0, - "position": { - "x": 0.7804999947547913, - "y": -0.20520000159740448, - "z": 1.4805999994277954 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - }, - { - "driven": false, - "maxHandBrakeTorque": 0.0, - "maxSteerAngleDeg": 35.0, - "position": { - "x": -0.7804999947547913, - "y": -0.20520000159740448, - "z": 1.4805999994277954 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - }, - { - "driven": true, - "maxHandBrakeTorque": 300.0, - "maxSteerAngleDeg": 0.0, - "position": { - "x": 0.7804999947547913, - "y": -0.20520000159740448, - "z": -1.2805999517440796 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - }, - { - "driven": true, - "maxHandBrakeTorque": 300.0, - "maxSteerAngleDeg": 0.0, - "position": { - "x": -0.7804999947547913, - "y": -0.20520000159740448, - "z": -1.2805999517440796 - }, - "radius": 0.3917999863624573, - "suspensionDamping": 1.0, - "suspensionFrequency": 1.2999999523162842, - "suspensionMaxLength": 0.3499999940395355, - "suspensionMinLength": 0.10000000149011612, - "width": 0.31450000405311584 - } - ] - } - }, - { - "children": [], - "id": 511, - "name": { - "name": "terrain" - }, - "terrain": { - "auxMaps": [], - "baseNoise": { - "amplitude": 15.0, - "frequency": 0.0003000000142492354, - "lacunarity": 2.0, - "octaves": 3, - "persistence": 0.5, - "seed": 55 - }, - "blendMapSize": 1024, - "compositeMapDistance": 300.0, - "detailNoise": { - "amplitude": 10.0, - "enabled": false, - "frequency": 0.006000000052154064, - "lacunarity": 2.0, - "octaves": 4, - "persistence": 0.5, - "seed": 26368 - }, - "enabled": true, - "farClipDistance": 6000.0, - "fogEnabled": true, - "fogEnd": 5500.0, - "fogStart": 2500.0, - "heightmapFile": "heightmap.bin", - "heightmapSize": 256, - "layers": [ - { - "diffuseTexture": "Ground23_col.jpg", - "name": "Base", - "normalTexture": "Ground23_normheight.dds", - "worldSize": 100.0 - }, - { - "diffuseTexture": "Ground37_diffspec.dds", - "name": "Layer 1", - "normalTexture": "Ground37_normheight.dds", - "worldSize": 100.0 - } - ], - "maxBatchSize": 65, - "maxPixelError": 1.0, - "minBatchSize": 17, - "pageHoldRadius": 3, - "pageLoadRadius": 2, - "roadConfig": { - "apronDrop": 1.0, - "apronEnabled": true, - "apronSkirtDepth": 2.0, - "apronWidth": 2.0, - "cornerFilletLength": 12.0, - "crossingHeightThreshold": 10.0, - "crossingSnapDistance": 5.0, - "decalMaterialName": "RoadDecalMaterial", - "laneWidth": 5.0, - "lanesPerDirection": 1, - "markingDashGap": 3.0, - "markingDashLength": 3.0, - "markingEdgeInset": 0.25, - "markingLineWidth": 0.15000000596046448, - "markingMaterialName": "RoadMarkingMaterial", - "markingStopSetback": 1.0, - "markingStopWidth": 0.4000000059604645, - "markingsEnabled": true, - "prefabDespawnDistance": 250.0, - "prefabSpawnDistance": 150.0, - "roadLodDistance": 200.0, - "roadMaterialName": "RoadMaterial", - "roadMeshTemplate": "road_segment.mesh", - "roadThickness": 0.30000001192092896, - "roadVisibilityDistance": 1000.0, - "sidewalkEnabled": false, - "sidewalkHeight": 0.15000000596046448, - "sidewalkMeshTemplate": "", - "sidewalkThickness": 0.30000001192092896, - "sidewalkWidth": 1.5, - "textureTileLength": 10.0 - }, - "streamingEnabled": true, - "terrainId": 4242424300000001, - "terrainSize": 65, - "worldSize": 2000.0, - "worldSizeUnits": 10000.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 - } - } - }, - { - "children": [], - "id": 512, - "name": { - "name": "sky" - }, - "skybox": { - "cloudiness": 0.0, - "dayBottomColor": [ - 0.6000000238418579, - 0.800000011920929, - 1.0 - ], - "dayTopColor": [ - 0.20000000298023224, - 0.5, - 1.0 - ], - "enabled": true, - "moonSize": 0.029999999329447746, - "nightBottomColor": [ - 0.05000000074505806, - 0.05000000074505806, - 0.15000000596046448 - ], - "nightTopColor": [ - 0.0, - 0.0, - 0.05000000074505806 - ], - "size": 443.0, - "starsEnabled": false, - "sunSize": 0.05000000074505806, - "sunriseColor": [ - 1.0, - 0.5, - 0.20000000298023224 - ], - "sunsetColor": [ - 1.0, - 0.30000001192092896, - 0.10000000149011612 - ] - }, - "sun": { - "ambientDay": [ - 0.30000001192092896, - 0.30000001192092896, - 0.30000001192092896 - ], - "ambientNight": [ - 0.05000000074505806, - 0.05000000074505806, - 0.15000000596046448 - ], - "ambientSunrise": [ - 0.30000001192092896, - 0.20000000298023224, - 0.15000000596046448 - ], - "ambientSunset": [ - 0.25, - 0.15000000596046448, - 0.10000000149011612 - ], - "castShadows": true, - "enabled": true, - "intensity": 1.7899999618530273, - "moonColor": [ - 0.30000001192092896, - 0.30000001192092896, - 0.5 - ], - "moonSphereSize": 3.4000000953674316, - "orbitTilt": 15.0, - "showMoonSphere": true, - "showSunSphere": true, - "sunColor": [ - 1.0, - 0.949999988079071, - 0.800000011920929 - ], - "sunSphereSize": 5.0, - "timeOfDay": 2.1072239875793457, - "timeSpeed": 0.12999999523162842 - }, - "transform": { - "position": { - "x": 4000.0, - "y": 10.0, - "z": 4000.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { - "children": [], - "id": 513, - "name": { - "name": "water" - }, - "transform": { - "position": { - "x": 4000.0, - "y": 0.0, - "z": 4000.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - }, - "waterPhysics": { - "defaultAngularDrag": 0.05000000074505806, - "defaultBuoyancy": 1.0, - "defaultLinearDrag": 0.25, - "defaultSubmergedThreshold": 0.10000000149011612, - "enabled": true, - "gravity": 9.8100004196167, - "waterDensity": 1000.0, - "waterSurfaceY": 6.0 - }, - "waterPlane": { - "autoUpdateFromWaterPhysics": true, - "enabled": true, - "planeSize": 12000.0, - "reflectivity": 0.3799999952316284, - "renderTextureSize": 512, - "tiling": 0.012000000104308128, - "waterColor": [ - 0.0, - 0.30000001192092896, - 0.5, - 0.800000011920929 - ], - "waterSurfaceY": 6.0, - "waveScale": 0.03099999949336052, - "waveSpeed": 0.9800000190734863 - } + "name": "lua_hello_action", + "preconditions": { + "bits": 0, + "mask": 0 } + }, + { + "behaviorTree": { + "children": [ + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "6.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + }, + { + "name": "luaHello", + "params": "message=\"hello, world!\"", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "testAction", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "vehicleEnter", + "params": "seat=driver", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "vehicle_seat_driver", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "vehicleEnter", + "params": "seat=passenger", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "vehicle_seat_passenger", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "vehicleEnter", + "params": "seat=rl", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "vehicle_seat_rl", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "vehicleEnter", + "params": "seat=rr", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "vehicle_seat_rr", + "preconditions": { + "bits": 0, + "mask": 0 + } + } ], - "version": "1.0" -} \ No newline at end of file + "bitNames": [ + { + "index": 1, + "name": "hungry" + }, + { + "index": 2, + "name": "thirsty" + } + ], + "goals": [] + }, + "bookmarks": [], + "entities": [ + { + "children": [], + "id": 489, + "name": { + "name": "arrival_b" + }, + "transform": { + "position": { + "x": 4000.60009765625, + "y": 9.990500450134277, + "z": 3995.89990234375 + }, + "rotation": { + "w": 0.0, + "x": 0.0, + "y": 1.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [ + { + "cellGrid": { + "ceilingRectName": "", + "cellHeight": 4.0, + "cellSize": 2.0, + "cells": [ + { + "flags": 37757955, + "x": -1, + "y": 0, + "z": 0 + }, + { + "flags": 2097667, + "x": 0, + "y": 0, + "z": 0 + }, + { + "flags": 41953283, + "x": 1, + "y": 0, + "z": 0 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 1 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 1 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 2 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 2 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 2 + }, + { + "flags": 20976643, + "x": -1, + "y": 0, + "z": 3 + }, + { + "flags": 16781315, + "x": 0, + "y": 0, + "z": 3 + }, + { + "flags": 25171971, + "x": 1, + "y": 0, + "z": 3 + } + ], + "depth": 10, + "doorActionName": "", + "doorConfigs": {}, + "doorMeshName": "", + "doorOpenAngle": 100.0, + "doorOpenSpeed": 180.0, + "doorRectName": "", + "doorSceneSwitchPath": "demo_scene_interior.json", + "doorSceneSwitchTarget": "arrival_a", + "doorSwingReversed": false, + "doorUseMeshMaterial": false, + "doorsEnabled": true, + "extDoorFrameRectName": "", + "extWallRectName": "", + "extWindowFrameRectName": "", + "floorRectName": "", + "friction": 0.5, + "furnitureCells": [], + "generationMode": "exteriorOnly", + "generationScript": "", + "glassColor": [ + 0.4000000059604645, + 0.6000000238418579, + 0.800000011920929, + 0.3499999940395355 + ], + "glassMaterialName": "", + "glassReflectivity": 0.800000011920929, + "gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78", + "height": 1, + "intDoorFrameRectName": "", + "intWallRectName": "", + "intWindowFrameRectName": "", + "roofSideRectName": "", + "roofTopRectName": "", + "width": 10 + }, + "proceduralMaterial": { + "ambient": { + "b": 0.20000000298023224, + "g": 0.20000000298023224, + "r": 0.20000000298023224 + }, + "diffuse": { + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "diffuseTextureId": "id_1820000000000000002_3", + "materialId": "id_1820000000000000001_1", + "materialName": "ProceduralMat_id_1820000000000000001_1", + "roughness": 0.5, + "shininess": 32.0, + "specular": { + "b": 0.0, + "g": 0.0, + "r": 0.0 + } + }, + "proceduralTexture": { + "colors": [ + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + } + ], + "namedRects": [ + { + "name": "ceiling", + "u1": 0.10000000149011612, + "u2": 0.20000000298023224, + "v1": 0.0, + "v2": 0.10000000149011612 + }, + { + "name": "floor", + "u1": 0.0, + "u2": 0.10000000149011612, + "v1": 0.0, + "v2": 0.10000000149011612 + } + ], + "textureId": "id_1820000000000000002_3", + "textureName": "ProceduralTex_id_1820000000000000002_3", + "textureSize": 512, + "uvMargin": 0.009999999776482582 + }, + "children": [ + { + "children": [], + "clearArea": { + "clearCells": true, + "clearFurniture": true, + "clearRoofs": false, + "clearRooms": false, + "maxX": 10, + "maxY": 1, + "maxZ": 10, + "minX": -10, + "minY": 0, + "minZ": -10 + }, + "id": 492, + "name": { + "name": "c1" + }, + "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 + } + } + }, + { + "children": [], + "id": 493, + "name": { + "name": "r1" + }, + "room": { + "connectedRoomIds": [], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": true, + "exits": [ + true, + false, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 4, + "minX": -1, + "minY": 0, + "minZ": 0, + "persistentId": "room_1788689581735260047_499", + "roomType": "", + "tags": [] + }, + "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 + } + } + } + ], + "id": 491, + "name": { + "name": "w1" + }, + "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 + } + } + } + ], + "id": 490, + "name": { + "name": "x1" + }, + "transform": { + "position": { + "x": 4000.0, + "y": 10.014800071716309, + "z": 4000.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 494, + "light": { + "castShadows": false, + "constantAttenuation": 1.0, + "diffuseColor": { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "direction": { + "x": 0.30000001192092896, + "y": -1.0, + "z": 0.20000000298023224 + }, + "intensity": 1.0, + "lightType": "directional", + "linearAttenuation": 0.0, + "quadraticAttenuation": 0.0, + "range": 100.0, + "specularColor": { + "a": 1.0, + "b": 0.5, + "g": 0.5, + "r": 0.5 + }, + "spotlightFalloff": 1.0, + "spotlightInnerAngle": 30.0, + "spotlightOuterAngle": 45.0 + }, + "name": { + "name": "demo_light" + }, + "transform": { + "position": { + "x": 4000.0, + "y": 20.0, + "z": 4000.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "characterSpawner": { + "despawnDistance": 200.0, + "registryId": 2, + "spawnDistance": 100.0 + }, + "children": [], + "id": 495, + "name": { + "name": "s1" + }, + "transform": { + "position": { + "x": 4000.0, + "y": 9.903900146484375, + "z": 3975.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 496, + "name": { + "name": "player" + }, + "playerController": { + "actuatorColor": [ + 0.0, + 0.4000000059604645, + 1.0 + ], + "actuatorCooldown": 1.5, + "actuatorDistance": 25.0, + "actuatorLabelFontSize": 12.0, + "cameraMode": 0, + "distantCircleRadius": 8.0, + "drivingState": "driving", + "fpsBoneName": "Head", + "idleState": "idle", + "locomotionStateMachine": "locomotion", + "mouseSensitivity": 0.20000000298023224, + "nearCircleRadius": 14.0, + "runState": "running", + "swimFastState": "swimming-fast", + "swimIdleState": "swim-idle", + "swimState": "swimming", + "targetCharacterName": "s1", + "tpsDistance": 3.0, + "tpsHeight": 2.0, + "walkState": "walking" + }, + "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 + } + } + }, + { + "children": [ + { + "children": [], + "name": { + "name": "sedan_taxi_door_rl" + }, + "renderable": { + "meshName": "sedan-v2-taxi-door-rl.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.9192, + "y": -0.2869, + "z": -0.132 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_rl" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_taxi_door_rr" + }, + "renderable": { + "meshName": "sedan-v2-taxi-door-rr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2869, + "z": -0.132 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_rr" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_taxi_door_fl" + }, + "renderable": { + "meshName": "sedan-v2-taxi-door-fl.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_driver" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_taxi_door_fr" + }, + "renderable": { + "meshName": "sedan-v2-taxi-door-fr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_passenger" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_taxi_hood" + }, + "renderable": { + "meshName": "sedan-v2-taxi-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_taxi_trunk" + }, + "renderable": { + "meshName": "sedan-v2-taxi-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.3821, + "z": -0.0629 + }, + "parameters": { + "x": 1.0107, + "y": 0.8412, + "z": 2.4834 + }, + "radius": 0.5, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "shapeType": "box" + }, + "name": { + "name": "sedan_taxi" + }, + "renderable": { + "meshName": "sedan-v2-taxi.mesh", + "visible": true + }, + "rigidBody": { + "bodyType": "dynamic", + "enabled": true, + "friction": 0.8, + "isSensor": false, + "mass": 1200.0, + "restitution": 0.0 + }, + "transform": { + "position": { + "x": 3996.921875, + "y": 10.871692657470703, + "z": 3960.160400390625 + }, + "rotation": { + "w": 0.9876378178596497, + "x": -0.05634903535246849, + "y": -0.14618928730487823, + "z": -0.005017466377466917 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "vehicle": { + "cameraDistance": 6.0, + "cameraHeight": 1.6, + "engineBrake": 0.0, + "maxPitchRollAngleDeg": 60.0, + "maxTorque": 300.0, + "rearSteer": false, + "seatOffset": { + "x": 0.48, + "y": -0.28, + "z": 0.2012 + }, + "steeringRatio": 4.0, + "steeringWheelMeshName": "sedan-v2-taxi-steering-wheel.mesh", + "steeringWheelOffset": { + "x": 0.4915, + "y": 0.0807, + "z": 0.601 + }, + "steeringWheelTiltDeg": 25.0, + "wheelMeshName": "sedan-v2-taxi-wheel.mesh", + "wheels": [ + { + "driven": false, + "maxHandBrakeTorque": 0.0, + "maxSteerAngleDeg": 35.0, + "position": { + "x": 0.7805, + "y": -0.2052, + "z": 1.4806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": false, + "maxHandBrakeTorque": 0.0, + "maxSteerAngleDeg": 35.0, + "position": { + "x": -0.7805, + "y": -0.2052, + "z": 1.4806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": true, + "maxHandBrakeTorque": 300.0, + "maxSteerAngleDeg": 0.0, + "position": { + "x": 0.7805, + "y": -0.2052, + "z": -1.2806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": true, + "maxHandBrakeTorque": 300.0, + "maxSteerAngleDeg": 0.0, + "position": { + "x": -0.7805, + "y": -0.2052, + "z": -1.2806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + } + ], + "seats": [ + { + "name": "driver", + "offset": { + "x": 0.48, + "y": -0.28, + "z": 0.2012 + }, + "driver": true + }, + { + "name": "passenger", + "offset": { + "x": -0.48, + "y": -0.28, + "z": 0.2012 + }, + "driver": false + }, + { + "name": "rl", + "offset": { + "x": 0.48, + "y": -0.28, + "z": -0.6032 + }, + "driver": false + }, + { + "name": "rr", + "offset": { + "x": -0.48, + "y": -0.28, + "z": -0.6032 + }, + "driver": false + } + ], + "seatCharacterScale": 1.0, + "tireFrictionLongitudinal": 1.3, + "tireFrictionLateral": 3.0, + "antiRollBarStiffness": 2000.0, + "centerOfMassOffset": { + "x": 0.0, + "y": -0.6903, + "z": 0.0 + }, + "downforceCoefficient": 45.0 + }, + "id": 497 + }, + { + "children": [ + { + "children": [], + "name": { + "name": "sedan_plain_door_rl" + }, + "renderable": { + "meshName": "sedan-v2-door-rl.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.9192, + "y": -0.2869, + "z": -0.132 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_rl" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_plain_door_rr" + }, + "renderable": { + "meshName": "sedan-v2-door-rr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2869, + "z": -0.132 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_rr" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_plain_door_fl" + }, + "renderable": { + "meshName": "sedan-v2-door-fl.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_driver" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_plain_door_fr" + }, + "renderable": { + "meshName": "sedan-v2-door-fr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_passenger" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_plain_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_plain_trunk" + }, + "renderable": { + "meshName": "sedan-v2-trunk.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.2744, + "z": -1.446 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "collider": { + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.1535, + "z": -0.0629 + }, + "parameters": { + "x": 1.0107, + "y": 0.6126, + "z": 2.4834 + }, + "radius": 0.5, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "shapeType": "box" + }, + "name": { + "name": "sedan_plain" + }, + "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": 4014.4599609375, + "y": 10.973583221435547, + "z": 3965.41259765625 + }, + "rotation": { + "w": 0.7366631627082825, + "x": -0.057442210614681244, + "y": 0.672011137008667, + "z": 0.04928449168801308 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "vehicle": { + "cameraDistance": 6.0, + "cameraHeight": 1.6, + "engineBrake": 0.0, + "maxPitchRollAngleDeg": 60.0, + "maxTorque": 300.0, + "rearSteer": false, + "seatOffset": { + "x": 0.48, + "y": -0.28, + "z": 0.2012 + }, + "steeringRatio": 4.0, + "steeringWheelMeshName": "sedan-v2-steering-wheel.mesh", + "steeringWheelOffset": { + "x": 0.4915, + "y": 0.0807, + "z": 0.601 + }, + "steeringWheelTiltDeg": 25.0, + "wheelMeshName": "sedan-v2-wheel.mesh", + "wheels": [ + { + "driven": false, + "maxHandBrakeTorque": 0.0, + "maxSteerAngleDeg": 35.0, + "position": { + "x": 0.7805, + "y": -0.2052, + "z": 1.4806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": false, + "maxHandBrakeTorque": 0.0, + "maxSteerAngleDeg": 35.0, + "position": { + "x": -0.7805, + "y": -0.2052, + "z": 1.4806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": true, + "maxHandBrakeTorque": 300.0, + "maxSteerAngleDeg": 0.0, + "position": { + "x": 0.7805, + "y": -0.2052, + "z": -1.2806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": true, + "maxHandBrakeTorque": 300.0, + "maxSteerAngleDeg": 0.0, + "position": { + "x": -0.7805, + "y": -0.2052, + "z": -1.2806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + } + ], + "seats": [ + { + "name": "driver", + "offset": { + "x": 0.48, + "y": -0.28, + "z": 0.2012 + }, + "driver": true + }, + { + "name": "passenger", + "offset": { + "x": -0.48, + "y": -0.28, + "z": 0.2012 + }, + "driver": false + }, + { + "name": "rl", + "offset": { + "x": 0.48, + "y": -0.28, + "z": -0.6032 + }, + "driver": false + }, + { + "name": "rr", + "offset": { + "x": -0.48, + "y": -0.28, + "z": -0.6032 + }, + "driver": false + } + ], + "seatCharacterScale": 1.0, + "tireFrictionLongitudinal": 1.3, + "tireFrictionLateral": 3.0, + "antiRollBarStiffness": 2000.0, + "centerOfMassOffset": { + "x": 0.0, + "y": -0.4617, + "z": 0.0 + }, + "downforceCoefficient": 35.0 + }, + "id": 504 + }, + { + "children": [], + "id": 511, + "name": { + "name": "terrain" + }, + "terrain": { + "auxMaps": [], + "baseNoise": { + "amplitude": 15.0, + "frequency": 0.0003000000142492354, + "lacunarity": 2.0, + "octaves": 3, + "persistence": 0.5, + "seed": 55 + }, + "blendMapSize": 1024, + "compositeMapDistance": 300.0, + "detailNoise": { + "amplitude": 10.0, + "enabled": false, + "frequency": 0.006000000052154064, + "lacunarity": 2.0, + "octaves": 4, + "persistence": 0.5, + "seed": 26368 + }, + "enabled": true, + "farClipDistance": 6000.0, + "fogEnabled": true, + "fogEnd": 5500.0, + "fogStart": 2500.0, + "heightmapFile": "heightmap.bin", + "heightmapSize": 256, + "layers": [ + { + "diffuseTexture": "Ground23_col.jpg", + "name": "Base", + "normalTexture": "Ground23_normheight.dds", + "worldSize": 100.0 + }, + { + "diffuseTexture": "Ground37_diffspec.dds", + "name": "Layer 1", + "normalTexture": "Ground37_normheight.dds", + "worldSize": 100.0 + } + ], + "maxBatchSize": 65, + "maxPixelError": 1.0, + "minBatchSize": 17, + "pageHoldRadius": 3, + "pageLoadRadius": 2, + "roadConfig": { + "apronDrop": 1.0, + "apronEnabled": true, + "apronSkirtDepth": 2.0, + "apronWidth": 2.0, + "cornerFilletLength": 12.0, + "crossingHeightThreshold": 10.0, + "crossingSnapDistance": 5.0, + "decalMaterialName": "RoadDecalMaterial", + "laneWidth": 5.0, + "lanesPerDirection": 1, + "markingDashGap": 3.0, + "markingDashLength": 3.0, + "markingEdgeInset": 0.25, + "markingLineWidth": 0.15000000596046448, + "markingMaterialName": "RoadMarkingMaterial", + "markingStopSetback": 1.0, + "markingStopWidth": 0.4000000059604645, + "markingsEnabled": true, + "prefabDespawnDistance": 250.0, + "prefabSpawnDistance": 150.0, + "roadLodDistance": 200.0, + "roadMaterialName": "RoadMaterial", + "roadMeshTemplate": "road_segment.mesh", + "roadThickness": 0.30000001192092896, + "roadVisibilityDistance": 1000.0, + "sidewalkEnabled": false, + "sidewalkHeight": 0.15000000596046448, + "sidewalkMeshTemplate": "", + "sidewalkThickness": 0.30000001192092896, + "sidewalkWidth": 1.5, + "textureTileLength": 10.0 + }, + "streamingEnabled": true, + "terrainId": 4242424300000001, + "terrainSize": 65, + "worldSize": 2000.0, + "worldSizeUnits": 10000.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 + } + } + }, + { + "children": [], + "id": 512, + "name": { + "name": "sky" + }, + "skybox": { + "cloudiness": 0.0, + "dayBottomColor": [ + 0.6000000238418579, + 0.800000011920929, + 1.0 + ], + "dayTopColor": [ + 0.20000000298023224, + 0.5, + 1.0 + ], + "enabled": true, + "moonSize": 0.029999999329447746, + "nightBottomColor": [ + 0.05000000074505806, + 0.05000000074505806, + 0.15000000596046448 + ], + "nightTopColor": [ + 0.0, + 0.0, + 0.05000000074505806 + ], + "size": 443.0, + "starsEnabled": false, + "sunSize": 0.05000000074505806, + "sunriseColor": [ + 1.0, + 0.5, + 0.20000000298023224 + ], + "sunsetColor": [ + 1.0, + 0.30000001192092896, + 0.10000000149011612 + ] + }, + "sun": { + "ambientDay": [ + 0.30000001192092896, + 0.30000001192092896, + 0.30000001192092896 + ], + "ambientNight": [ + 0.05000000074505806, + 0.05000000074505806, + 0.15000000596046448 + ], + "ambientSunrise": [ + 0.30000001192092896, + 0.20000000298023224, + 0.15000000596046448 + ], + "ambientSunset": [ + 0.25, + 0.15000000596046448, + 0.10000000149011612 + ], + "castShadows": true, + "enabled": true, + "intensity": 1.7899999618530273, + "moonColor": [ + 0.30000001192092896, + 0.30000001192092896, + 0.5 + ], + "moonSphereSize": 3.4000000953674316, + "orbitTilt": 15.0, + "showMoonSphere": true, + "showSunSphere": true, + "sunColor": [ + 1.0, + 0.949999988079071, + 0.800000011920929 + ], + "sunSphereSize": 5.0, + "timeOfDay": 2.1072239875793457, + "timeSpeed": 0.12999999523162842 + }, + "transform": { + "position": { + "x": 4000.0, + "y": 10.0, + "z": 4000.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 513, + "name": { + "name": "water" + }, + "transform": { + "position": { + "x": 4000.0, + "y": 0.0, + "z": 4000.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "waterPhysics": { + "defaultAngularDrag": 0.05000000074505806, + "defaultBuoyancy": 1.0, + "defaultLinearDrag": 0.25, + "defaultSubmergedThreshold": 0.10000000149011612, + "enabled": true, + "gravity": 9.8100004196167, + "waterDensity": 1000.0, + "waterSurfaceY": 6.0 + }, + "waterPlane": { + "autoUpdateFromWaterPhysics": true, + "enabled": true, + "planeSize": 12000.0, + "reflectivity": 0.3799999952316284, + "renderTextureSize": 512, + "tiling": 0.012000000104308128, + "waterColor": [ + 0.0, + 0.30000001192092896, + 0.5, + 0.800000011920929 + ], + "waterSurfaceY": 6.0, + "waveScale": 0.03099999949336052, + "waveSpeed": 0.9800000190734863 + } + } + ], + "version": "1.0" +} diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json b/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json index 93340bb..435bdf1 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json @@ -682,12 +682,404 @@ "antiRollBarStiffness": 2000.0, "centerOfMassOffset": { "x": 0.0, - "y": -0.45, + "y": -0.4617, "z": 0.0 }, "downforceCoefficient": 35.0 }, "id": 500 + }, + { + "children": [ + { + "children": [], + "name": { + "name": "sedan2_door_rl" + }, + "renderable": { + "meshName": "sedan-v2-door-rl.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.9192, + "y": -0.2869, + "z": -0.132 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_rl" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan2_door_rr" + }, + "renderable": { + "meshName": "sedan-v2-door-rr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2869, + "z": -0.132 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_rr" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan2_door_fl" + }, + "renderable": { + "meshName": "sedan-v2-door-fl.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_driver" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan2_door_fr" + }, + "renderable": { + "meshName": "sedan-v2-door-fr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_passenger" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan2_hood" + }, + "renderable": { + "meshName": "sedan-v2-hood.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.0, + "y": 0.266, + "z": 0.9992 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "name": { + "name": "sedan2_trunk" + }, + "renderable": { + "meshName": "sedan-v2-trunk.mesh", + "visible": true + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.2744, + "z": -1.446 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "collider": { + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.1535, + "z": -0.0629 + }, + "parameters": { + "x": 1.0107, + "y": 0.6126, + "z": 2.4834 + }, + "radius": 0.5, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "shapeType": "box" + }, + "name": { + "name": "sedan2" + }, + "renderable": { + "meshName": "sedan-v2.mesh", + "visible": true + }, + "rigidBody": { + "bodyType": "dynamic", + "enabled": true, + "friction": 0.8, + "isSensor": false, + "mass": 1200.0, + "restitution": 0.0 + }, + "transform": { + "position": { + "x": 40.0, + "y": 1.5, + "z": 25.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "vehicle": { + "cameraDistance": 6.0, + "cameraHeight": 1.6, + "engineBrake": 0.0, + "maxPitchRollAngleDeg": 60.0, + "maxTorque": 300.0, + "rearSteer": false, + "seatOffset": { + "x": 0.48, + "y": -0.28, + "z": 0.2012 + }, + "steeringRatio": 4.0, + "steeringWheelMeshName": "sedan-v2-steering-wheel.mesh", + "steeringWheelOffset": { + "x": 0.4915, + "y": 0.0807, + "z": 0.601 + }, + "steeringWheelTiltDeg": 25.0, + "wheelMeshName": "sedan-v2-wheel.mesh", + "wheels": [ + { + "driven": false, + "maxHandBrakeTorque": 0.0, + "maxSteerAngleDeg": 35.0, + "position": { + "x": 0.7805, + "y": -0.2052, + "z": 1.4806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": false, + "maxHandBrakeTorque": 0.0, + "maxSteerAngleDeg": 35.0, + "position": { + "x": -0.7805, + "y": -0.2052, + "z": 1.4806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": true, + "maxHandBrakeTorque": 300.0, + "maxSteerAngleDeg": 0.0, + "position": { + "x": 0.7805, + "y": -0.2052, + "z": -1.2806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + }, + { + "driven": true, + "maxHandBrakeTorque": 300.0, + "maxSteerAngleDeg": 0.0, + "position": { + "x": -0.7805, + "y": -0.2052, + "z": -1.2806 + }, + "radius": 0.3918, + "suspensionDamping": 1.0, + "suspensionFrequency": 1.3, + "suspensionMaxLength": 0.35, + "suspensionMinLength": 0.1, + "width": 0.3145 + } + ], + "seats": [ + { + "name": "driver", + "offset": { + "x": 0.48, + "y": -0.28, + "z": 0.2012 + }, + "driver": true + }, + { + "name": "passenger", + "offset": { + "x": -0.48, + "y": -0.28, + "z": 0.2012 + }, + "driver": false + }, + { + "name": "rl", + "offset": { + "x": 0.48, + "y": -0.28, + "z": -0.6032 + }, + "driver": false + }, + { + "name": "rr", + "offset": { + "x": -0.48, + "y": -0.28, + "z": -0.6032 + }, + "driver": false + } + ], + "seatCharacterScale": 1.0, + "tireFrictionLongitudinal": 1.3, + "tireFrictionLateral": 3.0, + "antiRollBarStiffness": 2000.0, + "centerOfMassOffset": { + "x": 0.0, + "y": -0.45, + "z": 0.0 + }, + "downforceCoefficient": 35.0 + }, + "id": 4294967795 } ], "version": "1.0" diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index 2220e64..e44f6a4 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -196,6 +196,29 @@ private: const std::set &m_characters; }; +/* Body filter skipping a single body (the querying vehicle's own + * chassis for the traffic whisker). */ +class SkipOneBodyFilter : public JPH::BodyFilter { +public: + explicit SkipOneBodyFilter(JPH::BodyID inSkip) + : m_skip(inSkip) + { + } + + virtual bool ShouldCollide(const JPH::BodyID &inBodyID) const override + { + return inBodyID != m_skip; + } + + virtual bool ShouldCollideLocked(const JPH::Body &) const override + { + return true; + } + +private: + JPH::BodyID m_skip; +}; + /// Class that determines if an object layer can collide with a broadphase layer class ObjectVsBroadPhaseLayerFilterImpl : public JPH::ObjectVsBroadPhaseLayerFilter { @@ -2120,6 +2143,47 @@ public: } return false; } + /* Traffic whisker (demo-taxi phase 5.2): like + * raycastQueryWalkable (walkable-normal filtering), but character + * bodies are NOT skipped — a pedestrian ahead must brake a car — + * and the querying vehicle's own chassis is skipped instead. */ + bool raycastQueryTraffic(Ogre::Vector3 startPoint, + Ogre::Vector3 endPoint, Ogre::Vector3 &position, + Ogre::Vector3 &normal, JPH::BodyID &id, + JPH::BodyID ignoreBody, float walkableNormalY) + { + Ogre::Vector3 direction = endPoint - startPoint; + JPH::RRayCast ray{ JoltPhysics::convert(startPoint), + JoltPhysics::convert(direction) }; + JPH::AllHitCollisionCollector collector; + JPH::RayCastSettings raySettings; + physics_system.GetNarrowPhaseQuery().CastRay( + ray, raySettings, collector, {}, + ObstacleObjectLayerFilter(), SkipOneBodyFilter(ignoreBody)); + if (!collector.HadHit()) + return false; + collector.Sort(); + for (const JPH::RayCastResult &hit : collector.mHits) { + JPH::RVec3 point = ray.GetPointOnRay(hit.mFraction); + JPH::Vec3 n; + { + JPH::BodyLockRead lock( + physics_system.GetBodyLockInterface(), + hit.mBodyID); + if (!lock.Succeeded()) + continue; + n = lock.GetBody().GetWorldSpaceSurfaceNormal( + hit.mSubShapeID2, point); + } + if (n.GetY() >= walkableNormalY) + continue; + position = JoltPhysics::convert(point); + normal = JoltPhysics::convert(n); + id = hit.mBodyID; + return true; + } + return false; + } /* Spawn-placement check: does a character capsule standing at * @p feetPosition overlap anything solid (static world or * vehicles; character bodies skipped)? */ @@ -2577,6 +2641,18 @@ bool JoltPhysicsWrapper::raycastQueryWalkable(Ogre::Vector3 startPoint, normal, id, walkableNormalY); } +bool JoltPhysicsWrapper::raycastQueryTraffic(Ogre::Vector3 startPoint, + Ogre::Vector3 endPoint, + Ogre::Vector3 &position, + Ogre::Vector3 &normal, + JPH::BodyID &id, + JPH::BodyID ignoreBody, + float walkableNormalY) +{ + return phys->raycastQueryTraffic(startPoint, endPoint, position, normal, + id, ignoreBody, walkableNormalY); +} + bool JoltPhysicsWrapper::capsuleOverlapsObstacles( const Ogre::Vector3 &feetPosition, float halfHeight, float radius) { diff --git a/src/features/editScene/physics/physics.h b/src/features/editScene/physics/physics.h index 4f1630e..ccccb1c 100644 --- a/src/features/editScene/physics/physics.h +++ b/src/features/editScene/physics/physics.h @@ -295,6 +295,15 @@ public: Ogre::Vector3 &position, Ogre::Vector3 &normal, JPH::BodyID &id, float walkableNormalY); + /* Traffic whisker (demo-taxi phase 5.2): like + * raycastQueryWalkable, but character bodies are NOT skipped (a + * pedestrian ahead must brake a car) and the querying vehicle's + * own chassis (ignoreBody) is. Render-space positions. */ + bool raycastQueryTraffic(Ogre::Vector3 startPoint, + Ogre::Vector3 endPoint, + Ogre::Vector3 &position, + Ogre::Vector3 &normal, JPH::BodyID &id, + JPH::BodyID ignoreBody, float walkableNormalY); /* Does a character capsule standing at @p feetPosition overlap * anything solid? Spawn-placement check. */ bool capsuleOverlapsObstacles(const Ogre::Vector3 &feetPosition, diff --git a/src/features/editScene/roadlib/RoadPath.cpp b/src/features/editScene/roadlib/RoadPath.cpp index 82a0069..86d9025 100644 --- a/src/features/editScene/roadlib/RoadPath.cpp +++ b/src/features/editScene/roadlib/RoadPath.cpp @@ -320,6 +320,95 @@ void offsetToShoulder(const RoadGraph &graph, } } +void offsetToRightLane(const RoadGraph &graph, + std::vector &path) +{ + if (path.size() < 2 || graph.edges.empty()) + return; + + /* id -> index (nodes are stored unordered, edges reference ids). */ + std::map indexOf; + for (size_t i = 0; i < graph.nodes.size(); ++i) + indexOf[graph.nodes[i].id] = (int)i; + + for (size_t i = 0; i < path.size(); ++i) { + const Ogre::Vector3 &w = path[i]; + + /* The edge this waypoint belongs to (same nearest-edge + * scan as offsetToShoulder). */ + const RoadEdge *best = nullptr; + float bestD = std::numeric_limits::max(); + Ogre::Vector3 bestProj = Ogre::Vector3::ZERO; + for (const RoadEdge &e : graph.edges) { + auto ia = indexOf.find(e.nodeA); + auto ib = indexOf.find(e.nodeB); + if (ia == indexOf.end() || ib == indexOf.end()) + continue; + const Ogre::Vector3 &a = graph.nodes[ia->second].position; + const Ogre::Vector3 &b = graph.nodes[ib->second].position; + float abx = b.x - a.x; + float abz = b.z - a.z; + float len2 = abx * abx + abz * abz; + float t = 0.0f; + if (len2 > 1.0e-12f) { + t = ((w.x - a.x) * abx + (w.z - a.z) * abz) / + len2; + t = std::max(0.0f, std::min(1.0f, t)); + } + float dx = w.x - (a.x + abx * t); + float dz = w.z - (a.z + abz * t); + float d = dx * dx + dz * dz; + if (d < bestD) { + bestD = d; + best = &e; + bestProj = Ogre::Vector3(a.x + abx * t, w.y, + a.z + abz * t); + } + } + if (!best) + continue; + + /* Travel direction at the waypoint (XZ); vehicles keep to + * the RIGHT of it. Endpoints use the one-sided + * direction. */ + Ogre::Vector3 dir; + if (i == 0) + dir = path[1] - path[0]; + else if (i + 1 == path.size()) + dir = path[i] - path[i - 1]; + else + dir = path[i + 1] - path[i - 1]; + dir.y = 0.0f; + if (dir.squaredLength() < 1.0e-8f) + continue; + dir.normalise(); + /* OGRE is right-handed with +Y up: right = forward x up + * (sanity: facing -Z, right must be +X). */ + Ogre::Vector3 right = dir.crossProduct(Ogre::Vector3::UNIT_Y); + + /* Total carriageway half width from the resolved lane + * counts; the rightmost lane's center sits half a lane + * width in from the curb. */ + int lanesFrom = 0; + int lanesTo = 0; + graph.resolveLaneCounts(*best, best->nodeA, lanesFrom, + lanesTo); + float halfW = + (lanesFrom + lanesTo) * graph.config.laneWidth * 0.5f; + float lateral = halfW - graph.config.laneWidth * 0.5f; + + /* Endpoints are off-road POIs (curbs sit on the lot in + * front of the building): snap them onto the carriageway + * first, so a vehicle route never leaves the road to + * touch the exact curb (and never plows into the car + * parked there). */ + if (i == 0 || i + 1 == path.size()) + path[i] = bestProj; + + path[i] += right * lateral; + } +} + bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos, Ogre::Vector3 &projPoint, Ogre::Vector3 &edgeDir, float &carriagewayHalf) diff --git a/src/features/editScene/roadlib/RoadPath.hpp b/src/features/editScene/roadlib/RoadPath.hpp index 88fec2c..4979d39 100644 --- a/src/features/editScene/roadlib/RoadPath.hpp +++ b/src/features/editScene/roadlib/RoadPath.hpp @@ -54,6 +54,19 @@ void offsetToShoulder(const RoadGraph &graph, std::vector &path, float shoulderMargin = 0.6f); +/** + * Shift a driving path onto the right traffic lane (demo-taxi phase + * 5.4): every waypoint moves perpendicular to its nearest road + * edge, to the RIGHT of the travel direction, onto the center of the + * rightmost lane (carriageway half width minus half a lane width). + * Endpoints are off-road POIs (curbs sit on the lot in front of the + * building): they are first snapped onto the nearest carriageway point, + * so a vehicle route never leaves the road. No-op for paths shorter + * than 2 points or an edgeless graph. + */ +void offsetToRightLane(const RoadGraph &graph, + std::vector &path); + /** Total polyline length in meters. */ float polylineLength(const std::vector &path); diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index dfb90c1..ac7a314 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -2911,7 +2911,17 @@ void CellGridSystem::destroyCellGridMeshes(flecs::entity entity) // Destroy physics colliders destroyPhysicsColliders(entity); + /* Keep the in-flight door swing states across the erase (a + * rebuild re-creates the MeshData right away and re-applies + * them to the rebuilt doors); a teardown for a dead owner + * (scene switch) drops them with the entry. */ + auto savedDoorStates = std::move(it->second.pendingDoorStates); m_entityMeshes.erase(it); + if (!savedDoorStates.empty() && entity.is_valid() && + entity.is_alive()) { + m_entityMeshes[entity.id()].pendingDoorStates = + std::move(savedDoorStates); + } } } @@ -5105,6 +5115,39 @@ void CellGridSystem::buildDoorEntities(flecs::entity entity, if (!doorEntity.is_valid()) return; + /* Re-apply in-flight swing state stashed by + * destroyDoorEntities() before this rebuild (one-shot). */ + if (!params.doorId.empty()) { + auto stIt = meshData.pendingDoorStates.find( + params.doorId); + if (stIt != meshData.pendingDoorStates.end()) { + const MeshData::DoorSwingState &st = + stIt->second; + auto &door = + doorEntity.get_mut(); + door.isOpen = st.isOpen; + door.currentAngle = st.currentAngle; + door.toggleRequested = st.toggleRequested; + door.sceneSwitchPending = + st.sceneSwitchPending; + if (transform.node && + st.currentAngle != 0.0f) { + auto &dt = doorEntity.get_mut< + TransformComponent>(); + if (dt.node) + dt.node->setOrientation( + DoorSystem::swingOrientation( + door, + st.currentAngle)); + } + auto &rb = doorEntity.get_mut< + RigidBodyComponent>(); + rb.enabled = !st.isOpen && + st.currentAngle == 0.0f; + meshData.pendingDoorStates.erase(stIt); + } + } + meshData.doorEntities.push_back({key, doorEntity}); }; @@ -5147,6 +5190,23 @@ void CellGridSystem::destroyDoorEntities(flecs::entity entity) if (!doorEntity.is_valid() || !doorEntity.is_alive()) continue; + /* Stash in-flight swing state so the rebuild that follows + * (e.g. the texture-ready rebuild right after scene load) + * does not eat a pending toggle or scene-switch request. */ + if (doorEntity.has()) { + const auto &d = doorEntity.get(); + if (!d.doorId.empty() && + (d.toggleRequested || d.sceneSwitchPending || + d.currentAngle != 0.0f)) { + MeshData::DoorSwingState st; + st.isOpen = d.isOpen; + st.currentAngle = d.currentAngle; + st.toggleRequested = d.toggleRequested; + st.sceneSwitchPending = d.sceneSwitchPending; + it->second.pendingDoorStates[d.doorId] = st; + } + } + // Remove the physics body before the entity dies if (doorEntity.has() && m_physicsSystem) { diff --git a/src/features/editScene/systems/CellGridSystem.hpp b/src/features/editScene/systems/CellGridSystem.hpp index 00aa8d5..bd05bd8 100644 --- a/src/features/editScene/systems/CellGridSystem.hpp +++ b/src/features/editScene/systems/CellGridSystem.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include "../components/RigidBody.hpp" @@ -304,6 +305,19 @@ private: // with its canonical edge key (F0) std::vector > doorEntities; std::string doorLeafMesh; // procedural leaf mesh (custom mesh not owned) + + /* In-flight door swing state carried over a rebuild: the grid + * rebuilds when its procedural texture first becomes ready + * (right after scene load), which recreates the door entities; + * without the carry-over a toggle/scene-switch request landing + * just before that rebuild is silently lost. */ + struct DoorSwingState { + bool isOpen = false; + float currentAngle = 0.0f; + bool toggleRequested = false; + bool sceneSwitchPending = false; + }; + std::map pendingDoorStates; }; std::unordered_map m_entityMeshes; diff --git a/src/features/editScene/systems/EditorWaterPlaneSystem.cpp b/src/features/editScene/systems/EditorWaterPlaneSystem.cpp index 6f4ac36..a209591 100644 --- a/src/features/editScene/systems/EditorWaterPlaneSystem.cpp +++ b/src/features/editScene/systems/EditorWaterPlaneSystem.cpp @@ -100,14 +100,25 @@ void EditorWaterPlaneSystem::update(float deltaTime, Ogre::Camera *camera) if (camera) updatePosition(wp, deltaTime, camera); - // Alternate viewport updates - if (wp.renderTarget) { + // Alternate viewport updates, throttled: each RTT update is a full + // scene re-render and dominates the water CPU cost. Skipped + // entirely while the water surface is outside the camera frustum. + if (wp.renderTarget) { + bool surfaceVisible = true; + if (camera && wp.manualObject) + surfaceVisible = camera->isVisible( + wp.manualObject->getWorldBoundingBox()); + if (surfaceVisible && + ++wp.rttFrameCounter >= wp.rttUpdateInterval) { + wp.rttFrameCounter = 0; if (wp.updateViewportIndex == 0 && wp.reflectionViewport) wp.reflectionViewport->update(); - else if (wp.updateViewportIndex == 1 && wp.refractionViewport) + else if (wp.updateViewportIndex == 1 && + wp.refractionViewport) wp.refractionViewport->update(); wp.updateViewportIndex = 1 - wp.updateViewportIndex; } + } wp.sceneNode->setVisible(true); }); @@ -206,6 +217,14 @@ void EditorWaterPlaneSystem::rebuildWaterPlane( } wp.manualObject->setMaterialName(0, "WaterPlane/Dynamic"); + // Cache the shader parameter targets so the per-frame update does + // not hit the material manager + wp.cachedMaterial = mat; + wp.cachedPass = nullptr; + if (mat && mat->getNumTechniques() > 0 && + mat->getTechnique(0)->getNumPasses() > 0) + wp.cachedPass = mat->getTechnique(0)->getPass(0); + // Set visibility mask so cameras can hide water wp.manualObject->setVisibilityFlags(WATER_MASK); @@ -260,6 +279,8 @@ void EditorWaterPlaneSystem::cleanupWaterPlane(WaterPlane &wp) m_sceneMgr->destroyManualObject(wp.manualObject); wp.manualObject = nullptr; } + wp.cachedMaterial.reset(); + wp.cachedPass = nullptr; if (wp.sceneNode) { if (wp.sceneNode->getParentSceneNode()) { wp.sceneNode->getParentSceneNode()->removeChild( @@ -409,15 +430,23 @@ void EditorWaterPlaneSystem::updateShaderParams(WaterPlane &wp, { wp.shaderTime += deltaTime * wp.waveSpeed; - Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().getByName( - "WaterPlane/Dynamic", - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - if (!mat) + if (!wp.cachedPass) { + wp.cachedMaterial = + Ogre::MaterialManager::getSingleton().getByName( + "WaterPlane/Dynamic", + Ogre::ResourceGroupManager:: + DEFAULT_RESOURCE_GROUP_NAME); + if (wp.cachedMaterial && + wp.cachedMaterial->getNumTechniques() > 0 && + wp.cachedMaterial->getTechnique(0)->getNumPasses() > 0) + wp.cachedPass = + wp.cachedMaterial->getTechnique(0)->getPass(0); + } + if (!wp.cachedPass) return; - Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); Ogre::GpuProgramParametersSharedPtr fpParams = - pass->getFragmentProgramParameters(); + wp.cachedPass->getFragmentProgramParameters(); if (!fpParams) return; @@ -430,7 +459,7 @@ void EditorWaterPlaneSystem::updateShaderParams(WaterPlane &wp, fpParams->setNamedConstant("reflectivity", wp.reflectivity); Ogre::GpuProgramParametersSharedPtr vpParams = - pass->getVertexProgramParameters(); + wp.cachedPass->getVertexProgramParameters(); if (vpParams) vpParams->setNamedConstant("tiling", wp.tiling); } diff --git a/src/features/editScene/systems/LodSystem.cpp b/src/features/editScene/systems/LodSystem.cpp index d85dfe4..12033cd 100644 --- a/src/features/editScene/systems/LodSystem.cpp +++ b/src/features/editScene/systems/LodSystem.cpp @@ -39,7 +39,10 @@ void EditorLodSystem::update() RenderableComponent &renderable) { // Try to resolve settings entity if we have an ID but no valid entity if (!lod.settingsId.empty() && !lod.hasValidSettings()) { - m_world.query().each( + /* Cached member query: building a fresh query here (per + * entity, per frame) walks the whole table index and + * showed up as ~5 ms/frame in EDITSCENE_PROFILE. */ + m_settingsQuery.each( [&](flecs::entity e, LodSettingsComponent &settings) { if (settings.settingsId == @@ -61,7 +64,7 @@ void EditorLodSystem::update() // Final fallback: if settingsId is still empty, try to use any available LOD settings if (lod.settingsId.empty() && !lod.hasValidSettings()) { - m_world.query().each( + m_settingsQuery.each( [&](flecs::entity e, LodSettingsComponent &settings) { if (lod.settingsEntity == diff --git a/src/features/editScene/systems/NpcDetailSystem.cpp b/src/features/editScene/systems/NpcDetailSystem.cpp index 7309a53..ced4a4b 100644 --- a/src/features/editScene/systems/NpcDetailSystem.cpp +++ b/src/features/editScene/systems/NpcDetailSystem.cpp @@ -17,6 +17,7 @@ #include "NpcMotionSystem.hpp" #include "NpcPopulationSystem.hpp" #include "NpcTaxiSystem.hpp" +#include "NpcTrafficSystem.hpp" #include "PhysicsSystem.hpp" #include "RenderOriginSystem.hpp" #include "../GameMode.hpp" @@ -61,6 +62,12 @@ bool NpcDetailSystem::isSpawnEligible(uint64_t registryId) const NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance(); if (taxi && taxi->hasActiveGoal(registryId)) return true; + /* Traffic drivers (phase 5.4) sit in their car — suspended from + * the schedule layer, so motionInfoOf reports nothing and the + * fallthrough below would wrongly spawn them as pedestrians. */ + NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance(); + if (traffic && traffic->isDriving(registryId)) + return false; NpcMotionSystem *motion = NpcMotionSystem::getInstance(); NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); if (!motion || !pop) diff --git a/src/features/editScene/systems/NpcDetailSystem.hpp b/src/features/editScene/systems/NpcDetailSystem.hpp index 7ca2ea2..3cd1c94 100644 --- a/src/features/editScene/systems/NpcDetailSystem.hpp +++ b/src/features/editScene/systems/NpcDetailSystem.hpp @@ -63,6 +63,17 @@ public: /** True while the NPC has a live spawned character entity. */ bool isHighDetail(uint64_t registryId) const; + /** Number of population NPCs currently in the high-detail tier + * (benchmark telemetry). */ + size_t highDetailCount() const + { + size_t n = 0; + for (const auto &kv : m_npcs) + if (kv.second.highDetail) + ++n; + return n; + } + /** * Spawn eligibility (demo-taxi phase 4.9.1): an NPC whose current * symbolic location is INDOORS (TownLocations::isIndoorKind — diff --git a/src/features/editScene/systems/NpcTaxiSystem.cpp b/src/features/editScene/systems/NpcTaxiSystem.cpp index 200aa8e..7e5646e 100644 --- a/src/features/editScene/systems/NpcTaxiSystem.cpp +++ b/src/features/editScene/systems/NpcTaxiSystem.cpp @@ -14,6 +14,7 @@ #include "RenderOriginSystem.hpp" #include "RoadSystem.hpp" #include "TerrainSystem.hpp" +#include "NpcTrafficSystem.hpp" #include "../EditorApp.hpp" #include "../GameMode.hpp" #include "../components/Character.hpp" @@ -130,6 +131,11 @@ bool NpcTaxiSystem::assignTaxiGoal(uint64_t registryId, CharacterRegistry::getSingleton().findCharacter(registryId); if (!pop || !motion || !rec) return false; + /* Traffic drivers (phase 5.4) are suspended in their car; a taxi + * goal would fight NpcTrafficSystem over the NPC. */ + NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance(); + if (traffic && traffic->isDriving(registryId)) + return false; int areaIdx = pop->areaIndexOf(registryId); if (areaIdx < 0 || !findLocation(areaIdx, destLocation)) return false;