v…
Data-Oriented Design, by picture · Chapter 4

The View

The same world, the same tanks, the same 4096 mites — redrawn as a top-down perspective 3-D scene. The simulation does not change by a single byte; only the picture does. The view is a projection you can replace wholesale.

follow
view (presentation only)
render profile
profiling…
loading…
The main view is top-down perspective 3-D and shows the whole 8×8 world as one connected map; the minimap below stays flat top-down — the same data drawn two ways. Drag to pan, shift- or right-drag (or a two-finger drag) to orbit & tilt, pinch or scroll to zoom, hover a cell to highlight it. The pitch/fov/lod dist sliders above are live; the toolbar follow-cams a tank (selecting it) or returns to free view — pan/zoom/orbit adjust the follow; F frames the selection. The mites run themselves. Tap a tank to cycle it: auto-path (a green spike marks it; tap a cell to send it there — its path + destination are drawn) → manual (a yellow spike; drive it: W/S/A/D or the pad) → unselected. Drive a tank through the swarm and watch a sighting spread — by default they all hunt it, but lower P(hunt) and some carry it home to a nest: hunting · homing (briefly, in its nest’s colour — one per screen) · wandering. Each tank’s turret aims and fires a piercing bolt ( a travelling streak, drawn in the translucent pass) that pierces every mite on its line ( burst); each kill’s death cry tells nearby mites where the shot came from. The low-poly art toggle above swaps the placeholder cubes for baked meshes — a render-only change that touches neither the projection nor the simulation.

This is chapter four of an interactive book about data-oriented design. Chapter 1 was two tanks on a grid of walls; chapter 2 grew the world to a toroidal grid of screens with self-routing tanks; chapter 3 widened it to 8×8 and added thousands of mites that gossip and swarm. Chapter 4 changes nothing about the simulation and everything about the picture: the same world, redrawn in top-down perspective 3-D. As before, every structure below is live — it reads the running game's memory directly.

The lesson is what chapters 1–3 earned: a strict simulation / render split with a one-way dependency. The renderer reads the simulation; the simulation never reads the renderer. Chapter 4 is the proof. We replace the flat 2-D view with a full top-down perspective 3-D one — new projection, depth, shading, camera, and art — and do it without editing a single line of the simulation. sim.c, mites.c, tanks_fire.c, the per-cell index, the gossip, the route fields, the RNG — all byte-for-byte identical to chapter 3. Only the render half changes: render.c, the shader, the view uniform, app.js, and a folder of meshes. That is the whole point, stated as a falsifiable claim and pinned by a test: for the same seed and inputs, chapter 4 produces the identical sim state-hash as chapter 3, tick for tick (test.c). If the hash diverged, presentation had leaked into the model.

The view is a projection

Chapter 3 placed each instance at a 2-D screen position and drew an axis-aligned quad. Chapter 4 keeps the instance-per-thing model but gives each instance a 3-D world placement — a cell (wx, wy) in subcells, a render-only height wz (the simulation has no z), a facing (the body / turret angle the sim already stores), and a tint — and lets the vertex shader project it with a top-down perspective camera, tilted a little off straight-down:

the projection is a host-built model-view-projection matrix; the shader just applies it

clip = mvp * vec4(world, 1)      // mvp = perspective · lookAt(eye, target) · scale

The camera looks down on the world from above and slightly to the south, so you see the tops of things and a sliver of their near faces, and the perspective gives real depth — near walls larger than far ones. Crucially, render.c does not bake screen positions — it emits world placements, and the matrix (the view uniform) transforms them. So dragging to pan, shift- / right- / two-finger-dragging to orbit and tilt, pinching/scrolling to zoom, sliding pitch or fov, and following a tank are all just a different matrix: the camera is a uniform, not a rebuild — a fact the native test pins (the same view rebuilds the identical bytes). The whole 8×8 world is one connected map (placements are world positions, so all sixty-four screens sit in their natural layout); the free camera shows any part, and the host hands the renderer the visible box (its four screen corners cast back onto the ground) so it emits only the cells and units in view — the whole world when zoomed out, a handful of cells when zoomed in, never the 19200-cell world or the 4096-strong pool wholesale. The minimap below is the same idea taken literally: the same sim data drawn two ways at once — perspective above, flat top-down here.

Clicking is unchanged from chapter 2 — tap a tank to cycle it, tap a cell to send the auto-path tank there — but the 3-D view makes it legible with render-only overlays: a hover highlight on the cell under the cursor, the selected tank's mode (a ring + a tall spike, green for auto-path, yellow for manual), a destination beacon, and the routed path drawn cell by cell from the same tables the tank follows. None of it touches the simulation.

the 8×8 world, TOP-DOWN · walls, tanks, live routes, and the whole swarm (dots) · the outlined screen is where the camera looks · click a screen to jump there

