diff --git a/docs/redesign/round5.md b/docs/redesign/round5.md new file mode 100644 index 0000000..ee1af14 --- /dev/null +++ b/docs/redesign/round5.md @@ -0,0 +1,112 @@ +# Round 5: The Gantry + +Brainstormed autonomously (Luca gave "100% leeway" and asked for the +brainstorming and frontend-design passes). This file records the +understanding, the options weighed and the decisions. Evidence is appended at +the end once the work is done. + +## Understanding + +What Luca said (round 4 feedback, desktop first, mobile must not break): + +1. A cinematic outro that mirrors the intro. +2. Two red lights drift away from the car, faster than it, while scrolling. + They must stay locked to the car. +3. Remove the white rectangles that flash on chapter changes. Nothing may + flash more than 3 times a second; no large bright flashes at all. +4. The top bar feels unfinished. It should feel ready and distinctive. +5. and 6. The empty band between the bar and the content (kept clear for + the 3D signs) is jarring. Keep the signs, make them better, kill the gap. +7. A lighter car on mobile, a cooler car on desktop (headless Blender only). +8. The soundtrack and settings buttons get in the way of content. +9. Card titles sit inside some cards and outside others. One rule. +10. Much more motion, fluid and professional, reduced motion respected. + +Assumptions: the night-drive identity, the content and the copy stay. "Signs" +means the chapter exit signs, not the districts. "Professional" rules out +gimmicks such as cursor trails, and anything that slows reading. + +## The big one: where do the signs live? (items 4, 5, 6, 8) + +The band exists because the signs dock at the top of the frame, and the +content had to stay out of their way. + +| Option | For | Against | +| --- | --- | --- | +| A. Signs pass at the screen edges | Real roadside feel | Content spans 1280 of 1440 px. Edges are covered for most chapters. Needs gutters, which is the same waste turned sideways. | +| B. Camera reframes so signs sit beside the content | No new UI | Every chapter layout differs (right rail, left timeline, full diagram). No side is free everywhere. | +| C. Content panels framed by the sign | Strong idea for openers | The sign is in the canvas, under the DOM. A frame has to be drawn twice and kept in sync on every scroll. Fragile. | +| D. Sign flies into the HUD (3D to 2D handoff) | Clear story, frees the band | On its own, the 2D twin is just a label. | +| **E. The top bar becomes an overhead gantry, the sign docks in its window (A + D)** | The bar and the signs become one object. Content gets the full height. The bar gains a reason to exist. | Needs a see-through window in the bar and a clipped reading viewport. | + +**Decision: E, "The Gantry".** + +- The top bar is redrawn as a steel overhead gantry spanning the road: a + full-bleed beam with rivet rails, a route line and the exits. +- Its centre holds a **sign window**: a real cut-out the WebGL canvas shows + through. The reading viewport is clipped at the beam's lower edge, so + content never shows through the window, and content now starts directly + under the beam. The gap is gone. +- On each chapter approach, the 3D sign rises out of the road on a small + overhead truss, grows as it approaches, and locks into the window (the + handoff). It holds, then passes overhead, out of the top of the frame, + exactly like driving under a gantry. +- Between signs, the window shows its 2D twin: the current exit, the next + exit, route progress and the Bucharest clock (the old bottom-right HUD). +- The window is interactive: hover tilts the docked sign toward the pointer. + Clicking it, or pressing Enter, drives to the next exit. +- The soundtrack and settings controls move into the gantry's right bay as + compact icon buttons. Nothing floats over the content any more. +- Mobile keeps its compact route bar and the screen-anchored shelf sign. The + shelf now docks into the bar too, so mobile also loses its band. + +## Other decisions + +- **Car lights (2), root cause first.** See "Car light bug" below. Fixed at + the cause, not by hiding symptoms. +- **Flashing (3).** The exit gates (nine near-white frames at about 4.5 Hz + with a lens flash and a bloom lift) are deleted, along with the flash + uniform. The chapter change is now carried by DOM motion: the plate + entrance, the gantry plate swap and the palette drift. Every remaining + blinking light is audited against the 3 Hz rule. +- **Outro (1).** It mirrors the intro. On arrival, the camera cranes up and + back to the sun, a telephoto end shot. Letterbox bars close in on every + tier (as CSS, so even the low tier gets them). A closing credit line made + only of real facts runs in the lower bar. Leaving the destination reverses + it. Under reduced motion, the bars appear without a crane. +- **Car (7).** Mobile loads a decimated GLB, built by a headless + `blender -b` script. Desktop gets better coachwork: clearcoat paint, a + lit tail bar with a soft halo, and a stronger rim from the key light. +- **Cards (9).** One rule: *chapter titles sit outside, above their plate; + every card title sits inside its card, first, under its mono plate label.* +- **Motion (10).** Chapter plates and cards rise and unmask on entry, with a + stagger. Hover lifts cards with an accent rule. The gantry plate swaps on + every chapter change, and the exits light in sequence. All of it is + transform and opacity only, behind `prefers-reduced-motion: + no-preference`. No cursor effects. + +## Car light bug (systematic debugging record) + +- **Symptom.** While scrolling, two red lights near the car move separately + from it and faster, then drift away. +- **Evidence.** Frame bursts at 1440, both idle and scrolling, show bodiless + red lamp pairs with trails and wet-road pools next to the car's flank, at + the height of its rear diffuser. These are `world/traffic.ts` vehicles: + they appear only where traffic lanes are, and their speed matches traffic, + not the car. +- **Root cause.** Traffic was authored at 1x scale while the hero car renders + at 2.78x (a half-width of about 2.6 units). + - The fast lane at x = -2.6 puts one lamp at x = -1.8, inside the car's + footprint. The truck lane at x = 4.9 runs along its flank. + - Lamp heights (0.95 to 1.95) and lamp spacing match the car's own tail + lamps. + - Traffic moves at its relative speed, up to about 110 units/s while + scrolling. So a bodiless pair of red lamps slides out of, or past, the + car. It reads as the car's own taillights coming loose. +- **Fix.** + - Lanes are moved clear of the car's envelope. + - The car's near field (from 18 units ahead of its nose to behind the + camera) is a hidden band. Traffic fades out before it and wraps inside + it, so no light ever passes the car. + - A unit test pins both rules. + - The car's own lamps and the wake stay parented to the car, as before. diff --git a/src/scene/world/traffic.ts b/src/scene/world/traffic.ts index 0017cd5..f45e005 100644 --- a/src/scene/world/traffic.ts +++ b/src/scene/world/traffic.ts @@ -9,9 +9,36 @@ import type { RenderQualitySettings } from '@/scene/render-quality'; * resting lets the faster lanes pull away. Instances wrap through a hidden band * behind the camera, so nothing pops in view. */ -const NEAR_Z = 46; +/** + * 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. Lamps fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z and + * wrap inside the hidden band, so no light ever slides past the car (round 5: + * bodiless lamp pairs passing the flank read as the car's own taillights + * coming loose). + */ +export const HERO_CAR_HALF_WIDTH = 2.6; +export const TRAFFIC_NEAR_Z = -24; +export const TRAFFIC_FADE_LENGTH = 22; const FAR_Z = -660; -const SPAN = NEAR_Z - FAR_Z; +const SPAN = TRAFFIC_NEAR_Z - FAR_Z; +/** Lane centres. Fast lanes on the left, slower trucks on the right. */ +export const TRAFFIC_LANES = [-5.7, -5.7, -4.3, 5.4, 5.7] as const; +export const TRAFFIC_LAMP_HALF_SPACING = { fast: 0.9, slow: 1.0 } as const; + +/** Visibility of a lamp, trail or pool 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); +} + +/** Keeps a vehicle inside [FAR_Z, TRAFFIC_NEAR_Z], wrapping through the + * hidden band at either end. */ +export function wrapTrafficZ(z: number): number { + if (z > TRAFFIC_NEAR_Z) return z - SPAN * Math.ceil((z - TRAFFIC_NEAR_Z) / SPAN); + if (z < FAR_Z) return z + SPAN * Math.ceil((FAR_Z - z) / SPAN); + return z; +} interface Vehicle { x: number; @@ -29,7 +56,7 @@ export function createTraffic( const group = new THREE.Group(); group.name = 'expressway-traffic'; const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7; - const lanes = [-5.1, -5.1, -2.6, 4.9, 5.2]; + const lanes = TRAFFIC_LANES; const vehicles: Vehicle[] = []; let seed = 7; const random = () => { @@ -42,9 +69,9 @@ export function createTraffic( const fast = lane < 0; vehicles.push({ x: lane + (random() - 0.5) * 0.5, - z: FAR_Z + random() * (SPAN - 120) + 20, + z: FAR_Z + 20 + random() * (SPAN - 40), speed: fast ? 26 + random() * 22 : 9 + random() * 6, - width: fast ? 0.78 + random() * 0.12 : 1.0, + width: fast ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow, height: fast ? 0.95 : 1.55 + random() * 0.4, tint: random(), }); @@ -56,10 +83,14 @@ export function createTraffic( varying vec2 vUv; varying float vDepth; varying float vLevel; + varying float vNear; void main() { vUv = uv; vLevel = aLevel; - vec4 view = modelViewMatrix * instanceMatrix * vec4(position, 1.0); + vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); + // Matches trafficNearFade(): dark before the hero car's near field. + vNear = smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0)); + vec4 view = viewMatrix * world; vDepth = -view.z; gl_Position = projectionMatrix * view; }`; @@ -67,9 +98,10 @@ export function createTraffic( varying vec2 vUv; varying float vDepth; varying float vLevel; + varying float vNear; void main() { float far = 1.0 - smoothstep(420.0, 660.0, vDepth); - float near = smoothstep(1.5, 7.0, vDepth); + float near = vNear; ${shape} vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), vLevel); gl_FragColor = vec4(color * glow * far * near, 1.0); @@ -135,9 +167,7 @@ export function createTraffic( for (let i = 0; i < count; i++) { const v = vehicles[i]!; const relative = travelSpeed - v.speed; // > 0: we are overtaking - v.z += relative * dt; - if (v.z > NEAR_Z) v.z -= SPAN; - else if (v.z < FAR_Z) v.z += SPAN; + v.z = wrapTrafficZ(v.z + relative * dt); const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14); // Trails stream away from the direction of relative travel. const trailDirection = relative >= 0 ? -1 : 1; diff --git a/tests/unit/traffic.test.ts b/tests/unit/traffic.test.ts new file mode 100644 index 0000000..5b22ed6 --- /dev/null +++ b/tests/unit/traffic.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + HERO_CAR_HALF_WIDTH, + TRAFFIC_FADE_LENGTH, + TRAFFIC_LAMP_HALF_SPACING, + TRAFFIC_LANES, + TRAFFIC_NEAR_Z, + trafficNearFade, + wrapTrafficZ, +} from '@/scene/world/traffic'; + +describe('expressway traffic stays clear of the hero car', () => { + it('keeps every lamp outside the car body, with lane jitter', () => { + for (const lane of TRAFFIC_LANES) { + const half = lane < 0 ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow; + const innerLamp = Math.abs(lane) - half - 0.25; // 0.25: max lane jitter + expect(innerLamp).toBeGreaterThan(HERO_CAR_HALF_WIDTH); + } + }); + + it('is fully dark before the car near field and lit beyond the fade', () => { + expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0); + expect(trafficNearFade(0)).toBe(0); + expect(trafficNearFade(40)).toBe(0); + expect(trafficNearFade(TRAFFIC_NEAR_Z - TRAFFIC_FADE_LENGTH)).toBe(1); + expect(trafficNearFade(-300)).toBe(1); + // The car's nose is at about z = -5.5: the near field starts well ahead. + expect(TRAFFIC_NEAR_Z).toBeLessThan(-18); + }); + + it('wraps vehicles inside the hidden band at any scroll speed', () => { + // 110 units/s at a 0.1 s frame clamp is 11 units per step; test far more. + for (const step of [0.5, 11, 64, 900, -11, -900]) { + let z = -200; + for (let i = 0; i < 400; i++) { + z = wrapTrafficZ(z + step); + expect(z).toBeLessThanOrEqual(TRAFFIC_NEAR_Z); + expect(z).toBeGreaterThanOrEqual(-660); + } + } + }); +});