city-walker
Project-specific guide to this 3D city-walker viewer — scene architecture, the shadow recipe and its decision history, terrain/water/vegetation/surface rendering, the EPSG/world coordinate frame, the
SKILL.md.claude/skills/city-walker/SKILL.md
GitHubCity Walker — rendering, data & QA
Stylized client-side 3D walk through a German city from its Land's open
geodata (Dresden is the reference). Read
AGENTS.md first for the high-level map; this skill is the deep reference for
how it's built. For what maps to what — the source→feature provenance
diagram, the transformation ledger (active / experimental / planned /
discontinued), and how to render other locations — see docs/
(docs/README.md). When you add, change, or drop a data→feature
transformation, update docs/transformations.md + docs/data-flow.md as part of
the change (Definition of Done in docs/README.md). docs/rendering.md is
the human-readable map of the frame (scene graph, the attribute → visual
codebook, budgets, boot sequence), docs/data-pipeline.md the bake/build
reference, and docs/adr/ the record of why the load-bearing choices below
were made — add an ADR when you change one.
Scene architecture
app/_components/create-app.ts is the spine: it builds the renderer, scene, a
PerspectiveCamera, a world group (Z-up data frame, rotated −90° about X to
Y-up), reads the tileset's extras (CRS, recenter offset, tile bounds), starts
streaming the site and the animation loop, and returns a CityWalkHandle
(the imperative API the React HUD calls) once the spawn tile's buildings and
any of its terrain levels are on screen — the first frame. The fixed
lamp-light pool and the fog floor are created before the first frame and
retargeted/lowered as tiles land. The data layers are not wired into
bootApp by hand: they start on their first switch-on (lazyFeature,
data-layers-feature.ts) and hear the spine through its hooks
(feature-seam.ts: frame, stream change, the look store, the scene's
clock — a watched value a late starter reads first); a tile's traffic
slot waits for their builder. The scene environment
(scene-environment.ts, not three's scene.environment) holds what the
sun and the date move — the night node, the season watch, the clock —
and a dressing's trees follow its season from birth to release
(followEnvironment); the traffic slot follows the look's layer from its hang. The picture styles and Modell are still
wired by hand. The data layers' code stays in the scene chunk: the first import() chunk that imports three.js costs the first
load ≈ 33 KB gz (three leaves the scene's hoisted scope), more than the
data layers moved (ADR 0040) — measure before splitting one off.
The renderer is three r187's WebGPURenderer — its WebGPU backend, or
its WebGL2 backend where the browser has no WebGPU (?gpu=webgl2 forces it:
scene-profile.ts forceWebGL; the preflight is gpu-support.ts). One
path: every material is a TSL node material, there is no GLSL, no
onBeforeCompile, no WebGLRenderer
(ADR 0027).
The viewer imports classes and node materials from three/webgpu, TSL from
three/tsl, addons from three/addons/… — never plain three
(lib/city/ stays three-free). See "Node materials" below before touching
a material.
Streaming (tile-stream.ts, ADR 0024): the build bakes the site into an
OGC 3D Tiles tileset (lib/city/tileset.ts): per site tile the buildings
(refine ADD) over the terrain at two levels (a 512² grid, geometric error
40 m, replaced near the camera by an error-bounded TIN of the native DGM,
ADR 0030). Content is glTF (meshopt, quantised,
pre-gzipped .glb.gz). 3DTilesRendererJS loads and unloads it by
screen-space error with an LRU cache; the sun's shadow camera is a second
camera while the sun is up, so casters outside the view stay loaded (by
night it is taken off the stream: it draws no shadow) — at a resolution
of its own (SHADOW_STREAM_PX, lib/city/shadow-fit.ts), not the map's:
the renderer's error for an orthographic camera ignores distance, so at
2048 px it refined the terrain under the whole frustum; at a desktop's
128 px it refines the fine level under the eye-level frustum only (ADR
0047). This camera is a square of its own: it draws no shadow, the rings do, and on a phone it streams nothing (shadowTilesStream, plan 071:
the tiles it kept were tiles in use the memory governor cannot free).
On a phone the view's own stream also ends at 2.2 km (streamFarFor;
not through Modell's dolly zooms, which back the camera kilometres away). A dressing plugin builds
what a tile carries in processTileModel and frees it in disposeTile; the
heavy part (vegetation incl. the street-tree cadastre and the OSM hedges,
lamps, rails on the fine terrain level; its walls
and stairs are baked into the glTF) waits
behind the HUD's gate and is built one tile at a time behind the streaming
chip. A terrain level that takes over from its tile's other level on
screen is loaded only once its dressing is built and compiled
(handsOver): the renderer keeps the old level, trees and all, drawn
until then, so a change of level never blinks the trees away. The
dressing is compiled off the scene and hung at the renderer's
load-model — never before it records the content's materials, which it
disposes with the tile. Every tile change re-renders the shadow map. The layers:
city-layer.ts—dressCityon a building tile: one glTF mesh per tile with a per-vertex feature id (EXT_mesh_features); the per-object table (EXT_structural_metadata: tint, heights, roof colour, glow, roughness, the demolish tree) is packed into a float texture the clay node reads per vertex withtextureLoadby thefeatureIdattribute (lib/city/city-mesh.ts). Demolish = drop the object's building tree from the index buffer and rebuild the tile's BVH (you can't hide one building in a batched mesh). Picking/collision usethree-mesh-bvhon every loaded tile — its BVH built when a ray first reaches the tile's box (ensureCityBvh; within 400 m of the camera in idle time), never a ray walking a tile without one (three-mesh-bvh's brute-force fallback). No CityJSON reaches the browser.terrain-layer.ts—dressTerrainon a terrain tile: the glTF mesh gets the land-cover material and hangs the water and mist sheets. The fine level is a TIN of the native 1 m DGM1 (±0.15 m,scripts/bake-terrain-tin.ts;extras.tin):heightAtreads its triangles through a bucket index (lib/city/terrain-tin.tsTinIndex), and the water gets an up-facing normal twin. Nothing is burned into it; the wall ribbons in itswallsnode snap to the measured step at bake time (lib/city/wall-snap.ts, ADR 0030). The coarse level is the 512² grid with the wall breaklines burned in (ADR 0030), heights read back from its first n·n vertices. Both are shaped under OSM stairs and terraces at bake time (scripts/bake-tiles.ts, ADR 0030); study and numbers in the ledger's "Terrain TIN" entry.landcover-splat.ts— paints the class raster with the one palette (lib/city/landcover.ts) into the colour splat on the GPU (ADR 0010).water-layer.ts— sheets over the terrain geometry, masked by the splat's alpha (water coverage), animated normal wobble.vegetation-layer.ts— instanced trees (Instances; rows + DOM1 canopy + the laser-scan extra trees + the cadastre's trunks and broadleaf crowns, passed in as precomputedTreeInstances), chunked for culling. Lives in the Y-up frame (a tile's content root), never inside the rotatedworldgroup itself.tree-inventory-layer.tsdraws only the cadastre's reshaped silhouettes (flame / cone / dome) and vetoes canopy trees inside its crowns (not in forest/copse);tile-stream.ts(buildTileVegetation) joins both into the tile's one vegetation control.low-vegetation-layer.tsdraws every hedge (OSM's and the DLM's), its lumps pushed out by a world-space noise so pieces agree. The trunk's foot bulges into roots and turns to earth (buildTrunkMaterial);tree-foot.tsadds the ground round it (pits, leaves, the crown's shade glaze, worn earth, roots in the paving, grates, guard hoops — the one opaque mesh) asInstancessets on the trunks' own attribute objects (no bytes of their own, nothing counted twice), their materials per tile with the class and edge rasters and the light in their slots;renderOrderorders the blended sets (rings before earth, so pits merge); the crowns' LOD hides a far chunk's sets.shader-chunks.ts— the shared TSL pieces: the node types (F,V2,V3,V4,Live),dataXY()/dataPosition()(glTF positions are quantised, so the data frame comes from world space: world (x, y, z) = data (x, −z, y)),rasterUv(xy, origin, size)(the corner auniform(Vector2), so every tile builds the same shader), and what the chunks compute with: a raster's texels (texelSize,texelAt,loadTexel,byteOf),floorMod, the hashes and the noise (gdHash,gdNoise,gdNoiseSlope,gdBand,hash21).instancing.ts—Instances, the instanced set every layer uses instead ofInstancedMesh, and its nodes (instancePosition,instanceTint,instanceMatrix,instanceFloat).three-utils.ts—sceneMaterial(key, make)(scene-wide node materials), the dispose helpers,sceneShared, the texture byte tracker.height-fog.ts—SceneFog: distance fog, the valley pool (Talnebel; its depth from the site's ground relief,lib/city/valley-fog.ts, set increate-app.tsfrom the tileset'sextras.ground) and the site-edge haze as onescene.fogNode.sun-rig.ts— directional light + shadow camera, the TSLSkyMeshdome (tempered, with a horizon haze band), hemisphere fill, fog colour by time of day.post-stack.ts— the scene pass into its own target and three's nodeRenderPipeline: GTAO, DoF, the picture style, SMAA, depth grading, vignette, paper grain — built per tier (postProfileFor, ADR 0047): a phone builds no DoF, antialiases with FXAA inside the last pass (fxaa.ts: a perceptual luma over the linear frame), drawsBeforeAAonly while a style is on; no tier warms more than the outline's programs (a picture style builds on its first frame); the AO smoothing and the outline's mask and blur are one-byte targets, the outline drawn only while something is asked; the nameplate (nameplate-layer.ts, the asked thing's name in the scene) is an overlay drawn onto the canvas after the frame (setOverlay), its colours sRGB, its atlas held only while asked. The picture styles (Comic, Film noir, Sin City, Papier;lib/city/render-style.ts) are one node,stylize-effect.ts(ADR 0034), in a styled pipeline pair the default pastel never draws: ink from the second difference of1/z(zero on planes; relative tow= silhouette, relative to slope = fold), tone bands / monochrome curves on the colour; the mode and the pen are uniforms. Read depth only at integer texel radii around a texel centre. Sin City, Papier, Strich and Schwarzplan touch the scene (paper-scene.ts, the row'spaperScenekind); Papier, for example, sets a per-framescene.overrideMaterial(white, flat-shaded node material; three carries each material'spositionNodeover) with sprites and see-through sheets hidden, restored after the render. QA the styles on a handful of views (aerial, roof sea, half-long building shot, street, river) — lite at pixel ratio 0.5 hides the stroke widths, so judge them at ≥ 1. Flicker and crawling (a still view that changes, a step that flips pixels) is measured, not judged: theflicker-probeskill.visual-style.ts— the one building style: opaque archviz clay + facade detail (tint, Boden-Verlauf, Höhenlinien, Traufkante, Streiflicht, dusk glow) as aMeshStandardNodeMaterial, always opaque (the see-through slider and itsalphaHashbuild are gone too). The old ghost/standard styles are gone.- The map's own marks: the ferry wakes of
riverside-layer.ts(landing stages, groynes, ferries), faded in with height bymap-overlay.ts. No text anywhere in the scene or HUD (plan 032's street names were removed; 🗃️ in the ledger). - More dressing:
tram-layer.ts(tracks in their bed, the contact wire sagging between spans and arms, stop signs;lib/city/tram.ts),fence-layer.ts(the fences' material; baked into the fine terrain),furniture-layer.ts(benches … plan 030's advertising columns, signals, hydrants, clocks, drinking fountains, bus-stop signs),crown-season.ts(per-day crown colour and bare stipple,lib/city/tree-season.ts). - Terms of the terrain's colour node (node functions called in order):
ground-detail.ts(firstsmoothPaths: a path's edge near the camera cut on the B-spline-smoothed class texels, which sand, slabs and urban green then follow — never a class or paving texel read NEAREST for a tint),sport-ground.ts,road-markings.ts(lib/city/markings.ts),cultivated-layer.ts(colony gardens, vine rows;lib/city/cultivated.ts); andsky-light.ts(sky view →aoNode, far horizon →receivedShadowNode;lib/city/skyview.ts, the raster shared with the buildings throughtile-rasters.ts, which hands every content root its rasters by kind: the kinds table holds a raster's look, tier twin and gate). - Buildings:
lib/city/building-tint.ts(tint, storey height, roofs, at bake time),lib/city/small-buildings.ts(the scan's sheds as boxes, and the canopy points they veto at build time),scripts/measured-roofs.ts(a LoD2 roof that misses DOM1 — the free-form roofs of complex buildings — replaced by whatpipeline/bake/roofs.pymeasures: flat levels, and faces where it slopes — the flanks of a pitched roof section by section, planes, an extruded profile, or on a landmark the scan's surface (roof_planes.py) — each an error-bounded TIN cut to its outline, the outlines turned onto the footprint's walls (regular_cover); ADR 0036). - Beyond LoD2 (plan 050, ADR 0036): geometry is added only where DOM1
measures it and OSM (or, for a landmark's roof relief, Wikidata)
names it — never from the surface model alone (cranes).
structures.py→lib/city/structures.tslathes chimneys/towers/masts, extrudes missing buildings and builds a landmark's relief from its measured height field (reliefMesh: thegridon 1 m, heights above the host's highest LoD2 roof, −1 outside, lightly smoothed in the bake; corners average the patch cells around them, each edge wall down to its cell'sfloor— the LoD2 roof under it, so a tower folded into a pitched nave stands on the slope — no bottom face; DOM voids below the LoD2 roof are filled in the bake — a truncated spire rises to its tip; the old stacked slabs made a stepped pyramid, and a relief withoutgridbuilds nothing);appendGapStructuresputs them in the city mesh withsource= 2 (a relief copies its host's row, so demolish takes it along). A tower, lighthouse or water tower under a LoD2 roof is LoD2's own (BUILT_TOWERS) — no second column. OSMbuilding:material/building:colour/roof:colourreach the clay only throughosmColourTint(hue kept, saturation ≤ 0.45, lightness clamped) and the palette families; glass (flag 4; also where the street photos read a glass front, ADR 0060,facade_glass.py→lib/city/photo-glass.ts) and metal (8) are a cool tint, lower roughness and, on glass, a Fresnel sky sheen (visual-style.ts:osmColour,clayGlow) — no panes, no textures (the window grid is vetoed). Landmarks (flag 16) come from Wikidata (landmarks.py, cached at fetch time), the site's twelve in the tileset'sextras.landmarks(one per building drawn) → the HUD's Orte, merged with the authored vantages (lib/city/places.ts: a landmark a vantage names or looks at is folded into it) →landmarkVantage. - Modell (plan 055, ADR 0044;
M): the city in parallel projection.model-rig.tsowns the view while it is on (pan / zoom about the pointer / turn / tilt, the presets, the dolly zoom in and out of the perspective camera);model-camera.tsis three's ortho camera with a shear for the Militärperspektive, 20 km back along the view; the pure half islib/city/model-view.ts. The post passes never hold the real camera:view-lens.tsis a stand-in whose matrices are copied from the camera drawing the frame, plusortho(which depth→distance formula, a per-pixel select) andequivalent(the distance a 55° camera would show the picture from, for far-field fades). Rays for any camera:view-ray.ts. The Schnitt (model-cuts.ts,visual-style.tssetClaySection: the clay two-sided, back faces the poché, a ground-profile strip) and the Ausschnitt (aClippingGrouparoundworld, a plinth — shown only oncePostStack.holdCuthas compiled and holds both sides of every build, since a switch of the group rebuilds the city's programs in the frame; its shadows are not clipped). The export (image-export.ts) tiles the frustum withsetViewOffset, one tile per animation frame — SMAA and GTAO areNodeUpdateType.FRAMEnodes and render once per frame. The planner styles Strich and Schwarzplan are rows of the style table and kinds of the paper swap (paper-scene.ts:paper,line,figure; the ground'spaperGroundOn= 1, 2, 3). Each terrain level carries its trees, in every mode: the fine one all of them, the coarse one a fixed third (drawnCoarse, a hash of where each stands), √3 wider, baked by the build from the fine level's placement (crowns_<t>.crw.gz,coarse-crowns-layer.ts) — the coarse level shows past 2.5 m/px and wherever the memory governor or a load leaves the fine one out, at any scale, so never tie a layer's trees to Modell's scale; anything else on the fine level vanishes there at once. A two-finger twist turns the ground with the fingers (twistedTurn). minimap.tsx,city-walk.tsx(HUD),poc-debug.ts(window.__poc).
Constants live in the layer files and are the source of truth; values quoted below may drift — confirm in code.
Node materials — how a look is written, and the traps
A look term is a TSL node in a slot of a node material
(MeshStandardNodeMaterial, MeshBasicNodeMaterial, PointsNodeMaterial,
NodeMaterial): colorNode, normalNode (view space), positionNode
(local, before the model matrix), emissiveNode, roughnessNode,
opacityNode, aoNode (indirect light only), receivedShadowNode (the
shadow a fragment receives), maskNode (discard — the shadow pass honours
it), castShadowPositionNode (what the shadow pass draws instead of
positionNode). A fixed look strength is a constant node named after
its term, beside it in its owner (TINT, WINDOWS, GROUND_DETAIL, …;
plan 073); a slider (only Nebel is left) or anything else that moves
is a uniform() node every tile's materials share; writing its
.value retunes live, no rebuild. A shared Vector3
passed to uniform(v) stays a live reference. Module-level uniform nodes
that a setter writes (fountainTime, mapAltitude) are fine; what the
sun and the date move (sceneNight, fountainsOn, clockMinutes) lives
in scene-environment.ts, written by setSun through the environment —
a layer reads it and adds no setter. A scene-wide material drawn only
after dark wears drawnAtNight (its visible, no rebuild); a new compile
path shows the night-only materials under what it compiles for its call (withNightMaterialsShown), or
such a material builds in the first dusk frame. Inside If/branches read textures with an explicit .level(0) or
textureLoad (no implicit derivatives in non-uniform control flow).
What three keys a node build (TSL → WGSL/GLSL, on the main thread) by, and therefore what keeps builds out of frames:
- Never
InstancedMesh. Up to r186 three put an InstancedMesh's uuid in the render object's cache key (its instancing node binds that mesh's matrix buffer), so a tile's hundreds of vegetation cells, furniture models and lamp parts were hundreds of identical builds, in the scene and in the shadow pass. (r187 shares InstancedMesh builds; every layer's nodes, shared buffers and anchors are written forInstances, and moving back buys nothing measured.)Instances(instancing.ts) is a plainMeshover anInstancedBufferGeometryview; the matrices travel as named instance-stepped attributes (iMat0…iMat3, coloursiColor), so every set with the same material and attribute layout shares one build. The material applies the transform itself:positionNode = instancePosition()(orinstancePosition(local)to bend the vertex first, as the crown sway does) and the colour (colorNode = materialColor.mul(instanceTint())). Extra per-instance floats: anInstancedBufferAttributeon the set's geometry, read withinstanceFloat(name). Instancestraps.drawCount, nevercount— acountabove one puts the uuid back in the key.instanceTints, neverinstanceColor— three multiplies the diffuse colour of any object with aninstanceColorproperty by an InstancedMesh-only varying the set never writes, and the set renders black. After writing matrices setinstanceMatrix.needsUpdate = trueand callcomputeBoundingSphere(). Sharing: assign another set'sinstanceMatrix/instanceTints. Freeing a set is freeing its geometry (disposeObject3D).- Materials without per-tile data are
sceneMaterial(key, make)(three-utils.ts): one material, one build for the whole site (crowns, trunks, hedges, lamps, monuments and fountains, furniture, tram masts and wires, vine rows, ferry wakes, the splat pass). They carryuserData.shared;disposeMaterial/disposeObject3Dskip them and the last app frees them (retainSceneMaterials). Materials with per-tile textures (terrain, water, clay) stay per tile — builds per tile material are cheap (none over 20 ms in the spike); one shared material with textures bound per draw (onObjectUpdate) was tried and failed (grey ground, untinted clay). - The scene renders top-level into its own target (
post-stack.ts), and theRenderPipelinereads that target's colour and depth; the styles' swap and dressing wrap exactly that render. A build is keyed by the target's attachments and the kind of camera (r187; up to r186 also by the call depth, so a scenepass()nested in the pipeline never found whatcompileAsyncprepared). The frame names every target it draws into and ends on the canvas, never on the one it found set: a compile in flight sets the target it was called with again after each build it resumes (a three r187 bug:docs/rendering.md, "Workarounds for upstream bugs"). - Compile ahead.
PostStack.compile(object)builds and compiles each drawable against the scene target (shown and unculled for the call); tiles await it inprocessTileModel, dressings compile one representative per material + draw kind + attribute layout (compileRepresentativesintile-stream.ts), both capped atCOMPILE_WAIT_MS. The crowns a date change may switch to are warmed by stand-ins (crownWarmup). One compile at a time: everycompileAsyncgoes throughcompileInTurn(compile-lanes.ts) — r187 keeps a compile's state (target, call depth, render context) on the renderer around every await inside it, and two in flight resume into each other's state and throw (an upstream bug, worked around until a release fixes it:docs/rendering.md, "Workarounds for upstream bugs"). Within one call three compiles the GPU pipelines side by side, so a whole set of stand-ins goes in one call (PipelineAnchors.anchorAll: the Ausschnitt's holds, Modell's warm-up) — never real tile objects, which a tile may free mid-call. A queued compile may start frames later: a drawable whose tile left while it waited compiles nothing (left, post-stack.ts), and a tile's own material anchored as itself leaves its set when the tile frees it (onOwnedGone, pipeline-anchors.ts). Modell's parallel camera has builds of its own (r187: a parallel view's light comes from one fixed direction):PostStack.warmViewbuilds them while the dolly zoom in runs and for every tile landing while Modell shows;holdCutholds for Modell's camera.compileAsyncprimes the main pass only: the shadow pass's pipelines for new casters still build in the frame that first draws them, and the WebGL2 backend compiles synchronously — the price of public API. Measure with a flight before adding machinery. - Different graphs are different builds. An optional raster that did
not load leaves its term out of the terrain's colour node, so that tile
builds apart; that is fine, but do not branch a graph on per-tile
values — put them in uniforms. Toggling a graph-level flag
(
side,alphaHash, a new slot) needsmaterial.needsUpdate = true. - Fog is
scene.fogNode(height-fog.tsinstallSceneFog): every material gets it;material.fog = falseopts out (the river mist, the splat pass). Never fog a material by hand. - No custom depth materials. The shadow pass draws
castShadowPositionNode ?? positionNodeand discards bymaskNode: a swaying crown casts rigidly throughcastShadowPositionNode = instancePosition(); the bare crown thins its shadow through its mask. - Vertex formats. WebGPU has no 1-component 8/16-bit vertex formats
(and the WebGL2 backend rejects them against TSL's float attribute): the
feature id is baked as FLOAT (
scripts/tile-glb.ts); the roof flag rides in the windows' snorm16_FACADE(−1 in its third,lib/city/windows.tsFACADE_ROOF). 3-component snorm8/16 positions and normals are fine (three pads them). - Eight vertex buffers a draw on WebGPU (the device's default limit;
three requests no more). Each non-interleaved attribute is one buffer,
positionandnormalincluded (an interleaved instance matrix is one). A ninth invalidates the pipeline and the mesh silently draws nothing on a real GPU, while the WebGL2 backend (sixteen) — the headless e2e's — draws it fine: the traffic flows vanished that way with ten single-float attributes. Pack scalars into vec4s and test the count. - Points are 1 px on WebGPU, whatever their size. The lamp halos are
billboard quads: an
Instancesset under aPointsNodeMaterial. - No tone mapping. The output node is
renderOutput(…, NoToneMapping, SRGBColorSpace); the look was tuned without tone mapping (the old composer never applied the renderer's ACES). ACES washed the clay out. - Public API only: no prototype patches, no
_-prefixed members, no wrapping renderer internals. Subclassing a node material's documentedsetup*is the last resort; prefer a slot. The spike's render guard, shared-instancing patch and render-context override were rejected. - Things a node material cannot reach, found in the port: the sun's shadow
map from inside another material (three's public
shadow(light)builds a second shadow node with its own map);SkyMesh's own cloud horizon fade (inside its colour node) — the haze band covers it. - 3DTilesRendererJS loads fine under
WebGPURenderer; its fade and overlay plugins patch GLSL and stay unused.
More traps, each found the hard way:
- A
ClippingGroupswitch rebuilds everything under it. three keeps one render object per drawable for both sides and keys its build by the clipping, freeing the side a switch leaves; a compile that does not walk through the group builds the wrong side. Modell's Ausschnitt shows only oncePostStack.holdCutholds both sides (aloneUnder), and leaves its shadows unclipped. - The clay is two varyings short of WebGPU's limit: of sixteen
inter-stage variables,
front_facingtakes one (and the Ausschnitt's clip distances one on the vertex side), and the clay passes thirteen of the other fifteen. A new value per building or per vertex rides in a varying it already passes; past fifteen the buildings vanish on WebGPU while WebGL2 still draws them (ADR 0010). - A TSL
selectis an if/else in the shader. three builds each arm in its own scope: what an arm builds first is built again where it is used outside, and a derivative or texture read first built in an arm runs in non-uniform control flow (three turns WGSL's uniformity check off, so nothing says so). In a per-pixel graph usemix/stepwith safe denominators; a select whose arms are constants is harmless. - A
Loop's body is a scope too. A node shared with the rest of the graph and first built inside the loop is assigned there, so only when the loop runs: the water's foam loop (no foam line on the tile: no iteration) left the waves' position at 0, and the Elbe lay flat on every tile without a weir beside a rippling one. Take what a loop reads of the fragment into atoVar()before it (foamAt, water-layer.ts).
Unit tests assert the node setup (a node material, the expected slots set,
the uniforms shared, instances written), not shader strings; node materials
and their node graphs construct without a GPU under bun test.
Coordinate frame (critical)
Data is ETRS89/UTM, Z-up — EPSG:25833 (zone 33) for Saxony and Berlin,
EPSG:25832 (zone 32) for Hamburg, Bavaria and NRW; the provider sets it
(sites/providers.ts, Provider.epsg) and every path takes it from the
site. world is rotated −90° about X so data-Z (elevation) → scene-Y. Conversions (lib/city/ground-clamp.ts):
world.x = epsgX − cx world.z = −(epsgY − cy) world.y = elevation
(cx, cy) = recenter offset from the spawn tile's CityJSON loader matrix,
taken at bake time and carried in the tileset's root extras; every tile is
baked against it so they align. The glTF content is Y-up and the renderer's
up-axis turn cancels world's rotation, so a tile's content root is the Y-up
frame. Trap: vegetation uses Y-up coords, so it never goes into the
rotated world group itself (else the −90° applies twice and trees launch
into the sky). Tiles are <zone><EEE>_<NNNN>_2<suffix> — the zone from the
CRS, the suffix the provider's (_sn, _nw, _by, _hh, _be); Dresden's
spawn tile is 33412_5656_2_sn, Hamburg's tiles look like 32564_5932_2_hh.
Shadows — the recipe and why (this took many rounds)
PCFShadowMap is soft: three's ShadowFilterNode (PCFShadowFilter)
spreads a 5-tap Vogel disk, rotated per pixel, by shadow.radius * texel
(node_modules/three/src/nodes/lighting/ShadowFilterNode.js). Default
radius is 1 ≈ hard. (PCFSoftShadowMap has been deprecated since r182.)
The map is four concentric rings (sun-rings.ts,
lib/city/shadow-rings.ts, ADR 0009; the numbers are in
docs/rendering.md):
| Setting | Value | Why |
|---|---|---|
renderer.shadowMap.type |
PCFShadowMap |
only soft option that doesn't ring (VSM "corduroy"-rings on grazing ground) |
rings (SHADOW_RING_HALVES) |
half-sizes 80, 240, 720, 2160 m | texels fine near, coarse far, whatever the view's or the sun's direction: a map warped over the view failed at a low sun looking along its light |
ring edge (shadowMapSizeFor) |
2048 (phones 900; smaller at a raised safety level, ADR 0047) | four of 900² are less than a phone's old 2048²: they pay for what the wide rings' casters hold |
shadow.radius |
2.5 (RING_PCF_RADIUS) |
five samples over 5 texels leave gaps: a thin shadow shows them as a stipple |
shadow.normalBias |
0 | a positive normalBias offsets the flat ground sample → bright peter-panning contact strip. Safe at 0 because terrain doesn't cast and solids cast via back faces (lit faces never self-acne) |
| casters' depth | the material itself | no custom depth materials: the shadow pass draws castShadowPositionNode ?? positionNode and honours maskNode |
| bias | 1.1 of a ring's texels, per ring, 2.5 m at most | residual cleanup |
terrain castShadow |
false | a casting terrain self-shadows into triangle/staircase acne at grazing sun; ground only receives |
| the map's colour target | none (RingShadowNode, set as sun.shadow.shadowNode: a depth array in a count: 0 target, r187) |
three's shadow map carries a colour texture of the shadow's mapType beside the depth, as large again, though only coloured shadows read it; keep renderer.shadowMap.transmitted off |
shadow.autoUpdate |
false | a ring redraws when it has drifted a fifth of its half-size, the sun moves or a caster changes (invalidateShadows()), one a frame, the longest waiting first, the wide two at a distance in time |
Traps of the rings:
- One camera for all rings, moved per ring (
placeRing): three keeps its state per drawable and camera. Never a camera per ring. - A ring's matrix changes only as it is drawn. Read a ring's map through
ringLookup(sunRig.shadowMap.rings);sun.shadow.matrixandsun.shadow.camerahold whichever ring was drawn last. - Coarse rings do not draw thin casters. A new thin caster calls
markThinCaster:tiny(the finest ring alone: a coarser filter turns it into a smear) orslender(the first three: thin but long, a lamp post). - Plates and captures wait for the rings:
handle.shadowBusy(). They redraw one a frame. - Ask only what a change touches.
invalidateShadows()redraws all four rings; a tile's change goes throughinvalidateShownTiles(only the rings it can fall on, only if what shows changed), a change near the eye throughsunRig.invalidateShadow(undefined, finest). renderer.clear()on a depth array throws on the WebGL2 backend; the render's own clear is what draws a layer clean.- The stream's square (
lib/city/shadow-fit.ts,sunRig.shadowCamera) only loads tiles:fitShadowRadiusandshadowFocusAheadhave no say over what is shadowed.
The far field: baked horizon + sky view (plan 033)
What the frustum cannot reach is baked: pipeline/bake/skyview.py writes,
from the committed DGM1 + LoD2 only, a sky-view factor (≈2 m) and a
horizon (≈8 m, 16 azimuths, two bands: occluders 80–1 500 m and
8–80 m away; eight layers of one array texture). The ground takes the
horizon as its material's receivedShadowNode, folded into the sun's
shadow by min(shadow, lit) (sky-light.ts applyGroundLight;
groundLitMaterial for what is baked into the fine terrain: kerbs, fences,
stairs, walls) — min, never a product, so an occluder both see never
darkens twice. The near band only counts outside the shadow rings:
the sun rig keeps the ground centre and half-size of the widest ring with
texels of 2 m at most in
shadowReach (a shared uniform(Vector3), live by reference); the near
band fades in over that ring's last 20 % and beyond it the horizon is
max(near, far) — without it a street past the map lost the shadow of
the block beside it. Change the rings and this follows by itself;
never feed the near band inside them (its 8 m / 22.5° smear would
fight the map's crisp edges). The sky view is the aoNode, which three
multiplies into the indirect light only (after the lights; the sun is
untouched), on the terrain and the clay facades (createClaySky: sampled
2.5 m outside the wall, doubled, faded out toward the eaves); cells under a
roof carry the nearest open value in both rasters (no dark bleed through
LINEAR/mipmaps). Unjudged on a GPU — watch for SVF + GTAO reading as dirt in
courtyards (weaken the contact shadows there first) and the hand-over at
the frustum's edge.
ADR 0009.
Dead ends (don't repeat): large normalBias (peter-panning), VSM at any blur
(rings/grid on lit faces), one map warped over the view (LiSPSM: gone at a low
sun looking along its light, ADR 0009), a radius of 5 on five samples (a
stipple). Open limit: past the widest ring the ground's shadows are the
horizon's (an angle at 8 m and 22.5°, no shape, not on facades or trees).
Surfaces (the land-cover splat)
The bake writes only a 4096² class raster (ids 0–9, pipeline/bake/landcover.py;
the ids and their names in pipeline/bake/classes.py).
At runtime landcover-splat.ts paints it with the one palette
(lib/city/landcover.ts, a uniformArray) into an RGBA target, one
full-screen QuadMesh pass per tile (a scene-wide node material per raster
size, textureLoad of the class ids; the class texture is on the GPU
before the paint — decoded in its turn (on a phone one raster at a time
for the site, uploaded at once, its bytes dropped; tile-rasters.ts,
raster-upload.ts); a
bitmap the decoder fell back to is never closed — a closed ImageBitmap
uploads empty on node pages): RGB = the pastel class colour,
A = water coverage (a 3×3 tent over the water class — the soft
shoreline). The terrain shader samples it LinearFilter + mipmaps +
anisotropy 16 (GPU AA at grazing angles — kills the NEAREST staircase);
water reads the alpha and smoothsteps it for a crisp shoreline. Edge
sharpness is bounded by the class raster's resolution, not the GPU filter.
A colour change is a look change, never a re-bake (ADR 0010).
The terrain's colour node (terrain-layer.ts splatColour) composes its
terms in a fixed order over one shared set of inputs (GroundInputs) and
colour fields (GroundColour): the palette splat, the meadow mottle, the
ground fields, urban green, the ground detail, the allotment gardens, the
sports grounds, the road markings, the NDVI tint, then the gated contour
ink. A term whose raster is absent is left out of the graph.
Ground detail (ground-detail.ts, a term of the same colour node): the
kerb band, lawn edges, parking lanes and the paving rows along a kerb are
drawn at signed distances in metres from the baked, smoothed edge raster
(pipeline/bake/edges.py → edges_<tile>.png, RG, valid to ±6 m). The
class texels alone give that distance only within a texel of the edge and
follow the 0.5 m staircase — a shading-normal "kerb face" from them read as
dashes, and 2–5 m parking lanes from them as arcs. They remain the fallback
(4×4, box-smoothed) where the edge raster is absent. The same bake writes
the kerb lines; the fine terrain glTF stands a real 12 cm kerb stone on
them (lib/city/kerbs.ts, kerb-layer.ts; triangles wound CCW about their
normals — the first cut was clockwise and rendered black). The OSM paving
raster (surface_<tile>.png, RGBA: surface ids + parking bits, the bearing,
a per-segment along-street offset — along = offset + (position from the
tile's NW corner)·d, exact per straight piece) picks the pattern; every
pattern length along a street divides SURFACE_ALONG_PERIOD (165 m).
Rotating patterns by the bearing about the far data origin made every bend
a shower of arcs — don't. Both rasters are greyscale PNGs 2×/4× wide with
the bytes interleaved, so the viewer's own decoder (lib/city/png-raster.ts)
reads them exactly. Bodendetail and Stadtgrün (urban green painted as
meadow) are their fixed strengths (GROUND_DETAIL, URBAN_GREEN). The contour ink guards fwidth == 0.
Sports grounds (sport-ground.ts sportGround, same node, after the ground
detail): pipeline/bake/sport.py writes a table of grounds (frame,
surface, line scheme, shape) and an RGBA index raster (row on top, a
second row grown wider, the exact bit). The fragment evaluates up to eight
candidate rows' analytic shapes (rotated rect, a track's capsule band,
else the raster outline) and keeps the deepest — reading one row per texel
sawed teeth into a track's inner edge where the capsule model strays past
the mapped outline. Lines use spLine, an exact box filter over the pixel
footprint; keep new lines on it (no smoothstep lines — they shimmer).
Goals, posts and nets are dressing (sport-fixtures.ts), one merged mesh
per tile, owned by the tile holding the ground's centre.
Road markings and allotment beds (plans 026, 028) are two more
terms of the same colour node (roadMarkings, colonyGarden), fine level
only. road-markings.ts reads a
table of rotated rectangles (crossings, stop lines; axis across the road)
and an RGBA raster (markings.py: 16-bit row in R + 256·A, per-side
cycle-lane bit and centre-line bit in G, the signed offset to the
carriageway's middle in B — the side is resolved in the bake because the
paving raster's bearing is modulo 180°). Every stripe is box-filtered
exactly (rmStripes), and along-street periods divide 165 m.
cultivated-layer.ts paints faint beds on the colony raster
(cultivated.py) in jittered-Voronoi plots — no colony in the fifteen tiles
maps its parcels, so keep it faint.
Parts meet the ground (ADR 0030)
The DGM1 rounds every real step into a 1–2 m ramp, so whatever stands on
the ground meets an edge that is not where its own is. Five layers got it
wrong at once (2026-09-30: the kerb's second step on the pavement, a quay
wall's jagged cap, bridge decks ending in the air, stairs in a trench, tram
beds decided per whole chain). The rules, in lib/city/ground-join.ts:
- feet under by a
SINKrow (band 5 cm, kerb 6, box/patch 20, planted/relief 25, wall 40, stair 60) — pick a row, don't add a number; - edges meet the ground:
meetGround(end the top at the ground's level, never below a floor such as the road),reachLevel(run on until the ground comes up to a flat or falling level: a cap, an approach ramp); - per-sample decisions smoothed along the part (
farthestNear; in the Python bakes a window majority + a minimum run,tram.pysmooth_beds/absorb_short) — never one decision per whole line, never raw per sample (a sawtooth); - report the joins: a builder returns
joins(footmust reach the ground,edgemust not stand > 5 cm above it), every metre along each span viajoinsAlong. Baked parts are listed inscripts/ground-joins.tsJOIN_PARTS; its test holds each part's share of misses to a budget on the spawn tile.bun scripts/ground-joins.ts [tile]prints the shares and the worst places — render those, obliquely.
Before shipping a part that stands on the ground, answer in its doc comment: what happens where the ground beside it is higher than the part (a bank: Wange, cap run-on), and where it is lower (a raised flight, a deck end: dig the DGM's shoulder down, land a ramp)?
Terrain seams
Vertices sit at pixel centres, so a tile stops half a pixel short of its bounds;
abutting tiles of different resolution left a sky-gap "white seam". Fix in
terrain-geometry.ts (run by the bake now): snap the border ring to the true tile edge and drop a
vertical skirt (~30 m) so any residual height-mismatch crack shows terrain,
not sky.
Vegetation
- Trees =
Instancessets (trunk + crown), hedges =Instancesof a trimmed block (low-vegetation-layer.ts), all on scene-wide materials (sceneCrowns,sceneMaterial). Crown =IcosahedronGeometry(r, 2)(180 tris) with lobes and radial normals. Three tiers per 250 m chunk, planned over the whole site on the 10 Hz pose tick (lib/city/vegetation-lod.ts, applied bycreateVegetationLod, whose arrays are kept between passes;createLodScheduleruns it in the frame the tiles in view change, or the eye jumps; the cadastre's own silhouettes are planned with the chunks, their cheap crown standing for mid and far): the rich multi-tuft crown (~1 440 tris) near the camera (in 220 m / out 300 m) but only within a site-wide budget of 2 500 trees, nearest chunks first — forest tiles (one tree per 7 m, up to ~1 300 per chunk) otherwise put ~9 000 rich crowns on screen and lost the GPU context; the mid crown + trunk; and past 650 m (back at 550 m) a detail-1 crown (80 tris), no trunk, dense chunks thinned to every other tree drawn wider. Trunks, mid and rich crowns of a chunk share ONEinstanceMatrix(and the crowns oneinstanceTints) — ~9 MB instead of ~21 MB for a forest tile; compute each set's bounding sphere after sharing. - Crown look (
buildCrownMaterial): sway inpositionNode(instancePosition(sway.local), the per-tree phase from the instance's world column), the shadow rigid (castShadowPositionNode); occlusion and the gust's brightness pulse incolorNode, noemissiveNode(the backlit glow flattened the crown, removed 2026-11-06). - Phones cache 168–336 MiB of tile content at safety level 0 (148–296,
136–272, 96–232 at levels 1–3;
tileCacheBytesForinapp/_components/scene-profile.ts,lruCache.min/maxBytesSize), weighed as the GPU holds it (the dressing plugin'scalculateBytesUsed, less the site-shared buffers; a tile that left is never re-weighed — the cache frees it inside its eviction, andrecalculateBytesUsedthere adds its bytes back to the total for good).maxis derived: the governor's soft line − the fixed costs (24 B per drawn pixel + the shadow map) − 100 MiB;max − minmust exceed the largest tile (128 MiB on a phone). The 320–600 MB before hadmaxabove the governor's own hard line, so the cache never bound anything before Safari took the GPU; the 120–180 MB before that was broken the other way: a phone flying to the Alaunpark never loaded the ground there. Seedocs/rendering.md, "GPU memory on a phone", and ADR 0047 — with its two rules: an attribute read on the CPU after the dressing (or first by a later material) stays off the CPU-copy drop lists, and a site-shared buffer is never on a geometry that is disposed. - Chunking: placements are bucketed into 250 m cells, one instanced set per
cell (shared geo/material), so off-screen cells frustum-cull from both the
main and shadow pass. After
setMatrixAtyou mustinstanceMatrix.needsUpdate = true+computeBoundingSphere()or the whole cloud is wrongly culled when the world origin is off-screen. - Canopy from
pipeline/bake/canopy.py:nDOM = DOM1 − DGM1, one tree per ~7 m cell at the tallest pixel, scaled to measured height, gated off road/bridge/water via the class raster. - Tree LOD (shipped): the three tiers above; a tier change invalidates the shadow map (plan 009).
- Cadastre + laser scan + hedges (all default-on): the street-tree
cadastre (
pipeline/bake/trees.py), the laser-scan trees outside the canopy mask (canopyx, thinned in the bake against the cadastre within max(4 m, crown radius)) and the OSM hedges with their laser-scan height (low-vegetation-layer.ts, superellipsoid chains, static, chunked) frompipeline/bake/lowveg.py. The bake's laser-scan-only hedges and shrubs are NOT shipped (~30 % crown-rim false positives;--step lowveg --researchwrites them for research). Measured lesson: the LSC multi-echo ratio is a tall-tree cue, not a shrub cue (hedges 26 % vs fences 56 % at ≥ 0.5). Draw-call model:scripts/eval/kataster-cost.ts. Numbers indocs/transformations.md. - Species and season (plan 025): the tree data carries the genus (
gn, an index into the file'sgenera=lib/city/tree-season.tsTREE_GENERA) and the trunk diameter; OSMnatural=treefills in where the cadastre has no tree within 3 m.crown-season.tsturns the scene date into per-instance tints (instanceTints) andaBare(1 − leaf, read withinstanceFloat) on a change of calendar day only (throttled, never per frame); a chunk with a bare crown wears the seasonal crown material, whose hashed alpha test in crown space (~1.25 px cells — fixed cells shattered big crowns into shards) is itsmaskNode, which the shadow pass honours, so the winter shadow thins. Cost:scripts/eval/season-cost.ts.
Sandbox crown — what is left to port
Cost = geometry_tris × instances, paid twice (shadow pass). Still open:
dappled canopy shadow (a colour-less proxy caster per chunk whose
maskNode cuts the gaps — the shadow pass honours it). Radial normals
and the LOD-gated multi-tuft crown are in
vegetation-layer.ts.
aesthetic-sandbox.html at the repo root is the historical playground those
were ported from; the layer file, not the sandbox, is the source of truth.
Bridges (ADR 0041)
Decks come from the Basis-DLM (ver06_l centrelines, ver06_f outlines),
built per fine terrain tile by bridge-layer.ts (called from
rail-layer.ts; the lift table and the approaches' plan are
bridge-deck.ts, which a layer that only rides the decks imports alone);
only the tile owning a deck's centre draws it (BridgeContext.owns),
every copy still lifts that tile's rails. The bake (rail.py + bridge.py) reads a DGM/DOM mosaic
700 m around the tile, so a seam bridge is the same in both files. What it
measures in DOM1: the roadway (lower third across the deck, held
−6/+4 m around the abutment ramp) and, on bridges whose class stands above
the deck (Wikidata over OSM: arch, truss, suspension, cantilever,
cable-stayed), the ribs — per half of the cross-section, the highest
surface over the deck, opened 7 m / closed 11 m, kept ≥ 3 m for ≥ 25 m.
Runtime (lib/city/bridge.ts): arch ribs share the tightest rib's
parabola (archFits) and are carried below the deck to the ground
(archSpringing) — the Waldschlößchenbrücke; a rib that fits no arch is
not drawn (catenary, trees). Truss/suspension ribs are drawn as measured
with pylons where they peak ≥ 10 m (the Blaues Wunder); cable-stayed gets
a pylon and a fan. The deck's depth comes from the OSM fairway clearance
over the DGM water; beam piers keep the fairway clear. The LoD2's own
bridge slabs (53001_*) are dropped from the building mesh.
Surfaces (bridge-surface.ts): the deck's top mesh carries its frame
per vertex (aDeck = station, offset, the outline's left/right there —
addDeckFrame, the outline's width read 3 m in from the ends; the top's
triangles split to ≤ 6 m, splitDeckTop, since the triangulation of a
curved deck joins vertices 190 m apart and the frame, exact at each
vertex, interpolates truly only over a short reach — the Marienbrücke's
carriageway sat 8 m off); the scene-wide deck material lays a road
deck out from it (footways, kerb and gutter, carriageway grain, centre
dashes on two lanes), a path deck as sand with kerbs, a rail deck as bed
and walkways; the stone (fascia, parapets, piers, masonry) is ashlar in
world space along each face's own horizontal. Fine detail fades by
fwidth; the footway/carriageway split and the mottle hold to 1 : 10 000.
Lines take their level along the whole line (ADR 0041). Rails and
trams are not lifted onto whatever deck lies under a point (a lower line
jumped onto the flyover above it; an upper one fell into the DGM's gap
beside its deck): lib/city/levels.ts picks, per 4 m (rail) / 2 m (tram)
sample, the ground or a deck within 2 m by a Viterbi on the climb beyond
the grade (rail 4 %, tram 8 %), then a grade cone turns a gap the line
cannot get down into from either side into a span (the layer draws a
span deck, addSpanDeck) and a fill it cannot climb into a cut. The
build solves every piece with 450 m of context from the pieces that run
on through its ends (lib/city/line-levels.ts) and publishes the runs as
lv; a cut ≥ 30 % under a drawn deck opens a passage in the terrain bake
(lib/city/passages.ts). Check with bun scripts/line-levels-cli.ts <site>.
Underground is not drawn. The DLM's rail lines say nothing of a
tunnel, so rail.py cuts a stretch that runs within 2 m of a DLM tunnel
(ver06 BWF=1870) for more than 15 m — a shorter overlap is a surface
track crossing over it (a 6 m reach took Munich Hauptbahnhof's surface
tracks). DLM trams (BKT=1201, standard gauge in Bavaria) are the OSM
tram layer's. OSM platforms below ground (osm.below_ground: tunnel,
location=underground, negative layer/level) and tram ways in a
tunnel (osm.in_tunnel) are skipped. A tram's street bed is any
pavement: road, path (squares, pedestrian zones) or built-up.
Tiles: batching, lifetime, freeing, rays, streaming
Buildings are already batched. Each tile's buildings are ONE glTF mesh
(scripts/bake-city-mesh.ts runs cityjson-threejs-loader at build time
and, cityMesh, writes it with a per-vertex feature id,
EXT_mesh_features), so draw calls are already low and BatchedMesh would
not help (and would break feature-id picking/demolish). Per-building data
(tint, heights, roof colour, glow, roughness, the demolish tree) is one row
per object in an EXT_structural_metadata property table, packed into a
float texture the clay shader reads (lib/city/city-mesh.ts) — add a
building attribute there, not as a vertex attribute. A word packed into
bits there (the windows' style) or a value that seeds a hash (the
roughness jitter) reaches the fragment as a flat varying
(setInterpolation(InterpolationSamplingType.FLAT)): interpolated, even a
value all three vertices share drifts by a few ulps, which past 2²⁰ reaches
a word's low bits and which a hash magnifies into a new value every pixel
(a loose grid's windows frayed at their sides). The perf bottleneck is
fill-rate (post FX + shadow map), not draw calls.
Tiles come and go. 3DTilesRendererJS loads and unloads tiles by
screen-space error (app/_components/tile-stream.ts; two terrain levels,
the coarse one's geometric error is COARSE_TERRAIN_ERROR in
lib/city/tileset.ts). Everything a tile brings (dressing, BVH, materials,
textures) is built in the dressing plugin's processTileModel and freed in
its disposeTile — never in bootApp, or it leaks when the tile unloads.
Before a tile or its dressing shows, its node materials are built and
compiled with compileAsync against the scene pass's target
(PostStack.compile, each drawable shown and unculled for the call) — add
new per-tile objects inside that path, or they build inside a frame.
Freeing is on us, not on three or the tile renderer (WebGPU keeps GPU
state per drawable that WebGL never had). WebGPURenderer frees a
drawable's render objects — its uniform buffers and bind groups — only on
its material's dispose or its own dispose event, and a scene-wide
material is never disposed; 3DTilesRenderer frees only the glTF's own
materials (not the per-tile ones the dressing put on), and on a load it
aborts after processTileModel it frees the textures alone — geometry a
compile already uploaded would stay. So a tile's release runs
disposeObject3D over its content (releaseRenderState dispatches the
event), a released or aborted tile is freed by the plugin itself, and
nothing released is compiled. Leak hunts: handle.getGpuDebug() sets the
scene's buffers against three's info.memory (a gap that grows per visit
to the same place is a leak; a steady one is the tile cache). What a
tile adds beyond its glTF must reach the cache's weighing
(DressingPlugin.calculateBytesUsed), and a phone's memory is watched
by lib/city/memory-governor.ts — see docs/rendering.md, "GPU memory on
a phone". A part that lands after its tile is a LatePart
(late-parts.ts: the dressing, the murals, the traffic bodies, the window niches), which
weighs it from its build, frees it after its compile and reports for it.
A content's CPU copies go once its compile has uploaded them
(dropCpuCopies over cpuDroppable / cityCpuDroppable, ADR 0047; a
compile resolves only once every drawable under it is uploaded, those
another compile had started too — compile-lanes.ts): an
attribute read on the CPU after the dressing, or first read by a material
the tile's own does not use, stays off those lists — met after the drop,
three uploads an empty buffer. A buffer the site shares
(markSceneShared: the coarse grid's and water's index) is never on a geometry when
that geometry is disposed — three would destroy it for every tile;
detach it first. A lost GPU reloads a safety level lighter, where the
player stood (gpu-recovery.ts, ADR 0047).
The terrain has no BVH: ground rays march the height function
(lib/city/ground-ray.ts) — the coarse grid's vertices, or the fine TIN's
triangles through a bucket index (lib/city/terrain-tin.ts TinIndex).
A building tile's BVH is built when a ray first needs it (ADR 0024,
ensureCityBvh), so a ray against the buildings goes through
nearestCityHit, cityObjectsAlong or the collider, which ask only the
tiles whose box the ray enters and build (or skip) a missing BVH — a
plain intersectObject(city.mesh) on a tile without one is
three-mesh-bvh's brute-force fallback over two million triangles. The
tiles near the camera get theirs ahead in the BVH worker
(bvh-client.ts): their positions and index are handed over meanwhile,
so nothing may read a building tile's CPU positions or index while
bvhBuilding says so — skip the tile or wait. The glTF extras key is tileId: the
renderer writes userData.tile itself and would overwrite ours. The
walk/fly camera does not stream itself: the renderer streams for its twin
whose far plane ends where the fog does, on a phone at 2.2 km at the
latest — not through Modell's dolly zooms, which back it kilometres away
(streamFarFor, lib/city/atmosphere.ts; synced each frame
before the update) — past the
fog's end a tile shows nothing, and the camera's 6 km far plane loaded the
site's every building tile in that direction as fog colour; register a new
streaming need against that twin, not the camera. The sun's shadow camera is a second
streaming camera while the sun is up, so tiles that cast into the view stay
loaded (by night it streams nothing: streamShadowTiles in create-app.ts;
on a phone never, shadowTilesStream) — at 128 px on a desktop
(SHADOW_STREAM_PX), not the map's resolution: an orthographic camera's error ignores distance, and at
2048 px it refined the terrain under its whole frustum;
displayActiveTiles keeps loaded tiles drawn while turning.
Performance model
The bottleneck is fill-rate: the post
pipeline (GTAO is the priciest) and the shadow-map render — which is why
DoF is skipped while the camera moves (lib/city/regression.ts; a
phone does not build it at all — its targets were the cost there, not its
passes). AO is
not: the contact shadows blinked on every footstep, so GTAO instead runs
permanently at half resolution (resolutionScale = 0.5, normals
reconstructed from depth, radius 2 m, occlusion raised to
AO_EXPONENT, 1.5) — about what the skip saved, paid every frame. Its sample
count (aoSamplesFor: 16, lite 8) rebuilds the pass's material, so it is a
construction-time setting. DoF is dropped by switching between two
prebuilt RenderPipelines (with and without the lens blur, both
built under the load screen), never by swapping one pipeline's output node,
which re-translates the whole post graph on the main thread. SMAA carries
the anti-aliasing (the canvas and the scene target have no MSAA) — once, in
a last pipeline over the target every pipeline draws into; a phone uses
FXAA inside that last pass instead (no targets of its own). Buildings
are opaque clay only; MeshPhysicalMaterial.transmission ≈ doubles scene
cost, so the frosted "ghost" style was dropped rather than kept as an
option. handle.getRenderInfo() exposes renderer.info of the last frame
— every pass (shadow map, scene, post). Spike numbers (Apple Silicon,
Chrome): WebGPU 40–80 % faster than the old WebGL path at the same look;
the WebGL2 backend on par in steady state but stalling while it compiles
(docs/plans/completed.md, plan 020).
QA: self-verify on a real GPU
The in-app Snapshot panel serializes camera + sun time + the look's
controls (Nebel, the style, the data layers) to JSON; __poc.handle.getCameraState() / applyCameraState() replay it. To eyeball a
change yourself:
# drop the snapshot JSON into shots/, then:
bun run shots # = SHOTS=1 playwright test e2e/snapshot-shot.spec.ts --headed
# writes shots/<name>.png (HUD hidden, real GPU). shots/ is gitignored.
# Before/after pairs: SHOTS_QUERY=scene=lite SHOTS_TAG=x bun run shots
# appends the query to the page URL and writes shots/<name>.x.png.
# The shots are taken at /dresden; SHOTS_SITE=leipzig takes them in another
# built city.
# The committed QA views (qa/views/*.json, plan 019's reference views,
# written by `bun scripts/qa-views.ts`) render too, as shots/qa-<name>.png;
# one of them: bun run shots -- --grep qa-<name>.
# Plain `bun run test:e2e` ignores the harness (testIgnore in playwright.config.ts).
A view also travels as a link: Link kopieren writes
/<site>?snap=<base64url of the snapshot> (lib/city/snapshot.ts) — in
the query, like every URL parameter, so the host's request log shows it
(the privacy page says so; the crash trail keeps only the QA knobs);
the boot reads it through parseSnapshot (a waiting GPU recovery wins,
lib/city/gpu-recovery.ts arrivalOf). Another link is another page
load.
Headless e2e uses SwiftShader through the renderer's WebGL2 backend (headless
Chromium has no WebGPU adapter) — shadows/AA look nothing like a real GPU, so
use --headed for any lighting work. To check the fallback on a GPU, add
?gpu=webgl2 (SHOTS_QUERY=gpu=webgl2); to see a lighter page of the
safety ladder, ?safety=1–3 (ADR 0047: pixel ratio, shadow map, tile
cache and governor a step down; nothing stored). Verify from oblique angles (a tree through
a bridge is invisible looking straight down). Snapshot JSON shape:
{ "v": 1,
"camera": { "mode": "fly", "pos": {"x":0,"y":0,"z":0}, "epsg": {"x":0,"y":0},
"headingDeg": 0, "pitchDeg": 0, "fov": 55 },
"date": "2026-06-15T08:30:00.000Z",
"look": { "fogPct":35,"style":"pastel" } }
Budget the e2e specs in frames, not seconds. Under SwiftShader every pixel
is shaded on the CPU. At the full profile this scene costs ~14 s to boot
and ~4 s per frame at 1280×720 (measured on four cores). Anything that waits
on rendered frames — the control walk uses waitForFrames — walks straight into
the per-test timeout if it spends frames carelessly. Playwright runs a single worker on CI for the same reason: parallel
viewer pages halve each other's frame rate. CI splits the suite over four
runners instead (the e2e matrix in .github/workflows/ci.yml), by tag: the
whole site with the shell and /wissen; the desktop HUD group
(@desktop-hud) with the phone (@phone); the desktop rendering group
(@desktop-render: snapshots, inquiry, Modell, designs, demolish); the look
group (@desktop-look: styles, motion quality, the view link). Each desktop
group boots its own page. Time on CI is SwiftShader's frames, not the driver:
a lighter headless browser (Lightpanda has no WebGL) or another test runner
gains nothing, fewer frames per runner do. The required
status check "E2E (Playwright)" is the small job that reports them all. A new
spec lands in the whole-site shard unless it carries one of those tags —
keep the shards within about 1.5 minutes of each other (the workflow's
comment has the measured runs).
The lite scene profile (headless e2e only)
?scene=lite (app/_components/scene-profile.ts) exists because SwiftShader
shades every pixel on the CPU. It changes four knobs (tiles, shadow map,
render scale, AO samples), all resolved once in city-walk-client.tsx
(currentSceneBudget) and handed down as numbers:
| Knob | full | lite | Why it is the right knob |
|---|---|---|---|
| tileset streamed | tileset.json (every site tile) |
tileset-spawn.json (spawn tile only) |
3/4 of the geometry AND 3/4 of the boot (measured on the old block loader: 14 s → 4.4 s, 74 MB → 18 MB) |
shadow ring edge (shadowMapSizeFor) |
2048 | 256 | the depth pass is fill that does not shrink with the canvas |
pixelRatio |
dpr≤1.5 | 0.5 | the canvas fills the viewport and the HUD needs ≥768 px to lay out, so render scale is the only honest way to cut fill-rate |
GTAO samples (aoSamplesFor) |
16 | 8 | half the AO samples; keyed on the profile, not navigator.webdriver (Playwright sets that in the --headed shot harness too, which must show the product's AO) |
Everything a spec asserts on — loaders, layer construction, every node
material, the HUD wiring — is identical in both. Never use lite to judge a
render: it is coarse by design; that is what the --headed harness above is for.
The specs share one booted page per context (mode: "serial" + beforeAll),
because boot is the largest fixed cost left once frames are cheap. The
snapshot-shot.spec.ts harness deliberately stays on the full profile.
Data pipeline
Every script takes the site as its first argument (bun run fetch leipzig,
ADR 0037); its data is data/<site>/. One deployment serves every site
whose data is ready, each at /<site>; / is the start page.
Bulk raw downloads (DLM, DOM1, DOP, OSM .osm.pbf) stay in the gitignored
data/_raw/<provider>/{dom1,dop,dlm,osm,trees,lsc,downloads}, shared by the
provider's sites; no Git-LFS. The build sources are the CityJSON and the
DGM1 GeoTIFF per tile in data/<site>/{cityjson,dgm}/ — prepare-data.ts
bakes the terrain from it at build time and the canopy/rail bakes read it —
next to the small derived artifacts in data/<site>/{dlm,dop}/. Dresden,
Grimma, Hamburg, Leipzig, Meißen, München and Unna are committed; a new
site's folder is a maintainer decision. bun run fetch <site> downloads everything through the
provider's adapter (pipeline/bake/providers/<id>.py) and converts the LoD2
CityGML itself (citygml.py); bun run site <site> says what is missing.
Stage 1, the offline bakes (ADR 0037): one Python package,
pipeline/bake/, in a uv environment (numpy, rasterio, pyogrio, shapely,
Pillow, scipy, scikit-image; GDAL inside the wheels, with the OSM driver;
laspy for the laser scan — no PDAL). If a tool is missing,
fix the environment (pipeline/pyproject.toml), don't bend the code.
bun run bake <site> runs every step for every tile of the site with its extent and
CRS, land cover first:
bun run fetch dresden # download what the site needs (its provider's adapter)
bun run bake dresden # every tile, every step
bun run bake dresden 33412_5656_2_sn # one tile, all steps
bun run bake dresden --step canopy # one step; the order is STEPS in pipeline/bake/__main__.py,
# each entry's comment says what it must follow;
# `transit` runs once for the site (SITE_STEPS)
bun run test:pipeline # pytest + ruff
Missing DOM1 or DOP skips the canopy, NDVI and roof-colour steps (the
runtime falls back); rail decks fall back to the DGM ramp. An RGB-only DOP
(Bavaria) gets the NDVI raster from the visible bands (GLI, ndvi.py
_gli). Every flight's index is mapped onto Dresden's scale (its
carriageways and mown lawns as anchors, pooled over the site's tiles), so a
green threshold tuned on Dresden holds in every city — never tune one per
site. Without a Basis-DLM (Hamburg, Berlin) rail reads OSM
(rail_osm.py: rails, ballast beds, bridge ways merged per bridge and
level) — it runs before canopy, which keeps crowns off those decks.
Stand-ins are marked per city in the generated
docs/guide/*/sources-by-city.md (bun run docs:matrix, ADR 0012). rasters.py
refuses a height mosaic flatter than 0.5 m (1–99 %) — Hamburg's DGM was
once committed as 0 m everywhere and every house floated; the pipeline
tests hold every committed DGM to ≥ 2 m. The laser scan (--lsc) is read
for Saxony (GeoSN's LSC), NRW (3D-Messdaten) and Bavaria (four 1 km LAZ
merged per tile by lsc.merge_laz, each provider's classes mapped into
AdV's by its table — NRW keeps the crown tops in class 1); every scan's
intensities are normalised against its own ground (lsc.rasterise).
The later modules, one step each: osm_buildings.py (shops, heritage,
material and colours per LoD2 object), markings.py, cultivated.py, tram.py,
riverside.py, skyview.py (DGM + LoD2, the rebuilt roofs of roofs.py
in place of theirs), small_buildings.py (plan 034),
landmarks.py and structures.py (plan 050; landmarks first, the relief
is measured on their objects). Seams:
a step whose result must agree on both sides of a tile edge reads the
neighbours through Tile.neighbours (the committed DGMs): markings
measure on the neighbours' class rasters and paint a neighbour's crossing
that reaches in, cultivated
takes a vineyard's slope from every DGM it touches, tram and small-buildings
read the neighbours' furniture / scan — so bake those steps on every tile. All OSM layers come
from the Geofabrik extract — no Overpass.
Stage 2, the build step (bun dev / bun run build →
prepare-sites.ts → prepare-data.ts <site> for every ready site, then the
index public/data/sites.json the start page and app/[site] read): per
site the tileset (tileset.json, tileset-spawn.json), per tile
city_<tile>.glb.gz, terrain_<tile>_l0|l1.glb.gz, footprints_<tile>.json
and the side files, all content-hashed under public/data/<site>/ with its
manifest.json (and the shared sites.json) as the no-cache entries. scripts/tile-glb.ts owns the
glTF writing (meshopt, quantisation, the feature table); the .tif and the
CityJSON are never served. It caches by content in .cache/prepare-data.
Researching three.js releases
GitHub's releases HTML page is JS-heavy and reads poorly via WebFetch.
Authoritative: registry.npmjs.org/three/latest (version),
github.com/mrdoob/three.js/releases.atom (feed),
raw.githubusercontent.com/wiki/mrdoob/three.js/Migration-Guide.md (API
changes). Confirm shader/behaviour claims in node_modules/three/src.
On a bump, go through Workarounds for upstream bugs in
docs/rendering.md and drop what the release fixed.