diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index 1340a42..960cd75 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -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. diff --git a/docs/overnight/polish/traffic-after.webp b/docs/overnight/polish/traffic-after.webp new file mode 100644 index 0000000..8ecb558 Binary files /dev/null and b/docs/overnight/polish/traffic-after.webp differ diff --git a/docs/overnight/polish/traffic-alongside-pass.webp b/docs/overnight/polish/traffic-alongside-pass.webp new file mode 100644 index 0000000..9971535 Binary files /dev/null and b/docs/overnight/polish/traffic-alongside-pass.webp differ diff --git a/docs/overnight/polish/traffic-before.webp b/docs/overnight/polish/traffic-before.webp new file mode 100644 index 0000000..8e6f943 Binary files /dev/null and b/docs/overnight/polish/traffic-before.webp differ diff --git a/docs/overnight/polish/traffic-flash-to-pass.webp b/docs/overnight/polish/traffic-flash-to-pass.webp new file mode 100644 index 0000000..3cf33bb Binary files /dev/null and b/docs/overnight/polish/traffic-flash-to-pass.webp differ diff --git a/docs/overnight/polish/traffic-overtake-indicator.webp b/docs/overnight/polish/traffic-overtake-indicator.webp new file mode 100644 index 0000000..50a1d7b Binary files /dev/null and b/docs/overnight/polish/traffic-overtake-indicator.webp differ diff --git a/docs/overnight/polish/traffic-scroll.webp b/docs/overnight/polish/traffic-scroll.webp new file mode 100644 index 0000000..9f2291e Binary files /dev/null and b/docs/overnight/polish/traffic-scroll.webp differ diff --git a/docs/overnight/traffic-interactive-spec.md b/docs/overnight/traffic-interactive-spec.md new file mode 100644 index 0000000..554ac83 --- /dev/null +++ b/docs/overnight/traffic-interactive-spec.md @@ -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. diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index b9f4b25..155fd47 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -44,6 +44,8 @@ export const classifyArtifact = (path, source = '') => { if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; // UNDERPASS: the pass swell, loaded when the first deck shades the road with sound on. if (/^assets\/underpass-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; + // NIGHT TRAFFIC: the pass swell, loaded when a vehicle first passes alongside with sound on. + if (/^assets\/traffic-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; // PARKING LIGHTS: the favicon renderer, loaded after mount. if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene'; diff --git a/src/audio/traffic-pass.ts b/src/audio/traffic-pass.ts new file mode 100644 index 0000000..198c91b --- /dev/null +++ b/src/audio/traffic-pass.ts @@ -0,0 +1,66 @@ +import { readSwitchboard } from '@/audio/music-pulse'; + +/** Swell length, seconds. */ +export const TRAFFIC_PASS_SECONDS = 0.5; +/** Peak gain, ~-22 dB: quieter than the underpass swell. */ +export const TRAFFIC_PASS_GAIN = 0.08; +/** Pan by the side the vehicle passes on. */ +export const TRAFFIC_PASS_PAN = 0.6; +/** Two seconds between swells at most. */ +export const TRAFFIC_PASS_GAP_SECONDS = 2; + +let lastStart = Number.NEGATIVE_INFINITY; +let noise: AudioBuffer | null = null; + +/** + * NIGHT TRAFFIC: a vehicle passing alongside, a ~500 ms swell of band-passed + * noise panned to its side, through the switchboard so mute and volume apply. + * Lazy-loaded by the scene (high and medium tiers); plays only while the + * soundtrack is running and audible; never creates an AudioContext and never + * throws. + */ +export function playTrafficPass(side: number): boolean { + try { + const input = readSwitchboard(); + if (input === null || input.context.state !== 'running') return false; + const context = input.context; + const start = context.currentTime + 0.005; + if (start - lastStart < TRAFFIC_PASS_GAP_SECONDS) return false; + lastStart = start; + if (noise === null || noise.sampleRate !== context.sampleRate) { + const length = Math.ceil(context.sampleRate * TRAFFIC_PASS_SECONDS); + noise = context.createBuffer(1, length, context.sampleRate); + const data = noise.getChannelData(0); + for (let i = 0; i < length; i += 1) data[i] = Math.random() * 2 - 1; + } + const source = context.createBufferSource(); + source.buffer = noise; + const filter = context.createBiquadFilter(); + filter.type = 'bandpass'; + filter.Q.setValueAtTime(1.1, start); + filter.frequency.setValueAtTime(420, start); + filter.frequency.exponentialRampToValueAtTime(900, start + TRAFFIC_PASS_SECONDS * 0.4); + filter.frequency.exponentialRampToValueAtTime(300, start + TRAFFIC_PASS_SECONDS); + const envelope = context.createGain(); + envelope.gain.setValueAtTime(0.0002, start); + envelope.gain.exponentialRampToValueAtTime(TRAFFIC_PASS_GAIN, start + TRAFFIC_PASS_SECONDS * 0.4); + envelope.gain.exponentialRampToValueAtTime(0.0002, start + TRAFFIC_PASS_SECONDS); + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(Math.max(-1, Math.min(1, side)) * TRAFFIC_PASS_PAN, start); + source.connect(filter); + filter.connect(envelope); + envelope.connect(panner); + panner.connect(input); + source.onended = () => { + source.disconnect(); + filter.disconnect(); + envelope.disconnect(); + panner.disconnect(); + }; + source.start(start); + source.stop(start + TRAFFIC_PASS_SECONDS + 0.01); + return true; + } catch { + return false; + } +} diff --git a/src/composables/useHeadlightBeam.ts b/src/composables/useHeadlightBeam.ts index 9ff5455..10fe532 100644 --- a/src/composables/useHeadlightBeam.ts +++ b/src/composables/useHeadlightBeam.ts @@ -1,5 +1,6 @@ import { onMounted, onUnmounted, watch, type Ref } from 'vue'; import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam'; +import { isFlashTarget } from '@/journey/flash-target'; import { readIgnition } from '@/journey/ignition-feed'; import type { QualityTier } from '@/journey/motion-policy'; import type { SectionId } from '@/content/types'; @@ -158,8 +159,9 @@ export function useHeadlightBeam(sources: { kick(); }; const onClick = (event: MouseEvent): void => { - const target = event.target; - if (!allowed() || !(target instanceof Element) || !target.closest('.hero-journey, .gantry-window')) return; + if (!allowed() || event.defaultPrevented) return; + // Anywhere that is not a control: the road is behind the whole page. + if (!isFlashTarget(event.target, window.getSelection()?.toString())) return; if (beam.flash(performance.now())) kick(); }; const onQuery = (): void => { fine = query?.matches ?? false; sync(); }; diff --git a/src/journey/flash-target.ts b/src/journey/flash-target.ts new file mode 100644 index 0000000..995216f --- /dev/null +++ b/src/journey/flash-target.ts @@ -0,0 +1,13 @@ +/** + * Click to flash: a click on the open road (anywhere that is not a control) + * lifts the high beam for a moment, and the traffic answers. The listener + * is passive and never cancels the click, so this only decides whether a + * click also flashes: never from a control, a form field, the dashcam + * frame, or while the reader is selecting text. + */ +export const FLASH_EXCLUDED_SELECTOR = + 'a,button,input,textarea,select,label,summary,[role=button],.dashcam-frame'; + +export function isFlashTarget(target: EventTarget | null, selection: string | null | undefined): boolean { + return !selection && target instanceof Element && !target.closest(FLASH_EXCLUDED_SELECTOR); +} diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 31d166f..4744cc7 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -19,6 +19,7 @@ import { createPostProcessor } from '@/scene/post-processing'; import { installCinematicGrade } from '@/scene/cinematic-grade'; import { createSkyline } from '@/scene/world/skyline'; import { createTraffic } from '@/scene/world/traffic'; +import { createFlashDetector, type TrafficInput } from '@/scene/world/traffic-model'; import { createDriveDynamics } from '@/scene/drive-dynamics'; import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics'; import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition'; @@ -211,6 +212,24 @@ export async function createProceduralSceneGeneration( // NIGHT FREIGHT: a gantry crane works the Additional work container yard. // Low tier and touch: crane, stacks and plates; no floods, lamps or pulse. const yard = createYard(context.resources, maintenanceTier === 'full'); + // NIGHT TRAFFIC: a car-following model (traffic-model.ts). The reader's + // speed, chapter, camera side, high-beam flash and a sweep (navigator jump, + // fling) drive it; one input object is reused every frame. + const trafficInput: TrafficInput = { + dt: 0, + heroSpeed: 0, + chapter: 'deep-night', + sweeping: false, + flash: false, + cameraX: 0, + engineOff: 0, + }; + let trafficRoute: number | null = null; + let trafficSweep = 0; + const trafficFlash = createFlashDetector(); + let trafficKey = ''; + const trafficSwell = context.quality !== 'low' + && (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true; let underpassShade = 0; let underpassKey = ''; let underpassAttr = false; @@ -530,11 +549,61 @@ export async function createProceduralSceneGeneration( const elapsedSeconds = atmosphericTime; horizonAir.update(currentPalette, elapsedSeconds, dawn); skyline.update(currentPalette, elapsedSeconds); - traffic.update( - deltaSeconds, - (18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll, - elapsedSeconds, - ); + // A sweep: the route moving faster than any reading scroll (a navigator + // jump or a fling), or a navigator arrival. No manoeuvre starts in one. + const routeRate = trafficRoute === null || deltaSeconds <= 0 + ? 0 + : Math.abs(snapshot.routeProgress - trafficRoute) / deltaSeconds; + trafficRoute = snapshot.routeProgress; + trafficSweep += (routeRate - trafficSweep) + * (1 - Math.exp(-deltaSeconds / (routeRate > trafficSweep ? 0.08 : 0.4))); + // Flash-to-pass: the rising edge of the high beam's flash (click on the + // road, or the beam's own flash triggers; headlight-beam.ts spaces them). + const flashed = trafficFlash(snapshot.beam?.intensity ?? 0); + trafficInput.dt = deltaSeconds; + trafficInput.heroSpeed = (18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll; + trafficInput.chapter = snapshot.chapter; + trafficInput.sweeping = trafficSweep > 0.12 || snapshot.sign?.navigationArrival === true; + trafficInput.flash = flashed; + trafficInput.cameraX = cameraState.x; + trafficInput.engineOff = engineOff; + const trafficFrame = traffic.update(trafficInput, elapsedSeconds); + const passSide = traffic.passed(); + if (passSide !== 0 && trafficSwell) { + void import('@/audio/traffic-pass').then((pass) => pass.playTrafficPass(passSide), () => undefined); + } + if (TEST_BUILD) { + const events = traffic.model.events(); + let visible = 0; + let centre = 0; + let indicators = 0; + for (let i = 0; i < trafficFrame.count; i += 1) { + if (trafficFrame.active[i] !== 1) continue; + visible += 1; + if (Math.abs(trafficFrame.x[i]!) < 2.7) centre += 1; + if (trafficFrame.indicator[i] !== 0) indicators += 1; + } + const key = `${visible}|${centre}|${indicators}|${events.passes}|${events.laneChanges}|${events.emergencyExits}|${flashed}|${trafficInput.sweeping}`; + if (key !== trafficKey) { + trafficKey = key; + window.dispatchEvent(new CustomEvent('portfolio-traffic-frame', { + detail: { + now, + chapter: snapshot.chapter, + heroSpeed: trafficInput.heroSpeed, + sweeping: trafficInput.sweeping, + flash: flashed, + visible, + centre, + indicators, + passes: events.passes, + laneChanges: events.laneChanges, + brakeOnsets: events.brakeOnsets, + emergencyExits: events.emergencyExits, + }, + })); + } + } sun.update( currentPalette, snapshot.routeProgress, diff --git a/src/scene/world/headlights.ts b/src/scene/world/headlights.ts index 51218a9..0fcac99 100644 --- a/src/scene/world/headlights.ts +++ b/src/scene/world/headlights.ts @@ -56,6 +56,14 @@ export const BEAM_PARS = /* glsl */ ` float reach = 1.0 - smoothstep(${(SPOT_DISTANCE * 0.45).toFixed(1)}, ${SPOT_DISTANCE.toFixed(1)}, d); return uBeamGain * cone * reach * (14.0 / (14.0 + d)); } + // The cone alone, without the spot's 70 m reach: retro-reflectors (traffic + // tail lamps, an under-run bar) answer the beam from hundreds of metres. + float headlightCone(vec3 p) { + if (uBeamGain <= 0.0) return 0.0; + vec3 ray = p - uBeamOrigin; + float cosA = dot(ray / max(length(ray), 0.001), uBeamDir); + return uBeamGain * smoothstep(${Math.cos(SPOT_ANGLE).toFixed(4)}, ${Math.cos(SPOT_ANGLE * (1 - SPOT_PENUMBRA)).toFixed(4)}, cosA); + } `; /** How much open road is on screen: 1 between chapters, 0 under a plate. */ diff --git a/src/scene/world/milestones.ts b/src/scene/world/milestones.ts index 6aa7f5e..e509801 100644 --- a/src/scene/world/milestones.ts +++ b/src/scene/world/milestones.ts @@ -56,7 +56,8 @@ export const MILESTONE_SIDE: Readonly> = { expe const POST_W = 0.4; const POST_H = 6.4; /** The plate hangs on an arm this far over the road (the post stays on the shoulder), - * so it stays in frame on the narrow side and clears traffic (4 m) by 2 m. */ + * so it stays in frame on the narrow side. Its bottom edge (~6.3 m) clears the + * tallest traffic, the 1.4x box trucks (5.67 m, traffic-model.ts), by ~0.6 m. */ const ARM = 4.2; const PLATE_W = 2.2; const PLATE_H = 1.0; diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts index cd7389d..0d210a7 100644 --- a/src/scene/world/road.ts +++ b/src/scene/world/road.ts @@ -137,7 +137,9 @@ export function createRoad( * (1.0 - smoothstep(7.46, 7.62, roadX)); float outerEdge = smoothstep(7.86, 7.98, roadX) * (1.0 - smoothstep(8.06, 8.18, roadX)); - float laneWidth = 1.0 - smoothstep(0.035, 0.07, abs(roadX - 0.17)); + // Lane dividers between the reader's centre lane and the traffic + // lanes (lane centres at 0 and +-5.4): dashes just outside the car. + float laneWidth = 1.0 - smoothstep(0.08, 0.13, abs(roadX - 3.0)); float laneCycle = fract(travelZ / 18.0); float laneDash = smoothstep(0.05, 0.1, laneCycle) * (1.0 - smoothstep(0.48, 0.56, laneCycle)); diff --git a/src/scene/world/traffic-model.ts b/src/scene/world/traffic-model.ts new file mode 100644 index 0000000..04530aa --- /dev/null +++ b/src/scene/world/traffic-model.ts @@ -0,0 +1,1015 @@ +import type { EnvironmentChapter } from '@/journey/types'; + +/** + * Night traffic as a small deterministic car-following simulation (no + * three.js here, so it is unit-testable and allocation free). + * + * A three-lane expressway: the reader drives the CENTRE lane, LEFT is the fast + * lane, RIGHT is freight and slow cars. Every vehicle runs IDM-lite against + * the nearest vehicle ahead whose lateral span (including the rest of a lane + * change) overlaps its own. The hero car is a virtual vehicle in CENTRE that + * nothing can touch: the only lane changes are right-lane cars borrowing the + * centre lane far ahead to pass a slower vehicle, and they leave it long + * before the reader reaches them (time to reach, a hard deadline, a flash). + * + * Coordinates are the hero frame: z grows toward the camera, a vehicle's + * origin is its rear face at road level, its body runs forward to z - length. + */ + +export const TRAFFIC_LANE_LEFT = 0; +export const TRAFFIC_LANE_CENTRE = 1; +export const TRAFFIC_LANE_RIGHT = 2; +/** Lane centres (world x). The hero drives CENTRE. */ +export const TRAFFIC_LANE_X: readonly number[] = Object.freeze([-5.4, 0, 5.4]); +/** Max lateral offset of a vehicle inside its lane (seeded per entry). */ +export const TRAFFIC_LANE_JITTER = 0.1; +/** The asphalt's half width (road.ts). */ +export const TRAFFIC_ASPHALT_HALF_WIDTH = 7.78; + +export const TRAFFIC_CAR = 0; +export const TRAFFIC_TRUCK = 1; +export interface TrafficKindSize { + /** Render scale over the 1x body (traffic.ts). */ + readonly scale: number; + readonly halfWidth: number; + readonly length: number; + readonly roof: number; +} +/** Cars 1.7x, trucks 1.4x the 1x bodies, so they read beside the 2.78x hero. */ +export const TRAFFIC_KINDS: readonly TrafficKindSize[] = Object.freeze([ + Object.freeze({ scale: 1.7, halfWidth: 1.87, length: 8.16, roof: 2.72 }), + Object.freeze({ scale: 1.4, halfWidth: 1.75, length: 21, roof: 5.67 }), +]); + +/** The hero car's envelope in the model (x +-2.6, nose -6, tail 10). */ +export const HERO_HALF_WIDTH = 2.6; +export const HERO_NOSE_Z = -6; +export const HERO_TAIL_Z = 10; + +/** A vehicle's rear beyond this (pulling away) leaves into the haze. */ +export const TRAFFIC_FAR_Z = -660; +/** Slower-than-hero vehicles enter here, inside the far fog. */ +export const TRAFFIC_ENTRY_FAR_Z = -640; +/** + * When a lane's far stretch is empty (after a scroll has streamed everything + * past), a slower vehicle may instead screen-door in at the edge of the haze, + * so the road ahead fills again in seconds rather than the minute a vehicle + * at 8 u/s closing takes from -640. Entry z in [NEAR - SPAN, NEAR]; only when + * nothing of that lane is in the band around it. + */ +export const TRAFFIC_ENTRY_NEAR_Z = -420; +export const TRAFFIC_ENTRY_NEAR_SPAN = 90; +export const TRAFFIC_ENTRY_NEAR_BAND_FAR = -560; +export const TRAFFIC_ENTRY_NEAR_BAND_NEAR = -270; +export const FADE_IN_SECONDS = 0.6; +/** + * A right car entering from the haze may join a truck's queue instead: the + * pair then drifts into the overtake band together, so a pass is on screen + * even when the pool is small (lite) and cars would not catch a truck in + * time on their own. The truck's rear z must lie in this window (the car + * fades in behind it, still in the haze) with no follower within the gap. + */ +export const TRAFFIC_CONVOY_FAR_Z = -600; +export const TRAFFIC_CONVOY_NEAR_Z = -450; +const CONVOY_FOLLOWER_GAP = 150; +const CONVOY_GAP_MIN = 30; +const CONVOY_GAP_SPAN = 20; +/** + * A vehicle whose nose has passed this z has gone beneath the chase camera + * (z 23-26, y >= 5.6) and out of every chapter's frame; it is still simulated + * (state BEHIND) so a car just overtaken can come back past the reader. + */ +export const TRAFFIC_EXIT_NOSE_Z = 20; +/** Behind the camera this far (rear z), a vehicle is forgotten. */ +export const TRAFFIC_BEHIND_Z = 150; +/** Near-fade limits: never (cars, most trucks) or a camera-side truck's band. */ +export const TRAFFIC_NEAR_OPEN = 64; +export const TRAFFIC_NEAR_CAMERA_SIDE = -30; +/** A truck's near limit is decided once, as its rear crosses this z. */ +export const TRAFFIC_LATCH_Z = -60; + +/** IDM-lite (SI units: m, m/s). */ +export const TRAFFIC_IDM = Object.freeze({ + A: 2.2, + B: 3.0, + S0: 6, + T: 1.3, + vMin: 4, + minGap: 2, +}); +/** + * Base desired speeds; each vehicle is +-8 % around its own. The reader cruises + * at 18 + 0.18 * 54 = 27.7 u/s: fast cars pass on the left at ~12 u/s, the + * right lane is overtaken at 8-15 u/s. + */ +export const TRAFFIC_SPEEDS = Object.freeze({ + leftCar: 40, + rightCar: 20, + truck: 13, +}); +/** + * A car borrowing the centre lane to pass drives this much over its own pace: + * about the reader's cruise, so it holds station ahead while the truck drops + * back, and the whole pass stays where it started. + */ +const OVERTAKE_BOOST = 8; +/** ...and this much when the reader closes in before it can pull back in. */ +const SPRINT_BOOST = 12; + +export const LANE_CHANGE_SECONDS = 1.4; +/** + * A yield the reader forces (closing fast, a flash, the deadline) is a swerve: + * the corridor (|x| >= 4.47 of the 5.4 m change, smoothstep 0.71) is clear + * 0.57 s in. With one 0.1 s frame of slack and the worst closing speed + * (120 - 4 u/s) that is 78 + 12 m, so a car that leaves at the deadline is + * clear of the corridor by -10, ahead of the nose at -6. + */ +export const FORCED_LANE_CHANGE_SECONDS = 0.8; +export const ENTRY_LEAD_SECONDS = 0.6; +export const YIELD_LEAD_SECONDS = 0.35; +/** + * A right-lane car enters the centre only this far ahead (rear z): 160 m + * from the nose, where a car is ~15 px tall at 1440x900. It drifts ~40 m + * toward the reader while it accelerates out of the queue, then holds + * station, so the pass and the return happen 110-150 m ahead. + */ +export const CENTRE_ENTRY_Z = -150; +/** ...and not so far that it is lost in the haze. */ +const CENTRE_ENTRY_FAR_Z = -210; +/** Still in the corridor past this z: it must leave now, or dither out. */ +export const CENTRE_DEADLINE_Z = -100; +/** A flash reaches a centre-lane vehicle this far ahead. */ +export const FLASH_REACH_Z = -320; +/** A centre vehicle the reader would reach within this many seconds yields. */ +export const TIME_TO_REACH_SECONDS = 3; +/** Urgent yields skip the indicator lead. */ +const URGENT_SECONDS = 2; +/** A centre car starts looking for its way out this early (time to reach, rear z). */ +const YIELD_SOON_SECONDS = 6; +export const YIELD_SOON_Z = -112; +/** Past this a yield skips its indicator lead and goes at once. */ +const YIELD_NOW_Z = CENTRE_DEADLINE_Z - 6; +/** Headway fraction a forced yield accepts. */ +const FORCED_HEADWAY = 0.35; +export const MANOEUVRE_GAP_SECONDS = 4; +/** Entry only when the reader is near cruise (closing speed below this). */ +const ENTRY_MAX_CLOSING = 12; +/** The slow leader must be within this gap and this much slower than the car wants. */ +const ENTRY_LEADER_GAP = 90; +const ENTRY_LEADER_DELTA = 4; + +/** Indicators: 1.5 Hz, 40 % duty, 80 ms edges (photosensitivity: <= 3 Hz). */ +export const INDICATOR_HZ = 1.5; +export const INDICATOR_DUTY = 0.4; +const INDICATOR_EDGE = 0.08; +/** Brake lamps: hysteresis on the IDM deceleration, minimum on time and spacing. */ +export const BRAKE_ON_ACCEL = -1.2; +export const BRAKE_OFF_ACCEL = -0.4; +export const BRAKE_MIN_ON_SECONDS = 0.5; +export const BRAKE_MIN_SPACING_SECONDS = 0.7; +export const BRAKE_GLOBAL_PER_SECOND = 3; +const BRAKE_RISE_SECONDS = 0.15; +const BRAKE_FALL_SECONDS = 0.3; +export const DITHER_SECONDS = 0.25; +const SPAWN_GAP_SECONDS = 1.5; + +/** + * The rising edge of a flash to pass, from the beam's intensity (0..1): + * fires once as it climbs past FLASH_ON after resting or falling below + * FLASH_OFF. The intro sweep climbs 0 -> 1 without ever falling, so it never + * arms the detector; the descent to the dipped level after it does. + */ +export const FLASH_ON = 0.95; +export const FLASH_OFF = 0.85; +export function createFlashDetector(): (intensity: number) => boolean { + let armed = false; + let previous = Number.NEGATIVE_INFINITY; + return (intensity: number): boolean => { + let fired = false; + if (intensity < FLASH_OFF) { + if (intensity <= previous) armed = true; + } else if (intensity > FLASH_ON && armed) { + armed = false; + fired = true; + } + previous = intensity; + return fired; + }; +} + +/** Frame states. */ +export const TRAFFIC_PENDING = 0; +export const TRAFFIC_VISIBLE = 1; +export const TRAFFIC_DITHERING = 2; +export const TRAFFIC_BEHIND = 3; + +export interface TrafficDensity { + readonly left: number; + readonly right: number; + /** Of the right lane, how many are trucks. */ + readonly trucks: number; +} +/** Visible vehicles each chapter wants per lane. */ +export const TRAFFIC_DENSITY: Readonly> = Object.freeze({ + 'deep-night': Object.freeze({ left: 2, right: 2, trucks: 1 }), + systems: Object.freeze({ left: 3, right: 3, trucks: 1 }), + infrastructure: Object.freeze({ left: 3, right: 5, trucks: 3 }), + builds: Object.freeze({ left: 3, right: 4, trucks: 2 }), + 'pre-dawn': Object.freeze({ left: 2, right: 2, trucks: 0 }), + destination: Object.freeze({ left: 1, right: 1, trucks: 0 }), +}); +/** Low tier: at most 2 / 4, one truck at a time (two in the pool). */ +export const TRAFFIC_LITE_CAP: TrafficDensity = Object.freeze({ + left: 2, + right: 4, + trucks: 1, +}); + +export interface TrafficConfig { + pool: 6 | 10 | 12; + seed: number; + lite: boolean; +} + +export interface TrafficInput { + /** Seconds; clamped to [0, 0.1]. */ + dt: number; + /** The reader's road speed, u/s. */ + heroSpeed: number; + chapter: EnvironmentChapter; + /** A navigator jump or a fling: no manoeuvre starts. */ + sweeping: boolean; + /** One frame: the reader flashed the high beam. */ + flash: boolean; + /** The damped chapter camera x (camera-side trucks keep a near fade). */ + cameraX: number; + /** 0..1, parked at the destination. */ + engineOff: number; +} + +export interface TrafficFrame { + readonly count: number; + /** 0 car, 1 truck (static). */ + readonly kind: Uint8Array; + /** 0 pending, 1 visible, 2 dithering out, 3 behind the camera (not drawn). */ + readonly active: Uint8Array; + readonly x: Float32Array; + readonly z: Float32Array; + readonly yaw: Float32Array; + /** Own road speed, u/s. */ + readonly speed: Float32Array; + /** 0..1, ramped. */ + readonly brake: Float32Array; + /** -1 left, 0 off, 1 right. */ + readonly indicator: Int8Array; + /** 0..1 lamp level of the indicator this frame. */ + readonly blink: Float32Array; + /** Near-fade limit (TRAFFIC_NEAR_OPEN or TRAFFIC_NEAR_CAMERA_SIDE). */ + readonly nearLimit: Float32Array; + /** 0..1 screen-door fade of an emergency exit. */ + readonly dither: Float32Array; +} + +export interface TrafficEvents { + /** Vehicles crossing the hero's middle either way. */ + passes: number; + emergencyExits: number; + brakeOnsets: number; + laneChanges: number; + /** Seconds of model time at the last emergency exit. */ + lastEmergencyAt: number; +} + +export interface TrafficModel { + step(input: TrafficInput): TrafficFrame; + readonly frame: TrafficFrame; + events(): TrafficEvents; + reset(): void; +} + +/** How many trucks a pool carries. */ +export function trafficTruckCount(pool: number): number { + return pool <= 6 ? 2 : 3; +} + +const NO_PLAN = -1; +const HERO = -2; + +const clamp = (value: number, min: number, max: number): number => (value < min ? min : value > max ? max : value); +const smooth = (from: number, to: number, value: number): number => { + const t = clamp((value - from) / (to - from), 0, 1); + return t * t * (3 - 2 * t); +}; + +/** Indicator lamp level `seconds` after it was switched on (on at once). */ +export function indicatorBlink(seconds: number): number { + const period = 1 / INDICATOR_HZ; + const on = period * INDICATOR_DUTY; + const t = seconds - Math.floor(seconds / period) * period; + return smooth(0, INDICATOR_EDGE, t) * (1 - smooth(on, on + INDICATOR_EDGE, t)); +} + +export function createTrafficModel(config: TrafficConfig): TrafficModel { + const n = config.pool; + const trucks = trafficTruckCount(n); + const kind = new Uint8Array(n); + for (let i = 0; i < n; i += 1) kind[i] = i < trucks ? TRAFFIC_TRUCK : TRAFFIC_CAR; + + const state = new Uint8Array(n); + const home = new Uint8Array(n); + const lane = new Uint8Array(n); + const plan = new Int8Array(n); + const indicator = new Int8Array(n); + const brakeOn = new Uint8Array(n); + const overtaking = new Int8Array(n); + const leader = new Int8Array(n); + const order = new Int8Array(n); + const x = new Float64Array(n); + const z = new Float64Array(n); + const prevZ = new Float64Array(n); + const v = new Float64Array(n); + const v0 = new Float64Array(n); + const accel = new Float64Array(n); + const fromX = new Float64Array(n); + const toX = new Float64Array(n); + const laneT = new Float64Array(n); + const laneSeconds = new Float64Array(n); + const fadeIn = new Float64Array(n); + const jitter = new Float64Array(n); + const planAt = new Float64Array(n); + const brakeSince = new Float64Array(n); + const lastOnset = new Float64Array(n); + const brakeLevel = new Float64Array(n); + const indicatorSince = new Float64Array(n); + const near = new Float64Array(n); + const dither = new Float64Array(n); + const onsets = new Float64Array(BRAKE_GLOBAL_PER_SECOND); + const lastSpawn = new Float64Array(3); + + const frame: TrafficFrame = { + count: n, + kind, + active: new Uint8Array(n), + x: new Float32Array(n), + z: new Float32Array(n), + yaw: new Float32Array(n), + speed: new Float32Array(n), + brake: new Float32Array(n), + indicator: new Int8Array(n), + blink: new Float32Array(n), + nearLimit: new Float32Array(n), + dither: new Float32Array(n), + }; + const counters: TrafficEvents = { + passes: 0, + emergencyExits: 0, + brakeOnsets: 0, + laneChanges: 0, + lastEmergencyAt: Number.NEGATIVE_INFINITY, + }; + + let seed = 1; + let time = 0; + let hero = 18; + let cameraX = 0; + let engineOff = 0; + let chapter: EnvironmentChapter = 'deep-night'; + let primed = false; + let centre = -1; + let sprint = -1; + /** The reader flashed the centre car: every later gap check is the forced one. */ + let hurried = false; + let lastManoeuvre = Number.NEGATIVE_INFINITY; + let onsetIndex = 0; + + const random = (): number => { + seed = (seed * 16807) % 2147483647; + return (seed - 1) / 2147483646; + }; + const halfWidth = (i: number): number => TRAFFIC_KINDS[kind[i]!]!.halfWidth; + const length = (i: number): number => TRAFFIC_KINDS[kind[i]!]!.length; + const present = (i: number): boolean => state[i] !== TRAFFIC_PENDING; + const laneOf = (value: number): number => + value < -2.7 ? TRAFFIC_LANE_LEFT : value > 2.7 ? TRAFFIC_LANE_RIGHT : TRAFFIC_LANE_CENTRE; + const inCorridor = (i: number): boolean => Math.abs(x[i]!) < HERO_HALF_WIDTH + halfWidth(i); + const lateral = (i: number): boolean => laneT[i]! < 1; + /** Where a vehicle's lane change (if any) will take it. */ + const targetLane = (i: number): number => (lateral(i) ? laneOf(toX[i]!) : plan[i]! !== NO_PLAN ? plan[i]! : lane[i]!); + + function reset(): void { + seed = (Math.floor(Math.abs(config.seed)) % 2147483646) + 1; + time = 0; + hero = 18; + cameraX = 0; + engineOff = 0; + chapter = 'deep-night'; + primed = false; + centre = -1; + sprint = -1; + hurried = false; + lastManoeuvre = Number.NEGATIVE_INFINITY; + onsetIndex = 0; + onsets.fill(Number.NEGATIVE_INFINITY); + lastSpawn.fill(Number.NEGATIVE_INFINITY); + state.fill(TRAFFIC_PENDING); + plan.fill(NO_PLAN); + indicator.fill(0); + brakeOn.fill(0); + brakeLevel.fill(0); + overtaking.fill(-1); + laneT.fill(1); + laneSeconds.fill(LANE_CHANGE_SECONDS); + fadeIn.fill(0); + dither.fill(0); + near.fill(TRAFFIC_NEAR_OPEN); + lastOnset.fill(Number.NEGATIVE_INFINITY); + for (let i = 0; i < n; i += 1) order[i] = i; + counters.passes = 0; + counters.emergencyExits = 0; + counters.brakeOnsets = 0; + counters.laneChanges = 0; + counters.lastEmergencyAt = Number.NEGATIVE_INFINITY; + writeFrame(); + } + + const cameraSide = (laneIndex: number): boolean => + Math.abs(cameraX) > 1.5 && Math.sign(cameraX) === Math.sign(TRAFFIC_LANE_X[laneIndex]!); + const latch = (i: number): void => { + near[i] = kind[i] === TRAFFIC_TRUCK && cameraSide(laneOf(x[i]!)) ? TRAFFIC_NEAR_CAMERA_SIDE : TRAFFIC_NEAR_OPEN; + }; + + /** Swept lateral span of i: where it is and where its lane change ends. */ + const spanLo = (i: number): number => Math.min(x[i]!, toX[i]!) - halfWidth(i); + const spanHi = (i: number): number => Math.max(x[i]!, toX[i]!) + halfWidth(i); + + /** The nearest vehicle ahead whose swept span overlaps i's (or the hero). */ + function findLeader(i: number): number { + const lo = spanLo(i); + const hi = spanHi(i); + const zi = z[i]!; + let best = -1; + let bestZ = Number.NEGATIVE_INFINITY; + for (let j = 0; j < n; j += 1) { + if (j === i || !present(j)) continue; + const zj = z[j]!; + if (zj >= zi || zj <= bestZ) continue; + if (spanHi(j) <= lo || spanLo(j) >= hi) continue; + best = j; + bestZ = zj; + } + if (HERO_TAIL_Z < zi && HERO_TAIL_Z > bestZ && hi > -HERO_HALF_WIDTH && lo < HERO_HALF_WIDTH) best = HERO; + return best; + } + + /** Room for a body of `len` / `hw` at rear z `zr` and speed `speed`, laterally over [lo, hi]. */ + function clearAt(self: number, lo: number, hi: number, zr: number, len: number, speed: number, headway = 1): boolean { + for (let j = 0; j < n; j += 1) { + if (j === self || !present(j)) continue; + if (spanHi(j) <= lo || spanLo(j) >= hi) continue; + const zj = z[j]!; + if (zj <= zr) { + // j ahead: our nose behind its rear by a safe gap. + if (zr - len - zj < (TRAFFIC_IDM.S0 + speed * TRAFFIC_IDM.T) * headway) return false; + } else if (zj - length(j) - zr < (TRAFFIC_IDM.S0 + v[j]! * TRAFFIC_IDM.T + Math.max(0, v[j]! - speed) * 1.5) * headway) { + // j behind: its nose behind our rear by its safe gap, more if it is closing. + return false; + } + } + return true; + } + + /** Can vehicle i move into `laneIndex` from where it is now? */ + /** + * Can vehicle i move into `laneIndex` from where it is now? A yield the + * reader forces accepts a tighter gap (a real driver ducking in, braking). + */ + function slotFree(i: number, laneIndex: number, forced = false): boolean { + const target = TRAFFIC_LANE_X[laneIndex]!; + const hw = halfWidth(i); + const lo = Math.min(x[i]!, target) - hw - TRAFFIC_LANE_JITTER; + const hi = Math.max(x[i]!, target) + hw + TRAFFIC_LANE_JITTER; + return clearAt(i, lo, hi, z[i]!, length(i), v[i]!, forced ? FORCED_HEADWAY : 1); + } + + /** Is there room to pull back in ahead of the vehicle being passed? */ + function returnRoom(i: number, l: number): boolean { + const front = z[l]! - length(l); + const need = length(i) + 2 * (TRAFFIC_IDM.S0 + v0[i]! * TRAFFIC_IDM.T); + for (let j = 0; j < n; j += 1) { + if (j === i || j === l || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue; + if (z[j]! < z[l]! && front - z[j]! < need) return false; + } + return true; + } + + function startLateral(i: number, laneIndex: number, seconds = LANE_CHANGE_SECONDS): void { + fromX[i] = x[i]!; + toX[i] = TRAFFIC_LANE_X[laneIndex]! + (laneIndex === TRAFFIC_LANE_CENTRE ? 0 : jitter[i]!); + laneT[i] = 0; + laneSeconds[i] = seconds; + plan[i] = NO_PLAN; + const direction = Math.sign(toX[i]! - fromX[i]!); + if (indicator[i] !== direction) { + indicator[i] = direction; + indicatorSince[i] = time; + } + counters.laneChanges += 1; + } + + /** A change already under way finishes at the swerve pace (progress is kept: no jump in x). */ + function hurry(i: number): void { + if (lateral(i) && laneSeconds[i]! > FORCED_LANE_CHANGE_SECONDS) laneSeconds[i] = FORCED_LANE_CHANGE_SECONDS; + } + + function signal(i: number, laneIndex: number, lead: number): void { + plan[i] = laneIndex; + planAt[i] = time + lead; + const direction = Math.sign(TRAFFIC_LANE_X[laneIndex]! - x[i]!); + if (indicator[i] !== direction) { + indicator[i] = direction; + indicatorSince[i] = time; + } + } + + function cancel(i: number): void { + plan[i] = NO_PLAN; + indicator[i] = 0; + overtaking[i] = -1; + if (centre === i) { + centre = -1; + sprint = -1; + hurried = false; + } + } + + function park(i: number): void { + state[i] = TRAFFIC_PENDING; + plan[i] = NO_PLAN; + indicator[i] = 0; + brakeOn[i] = 0; + brakeLevel[i] = 0; + laneT[i] = 1; + dither[i] = 0; + fadeIn[i] = 0; + overtaking[i] = -1; + if (centre === i) { + centre = -1; + sprint = -1; + hurried = false; + } + } + + function place(i: number, laneIndex: number, rear: number, fading = false): void { + const k = kind[i]!; + const base = + k === TRAFFIC_TRUCK + ? TRAFFIC_SPEEDS.truck + : laneIndex === TRAFFIC_LANE_LEFT + ? TRAFFIC_SPEEDS.leftCar + : TRAFFIC_SPEEDS.rightCar; + v0[i] = base * (0.92 + random() * 0.16); + v[i] = v0[i]!; + accel[i] = 0; + jitter[i] = (random() * 2 - 1) * TRAFFIC_LANE_JITTER; + home[i] = laneIndex; + lane[i] = laneIndex; + x[i] = TRAFFIC_LANE_X[laneIndex]! + jitter[i]!; + fromX[i] = x[i]!; + toX[i] = x[i]!; + laneT[i] = 1; + z[i] = rear; + prevZ[i] = rear; + plan[i] = NO_PLAN; + indicator[i] = 0; + brakeOn[i] = 0; + brakeLevel[i] = 0; + lastOnset[i] = Number.NEGATIVE_INFINITY; + overtaking[i] = -1; + dither[i] = 0; + fadeIn[i] = fading ? 1 : 0; + laneSeconds[i] = LANE_CHANGE_SECONDS; + near[i] = TRAFFIC_NEAR_OPEN; + state[i] = TRAFFIC_VISIBLE; + } + + /** + * Rear z (< 0) for a car queued behind an unfollowed right-lane truck in + * the convoy window; 1 while such a truck is still short of the window + * (worth waiting a few seconds for); 0 when there is none. + */ + function convoyRear(len: number): number { + let soon = 0; + for (let t = 0; t < n; t += 1) { + if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue; + const zt = z[t]!; + if (zt >= TRAFFIC_CONVOY_NEAR_Z) continue; + if (zt <= TRAFFIC_CONVOY_FAR_Z) { + soon = 1; + continue; + } + let followed = false; + for (let j = 0; j < n; j += 1) { + if (j === t || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue; + if (z[j]! > zt && z[j]! - length(j) - zt < CONVOY_FOLLOWER_GAP) { + followed = true; + break; + } + } + if (followed) continue; + return zt + len + CONVOY_GAP_MIN + random() * CONVOY_GAP_SPAN; + } + return soon; + } + + /** Is any vehicle of this lane in the band around the near entry? */ + function laneBandEmpty(laneIndex: number): boolean { + for (let j = 0; j < n; j += 1) { + if (!present(j) || laneOf(x[j]!) !== laneIndex) continue; + if (z[j]! > TRAFFIC_ENTRY_NEAR_BAND_FAR && z[j]! < TRAFFIC_ENTRY_NEAR_BAND_NEAR) return false; + } + return true; + } + + function target(): TrafficDensity { + const wanted = TRAFFIC_DENSITY[chapter]; + const parked = chapter === 'destination' && engineOff > 0.5; + const cap = config.lite ? TRAFFIC_LITE_CAP : null; + const left = parked ? 0 : cap ? Math.min(wanted.left, cap.left) : wanted.left; + const right = parked ? 0 : cap ? Math.min(wanted.right, cap.right) : wanted.right; + const truckCount = parked ? 0 : Math.min(right, cap ? Math.min(wanted.trucks, cap.trucks) : wanted.trucks); + scratchDensity.left = left; + scratchDensity.right = right; + scratchDensity.trucks = truckCount; + return scratchDensity; + } + const scratchDensity = { left: 0, right: 0, trucks: 0 }; + + function countVisible(laneIndex: number, k: number): number { + let count = 0; + for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_VISIBLE && home[i] === laneIndex && kind[i] === k) count += 1; + return count; + } + + function pendingOf(k: number): number { + for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_PENDING && kind[i] === k) return i; + return -1; + } + + /** One group (lane, kind) spawns at most one vehicle per call. */ + function spawn(group: number, laneIndex: number, k: number, want: number, initial: boolean): void { + if (countVisible(laneIndex, k) >= want) return; + if (!initial && time - lastSpawn[group]! < SPAWN_GAP_SECONDS) return; + const i = pendingOf(k); + if (i < 0) return; + const len = TRAFFIC_KINDS[k]!.length; + const hw = TRAFFIC_KINDS[k]!.halfWidth; + const base = + k === TRAFFIC_TRUCK + ? TRAFFIC_SPEEDS.truck + : laneIndex === TRAFFIC_LANE_LEFT + ? TRAFFIC_SPEEDS.leftCar + : TRAFFIC_SPEEDS.rightCar; + const lx = TRAFFIC_LANE_X[laneIndex]!; + const lo = lx - hw - TRAFFIC_LANE_JITTER; + const hi = lx + hw + TRAFFIC_LANE_JITTER; + let rear: number; + if (initial) { + // Spread the opening road through the visible span. + const seen = countVisible(laneIndex, k); + const slots = Math.max(1, want); + rear = + -90 - + ((seen + 0.5) / slots) * 500 - + (laneIndex === TRAFFIC_LANE_LEFT ? 40 : 0) - + random() * 30 - + (k === TRAFFIC_TRUCK ? 15 : 0); + } else if (base * 1.08 > hero + 0.5) { + rear = TRAFFIC_EXIT_NOSE_Z + len - 0.01; + } else if (k === TRAFFIC_CAR && laneIndex === TRAFFIC_LANE_RIGHT && (rear = convoyRear(len)) !== 0) { + // A truck is about to reach the window: wait for it (rear > 0)... + if (rear > 0) return; + // ...or join its queue in the haze: a pass waiting to happen. + if (!clearAt(i, lo, hi, rear, len, TRAFFIC_SPEEDS.truck)) return; + place(i, laneIndex, rear, true); + v[i] = TRAFFIC_SPEEDS.truck; + lastSpawn[group] = time; + return; + } else if (laneBandEmpty(laneIndex)) { + // The far stretch is empty: screen-door in at the edge of the haze. + rear = TRAFFIC_ENTRY_NEAR_Z - random() * TRAFFIC_ENTRY_NEAR_SPAN; + if (!clearAt(i, lo, hi, rear, len, base * 1.08)) return; + place(i, laneIndex, rear, true); + lastSpawn[group] = time; + return; + } else { + rear = TRAFFIC_ENTRY_FAR_Z; + } + if (!clearAt(i, lo, hi, rear, len, base * 1.08)) return; + place(i, laneIndex, rear); + if (!initial && rear > TRAFFIC_LATCH_Z) latch(i); + lastSpawn[group] = time; + } + + function spawnAll(initial: boolean): void { + const want = target(); + // Groups: left cars, right trucks, right cars. + const rounds = initial ? 6 : 1; + for (let r = 0; r < rounds; r += 1) { + spawn(0, TRAFFIC_LANE_LEFT, TRAFFIC_CAR, want.left, initial); + spawn(1, TRAFFIC_LANE_RIGHT, TRAFFIC_TRUCK, want.trucks, initial); + spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial); + } + } + + function sortOrder(): void { + // Front (most negative rear z) first; pending vehicles last. Insertion sort, n <= 12. + for (let a = 1; a < n; a += 1) { + const item = order[a]!; + const key = present(item) ? z[item]! : Number.POSITIVE_INFINITY; + let b = a - 1; + while (b >= 0) { + const other = order[b]!; + const otherKey = present(other) ? z[other]! : Number.POSITIVE_INFINITY; + if (otherKey <= key) break; + order[b + 1] = other; + b -= 1; + } + order[b + 1] = item; + } + } + + function desired(i: number): number { + const centreBound = lane[i] === TRAFFIC_LANE_CENTRE || targetLane(i) === TRAFFIC_LANE_CENTRE; + if (!centreBound || overtaking[i]! < 0) return v0[i]!; + // Hurried out by the reader: finish the pass rather than dawdle beside the truck. + return v0[i]! + (sprint === i ? SPRINT_BOOST : OVERTAKE_BOOST); + } + + function accelerations(): void { + const { A, B, S0, T } = TRAFFIC_IDM; + const root = 2 * Math.sqrt(A * B); + for (let i = 0; i < n; i += 1) { + if (!present(i)) continue; + const want = desired(i); + const speed = v[i]!; + // Above its own pace a vehicle coasts (no brake lamps), it does not stamp on the brake. + let a = speed <= want ? A * (1 - (speed / want) ** 4) : -0.8; + const l = findLeader(i); + leader[i] = l; + if (l !== -1) { + const leaderRear = l === HERO ? HERO_TAIL_Z : z[l]!; + const leaderSpeed = l === HERO ? hero : v[l]!; + const gap = Math.max(0.5, z[i]! - length(i) - leaderRear); + const sStar = S0 + Math.max(0, speed * T + (speed * (speed - leaderSpeed)) / root); + a -= A * (sStar / gap) ** 2; + } + accel[i] = clamp(a, -12, A); + } + } + + function overtakeDone(i: number): boolean { + const l = overtaking[i]!; + if (l < 0 || !present(l)) return true; + return z[i]! < z[l]! - length(l); + } + + function manoeuvres(sweeping: boolean, flash: boolean): void { + if (centre >= 0) { + const c = centre; + const closing = hero - v[c]!; + const gap = HERO_NOSE_Z - z[c]!; + const reach = closing > 0.1 ? gap / closing : Number.POSITIVE_INFINITY; + const urgent = reach < TIME_TO_REACH_SECONDS || z[c]! > CENTRE_DEADLINE_Z; + // The reader speeding up, closing in or the deadline near: leave at the first comfortable gap. + // (The reader's pace against the car's own, not its queued speed: a car + // just out from behind a truck is still accelerating.) + const soon = reach < YIELD_SOON_SECONDS || z[c]! > YIELD_SOON_Z || hero - desired(c) >= ENTRY_MAX_CLOSING; + if (flash && z[c]! > FLASH_REACH_Z) hurried = true; + const flashed = hurried; + const leaving = lateral(c) && laneOf(toX[c]!) !== TRAFFIC_LANE_CENTRE; + if (state[c] !== TRAFFIC_VISIBLE || leaving) { + // Dithering out, or already on its way back out: a reader now closing fast turns the rest into a swerve. + if (leaving && (urgent || flashed)) hurry(c); + } else if (plan[c] === TRAFFIC_LANE_CENTRE) { + // Signalling to pull out: a reader closing in (or a flash) calls it off. + if (urgent || flashed || engineOff > 0) { + cancel(c); + } else if (time >= planAt[c]!) { + if (slotFree(c, TRAFFIC_LANE_CENTRE)) { + startLateral(c, TRAFFIC_LANE_CENTRE); + lane[c] = TRAFFIC_LANE_CENTRE; + } else { + cancel(c); + } + } + } else if (plan[c] !== NO_PLAN) { + // Signalled a yield: go when the lead is up (at once if it got urgent). + if (time >= planAt[c]! || reach < URGENT_SECONDS || z[c]! > YIELD_NOW_Z) { + const out = plan[c]!; + const forced = urgent || flashed; + const pace = forced ? FORCED_LANE_CHANGE_SECONDS : LANE_CHANGE_SECONDS; + if (slotFree(c, out, forced)) startLateral(c, out, pace); + else if (out !== TRAFFIC_LANE_LEFT && forced && slotFree(c, TRAFFIC_LANE_LEFT, true)) { + home[c] = TRAFFIC_LANE_LEFT; + startLateral(c, TRAFFIC_LANE_LEFT, pace); + } else if (forced || soon) sprint = c; + } + } else if (urgent || flashed || soon || (!sweeping && overtakeDone(c))) { + // In the centre lane, or moving in (a reversal is allowed): leave. + const forced = urgent || flashed; + const out = slotFree(c, home[c]!, forced) + ? home[c]! + : (forced || soon) && slotFree(c, TRAFFIC_LANE_LEFT, forced) + ? TRAFFIC_LANE_LEFT + : -1; + sprint = out < 0 && (forced || soon) ? c : -1; + if (out >= 0) { + home[c] = out; + if (reach < URGENT_SECONDS || z[c]! > YIELD_NOW_Z || lateral(c)) { + startLateral(c, out, forced ? FORCED_LANE_CHANGE_SECONDS : LANE_CHANGE_SECONDS); + } else signal(c, out, YIELD_LEAD_SECONDS); + } + } + // The emergency exit: still in the corridor at the deadline and not on its way out. + if ( + state[c] === TRAFFIC_VISIBLE && + inCorridor(c) && + z[c]! > CENTRE_DEADLINE_Z && + !(lateral(c) && laneOf(toX[c]!) !== TRAFFIC_LANE_CENTRE) + ) { + state[c] = TRAFFIC_DITHERING; + dither[c] = 0; + counters.emergencyExits += 1; + counters.lastEmergencyAt = time; + } + return; + } + if (sweeping || engineOff > 0 || time - lastManoeuvre < MANOEUVRE_GAP_SECONDS) return; + for (let k = 0; k < n; k += 1) { + const i = order[k]!; + if (state[i] !== TRAFFIC_VISIBLE || kind[i] !== TRAFFIC_CAR || home[i] !== TRAFFIC_LANE_RIGHT) continue; + if (lane[i] !== TRAFFIC_LANE_RIGHT || lateral(i) || plan[i] !== NO_PLAN) continue; + const zi = z[i]!; + if (zi >= CENTRE_ENTRY_Z || zi <= CENTRE_ENTRY_FAR_Z) continue; + const l = leader[i]!; + if (l < 0 || laneOf(x[l]!) !== TRAFFIC_LANE_RIGHT) continue; + if (zi - length(i) - z[l]! > ENTRY_LEADER_GAP || v0[i]! - v[l]! < ENTRY_LEADER_DELTA) continue; + // The reader near cruise (against the car's own pace; it is queued now)... + if (hero - v0[i]! >= ENTRY_MAX_CLOSING) continue; + // ...and not about to reach it once it is up to its passing pace. + const closing = hero - (v0[i]! + OVERTAKE_BOOST); + if (closing > 0.1 && (HERO_NOSE_Z - zi) / closing < 10) continue; + if (!slotFree(i, TRAFFIC_LANE_CENTRE)) continue; + if (!returnRoom(i, l)) continue; + centre = i; + overtaking[i] = l; + lastManoeuvre = time; + signal(i, TRAFFIC_LANE_CENTRE, ENTRY_LEAD_SECONDS); + return; + } + } + + function integrate(dt: number): void { + const { vMin, minGap } = TRAFFIC_IDM; + for (let k = 0; k < n; k += 1) { + const i = order[k]!; + if (!present(i)) continue; + v[i] = Math.max(vMin, v[i]! + accel[i]! * dt); + z[i] = z[i]! + (hero - v[i]!) * dt; + if (lateral(i)) { + laneT[i] = Math.min(1, laneT[i]! + dt / laneSeconds[i]!); + x[i] = fromX[i]! + (toX[i]! - fromX[i]!) * smooth(0, 1, laneT[i]!); + if (laneT[i]! >= 1) { + x[i] = toX[i]!; + lane[i] = laneOf(x[i]!); + indicator[i] = 0; + if (lane[i] !== TRAFFIC_LANE_CENTRE) { + overtaking[i] = -1; + if (centre === i) { + centre = -1; + sprint = -1; + hurried = false; + } + } + } + } + // Never through the vehicle ahead (leaders are already integrated). + const l = findLeader(i); + if (l !== -1) { + const leaderRear = l === HERO ? HERO_TAIL_Z : z[l]!; + const minZ = leaderRear + length(i) + minGap; + if (z[i]! < minZ) { + z[i] = minZ; + v[i] = Math.min(v[i]!, l === HERO ? Math.max(vMin, hero) : v[l]!); + } + } + } + } + + function brakeOnsetAllowed(): boolean { + return time - onsets[onsetIndex]! >= 1; + } + + function lamps(dt: number): void { + for (let i = 0; i < n; i += 1) { + if (!present(i)) continue; + const a = accel[i]!; + if (brakeOn[i] === 0) { + if (a < BRAKE_ON_ACCEL && time - lastOnset[i]! >= BRAKE_MIN_SPACING_SECONDS && brakeOnsetAllowed()) { + brakeOn[i] = 1; + brakeSince[i] = time; + lastOnset[i] = time; + onsets[onsetIndex] = time; + onsetIndex = (onsetIndex + 1) % BRAKE_GLOBAL_PER_SECOND; + counters.brakeOnsets += 1; + } + } else if (a > BRAKE_OFF_ACCEL && time - brakeSince[i]! >= BRAKE_MIN_ON_SECONDS) { + brakeOn[i] = 0; + } + const goal = brakeOn[i]!; + const rate = dt / (goal > brakeLevel[i]! ? BRAKE_RISE_SECONDS : BRAKE_FALL_SECONDS); + brakeLevel[i] = goal > brakeLevel[i]! ? Math.min(goal, brakeLevel[i]! + rate) : Math.max(goal, brakeLevel[i]! - rate); + } + } + + function wrap(dt: number): void { + for (let i = 0; i < n; i += 1) { + if (!present(i)) continue; + const zi = z[i]!; + const nose = zi - length(i); + const mid = zi - length(i) / 2; + const before = prevZ[i]!; + prevZ[i] = zi; + if (state[i] !== TRAFFIC_BEHIND && (before - length(i) / 2 - 2) * (mid - 2) < 0) counters.passes += 1; + // Rear crossed the latch line toward the camera this step. + if (before < TRAFFIC_LATCH_Z && zi >= TRAFFIC_LATCH_Z && state[i] === TRAFFIC_VISIBLE) latch(i); + // Last resort: nothing ever drives through the reader's car. + if (state[i] !== TRAFFIC_BEHIND && inCorridor(i) && nose < HERO_TAIL_Z + 1 && zi > HERO_NOSE_Z - 1) { + counters.emergencyExits += 1; + counters.lastEmergencyAt = time; + park(i); + continue; + } + if (state[i] === TRAFFIC_DITHERING) { + dither[i] = dither[i]! + dt / DITHER_SECONDS; + if (dither[i]! >= 1) park(i); + continue; + } + if (fadeIn[i]! > 0) fadeIn[i] = Math.max(0, fadeIn[i]! - dt / FADE_IN_SECONDS); + if (zi < TRAFFIC_FAR_Z || zi > TRAFFIC_BEHIND_Z) { + park(i); + } else if (nose > TRAFFIC_EXIT_NOSE_Z) { + state[i] = TRAFFIC_BEHIND; + } else if (state[i] === TRAFFIC_BEHIND) { + // Coming back past the reader from beneath the camera. + state[i] = TRAFFIC_VISIBLE; + latch(i); + } + } + } + + function writeFrame(): void { + for (let i = 0; i < n; i += 1) { + frame.active[i] = state[i]!; + frame.x[i] = x[i]!; + frame.z[i] = z[i]!; + frame.speed[i] = v[i]!; + frame.brake[i] = state[i] === TRAFFIC_PENDING ? 0 : brakeLevel[i]!; + frame.indicator[i] = indicator[i]!; + frame.blink[i] = indicator[i] === 0 || state[i] === TRAFFIC_PENDING ? 0 : indicatorBlink(time - indicatorSince[i]!); + frame.nearLimit[i] = near[i]!; + frame.dither[i] = + state[i] === TRAFFIC_DITHERING ? Math.min(1, dither[i]!) : state[i] === TRAFFIC_VISIBLE ? fadeIn[i]! : 0; + frame.yaw[i] = lateral(i) ? -Math.sign(toX[i]! - fromX[i]!) * 0.05 * Math.sin(Math.PI * laneT[i]!) : 0; + } + } + + reset(); + + return { + frame, + step(input: TrafficInput): TrafficFrame { + const dt = Number.isFinite(input.dt) ? clamp(input.dt, 0, 0.1) : 0; + if (Number.isFinite(input.heroSpeed)) hero = clamp(input.heroSpeed, 0, 240); + if (Number.isFinite(input.cameraX)) cameraX = input.cameraX; + if (Number.isFinite(input.engineOff)) engineOff = clamp(input.engineOff, 0, 1); + if (Object.prototype.hasOwnProperty.call(TRAFFIC_DENSITY, input.chapter)) chapter = input.chapter; + time += dt; + if (!primed) { + primed = true; + spawnAll(true); + } + sortOrder(); + accelerations(); + manoeuvres(input.sweeping === true, input.flash === true); + integrate(dt); + lamps(dt); + wrap(dt); + spawnAll(false); + writeFrame(); + return frame; + }, + events: () => counters, + reset, + }; +} diff --git a/src/scene/world/traffic.ts b/src/scene/world/traffic.ts index 79ea255..5efb711 100644 --- a/src/scene/world/traffic.ts +++ b/src/scene/world/traffic.ts @@ -1,100 +1,86 @@ import * as THREE from 'three'; import type { SceneResourceBag } from '@/scene/resources'; import type { RenderQualitySettings } from '@/scene/render-quality'; +import { BEAM_PARS, beamUniforms } from '@/scene/world/headlights'; +import { + TRAFFIC_BEHIND, + TRAFFIC_CAR, + TRAFFIC_KINDS, + TRAFFIC_PENDING, + TRAFFIC_TRUCK, + createTrafficModel, + type TrafficFrame, + type TrafficInput, +} from '@/scene/world/traffic-model'; /** - * Other vehicles on the night shift: dark low-poly cars and box trucks with - * readable rear lamps (a tail-light pair, a high centre brake light on cars, - * amber clearance lamps on trucks), their roofs catching the horizon glow, - * their rear faces our headlights, plus short long-exposure trails and red - * pools on the wet asphalt. Scrolling faster than the traffic overtakes it; - * resting lets the faster lanes pull away. Instances wrap through a hidden - * band behind the camera, so nothing pops in view. + * Other vehicles on the night shift, drawn from the car-following model in + * traffic-model.ts: dark low-poly cars and box trucks at a scale that reads + * beside the 2.78x hero, with live rear lamps (tails that brighten under + * braking, a high-mount stop lamp that only lights then, amber indicators at + * 1.5 Hz, clearance lamps on the trucks), long-exposure trails, red pools + * behind them and warm headlight pools ahead of them on the wet asphalt. + * Their tail lamps and the trucks' under-run bars are retro-reflective: they + * flare when the reader's high beam (pointer aim, click to flash) finds them. * - * Owner feedback (polish round): bodiless lamp pairs read as random red dots - * on the road. Each lane now runs at one speed, so vehicles in a lane never - * drive through each other, and adjacent lanes never overlap. - */ -/** - * The hero car renders at 2.78x scale: its body spans |x| < 2.6 and its nose - * reaches z = -5.5. Traffic never enters that envelope or the near field - * around it. Vehicles fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z - * and wrap inside the hidden band, so nothing ever slides past the car. + * Vehicles pass alongside the hero and leave beneath the chase camera; only + * a truck in the lane under the camera fades before it would fill the lens. */ +/** The hero car's body (2.78x) spans |x| < 2.6. */ export const HERO_CAR_HALF_WIDTH = 2.6; -export const TRAFFIC_NEAR_Z = -24; +/** A vehicle fades over this many metres before its near limit. */ export const TRAFFIC_FADE_LENGTH = 22; -const FAR_Z = -660; -const SPAN = TRAFFIC_NEAR_Z - FAR_Z; -/** Max lateral jitter of a vehicle inside its lane. */ -export const TRAFFIC_LANE_JITTER = 0.1; export type TrafficKind = 'car' | 'truck'; -export interface TrafficLane { - x: number; - /** World units per second; one speed per lane, so no one drives through anyone. */ - speed: number; - kind: TrafficKind; -} -/** Fast cars on the left, slower cars and box trucks on the right. */ -export const TRAFFIC_LANES: readonly TrafficLane[] = [ - { x: -6.5, speed: 46, kind: 'car' }, - { x: 4.0, speed: 16, kind: 'car' }, - { x: -4.0, speed: 33, kind: 'car' }, - { x: 6.6, speed: 11, kind: 'truck' }, -]; -/** Body half widths and lengths (metres, world scale). */ -export const TRAFFIC_BODY = { - car: { halfWidth: 1.1, length: 4.8 }, - truck: { halfWidth: 1.25, length: 15 }, -} as const; - -/** Visibility of a lamp, trail, pool or body at world z (1 = fully lit). */ -export function trafficNearFade(z: number): number { - const t = Math.min(1, Math.max(0, (TRAFFIC_NEAR_Z - z) / TRAFFIC_FADE_LENGTH)); +/** 1 lit, 0 dark: the near fade of a point at world z for a near limit. */ +export function trafficNearFade(z: number, nearLimit: number): number { + const t = Math.min(1, Math.max(0, (nearLimit - z) / TRAFFIC_FADE_LENGTH)); return t * t * (3 - 2 * t); } -/** Keeps a vehicle inside [FAR_Z, TRAFFIC_NEAR_Z], wrapping through the - * hidden band at either end. */ -export function wrapTrafficZ(z: number): number { - if (z > TRAFFIC_NEAR_Z) return z - SPAN * Math.ceil((z - TRAFFIC_NEAR_Z) / SPAN); - if (z < FAR_Z) return z + SPAN * Math.ceil((FAR_Z - z) / SPAN); - return z; -} - -interface Lamp { +export type TrafficLampRole = 'tail' | 'stop' | 'clearance' | 'indicator'; +export interface TrafficLamp { x: number; y: number; z: number; w: number; h: number; - /** 0..0.6 red to red-orange, 2 amber. */ - level: number; + role: TrafficLampRole; + /** -1 left, 1 right (indicators), 0 otherwise. */ + side: number; } -/** Rear lamps in body space (origin at the rear face, ground level). */ -export function trafficLamps(kind: TrafficKind, tint: number): Lamp[] { - const red = tint * 0.6; +/** Rear lamps in 1x body space (origin at the rear face, ground level). */ +export function trafficLamps(kind: TrafficKind): TrafficLamp[] { if (kind === 'car') return [ - { x: -0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red }, - { x: 0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red }, - // Centre high-mounted brake light on the rear window's top edge. - { x: 0, y: 1.55, z: -0.9, w: 0.62, h: 0.12, level: red }, + { x: -0.62, y: 0.92, z: 0.05, w: 0.44, h: 0.3, role: 'tail', side: 0 }, + { x: 0.62, y: 0.92, z: 0.05, w: 0.44, h: 0.3, role: 'tail', side: 0 }, + // High-mounted stop lamp on the rear window's top edge: braking only. + { x: 0, y: 1.55, z: -0.9, w: 0.62, h: 0.12, role: 'stop', side: 0 }, + { x: -0.96, y: 0.92, z: 0.05, w: 0.24, h: 0.26, role: 'indicator', side: -1 }, + { x: 0.96, y: 0.92, z: 0.05, w: 0.24, h: 0.26, role: 'indicator', side: 1 }, ]; return [ - { x: -0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red }, - { x: 0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red }, + { x: -0.72, y: 1.3, z: 0.05, w: 0.42, h: 0.32, role: 'tail', side: 0 }, + { x: 0.72, y: 1.3, z: 0.05, w: 0.42, h: 0.32, role: 'tail', side: 0 }, // Amber clearance lamps at the top corners give the box its outline. - { x: -1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 }, - { x: 1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 }, + { x: -1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, role: 'clearance', side: 0 }, + { x: 1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, role: 'clearance', side: 0 }, + { x: -1.07, y: 1.3, z: 0.05, w: 0.26, h: 0.3, role: 'indicator', side: -1 }, + { x: 1.07, y: 1.3, z: 0.05, w: 0.26, h: 0.3, role: 'indicator', side: 1 }, ]; } -const LAMPS_PER_VEHICLE = 4; +export const LAMPS_PER_VEHICLE = 6; -/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in body space. */ +/** 1x body half widths and lengths; the model's sizes are these times the kind's scale. */ +export const TRAFFIC_BODY = { + car: { halfWidth: 1.1, length: 4.8, roof: 1.6 }, + truck: { halfWidth: 1.25, length: 15, roof: 4.05 }, +} as const; + +/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in 1x body space. */ type Box = readonly [number, number, number, number, number, number]; const CAR_BOXES: readonly Box[] = [ [-1.1, 1.1, 0.34, 1.1, -4.8, 0], // body @@ -130,13 +116,36 @@ function boxGeometry(boxes: readonly Box[]) { return geometry; } -interface Vehicle { - lane: TrafficLane; - x: number; - z: number; - tint: number; +/** Level codes for the additive quads: <1 red, 2 amber, 3 warm headlight. */ +const AMBER = 2; +const WARM = 3; +/** Indicators: brighter and larger than the lamp while lit, so the blink reads at 200 m. */ +export const INDICATOR_GAIN = 2.4; +export const INDICATOR_SWELL = 1.6; +/** Tail lamps swell this much at full high-beam lift (the flash to pass). */ +export const FLARE_SWELL = 0.3; +const FRONT_POOL_LENGTH = 14; +const PASS_Z = 8; +/** Camera-side vehicles dissolve over this z band as they pass beneath the lens. */ +const UNDER_FADE_START = 6; +const UNDER_FADE_END = 22; +const underFade = (z: number): number => { + const t = Math.min(1, Math.max(0, (z - UNDER_FADE_START) / (UNDER_FADE_END - UNDER_FADE_START))); + return 1 - t * t * (3 - 2 * t); +}; +const PASS_RELATIVE_SPEED = 6; + +/** Pool size per quality tier: 12 high, 10 medium, 6 low (simplified). */ +export function trafficPoolSize(quality: Pick): 6 | 10 | 12 { + return quality.postEffects ? (quality.postScale >= 1 ? 12 : 10) : 6; } +/** Is a vehicle at x in the lane beneath a chase camera at cameraX? */ +export function trafficCameraSide(x: number, cameraX: number): boolean { + return Number.isFinite(cameraX) && Math.abs(cameraX) > 1.5 && Math.abs(x) > 2.7 && Math.sign(x) === Math.sign(cameraX); +} +const cameraSide = trafficCameraSide; + export function createTraffic( resources: SceneResourceBag, quality: RenderQualitySettings, @@ -145,45 +154,46 @@ export function createTraffic( ) { const group = new THREE.Group(); group.name = 'expressway-traffic'; - const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7; - const vehicles: Vehicle[] = []; - let seed = 7; + const pool = trafficPoolSize(quality); + const lite = pool === 6; + const model = createTrafficModel({ pool, seed: 7, lite }); + const frame = model.frame; + const kinds: TrafficKind[] = Array.from(frame.kind, (k) => (k === TRAFFIC_TRUCK ? 'truck' : 'car')); + let seed = 11; const random = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; - const lanes = TRAFFIC_LANES.length; - const perLane = Array.from({ length: lanes }, (_, lane) => Math.ceil((count - lane) / lanes)); - for (let i = 0; i < count; i++) { - const laneIndex = i % lanes; - const lane = TRAFFIC_LANES[laneIndex]!; - // Evenly spread through the span with jitter smaller than the gap, so - // vehicles in one lane (one speed) keep their spacing forever. - const slot = Math.floor(i / lanes); - const gap = SPAN / perLane[laneIndex]!; - const jitter = (random() - 0.5) * Math.max(0, gap - TRAFFIC_BODY[lane.kind].length - 30); - vehicles.push({ - lane, - x: lane.x + (random() - 0.5) * 2 * TRAFFIC_LANE_JITTER, - z: wrapTrafficZ(FAR_Z + gap * (slot + 0.5) + laneIndex * 37 + jitter), - tint: random(), - }); - } + const tints = kinds.map(() => random()); + const lampsFor = kinds.map((kind) => trafficLamps(kind)); - const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen } }; - const nearFade = /* glsl */ `smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0))`; + const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen }, ...beamUniforms }; const vertexShader = /* glsl */ ` + ${BEAM_PARS} attribute float aLevel; + attribute vec2 aGain; varying vec2 vUv; varying float vDepth; varying float vLevel; - varying float vNear; + varying float vGain; + varying float vFlare; void main() { vUv = uv; vLevel = aLevel; + vGain = aGain.x; vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); - // Matches trafficNearFade(): dark before the hero car's near field. - vNear = ${nearFade}; + // Retro-reflection: the lamp's centre in the beam's cone, out to 420 m. + vFlare = 0.0; + if (aGain.y > 0.0) { + vec3 centre = (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + float lift = smoothstep(0.78, 1.0, uBeamGain); + // Not up close (a near car's lamps would bloom into a bright patch). + float d = distance(centre, uBeamOrigin); + float reach = smoothstep(18.0, 50.0, d) * (1.0 - smoothstep(180.0, 420.0, d)); + // Brightness stays where it was (the bloom pass spreads anything + // hotter into a white patch); the flash reads by colour and size. + vFlare = aGain.y * headlightCone(centre) * reach * (0.25 + 1.6 * lift); + } vec4 view = viewMatrix * world; vDepth = -view.z; gl_Position = projectionMatrix * view; @@ -192,14 +202,17 @@ export function createTraffic( varying vec2 vUv; varying float vDepth; varying float vLevel; - varying float vNear; + varying float vGain; + varying float vFlare; void main() { float far = 1.0 - smoothstep(420.0, 660.0, vDepth); - float near = vNear; ${shape} vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0)); color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel)); - gl_FragColor = vec4(color * glow * far * near, 1.0); + color = mix(color, vec3(1.0, 0.86, 0.6), step(2.5, vLevel)); + // A reflector answering the beam whitens toward the headlight colour. + color = mix(color, vec3(1.0, 0.72, 0.58), clamp(vFlare, 0.0, 1.0) * 0.7); + gl_FragColor = vec4(color * glow * (vGain + vFlare) * far, 1.0); #include #include }`; @@ -227,41 +240,66 @@ export function createTraffic( const trailMaterial = additive(` float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0)); float glow = across * pow(vUv.y, 1.6) * 0.8;`); - // Wet-road pools: long and soft under each vehicle. + // Wet-road pools: long and soft, brightest at the vehicle. const poolMaterial = additive(` float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0)); float glow = across * pow(vUv.y, 2.2) * 0.2;`); // Bodies: near-black coachwork that reads as a silhouette against the - // horizon, with the fog glow on the roof, our headlights on the rear face, - // a glass rear window, door seams on the trucks and red lamp spill. - const bodyMaterial = (kind: TrafficKind) => resources.own( - 'material', - new THREE.ShaderMaterial({ - uniforms, - vertexShader: /* glsl */ ` + // horizon, with the fog glow on the roof, the reader's headlights on the + // rear face, a glass rear window, door seams on the trucks, red lamp spill + // (stronger under braking) and the screen-door fade of an emergency exit. + const bodyMaterial = (kind: TrafficKind) => + resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader: /* glsl */ ` + attribute vec4 aBody; + attribute float aBlink; varying vec3 vLocal; varying vec3 vNormal; + varying vec3 vWorld; varying float vDepth; varying float vNear; + varying float vDither; + varying float vBrake; + varying float vUnder; + varying float vBlink; void main() { vLocal = position; + vBlink = aBlink; vNormal = normal; vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); - vNear = ${nearFade}; + vWorld = world.xyz; + // Matches trafficNearFade(): only a camera-side truck has a near limit in view. + vNear = smoothstep(0.0, 1.0, clamp((aBody.x - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0)); + vDither = aBody.y; + vBrake = aBody.z; + // Under the chase camera's own lane, a body dissolves as it slides + // beneath the lens instead of filling the frame's bottom edge. + vUnder = aBody.w * smoothstep(${UNDER_FADE_START.toFixed(1)}, ${UNDER_FADE_END.toFixed(1)}, world.z); vec4 view = viewMatrix * world; vDepth = -view.z; gl_Position = projectionMatrix * view; }`, - fragmentShader: /* glsl */ ` + fragmentShader: /* glsl */ ` + ${BEAM_PARS} uniform vec3 uSheen; varying vec3 vLocal; varying vec3 vNormal; + varying vec3 vWorld; varying float vDepth; varying float vNear; + varying float vDither; + varying float vBrake; + varying float vUnder; + varying float vBlink; void main() { float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear; if (alpha < 0.02) discard; + float screen = max(vDither, vUnder); + if (screen > 0.0 && fract(sin(dot(floor(gl_FragCoord.xy), vec2(12.9898, 78.233))) * 43758.5453) < screen) discard; vec3 n = normalize(vNormal); float up = max(0.0, n.y); float rear = max(0.0, n.z); @@ -270,56 +308,95 @@ export function createTraffic( vec3 color = vec3(0.034, 0.02, 0.058); // Roof and shoulders: the horizon glow as a satin sheen. color += uSheen * (up * 0.42 + side * 0.07 * smoothstep(0.5, 1.4, vLocal.y)); - // Our headlights on the rear face, falling off down the road. - float beam = exp(-max(0.0, vDepth - 40.0) / 90.0); - color += vec3(0.19, 0.15, 0.27) * rear * beam * 0.55; - ${kind === 'car' - ? `// Rear window: glass reflects a lavender band of sky. + // The reader's headlights on the rear face: a faint fill down the + // road, the real beam (pointer aim, high-beam flash) up close. + float fill = exp(-max(0.0, vDepth - 40.0) / 90.0) * 0.3; + color += vec3(0.19, 0.15, 0.27) * rear * (fill + headlightBeam(vWorld)); + // A flash to pass paints the whole rear face from 300 m in. + float lift = smoothstep(0.78, 1.0, uBeamGain); + float beamDistance = distance(vWorld, uBeamOrigin); + color += vec3(0.62, 0.5, 0.4) * rear * headlightCone(vWorld) * (1.0 - smoothstep(120.0, 320.0, beamDistance)) * lift * 0.16; + ${ + kind === 'car' + ? `// Rear window: glass reflects a lavender band of sky. float glass = rear * step(1.12, vLocal.y) * step(vLocal.z, -0.9); color = mix(color, vec3(0.05, 0.05, 0.12) + uSheen * 0.22 * smoothstep(1.2, 1.55, vLocal.y), glass); float lampBand = exp(-pow((vLocal.y - 0.92) / 0.18, 2.0));` - : `// Box doors: a centre seam and two lock bars. + : `// Box doors: a centre seam and two lock bars. float seam = 1.0 - smoothstep(0.02, 0.05, abs(vLocal.x)); float bars = 1.0 - smoothstep(0.03, 0.06, abs(abs(vLocal.x) - 0.42)); color *= 1.0 - rear * max(seam * 0.6, bars * 0.35); - float lampBand = exp(-pow((vLocal.y - 1.3) / 0.2, 2.0));`} + float lampBand = exp(-pow((vLocal.y - 1.3) / 0.2, 2.0)); + // The under-run bar is a reflector strip: it answers the beam from far off. + float bar = rear * step(0.42, vLocal.y) * step(vLocal.y, 0.56); + color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * smoothstep(18.0, 50.0, beamDistance) * (1.0 - smoothstep(180.0, 420.0, beamDistance)) * (0.2 + 1.4 * lift);` + } // The tail lamps spill red on the bumper around them. - color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35; + color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35 * (1.0 + vBrake * 1.5); + // ...and a lit indicator floods its corner amber. + color += vec3(1.0, 0.55, 0.08) * rear * abs(vBlink) * smoothstep(0.3, 0.95, vLocal.x * sign(vBlink)) * lampBand * 1.2; color *= 1.0 - wheel * 0.6; gl_FragColor = vec4(color, alpha); #include #include }`, - transparent: true, - depthWrite: true, - depthTest: true, - }), - ); + transparent: true, + depthWrite: true, + depthTest: true, + }), + ); const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1)); - const lampCount = count * LAMPS_PER_VEHICLE; - const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => { + interface Quad { + mesh: InstanceType; + gain: Float32Array; + gainAttribute: InstanceType; + } + const hidden = new THREE.Matrix4().makeScale(0, 0, 0); + const quad = (name: string, material: unknown, instances: number, level: (i: number) => number): Quad => { const geometry = resources.own('geometry', plane.clone()); const levels = new Float32Array(instances); for (let i = 0; i < instances; i++) levels[i] = level(i); geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1)); - const instanced = new THREE.InstancedMesh(geometry, material, instances); - instanced.name = name; - instanced.frustumCulled = false; - instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage); - group.add(instanced); - return instanced; + const gain = new Float32Array(instances * 2); + const gainAttribute = new THREE.InstancedBufferAttribute(gain, 2); + gainAttribute.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('aGain', gainAttribute); + const mesh = new THREE.InstancedMesh(geometry, material, instances); + mesh.name = name; + mesh.frustumCulled = false; + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + for (let i = 0; i < instances; i++) mesh.setMatrixAt(i, hidden); + group.add(mesh); + return { mesh, gain, gainAttribute }; }; - const lampsFor = vehicles.map((v) => trafficLamps(v.lane.kind, v.tint)); - const lamps = quad('traffic-lamps', lampMaterial, lampCount, (i) => lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]?.level ?? 0); + const lampLevel = (vehicle: number, lamp: TrafficLamp | undefined): number => { + if (!lamp) return 0; + return lamp.role === 'clearance' || lamp.role === 'indicator' ? AMBER : tints[vehicle]! * 0.6; + }; + const lamps = quad('traffic-lamps', lampMaterial, pool * LAMPS_PER_VEHICLE, (i) => + lampLevel(Math.floor(i / LAMPS_PER_VEHICLE), lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]), + ); // Two trails per vehicle, from the tail-lamp pair. - const trails = quad('traffic-light-trails', trailMaterial, count * 2, (i) => vehicles[Math.floor(i / 2)]!.tint * 0.6); - // One pool per vehicle, tinted like that vehicle's own lamps. - const pools = quad('traffic-road-pools', poolMaterial, count, (i) => vehicles[i]!.tint * 0.6); + const trails = quad('traffic-light-trails', trailMaterial, pool * 2, (i) => tints[Math.floor(i / 2)]! * 0.6); + // A red pool behind each vehicle, tinted like its own lamps; on high and + // medium a warm headlight pool ahead of it. + const pools = quad('traffic-road-pools', poolMaterial, pool, (i) => tints[i]! * 0.6); + const frontPools = lite ? null : quad('traffic-headlight-pools', poolMaterial, pool, () => WARM); const bodies = (['car', 'truck'] as const).map((kind) => { - const members = vehicles.filter((v) => v.lane.kind === kind); + const code = kind === 'truck' ? TRAFFIC_TRUCK : TRAFFIC_CAR; + const members: number[] = []; + for (let i = 0; i < pool; i++) if (frame.kind[i] === code) members.push(i); const geometry = resources.own('geometry', boxGeometry(kind === 'car' ? CAR_BOXES : TRUCK_BOXES)); + const body = new Float32Array(Math.max(1, members.length) * 4); + const bodyAttribute = new THREE.InstancedBufferAttribute(body, 4); + bodyAttribute.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('aBody', bodyAttribute); + const blink = new Float32Array(Math.max(1, members.length)); + const blinkAttribute = new THREE.InstancedBufferAttribute(blink, 1); + blinkAttribute.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('aBlink', blinkAttribute); const mesh = new THREE.InstancedMesh(geometry, bodyMaterial(kind), Math.max(1, members.length)); mesh.name = `traffic-${kind}-bodies`; mesh.frustumCulled = false; @@ -328,67 +405,174 @@ export function createTraffic( // Bodies draw before the additive lamps so the lamps sit on them. mesh.renderOrder = -1; group.add(mesh); - return { mesh, members }; + return { mesh, members, body, bodyAttribute, blink, blinkAttribute }; }); - const dummy = new THREE.Object3D(); - const hidden = new THREE.Matrix4().makeScale(0, 0, 0); + // The pass swell: a visible vehicle's rear crossing z 8 at |relative| > 6. + const lastZ = new Float32Array(pool).fill(Number.NaN); + let passSide = 0; + const quads = frontPools ? [lamps, trails, pools, frontPools] : [lamps, trails, pools]; + const matrix = new THREE.Matrix4(); + const scale = new THREE.Vector3(); + const place = ( + target: Quad['mesh'], + index: number, + x: number, + y: number, + z: number, + rotateX: number, + yaw: number, + sx: number, + sy: number, + ) => { + if (rotateX !== 0) matrix.makeRotationX(rotateX); + else matrix.makeRotationY(yaw); + matrix.scale(scale.set(sx, sy, 1)); + matrix.setPosition(x, y, z); + target.setMatrixAt(index, matrix); + }; + return { group, + model, /** - * @param travelSpeed world units per second the camera car is covering. + * Steps the model (the input object is the caller's, reused every frame) + * and writes the instances. Returns the model frame. */ - update(deltaSeconds: number, travelSpeed: number, time: number) { + update(input: TrafficInput, time: number): TrafficFrame { uniforms.uTime.value = time; - const dt = Math.min(0.1, Math.max(0, deltaSeconds)); - for (let i = 0; i < count; i++) { - const v = vehicles[i]!; - const relative = travelSpeed - v.lane.speed; // > 0: we are overtaking - v.z = wrapTrafficZ(v.z + relative * dt); - const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12); + model.step(input); + const hero = input.heroSpeed; + // The flash to pass: tail lamps in reach swell with the beam. + const lift = THREE.MathUtils.smoothstep(beamUniforms.uBeamGain.value, 0.78, 1); + passSide = 0; + for (let i = 0; i < pool; i++) { + const state = frame.active[i]!; + const was = lastZ[i]!; + lastZ[i] = state === TRAFFIC_PENDING ? Number.NaN : frame.z[i]!; + if ( + !lite && + state !== TRAFFIC_PENDING && + (was - PASS_Z) * (frame.z[i]! - PASS_Z) < 0 && + Math.abs(hero - frame.speed[i]!) > PASS_RELATIVE_SPEED + ) + passSide = Math.sign(frame.x[i]!); + const lampBase = i * LAMPS_PER_VEHICLE; + if (state === TRAFFIC_PENDING || state === TRAFFIC_BEHIND) { + for (let k = 0; k < LAMPS_PER_VEHICLE; k++) { + lamps.mesh.setMatrixAt(lampBase + k, hidden); + lamps.gain[(lampBase + k) * 2] = 0; + lamps.gain[(lampBase + k) * 2 + 1] = 0; + } + trails.mesh.setMatrixAt(i * 2, hidden); + trails.mesh.setMatrixAt(i * 2 + 1, hidden); + pools.mesh.setMatrixAt(i, hidden); + frontPools?.mesh.setMatrixAt(i, hidden); + continue; + } + const size = TRAFFIC_KINDS[frame.kind[i]!]!; + const s = size.scale; + const x = frame.x[i]!; + const z = frame.z[i]!; + const yaw = frame.yaw[i]!; + const near = frame.nearLimit[i]!; + const visible = 1 - frame.dither[i]!; + const under = cameraSide(x, input.cameraX); + const brake = frame.brake[i]!; + const blink = frame.blink[i]!; + const indicator = frame.indicator[i]!; + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const relative = hero - frame.speed[i]!; // > 0: the reader is overtaking it + const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12) * s * 0.6; // Trails stream away from the direction of relative travel. const trailDirection = relative >= 0 ? -1 : 1; const own = lampsFor[i]!; for (let k = 0; k < LAMPS_PER_VEHICLE; k++) { const lamp = own[k]; + const g = (lampBase + k) * 2; if (lamp === undefined) { - lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, hidden); + lamps.mesh.setMatrixAt(lampBase + k, hidden); + lamps.gain[g] = 0; + lamps.gain[g + 1] = 0; continue; } - dummy.position.set(v.x + lamp.x, lamp.y, v.z + lamp.z); - dummy.rotation.set(0, 0, 0); - dummy.scale.set(lamp.w, lamp.h, 1); - dummy.updateMatrix(); - lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, dummy.matrix); + const lx = x + s * (lamp.x * cos + lamp.z * sin); + const lz = z + s * (-lamp.x * sin + lamp.z * cos); + const fade = trafficNearFade(lz, near) * visible * (under ? underFade(lz) : 1); + let gain = 0; + let flare = 0; + if (lamp.role === 'tail') { + gain = 1 + 1.3 * brake; + flare = 1; + } else if (lamp.role === 'stop') gain = brake; + else if (lamp.role === 'clearance') { + gain = 0.9; + flare = 0.6; + } else gain = indicator === lamp.side ? blink * INDICATOR_GAIN : 0; + lamps.gain[g] = gain * fade; + lamps.gain[g + 1] = flare * fade; + // A lit indicator and a flared tail lamp swell: legible at 200 m. + const swell = + lamp.role === 'indicator' ? 1 + INDICATOR_SWELL * blink : lamp.role === 'tail' ? 1 + FLARE_SWELL * lift : 1; + place(lamps.mesh, lampBase + k, lx, s * lamp.y, lz, 0, yaw, s * lamp.w * swell, s * lamp.h * swell); if (k > 1) continue; // Horizontal ribbon at lamp height, uv.y = 1 at the lamp. - dummy.position.set(v.x + lamp.x, lamp.y - 0.02, v.z + (trailDirection * trailLength) / 2); - dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0); - dummy.scale.set(0.2, trailLength, 1); - dummy.updateMatrix(); - trails.setMatrixAt(i * 2 + k, dummy.matrix); + place( + trails.mesh, + i * 2 + k, + lx, + s * lamp.y - 0.03, + lz + (trailDirection * trailLength) / 2, + trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, + 0, + 0.2 * s, + trailLength, + ); + trails.gain[(i * 2 + k) * 2] = fade; } - const halfWidth = TRAFFIC_BODY[v.lane.kind].halfWidth; + const poolFade = trafficNearFade(z, near) * visible * (under ? underFade(z) : 1); const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08); - dummy.position.set(v.x, 0.04, v.z + poolLength / 2); - dummy.rotation.set(-Math.PI / 2, 0, 0); - dummy.scale.set(halfWidth * 1.7, poolLength, 1); - dummy.updateMatrix(); - pools.setMatrixAt(i, dummy.matrix); + place(pools.mesh, i, x, 0.04, z + poolLength / 2, -Math.PI / 2, 0, size.halfWidth * 1.7, poolLength); + pools.gain[i * 2] = poolFade * (1 + brake * 0.8); + if (frontPools) { + const nose = z - size.length; + place(frontPools.mesh, i, x, 0.045, nose - FRONT_POOL_LENGTH / 2, Math.PI / 2, 0, size.halfWidth * 2, FRONT_POOL_LENGTH); + frontPools.gain[i * 2] = trafficNearFade(nose, near) * visible * 0.55; + } } - for (const { mesh, members } of bodies) { - members.forEach((v, j) => { - dummy.position.set(v.x, 0, v.z); - dummy.rotation.set(0, 0, 0); - dummy.scale.set(1, 1, 1); - dummy.updateMatrix(); - mesh.setMatrixAt(j, dummy.matrix); - }); + for (const { mesh, members, body, bodyAttribute, blink, blinkAttribute } of bodies) { + for (let j = 0; j < members.length; j++) { + const i = members[j]!; + const state = frame.active[i]!; + if (state === TRAFFIC_PENDING || state === TRAFFIC_BEHIND) { + mesh.setMatrixAt(j, hidden); + continue; + } + const s = TRAFFIC_KINDS[frame.kind[i]!]!.scale; + matrix.makeRotationY(frame.yaw[i]!); + matrix.scale(scale.set(s, s, s)); + matrix.setPosition(frame.x[i]!, 0, frame.z[i]!); + mesh.setMatrixAt(j, matrix); + body[j * 4] = frame.nearLimit[i]!; + body[j * 4 + 1] = frame.dither[i]!; + body[j * 4 + 2] = frame.brake[i]!; + body[j * 4 + 3] = cameraSide(frame.x[i]!, input.cameraX) ? 1 : 0; + blink[j] = frame.indicator[i]! * frame.blink[i]! * (1 - frame.dither[i]!); + } mesh.instanceMatrix.needsUpdate = true; + bodyAttribute.needsUpdate = true; + blinkAttribute.needsUpdate = true; } - lamps.instanceMatrix.needsUpdate = true; - trails.instanceMatrix.needsUpdate = true; - pools.instanceMatrix.needsUpdate = true; + for (const q of quads) { + q.mesh.instanceMatrix.needsUpdate = true; + q.gainAttribute.needsUpdate = true; + } + return frame; + }, + /** -1 / 1: a vehicle passed the reader on that side this frame (0: none). */ + passed(): number { + return passSide; }, }; } diff --git a/tests/unit/flash-target.test.ts b/tests/unit/flash-target.test.ts new file mode 100644 index 0000000..c5146a2 --- /dev/null +++ b/tests/unit/flash-target.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { isFlashTarget } from '@/journey/flash-target'; + +function dom(html: string): Document { + document.body.innerHTML = html; + return document; +} + +describe('click to flash: which clicks lift the high beam', () => { + it('flashes from the open page: body, a section, a paragraph', () => { + const d = dom('

road

'); + expect(isFlashTarget(d.body, '')).toBe(true); + expect(isFlashTarget(d.getElementById('m'), null)).toBe(true); + expect(isFlashTarget(d.getElementById('p'), undefined)).toBe(true); + }); + + it('never from a control, its descendants, a form field or the dashcam frame', () => { + const d = dom(` + link + +
go
+ +
more
+
frame
`); + for (const id of ['a', 'in-a', 'b', 'svg', 'rb', 'i', 't', 's', 'o', 'l', 'sum', 'dc']) { + expect(isFlashTarget(d.getElementById(id), ''), id).toBe(false); + } + }); + + it('never while text is selected, and never from a non-element target', () => { + const d = dom('

road

'); + expect(isFlashTarget(d.getElementById('p'), 'road')).toBe(false); + expect(isFlashTarget(null, '')).toBe(false); + expect(isFlashTarget(d, '')).toBe(false); + }); +}); diff --git a/tests/unit/traffic-model.test.ts b/tests/unit/traffic-model.test.ts new file mode 100644 index 0000000..6ccf0db --- /dev/null +++ b/tests/unit/traffic-model.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, it } from 'vitest'; +import type { EnvironmentChapter } from '@/journey/types'; +import { + BRAKE_GLOBAL_PER_SECOND, + BRAKE_MIN_ON_SECONDS, + BRAKE_MIN_SPACING_SECONDS, + CENTRE_DEADLINE_Z, + CENTRE_ENTRY_Z, + FADE_IN_SECONDS, + FORCED_LANE_CHANGE_SECONDS, + HERO_HALF_WIDTH, + HERO_NOSE_Z, + HERO_TAIL_Z, + INDICATOR_DUTY, + INDICATOR_HZ, + TRAFFIC_ASPHALT_HALF_WIDTH, + TRAFFIC_BEHIND, + TRAFFIC_DENSITY, + TRAFFIC_ENTRY_FAR_Z, + TRAFFIC_ENTRY_NEAR_Z, + TRAFFIC_ENTRY_NEAR_SPAN, + TRAFFIC_EXIT_NOSE_Z, + TRAFFIC_KINDS, + TRAFFIC_LANE_X, + TRAFFIC_NEAR_CAMERA_SIDE, + TRAFFIC_NEAR_OPEN, + TRAFFIC_PENDING, + TRAFFIC_TRUCK, + TRAFFIC_VISIBLE, + createFlashDetector, + createTrafficModel, + indicatorBlink, + type TrafficConfig, + type TrafficFrame, + type TrafficInput, +} from '@/scene/world/traffic-model'; + +const CRUISE = 18 + 0.18 * 54; +const CHAPTERS: EnvironmentChapter[] = ['deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn', 'destination']; + +const input = (patch: Partial = {}): TrafficInput => ({ + dt: 1 / 60, + heroSpeed: CRUISE, + chapter: 'deep-night', + sweeping: false, + flash: false, + cameraX: -5.6, + engineOff: 0, + ...patch, +}); + +const drawn = (f: TrafficFrame, i: number): boolean => f.active[i] !== TRAFFIC_PENDING && f.active[i] !== TRAFFIC_BEHIND; +const size = (f: TrafficFrame, i: number) => TRAFFIC_KINDS[f.kind[i]!]!; + +/** Invariants 1-4 and the lamp limits for one frame; returns a message or null. */ +function violation(f: TrafficFrame): string | null { + let corridor = 0; + let indicators = 0; + for (let i = 0; i < f.count; i += 1) { + if (f.active[i] === TRAFFIC_PENDING) continue; + const { halfWidth, length } = size(f, i); + const x = f.x[i]!; + const z = f.z[i]!; + if (!Number.isFinite(x) || !Number.isFinite(z)) return `vehicle ${i} non-finite`; + if (Math.abs(x) + halfWidth > TRAFFIC_ASPHALT_HALF_WIDTH) return `vehicle ${i} off the asphalt at x ${x}`; + const inCorridor = Math.abs(x) < HERO_HALF_WIDTH + halfWidth; + if (inCorridor && z > HERO_NOSE_Z && z - length < HERO_TAIL_Z) return `vehicle ${i} overlaps the hero at x ${x} z ${z}`; + if (inCorridor && f.active[i] !== TRAFFIC_BEHIND) corridor += 1; + if (f.indicator[i] !== 0) indicators += 1; + for (let j = i + 1; j < f.count; j += 1) { + if (f.active[j] === TRAFFIC_PENDING) continue; + const other = size(f, j); + const lateral = Math.abs(x - f.x[j]!) < halfWidth + other.halfWidth; + const along = z > f.z[j]! - other.length && f.z[j]! > z - length; + if (lateral && along) return `vehicles ${i} and ${j} overlap (x ${x}/${f.x[j]} z ${z}/${f.z[j]})`; + } + } + if (corridor > 1) return `${corridor} vehicles in the centre lane`; + if (indicators > 2) return `${indicators} indicators at once`; + return null; +} + +/** A small deterministic generator for the drive scripts. */ +function rng(seed: number) { + let s = seed % 2147483647 || 1; + return () => { + s = (s * 48271) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +describe('traffic model: long randomised drives', () => { + it('never overlaps, stays on the asphalt and keeps the lamp limits (20 seeds x 10 min)', () => { + for (let seed = 1; seed <= 20; seed += 1) { + const pool = ([12, 10, 6] as const)[seed % 3]!; + const model = createTrafficModel({ pool, seed: seed * 7919, lite: pool === 6 }); + const random = rng(seed * 104729); + const step = input(); + let time = 0; + let hero = CRUISE; + let target = CRUISE; + let nextSpeed = 0; + let nextChapter = 20; + let nextFlash = 3; + let sweepUntil = -1; + const onsetsAt: number[] = []; + const lastOnset = new Float64Array(pool).fill(Number.NEGATIVE_INFINITY); + const brakeWas = new Uint8Array(pool); + const brakeOnAt = new Float64Array(pool); + const lastZ = new Float64Array(pool); + let previousOnsets = 0; + const end = 600; + while (time < end) { + const dt = random() < 0.01 ? 0.1 : 1 / 60; + time += dt; + if (time >= nextSpeed) { + const roll = random(); + target = roll < 0.35 ? CRUISE : roll < 0.6 ? 120 : 18 + random() * 102; + if (random() < 0.4) hero = target; // instant steps + nextSpeed = time + 0.3 + random() * 3.7; + } + hero += (target - hero) * Math.min(1, dt * 3); + if (time >= nextChapter) { + step.chapter = CHAPTERS[Math.floor(random() * CHAPTERS.length)]!; + nextChapter = time + 20 + random() * 40; + if (random() < 0.4) sweepUntil = time + 0.5 + random(); + } + step.flash = time >= nextFlash; + if (step.flash) nextFlash = time + 0.6 + random() * 5; + step.sweeping = time < sweepUntil; + step.engineOff = time > end - 30 ? Math.min(1, (time - (end - 30)) / 10) : 0; + if (step.engineOff > 0) step.chapter = 'destination'; + step.heroSpeed = hero * (1 - step.engineOff); + step.cameraX = Math.sin(time * 0.05) * 5.6; + step.dt = dt; + const f = model.step(step); + const problem = violation(f); + if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`); + const events = model.events(); + if (events.brakeOnsets > previousOnsets) { + for (let k = previousOnsets; k < events.brakeOnsets; k += 1) onsetsAt.push(time); + previousOnsets = events.brakeOnsets; + } + for (let i = 0; i < pool; i += 1) { + const on = f.brake[i]! > 0 ? 1 : 0; + const respawned = Math.abs(f.z[i]! - lastZ[i]!) > 20; + lastZ[i] = f.z[i]!; + if (on === 1 && brakeWas[i] === 0 && !respawned) { + expect(time - lastOnset[i]!).toBeGreaterThanOrEqual(BRAKE_MIN_SPACING_SECONDS - 1e-6); + lastOnset[i] = time; + brakeOnAt[i] = time; + } + if (on === 0 && brakeWas[i] === 1 && f.active[i] !== TRAFFIC_PENDING && !respawned) { + // The lamp has ramped down to zero: it was held on for the minimum first. + expect(time - brakeOnAt[i]!).toBeGreaterThanOrEqual(BRAKE_MIN_ON_SECONDS); + } + brakeWas[i] = on; + } + } + for (let k = BRAKE_GLOBAL_PER_SECOND; k < onsetsAt.length; k += 1) { + expect(onsetsAt[k]! - onsetsAt[k - BRAKE_GLOBAL_PER_SECOND]!).toBeGreaterThanOrEqual(1 - 1e-6); + } + const events = model.events(); + expect(events.laneChanges, `seed ${seed} lane changes`).toBeGreaterThan(0); + expect(events.passes, `seed ${seed} passes`).toBeGreaterThan(0); + expect(events.brakeOnsets, `seed ${seed} brake onsets`).toBeGreaterThan(0); + expect(events.emergencyExits, `seed ${seed} emergency exits`).toBeLessThanOrEqual(1); + } + }); +}); + +describe('traffic model: adversarial', () => { + /** Drive the model until a car is in the centre lane, then return it. */ + function withCentreCar(config: TrafficConfig) { + const model = createTrafficModel(config); + const step = input({ chapter: 'infrastructure' }); + for (let t = 0; t < 400; t += 1 / 60) { + const f = model.step(step); + for (let i = 0; i < f.count; i += 1) { + if (drawn(f, i) && Math.abs(f.x[i]!) < 0.5) return { model, step, index: i }; + } + } + throw new Error('no overtake in 400 s'); + } + + it('a right-lane car pulls into the centre to pass a truck at idle', () => { + const { model, index } = withCentreCar({ pool: 12, seed: 3, lite: false }); + expect(model.frame.kind[index]).toBe(0); + expect(model.frame.z[index]!).toBeLessThan(-150); + }); + + it('an instant step to 120 u/s with a car in the centre still never overlaps', () => { + for (const seed of [3, 11, 29, 41]) { + const { model, step, index } = withCentreCar({ pool: 12, seed, lite: false }); + step.heroSpeed = 120; + for (let t = 0; t < 12; t += 1 / 60) { + const f = model.step(step); + expect(violation(f)).toBeNull(); + } + expect(Math.abs(model.frame.x[index]!) > 4 || model.frame.active[index] !== TRAFFIC_VISIBLE).toBe(true); + } + }); + + it('0.1 s frames at 120 u/s keep every invariant', () => { + const model = createTrafficModel({ pool: 12, seed: 5, lite: false }); + const step = input({ chapter: 'infrastructure', dt: 0.1, heroSpeed: 120 }); + for (let k = 0; k < 3000; k += 1) { + step.heroSpeed = k % 40 < 20 ? 120 : CRUISE; + expect(violation(model.step(step))).toBeNull(); + } + }); + + it('clamps dt and ignores non-finite input', () => { + const model = createTrafficModel({ pool: 10, seed: 9, lite: false }); + model.step(input()); + const before = Array.from(model.frame.z); + model.step(input({ dt: Number.NaN, heroSpeed: Number.NaN, cameraX: Number.POSITIVE_INFINITY, engineOff: Number.NaN })); + expect(Array.from(model.frame.z)).toEqual(before); + model.step(input({ dt: 5 })); + for (let i = 0; i < model.frame.count; i += 1) { + if (model.frame.active[i] === TRAFFIC_PENDING) continue; + // At most one 0.1 s step of relative motion (40 u/s fast lane - 27.7 + jitter). + expect(Math.abs(model.frame.z[i]! - before[i]!)).toBeLessThan(4); + } + expect(violation(model.frame)).toBeNull(); + }); +}); + +describe('traffic model: determinism and allocation', () => { + it('the same seed and inputs give the same frames', () => { + const run = () => { + const model = createTrafficModel({ pool: 12, seed: 77, lite: false }); + const out: number[] = []; + for (let k = 0; k < 3600; k += 1) { + const f = model.step(input({ heroSpeed: k % 600 < 300 ? CRUISE : 90, chapter: k < 1800 ? 'systems' : 'infrastructure' })); + if (k % 60 === 0) out.push(...f.z, ...f.x, ...f.active); + } + return out; + }; + expect(run()).toEqual(run()); + }); + + it('returns the same preallocated frame every step', () => { + const model = createTrafficModel({ pool: 6, seed: 2, lite: true }); + const first = model.step(input()); + const z = first.z; + for (let k = 0; k < 600; k += 1) expect(model.step(input())).toBe(first); + expect(first.z).toBe(z); + expect(model.frame).toBe(first); + }); + + it('freezes its tables', () => { + expect(Object.isFrozen(TRAFFIC_LANE_X)).toBe(true); + expect(Object.isFrozen(TRAFFIC_KINDS)).toBe(true); + expect(Object.isFrozen(TRAFFIC_DENSITY)).toBe(true); + expect(Object.isFrozen(TRAFFIC_DENSITY.infrastructure)).toBe(true); + }); + + it('reset replays the run', () => { + const model = createTrafficModel({ pool: 10, seed: 13, lite: false }); + const take = () => { + for (let k = 0; k < 1200; k += 1) model.step(input({ chapter: 'builds' })); + return Array.from(model.frame.z); + }; + const a = take(); + model.reset(); + expect(take()).toEqual(a); + }); +}); + +describe('traffic model: density', () => { + const visible = (f: TrafficFrame, side: number, truck?: boolean): number => { + let count = 0; + for (let i = 0; i < f.count; i += 1) { + if (f.active[i] !== TRAFFIC_VISIBLE) continue; + if (Math.sign(f.x[i]!) !== side) continue; + if (truck !== undefined && (f.kind[i] === TRAFFIC_TRUCK) !== truck) continue; + count += 1; + } + return count; + }; + + it('converges to each chapter target within 30 s at cruise', () => { + for (const chapter of CHAPTERS) { + const model = createTrafficModel({ pool: 12, seed: 21, lite: false }); + let f = model.frame; + for (let t = 0; t < 30; t += 1 / 60) f = model.step(input({ chapter })); + const want = TRAFFIC_DENSITY[chapter]; + // Vehicles mid-pass on the hero's side may sit in the centre, so allow one short. + expect(visible(f, -1), `${chapter} left`).toBeGreaterThanOrEqual(want.left - 1); + expect(visible(f, -1), `${chapter} left`).toBeLessThanOrEqual(want.left + 1); + expect(visible(f, 1) + visible(f, 0), `${chapter} right`).toBeGreaterThanOrEqual(want.right - 1); + } + }); + + it('never despawns a drawn vehicle on screen when the chapter thins out', () => { + const model = createTrafficModel({ pool: 12, seed: 4, lite: false }); + for (let t = 0; t < 30; t += 1 / 60) model.step(input({ chapter: 'infrastructure' })); + for (let t = 0; t < 20; t += 1 / 60) { + const before = Array.from(model.frame.active); + const z = Array.from(model.frame.z); + const f = model.step(input({ chapter: 'destination' })); + for (let i = 0; i < f.count; i += 1) { + if (before[i] === TRAFFIC_VISIBLE && f.active[i] === TRAFFIC_PENDING) { + // Leaving is only allowed at the far haze or behind the camera. + expect(z[i]! < -600 || z[i]! - size(f, i).length > TRAFFIC_EXIT_NOSE_Z - 1).toBe(true); + } + } + } + }); + + it('empties the road once the engine is off', () => { + const model = createTrafficModel({ pool: 12, seed: 8, lite: false }); + for (let t = 0; t < 120; t += 1 / 60) model.step(input({ chapter: 'destination', engineOff: 1, heroSpeed: 0 })); + // Nothing new enters; what is left drives off. + const events = model.events(); + let drawnNow = 0; + for (let i = 0; i < model.frame.count; i += 1) if (drawn(model.frame, i)) drawnNow += 1; + expect(drawnNow).toBeLessThanOrEqual(2); + expect(events.laneChanges).toBe(0); + }); + + it('the low tier caps trucks and lanes', () => { + const model = createTrafficModel({ pool: 6, seed: 6, lite: true }); + let maxTrucks = 0; + let maxRight = 0; + for (let t = 0; t < 300; t += 1 / 60) { + const f = model.step(input({ chapter: 'infrastructure', heroSpeed: t % 40 < 10 ? 90 : CRUISE })); + maxTrucks = Math.max(maxTrucks, visible(f, 1, true) + visible(f, -1, true)); + maxRight = Math.max(maxRight, visible(f, 1)); + } + expect(maxTrucks).toBeLessThanOrEqual(2); + expect(maxRight).toBeLessThanOrEqual(5); + }); +}); + +describe('traffic model: interaction', () => { + it('a flash sends a centre-lane car back to its lane', () => { + for (const seed of [3, 11, 29]) { + const model = createTrafficModel({ pool: 12, seed, lite: false }); + const step = input({ chapter: 'infrastructure' }); + let index = -1; + for (let t = 0; t < 400 && index < 0; t += 1 / 60) { + const f = model.step(step); + for (let i = 0; i < f.count; i += 1) if (drawn(f, i) && Math.abs(f.x[i]!) < 0.5 && f.z[i]! > -320) index = i; + } + if (index < 0) continue; + model.step(input({ chapter: 'infrastructure', flash: true })); + for (let t = 0; t < 2.5; t += 1 / 60) model.step(step); + expect(Math.abs(model.frame.x[index]!), `seed ${seed}`).toBeGreaterThan(4); + return; + } + throw new Error('no centre-lane car within flash reach'); + }); + + it('sweeping starts no manoeuvre', () => { + const model = createTrafficModel({ pool: 12, seed: 3, lite: false }); + for (let t = 0; t < 300; t += 1 / 60) model.step(input({ chapter: 'infrastructure', sweeping: true })); + expect(model.events().laneChanges).toBe(0); + }); + + it('nothing in the centre lane survives past the deadline', () => { + const model = createTrafficModel({ pool: 12, seed: 17, lite: false }); + for (let t = 0; t < 600; t += 1 / 60) { + const f = model.step(input({ chapter: 'infrastructure', heroSpeed: Math.floor(t / 7) % 2 ? 110 : CRUISE })); + for (let i = 0; i < f.count; i += 1) { + if (f.active[i] !== TRAFFIC_VISIBLE) continue; + if (Math.abs(f.x[i]!) < HERO_HALF_WIDTH + size(f, i).halfWidth) expect(f.z[i]!).toBeLessThan(CENTRE_DEADLINE_Z + 160); + } + } + }); +}); + +describe('traffic model: idle choreography', () => { + it('a left-lane car passes the reader within 60 s and a right-lane truck is passed', () => { + const model = createTrafficModel({ pool: 12, seed: 1, lite: false }); + const mid = new Float64Array(12).fill(Number.NaN); + let leftPass = false; + let truckPassed = false; + for (let t = 0; t < 90; t += 1 / 60) { + const f = model.step(input({ chapter: 'deep-night' })); + for (let i = 0; i < f.count; i += 1) { + if (f.active[i] === TRAFFIC_PENDING) { + mid[i] = Number.NaN; + continue; + } + const now = f.z[i]! - size(f, i).length / 2; + const was = mid[i]!; + if (Number.isFinite(was)) { + if (t < 60 && f.x[i]! < -3 && was > 2 && now <= 2) leftPass = true; + if (f.kind[i] === TRAFFIC_TRUCK && f.x[i]! > 3 && was < 2 && now >= 2) truckPassed = true; + } + mid[i] = now; + } + } + expect(leftPass).toBe(true); + expect(truckPassed).toBe(true); + }); +}); + +describe('traffic model: lamps', () => { + it('indicators blink at 1.5 Hz with a 40 % duty', () => { + const period = 1 / INDICATOR_HZ; + let on = 0; + let rises = 0; + let was = 0; + const samples = 6000; + for (let k = 0; k < samples; k += 1) { + const level = indicatorBlink((k / samples) * period * 4); + if (level > 0.5) on += 1; + if (level > 0.5 && was <= 0.5) rises += 1; + was = level; + } + expect(on / samples).toBeCloseTo(INDICATOR_DUTY, 1); + expect(rises).toBe(4); + expect(indicatorBlink(0.02)).toBeGreaterThan(0); + }); + + it('the high-mount stop lamp is dark at steady cruise', () => { + const model = createTrafficModel({ pool: 12, seed: 31, lite: false }); + for (let t = 0; t < 30; t += 1 / 60) model.step(input({ chapter: 'deep-night' })); + let lit = 0; + for (let i = 0; i < model.frame.count; i += 1) if (drawn(model.frame, i) && model.frame.brake[i]! > 0.5) lit += 1; + expect(lit).toBeLessThanOrEqual(1); + }); + + it('a queue behind a truck lights brake lamps, with hysteresis', () => { + const model = createTrafficModel({ pool: 12, seed: 2, lite: false }); + for (let t = 0; t < 120; t += 1 / 60) model.step(input({ chapter: 'infrastructure' })); + expect(model.events().brakeOnsets).toBeGreaterThan(0); + // Brake levels stay in range. + for (let i = 0; i < model.frame.count; i += 1) { + expect(model.frame.brake[i]!).toBeGreaterThanOrEqual(0); + expect(model.frame.brake[i]!).toBeLessThanOrEqual(1); + } + }); +}); + +describe('traffic model: near limit', () => { + it('latches a camera-side truck to the near band and leaves cars open', () => { + const model = createTrafficModel({ pool: 12, seed: 12, lite: false }); + // Camera over the right lane: right-lane trucks are camera side. + let latched = false; + for (let t = 0; t < 200 && !latched; t += 1 / 60) { + const f = model.step(input({ chapter: 'infrastructure', cameraX: 4.5 })); + for (let i = 0; i < f.count; i += 1) { + if (!drawn(f, i)) continue; + if (f.kind[i] !== TRAFFIC_TRUCK) expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_OPEN); + else if (f.x[i]! > 3 && f.z[i]! > -40) { + expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_CAMERA_SIDE); + latched = true; + } + } + } + expect(latched).toBe(true); + }); + + it('a truck on the far side of the camera stays open', () => { + const model = createTrafficModel({ pool: 12, seed: 12, lite: false }); + for (let t = 0; t < 200; t += 1 / 60) { + const f = model.step(input({ chapter: 'infrastructure', cameraX: -5.6 })); + for (let i = 0; i < f.count; i += 1) { + if (drawn(f, i) && f.x[i]! > 3) expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_OPEN); + } + } + }); + + it('keeps every lane on the asphalt and clear of the hero', () => { + for (const side of [0, 2]) { + for (const kind of TRAFFIC_KINDS) { + const x = TRAFFIC_LANE_X[side]!; + expect(Math.abs(x) + kind.halfWidth + 0.1).toBeLessThanOrEqual(TRAFFIC_ASPHALT_HALF_WIDTH); + expect(Math.abs(x) - kind.halfWidth - 0.1).toBeGreaterThanOrEqual(HERO_HALF_WIDTH); + } + } + expect(HERO_TAIL_Z - HERO_NOSE_Z).toBe(16); + }); +}); + +describe('traffic model: the interaction is in frame', () => { + /** Centre-lane entries and exits (rear z) of right-lane cars over a cruise. */ + function passes(config: TrafficConfig, seconds: number) { + const model = createTrafficModel(config); + const step = input({ chapter: 'infrastructure' }); + const prev = new Float64Array(config.pool); + const entries: number[] = []; + const exits: number[] = []; + for (let t = 0; t < seconds; t += 1 / 60) { + const f = model.step(step); + for (let i = 0; i < f.count; i += 1) { + const x = f.active[i] === TRAFFIC_VISIBLE ? f.x[i]! : 9; + if (prev[i]! > 3 && x <= 3) entries.push(f.z[i]!); + if (Math.abs(prev[i]!) <= 3 && Math.abs(x) > 3 && x !== 9) exits.push(f.z[i]!); + prev[i] = x; + } + } + return { entries, exits }; + } + + it('overtakes start 150-215 m ahead and end before the deadline, on every tier', () => { + const configs: TrafficConfig[] = [ + { pool: 12, seed: 3, lite: false }, + { pool: 12, seed: 9, lite: false }, + { pool: 6, seed: 5, lite: true }, + { pool: 6, seed: 8, lite: true }, + ]; + for (const config of configs) { + const { entries, exits } = passes(config, 240); + expect(entries.length, `seed ${config.seed} entries`).toBeGreaterThanOrEqual(2); + for (const z of entries) { + expect(z, `seed ${config.seed} entry`).toBeGreaterThan(-215); + expect(z, `seed ${config.seed} entry`).toBeLessThan(CENTRE_ENTRY_Z + 20); + } + for (const z of exits) expect(z, `seed ${config.seed} exit`).toBeLessThan(CENTRE_DEADLINE_Z + 1); + } + }); + + it('a right car joining a truck in the haze fades in, in the haze, and the road refills from the near entry', () => { + const model = createTrafficModel({ pool: 6, seed: 5, lite: true }); + const step = input({ chapter: 'infrastructure' }); + const seen = new Uint8Array(6); + let faded = 0; + const fading = new Float64Array(6).fill(-1); + for (let t = 0; t < 400; t += 1 / 60) { + const f = model.step(step); + for (let i = 0; i < f.count; i += 1) { + if (f.active[i] !== TRAFFIC_VISIBLE) { + seen[i] = 0; + continue; + } + if (!seen[i]) { + seen[i] = 1; + if (t === 0) continue; + if (f.z[i]! > TRAFFIC_ENTRY_FAR_Z + 1 && f.z[i]! < TRAFFIC_EXIT_NOSE_Z - 1) { + // Nearer than the far entry: screen-doored in, still in the haze. + expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(TRAFFIC_ENTRY_NEAR_Z + 2); + expect(f.z[i]!).toBeGreaterThan(TRAFFIC_ENTRY_NEAR_Z - TRAFFIC_ENTRY_NEAR_SPAN - 50); + expect(f.dither[i]!).toBeGreaterThan(0.9); + fading[i] = t; + faded += 1; + } else expect(f.dither[i]!).toBe(0); + } else if (fading[i]! >= 0) { + if (t - fading[i]! > FADE_IN_SECONDS + 0.05) { + expect(f.dither[i]!).toBe(0); + fading[i] = -1; + } else expect(f.dither[i]!).toBeLessThanOrEqual(1); + } + } + } + expect(faded).toBeGreaterThan(2); + }); + + it('a yield the reader forces is a swerve that clears the corridor in under a second', () => { + for (const seed of [3, 11, 29]) { + const model = createTrafficModel({ pool: 12, seed, lite: false }); + const step = input({ chapter: 'infrastructure' }); + let index = -1; + for (let t = 0; t < 400 && index < 0; t += 1 / 60) { + const f = model.step(step); + for (let i = 0; i < f.count; i += 1) if (drawn(f, i) && Math.abs(f.x[i]!) < 0.3 && f.z[i]! > -200) index = i; + } + if (index < 0) continue; + step.heroSpeed = 120; + let clearAt = -1; + for (let t = 0; t < 3 && clearAt < 0; t += 1 / 60) { + const f = model.step(step); + expect(violation(f)).toBeNull(); + if (Math.abs(f.x[index]!) >= 4.47 || f.active[index] !== TRAFFIC_VISIBLE) clearAt = t; + } + expect(clearAt, `seed ${seed}`).toBeGreaterThanOrEqual(0); + expect(clearAt, `seed ${seed}`).toBeLessThan(FORCED_LANE_CHANGE_SECONDS * 0.71 + 0.25); + expect(model.events().emergencyExits).toBe(0); + return; + } + throw new Error('no centre-lane car to force'); + }); +}); + +describe('flash detector', () => { + it('ignores the intro sweep (a climb from dark) and fires on a flash from the dipped beam', () => { + const detect = createFlashDetector(); + const fired: number[] = []; + // Sweep: 0 -> 1 over 96 frames, held at 1, then the fall to the dipped 0.72. + for (let k = 0; k <= 96; k += 1) if (detect(k / 96)) fired.push(k); + for (let k = 0; k < 5; k += 1) if (detect(1)) fired.push(100 + k); + let i = 1; + for (let k = 0; k < 120; k += 1) { + i = 0.72 + (i - 0.72) * 0.9; + if (detect(i)) fired.push(200 + k); + } + expect(fired).toEqual([]); + // A flash: 0.72 -> 1 (rise), hold, fall. + i = 0.72; + for (let k = 0; k < 30; k += 1) { + i = 1 + (i - 1) * 0.8; + if (detect(i)) fired.push(300 + k); + } + for (let k = 0; k < 25; k += 1) if (detect(1)) fired.push(400 + k); + expect(fired.length).toBe(1); + // Nothing more until it has fallen back below 0.85. + for (let k = 0; k < 20; k += 1) { + i = 0.9 + (i - 0.9) * 0.8; + if (detect(i)) fired.push(500 + k); + } + expect(fired.length).toBe(1); + for (let k = 0; k < 60; k += 1) { + i = 0.72 + (i - 0.72) * 0.8; + if (detect(i)) fired.push(600 + k); + } + for (let k = 0; k < 30; k += 1) { + i = 1 + (i - 1) * 0.8; + if (detect(i)) fired.push(700 + k); + } + expect(fired.length).toBe(2); + }); +}); diff --git a/tests/unit/traffic-pass.test.ts b/tests/unit/traffic-pass.test.ts new file mode 100644 index 0000000..92658f7 --- /dev/null +++ b/tests/unit/traffic-pass.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse'; +import { TRAFFIC_PASS_GAP_SECONDS, TRAFFIC_PASS_PAN, playTrafficPass } from '@/audio/traffic-pass'; + +/** A minimal audio graph that records what the swell builds. */ +function fakeContext() { + const pans: number[] = []; + const param = () => ({ setValueAtTime: (v: number) => pans.push(v), exponentialRampToValueAtTime: () => undefined }); + const node = (extra: object = {}) => ({ connect: () => undefined, disconnect: () => undefined, ...extra }); + const context = { + state: 'running', + currentTime: 10, + sampleRate: 8000, + started: 0, + createGain: () => node({ context, gain: { setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined } }), + createAnalyser: () => { + throw new Error('no analyser'); + }, + createBuffer: (_channels: number, length: number) => ({ sampleRate: 8000, getChannelData: () => new Float32Array(length) }), + createBufferSource: () => + node({ + buffer: null, + onended: null, + start: () => { + context.started += 1; + }, + stop: () => undefined, + }), + createBiquadFilter: () => + node({ + type: '', + Q: { setValueAtTime: () => undefined }, + frequency: { setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined }, + }), + createStereoPanner: () => node({ pan: param() }), + }; + return { context, pans }; +} + +describe('traffic pass swell', () => { + afterEach(() => setMusicPulseAudible(false)); + + it('is silent with no audible soundtrack', () => { + expect(readSwitchboard()).toBeNull(); + expect(playTrafficPass(1)).toBe(false); + }); + + it('plays once per gap, panned to the passing side', () => { + const { context, pans } = fakeContext(); + createPulseTap(context as unknown as BaseAudioContext, { connect: () => undefined } as unknown as AudioNode); + setMusicPulseAudible(true); + expect(playTrafficPass(-1)).toBe(true); + expect(pans).toContain(-TRAFFIC_PASS_PAN); + expect(playTrafficPass(1)).toBe(false); + context.currentTime += TRAFFIC_PASS_GAP_SECONDS + 0.01; + expect(playTrafficPass(1)).toBe(true); + expect(pans).toContain(TRAFFIC_PASS_PAN); + expect(context.started).toBe(2); + }); + + it('never plays on a suspended context', () => { + const { context } = fakeContext(); + context.state = 'suspended'; + createPulseTap(context as unknown as BaseAudioContext, { connect: () => undefined } as unknown as AudioNode); + setMusicPulseAudible(true); + expect(playTrafficPass(1)).toBe(false); + }); + + it('stays out of the initial bundle: only the scene chunk imports it, lazily, never on lite', () => { + const environment = readFileSync(resolve(process.cwd(), 'src/scene/world/environment.ts'), 'utf8'); + expect(environment).toContain("import('@/audio/traffic-pass')"); + expect(environment).not.toMatch(/from '@\/audio\/traffic-pass'/u); + const budgets = readFileSync(resolve(process.cwd(), 'scripts/lib/bundle-budgets.mjs'), 'utf8'); + expect(budgets).toContain('traffic-pass-'); + }); +}); diff --git a/tests/unit/traffic.test.ts b/tests/unit/traffic.test.ts index a12adb0..d6073a8 100644 --- a/tests/unit/traffic.test.ts +++ b/tests/unit/traffic.test.ts @@ -1,72 +1,109 @@ import { describe, expect, it } from 'vitest'; import { HERO_CAR_HALF_WIDTH, + LAMPS_PER_VEHICLE, TRAFFIC_BODY, TRAFFIC_FADE_LENGTH, - TRAFFIC_LANE_JITTER, - TRAFFIC_LANES, - TRAFFIC_NEAR_Z, trafficLamps, trafficNearFade, - wrapTrafficZ, + trafficCameraSide, + trafficPoolSize, } from '@/scene/world/traffic'; +import { + HERO_HALF_WIDTH, + TRAFFIC_ASPHALT_HALF_WIDTH, + TRAFFIC_KINDS, + TRAFFIC_LANE_JITTER, + TRAFFIC_LANE_X, + TRAFFIC_NEAR_CAMERA_SIDE, + TRAFFIC_NEAR_OPEN, +} from '@/scene/world/traffic-model'; + +const kinds = ['car', 'truck'] as const; +const sideLanes = [TRAFFIC_LANE_X[0]!, TRAFFIC_LANE_X[2]!]; describe('expressway traffic stays clear of the hero car', () => { - it('keeps every body outside the hero car, with lane jitter', () => { - for (const lane of TRAFFIC_LANES) { - const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER; - expect(inner).toBeGreaterThan(HERO_CAR_HALF_WIDTH); - } + it('agrees with the model on the hero envelope', () => { + expect(HERO_CAR_HALF_WIDTH).toBe(HERO_HALF_WIDTH); }); - it('never lets bodies in adjacent lanes overlap, and keeps them on the road', () => { - const spans = TRAFFIC_LANES.map((lane) => { - const half = TRAFFIC_BODY[lane.kind].halfWidth + TRAFFIC_LANE_JITTER; - return [lane.x - half, lane.x + half] as const; - }).sort((a, b) => a[0] - b[0]); - for (let i = 1; i < spans.length; i++) expect(spans[i]![0]).toBeGreaterThan(spans[i - 1]![1]); - for (const [left, right] of spans) { - expect(left).toBeGreaterThan(-8); - expect(right).toBeLessThan(8); - } + it('scales the 1x bodies to the model sizes', () => { + kinds.forEach((kind, k) => { + const size = TRAFFIC_KINDS[k]!; + expect(TRAFFIC_BODY[kind].halfWidth * size.scale).toBeCloseTo(size.halfWidth, 1); + expect(TRAFFIC_BODY[kind].roof * size.scale).toBeCloseTo(size.roof, 1); + // The truck's length runs from the under-run bar to the cab's nose. + expect(size.length).toBeGreaterThanOrEqual(TRAFFIC_BODY[kind].length * size.scale - 0.01); + }); }); - it('runs each lane at one speed, so no vehicle drives through another', () => { - const xs = TRAFFIC_LANES.map((lane) => lane.x); - expect(new Set(xs).size).toBe(xs.length); + it('keeps every side-lane body outside the hero car and on the asphalt, with lane jitter', () => { + for (const x of sideLanes) { + for (const size of TRAFFIC_KINDS) { + expect(Math.abs(x) - size.halfWidth - TRAFFIC_LANE_JITTER).toBeGreaterThan(HERO_CAR_HALF_WIDTH); + expect(Math.abs(x) + size.halfWidth + TRAFFIC_LANE_JITTER).toBeLessThan(TRAFFIC_ASPHALT_HALF_WIDTH); + } + } + expect(TRAFFIC_LANE_X[1]).toBe(0); }); it('puts every lamp on the vehicle body', () => { - for (const kind of ['car', 'truck'] as const) { - const lamps = trafficLamps(kind, 0.5); - expect(lamps.length).toBeGreaterThanOrEqual(3); - for (const lamp of lamps) expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01); + for (const kind of kinds) { + const lamps = trafficLamps(kind); + expect(lamps.length).toBeGreaterThanOrEqual(5); + expect(lamps.length).toBeLessThanOrEqual(LAMPS_PER_VEHICLE); + for (const lamp of lamps) { + expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01); + expect(lamp.y + lamp.h / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].roof + 0.01); + } } // A readable pair: two tail lamps mirrored at one height. - const [left, right] = trafficLamps('car', 0); + const [left, right] = trafficLamps('car'); expect(left!.x).toBe(-right!.x); expect(left!.y).toBe(right!.y); }); - it('is fully dark before the car near field and lit beyond the fade', () => { - expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0); - expect(trafficNearFade(0)).toBe(0); - expect(trafficNearFade(40)).toBe(0); - expect(trafficNearFade(TRAFFIC_NEAR_Z - TRAFFIC_FADE_LENGTH)).toBe(1); - expect(trafficNearFade(-300)).toBe(1); - // The car's nose is at about z = -5.5: the near field starts well ahead. - expect(TRAFFIC_NEAR_Z).toBeLessThan(-18); - }); - - it('wraps vehicles inside the hidden band at any scroll speed', () => { - // 110 units/s at a 0.1 s frame clamp is 11 units per step; test far more. - for (const step of [0.5, 11, 64, 900, -11, -900]) { - let z = -200; - for (let i = 0; i < 400; i++) { - z = wrapTrafficZ(z + step); - expect(z).toBeLessThanOrEqual(TRAFFIC_NEAR_Z); - expect(z).toBeGreaterThanOrEqual(-660); + it('gives every vehicle a left and a right indicator beside, not on, its tail lamps', () => { + for (const kind of kinds) { + const lamps = trafficLamps(kind); + const indicators = lamps.filter((lamp) => lamp.role === 'indicator'); + expect(indicators.map((lamp) => lamp.side).sort()).toEqual([-1, 1]); + for (const indicator of indicators) { + expect(Math.sign(indicator.x)).toBe(indicator.side); + for (const tail of lamps.filter((lamp) => lamp.role === 'tail')) { + if (Math.sign(tail.x) !== indicator.side) continue; + const apart = Math.abs(indicator.x - tail.x) - (indicator.w + tail.w) / 2; + expect(apart).toBeGreaterThanOrEqual(-0.005); + } } } + // Only cars carry a high-mount stop lamp; trucks carry clearance lamps. + expect(trafficLamps('car').filter((lamp) => lamp.role === 'stop')).toHaveLength(1); + expect(trafficLamps('truck').filter((lamp) => lamp.role === 'clearance')).toHaveLength(2); + }); + + it('fades only before a near limit', () => { + expect(trafficNearFade(TRAFFIC_NEAR_CAMERA_SIDE, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(0); + expect(trafficNearFade(0, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(0); + expect(trafficNearFade(TRAFFIC_NEAR_CAMERA_SIDE - TRAFFIC_FADE_LENGTH, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(1); + expect(trafficNearFade(-300, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(1); + // Open vehicles stay lit all the way past the hero and under the camera. + expect(trafficNearFade(20, TRAFFIC_NEAR_OPEN)).toBe(1); + }); + + it('dissolves only the lane beneath an off-centre chase camera', () => { + expect(trafficCameraSide(-5.4, -5.6)).toBe(true); + expect(trafficCameraSide(5.4, -5.6)).toBe(false); + expect(trafficCameraSide(5.4, 4.5)).toBe(true); + // A centred camera, or a car in the centre lane, never. + expect(trafficCameraSide(-5.4, 0)).toBe(false); + expect(trafficCameraSide(-1, -5.6)).toBe(false); + expect(trafficCameraSide(-5.4, Number.NaN)).toBe(false); + }); + + it('sizes the pool per tier', () => { + expect(trafficPoolSize({ postEffects: true, postScale: 1 })).toBe(12); + expect(trafficPoolSize({ postEffects: true, postScale: 0.75 })).toBe(10); + expect(trafficPoolSize({ postEffects: false, postScale: 1 })).toBe(6); }); });