Depth — a z-buffer, and a painter's key for the glow

Opaque geometry — terrain, tanks, mites, nests — draws with a depth buffer (new render state, a texture in the pass; chapter 3 had none). The perspective matrix writes real depth (clip.z/w), so the GPU resolves occlusion — no per-instance CPU sort — and a taller, nearer block correctly hides what's behind it. The translucent FX — the bolt streaks and the destruction bursts — draw in a second pass after the opaque one, with depth-test on but depth-write off, painter-sorted back-to-front by a small key: since the camera leans south and looks down, “nearer” grows with +y (south) and +z (height), which is enough to order the few, ground-level FX that ever overlap (the test pins it).

Flat shading — a 3-D scene from primitives, no art required

The picture reads as 3-D before any asset, from flat per-face shading: one directional light, each face tinted by dot(faceNormal, light) over a small ambient floor. For an axis-aligned block that is three brightnesses — top brightest, the two visible sides darker — which is exactly the cue that reads “a raised 3-D block.” It needs only the face normal — no normal texture, no per-vertex lighting — so the geometry pass just writes that normal into the G-buffer, and a screen pass turns it into light (next). The whole first pass is built from a single unit cube, instanced, projected, depth-tested — a body/turret/barrel for a tank, a little spike per mite, and for the static map a white massing model whose per-instance heights already read the town: a flat slab for a road, a taller box for a building, a tall one for each landmark. It is genuinely 3-D, just primitive — and it is shippable on its own.

Lighting is a screen pass — a deferred renderer

The shading now runs deferred. The opaque scene is drawn once into a G-buffer — two screen-sized textures holding each pixel's albedo and its world-space normal, plus depth — and no light is evaluated there. The lighting is a separate screen pass that reads the G-buffer and writes the lit result: a hemisphere ambient (cool sky overhead, a warm ground bounce below, blended by how “up” each face points) plus one directional sun. The background is simply the sky. A gentle exposure curve then rolls the result down to the display.

Why split it in two? Because the lighting cost then scales with the pixels a light touches, not the object count times the light count. Shaded in the geometry pass, a thousand small lights would mean re-lighting every triangle a thousand times; as a screen pass, each light only pays for the pixels it actually reaches. That is the frequency-of-change argument again, in screen space — the geometry is rasterised once, each light is evaluated only where it lands.

That is what buys the thousands of dynamic point lights here: one per mite (a faint glow in its role colour) and one per FX (a bright, fading flash for a popping mite or a striking bolt). Each is built by the sim each frame, culled to the visible box, and drawn as a tiny cube volume that additively accumulates into the same HDR target — reading the G-buffer only for the pixels it covers. So the whole glowing swarm costs lit pixels, not four-thousand-lights-times-the-scene. The environment is just the first, global light; the rest are the same idea, localised. None of it touches the model — the simulation still does not know it is lit.

Shadows, split by what moves — a baked sun map + a screen-space march

The sun's shadow splits the same way the rest of the chapter splits its data: by frequency of change. The town is static and the sun is fixed, so the buildings' shadow is a pure function of the frozen map — and like the town instances, it is baked once. A sun shadow map is just the scene's depth rendered from the light's point of view; render the static town's depth through one orthographic pass down the sun direction, and every later pixel can ask it "is something between me and the sun?" by projecting its world position into that depth map and comparing. The bake records each occluder's far side (it culls the sun-facing faces), so a lit roof is never its own shadow — that one flip removes the self-shadow speckle without a per-surface bias to tune. The bake runs at init and re-runs only when the map re-bakes (a wall toggled, a nest moved) — the exact trigger the static instance buffer already uses. Per frame the cost is one filtered compare per pixel, independent of the map's resolution; the shadow of the whole town is, once more, a projection of the frozen map that nobody recomputes.

That handles the static occluders. The moving ones — tanks, the swarm, the bolts — aren't in the bake, so they get the complementary technique: Bend Studio's screen-space shadow march (shipped in Days Gone). For each lit pixel, step a ray from the surface toward the sun; project each step back to the screen and read the stored position there. If an on-screen surface sits in front of the ray within a thickness window, the sun is blocked. This is the conventional pairing: a light-space map carries the long cast shadows, a screen-space march adds the contact detail and the actors the map never saw. Its honest limit is one depth layer — it can't see an occluder's far side, so marched too far past a tower it smears a truncated silhouette; kept medium-range it just grounds what moves. The two visibilities multiply, and because the shadows carry the contrast the sun runs bright and warm over a dim dusk ambient — long building shadows down the streets, a soft pool under every tank. Still over a frozen sim, which never learns it casts one.

Assets are late-bound data

