feat: interactive traffic with car-following, overtakes and flash-to-pass
This commit is contained in:
@@ -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.
|
- **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.
|
- **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).
|
- 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.
|
||||||
|
|||||||
BIN
docs/overnight/polish/traffic-after.webp
Normal file
BIN
docs/overnight/polish/traffic-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
BIN
docs/overnight/polish/traffic-alongside-pass.webp
Normal file
BIN
docs/overnight/polish/traffic-alongside-pass.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/overnight/polish/traffic-before.webp
Normal file
BIN
docs/overnight/polish/traffic-before.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/overnight/polish/traffic-flash-to-pass.webp
Normal file
BIN
docs/overnight/polish/traffic-flash-to-pass.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/overnight/polish/traffic-overtake-indicator.webp
Normal file
BIN
docs/overnight/polish/traffic-overtake-indicator.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/overnight/polish/traffic-scroll.webp
Normal file
BIN
docs/overnight/polish/traffic-scroll.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
136
docs/overnight/traffic-interactive-spec.md
Normal file
136
docs/overnight/traffic-interactive-spec.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Traffic, interactive: design spec
|
||||||
|
|
||||||
|
Status: design only, nothing built. Branch `redesign/overnight` at 8c9ec69. Owner's ask: "make the traffic more interactive, without significantly crippling performance; it just exists, it clips with the car and has no real interaction."
|
||||||
|
|
||||||
|
## 1. Diagnosis (why it clips, why it is dead)
|
||||||
|
|
||||||
|
Read from `src/scene/world/traffic.ts` and `environment.ts`, checked on hardware GL (1440x900, idle, brisk scroll, reverse; frames in the scratchpad `traffic-look/`).
|
||||||
|
|
||||||
|
1. **It never passes the car; it dissolves.** Vehicles live in z in [-660, -24] and fade to black over [-46, -24] (`TRAFFIC_NEAR_Z`, `TRAFFIC_FADE_LENGTH`), then wrap to the far end. The hero's nose is at z -5.5, the chase camera at z 23-26. So every vehicle that is slower than the reader (all of them during any scroll: travel speed 18-120 u/s vs lanes 11/16/33/46) drives *into* a 20 m black zone in front of the bonnet and vanishes mid-road, and at idle the two fast lanes (33, 46 u/s) *materialise* out of that same zone 20 m ahead of the bonnet and drive off. Both read as the hero car eating or extruding traffic. That is the "clipping".
|
||||||
|
2. **The inner lanes shave the car.** Lanes at x +-4.0 with 1.1 m half-width leave 0.3 m (0.2 m with jitter) to the hero's 2.6 m half-width, and their 1.87 m wide road pools extend under the hero's lane. Side by side they would read as a collision; today the fade hides it, which is the point above.
|
||||||
|
3. **Scale mismatch is the root reason the fade exists.** The hero renders at 2.78x (body 5.2 m wide, 14 m long); traffic is 1x (2.2 x 4.8 m). A 1x car alongside the hero would read as a toy, so it was kept 24 m away and faded. Any design that lets traffic pass the car must fix the scale first.
|
||||||
|
4. **No interaction.** One speed per lane forever, no following, no braking, no lane changes, nothing responds to the reader's speed beyond the relative-speed trail length, nothing to the headlights, the chapter, the pointer or the destination. The centre lamp is always lit (a brake lamp that never brakes). The reader is not in the traffic; the traffic is a conveyor next to them.
|
||||||
|
|
||||||
|
## 2. Design (what it should feel like)
|
||||||
|
|
||||||
|
A three-lane night expressway. The reader drives the **centre lane**. Left lane is the fast lane, right lane is freight and slow cars. Traffic is a small deterministic car-following simulation that treats the hero as a vehicle it cannot touch:
|
||||||
|
|
||||||
|
- **Idle (cruise 18 u/s):** fast cars come up from behind on the left, pass alongside the hero and pull away; trucks and slow cars are overtaken on the right, slowly. Far ahead, a right-lane car stuck behind a truck signals, pulls into *your* lane, passes the truck and signals back in. Brake lamps come on when anyone closes on a slower vehicle (real queues form behind trucks in the freight chapters).
|
||||||
|
- **Scrolling (up to 120 u/s):** everything streams toward you and exits beneath the camera; trails lengthen as today. Anything in your lane sees you coming and **moves over** (indicator, lane change) long before you reach it: fast scroll clears the road ahead. Nothing new pulls into your lane while you are moving fast.
|
||||||
|
- **Stop scrolling:** you drop to cruise in ~1 s; the cars you just passed come back past you on the left over the next seconds. Idle again, someone pulls out ahead.
|
||||||
|
- **Headlights (HIGH BEAM):** the beam the cursor steers lights the rear faces and the reflectors of the vehicles it sweeps across (retro-reflective flare on the tail lamps and the truck's rear bar). **Click = flash to pass**: a vehicle in your lane within 320 m signals and moves over.
|
||||||
|
- **Chapters:** deep-night sparse; Experience moderate; Infrastructure and Builds are night freight (trucks, queues, brake chains); Toolkit pre-dawn sparse; at the destination the engine stops and the night traffic goes on past the parked car, thinning to none as the dawn rises.
|
||||||
|
- **Reverse scroll:** the road never runs backwards (LOG rule), so reverse is a forward drive: same behaviour, no special case. **Nav jumps:** a sweep; no manoeuvre starts while sweeping, in-flight ones finish, vehicles in the centre yield on the normal deadline.
|
||||||
|
|
||||||
|
Cut (gimmicks, or unsafe): honks, oncoming high-beam dips (no oncoming lane), hover labels, hazard flashing, any random strobe. The pointer already steers the beam; lighting a car's reflectors by pointing at it and flashing it out of your way *is* the pointer interaction.
|
||||||
|
|
||||||
|
## 3. Geometry
|
||||||
|
|
||||||
|
- Asphalt is |x| < 7.78 (`road.ts`). Lane centres: LEFT x = -5.4, RIGHT x = +5.4, CENTRE x = 0 (hero; traffic only transiently, far ahead). Road shader: move the single centre dash (|roadX| = 0.17) to two dashes at |roadX| = 3.0 (hero wheel track is 2.5, body edge 2.6: 0.4 m clear).
|
||||||
|
- **Vehicle scale:** cars 1.7x (half-width 1.87, length 8.2, roof 2.7), trucks 1.4x (half 1.75, length 21, roof 5.7). Trucks must stay under 6 m: the mile-marker plates hang 6.3 m over the right lane (`milestones.ts` ARM). Edges: cars 3.53..7.27, trucks 3.65..7.15; hero clearance >= 0.9 m, asphalt clearance >= 0.5 m. Lane jitter +-0.1 stays.
|
||||||
|
- **Hero envelope (model):** x in [-2.6, 2.6], z in [-6, 10] (nose -5.5, tail 3.17 * 2.78 + 0.72). Corridor: a vehicle is "in the corridor" when |x| < 2.6 + its half-width.
|
||||||
|
- **Visible z range:** [-660, +18]. +18 is below every chapter camera's frame (camera z 23-26, y 5.6-9.5; at z 18 a vehicle is 40-60 degrees below the view axis, half-FOV <= 32). Vehicles leave at the bottom edge, never dissolve, never reach the camera. Far end: existing fog fade (420-660 view depth) covers entry.
|
||||||
|
- **Camera-side trucks:** when the chapter camera sits on a lane's side (`sign(cameraX) == laneSide && |cameraX| > 1.5`, e.g. Profile x +4.5, Selected builds +2.5), a 5.7 m truck passing at x 5.4 would fill the frame. Such trucks keep today's near fade (-30, 22 m band). The limit is **latched per vehicle when it crosses z = -60** so a camera move mid-pass cannot pop it. Cars never fade.
|
||||||
|
|
||||||
|
## 4. Pure model (`src/scene/world/traffic-model.ts`, no three.js import, lazy scene chunk)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface TrafficConfig { pool: 6 | 10 | 12; seed: number; lite: boolean }
|
||||||
|
export interface TrafficInput {
|
||||||
|
dt: number; // clamped to [0, 0.1] inside
|
||||||
|
heroSpeed: number; // u/s, (18 + driveSpeed*54 + roadEnergy*48) * (1 - engineOff) * roll
|
||||||
|
chapter: EnvironmentChapter;
|
||||||
|
sweeping: boolean; // milestones' smoothed scroll rate > MILESTONE_FLASH_MAX_RATE, or nav in flight
|
||||||
|
flash: boolean; // rising edge of beam.intensity > 0.95 (one frame)
|
||||||
|
cameraX: number; // damped chapter camera x
|
||||||
|
engineOff: number; // 0..1
|
||||||
|
}
|
||||||
|
export interface TrafficFrame { // preallocated, rewritten in place every step
|
||||||
|
readonly count: number;
|
||||||
|
readonly kind: Uint8Array; // 0 car, 1 truck (static)
|
||||||
|
readonly active: Uint8Array; // 0 pending/hidden, 1 visible, 2 dithering out
|
||||||
|
readonly x: Float32Array; readonly z: Float32Array; readonly yaw: Float32Array;
|
||||||
|
readonly speed: Float32Array; // own road speed, u/s
|
||||||
|
readonly brake: Float32Array; // 0..1, ramped
|
||||||
|
readonly indicator: Int8Array; // -1 left, 0 off, 1 right
|
||||||
|
readonly blink: Float32Array; // 0..1 lamp level for the indicator this frame
|
||||||
|
readonly nearLimit: Float32Array; // +18 or -30 (latched)
|
||||||
|
readonly dither: Float32Array; // 0..1 emergency fade
|
||||||
|
}
|
||||||
|
export interface TrafficModel { step(input: TrafficInput): TrafficFrame; events(): TrafficEvents; reset(): void }
|
||||||
|
export interface TrafficEvents { passes: number; emergencyExits: number; brakeOnsets: number; laneChanges: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
**State per vehicle:** kind, scale, home lane (LEFT/RIGHT), lane (LEFT/CENTRE/RIGHT), x, targetX, laneT (0..1 lateral progress), z, v, v0 (kind base +-8 % seeded), a (last accel), brakeOn, brakeSince, brakeOffSince, indicator, indicatorSince, phase (seeded 0..1), nearLimit, pending flag. Lanes keep a fixed-size index array ordered by z (insertion sort each step, n <= 12). The hero is a virtual vehicle in CENTRE at z in [-6, 10], v = heroSpeed, never stepped.
|
||||||
|
|
||||||
|
**Step order:** (1) sort lanes; (2) accelerations: IDM-lite per vehicle against its lane leader (a vehicle mid-change counts in *both* lanes until laneT > 0.5); (3) manoeuvre decisions (below); (4) integrate v and z in the hero frame: `z += (heroSpeed - v) * dt`; lateral `x = lerp(fromX, targetX, smoothstep(laneT))`, `laneT += dt / 1.4`, `yaw = +-0.05 * sin(pi * laneT)`; (5) lamps: brake hysteresis and ramps, indicator blink `blink = smoothstep(0.0, 0.08, tri(1.5 Hz, phase)) * duty 0.4`; (6) wrap: z > +18 or z < -660 -> pending; pending vehicles re-enter where the chapter wants them and a slot is free; (7) events.
|
||||||
|
|
||||||
|
**IDM-lite:** `a = A * (1 - (v/v0)^4 - (sStar/s)^2)`, `sStar = S0 + v*T + v*dv / (2*sqrt(A*B))`, A 2.2, B 3.0, S0 6, T 1.3; `s` is the bumper gap (leader z + leader length... in body space the origin is the rear face, so gap = leaderZ - (z - ownLength)); no leader: free road. v clamped >= 4 (nothing stops dead on an expressway; the hero is never a leader for the same lane because nothing is behind it in the centre).
|
||||||
|
|
||||||
|
**Centre-lane overtake (the only lane changes):**
|
||||||
|
- Enter CENTRE only if: kind car, home RIGHT, z < -220, the home leader is within 60 m and >= 4 u/s slower, `heroSpeed - v < 12` (reader near cruise), no vehicle in CENTRE, not sweeping, >= 4 s since any manoeuvre started, engineOff == 0. Indicator left for 0.6 s, then the 1.4 s lateral.
|
||||||
|
- Leave CENTRE when any of: overtake complete (ahead of the home leader by S0 + v*T); **time-to-reach** `(z + 6) / max(heroSpeed - v, 0.1) < 3.0 s` with closing > 0; **flash** while z > -320; **deadline** z > -170. Return to RIGHT if its slot is free (gap to the leader >= S0 + v*T, to the follower >= S0 + vf*T at this z), else LEFT if free (the vehicle then adopts LEFT as home), else keep driving and retest each frame.
|
||||||
|
- **Emergency exit:** still in the corridor at z > -170 with no slot: dither out over 0.25 s (screen-door, as the yard and posts use), become pending, re-enter from the far haze. Proof of the no-overlap invariant: leaving the corridor takes 1.2 s of the 1.4 s change (|x| >= 2.6 + 1.87 = 4.47 of 5.4); worst closing is 120 - 4 = 116 u/s plus one 0.1 s frame = 151 m; an exit started at -150 would clear the corridor only at z +1, so the deadline is **-170** (clear by -19, nose at -6). Entry at -220 leaves >= 50 m of centre-lane time even in the worst case; at idle it is minutes.
|
||||||
|
|
||||||
|
**Pending and density:** chapter targets per lane (LEFT/RIGHT): deep-night 2/2, systems 3/3, infrastructure 3/5 (3 trucks), builds 3/4 (2 trucks), pre-dawn 2/2, destination 1/1 and 0/0 once engineOff > 0.5. Pool 12 (high), 10 (medium), 6 (low: 2/4, 1 truck in RIGHT, max 2 trucks ever). Re-entry: from the far end (-640) if the vehicle is slower than the hero, from behind (+18) if faster; only when the gap to the lane neighbour at the entry point is >= S0 + v*T + length. Targets above the pool are clamped; a chapter change never despawns on screen (extra vehicles exit naturally and stay pending).
|
||||||
|
|
||||||
|
**Invariants (always, every step):**
|
||||||
|
1. No vehicle AABB intersects the hero AABB (x +-2.6, z [-6, 10], using the vehicle's live x and its length).
|
||||||
|
2. No two vehicle AABBs intersect (lateral check uses live x, so mid-change vehicles count against both lanes).
|
||||||
|
3. Every visible vehicle has |x| + halfWidth <= 7.78 and z in [-660, 18].
|
||||||
|
4. At most one vehicle in CENTRE; at most two indicators on; indicator frequency exactly 1.5 Hz; a vehicle's brake onsets are >= 0.7 s apart and last >= 0.5 s; global brake onsets <= 3 per second (later ones delay 150 ms).
|
||||||
|
5. Same seed and input sequence -> byte-identical frames (deterministic LCG, no Math.random, no Date).
|
||||||
|
6. `step` allocates nothing after construction (returns the same TrafficFrame object).
|
||||||
|
7. Emergency exits are rare: <= 1 per 10 simulated minutes of adversarial driving (see tests), and never within 2 s of the last.
|
||||||
|
|
||||||
|
## 5. Rendering changes (`traffic.ts`)
|
||||||
|
|
||||||
|
- Bodies: two InstancedMeshes as today, instance matrix = translate(x, 0, z) * rotY(yaw) * scale(kind). Keep `renderOrder -1`.
|
||||||
|
- Lamps: `LAMPS_PER_VEHICLE` 4 -> 6 (tail L/R, CHMSL or clearance L/R, indicator L/R at the rear corners: cars at x +-0.95 y 0.92, trucks at +-1.05 y 1.3, amber). `aLevel` becomes a **dynamic** `InstancedBufferAttribute` (DynamicDrawUsage) written per frame from the model: tail = tint*0.6 * (1 + 1.4*brake); CHMSL = brake only (off at rest; today it is always lit); indicator = 2 + blink (shader: amber * blink); hidden lamps scale 0.
|
||||||
|
- Beam: add `vWorld` and `BEAM_PARS` (`headlights.ts`) to both shaders on high/medium (`uBeamGain` is 0 on low, so one code path): body rear face `+= vec3(0.19,0.15,0.27) * rear * (0.55*fakeBeam + 1.6*headlightBeam(vWorld))`; lamp quads `glow += headlightBeam(vWorld) * 2.0` on the tail pair and the truck under-run bar (retro-reflector flare). The beam uniforms are already shared; no new lights.
|
||||||
|
- Pools: one rear pool per vehicle as today plus, on high/medium, one warm **front pool** (vec3(1.0, 0.86, 0.6), ahead of the nose, length 14) so a car alongside reads as lit. Count = 2 * pool.
|
||||||
|
- Near fade: shader term takes a per-instance `aNear` (+18 or -30) instead of the constant: `smoothstep(0,1, clamp((aNear - world.z) / 22, 0, 1))`; dither: `if (hash(gl_FragCoord.xy) < aDither) discard` on the body (same pattern as the yard).
|
||||||
|
- Trails unchanged (direction from the live relative speed per vehicle).
|
||||||
|
- `environment.ts`: build `TrafficInput` from the existing snapshot/response values; `sweeping` reuses the milestones' smoothed rate (export it or compute the same 80/400 ms filter once); `flash` from `beamLevels(beam)`; `cameraX` from `cameraState.x`.
|
||||||
|
- Audio (P2, lazy `src/audio/traffic-pass.ts`, same contract as `relay-tone.ts`): a 0.5 s band-passed noise swell (gain 0.08, StereoPanner +-0.6 by lane side) when a visible vehicle crosses z = 8 with |heroSpeed - v| > 6; >= 2 s apart; only while the switchboard is audible; not on lite.
|
||||||
|
|
||||||
|
## 6. Tiers, motion, photosensitivity
|
||||||
|
|
||||||
|
- High 12 vehicles, medium 10 (both: beam term, front pools, whoosh); low/phones 6, no front pools, no whoosh, same model (it is 12 IDM evaluations and a 12-element sort per frame). Reduced motion / static: no scene, nothing added.
|
||||||
|
- Indicators 1.5 Hz with 80 ms edges, <= 2 at once, each lamp < 0.05 % of the frame when nearest; brake lamps ramp 150 ms up / 300 ms down with the 0.5 s / 0.7 s rules; CHMSL only under braking; reflector flare follows the beam (continuous, <= 1.67 Hz flash rule already enforced by `headlight-beam.ts`); dither-out 0.25 s at >= 170 m, <= 1 per 2 s. No full-field change: the existing `visual-regression.spec.ts` flash analysis must stay green at idle and brisk scroll.
|
||||||
|
|
||||||
|
## 7. Budgets and cost
|
||||||
|
|
||||||
|
- Initial JS: unchanged (everything is in the lazy scene chunk; no new exports from the initial chunk; `sweeping` must not pull milestone code into the initial chunk).
|
||||||
|
- Scene chunk: model ~2.5 KB gz, shader and lamp changes ~1 KB, road dashes ~0.1 KB, audio ~0.4 KB: target +4 KB, hard cap +8 KB (225 -> <= 233 KB gz).
|
||||||
|
- Per frame: no allocation (frame arrays preallocated, lane index arrays fixed, `dummy` Object3D reused), 12 vehicles x (IDM + 6 lamps + 2 trails + 2 pools) matrix writes ~ 130 `setMatrixAt`, one extra attribute upload (72 floats). Expect rAF p50/p95 unchanged (16.7 / <= 16.8 ms on the review rig).
|
||||||
|
|
||||||
|
## 8. Tests
|
||||||
|
|
||||||
|
`tests/unit/traffic-model.test.ts` (vitest, no three):
|
||||||
|
- **Long randomised drives, no overlap ever:** 20 seeds x 10 simulated minutes at dt 1/60 with a hero-speed script that mixes idle, ramps to 120, instant 18<->120 steps every 0.3-4 s, 0.1 s frames, sweeps, chapter changes every 20-60 s, random flashes (>= 0.6 s apart) and an engine-off tail. Assert invariants 1-4 every step, 7 over the run, and count `laneChanges > 0`, `passes > 0`, `brakeOnsets > 0` per seed.
|
||||||
|
- Adversarial: floor the hero the frame after a centre entry starts, and the frame a vehicle reaches the deadline; assert invariant 1 and that the exit is a lane change, not an emergency, whenever a slot exists.
|
||||||
|
- Determinism (invariant 5), no allocation (same frame object, same typed arrays, `Object.isFrozen` on the lane tables), dt clamp, non-finite inputs ignored.
|
||||||
|
- Density: per chapter the visible count converges to the target within 30 s at idle and never despawns on screen on a change; low pool caps trucks at 2.
|
||||||
|
- Flash to pass: a centre vehicle at z -250 yields within one frame of `flash`, indicator first; a flash with nothing in the centre does nothing.
|
||||||
|
- Sweeping: no manoeuvre starts while `sweeping`; in-flight changes complete.
|
||||||
|
- Idle choreography: with the hero at 18 u/s, a LEFT vehicle crosses the hero envelope from behind to ahead (a pass) within 60 s; a RIGHT truck is passed.
|
||||||
|
- Lamps: indicator blink period 1/1.5 s with the 0.4 duty; brake hysteresis and minimum on-time; CHMSL off at rest.
|
||||||
|
- Near limit latch: camera-side truck gets -30 at z -60 and keeps it after `cameraX` changes sign.
|
||||||
|
|
||||||
|
`tests/unit/traffic.test.ts` (update): lane geometry with the new scales (hero and asphalt clearances, lane/lane gap), lamp placement inside the body incl. the amber pair, 6 lamps per vehicle, `nearFade` with +18 and -30, wrap band [-660, 18], shader source contains `headlightBeam(` and `aDither`, road shader has two dashes at |roadX| 3.0.
|
||||||
|
|
||||||
|
`tests/unit/traffic-pass.test.ts`: plays only when audible, 2 s gap, pan sign by side (mirror `relay-tone.test`).
|
||||||
|
|
||||||
|
## 9. Acceptance (hardware GL, 1440x900 high, plus 1024x700 medium and 390x844 low)
|
||||||
|
|
||||||
|
1. Idle 60 s at Start and at Profile: at least one car passes alongside on the left and leaves at the bottom edge; no vehicle dissolves in front of the bonnet; no vehicle ever overlaps the hero in any frame (sheet at 2 fps).
|
||||||
|
2. Brisk wheel scroll through Infrastructure: a centre-lane vehicle, if present, signals and moves over before 170 m; trails lengthen; frame p95 <= 16.8 ms, 0 frames over 34 ms; no console errors.
|
||||||
|
3. Stop after a brisk scroll: within 8 s a left-lane car re-passes the hero.
|
||||||
|
4. Hover the pointer across a vehicle 60-150 m ahead: its tail lamps and rear face brighten with the beam; click with a car in the centre lane: it indicates and moves over; click with none: nothing.
|
||||||
|
5. Infrastructure at idle: trucks in the right lane with a queue and at least one brake-lamp onset per 20 s on average over 2 min.
|
||||||
|
6. Contact: traffic passes the parked car and thins to none by full day; reverse out of Contact restores it.
|
||||||
|
7. Nav jump Start -> Toolkit: no indicator starts during the glide, no vehicle pops, no emergency exit logged (`portfolio-traffic-frame` test-only event, like the underpass).
|
||||||
|
8. Photosensitivity: `visual-regression.spec.ts` flash analysis passes idle and accelerated; no region blinks above 1.5 Hz by inspection of a 10 fps sheet.
|
||||||
|
9. Budgets: `check:budgets:transfer` violations [], initial JS 115.5 KB gz unchanged, scene chunk <= 233 KB gz. Unit suite green.
|
||||||
|
10. Low tier (390x844): 6 vehicles, no front pools, no whoosh, same no-overlap behaviour; reduced motion unchanged.
|
||||||
@@ -44,6 +44,8 @@ export const classifyArtifact = (path, source = '') => {
|
|||||||
if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
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.
|
// 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';
|
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.
|
// PARKING LIGHTS: the favicon renderer, loaded after mount.
|
||||||
if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
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';
|
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
|
|||||||
66
src/audio/traffic-pass.ts
Normal file
66
src/audio/traffic-pass.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
|
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
|
||||||
import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam';
|
import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam';
|
||||||
|
import { isFlashTarget } from '@/journey/flash-target';
|
||||||
import { readIgnition } from '@/journey/ignition-feed';
|
import { readIgnition } from '@/journey/ignition-feed';
|
||||||
import type { QualityTier } from '@/journey/motion-policy';
|
import type { QualityTier } from '@/journey/motion-policy';
|
||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
@@ -158,8 +159,9 @@ export function useHeadlightBeam(sources: {
|
|||||||
kick();
|
kick();
|
||||||
};
|
};
|
||||||
const onClick = (event: MouseEvent): void => {
|
const onClick = (event: MouseEvent): void => {
|
||||||
const target = event.target;
|
if (!allowed() || event.defaultPrevented) return;
|
||||||
if (!allowed() || !(target instanceof Element) || !target.closest('.hero-journey, .gantry-window')) 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();
|
if (beam.flash(performance.now())) kick();
|
||||||
};
|
};
|
||||||
const onQuery = (): void => { fine = query?.matches ?? false; sync(); };
|
const onQuery = (): void => { fine = query?.matches ?? false; sync(); };
|
||||||
|
|||||||
13
src/journey/flash-target.ts
Normal file
13
src/journey/flash-target.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { createPostProcessor } from '@/scene/post-processing';
|
|||||||
import { installCinematicGrade } from '@/scene/cinematic-grade';
|
import { installCinematicGrade } from '@/scene/cinematic-grade';
|
||||||
import { createSkyline } from '@/scene/world/skyline';
|
import { createSkyline } from '@/scene/world/skyline';
|
||||||
import { createTraffic } from '@/scene/world/traffic';
|
import { createTraffic } from '@/scene/world/traffic';
|
||||||
|
import { createFlashDetector, type TrafficInput } from '@/scene/world/traffic-model';
|
||||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||||
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
||||||
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
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.
|
// NIGHT FREIGHT: a gantry crane works the Additional work container yard.
|
||||||
// Low tier and touch: crane, stacks and plates; no floods, lamps or pulse.
|
// Low tier and touch: crane, stacks and plates; no floods, lamps or pulse.
|
||||||
const yard = createYard(context.resources, maintenanceTier === 'full');
|
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 underpassShade = 0;
|
||||||
let underpassKey = '';
|
let underpassKey = '';
|
||||||
let underpassAttr = false;
|
let underpassAttr = false;
|
||||||
@@ -530,11 +549,61 @@ export async function createProceduralSceneGeneration(
|
|||||||
const elapsedSeconds = atmosphericTime;
|
const elapsedSeconds = atmosphericTime;
|
||||||
horizonAir.update(currentPalette, elapsedSeconds, dawn);
|
horizonAir.update(currentPalette, elapsedSeconds, dawn);
|
||||||
skyline.update(currentPalette, elapsedSeconds);
|
skyline.update(currentPalette, elapsedSeconds);
|
||||||
traffic.update(
|
// A sweep: the route moving faster than any reading scroll (a navigator
|
||||||
deltaSeconds,
|
// jump or a fling), or a navigator arrival. No manoeuvre starts in one.
|
||||||
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll,
|
const routeRate = trafficRoute === null || deltaSeconds <= 0
|
||||||
elapsedSeconds,
|
? 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(
|
sun.update(
|
||||||
currentPalette,
|
currentPalette,
|
||||||
snapshot.routeProgress,
|
snapshot.routeProgress,
|
||||||
|
|||||||
@@ -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);
|
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));
|
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. */
|
/** How much open road is on screen: 1 between chapters, 0 under a plate. */
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { expe
|
|||||||
const POST_W = 0.4;
|
const POST_W = 0.4;
|
||||||
const POST_H = 6.4;
|
const POST_H = 6.4;
|
||||||
/** The plate hangs on an arm this far over the road (the post stays on the shoulder),
|
/** 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 ARM = 4.2;
|
||||||
const PLATE_W = 2.2;
|
const PLATE_W = 2.2;
|
||||||
const PLATE_H = 1.0;
|
const PLATE_H = 1.0;
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ export function createRoad(
|
|||||||
* (1.0 - smoothstep(7.46, 7.62, roadX));
|
* (1.0 - smoothstep(7.46, 7.62, roadX));
|
||||||
float outerEdge = smoothstep(7.86, 7.98, roadX)
|
float outerEdge = smoothstep(7.86, 7.98, roadX)
|
||||||
* (1.0 - smoothstep(8.06, 8.18, 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 laneCycle = fract(travelZ / 18.0);
|
||||||
float laneDash = smoothstep(0.05, 0.1, laneCycle)
|
float laneDash = smoothstep(0.05, 0.1, laneCycle)
|
||||||
* (1.0 - smoothstep(0.48, 0.56, laneCycle));
|
* (1.0 - smoothstep(0.48, 0.56, laneCycle));
|
||||||
|
|||||||
1015
src/scene/world/traffic-model.ts
Normal file
1015
src/scene/world/traffic-model.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,86 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
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
|
* Other vehicles on the night shift, drawn from the car-following model in
|
||||||
* readable rear lamps (a tail-light pair, a high centre brake light on cars,
|
* traffic-model.ts: dark low-poly cars and box trucks at a scale that reads
|
||||||
* amber clearance lamps on trucks), their roofs catching the horizon glow,
|
* beside the 2.78x hero, with live rear lamps (tails that brighten under
|
||||||
* their rear faces our headlights, plus short long-exposure trails and red
|
* braking, a high-mount stop lamp that only lights then, amber indicators at
|
||||||
* pools on the wet asphalt. Scrolling faster than the traffic overtakes it;
|
* 1.5 Hz, clearance lamps on the trucks), long-exposure trails, red pools
|
||||||
* resting lets the faster lanes pull away. Instances wrap through a hidden
|
* behind them and warm headlight pools ahead of them on the wet asphalt.
|
||||||
* band behind the camera, so nothing pops in view.
|
* 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
|
* Vehicles pass alongside the hero and leave beneath the chase camera; only
|
||||||
* on the road. Each lane now runs at one speed, so vehicles in a lane never
|
* a truck in the lane under the camera fades before it would fill the lens.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
/** The hero car's body (2.78x) spans |x| < 2.6. */
|
||||||
export const HERO_CAR_HALF_WIDTH = 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;
|
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 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). */
|
/** 1 lit, 0 dark: the near fade of a point at world z for a near limit. */
|
||||||
export const TRAFFIC_BODY = {
|
export function trafficNearFade(z: number, nearLimit: number): number {
|
||||||
car: { halfWidth: 1.1, length: 4.8 },
|
const t = Math.min(1, Math.max(0, (nearLimit - z) / TRAFFIC_FADE_LENGTH));
|
||||||
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));
|
|
||||||
return t * t * (3 - 2 * t);
|
return t * t * (3 - 2 * t);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keeps a vehicle inside [FAR_Z, TRAFFIC_NEAR_Z], wrapping through the
|
export type TrafficLampRole = 'tail' | 'stop' | 'clearance' | 'indicator';
|
||||||
* hidden band at either end. */
|
export interface TrafficLamp {
|
||||||
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 {
|
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
/** 0..0.6 red to red-orange, 2 amber. */
|
role: TrafficLampRole;
|
||||||
level: number;
|
/** -1 left, 1 right (indicators), 0 otherwise. */
|
||||||
|
side: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rear lamps in body space (origin at the rear face, ground level). */
|
/** Rear lamps in 1x body space (origin at the rear face, ground level). */
|
||||||
export function trafficLamps(kind: TrafficKind, tint: number): Lamp[] {
|
export function trafficLamps(kind: TrafficKind): TrafficLamp[] {
|
||||||
const red = tint * 0.6;
|
|
||||||
if (kind === 'car')
|
if (kind === 'car')
|
||||||
return [
|
return [
|
||||||
{ x: -0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
|
{ x: -0.62, y: 0.92, z: 0.05, w: 0.44, h: 0.3, role: 'tail', side: 0 },
|
||||||
{ x: 0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
|
{ x: 0.62, y: 0.92, z: 0.05, w: 0.44, h: 0.3, role: 'tail', side: 0 },
|
||||||
// Centre high-mounted brake light on the rear window's top edge.
|
// 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, level: red },
|
{ 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 [
|
return [
|
||||||
{ 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.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 },
|
||||||
// Amber clearance lamps at the top corners give the box its outline.
|
// 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, role: 'clearance', side: 0 },
|
||||||
{ 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.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];
|
type Box = readonly [number, number, number, number, number, number];
|
||||||
const CAR_BOXES: readonly Box[] = [
|
const CAR_BOXES: readonly Box[] = [
|
||||||
[-1.1, 1.1, 0.34, 1.1, -4.8, 0], // body
|
[-1.1, 1.1, 0.34, 1.1, -4.8, 0], // body
|
||||||
@@ -130,13 +116,36 @@ function boxGeometry(boxes: readonly Box[]) {
|
|||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Vehicle {
|
/** Level codes for the additive quads: <1 red, 2 amber, 3 warm headlight. */
|
||||||
lane: TrafficLane;
|
const AMBER = 2;
|
||||||
x: number;
|
const WARM = 3;
|
||||||
z: number;
|
/** Indicators: brighter and larger than the lamp while lit, so the blink reads at 200 m. */
|
||||||
tint: number;
|
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<RenderQualitySettings, 'postEffects' | 'postScale'>): 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(
|
export function createTraffic(
|
||||||
resources: SceneResourceBag,
|
resources: SceneResourceBag,
|
||||||
quality: RenderQualitySettings,
|
quality: RenderQualitySettings,
|
||||||
@@ -145,45 +154,46 @@ export function createTraffic(
|
|||||||
) {
|
) {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = 'expressway-traffic';
|
group.name = 'expressway-traffic';
|
||||||
const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7;
|
const pool = trafficPoolSize(quality);
|
||||||
const vehicles: Vehicle[] = [];
|
const lite = pool === 6;
|
||||||
let seed = 7;
|
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 = () => {
|
const random = () => {
|
||||||
seed = (seed * 16807) % 2147483647;
|
seed = (seed * 16807) % 2147483647;
|
||||||
return (seed - 1) / 2147483646;
|
return (seed - 1) / 2147483646;
|
||||||
};
|
};
|
||||||
const lanes = TRAFFIC_LANES.length;
|
const tints = kinds.map(() => random());
|
||||||
const perLane = Array.from({ length: lanes }, (_, lane) => Math.ceil((count - lane) / lanes));
|
const lampsFor = kinds.map((kind) => trafficLamps(kind));
|
||||||
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 uniforms = { uTime: { value: 0 }, uSheen: { value: sheen } };
|
const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen }, ...beamUniforms };
|
||||||
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 vertexShader = /* glsl */ `
|
const vertexShader = /* glsl */ `
|
||||||
|
${BEAM_PARS}
|
||||||
attribute float aLevel;
|
attribute float aLevel;
|
||||||
|
attribute vec2 aGain;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
varying float vLevel;
|
varying float vLevel;
|
||||||
varying float vNear;
|
varying float vGain;
|
||||||
|
varying float vFlare;
|
||||||
void main() {
|
void main() {
|
||||||
vUv = uv;
|
vUv = uv;
|
||||||
vLevel = aLevel;
|
vLevel = aLevel;
|
||||||
|
vGain = aGain.x;
|
||||||
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||||
// Matches trafficNearFade(): dark before the hero car's near field.
|
// Retro-reflection: the lamp's centre in the beam's cone, out to 420 m.
|
||||||
vNear = ${nearFade};
|
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;
|
vec4 view = viewMatrix * world;
|
||||||
vDepth = -view.z;
|
vDepth = -view.z;
|
||||||
gl_Position = projectionMatrix * view;
|
gl_Position = projectionMatrix * view;
|
||||||
@@ -192,14 +202,17 @@ export function createTraffic(
|
|||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
varying float vLevel;
|
varying float vLevel;
|
||||||
varying float vNear;
|
varying float vGain;
|
||||||
|
varying float vFlare;
|
||||||
void main() {
|
void main() {
|
||||||
float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
|
float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
|
||||||
float near = vNear;
|
|
||||||
${shape}
|
${shape}
|
||||||
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0));
|
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));
|
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 <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
}`;
|
}`;
|
||||||
@@ -227,41 +240,66 @@ export function createTraffic(
|
|||||||
const trailMaterial = additive(`
|
const trailMaterial = additive(`
|
||||||
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
|
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
|
||||||
float glow = across * pow(vUv.y, 1.6) * 0.8;`);
|
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(`
|
const poolMaterial = additive(`
|
||||||
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
|
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
|
||||||
float glow = across * pow(vUv.y, 2.2) * 0.2;`);
|
float glow = across * pow(vUv.y, 2.2) * 0.2;`);
|
||||||
|
|
||||||
// Bodies: near-black coachwork that reads as a silhouette against the
|
// 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,
|
// horizon, with the fog glow on the roof, the reader's headlights on the
|
||||||
// a glass rear window, door seams on the trucks and red lamp spill.
|
// rear face, a glass rear window, door seams on the trucks, red lamp spill
|
||||||
const bodyMaterial = (kind: TrafficKind) => resources.own(
|
// (stronger under braking) and the screen-door fade of an emergency exit.
|
||||||
'material',
|
const bodyMaterial = (kind: TrafficKind) =>
|
||||||
new THREE.ShaderMaterial({
|
resources.own(
|
||||||
uniforms,
|
'material',
|
||||||
vertexShader: /* glsl */ `
|
new THREE.ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader: /* glsl */ `
|
||||||
|
attribute vec4 aBody;
|
||||||
|
attribute float aBlink;
|
||||||
varying vec3 vLocal;
|
varying vec3 vLocal;
|
||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vWorld;
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
varying float vNear;
|
varying float vNear;
|
||||||
|
varying float vDither;
|
||||||
|
varying float vBrake;
|
||||||
|
varying float vUnder;
|
||||||
|
varying float vBlink;
|
||||||
void main() {
|
void main() {
|
||||||
vLocal = position;
|
vLocal = position;
|
||||||
|
vBlink = aBlink;
|
||||||
vNormal = normal;
|
vNormal = normal;
|
||||||
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
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;
|
vec4 view = viewMatrix * world;
|
||||||
vDepth = -view.z;
|
vDepth = -view.z;
|
||||||
gl_Position = projectionMatrix * view;
|
gl_Position = projectionMatrix * view;
|
||||||
}`,
|
}`,
|
||||||
fragmentShader: /* glsl */ `
|
fragmentShader: /* glsl */ `
|
||||||
|
${BEAM_PARS}
|
||||||
uniform vec3 uSheen;
|
uniform vec3 uSheen;
|
||||||
varying vec3 vLocal;
|
varying vec3 vLocal;
|
||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vWorld;
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
varying float vNear;
|
varying float vNear;
|
||||||
|
varying float vDither;
|
||||||
|
varying float vBrake;
|
||||||
|
varying float vUnder;
|
||||||
|
varying float vBlink;
|
||||||
void main() {
|
void main() {
|
||||||
float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear;
|
float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear;
|
||||||
if (alpha < 0.02) discard;
|
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);
|
vec3 n = normalize(vNormal);
|
||||||
float up = max(0.0, n.y);
|
float up = max(0.0, n.y);
|
||||||
float rear = max(0.0, n.z);
|
float rear = max(0.0, n.z);
|
||||||
@@ -270,56 +308,95 @@ export function createTraffic(
|
|||||||
vec3 color = vec3(0.034, 0.02, 0.058);
|
vec3 color = vec3(0.034, 0.02, 0.058);
|
||||||
// Roof and shoulders: the horizon glow as a satin sheen.
|
// 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));
|
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.
|
// The reader's headlights on the rear face: a faint fill down the
|
||||||
float beam = exp(-max(0.0, vDepth - 40.0) / 90.0);
|
// road, the real beam (pointer aim, high-beam flash) up close.
|
||||||
color += vec3(0.19, 0.15, 0.27) * rear * beam * 0.55;
|
float fill = exp(-max(0.0, vDepth - 40.0) / 90.0) * 0.3;
|
||||||
${kind === 'car'
|
color += vec3(0.19, 0.15, 0.27) * rear * (fill + headlightBeam(vWorld));
|
||||||
? `// Rear window: glass reflects a lavender band of sky.
|
// 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);
|
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);
|
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));`
|
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 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));
|
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);
|
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.
|
// 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;
|
color *= 1.0 - wheel * 0.6;
|
||||||
gl_FragColor = vec4(color, alpha);
|
gl_FragColor = vec4(color, alpha);
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
}`,
|
}`,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
depthWrite: true,
|
depthWrite: true,
|
||||||
depthTest: true,
|
depthTest: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
||||||
const lampCount = count * LAMPS_PER_VEHICLE;
|
interface Quad {
|
||||||
const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => {
|
mesh: InstanceType<typeof THREE.InstancedMesh>;
|
||||||
|
gain: Float32Array;
|
||||||
|
gainAttribute: InstanceType<typeof THREE.InstancedBufferAttribute>;
|
||||||
|
}
|
||||||
|
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 geometry = resources.own('geometry', plane.clone());
|
||||||
const levels = new Float32Array(instances);
|
const levels = new Float32Array(instances);
|
||||||
for (let i = 0; i < instances; i++) levels[i] = level(i);
|
for (let i = 0; i < instances; i++) levels[i] = level(i);
|
||||||
geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
|
geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
|
||||||
const instanced = new THREE.InstancedMesh(geometry, material, instances);
|
const gain = new Float32Array(instances * 2);
|
||||||
instanced.name = name;
|
const gainAttribute = new THREE.InstancedBufferAttribute(gain, 2);
|
||||||
instanced.frustumCulled = false;
|
gainAttribute.setUsage(THREE.DynamicDrawUsage);
|
||||||
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
geometry.setAttribute('aGain', gainAttribute);
|
||||||
group.add(instanced);
|
const mesh = new THREE.InstancedMesh(geometry, material, instances);
|
||||||
return instanced;
|
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 lampLevel = (vehicle: number, lamp: TrafficLamp | undefined): number => {
|
||||||
const lamps = quad('traffic-lamps', lampMaterial, lampCount, (i) => lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]?.level ?? 0);
|
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.
|
// 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);
|
const trails = quad('traffic-light-trails', trailMaterial, pool * 2, (i) => tints[Math.floor(i / 2)]! * 0.6);
|
||||||
// One pool per vehicle, tinted like that vehicle's own lamps.
|
// A red pool behind each vehicle, tinted like its own lamps; on high and
|
||||||
const pools = quad('traffic-road-pools', poolMaterial, count, (i) => vehicles[i]!.tint * 0.6);
|
// 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 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 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));
|
const mesh = new THREE.InstancedMesh(geometry, bodyMaterial(kind), Math.max(1, members.length));
|
||||||
mesh.name = `traffic-${kind}-bodies`;
|
mesh.name = `traffic-${kind}-bodies`;
|
||||||
mesh.frustumCulled = false;
|
mesh.frustumCulled = false;
|
||||||
@@ -328,67 +405,174 @@ export function createTraffic(
|
|||||||
// Bodies draw before the additive lamps so the lamps sit on them.
|
// Bodies draw before the additive lamps so the lamps sit on them.
|
||||||
mesh.renderOrder = -1;
|
mesh.renderOrder = -1;
|
||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
return { mesh, members };
|
return { mesh, members, body, bodyAttribute, blink, blinkAttribute };
|
||||||
});
|
});
|
||||||
|
|
||||||
const dummy = new THREE.Object3D();
|
// The pass swell: a visible vehicle's rear crossing z 8 at |relative| > 6.
|
||||||
const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
|
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 {
|
return {
|
||||||
group,
|
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;
|
uniforms.uTime.value = time;
|
||||||
const dt = Math.min(0.1, Math.max(0, deltaSeconds));
|
model.step(input);
|
||||||
for (let i = 0; i < count; i++) {
|
const hero = input.heroSpeed;
|
||||||
const v = vehicles[i]!;
|
// The flash to pass: tail lamps in reach swell with the beam.
|
||||||
const relative = travelSpeed - v.lane.speed; // > 0: we are overtaking
|
const lift = THREE.MathUtils.smoothstep(beamUniforms.uBeamGain.value, 0.78, 1);
|
||||||
v.z = wrapTrafficZ(v.z + relative * dt);
|
passSide = 0;
|
||||||
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12);
|
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.
|
// Trails stream away from the direction of relative travel.
|
||||||
const trailDirection = relative >= 0 ? -1 : 1;
|
const trailDirection = relative >= 0 ? -1 : 1;
|
||||||
const own = lampsFor[i]!;
|
const own = lampsFor[i]!;
|
||||||
for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
|
for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
|
||||||
const lamp = own[k];
|
const lamp = own[k];
|
||||||
|
const g = (lampBase + k) * 2;
|
||||||
if (lamp === undefined) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
dummy.position.set(v.x + lamp.x, lamp.y, v.z + lamp.z);
|
const lx = x + s * (lamp.x * cos + lamp.z * sin);
|
||||||
dummy.rotation.set(0, 0, 0);
|
const lz = z + s * (-lamp.x * sin + lamp.z * cos);
|
||||||
dummy.scale.set(lamp.w, lamp.h, 1);
|
const fade = trafficNearFade(lz, near) * visible * (under ? underFade(lz) : 1);
|
||||||
dummy.updateMatrix();
|
let gain = 0;
|
||||||
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, dummy.matrix);
|
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;
|
if (k > 1) continue;
|
||||||
// Horizontal ribbon at lamp height, uv.y = 1 at the lamp.
|
// 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);
|
place(
|
||||||
dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0);
|
trails.mesh,
|
||||||
dummy.scale.set(0.2, trailLength, 1);
|
i * 2 + k,
|
||||||
dummy.updateMatrix();
|
lx,
|
||||||
trails.setMatrixAt(i * 2 + k, dummy.matrix);
|
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);
|
const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08);
|
||||||
dummy.position.set(v.x, 0.04, v.z + poolLength / 2);
|
place(pools.mesh, i, x, 0.04, z + poolLength / 2, -Math.PI / 2, 0, size.halfWidth * 1.7, poolLength);
|
||||||
dummy.rotation.set(-Math.PI / 2, 0, 0);
|
pools.gain[i * 2] = poolFade * (1 + brake * 0.8);
|
||||||
dummy.scale.set(halfWidth * 1.7, poolLength, 1);
|
if (frontPools) {
|
||||||
dummy.updateMatrix();
|
const nose = z - size.length;
|
||||||
pools.setMatrixAt(i, dummy.matrix);
|
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) {
|
for (const { mesh, members, body, bodyAttribute, blink, blinkAttribute } of bodies) {
|
||||||
members.forEach((v, j) => {
|
for (let j = 0; j < members.length; j++) {
|
||||||
dummy.position.set(v.x, 0, v.z);
|
const i = members[j]!;
|
||||||
dummy.rotation.set(0, 0, 0);
|
const state = frame.active[i]!;
|
||||||
dummy.scale.set(1, 1, 1);
|
if (state === TRAFFIC_PENDING || state === TRAFFIC_BEHIND) {
|
||||||
dummy.updateMatrix();
|
mesh.setMatrixAt(j, hidden);
|
||||||
mesh.setMatrixAt(j, dummy.matrix);
|
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;
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
bodyAttribute.needsUpdate = true;
|
||||||
|
blinkAttribute.needsUpdate = true;
|
||||||
}
|
}
|
||||||
lamps.instanceMatrix.needsUpdate = true;
|
for (const q of quads) {
|
||||||
trails.instanceMatrix.needsUpdate = true;
|
q.mesh.instanceMatrix.needsUpdate = true;
|
||||||
pools.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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
36
tests/unit/flash-target.test.ts
Normal file
36
tests/unit/flash-target.test.ts
Normal file
@@ -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('<main id="m"><section class="plate"><p id="p">road</p></section></main>');
|
||||||
|
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(`
|
||||||
|
<a href="#x" id="a"><span id="in-a">link</span></a>
|
||||||
|
<button id="b"><svg id="svg"></svg></button>
|
||||||
|
<div role="button" id="rb">go</div>
|
||||||
|
<input id="i"><textarea id="t"></textarea><select id="s"><option id="o">1</option></select>
|
||||||
|
<label id="l">label</label><details><summary id="sum">more</summary></details>
|
||||||
|
<div class="dashcam-frame"><div id="dc">frame</div></div>`);
|
||||||
|
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('<p id="p">road</p>');
|
||||||
|
expect(isFlashTarget(d.getElementById('p'), 'road')).toBe(false);
|
||||||
|
expect(isFlashTarget(null, '')).toBe(false);
|
||||||
|
expect(isFlashTarget(d, '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
616
tests/unit/traffic-model.test.ts
Normal file
616
tests/unit/traffic-model.test.ts
Normal file
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
tests/unit/traffic-pass.test.ts
Normal file
78
tests/unit/traffic-pass.test.ts
Normal file
@@ -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-');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,72 +1,109 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
HERO_CAR_HALF_WIDTH,
|
HERO_CAR_HALF_WIDTH,
|
||||||
|
LAMPS_PER_VEHICLE,
|
||||||
TRAFFIC_BODY,
|
TRAFFIC_BODY,
|
||||||
TRAFFIC_FADE_LENGTH,
|
TRAFFIC_FADE_LENGTH,
|
||||||
TRAFFIC_LANE_JITTER,
|
|
||||||
TRAFFIC_LANES,
|
|
||||||
TRAFFIC_NEAR_Z,
|
|
||||||
trafficLamps,
|
trafficLamps,
|
||||||
trafficNearFade,
|
trafficNearFade,
|
||||||
wrapTrafficZ,
|
trafficCameraSide,
|
||||||
|
trafficPoolSize,
|
||||||
} from '@/scene/world/traffic';
|
} 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', () => {
|
describe('expressway traffic stays clear of the hero car', () => {
|
||||||
it('keeps every body outside the hero car, with lane jitter', () => {
|
it('agrees with the model on the hero envelope', () => {
|
||||||
for (const lane of TRAFFIC_LANES) {
|
expect(HERO_CAR_HALF_WIDTH).toBe(HERO_HALF_WIDTH);
|
||||||
const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER;
|
|
||||||
expect(inner).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never lets bodies in adjacent lanes overlap, and keeps them on the road', () => {
|
it('scales the 1x bodies to the model sizes', () => {
|
||||||
const spans = TRAFFIC_LANES.map((lane) => {
|
kinds.forEach((kind, k) => {
|
||||||
const half = TRAFFIC_BODY[lane.kind].halfWidth + TRAFFIC_LANE_JITTER;
|
const size = TRAFFIC_KINDS[k]!;
|
||||||
return [lane.x - half, lane.x + half] as const;
|
expect(TRAFFIC_BODY[kind].halfWidth * size.scale).toBeCloseTo(size.halfWidth, 1);
|
||||||
}).sort((a, b) => a[0] - b[0]);
|
expect(TRAFFIC_BODY[kind].roof * size.scale).toBeCloseTo(size.roof, 1);
|
||||||
for (let i = 1; i < spans.length; i++) expect(spans[i]![0]).toBeGreaterThan(spans[i - 1]![1]);
|
// The truck's length runs from the under-run bar to the cab's nose.
|
||||||
for (const [left, right] of spans) {
|
expect(size.length).toBeGreaterThanOrEqual(TRAFFIC_BODY[kind].length * size.scale - 0.01);
|
||||||
expect(left).toBeGreaterThan(-8);
|
});
|
||||||
expect(right).toBeLessThan(8);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs each lane at one speed, so no vehicle drives through another', () => {
|
it('keeps every side-lane body outside the hero car and on the asphalt, with lane jitter', () => {
|
||||||
const xs = TRAFFIC_LANES.map((lane) => lane.x);
|
for (const x of sideLanes) {
|
||||||
expect(new Set(xs).size).toBe(xs.length);
|
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', () => {
|
it('puts every lamp on the vehicle body', () => {
|
||||||
for (const kind of ['car', 'truck'] as const) {
|
for (const kind of kinds) {
|
||||||
const lamps = trafficLamps(kind, 0.5);
|
const lamps = trafficLamps(kind);
|
||||||
expect(lamps.length).toBeGreaterThanOrEqual(3);
|
expect(lamps.length).toBeGreaterThanOrEqual(5);
|
||||||
for (const lamp of lamps) expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
|
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.
|
// 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!.x).toBe(-right!.x);
|
||||||
expect(left!.y).toBe(right!.y);
|
expect(left!.y).toBe(right!.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is fully dark before the car near field and lit beyond the fade', () => {
|
it('gives every vehicle a left and a right indicator beside, not on, its tail lamps', () => {
|
||||||
expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0);
|
for (const kind of kinds) {
|
||||||
expect(trafficNearFade(0)).toBe(0);
|
const lamps = trafficLamps(kind);
|
||||||
expect(trafficNearFade(40)).toBe(0);
|
const indicators = lamps.filter((lamp) => lamp.role === 'indicator');
|
||||||
expect(trafficNearFade(TRAFFIC_NEAR_Z - TRAFFIC_FADE_LENGTH)).toBe(1);
|
expect(indicators.map((lamp) => lamp.side).sort()).toEqual([-1, 1]);
|
||||||
expect(trafficNearFade(-300)).toBe(1);
|
for (const indicator of indicators) {
|
||||||
// The car's nose is at about z = -5.5: the near field starts well ahead.
|
expect(Math.sign(indicator.x)).toBe(indicator.side);
|
||||||
expect(TRAFFIC_NEAR_Z).toBeLessThan(-18);
|
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;
|
||||||
it('wraps vehicles inside the hidden band at any scroll speed', () => {
|
expect(apart).toBeGreaterThanOrEqual(-0.005);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user