Districts that look like places, and buildings worth steering by
Every building was the same grey box between four and twelve metres tall. From the air the whole world was one texture; from the driver's seat every junction looked like the junction before it, and "learn the map" was not something the map supported. The world is now cut into districts, and a district agrees with itself: its buildings share a palette, a rough height and a silhouette and vary only slightly around them. That agreement is the point - it is what makes crossing into the next one a visible event, and it is the only reason the exceptions read at all. Because the occasional building ignores its district completely: much taller, lighter, a different shape. Those are the things you actually navigate by. They clear every ordinary roofline on the map, since a landmark you cannot see from outside its own district is no use in the only place you need it. Colour rides on the instance rather than the material, so a district costs no extra draw calls. Colliders stay boxes whatever the silhouette says - both the physics and the line-of-sight grid already assume a building is solid to its own footprint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3787350d4a
commit
759399c53c
@ -138,29 +138,48 @@ export function createScene(model: WorldModel): SceneView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Obstacles ---
|
// --- Obstacles ---
|
||||||
// Buildings never move, so they go into one instanced draw call. With a few
|
// Buildings never move, so they go into instanced draw calls: one per
|
||||||
// hundred of them, a mesh each was a real cost in a frame budget that also has
|
// silhouette. With a few hundred of them, a mesh each was a real cost in a
|
||||||
// to fit a shadow pass.
|
// frame budget that also has to fit a shadow pass.
|
||||||
const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 });
|
//
|
||||||
|
// 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 crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 });
|
||||||
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
|
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 transform = new THREE.Matrix4();
|
||||||
const quaternion = new THREE.Quaternion();
|
const quaternion = new THREE.Quaternion();
|
||||||
blocks.forEach((o, i) => {
|
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(
|
transform.compose(
|
||||||
new THREE.Vector3(o.x, o.height / 2, o.z),
|
new THREE.Vector3(o.x, o.height / 2, o.z),
|
||||||
quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), o.yaw),
|
quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), o.yaw),
|
||||||
new THREE.Vector3(o.width, o.height, o.depth),
|
new THREE.Vector3(o.width, o.height, o.depth),
|
||||||
);
|
);
|
||||||
blockMesh.setMatrixAt(i, transform);
|
mesh.setMatrixAt(i, transform);
|
||||||
|
mesh.setColorAt(i, instanceColour.setHSL(o.hue, o.saturation, o.lightness));
|
||||||
});
|
});
|
||||||
blockMesh.instanceMatrix.needsUpdate = true;
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
scene.add(blockMesh);
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
scene.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
// Crates can be shoved around, so they stay individual meshes that get synced.
|
// Crates can be shoved around, so they stay individual meshes that get synced.
|
||||||
const crates = model.obstacles
|
const crates = model.obstacles
|
||||||
|
|||||||
@ -168,3 +168,56 @@ describe('repair', () => {
|
|||||||
expect(previousCeiling).toBeGreaterThan(0);
|
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]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
154
src/sim/world.ts
154
src/sim/world.ts
@ -9,6 +9,15 @@ import { makeRng, randRange, type Rng } from '../core/rng';
|
|||||||
import { distanceToRoad, generateRoads, type RoadNetwork } from './roads';
|
import { distanceToRoad, generateRoads, type RoadNetwork } from './roads';
|
||||||
import { frontAxis } from './regions';
|
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 {
|
export interface Obstacle {
|
||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
@ -19,6 +28,13 @@ export interface Obstacle {
|
|||||||
yaw: number;
|
yaw: number;
|
||||||
/** Static blocks are terrain-like; dynamic ones can be shoved around. */
|
/** Static blocks are terrain-like; dynamic ones can be shoved around. */
|
||||||
kind: 'block' | 'crate';
|
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 {
|
export interface WorldModel {
|
||||||
@ -31,6 +47,8 @@ export interface WorldModel {
|
|||||||
spawn: { x: number; z: number };
|
spawn: { x: number; z: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
const SPAWN_CLEARANCE = 22;
|
const SPAWN_CLEARANCE = 22;
|
||||||
/** Scenery this close to tarmac would read as a roadblock. Heat places those. */
|
/** Scenery this close to tarmac would read as a roadblock. Heat places those. */
|
||||||
const ROAD_CLEARANCE = 2.5;
|
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. */
|
/** Share of plots left empty, so the maze has through-routes to find. */
|
||||||
const BLOCK_GAP_CHANCE = 0.05;
|
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<number, District>();
|
||||||
|
|
||||||
|
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 {
|
export function generateWorld(seed: number, count = 1800, extent = 400): WorldModel {
|
||||||
const rng: Rng = makeRng(seed);
|
const rng: Rng = makeRng(seed);
|
||||||
const roads = generateRoads(seed, extent);
|
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.
|
// far more plots than the budget allows.
|
||||||
const blockTarget = Math.round(count * 0.85);
|
const blockTarget = Math.round(count * 0.85);
|
||||||
const cells = Math.floor((extent * 2) / BLOCK_CELL);
|
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 row = 0; row <= cells && obstacles.length < blockTarget; row++) {
|
||||||
for (let col = 0; col <= cells && obstacles.length < blockTarget; col++) {
|
for (let col = 0; col <= cells && obstacles.length < blockTarget; col++) {
|
||||||
if (rng() < BLOCK_GAP_CHANCE) continue;
|
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 = {
|
const candidate: Obstacle = {
|
||||||
x: -extent + col * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER),
|
x,
|
||||||
z: -extent + row * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER),
|
z,
|
||||||
width: randRange(rng, BLOCK_MIN, BLOCK_MAX),
|
width: footprint(),
|
||||||
height: randRange(rng, 4, 12),
|
height,
|
||||||
depth: randRange(rng, BLOCK_MIN, BLOCK_MAX),
|
depth: footprint(),
|
||||||
// Only a slight lean: enough to look unplanned, not enough that a
|
// Only a slight lean: enough to look unplanned, not enough that a
|
||||||
// rotated corner spills into the neighbouring plot.
|
// rotated corner spills into the neighbouring plot.
|
||||||
yaw: randRange(rng, -0.25, 0.25),
|
yaw: randRange(rng, -0.25, 0.25),
|
||||||
kind: 'block',
|
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,
|
// No mutual-overlap test here: the lattice already guarantees separation,
|
||||||
// and testing it by circumscribed radius would reject every neighbour,
|
// 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),
|
depth: randRange(rng, 0.8, 1.4),
|
||||||
yaw: rng() * Math.PI,
|
yaw: rng() * Math.PI,
|
||||||
kind: 'crate',
|
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);
|
if (clearsTheMap(candidate) && clearsOtherObstacles(candidate)) obstacles.push(candidate);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user