The second pass swaps the primitive cube for real low-poly meshes without touching the projection, the depth, the shading, or the sim. The only new machinery is a small table loaded once at startup (a packed vertex buffer per mesh, host-baked like the escape table — see ASSETS.md) and a kind → mesh binding when emitting instances. The instance data — placement, facing, tint — is unchanged; a baked mesh simply replaces the body-box. Tick low-poly art at the top to watch it swap live: the picture is data you replace; the pipeline that draws it does not move.

The map is static, so the town is baked once

The map is static and totally known: which cells are walls, where the nests sit. So the town it becomes is known too — there is no reason to re-derive it every frame. A single offline pass (build_static_map) reads the frozen grid and classifies every cell once: a wall becomes a building, an open cell touching a wall becomes an autotiled road (the streets follow the maze corridors, each tile rotated to match its neighbours), a wide-open interior becomes grass, and a nest becomes a landmark tinted in its hue. That is one instance per cell, grouped by screen, uploaded once. Per frame the renderer does the one thing a static map needs — a frustum cull, picking which screens' runs are visible — and draws them. No re-emit, no re-classification; the town only re-bakes when the map itself changes (toggle a wall and watch the buildings and streets re-knit around it). The dynamic things — the swarm, the tanks, the FX, the overlays — still rebuild every frame, scaling with what the camera shows, not with the 19200-cell world or the 4096-strong pool. The frequency-of-change spectrum is the whole story: the town bakes once, the projection runs every frame (constant), and only the live agents move through the per-frame path. The sim never knows it became a town — the town is a projection of its walls and nests, the same way the camera is a projection of its coordinates.

Three levels of detail — including a projected imposter

A full Kenney building is ~3,500 vertices — windows, eaves, doors. Up close that detail earns its keep; a hundred cells away it is a smear. So each town mesh is baked at three levels of detail, picked per screen by distance:

The reference is the closest, most-detailed view — max zoom at a 56° tilt; everything visible there is LOD0, which fixes the LOD0→LOD1 distance, and a multiple of it fixes LOD1→LOD2. The same baked instance renders any tier (LOD0/LOD2 share the flat geometry pipeline; the imposter adds a second textured pass over the same instances), so the choice is still just a per-screen compare at the cull — nothing about the instances or the sim changes. The lod dist slider scales the crossovers live (1× is the reference; lower pulls the cheaper tiers in, higher holds the full art farther out).

The simulation does not know it is drawn

Everything below this line is the model the view reads — and it is frozen, byte-for-byte, from chapter 3. Height, mesh id, the light, the camera, and the depth texture are all render-side; none of them appears in the World. The widgets are the same live windows into the running memory, shown here as raw bytes and as a top-down grid beside the perspective scene the GPU draws from the very same numbers. Tap a tank in the scene, or cycle it here:

per-tank data · state, destination, status — tap a tank on the map, or “cycle state” here

The tanks still route along precomputed path tables; the mites still bin into a per-cell index every tick. Toggle a wall on the left (it rebuilds the path tables — rare) and watch the per-cell occupancy on the right shift as the swarm flows (rebuilt every tick):

left: the current screen's walls — click a cell to toggle (rebuilds the path tables, rarely) · right: per-cell mite occupancy 0–4 (rebuilt every tick)

walls
occupancy

The gossip still works the same way: drive a tank into the swarm and watch the belief field diffuse outward from a sighting and dissolve as the record ages — the 3-D view tints the same mites red/teal/nest-colour from the same modes.

the belief field on the current screen · a cell is tinted where mites believe a tank is — bright = a fresh sighting, fading as the record ages

the mite pool · live count and a few structure-of-arrays rows (position, heading, mode, record)

And the swarm is still a pure function of the seed and the inputs — the determinism chapter 3 earned is exactly what makes the chapter-4 contract testable. Edit the seed to re-scatter the swarm and tune the rest live:

the editable seed and the tunables · sensing range, cap, P(hunt), mite speed/turn, fire rate, respawn delay, turret turn, bolt speed

What this bought us

A wholesale new view — a top-down perspective projection, a z-buffer, flat shading, a free camera, and late-bound low-poly art — over a simulation that did not move. The projection is a pure function in the shader; the camera is a uniform; depth is the GPU's z-buffer for the opaque pass and the painter's key x+y+z for the translucent one; the art is a kind→mesh table you can swap at runtime; and the whole thing emits instances only for what is visible. The proof that the boundary held is a single number — the sim state-hash, identical to chapter 3, tick for tick, checked natively with no browser or GPU. The simulation does not know it is being drawn; the view is a projection you can replace wholesale.

Source for this chapter: view.h · render.c · render.h · gen_meshes.c · mesh_data.h · wasm.c · app.js · test.c · README · CONTRACT · ASSETS