1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/blocks.ts
T
karti 8fb85cd2e5 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>
2026-08-21 23:43:14 -07:00

389 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The built city, plus the handful of landmarks placed by hand.
*
* Two things make this read as a city rather than as noise:
*
* - **Buildings step along a block lattice** in each district's own grid
* bearing. An earlier version rejection-sampled uniformly inside each
* district and it looked like rubble, because a city is not a Poisson
* process. In San Francisco the lattice also reproduces the 46° between
* the grid north of Market and the grid south of it, and Market Street
* falls out as a seam rather than having to be drawn.
* - **Buildings stand on the terrain.** Every base is sampled from
* `world.groundAt`, so Nob Hill's low-rises tower over taller blocks in the
* flats below — which is true of that city, and which a flat map gets
* exactly backwards.
*
* Everything is instanced: one draw call for the whole city.
*/
import * as THREE from "three";
import type { District } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
/** Lot size in scene units, and how many lots sit between cross-streets. */
const LOT = 0.42; // ~40 m at SF's scale
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
/**
* The ground size at which a lot stops being a city block.
*
* `LOT` is fixed in **scene units**, which is right — the three boards are 1003,
* 308 and 284 units across and are looked at from comparable standoffs, so a
* lot that is legible on one is legible on the others. But it means a lot is
* 40 m in San Francisco, 164 m in Southern California and **806 m** on the
* statewide California board, and two things that are correct for a city are
* wrong at 806 m:
*
* - **The street lattice.** Skipping every fourth row and column leaves 44%
* of a district unbuilt. At 40 m those gaps are streets. At 806 m they are
* eight-hundred-metre voids, and Los Angeles came out as a chequerboard of
* separate white squares rather than as a city — the one thing the state
* board most needed it to be. Above the threshold the lots tile, and the
* district's `coverage` roll does all the thinning, which reads as urban
* fabric because its gaps are irregular.
* - **Casting shadows.** A shadow caster pays for itself twice, once in the
* shadow pass and once in the beauty pass. A 40 m building on a San
* Francisco hillside throws a shadow you can see; a 60 m building on a
* 806 m lot throws about one pixel, and paying a second pass over a hundred
* thousand triangles for it — on the board with the tightest budget of the
* three — is not a trade anyone would make on purpose. Lambert still shades
* the four walls, which is all the state camera can resolve anyway.
* - **The underside.** Same measurement, same argument; see the geometry.
*
* 260 m is comfortably above Southern California's 164 and far below
* California's 806, so neither of the detailed boards changes at all.
*/
const NEIGHBOURHOOD_LOT_METRES = 260;
const PALETTES = {
downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>;
/**
* How commercial each palette's buildings are, 0..1.
*
* Read only by `nightlights.ts`, and the reason a night city looks like a city
* rather than like a uniform field of dots: an office floor is a continuous
* band of large windows with half of them left on all night, and a house is two
* small warm rectangles that go out. The number is the same fact the palette
* already encodes, which is why it is derived from it rather than authored
* again per district.
*/
const COMMERCIAL = {
downtown: 1,
residential: 0.12,
industrial: 0.45,
} satisfies Record<District["palette"], number>;
/**
* The name of the per-instance attribute `createBlocks` leaves on its geometry:
* `[commercial, seed]`.
*
* A vertex attribute rather than a field on `userData` because the only
* consumer is a shader, and this puts the data where the GPU already wants it.
* `createBlocks` writes it because `createBlocks` is what knows which district
* a given instance came out of; nothing else can recover that from the mesh.
*/
export const FACADE_ATTRIBUTE = "aFacade";
/** An independent stream for the facades; see where it is drawn from. */
const FACADE_SEED = 20_261;
interface Box {
x: number;
z: number;
y: number;
w: number;
d: number;
h: number;
rot: number;
color: THREE.Color;
commercial: number;
}
/**
* A named building's claim on the anonymous city scatter, in scene units.
*
* Circles are deliberately conservative. Anonymous buildings rotate with
* their districts and named glyphs rotate with their streets; a circle is the
* one cheap overlap test that cannot leave a corner poking through, and there
* are only a handful of reservations to test.
*/
export interface BuildingReservation {
x: number;
z: number;
radius: number;
}
function polygonBounds(poly: [number, number][]) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const [lat, lng] of poly) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
return { minLat, maxLat, minLng, maxLng };
}
export function createBlocks(
world: World,
reservations: readonly BuildingReservation[] = [],
): THREE.InstancedMesh {
const boxes: Box[] = [];
let seedBase = 1337;
// See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
// them are about how much ground a lot covers rather than about which board
// this is — a self-hoster's pack gets the same treatment without naming it.
const lotMetres = LOT * world.metresPerUnit;
const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES;
for (const district of world.city.districts) {
const rand = seededRandom(seedBase);
seedBase += 7919;
const palette = PALETTES[district.palette];
const commercial = COMMERCIAL[district.palette];
const angle = district.gridAngle;
const coverage = district.coverage ?? 0.88;
// The district's extent in scene space, padded so the rotated lattice
// still covers the corners once it is turned.
const b = polygonBounds(district.polygon);
const corners = [
world.project(b.minLat, b.minLng),
world.project(b.minLat, b.maxLng),
world.project(b.maxLat, b.minLng),
world.project(b.maxLat, b.maxLng),
];
const xs = corners.map((c) => c[0]);
const zs = corners.map((c) => c[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const steps = Math.ceil(reach / LOT);
for (let iu = -steps; iu <= steps; iu++) {
if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
for (let iv = -steps; iv <= steps; iv++) {
if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
const x = cx + u * cos - v * sin;
const z = cz + u * sin + v * cos;
const [lat, lng] = world.unproject(x, z);
if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
/**
* Land and parks come off the lattice; the district polygon does not.
*
* The three tests used to be three exhaustive polygon walks each, and
* on the Bay Area's 186k candidate lots that was 240 ms of the boot's
* main thread — the largest single item in it, spent re-deriving what
* the heightfield Worker had already worked out for the whole board.
* `isLandSampled` and `inParkSampled` read that answer and fall through
* to the exact test only on a lattice cell that straddles the edge, so
* the coastline and the park boundaries are still decided by the
* polygons; see `World.sampled`. Same 186k lots, 19 ms.
*
* The district stays exact because there is no mask for it: districts
* are not a property of the lattice, they overlap, and San Francisco
* declares fifty-two of them. It is also the cheap one — the polygons
* are a dozen vertices and the bounding box rejects almost everything,
* which is 47 ms against the coastline's 235.
*/
if (!world.isLandSampled(lat, lng)) continue;
if (world.inParkSampled(lat, lng)) continue;
if (rand() > coverage) continue; // yards, car parks, the unbuilt lots
// Cubed, so tall buildings stay rare and the skyline keeps a
// silhouette instead of turning into a plateau.
const roll = rand();
const isTower = rand() < district.towerChance;
const t = isTower ? 0.55 + roll * 0.45 : roll ** 3;
const heightM = district.minHeight + t * (district.maxHeight - district.minHeight);
// Towers take several lots. A 260 m tower on one 40 m lot is a 25:1
// needle, and downtown came out looking like a bed of nails; real
// towers assemble their sites, and Salesforce Tower is about 5:1.
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
const width = LOT * fill;
const depth = LOT * fill * (0.85 + rand() * 0.3);
const rotation = angle + (rand() - 0.5) * 0.03;
const color = new THREE.Color(
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
);
/**
* Keep a named building legible instead of drawing it inside a random
* one at the same address.
*
* Every random property is drawn before this test. The sequence is
* load-bearing: skipping those calls for one reserved lot would
* reshuffle every anonymous building after it and turn a local change
* into a whole new skyline.
*/
const radius = Math.hypot(width, depth) / 2;
if (
reservations.some(
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < radius + reserved.radius,
)
) {
continue;
}
boxes.push({
x,
z,
y: world.groundAt(lat, lng),
w: width,
d: depth,
h: world.metres(heightM),
rot: rotation,
color,
// A tower is an office whatever district it landed in.
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
});
}
}
}
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
if (!lotIsABlock) {
/**
* Drop the underside at neighbourhood scale.
*
* `BoxGeometry` lays its groups out px, nx, py, ny, pz, nz, two triangles
* each, so the six indices from 18 are the floor. A building sits on the
* ground and that face is never visible — except on San Francisco's
* steepest blocks, where a 40 m lot spanning a 3.6×-exaggerated hillside can
* leave a corner clear of the terrain and you would see straight through the
* hole. So this is tied to the same measurement as the street lattice and
* the shadow pass: at 806 m to the lot the ground under a building is flat
* to within a hair and nothing can get beneath it, and one sixth of the
* board's largest triangle consumer goes back to the budget.
*/
const index = geometry.getIndex();
if (index) {
const kept = Array.from(index.array).filter((_, at) => at < 18 || at >= 24);
geometry.setIndex(kept);
}
}
// The per-instance facade data, drawn from a stream of its own.
//
// The obvious place for the seed is inside the placement loop, next to every
// other `rand()` — and putting it there would have been a mistake, because a
// scatter's draw sequence is load-bearing. One extra call shifts every
// subsequent draw, and the whole city would have rebuilt itself the first
// time anyone lit a window. A second stream costs nothing, is just as
// deterministic across reloads, and leaves the skyline exactly where it was.
const windows = seededRandom(FACADE_SEED);
const facade = new Float32Array(boxes.length * 2);
boxes.forEach((b, i) => {
facade[i * 2] = b.commercial;
facade[i * 2 + 1] = windows();
});
geometry.setAttribute(FACADE_ATTRIBUTE, new THREE.InstancedBufferAttribute(facade, 2));
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks";
mesh.castShadow = lotIsABlock;
mesh.receiveShadow = true;
const matrix = new THREE.Matrix4();
const quat = new THREE.Quaternion();
const pos = new THREE.Vector3();
const scl = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
boxes.forEach((b, i) => {
pos.set(b.x, b.y, b.z);
quat.setFromAxisAngle(up, b.rot);
scl.set(b.w, b.h, b.d);
matrix.compose(pos, quat, scl);
mesh.setMatrixAt(i, matrix);
mesh.setColorAt(i, b.color);
});
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
return mesh;
}
/**
* The named buildings. Separate meshes because the eye goes looking for these
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
* Hill, the red tripod on the ridge — and a box would not do.
*/
export function createLandmarks(
world: World,
reservations: readonly BuildingReservation[] = [],
): THREE.Group {
const group = new THREE.Group();
group.name = "landmarks";
for (const lm of world.city.landmarks) {
const [x, z] = world.project(lm.lat, lm.lng);
// A richer stable glyph at this address supersedes the coarse landmark
// primitive. Drawing both would hide the glyph inside the old mesh and
// leave two different sources claiming the same real building.
if (
reservations.some(
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < reserved.radius,
)
) {
continue;
}
const base = world.groundAt(lm.lat, lm.lng);
const h = world.metres(lm.height);
const w = lm.footprint * world.lngScale * 2;
let geo: THREE.BufferGeometry;
switch (lm.shape) {
case "pyramid":
geo = new THREE.ConeGeometry(w * 0.72, h, 4);
geo.translate(0, h / 2, 0);
geo.rotateY(Math.PI / 4);
break;
case "cylinder":
geo = new THREE.CylinderGeometry(w * 0.6, w * 0.68, h, 20);
geo.translate(0, h / 2, 0);
break;
case "tower":
geo = new THREE.CylinderGeometry(w * 0.42, w * 0.62, h, 4);
geo.rotateY(Math.PI / 4);
geo.translate(0, h / 2, 0);
break;
default:
geo = new THREE.BoxGeometry(w, h, w);
geo.translate(0, h / 2, 0);
}
const mesh = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: lm.color ?? 0xaebac6 }),
);
mesh.position.set(x, base, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.landmark = lm;
group.add(mesh);
}
return group;
}