feat: give the boards a horizon, a sea that reflects, and a state worth flying over
The wide shot, which is what an anonymous visitor actually lands on. **The sea was `MeshLambertMaterial`** — a material with no specular term at all, by construction — on a board where water is half the frame. It is now a low-roughness dielectric that reads `scene.environment`, with a runtime-generated tiling swell normal map sampled twice per fragment at two scales and two headings, so the sun breaks into a moving glitter path instead of a mirror point. An `onBeforeCompile` patch takes the body colour toward the deep value looking straight down and leaves it to the reflection at grazing, and walks roughness up past 1.6 board spans so the far water cannot shimmer. The swell spectrum is 1/k^2 and not 1/k because the first attempt was photographed: at 1/k every component carries the same slope, the shortest wave wins, and the sea renders as hard diagonal corduroy. A test holds it now. **The board no longer ends in a diamond.** The sea plane went from 1.8 board spans to 18, past the fog's far plane from anywhere the orbit reaches, and the sky is a world-space dome rather than a screen-space gradient. That gradient was wrong in a way dusk made obvious: the sunset band was painted along the *bottom* of the picture, under the board, while the true horizon at the top of frame stayed zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the seam was a symptom of it. **Terrain casts shadows.** Left off before because double-sided terrain against a ~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is a stride-2 decimation appended to the same index buffer and swapped in by `onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of the depth cost, and indistinguishable from the full-resolution caster in a side-by-side crop. Stride 1 was measured at +65,566 triangles and would have missed the budget by ~47,000, so it was not shipped. **California reads as California.** It was a beige kite: the eastern edge one ruled line for five degrees of latitude, the south closing in a diagonal V, the whole south-east a featureless tan wedge. Now the coast runs to the Mexican border with San Diego on it, the eastern edge follows the Colorado and the Nevada diagonal, and the south-east is the Basin and Range — forty parallel desert ridges throwing shadows east, Death Valley as a white pan between the Panamints and the Black Mountains, the Salton Sea the one cool value for two hundred kilometres. The opening pose is retuned to the bigger board; the old 452/392 stand-off left a slab of empty ocean where the state should be. **The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to find one at all — indistinguishable from a dead pixel, on a board whose entire claim is that the sky is live. They are airliners now, with planform and trail, and clicking one raises its card for a signed-out visitor. **The Model X is off the wall.** It stood at floor level outside a studio 188 m up a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen from `site.elevation`, which the pack already carries — not from an office id, which is the bug class this repo already hit once when a door marker gated on `id === "sf"` and would have pinned the Los Angeles building to San Francisco. Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground on the Bay Area and SoCal boards. And one test asserted an exact source line for the water material, so the better multi-line implementation failed it — it now asserts the property (dielectric, metalness 0, low roughness) rather than the author's first guess at formatting. Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000 triangles — 2.8% of triangle headroom left, which is the number the next person should check first. No budget was raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+151
-27
@@ -172,24 +172,51 @@ function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE
|
||||
}
|
||||
|
||||
/**
|
||||
* A draped, flat road deck. A tube turns a freeway into a raised pipeline.
|
||||
* A draped strip running between two parallel offsets from a path, each at its
|
||||
* own lateral distance and its own height.
|
||||
*
|
||||
* The UVs run 0..1 across the carriageway and in **metres** along it, which is
|
||||
* the sane convention if anyone ever puts a surface texture on a road. Right
|
||||
* now nothing does, and they are here for a duller reason: `mergeGeometries`
|
||||
* only merges geometries whose attribute sets match exactly, so a strip without
|
||||
* UVs cannot share a bucket with the tube barriers beside it.
|
||||
* The flat symmetric case is a road deck; the asymmetric case is an embankment
|
||||
* batter, and it is the reason this generalised. A ribbon whose two rails sit
|
||||
* at different heights has a **tilted normal**, which is the entire mechanism
|
||||
* by which a freeway stops reading as a line drawn on the ground: the crown
|
||||
* catches the sun and the two flanks do not, so the corridor has a lit edge and
|
||||
* a shaded one at every hour instead of being one flat value.
|
||||
*
|
||||
* The UVs run 0..1 across the strip and in **metres** along it, which is the
|
||||
* sane convention if anyone ever puts a surface texture on a road. Right now
|
||||
* nothing does, and they are here for a duller reason: `mergeGeometries` only
|
||||
* merges geometries whose attribute sets match exactly, so a strip without UVs
|
||||
* cannot share a bucket with the tube barriers beside it.
|
||||
*/
|
||||
function roadRibbonGeometry(
|
||||
function bandGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
lift = 0,
|
||||
offsetA: number,
|
||||
liftA: number,
|
||||
offsetB: number,
|
||||
liftB: number,
|
||||
): THREE.BufferGeometry {
|
||||
/**
|
||||
* The rail at the larger offset is always emitted first, whichever order the
|
||||
* caller wrote them in.
|
||||
*
|
||||
* This is not tidiness. These strips are `deck` material, which is
|
||||
* `DoubleSide`, and three.js negates the shading normal on a back face — so a
|
||||
* strip whose two rails arrive in the opposite order to its neighbours has
|
||||
* reversed winding, gets its up-pointing normal turned to face the ground,
|
||||
* and renders as an unlit black band. That is exactly what the right-hand
|
||||
* embankment did the first time it was built from `side * 1.75` and
|
||||
* `side * 2.3`: on the `-1` side those two offsets are in decreasing order,
|
||||
* and a black stripe ran the length of US-101.
|
||||
*/
|
||||
const ordered = offsetA >= offsetB;
|
||||
const leftOffset = ordered ? offsetA : offsetB;
|
||||
const leftLift = ordered ? liftA : liftB;
|
||||
const rightOffset = ordered ? offsetB : offsetA;
|
||||
const rightLift = ordered ? liftB : liftA;
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const half = width / 2;
|
||||
let along = 0;
|
||||
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
@@ -200,14 +227,38 @@ function roadRibbonGeometry(
|
||||
const dx = next.x - previous.x;
|
||||
const dz = next.z - previous.z;
|
||||
const length = Math.hypot(dx, dz) || 1;
|
||||
const nx = -dz / length;
|
||||
const nz = dx / length;
|
||||
const tx = dx / length;
|
||||
const tz = dz / length;
|
||||
// Left of travel, in the ground plane.
|
||||
const nx = -tz;
|
||||
const nz = tx;
|
||||
if (index > 0) along += point.distanceTo(previous);
|
||||
|
||||
// The across-vector from the right rail to the left one, in three
|
||||
// dimensions. Crossed with the tangent it gives the strip's true normal;
|
||||
// the sign flip keeps that normal pointing at the sky whichever way round
|
||||
// the two offsets were handed in.
|
||||
const ax = nx * (leftOffset - rightOffset);
|
||||
const ay = leftLift - rightLift;
|
||||
const az = nz * (leftOffset - rightOffset);
|
||||
let mx = ay * tz - az * 0;
|
||||
let my = az * tx - ax * tz;
|
||||
let mz = ax * 0 - ay * tx;
|
||||
const mLength = Math.hypot(mx, my, mz) || 1;
|
||||
mx /= mLength;
|
||||
my /= mLength;
|
||||
mz /= mLength;
|
||||
if (my < 0) {
|
||||
mx = -mx;
|
||||
my = -my;
|
||||
mz = -mz;
|
||||
}
|
||||
|
||||
positions.push(
|
||||
point.x + nx * half, point.y + lift, point.z + nz * half,
|
||||
point.x - nx * half, point.y + lift, point.z - nz * half,
|
||||
point.x + nx * leftOffset, point.y + leftLift, point.z + nz * leftOffset,
|
||||
point.x + nx * rightOffset, point.y + rightLift, point.z + nz * rightOffset,
|
||||
);
|
||||
normals.push(0, 1, 0, 0, 1, 0);
|
||||
normals.push(mx, my, mz, mx, my, mz);
|
||||
uvs.push(0, along, 1, along);
|
||||
if (index < points.length - 1) {
|
||||
const a = index * 2;
|
||||
@@ -224,6 +275,15 @@ function roadRibbonGeometry(
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
|
||||
function roadRibbonGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
lift = 0,
|
||||
): THREE.BufferGeometry {
|
||||
return bandGeometry(points, width / 2, lift, -width / 2, lift);
|
||||
}
|
||||
|
||||
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
|
||||
return points.map((point, index) => {
|
||||
const previous = points[Math.max(0, index - 1)] ?? point;
|
||||
@@ -313,7 +373,12 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const batch = new Batch();
|
||||
const asphalt = [0x353a3d, 0x303538];
|
||||
const shoulder = [0x555759, 0x4e5153];
|
||||
const berm = [0x64705c, 0x74674c];
|
||||
const berm = [0x8d8a66, 0x9a8c62];
|
||||
// One shadow colour for both corridors' batters. Two would be one more
|
||||
// material and one more draw call for a difference nobody can see on a
|
||||
// surface that is, by construction, the part of the corridor facing away
|
||||
// from the sun.
|
||||
const batter = 0x5f5740;
|
||||
const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa });
|
||||
const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 });
|
||||
const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false });
|
||||
@@ -352,12 +417,43 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const route = plan.routes[roadIndex];
|
||||
const identityIndex = route?.identity === "interstate" ? 1 : 0;
|
||||
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
|
||||
// Broad earthwork under separate decks makes grade and curve changes read.
|
||||
batch.add(
|
||||
"freeway:berm",
|
||||
roadRibbonGeometry(path, 2.75, -0.09),
|
||||
batch.material("deck", berm[identityIndex] ?? berm[0]!),
|
||||
);
|
||||
/**
|
||||
* The earthwork, as a crown and two batters rather than one flat ribbon.
|
||||
*
|
||||
* This is the fix for the defect that mattered most on the California
|
||||
* board: at 1,919 m to the scene unit the whole corridor is about eleven
|
||||
* pixels wide from the default camera, and eleven pixels of flat mid-grey
|
||||
* lying exactly on the ground reads as a line somebody drew on the map, not
|
||||
* as a road. Three things change that, and none of them is width for its
|
||||
* own sake:
|
||||
*
|
||||
* - **A graded right-of-way that is not the colour of the asphalt.** The
|
||||
* crown runs out to ±1.75 in dry cut earth, so the corridor arrives as
|
||||
* pale / dark / pale instead of as one dark stroke, and the eye reads
|
||||
* three bands where it used to read one line.
|
||||
* - **Batters with a real normal.** The flanks fall 0.09 units over 0.55,
|
||||
* which is about nine degrees — enough that Lambert separates them from
|
||||
* the crown at every sun angle, and enough that at dusk the corridor has
|
||||
* a lit side and a shaded side.
|
||||
* - **Sitting slightly proud of the ground.** The crown is at -0.02
|
||||
* rather than -0.09, so the earthwork is a causeway across the flats
|
||||
* rather than a trench cut into them.
|
||||
*
|
||||
* All three survive the drive chapters, where the same geometry is two
|
||||
* hundred pixels of verge and a shallow embankment falling away to the
|
||||
* fields — which is what US-101 through the Salinas Valley actually looks
|
||||
* like out of a car window.
|
||||
*/
|
||||
const bermMaterial = batch.material("deck", berm[identityIndex] ?? berm[0]!);
|
||||
batch.add("freeway:berm", roadRibbonGeometry(path, 3.5, -0.02), bermMaterial);
|
||||
const batterMaterial = batch.material("deck", batter);
|
||||
for (const side of [-1, 1] as const) {
|
||||
batch.add(
|
||||
"freeway:embankment",
|
||||
bandGeometry(path, side * 1.75, -0.02, side * 2.3, -0.11),
|
||||
batterMaterial,
|
||||
);
|
||||
}
|
||||
for (const side of [-1, 1] as const) {
|
||||
batch.add(
|
||||
"freeway:shoulder",
|
||||
@@ -384,11 +480,32 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
|
||||
const guardPath = offsetPath(path, side * 1.27);
|
||||
/**
|
||||
* One tubular segment per draped sample, and three sides, not five.
|
||||
*
|
||||
* `drapePath` already samples every leg 52 times — roughly a point per
|
||||
* kilometre along a 700 km corridor — so a tube at `length * 2` was
|
||||
* subdividing an interval nothing curves inside. Between the four
|
||||
* guardrails and the four median walls that was 82,000 triangles, an
|
||||
* eighth of the whole board's budget, spent on two objects that are a
|
||||
* hairline from the state camera and a thin grey rail from the chase
|
||||
* camera. Halving the segments and dropping two radial sides gives back
|
||||
* 55,000 of them, which is what pays for the state's relief and its
|
||||
* cities; a five-sided 25 mm-radius tube and a three-sided one are the
|
||||
* same handful of pixels at both distances this corridor is ever seen
|
||||
* from.
|
||||
*
|
||||
* It also stopped casting. A shadow caster is drawn twice, and what this
|
||||
* one casts is the shadow of a fifty-metre pipe standing in for a
|
||||
* half-metre rail — a fiction lying a few centimetres from the object
|
||||
* that threw it, at both distances this corridor is seen from. The sign
|
||||
* posts still cast, because a sign standing clear of the road is the one
|
||||
* roadside object whose shadow tells you where the ground is.
|
||||
*/
|
||||
batch.add(
|
||||
"freeway:outer-guardrail",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), 0.025, 3, false),
|
||||
guardMaterial,
|
||||
{ cast: true },
|
||||
);
|
||||
}
|
||||
// Low concrete median walls keep both carriageways visually independent.
|
||||
@@ -396,7 +513,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
|
||||
batch.add(
|
||||
"freeway:median-barrier",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), 0.055, 3, false),
|
||||
barrierMaterial,
|
||||
);
|
||||
}
|
||||
@@ -404,12 +521,19 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
// The matrices are collected across every corridor and committed to one
|
||||
// `InstancedMesh` after the loop, because two corridors' worth of the same
|
||||
// 0.018 m box is two draw calls for something nobody can resolve.
|
||||
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
|
||||
//
|
||||
// Every sixth sample rather than every second: 2,296 boxes were 27,500
|
||||
// triangles for studs the chase camera sees a dozen of at a time and the
|
||||
// state camera cannot resolve at all. At this stride they are still about
|
||||
// one every seven kilometres of a road whose lanes are two kilometres wide,
|
||||
// and 20,000 triangles come back to the relief and the cities.
|
||||
const reflectorStride = 6;
|
||||
const reflectorPoints = path.filter((_, index) => index % reflectorStride === 0);
|
||||
for (const pointIndex of reflectorPoints.keys()) {
|
||||
const point = reflectorPoints[pointIndex];
|
||||
if (!point) continue;
|
||||
for (const offset of [-0.81, -0.47, 0.47, 0.81]) {
|
||||
const shifted = offsetPath(path, offset)[pointIndex * 2] ?? point;
|
||||
const shifted = offsetPath(path, offset)[pointIndex * reflectorStride] ?? point;
|
||||
dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z);
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.scale.setScalar(1);
|
||||
|
||||
Reference in New Issue
Block a user