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/test/render/structuresBatching.test.ts
T
karti f81d5218d4 feat: SFO, LAX, both bridges, a road that reads as a road, and aeroplanes that move
**The aeroplanes were stuck because the wire could not describe motion.**
`WireAircraft` carried position, altitude and heading and nothing else, so the
client could only interpolate between the last two observations: every aircraft
replayed a segment it had already flown, arrived at the newest known point, and
sat still until the next poll landed five to fifteen seconds later. The feed had
the missing numbers the whole time and the server threw them away. Sampled live
from `api.adsb.lol/v2/point` while writing this — `gs` ground speed, `track`,
`baro_rate`, plus `r` registration and `t` type designator. They are on the wire
now in SI, aircraft dead-reckon along their own track and correct toward the
truth when a fix lands, and the click card an anonymous visitor gets says
"B739 · N68834". That last part is the enrichment FR24 was wanted for, obtained
from an ODbL feed we may actually republish.

**SFO and LAX exist.** A new `engine/airports.ts` composes an airport from
runways, taxiways, aprons and terminal masses, with markings drawn on a canvas
rather than modelled; the pattern of the runways is what the eye recognises from
altitude, long before any building does. SFO is the two crossing pairs on the bay
fill; LAX is the four parallels either side of the terminal horseshoe, plus the
Southland fields under the traffic that actually flies there.

**The Golden Gate and the Bay Bridge are those bridges.** One kit in
`engine/bridges.ts`, because a suspension bridge is a repeated tower, a catenary
main cable, a series of hangers and a deck — so both are configurations rather
than two private implementations. The Bay Bridge carries the real 2013 topology:
two suspension towers west of Yerba Buena, one east, then the piered causeway.
The freeway stopped being a wireframe overlay and became a road, with shoulders,
a median, and lane markings as texture.

**And the board got faster while all of that landed.** California went from
728,744 triangles and 562 draw calls to 391,169 and 371 — headroom from 2.8% to
47.8%. The Bay Area board is 506,550 triangles lighter than before this work.
Two things paid for it:

- `transmission: 0.08` on the aircraft cockpit glass. three.js runs a full
  transmission backdrop pass whenever any rendered material has transmission
  above zero, re-drawing the entire opaque scene into a second target every
  frame — so the city was rendering terrain, every block and every freeway piece
  TWICE. Measured by patching only that number in a copy of the built bundle:
  703,267 tris / 562 draws with it, 398,608 / 371 without. The material was
  already `transparent: true, opacity: 0.86`, so it was buying nothing.
- Flatness-adaptive terrain LOD, which collapses runs of lattice cells wherever
  the height and colour agree with the quad replacing them. The coastline is
  provably untouched — a patch collapses only when every point is on land and
  agrees about `park` — and a test asserts the drawn footprint matches the
  cell-by-cell area to 1e-6. `createTerrain` got *faster*: the vertices it stops
  emitting cost more than the flatness scan costs to run.

**The budget now watches the boards this was built on.** There was no `bay-area`
or `socal` cell — so SFO, LAX and both bridges all landed in frames nothing
measured, which is how a cap you do not have looks from the inside. Both are in
the matrix now with caps set from measurement, and the rationale lives in the
harness because JSON cannot hold a comment.

Two known defects ship with this, both recorded in TODO.md rather than hidden:

- `bay-area.desktop` drops about one frame in twenty (p50 16.7, p95 33.3). It is
  desktop-only and not fill rate — mobile runs the same 2.26 M triangles at a
  comparable pixel count and holds 16.7 flat — which points at the 2048 shadow
  map desktop uses against handheld's 1024. Measured at the commit before this
  work with the same harness: identical p95 33.3. Pre-existing, and invisible
  until the cell existed.
- The aeroplane glyph is still about 1.5x the Golden Gate's main span at chapter
  zoom, down from 2.5x. `GLYPH_MAX_SCALE` is 52 because the raw scale at the far
  end of the California orbit is 51.0 at a 60-degree field of view, and 26 —
  tried first — put the glyph at 0.0123 of the frame against the 0.012 where the
  wings stop resolving. The real fix is to clamp against the camera's focus
  distance rather than the aircraft's, which is a signature change.

Tests 1020 -> 1137. Typecheck, build, eight budget cells, no-binaries,
provenance, zero-config boot, dependency licences, arena source hashes and the
UI smoke across two viewports and two access tiers all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:06:12 -07:00

