/** * 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; /** * 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; /** * 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; }