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) · scaleThe 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:
- LOD0 — the full art (~3,500 verts), for the near screens.
- LOD1 — a textured imposter (~40 verts + a texture): the cheap
box-and-roof cage, but each face samples a picture of the real building. Offline, a tiny
CPU rasteriser renders LOD0 orthographically from the top and the four sides and packs those
projections into one atlas (
town_atlas.png); the cage's UVs index it. So a mid-distance block keeps its windows and doors for forty vertices — it just wears them as a texture. (This is the render-to-texture imposter workflow, baked in JS.) - LOD2 — the same cage in a flat, colour-matched massing (no texture), for the far screens where even the projection is a smear.
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)
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