19 KiB
Traffic, interactive: design spec
Status: design only, nothing built. Branch redesign/overnight at 8c9ec69. Owner's ask: "make the traffic more interactive, without significantly crippling performance; it just exists, it clips with the car and has no real interaction."
1. Diagnosis (why it clips, why it is dead)
Read from src/scene/world/traffic.ts and environment.ts, checked on hardware GL (1440x900, idle, brisk scroll, reverse; frames in the scratchpad traffic-look/).
- It never passes the car; it dissolves. Vehicles live in z in [-660, -24] and fade to black over [-46, -24] (
TRAFFIC_NEAR_Z,TRAFFIC_FADE_LENGTH), then wrap to the far end. The hero's nose is at z -5.5, the chase camera at z 23-26. So every vehicle that is slower than the reader (all of them during any scroll: travel speed 18-120 u/s vs lanes 11/16/33/46) drives into a 20 m black zone in front of the bonnet and vanishes mid-road, and at idle the two fast lanes (33, 46 u/s) materialise out of that same zone 20 m ahead of the bonnet and drive off. Both read as the hero car eating or extruding traffic. That is the "clipping". - The inner lanes shave the car. Lanes at x +-4.0 with 1.1 m half-width leave 0.3 m (0.2 m with jitter) to the hero's 2.6 m half-width, and their 1.87 m wide road pools extend under the hero's lane. Side by side they would read as a collision; today the fade hides it, which is the point above.
- Scale mismatch is the root reason the fade exists. The hero renders at 2.78x (body 5.2 m wide, 14 m long); traffic is 1x (2.2 x 4.8 m). A 1x car alongside the hero would read as a toy, so it was kept 24 m away and faded. Any design that lets traffic pass the car must fix the scale first.
- No interaction. One speed per lane forever, no following, no braking, no lane changes, nothing responds to the reader's speed beyond the relative-speed trail length, nothing to the headlights, the chapter, the pointer or the destination. The centre lamp is always lit (a brake lamp that never brakes). The reader is not in the traffic; the traffic is a conveyor next to them.
2. Design (what it should feel like)
A three-lane night expressway. The reader drives the centre lane. Left lane is the fast lane, right lane is freight and slow cars. Traffic is a small deterministic car-following simulation that treats the hero as a vehicle it cannot touch:
- Idle (cruise 18 u/s): fast cars come up from behind on the left, pass alongside the hero and pull away; trucks and slow cars are overtaken on the right, slowly. Far ahead, a right-lane car stuck behind a truck signals, pulls into your lane, passes the truck and signals back in. Brake lamps come on when anyone closes on a slower vehicle (real queues form behind trucks in the freight chapters).
- Scrolling (up to 120 u/s): everything streams toward you and exits beneath the camera; trails lengthen as today. Anything in your lane sees you coming and moves over (indicator, lane change) long before you reach it: fast scroll clears the road ahead. Nothing new pulls into your lane while you are moving fast.
- Stop scrolling: you drop to cruise in ~1 s; the cars you just passed come back past you on the left over the next seconds. Idle again, someone pulls out ahead.
- Headlights (HIGH BEAM): the beam the cursor steers lights the rear faces and the reflectors of the vehicles it sweeps across (retro-reflective flare on the tail lamps and the truck's rear bar). Click = flash to pass: a vehicle in your lane within 320 m signals and moves over.
- Chapters: deep-night sparse; Experience moderate; Infrastructure and Builds are night freight (trucks, queues, brake chains); Toolkit pre-dawn sparse; at the destination the engine stops and the night traffic goes on past the parked car, thinning to none as the dawn rises.
- Reverse scroll: the road never runs backwards (LOG rule), so reverse is a forward drive: same behaviour, no special case. Nav jumps: a sweep; no manoeuvre starts while sweeping, in-flight ones finish, vehicles in the centre yield on the normal deadline.
Cut (gimmicks, or unsafe): honks, oncoming high-beam dips (no oncoming lane), hover labels, hazard flashing, any random strobe. The pointer already steers the beam; lighting a car's reflectors by pointing at it and flashing it out of your way is the pointer interaction.
3. Geometry
- Asphalt is |x| < 7.78 (
road.ts). Lane centres: LEFT x = -5.4, RIGHT x = +5.4, CENTRE x = 0 (hero; traffic only transiently, far ahead). Road shader: move the single centre dash (|roadX| = 0.17) to two dashes at |roadX| = 3.0 (hero wheel track is 2.5, body edge 2.6: 0.4 m clear). - Vehicle scale: cars 1.7x (half-width 1.87, length 8.2, roof 2.7), trucks 1.4x (half 1.75, length 21, roof 5.7). Trucks must stay under 6 m: the mile-marker plates hang 6.3 m over the right lane (
milestones.tsARM). Edges: cars 3.53..7.27, trucks 3.65..7.15; hero clearance >= 0.9 m, asphalt clearance >= 0.5 m. Lane jitter +-0.1 stays. - Hero envelope (model): x in [-2.6, 2.6], z in [-6, 10] (nose -5.5, tail 3.17 * 2.78 + 0.72). Corridor: a vehicle is "in the corridor" when |x| < 2.6 + its half-width.
- Visible z range: [-660, +18]. +18 is below every chapter camera's frame (camera z 23-26, y 5.6-9.5; at z 18 a vehicle is 40-60 degrees below the view axis, half-FOV <= 32). Vehicles leave at the bottom edge, never dissolve, never reach the camera. Far end: existing fog fade (420-660 view depth) covers entry.
- Camera-side trucks: when the chapter camera sits on a lane's side (
sign(cameraX) == laneSide && |cameraX| > 1.5, e.g. Profile x +4.5, Selected builds +2.5), a 5.7 m truck passing at x 5.4 would fill the frame. Such trucks keep today's near fade (-30, 22 m band). The limit is latched per vehicle when it crosses z = -60 so a camera move mid-pass cannot pop it. Cars never fade.
4. Pure model (src/scene/world/traffic-model.ts, no three.js import, lazy scene chunk)
export interface TrafficConfig { pool: 6 | 10 | 12; seed: number; lite: boolean }
export interface TrafficInput {
dt: number; // clamped to [0, 0.1] inside
heroSpeed: number; // u/s, (18 + driveSpeed*54 + roadEnergy*48) * (1 - engineOff) * roll
chapter: EnvironmentChapter;
sweeping: boolean; // milestones' smoothed scroll rate > MILESTONE_FLASH_MAX_RATE, or nav in flight
flash: boolean; // rising edge of beam.intensity > 0.95 (one frame)
cameraX: number; // damped chapter camera x
engineOff: number; // 0..1
}
export interface TrafficFrame { // preallocated, rewritten in place every step
readonly count: number;
readonly kind: Uint8Array; // 0 car, 1 truck (static)
readonly active: Uint8Array; // 0 pending/hidden, 1 visible, 2 dithering out
readonly x: Float32Array; readonly z: Float32Array; readonly yaw: Float32Array;
readonly speed: Float32Array; // own road speed, u/s
readonly brake: Float32Array; // 0..1, ramped
readonly indicator: Int8Array; // -1 left, 0 off, 1 right
readonly blink: Float32Array; // 0..1 lamp level for the indicator this frame
readonly nearLimit: Float32Array; // +18 or -30 (latched)
readonly dither: Float32Array; // 0..1 emergency fade
}
export interface TrafficModel { step(input: TrafficInput): TrafficFrame; events(): TrafficEvents; reset(): void }
export interface TrafficEvents { passes: number; emergencyExits: number; brakeOnsets: number; laneChanges: number }
State per vehicle: kind, scale, home lane (LEFT/RIGHT), lane (LEFT/CENTRE/RIGHT), x, targetX, laneT (0..1 lateral progress), z, v, v0 (kind base +-8 % seeded), a (last accel), brakeOn, brakeSince, brakeOffSince, indicator, indicatorSince, phase (seeded 0..1), nearLimit, pending flag. Lanes keep a fixed-size index array ordered by z (insertion sort each step, n <= 12). The hero is a virtual vehicle in CENTRE at z in [-6, 10], v = heroSpeed, never stepped.
Step order: (1) sort lanes; (2) accelerations: IDM-lite per vehicle against its lane leader (a vehicle mid-change counts in both lanes until laneT > 0.5); (3) manoeuvre decisions (below); (4) integrate v and z in the hero frame: z += (heroSpeed - v) * dt; lateral x = lerp(fromX, targetX, smoothstep(laneT)), laneT += dt / 1.4, yaw = +-0.05 * sin(pi * laneT); (5) lamps: brake hysteresis and ramps, indicator blink blink = smoothstep(0.0, 0.08, tri(1.5 Hz, phase)) * duty 0.4; (6) wrap: z > +18 or z < -660 -> pending; pending vehicles re-enter where the chapter wants them and a slot is free; (7) events.
IDM-lite: a = A * (1 - (v/v0)^4 - (sStar/s)^2), sStar = S0 + v*T + v*dv / (2*sqrt(A*B)), A 2.2, B 3.0, S0 6, T 1.3; s is the bumper gap (leader z + leader length... in body space the origin is the rear face, so gap = leaderZ - (z - ownLength)); no leader: free road. v clamped >= 4 (nothing stops dead on an expressway; the hero is never a leader for the same lane because nothing is behind it in the centre).
Centre-lane overtake (the only lane changes):
- Enter CENTRE only if: kind car, home RIGHT, z < -220, the home leader is within 60 m and >= 4 u/s slower,
heroSpeed - v < 12(reader near cruise), no vehicle in CENTRE, not sweeping, >= 4 s since any manoeuvre started, engineOff == 0. Indicator left for 0.6 s, then the 1.4 s lateral. - Leave CENTRE when any of: overtake complete (ahead of the home leader by S0 + vT); time-to-reach
(z + 6) / max(heroSpeed - v, 0.1) < 3.0 swith closing > 0; flash while z > -320; deadline z > -170. Return to RIGHT if its slot is free (gap to the leader >= S0 + vT, to the follower >= S0 + vf*T at this z), else LEFT if free (the vehicle then adopts LEFT as home), else keep driving and retest each frame. - Emergency exit: still in the corridor at z > -170 with no slot: dither out over 0.25 s (screen-door, as the yard and posts use), become pending, re-enter from the far haze. Proof of the no-overlap invariant: leaving the corridor takes 1.2 s of the 1.4 s change (|x| >= 2.6 + 1.87 = 4.47 of 5.4); worst closing is 120 - 4 = 116 u/s plus one 0.1 s frame = 151 m; an exit started at -150 would clear the corridor only at z +1, so the deadline is -170 (clear by -19, nose at -6). Entry at -220 leaves >= 50 m of centre-lane time even in the worst case; at idle it is minutes.
Pending and density: chapter targets per lane (LEFT/RIGHT): deep-night 2/2, systems 3/3, infrastructure 3/5 (3 trucks), builds 3/4 (2 trucks), pre-dawn 2/2, destination 1/1 and 0/0 once engineOff > 0.5. Pool 12 (high), 10 (medium), 6 (low: 2/4, 1 truck in RIGHT, max 2 trucks ever). Re-entry: from the far end (-640) if the vehicle is slower than the hero, from behind (+18) if faster; only when the gap to the lane neighbour at the entry point is >= S0 + v*T + length. Targets above the pool are clamped; a chapter change never despawns on screen (extra vehicles exit naturally and stay pending).
Invariants (always, every step):
- No vehicle AABB intersects the hero AABB (x +-2.6, z [-6, 10], using the vehicle's live x and its length).
- No two vehicle AABBs intersect (lateral check uses live x, so mid-change vehicles count against both lanes).
- Every visible vehicle has |x| + halfWidth <= 7.78 and z in [-660, 18].
- At most one vehicle in CENTRE; at most two indicators on; indicator frequency exactly 1.5 Hz; a vehicle's brake onsets are >= 0.7 s apart and last >= 0.5 s; global brake onsets <= 3 per second (later ones delay 150 ms).
- Same seed and input sequence -> byte-identical frames (deterministic LCG, no Math.random, no Date).
stepallocates nothing after construction (returns the same TrafficFrame object).- Emergency exits are rare: <= 1 per 10 simulated minutes of adversarial driving (see tests), and never within 2 s of the last.
5. Rendering changes (traffic.ts)
- Bodies: two InstancedMeshes as today, instance matrix = translate(x, 0, z) * rotY(yaw) * scale(kind). Keep
renderOrder -1. - Lamps:
LAMPS_PER_VEHICLE4 -> 6 (tail L/R, CHMSL or clearance L/R, indicator L/R at the rear corners: cars at x +-0.95 y 0.92, trucks at +-1.05 y 1.3, amber).aLevelbecomes a dynamicInstancedBufferAttribute(DynamicDrawUsage) written per frame from the model: tail = tint0.6 * (1 + 1.4brake); CHMSL = brake only (off at rest; today it is always lit); indicator = 2 + blink (shader: amber * blink); hidden lamps scale 0. - Beam: add
vWorldandBEAM_PARS(headlights.ts) to both shaders on high/medium (uBeamGainis 0 on low, so one code path): body rear face+= vec3(0.19,0.15,0.27) * rear * (0.55*fakeBeam + 1.6*headlightBeam(vWorld)); lamp quadsglow += headlightBeam(vWorld) * 2.0on the tail pair and the truck under-run bar (retro-reflector flare). The beam uniforms are already shared; no new lights. - Pools: one rear pool per vehicle as today plus, on high/medium, one warm front pool (vec3(1.0, 0.86, 0.6), ahead of the nose, length 14) so a car alongside reads as lit. Count = 2 * pool.
- Near fade: shader term takes a per-instance
aNear(+18 or -30) instead of the constant:smoothstep(0,1, clamp((aNear - world.z) / 22, 0, 1)); dither:if (hash(gl_FragCoord.xy) < aDither) discardon the body (same pattern as the yard). - Trails unchanged (direction from the live relative speed per vehicle).
environment.ts: buildTrafficInputfrom the existing snapshot/response values;sweepingreuses the milestones' smoothed rate (export it or compute the same 80/400 ms filter once);flashfrombeamLevels(beam);cameraXfromcameraState.x.- Audio (P2, lazy
src/audio/traffic-pass.ts, same contract asrelay-tone.ts): a 0.5 s band-passed noise swell (gain 0.08, StereoPanner +-0.6 by lane side) when a visible vehicle crosses z = 8 with |heroSpeed - v| > 6; >= 2 s apart; only while the switchboard is audible; not on lite.
6. Tiers, motion, photosensitivity
- High 12 vehicles, medium 10 (both: beam term, front pools, whoosh); low/phones 6, no front pools, no whoosh, same model (it is 12 IDM evaluations and a 12-element sort per frame). Reduced motion / static: no scene, nothing added.
- Indicators 1.5 Hz with 80 ms edges, <= 2 at once, each lamp < 0.05 % of the frame when nearest; brake lamps ramp 150 ms up / 300 ms down with the 0.5 s / 0.7 s rules; CHMSL only under braking; reflector flare follows the beam (continuous, <= 1.67 Hz flash rule already enforced by
headlight-beam.ts); dither-out 0.25 s at >= 170 m, <= 1 per 2 s. No full-field change: the existingvisual-regression.spec.tsflash analysis must stay green at idle and brisk scroll.
7. Budgets and cost
- Initial JS: unchanged (everything is in the lazy scene chunk; no new exports from the initial chunk;
sweepingmust not pull milestone code into the initial chunk). - Scene chunk: model ~2.5 KB gz, shader and lamp changes ~1 KB, road dashes ~0.1 KB, audio ~0.4 KB: target +4 KB, hard cap +8 KB (225 -> <= 233 KB gz).
- Per frame: no allocation (frame arrays preallocated, lane index arrays fixed,
dummyObject3D reused), 12 vehicles x (IDM + 6 lamps + 2 trails + 2 pools) matrix writes ~ 130setMatrixAt, one extra attribute upload (72 floats). Expect rAF p50/p95 unchanged (16.7 / <= 16.8 ms on the review rig).
8. Tests
tests/unit/traffic-model.test.ts (vitest, no three):
- Long randomised drives, no overlap ever: 20 seeds x 10 simulated minutes at dt 1/60 with a hero-speed script that mixes idle, ramps to 120, instant 18<->120 steps every 0.3-4 s, 0.1 s frames, sweeps, chapter changes every 20-60 s, random flashes (>= 0.6 s apart) and an engine-off tail. Assert invariants 1-4 every step, 7 over the run, and count
laneChanges > 0,passes > 0,brakeOnsets > 0per seed. - Adversarial: floor the hero the frame after a centre entry starts, and the frame a vehicle reaches the deadline; assert invariant 1 and that the exit is a lane change, not an emergency, whenever a slot exists.
- Determinism (invariant 5), no allocation (same frame object, same typed arrays,
Object.isFrozenon the lane tables), dt clamp, non-finite inputs ignored. - Density: per chapter the visible count converges to the target within 30 s at idle and never despawns on screen on a change; low pool caps trucks at 2.
- Flash to pass: a centre vehicle at z -250 yields within one frame of
flash, indicator first; a flash with nothing in the centre does nothing. - Sweeping: no manoeuvre starts while
sweeping; in-flight changes complete. - Idle choreography: with the hero at 18 u/s, a LEFT vehicle crosses the hero envelope from behind to ahead (a pass) within 60 s; a RIGHT truck is passed.
- Lamps: indicator blink period 1/1.5 s with the 0.4 duty; brake hysteresis and minimum on-time; CHMSL off at rest.
- Near limit latch: camera-side truck gets -30 at z -60 and keeps it after
cameraXchanges sign.
tests/unit/traffic.test.ts (update): lane geometry with the new scales (hero and asphalt clearances, lane/lane gap), lamp placement inside the body incl. the amber pair, 6 lamps per vehicle, nearFade with +18 and -30, wrap band [-660, 18], shader source contains headlightBeam( and aDither, road shader has two dashes at |roadX| 3.0.
tests/unit/traffic-pass.test.ts: plays only when audible, 2 s gap, pan sign by side (mirror relay-tone.test).
9. Acceptance (hardware GL, 1440x900 high, plus 1024x700 medium and 390x844 low)
- Idle 60 s at Start and at Profile: at least one car passes alongside on the left and leaves at the bottom edge; no vehicle dissolves in front of the bonnet; no vehicle ever overlaps the hero in any frame (sheet at 2 fps).
- Brisk wheel scroll through Infrastructure: a centre-lane vehicle, if present, signals and moves over before 170 m; trails lengthen; frame p95 <= 16.8 ms, 0 frames over 34 ms; no console errors.
- Stop after a brisk scroll: within 8 s a left-lane car re-passes the hero.
- Hover the pointer across a vehicle 60-150 m ahead: its tail lamps and rear face brighten with the beam; click with a car in the centre lane: it indicates and moves over; click with none: nothing.
- Infrastructure at idle: trucks in the right lane with a queue and at least one brake-lamp onset per 20 s on average over 2 min.
- Contact: traffic passes the parked car and thins to none by full day; reverse out of Contact restores it.
- Nav jump Start -> Toolkit: no indicator starts during the glide, no vehicle pops, no emergency exit logged (
portfolio-traffic-frametest-only event, like the underpass). - Photosensitivity:
visual-regression.spec.tsflash analysis passes idle and accelerated; no region blinks above 1.5 Hz by inspection of a 10 fps sheet. - Budgets:
check:budgets:transferviolations [], initial JS 115.5 KB gz unchanged, scene chunk <= 233 KB gz. Unit suite green. - Low tier (390x844): 6 vehicles, no front pools, no whoosh, same no-overlap behaviour; reduced motion unchanged.