diff --git a/src/render/scene.ts b/src/render/scene.ts index a158539..159efa2 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -138,29 +138,48 @@ export function createScene(model: WorldModel): SceneView { } // --- Obstacles --- - // Buildings never move, so they go into one instanced draw call. With a few - // hundred of them, a mesh each was a real cost in a frame budget that also has - // to fit a shadow pass. - const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 }); + // Buildings never move, so they go into instanced draw calls: one per + // silhouette. With a few hundred of them, a mesh each was a real cost in a + // frame budget that also has to fit a shadow pass. + // + // Colour is per instance rather than per material, which is what lets a + // district agree with itself without needing a material per district. + const blockMat = new THREE.MeshStandardMaterial({ roughness: 0.9 }); const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 }); const boxGeo = new THREE.BoxGeometry(1, 1, 1); + // Unit-sized like the box, so one scale vector drives every shape. Both are + // centred on the origin and one metre tall, so `height / 2` places them all. + const roundGeo = new THREE.CylinderGeometry(0.5, 0.5, 1, 12); + const spireGeo = new THREE.ConeGeometry(0.72, 1, 4).rotateY(Math.PI / 4); - const blocks = model.obstacles.filter((o) => o.kind === 'block'); - const blockMesh = new THREE.InstancedMesh(boxGeo, blockMat, Math.max(1, blocks.length)); - blockMesh.castShadow = true; - blockMesh.receiveShadow = true; const transform = new THREE.Matrix4(); const quaternion = new THREE.Quaternion(); - blocks.forEach((o, i) => { - transform.compose( - new THREE.Vector3(o.x, o.height / 2, o.z), - quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), o.yaw), - new THREE.Vector3(o.width, o.height, o.depth), - ); - blockMesh.setMatrixAt(i, transform); - }); - blockMesh.instanceMatrix.needsUpdate = true; - scene.add(blockMesh); + const instanceColour = new THREE.Color(); + const blocks = model.obstacles.filter((o) => o.kind === 'block'); + + for (const [shape, geometry] of [ + ['box', boxGeo], + ['round', roundGeo], + ['spire', spireGeo], + ] as const) { + const of = blocks.filter((o) => o.shape === shape); + if (of.length === 0) continue; + const mesh = new THREE.InstancedMesh(geometry, blockMat, of.length); + mesh.castShadow = true; + mesh.receiveShadow = true; + of.forEach((o, i) => { + transform.compose( + new THREE.Vector3(o.x, o.height / 2, o.z), + quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), o.yaw), + new THREE.Vector3(o.width, o.height, o.depth), + ); + mesh.setMatrixAt(i, transform); + mesh.setColorAt(i, instanceColour.setHSL(o.hue, o.saturation, o.lightness)); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + scene.add(mesh); + } // Crates can be shoved around, so they stay individual meshes that get synced. const crates = model.obstacles diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index cf41af9..0f05371 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -168,3 +168,56 @@ describe('repair', () => { expect(previousCeiling).toBeGreaterThan(0); }); }); + +describe('districts and landmarks', () => { + const world = generateWorld(2024); + const blocks = world.obstacles.filter((o) => o.kind === 'block'); + const near = (o: { x: number; z: number }, radius: number) => + blocks.filter((b) => Math.hypot(b.x - o.x, b.z - o.z) < radius && b !== o); + + it('builds a district that agrees with itself', () => { + // Take an ordinary building and look at its immediate neighbours: within a + // district the roofline should be close to level. Uniformity is what makes + // the exceptions readable, so it is the thing worth pinning down. + const ordinary = blocks.filter((o) => !o.landmark); + let compared = 0; + let agreed = 0; + for (const o of ordinary) { + for (const other of near(o, 30)) { + if (other.landmark) continue; + compared++; + if (Math.abs(other.height - o.height) < o.height * 0.6) agreed++; + } + } + expect(compared).toBeGreaterThan(200); + expect(agreed / compared).toBeGreaterThan(0.9); + }); + + it('puts up the occasional building that ignores all of that', () => { + const landmarks = blocks.filter((o) => o.landmark); + // Rare enough to mean something, common enough to navigate by. + expect(landmarks.length).toBeGreaterThan(5); + expect(landmarks.length / blocks.length).toBeLessThan(0.06); + + const median = [...blocks.map((o) => o.height)].sort((a, b) => a - b)[ + Math.floor(blocks.length / 2) + ]!; + for (const l of landmarks) { + // It has to clear the roofline it interrupts, or it is not a landmark, + // it is just a building. + expect(l.height).toBeGreaterThan(median * 1.8); + } + }); + + it('uses more than one silhouette and more than one colour', () => { + expect(new Set(blocks.map((o) => o.shape)).size).toBeGreaterThan(1); + expect(new Set(blocks.map((o) => o.hue.toFixed(2))).size).toBeGreaterThan(3); + }); + + it('keeps colour and shape reproducible from the seed', () => { + const again = generateWorld(2024).obstacles.filter((o) => o.kind === 'block'); + expect(again.map((o) => [o.shape, o.hue, o.height])).toEqual( + blocks.map((o) => [o.shape, o.hue, o.height]), + ); + }); +}); diff --git a/src/sim/world.ts b/src/sim/world.ts index 0bca4d0..34864f2 100644 --- a/src/sim/world.ts +++ b/src/sim/world.ts @@ -9,6 +9,15 @@ import { makeRng, randRange, type Rng } from '../core/rng'; import { distanceToRoad, generateRoads, type RoadNetwork } from './roads'; import { frontAxis } from './regions'; +/** + * The silhouette a building is drawn with. + * + * Colliders stay boxes whatever this says — a building is an obstacle, not + * precision geometry, and being solid to its own footprint is what both the + * physics and the line-of-sight grid already assume. + */ +export type BuildingShape = 'box' | 'round' | 'spire'; + export interface Obstacle { x: number; z: number; @@ -19,6 +28,13 @@ export interface Obstacle { yaw: number; /** Static blocks are terrain-like; dynamic ones can be shoved around. */ kind: 'block' | 'crate'; + shape: BuildingShape; + /** Colour as HSL, each 0..1. Kept as numbers so `sim` owes three.js nothing. */ + hue: number; + saturation: number; + lightness: number; + /** The odd building that broke with its district. Something to steer by. */ + landmark: boolean; } export interface WorldModel { @@ -31,6 +47,8 @@ export interface WorldModel { spawn: { x: number; z: number }; } +const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); + const SPAWN_CLEARANCE = 22; /** Scenery this close to tarmac would read as a roadblock. Heat places those. */ const ROAD_CLEARANCE = 2.5; @@ -48,6 +66,96 @@ const BLOCK_MAX = 10; /** Share of plots left empty, so the maze has through-routes to find. */ const BLOCK_GAP_CHANCE = 0.05; +// --- Districts ------------------------------------------------------------ + +/** + * Why the map needed districts at all. + * + * Every building was the same grey box between four and twelve metres tall, so + * from the air the whole world was one texture and from the driver's seat there + * was nothing to steer by — every junction looked like the junction before it, + * and "learn the map" was not something the map supported. + * + * So: the world is cut into districts, and a district agrees with itself. Its + * buildings share a colour, a rough height and a silhouette, and vary only + * slightly around them, which is what makes an area read as *an* area rather + * than as scatter. Crossing into the next one is then a visible event. + * + * On top of that, the occasional building ignores its district entirely: much + * taller, a different shape, a colour that does not belong. Those are the + * things you actually navigate by — "left at the tower" — and they only work + * because everything around them is uniform. + */ +const DISTRICT_SIZE = 130; +/** Chance a plot ignores its district and becomes something you steer by. */ +const LANDMARK_CHANCE = 0.022; +/** How much taller a landmark stands than the roofline it interrupts. */ +const LANDMARK_HEIGHT = { min: 2.2, max: 3.8 }; +/** + * Floor on a landmark's height, metres. + * + * Scaling a district's own base height is what makes a landmark read as an + * exception *locally*, but a district whose norm is five metres produced + * "landmarks" shorter than an ordinary building two streets over. A landmark + * has to clear every ordinary roofline on the map or it cannot be steered by + * from outside its own district, which is the only place you need it. + */ +const LANDMARK_FLOOR = 18; +/** How far an ordinary building drifts from its district's height. */ +const HEIGHT_VARIATION = 0.14; + +const SHAPES: BuildingShape[] = ['box', 'box', 'box', 'round', 'spire']; + +/** + * The palettes a district can be built in. Muted and close together on purpose: + * these have to read as different parts of one town under one sky, not as + * coloured zones on a diagram. + */ +const PALETTES: Array<{ hue: number; saturation: number; lightness: number }> = [ + { hue: 0.09, saturation: 0.16, lightness: 0.5 }, // sand + { hue: 0.58, saturation: 0.07, lightness: 0.44 }, // slate + { hue: 0.05, saturation: 0.26, lightness: 0.38 }, // brick + { hue: 0.12, saturation: 0.1, lightness: 0.56 }, // bone + { hue: 0.3, saturation: 0.06, lightness: 0.35 }, // moss concrete + { hue: 0.62, saturation: 0.13, lightness: 0.3 }, // cold grey +]; + +interface District { + palette: (typeof PALETTES)[number]; + height: number; + shape: BuildingShape; + /** Footprint the district builds to, before per-plot jitter. */ + footprint: number; +} + +/** + * Everything a district agrees on, derived from its own seed so the same world + * always builds the same town. Memoised: a thousand plots would otherwise + * rebuild the same handful of districts a thousand times. + */ +function districts(seed: number, extent: number): (x: number, z: number) => District { + const perSide = Math.ceil(((extent + 60) * 2) / DISTRICT_SIZE); + const cache = new Map(); + + return (x, z) => { + const col = Math.floor((x + extent + 60) / DISTRICT_SIZE); + const row = Math.floor((z + extent + 60) / DISTRICT_SIZE); + const index = row * perSide + col; + const known = cache.get(index); + if (known) return known; + + const rng = makeRng((seed ^ 0x9e3779b9) + index * 0x85ebca6b); + const made: District = { + palette: PALETTES[Math.floor(rng() * PALETTES.length)]!, + height: randRange(rng, 5, 11), + shape: SHAPES[Math.floor(rng() * SHAPES.length)]!, + footprint: randRange(rng, BLOCK_MIN, BLOCK_MAX), + }; + cache.set(index, made); + return made; + }; +} + export function generateWorld(seed: number, count = 1800, extent = 400): WorldModel { const rng: Rng = makeRng(seed); const roads = generateRoads(seed, extent); @@ -100,19 +208,49 @@ export function generateWorld(seed: number, count = 1800, extent = 400): WorldMo // far more plots than the budget allows. const blockTarget = Math.round(count * 0.85); const cells = Math.floor((extent * 2) / BLOCK_CELL); + const districtAt = districts(seed, extent); for (let row = 0; row <= cells && obstacles.length < blockTarget; row++) { for (let col = 0; col <= cells && obstacles.length < blockTarget; col++) { if (rng() < BLOCK_GAP_CHANCE) continue; + const x = -extent + col * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER); + const z = -extent + row * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER); + const district = districtAt(x, z); + const landmark = rng() < LANDMARK_CHANCE; + + // An ordinary building agrees with its neighbours and differs only + // slightly. That agreement is the whole point: it is what makes the odd + // building that ignores it legible as a landmark from three streets away. + const height = landmark + ? Math.max( + LANDMARK_FLOOR, + district.height * randRange(rng, LANDMARK_HEIGHT.min, LANDMARK_HEIGHT.max), + ) + : district.height * randRange(rng, 1 - HEIGHT_VARIATION, 1 + HEIGHT_VARIATION); + const footprint = () => + Math.min( + BLOCK_MAX, + Math.max(BLOCK_MIN, district.footprint * randRange(rng, 0.85, 1.15)), + ); + const candidate: Obstacle = { - x: -extent + col * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER), - z: -extent + row * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER), - width: randRange(rng, BLOCK_MIN, BLOCK_MAX), - height: randRange(rng, 4, 12), - depth: randRange(rng, BLOCK_MIN, BLOCK_MAX), + x, + z, + width: footprint(), + height, + depth: footprint(), // Only a slight lean: enough to look unplanned, not enough that a // rotated corner spills into the neighbouring plot. yaw: randRange(rng, -0.25, 0.25), kind: 'block', + shape: landmark ? SHAPES[Math.floor(rng() * SHAPES.length)]! : district.shape, + hue: (district.palette.hue + randRange(rng, -0.012, 0.012) + (landmark ? 0.06 : 0) + 1) % 1, + saturation: clamp01(district.palette.saturation + randRange(rng, -0.03, 0.03)), + // Landmarks are lighter as well as taller, so they carry at the range + // the fog leaves you. Height alone disappears into a grey skyline. + lightness: clamp01( + district.palette.lightness + randRange(rng, -0.05, 0.05) + (landmark ? 0.16 : 0), + ), + landmark, }; // No mutual-overlap test here: the lattice already guarantees separation, // and testing it by circumscribed radius would reject every neighbour, @@ -131,6 +269,12 @@ export function generateWorld(seed: number, count = 1800, extent = 400): WorldMo depth: randRange(rng, 0.8, 1.4), yaw: rng() * Math.PI, kind: 'crate', + shape: 'box', + // Crates are not part of any district; they are the same tan everywhere. + hue: 0.08, + saturation: 0.46, + lightness: 0.44, + landmark: false, }; if (clearsTheMap(candidate) && clearsOtherObstacles(candidate)) obstacles.push(candidate); }