fix(scene): keep traffic lamps out of the hero car's near field

This commit is contained in:
2026-10-10 22:02:39 +03:00
parent 48a3413ebc
commit 298da54871
3 changed files with 194 additions and 10 deletions

112
docs/redesign/round5.md Normal file
View File

@@ -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.

View File

@@ -9,9 +9,36 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
* resting lets the faster lanes pull away. Instances wrap through a hidden band * resting lets the faster lanes pull away. Instances wrap through a hidden band
* behind the camera, so nothing pops in view. * 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 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 { interface Vehicle {
x: number; x: number;
@@ -29,7 +56,7 @@ 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 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[] = []; const vehicles: Vehicle[] = [];
let seed = 7; let seed = 7;
const random = () => { const random = () => {
@@ -42,9 +69,9 @@ export function createTraffic(
const fast = lane < 0; const fast = lane < 0;
vehicles.push({ vehicles.push({
x: lane + (random() - 0.5) * 0.5, 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, 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, height: fast ? 0.95 : 1.55 + random() * 0.4,
tint: random(), tint: random(),
}); });
@@ -56,10 +83,14 @@ export function createTraffic(
varying vec2 vUv; varying vec2 vUv;
varying float vDepth; varying float vDepth;
varying float vLevel; varying float vLevel;
varying float vNear;
void main() { void main() {
vUv = uv; vUv = uv;
vLevel = aLevel; 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; vDepth = -view.z;
gl_Position = projectionMatrix * view; gl_Position = projectionMatrix * view;
}`; }`;
@@ -67,9 +98,10 @@ export function createTraffic(
varying vec2 vUv; varying vec2 vUv;
varying float vDepth; varying float vDepth;
varying float vLevel; varying float vLevel;
varying float vNear;
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 = smoothstep(1.5, 7.0, vDepth); float near = vNear;
${shape} ${shape}
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), vLevel); 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); gl_FragColor = vec4(color * glow * far * near, 1.0);
@@ -135,9 +167,7 @@ export function createTraffic(
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const v = vehicles[i]!; const v = vehicles[i]!;
const relative = travelSpeed - v.speed; // > 0: we are overtaking const relative = travelSpeed - v.speed; // > 0: we are overtaking
v.z += relative * dt; v.z = wrapTrafficZ(v.z + relative * dt);
if (v.z > NEAR_Z) v.z -= SPAN;
else if (v.z < FAR_Z) v.z += SPAN;
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14); const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14);
// 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;

View File

@@ -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);
}
}
});
});