fix(scene): keep traffic lamps out of the hero car's near field
This commit is contained in:
112
docs/redesign/round5.md
Normal file
112
docs/redesign/round5.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
42
tests/unit/traffic.test.ts
Normal file
42
tests/unit/traffic.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user