feat: interactive traffic with car-following, overtakes and flash-to-pass

This commit is contained in:
2026-10-11 23:13:05 +03:00
parent 8c9ec69565
commit 50da616a46
22 changed files with 2513 additions and 230 deletions

View File

@@ -110,3 +110,21 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Review (Sonnet, hardware GL):** per-frame allocations removed, non-finite distance guarded; featured plates now hang on a 4.2 m arm over the road (clears traffic at ~6.3 m) so the Experience plate is fully in frame at 1440.
- **Fixed after (opening entry):** a chapter's first entry used to arrive up to two entries late when the reader started at the heading, because only posts in the haze (~150 m out) could take an entry. Now, for 0.75 s after a chapter change, the posts already on the road stay dark; once the reading pace is known, the one that will pass closest to the first entry's activation takes it (the nearest when the reader is still), unless a post from the haze would be on time anyway. Haze assignments wait for that, and there is no glint in the 0.3 s after a change or on a post leaving its chapter. Unit tests show entry 0 now passes within 0.4 of an entry's share of its line (it used to miss by up to about 2 shares), and within 6.5 s when the reader stays at the heading.
- Screenshots: docs/overnight/polish/posts-*.webp (before/after, idle contact strips before and after, feature plate zoom).
## Polish: interactive traffic (owner feedback)
- **What:** traffic was scenery that clipped through the hero car. It is now a small deterministic simulation (scene/world/traffic-model.ts, typed arrays, a seeded LCG, a preallocated frame, no per-frame allocation). Vehicles keep their distance with IDM-lite car-following, treat the hero as a virtual leader, and a hard front-to-back constraint means no two bodies (hero included) ever overlap. The road is three lanes with dashed dividers: fast cars on the left (about 40 u/s, they overtake the hero alongside and pull away), slow cars and box trucks on the right (the hero passes them), and the centre lane is used for overtakes only. An overtaking car signals for 1.4 s, at 1.5 Hz with a 0.4 duty, before it changes lane. It plans its return before it enters and yields early, or sprints, when the hero closes in. Brake lamps and the CHMSL light on real deceleration, with hysteresis and minimum on-time, rippling back through a queue and capped at 3 onsets per second. **Flash to pass:** a high-beam flash makes a centre-lane car indicate and move out of the way, and tail lamps and truck under-run bars flare as retro-reflectors in the beam cone (new `headlightCone()` in BEAM_PARS). **Scroll:** while the drive sweeps, or on a navigator arrival, nobody changes lane. Density follows the chapter (busy in Infrastructure, empty at the destination and with the engine off). Vehicles enter and leave only in the haze or behind the camera, never on screen. A vehicle passing under an off-centre chase camera dissolves out instead of becoming a dark slab. On medium and high tiers (not lite, not Save-Data), a short band-passed whoosh panned to the passing side plays through the soundtrack's switchboard; it lives in a lazy 0.5 KB chunk.
- **Build:** traffic-model.ts (new, pure), traffic.ts (rewritten renderer: instanced bodies with aBody near/dither/brake/camera-side, six lamps per vehicle, trails, rear pools, warm front pools on medium and high), environment.ts (sweep, flash and chapter inputs, lazy swell), headlights.ts (`headlightCone`), road.ts (centre line becomes two lane dividers), milestones.ts (comment: the arm clears 5.67 m trucks), audio/traffic-pass.ts (new), bundle-budgets.mjs (`traffic-pass-` deferred-non-video). 963 tests (81 files, +31; 20 seeds × 10 simulated minutes with zero overlaps, adversarial speeds and frame steps, flash, sweep, density, brake and blink timing). violations [].
- **Budgets:** initial JS unchanged (339354 B raw; the gz figure moves by 1 B of hash noise), scene chunk 222.09 -> 227.39 KB gz (+5.3, cap 8), traffic-pass chunk 494 B (deferred-non-video).
- **Perf (hardware GL 1440x900, r6 flags, same runs as the baseline):** rAF p50 16.7 / p95 16.7-16.8 ms on idle Start, brisk scroll, idle Infrastructure, wheel, nav jump and reverse, identical to the baseline. 0 frames over 34 ms; the wheel run's single 33.4 ms max is also in the baseline. No console errors.
- **Deviations from the spec:** speeds retuned against the real idle cruise (18 + 0.18·54 = 27.7 u/s, not 18); vehicles leave by their nose at z 20 into a behind-camera buffer instead of by their rear at +18; cars never near-fade (only trucks latch a camera-side near limit at -30); the camera-side dissolve applies to every kind; sweep = smoothed |Δroute|/dt > 0.12 or a navigator arrival; added yield heuristics (forced 0.35 s headway, a 6 s "soon" horizon, a sprint boost, a latch after a flash, a return-room check), without which the centre car could be boxed in, which produced emergency exits.
- **Known weaknesses:** overtakes and their indicators happen 200-320 m ahead, where a vehicle is a few pixels wide, so the blink is barely legible at 1440 (the strips show the manoeuvre, not a readable amber). The reflector flare is subtle. Right after a scroll the road ahead is sparse until new vehicles come out of the haze at -640. The intro sweep can register as one flash. A hardware-GL screenshot run cannot tell an indicator from the yellow lane dashes, so indicator timing is proven in unit tests, not on screen.
- Screenshots: docs/overnight/polish/traffic-*.webp (before/after idle at Start; alongside pass, a left-lane car coming up beside the hero and pulling away; overtake, left-lane overtakes ahead; flash-to-pass, a truck's lamps flaring under the flash and passing on the right; scroll).
- **Review (Sonnet, hardware GL) of the weaknesses above:**
- *Overtakes in frame.* The centre-lane window moved from -280..-150 to -210..-150 (entry at about -192, exit at -115..-150, i.e. 150-215 m from the camera on every tier), with the entry gate relaxed (closing speed on the hero <= 12 u/s, a 90 m leader gap, reach checked with the 8 u/s sprint boost). A **convoy spawn** rule makes the manoeuvre happen at all on the lite pool: a right-lane car fades in queued 30-50 m behind an unfollowed truck in the haze window (-600..-450), or waits a few seconds for a truck to reach it, so a car is behind a truck inside the band instead of only when two spawns happened to line up. Seed 5 lite went from 0 lane changes in 300 s to 6-10; the full pool makes 14-26. If the hero closes before a car reaches the deadline it yields with a forced 0.8 s swerve, which clears the hero corridor by -10 even when the drive steps straight to 120 u/s. The indicator now reads at 1440: the lamp quad swells 1.6x, the gain is 2.4x, and an amber spill (aBlink) paints the signalling corner of the body.
- *Flash.* The flare is a near cutoff (18-50 m) with a warm-white mix (0.7) and a 1.3x lamp swell under the beam lift, plus a faint warm wash on rear faces within 320 m. A brighter version (3.2x, 0.8 swell) was tried and rejected: the bloom pass turned every tail lamp within 100 m into a white patch (road luminance 110 -> 166). At the final level the clip's mean luminance rises 82 -> 102 at most on a flash with vehicles at 100 m, and does not move on an empty road.
- *Sparse after a scroll.* When a lane's band -560..-270 is empty a vehicle enters at -420..-510 with a 0.6 s screen-door fade-in (the dither channel) in the haze instead of at -640.
- *Intro sweep.* `createFlashDetector()` (pure, in traffic-model.ts) arms only on a reading that is below 0.85 and not rising, so the 0 -> 1 sweep never fires; a flash fires once per crossing of 0.95.
- *Click to flash.* Any click that is not on a control, a label, the dashcam frame or a text selection flashes (journey/flash-target.ts, passive listener, nothing prevented), not only `.hero-journey` and `.gantry-window`. Initial JS +120 B raw (339474) for the predicate.
- Tests: 970 (82 files): in-frame overtakes for four pool/seed configurations, near-spawn fade-in, the forced swerve against instant 120 u/s, the detector against the sweep and two flashes, and the click predicate.
- Budgets: scene chunk 228.15 KB gz9 (+6.06 vs 222.09, cap +8). Perf unchanged: rAF p50 16.7 / p95 16.7-16.8 ms on the six runs, 0 frames over 34 ms, no console errors.
- Still weak: there is no visual hint that a click flashes; on lite the overtake comes every ~50 s; the flash is still clearly bright through bloom on vehicles within 100 m (within the 3 Hz, 600 ms interval rule); `traffic-overtake-indicator.webp` is a 120 s capture crop at Infrastructure (centre-lane car at about 200 m with the amber on), `traffic-flash-to-pass.webp` is a before / +1 / +2 frame strip of one flash.

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@@ -0,0 +1,136 @@
# 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/`).
1. **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".
2. **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.
3. **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.
4. **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.ts` ARM). 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)
```ts
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 + v*T); **time-to-reach** `(z + 6) / max(heroSpeed - v, 0.1) < 3.0 s` with closing > 0; **flash** while z > -320; **deadline** z > -170. Return to RIGHT if its slot is free (gap to the leader >= S0 + v*T, 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):**
1. No vehicle AABB intersects the hero AABB (x +-2.6, z [-6, 10], using the vehicle's live x and its length).
2. No two vehicle AABBs intersect (lateral check uses live x, so mid-change vehicles count against both lanes).
3. Every visible vehicle has |x| + halfWidth <= 7.78 and z in [-660, 18].
4. 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).
5. Same seed and input sequence -> byte-identical frames (deterministic LCG, no Math.random, no Date).
6. `step` allocates nothing after construction (returns the same TrafficFrame object).
7. 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_VEHICLE` 4 -> 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). `aLevel` becomes a **dynamic** `InstancedBufferAttribute` (DynamicDrawUsage) written per frame from the model: tail = tint*0.6 * (1 + 1.4*brake); CHMSL = brake only (off at rest; today it is always lit); indicator = 2 + blink (shader: amber * blink); hidden lamps scale 0.
- Beam: add `vWorld` and `BEAM_PARS` (`headlights.ts`) to both shaders on high/medium (`uBeamGain` is 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 quads `glow += headlightBeam(vWorld) * 2.0` on 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) discard` on the body (same pattern as the yard).
- Trails unchanged (direction from the live relative speed per vehicle).
- `environment.ts`: build `TrafficInput` from the existing snapshot/response values; `sweeping` reuses the milestones' smoothed rate (export it or compute the same 80/400 ms filter once); `flash` from `beamLevels(beam)`; `cameraX` from `cameraState.x`.
- Audio (P2, lazy `src/audio/traffic-pass.ts`, same contract as `relay-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 existing `visual-regression.spec.ts` flash 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; `sweeping` must 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, `dummy` Object3D reused), 12 vehicles x (IDM + 6 lamps + 2 trails + 2 pools) matrix writes ~ 130 `setMatrixAt`, 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 > 0` per 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.isFrozen` on 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 `cameraX` changes 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)
1. 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).
2. 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.
3. Stop after a brisk scroll: within 8 s a left-lane car re-passes the hero.
4. 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.
5. 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.
6. Contact: traffic passes the parked car and thins to none by full day; reverse out of Contact restores it.
7. Nav jump Start -> Toolkit: no indicator starts during the glide, no vehicle pops, no emergency exit logged (`portfolio-traffic-frame` test-only event, like the underpass).
8. Photosensitivity: `visual-regression.spec.ts` flash analysis passes idle and accelerated; no region blinks above 1.5 Hz by inspection of a 10 fps sheet.
9. Budgets: `check:budgets:transfer` violations [], initial JS 115.5 KB gz unchanged, scene chunk <= 233 KB gz. Unit suite green.
10. Low tier (390x844): 6 vehicles, no front pools, no whoosh, same no-overlap behaviour; reduced motion unchanged.