252 lines
10 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 draw-call reclaim in `engine/structures.ts`.
*
* These are budget tests, and they are here because the budget is the reason
* anything else in this build can be made to look better. The city measured 616
* draw calls against a cap of 650 while the office spent 8% of its triangle
* allowance: indoors quality is nearly free, outdoors it is not, and every call
* this module gives back is one the exterior Model X and the aircraft get to
* spend. `scripts/performance-budget.mjs` is the real gate, but it needs a
* built bundle, a browser and eleven seconds a cell — these run in
* milliseconds and fail on the line that caused the regression.
*
* Two invariants, and they are the two ways this file has gone wrong before:
*
* 1. **A material is per colour, not per call site.** `roadRibbon` used to
* close over `new THREE.MeshLambertMaterial({ color })`, so twelve
* identical asphalt decks were twelve materials — and two meshes that do
* not share a material can never be merged, whatever else you do.
* 2. **Geometry is merged per bucket.** A suspension bridge used to arrive as
* about thirty-four meshes of one colour.
*
* There is a third thing the tests below quietly guard, and it is the one that
* fails silently: `mergeGeometries` returns `null` when the attribute sets
* disagree, so a ribbon without UVs sitting in a bucket beside a tube that has
* them loses the whole bucket. Asserting on merged vertex counts is what catches
* that, because a dropped bucket looks exactly like a very efficient one.
*/
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { createBridge, createBridges, createRoads } from "../../engine/structures.ts";
import type { Bridge, City, Road } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";
/**
* The smallest thing `structures.ts` will accept: San Francisco's projection,
* ground at sea level, and no heightfield.
*
* A real `World` builds one, which is 0.53M lattice points and a couple of
* seconds — none of which any assertion here depends on. The *scale* does
* matter and used to be a tidy 20 units per degree with metres straight
* through: `bridges.ts` sizes its members against `metresPerUnit` and compares
* span lengths against tower heights, so a world whose projection and whose
* `metres()` disagree gives a bridge nothing real to be checked against.
*/
const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
function flatWorld(city: Partial<City>): World {
const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
return {
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
project(lat: number, lng: number): [number, number] {
return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
},
groundAt(): number {
return 0;
},
metres(value: number): number {
return (value / METRES_PER_UNIT) * 3.6;
},
metresPerUnit: METRES_PER_UNIT,
} as unknown as World;
}
const GOLDEN_GATE: Bridge = {
name: "golden-gate",
path: [
[37.806, -122.4756],
[37.8199, -122.4783],
[37.8324, -122.4796],
],
towers: [
[37.8104, -122.4767],
[37.8249, -122.4787],
],
deckHeight: 67,
towerHeight: 227,
sag: 0.45,
color: 0xc0553b,
};
/** Everything painted the bridge's own colour, which is everything but the road. */
function structureOf(root: THREE.Object3D): THREE.Mesh | undefined {
return meshes(root).find((mesh) => mesh.name !== "bridge:roadway");
}
function meshes(root: THREE.Object3D): THREE.Mesh[] {
const found: THREE.Mesh[] = [];
root.traverse((object) => {
if (object instanceof THREE.Mesh) found.push(object);
});
return found;
}
function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
const set = new Set<THREE.Material>();
for (const mesh of meshes(root)) {
if (Array.isArray(mesh.material)) for (const material of mesh.material) set.add(material);
else set.add(mesh.material);
}
return set;
}
// ---- Bridges ---------------------------------------------------------------
test("a suspension bridge is two draw calls: structure and roadway", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
// The spec's number was six for a bridge painted one colour throughout, and
// it is two now for a reason worth stating: the deck of a bridge is a road,
// and painting it International Orange with the towers is most of why the
// Golden Gate used to read as a red line. Everything structural is still one
// material — anything above two is a part that fell out of a bucket.
const distinct = materialsIn(bridge);
assert.equal(distinct.size, 2, `the bridge holds ${distinct.size} materials, not two`);
assert.equal(meshes(bridge).length, 2, "the bridge did not merge into two meshes");
assert.ok(structureOf(bridge), "nothing in the bridge is painted the bridge's colour");
});
test("merging kept every part of the bridge", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
const merged = structureOf(bridge);
assert.ok(merged);
// The arithmetic, because a bucket that failed to merge comes out as one
// *part* of a bridge and otherwise looks entirely healthy. Two towers are six
// frusta, two fenders and five struts each — 13 boxes, 24 vertices apiece —
// the deck box is four strips over about fifty stations, and the cables and
// their hangers are the rest. Two thousand is well under the floor.
const vertices = merged.geometry.getAttribute("position").count;
assert.ok(vertices > 2_000, `the bridge merged down to ${vertices} vertices`);
// The merge only happens because every part carries the same attributes.
for (const name of ["position", "normal", "uv"]) {
assert.ok(merged.geometry.getAttribute(name), `the merged bridge has no ${name}`);
}
assert.ok(merged.geometry.getIndex(), "the merged bridge lost its index");
// A 227 m tower is the tallest thing on the board; it has to cast.
assert.equal(merged.castShadow, true);
});
test("the bridge is still shaped like a bridge after the merge", () => {
const world = flatWorld({});
const bridge = createBridge(world, GOLDEN_GATE);
const merged = structureOf(bridge);
assert.ok(merged);
merged.geometry.computeBoundingBox();
const box = merged.geometry.boundingBox;
assert.ok(box);
// Towers to 8.66 units — 227 m at 94 m per unit and 3.6× exaggeration — with
// the deck and its cables below. Baking the transforms into the geometry is
// where a merge goes wrong: a part that lost its translation collapses onto
// the origin and the box stops matching.
const top = world.metres(227);
assert.ok(Math.abs(box.max.y - top) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
// Tower feet and pier footings go under the surface on purpose; nothing
// should be a whole tower's worth of them.
assert.ok(box.min.y > -1, `something sank to ${box.min.y.toFixed(2)}`);
assert.ok(box.max.z - box.min.z > 20, "the bridge has no span");
});
test("two bridges are three draw calls, not sixty-eight", () => {
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
// Two structures — different colours, so genuinely two materials — and one
// roadway, because both decks are the same asphalt and one batch covers the
// whole board. That batch still cannot outlive the build: `createScene()
// .dispose()` walks the scene disposing every material it finds, and a cache
// that survived would hand the next board a disposed one.
assert.equal(meshes(group).length, 3);
assert.equal(materialsIn(group).size, 3);
const names = meshes(group).map((mesh) => mesh.name).sort();
assert.deepEqual(names, ["bay-bridge", "bridge:roadway", "golden-gate"]);
});
// ---- Roads -----------------------------------------------------------------
test("identical roads share one material and one mesh", () => {
const street: Road = {
kind: "street",
width: 0.1,
path: [
[37.7, -122.4],
[37.75, -122.42],
[37.8, -122.45],
],
};
const group = createRoads(flatWorld({ roads: [street, street, street] }));
// Three streets, one colour: one draw call. Before the cache this was three
// materials and three meshes, and it scaled with the pack.
assert.equal(materialsIn(group).size, 1);
assert.equal(meshes(group).length, 1);
const merged = meshes(group)[0];
assert.ok(merged);
// All three really are in there — three drapes of the same path.
const vertices = merged.geometry.getAttribute("position").count;
assert.ok(vertices > 100, `three roads merged to ${vertices} vertices`);
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
});
test("a freeway carries its markings as texture, not as a second ribbon", () => {
const freeway: Road = {
kind: "freeway",
width: 0.14,
path: [
[37.7, -122.4],
[37.9, -122.45],
],
};
const group = createRoads(flatWorld({ roads: [freeway] }));
// One ribbon, one call. The median stroke used to be a second draped ribbon
// in a second colour; verge, shoulders, edge lines and median now live in the
// surface texture, which costs no triangles and reads as a road rather than
// as a line on a map.
assert.equal(meshes(group).length, 1);
assert.equal(materialsIn(group).size, 1);
});
test("a road is drawn wider than its carriageway, and streets less so", () => {
const shape = (kind: Road["kind"]): number => {
const road: Road = {
kind,
width: 0.2,
path: [
[37.7, -122.4],
[37.9, -122.4],
],
};
const mesh = meshes(createRoads(flatWorld({ roads: [road] })))[0];
assert.ok(mesh);
mesh.geometry.computeBoundingBox();
const box = mesh.geometry.boundingBox;
assert.ok(box);
return box.max.x - box.min.x;
};
// The widening is the graded right-of-way the texture paints, and a freeway
// gets more of it than a boulevard does. Both are wider than the authored
// 0.2, which is the carriageway alone.
const street = shape("street");
const freeway = shape("freeway");
assert.ok(street > 0.2 && street < 0.35, `a street came out ${street.toFixed(3)} wide`);
assert.ok(freeway > street, "a freeway is no wider than a street");
});