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:
dejvino
2026-08-09 07:23:58 +02:00
co-authored by Claude Opus 5
parent 3787350d4a
commit 759399c53c
3 changed files with 239 additions and 23 deletions
+37 -18
View File
@@ -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