t4as

Software & Sims · 2026

Seeing Through Stone: A Top-Down Camera for a Generated 3D World

Good world generation is like an ogre: it has layers. This one has six, and all of them are procedural, from the plan of a city and the underworld beneath it down to the last crack and tuft of grass. Nobody designed its rooms, so a top-down camera in Godot 4 can't rely on hand-authored roof hiding. I built occlusion from the generator's own records: a line-of-sight cube decides what the player sees, a wall-following cutaway decides what the camera may remove, and rendered tests check both.

  • Godot 4.7
  • GDScript
  • GLSL shaders
  • Python
  • NumPy
  • SciPy
  • Signed distance fields
  • Dual contouring
  • glTF 2.0
largest jump of the cut boundary for a half-degree camera orbit, before and after (243 sampled angles)
6.6 m → 0.6 m
rendered views in the stability test, with the production shaders, coarse and fine meshes
524
frame time at an underground pose after the visibility fix (RTX 3060 Laptop, 1600×900, MSAA 4×)
17.1 → 15.4 ms
triangles in the generated world (seed 29), built in 266 s; fine detail streams in around the player
2.66 M
Top-down view of a generated underground court: a ruined stone hall with vines, a sunken passage and doorways, lit by the player's lantern; the foreground wall is cut away

Result

Nothing in this world was placed by hand. A seed goes in, and out comes a sunlit city of courts and towers on hills, brick sewers under it, and below that an underworld of rooms at every height around deep voids, down to the last crack in the paving and the last tuft of grass. The six layers build on each other: an abstract plan comes first, then geometry, then the glue that makes separately planned pieces one place, then the mesh, then the surface wear, and finally the plants. Each layer only works on what the layer below has decided.

The game camera looks down at a walking character from 18–40 m away. In a world like that, almost every frame has something between the camera and the player: a roof, the floor above, the wall of the room you just left, 50 m of rock.

Walking through the generated underworld (seed 29) with the game camera. The foreground opens as the player moves; side and back walls stay standing; black is everything the lantern cannot see.

What works now:

  • The player sees only what the lantern sees. A depth cube rendered from the light decides visibility per fragment. Underground, the next room over is not dark, it is absent, so the screen never gives away a layout the character could not know.
  • The camera sees the player, and the room stays readable. A cutaway follows the walls of the room the player stands in: level above the head, rising vertically at the side and back walls, open through anything on the camera’s side. The same code runs on the surface and underground.
  • It holds up under a moving camera. Orbiting by half a degree moved the cut boundary by up to 6.75 m in the first version. It now moves by less than 0.6 m, and a rendered test checks the floor at the player in 480 views of room centres and corners, plus a 44-view walk between rooms.
  • No authoring. Room footprints come from the generator’s plan files. Regenerate the world and the occlusion works on the new one.

The problem: a camera above a world with no storeys

Games with a top-down or isometric camera usually know their buildings. A level designer marks the roof to fade, puts the cut plane at each floor height, and places trigger volumes at doors. Where levels are mostly flat, one cut height works everywhere.

This world has none of that. The surface spans 50 m of floor heights across 179 spaces, joined by 59 causeways, helical ramps and switchback halls. Underground, the composite generator has no storeys at all: rooms attach wherever they fit, every cluster has its own slow tilt, and long passages rise by heavy-tailed amounts. A horizontal cut plane that clears the roof of one room slices the next one through its middle.

So occlusion had to answer two questions from data the generator already writes, and I kept them apart on purpose:

Question Answered by Geometry it uses
What may the player see? line-of-sight cube from the lantern the complete world, nothing cut
What may the camera remove so the player stays visible? wall-following cutaway the room footprint the player stands in

Mixing them is the classic failure: cut a wall for the camera and the light passes through the hole too, so the player suddenly sees the next room. In the shader the split is one branch. The cube’s cameras carry an extra cull bit, and on that pass nothing is ever cut (viewer/lantern.gdshader):

bool depth_pass = (CAMERA_VISIBLE_LAYERS & (1u << 19u)) != 0u;
// The depth cube keeps the complete world for lantern shadows. Only the
// game camera opens this foreground window, for every lighting style.
if (!depth_pass && room_cut_hidden(world_pos, FRAGCOORD.xy)) {
	discard;
}
if (depth_pass) {
	// the line-of-sight cube: distance from the lantern, both faces, nothing cut
	ALBEDO = vec3(0.0);
	EMISSION = vec3(length(world_pos - CAMERA_POSITION_WORLD) / lantern_far);
}

Line of sight: a hand-made omni shadow map

Godot does not expose an omni light’s shadow map to a material shader, and I needed more than shading anyway: a fragment the light cannot reach has to be discarded, not drawn black. So the lantern carries six 90° cameras rendering into 512 × 512 float targets. Each stores the distance to the nearest surface; the main shader projects every fragment into the right face and compares.

Six extra renders of everything around the player is the most expensive part of the viewer, so the faces are budgeted. They are redrawn only when the lantern has moved 2 cm, two per frame in turn, plus a slow refresh every 0.5 s for chunks that streamed in. When the player runs and the oldest face falls behind, the budget grows (viewer/lantern_cube.gd):

# more faces a frame when the shadows fall behind (running, low frame rate)
var per_frame := clampi(int(ceil(lag() / 0.4)), min_faces, 6)
var done := 0
var start := _next
for i in 6:
	var k := (start + i) % 6
	if _dirty[k] and done < per_frame:
		viewports[k].render_target_update_mode = SubViewport.UPDATE_ONCE
		_dirty[k] = false
		_face_pos[k] = _last_pos
		# the shader measures this face from where it is drawn now
		RenderingServer.global_shader_parameter_set("lantern_face%d" % k, _last_pos)
		done += 1
		_next = (k + 1) % 6

Staggering creates a subtle problem: the six faces were rendered from up to six different lantern positions. The shader therefore stores each face’s own origin and measures a fragment from that origin, re-choosing the face if the two origins disagree near a cube edge. Shadows trail a running player slightly instead of tearing at face seams.

The rest of the lantern is built on the same lookup. Soft shadows use a blocker search followed by a penumbra ring sized from the blocker distance (PCSS). The comparison bias grows with the slope, because a grazed wall changes depth quickly across one texel and a fixed bias leaves saw teeth on it. Visibility is decided by the wall’s macro normal, not the detail relief, so bumps turned away from the light do not punch a leopard pattern of holes into a lit wall. Bioluminescent plants and fire light are gated by the same lookup, so nothing glows through a wall.

God vision is the deliberate exception. Surfaces near the player that face the light but are hidden from it are drawn faint and cool. They are excluded from a cone between the light and the camera, where they would hang in front of the player. It gives a sense of the rooms around you without spoiling them.

The cutaway: follow the walls, not a plane

The camera-side problem took the most iterations. Each approach below was built, captured at the same poses and compared before the next:

  1. A broad tube from the camera to the player. It cleared the view, but also removed objects that weren’t in the way.
  2. A narrow perspective aperture. Precise, but it read as a small hole in a dark mass. I rejected it after review.
  3. A wider projected aperture. Better, but it still sliced walls diagonally, which makes a room hard to read.
  4. A wall-following section, which is what runs now.

The section takes the footprint of the room the player stands in. The generator records every space as polygons with a floor and ceiling per part, so the viewer can tell stacked rooms apart and keep a tall ramp hall selected while the player climbs it. The polygon becomes at most 16 edge planes, published as shader globals. Each world fragment then computes one signed distance to a bent surface: level 2.45 m above the feet inside the footprint, vertical along the footprint’s edges.

Plan and section of the wall-following cutaway Plan: the room the player stands in player side and back walls kept (boundary inset ≤ 3.5 m) sight corridor 0.65 m toward the camera dashed: the cut footprint; camera-facing walls open past the camera Section: what is removed camera removed: above head height, inside the footprint cut at feet + 2.45 m foreground wall cut at head height back wall and roof edge kept
Figure 1. The cut is a shader mask, not a mesh or collision change. Back and side walls stay at full height a few metres from the player, so the room keeps its shape. Walls on the camera's side open through their thickness. A narrow corridor from the feet toward the camera keeps the floor visible when the player hugs a corner.

Corners are rounded geometrically, not by fading opacity: a polynomial smooth maximum over the edge distances with a 0.9 m width. A static ripple of at most 0.22 m breaks up long, ruler-straight cut lines. The final min keeps a corridor from the feet toward the camera open, whatever the rounding and the ripple do near a corner (viewer/common.gdshaderinc):

for (int i = 0; i < 16; i++) {
	if (i >= room_cut_edges) break;
	float edge = dot(edges[i].xy, point) - edges[i].z;
	// Smooth the polygon's corners geometrically, not by fading opacity.
	float h = max(0.9 - abs(boundary - edge), 0.0) / 0.9;
	boundary = max(boundary, edge) + h * h * 0.225;
}
// A restrained, static ripple breaks up long ruler-straight sections.
boundary += 0.14 * sin(point.x * 0.71 + sin(point.y * 0.53))
	+ 0.08 * sin(point.y * 1.37 + point.x * 0.31);
// Keep a narrow rounded sight corridor above head height when hugging a corner.
return min(boundary, sight_distance - 0.65);

When the shared cut broke the underground

Running one cutaway above and below ground was the right design, but the first version came back with five reported defects, including underground views that had worked before. I checkpointed the source and the five reports as screenshots with exact poses, then fixed forward instead of reverting the new surface work.

Two of the five reports, before (left) and after (right), at the same poses:

Before: a wall that coincided with the cut's soft opacity band stayed as a purple, dithered ghost across a large area, and the terrain behind showed through

After, same pose: a binary section mask; the cut solids read as opaque dark stone with faint veins, and the floor at the player is clear

Before: underground at −148 m, surface ambient light lit terrain and rooms the lantern could not see, so the whole level was visible through the walls

After, same pose: only what the lantern's depth cube can see is drawn; the room and the two passages out of it

Three findings decided the fix:

  • The leak wasn’t in the cut. Underground terrain showed through walls because a new surface “context” ambient and the per-fragment daylight were added independently of the lantern cube, at every depth. Turning up brightness would have made it worse. Each room now carries a surface or underground tag from the plan, and context and daylight apply only to surface rooms. Underground visibility comes from the cube alone.
  • Soft edges were the problem, not a fix. The boundary used a smooth opacity band rendered as Bayer dither. Any wall lying along that band stayed half-visible across its whole face. The section is now binary, and MSAA handles the edge. Where the cut exposes the inside of a solid on the surface, back faces are shaded as opaque dark stone, so a cut reads as a cut through rock, not a hole into the void.
  • Two of the five bug poses were outside every room. The obvious suspect was the camera-facing threshold. The recorded positions showed that the player was standing in door gaps and on open ground, where the code fell back to a fixed 7 × 8 m rectangle. That box was what looked wrong. Changing the threshold would not have touched it. The fallback is now an analytic capsule from the feet toward the camera.

Making it stable under a moving camera

The next review was about motion. A wall counted as “facing the camera” when its normal’s dot product with the camera direction exceeded 0.05. Below that it kept its inset, above it opened past the camera. At a 28 m camera radius, a half-degree orbit could move a boundary by 6.50–6.75 m. In the generated gatehouse that looked like a large column popping in and out between two nearby headings.

The fix is to interpolate the boundary’s position, never its opacity (viewer/cutaway.gd):

func wall_limit(normal: Vector2, limit: float, feet: Vector2,
		camera: Vector2, toward_camera: Vector2) -> float:
	var clearance := maxf(0.0, limit - normal.dot(feet))
	var inset := minf(wall_clearance, clearance * CLEARANCE_SHARE)
	var retained := limit - minf(inset, maxf(0.0, clearance - PLAYER_CLEARANCE))
	var opened := maxf(limit + 5.0, normal.dot(camera) + 0.5)
	# Move the vertical boundary continuously when a wall becomes foreground.
	# Interpolate its position, never opacity: the RPG section stays solid.
	var facing := smoothstep(0.0, 0.15, normal.dot(toward_camera))
	return lerpf(retained, opened, facing)
Boundary position against how much a wall faces the camera, old switch and new blend −0.05 0 0.05 0.10 0.15 0.20 0.25 opened kept wall normal · direction to the camera (horizontal) old: switch at 0.05 now: smoothstep 0 to 0.15
Figure 2. Where a wall's cut boundary sits as the camera turns, from the code above. The old switch jumped the whole distance between "kept" and "opened" at one angle (6.50–6.75 m in the sweep). The blend spreads it over about 8.6° of heading. Walls at or behind side-on keep their full clearance.

Two smaller changes came from the same tests. The profile cache used to refresh only after the camera moved 0.4 m, but a steep camera can orbit a long way within that distance. It now also refreshes when the horizontal view direction turns by more than about 0.35°. And a narrow 0.65 m corridor from the feet toward the camera, with margin around the player capsule, keeps the floor readable in corners. I tried a small circle around the player first, but some corner angles still failed.

How it is tested

The cutaway is a visual feature, so the tests render. A Godot fixture builds synthetic rooms with a low roof, so a failure is obvious, and renders them with the production world and lantern shaders, coarse and fine materials, through a perspective camera:

Check Size Result
Boundary sweep: 81 half-degree headings × clearances 1.5, 3.5, 5.0 m 243 samples every adjacent change < 0.6 m (was up to 6.75 m)
Room centre + 4 corners × normal/steep camera × 12 headings × 2 shaders × coarse/fine 480 renders floor at the player readable in all
Walk room → gap → room, 11 positions × shaders × materials 44 renders floor readable through every room switch
Underground depth cube: terrain behind a wall, with surface daylight available all 3 effects, coarse/fine hidden; removing the wall reveals it

The fixture’s first run, before the final fixes, recorded 27 failed grouped assertions: three boundary jumps and 24 corner batches. That log is kept as evidence of the failure, alongside before/after captures at the same real-map poses.

What it costs

Same map, warm cache, Godot 4.7.1 Vulkan Forward+, RTX 3060 Laptop GPU, 1600 × 900, MSAA 4× + FXAA, VSync off; 90 measured frames after 30 warm-up frames. “Cube redrawing” forces the depth cube to redraw every face.

Pose Before the visibility fix After After the stability pass
Surface, standing 9.93 ms 10.42 ms 9.09 ms ¹
Surface, cube redrawing 12.04 ms 12.50 ms 11.20 ms ¹
Underground hall, standing 17.07 ms 15.38 ms 15.87 ms
Underground hall, cube redrawing 19.44 ms 17.68 ms 17.90 ms

¹ The stability pass was measured at a slightly different surface heading (57° instead of 63°), so compare it with its own baseline: 8.96 → 9.09 ms and 11.07 → 11.20 ms. Across all four poses that pass cost 0.11–0.15 ms. These are single warm-cache samples at fixed poses, not a frame-time guarantee for the whole map.

How the world is built, layer by layer

The occlusion only matters because of what the generator produces, so this section covers how it gets there. The core idea is unchanged from the first version: the world is one ordered program of solid-modelling operations over signed distance fields, and every triangle traces back to the record that made it. What is new is what gets built, and that it is built in clearly separated layers. The expensive layers run once per seed. The fine ones run only near the player.

The six layers of world generation, what each produces and when it runs what the layer produces when it runs (seed 29) 1 Plan2 Geometry3 Glue 4 Mesh5 Surface6 Plants terrain, rooms, routes, sewers: records only the plan as 3,415 ordered CSG operations joins, bridges, ramps, soft unions, weld 1.25 m dual contouring: 2.66 M triangles relief, wall structure, damage, decoration ivy, grass, moss, fungi, flowers once per seed, ~14 s once per seed once per seed once per seed, ~4 min per 10 m chunk, ~0.2 s per 10 m chunk, ~0.37 s pre-generated world.glb 0.3125 m, near the player
Figure 3. Layers 1 to 4 make the exported world once per seed. Layers 5 and 6 add detail that only a fine grid can hold, so they run on 10 m chunks around the player, streamed live or baked in advance. The timings are from the seed-29 generation log and the per-chunk measurements on the test map.

Seed 29 from 270 m up, in free flight: the whole city grown on its hills, coarse mesh only, at 125 fps

1. The plan: decide what exists before building anything

The first layer produces no geometry, only records: which spaces exist, where, how high and how they connect. It is fast (about 14 s for seed 29, of the 266 s total), so the design can be iterated as a plan and looked at in the viewer’s schematic mode seconds after a run.

  • The city is grown, not placed. Rooms from a catalogue (courts, gatehouses, pavilions, long halls, gardens) attach to free sockets on rooms already placed, with irregular footprints, drifting floor heights and circulation loops. Ramp towers, switchback halls and stacked terraces are weighted six times higher, so the city climbs. Seed 29 has 179 surface spaces, 32 of them vertical modules, with floors spread over 50 m and 59 causeways between them.
  • The underworld has no floors. The underground is a composite field, built from an overnight round of six experimental generators, each rendered and judged on the same seeds. A landmark (a screw shaft, or a building hanging in a void) is reserved before anything grows. Rooms attach wherever they fit, each cluster with its own slow tilt, and long passages rise by heavy-tailed amounts at a gentle grade, so a walk drifts up and down with no common datum. The rock masses that growth avoided become deep voids. Seed 29 has 376 spaces and 299 doors in that field.
  • Infrastructure is routed, not drawn. Freight, coolant, power, ventilation and maintenance each have a cross-section, a preferred depth and a slope limit. A demand graph decides what connects to what, and every route is solved through the rock with Dijkstra on a 24 × 24 × 12 m lattice, with costs for depth, hardness and distance from other systems.
  • The sewers tie the layers together. The underground freight trunk becomes the city’s main collector. Brick-vaulted sewers run as a spanning tree about 13 m under the lowest courts, with eight ramp entrances from courts, three spiral-stair drain shafts down to the trunk and four passages from it into the ruins.

The plan of the same region in the viewer's schematic mode: every planned space and route drawn as a coloured box or tube by category, before any geometry exists

2. Geometry: the plan as an ordered program

Each record is then realised as operations on a signed distance field: union, subtract, smooth union or intersect a shape, tagged with a material and the id of the record it came from. Seed 29 is 3,415 operations. Order is history, so later work cuts through earlier work, as it would in a place that was built over for ages. A field makes intersections free: a tunnel meeting a hall, or a vault running under a ramp, needs no special mesh-boolean case.

The ground is shaped to the plan too. In the first sunlit city, 103 of the 212 rooms checked were floating above the ground. Instead of moving the rooms, landscape() reshapes the ground after the plan is grown: a hill rises under every raised room and falls away at an irregular slope, while open courts are kept open (mapgen/overworld.py):

if s["open_sky"]:
    # keep the court open, except under a higher room standing over it: there
    # the hill carries that room and the court runs on underneath as a hall
    open_ = d < 0.6
    for t, Q, q0 in supports:
        if q0 > z0 + 6.0 and np.all(Q.min(axis=0) < hi) and np.all(Q.max(axis=0) > lo):
            open_ &= poly_sdf(Q, X[win], Y[win]) > 2.0
    cap[win] = np.where(open_, np.minimum(cap[win], z0 - 0.3), cap[win])
...
# hill under the room: its floor, falling away at 0.47..0.77 (irregular flanks)
slope = 0.62 + 0.15 * rough[win]
top = z0 - 0.5 - np.maximum(d, 0.0) * slope + 2.0 * rough[win] * (d > 2.0)
support[win] = np.maximum(support[win], top)

Vertical modules get a hill only at their lowest level, because filling a ramp tower level by level would block its own ramps. The result: zero floating rooms. Terrain overhangs and strata are calmed within 15–40 m of buildings, where they had peeled floating flakes off the built cliffs.

3. Glue: make separately planned pieces one place

Pieces planned on their own don’t meet by themselves. Most of the work that makes the world walkable happens here, in two kinds of glue.

In the plan. Rooms that happen to face the landmark are joined to it, meeting the shaft’s helical ramp at the height where it passes. Two rooms facing each other across a void get a bridge. Doors between rooms of a tilted cluster step by up to 1.25 m on a short ramp. Six cross-passages link parallel bores in seed 29. Short link doors on the surface are cut at least 2 × wall + 2 deep (8.4 m at the 3.2 m wall thickness) so that no later shell seals an earlier opening. Ramp floors are re-laid after the sewer vaults, so a vault passing underneath leaves a sloped deck, not a ledge. The order matters as much as the shapes: built walls are established before interiors are cut.

In the geometry. CSG joins are softened with a smooth-union radius of 0.8 voxels of the grid being meshed, so the joins stay soft at every resolution. Sharp joins at an angle to the grid are what dual contouring turns into saw teeth. Anything thinner than about 2.5 voxels is raised to that minimum or left out, because a sampled field can’t hold it cleanly. After meshing, chunks share bit-identical border vertices, so the whole world is welded by exact position and floating specks are removed. That was 3 components and 34 triangles in seed 29.

4. Mesh: one solid world, pre-generated

The program is evaluated in chunks; chunks that are pure rock or pure air are skipped and the rest run in parallel workers. Each chunk is meshed with surface nets and dual-contouring vertex placement, so walls are flat and corners sharp. For seed 29: 366 of the 471 chunks produced a surface, 2.66 M triangles in 129 s, then a 2.5 m LOD (677,000 triangles) in 87 s. The whole run takes about 4.5 minutes.

That is why the main geometry is generated ahead of time, even for testing. A seed is generated once, and every test session, screenshot run and the demo itself load the exported glTF instead of waiting for the solver. The generator is deterministic and its tests check that the same seed gives an identical geometry digest, so a regenerated world is the same world.

5. Surface: what the walls carry

Bare CSG looks like a model kit. The surface layer adds what the shapes would carry, as a displacement of the field within 1.2 m of a surface, so open space and solid rock cost nothing:

  • Natural rock gets stone distortion, gentler on floors.
  • Built walls get pilasters or ribs (ring ribs in tunnels) and string courses. Spacing, width and depth are chosen per 24 m cell, so different districts look built by different hands, and in decayed patches the structure fades back to stone. Floors stay flat so walking is unchanged, and ceilings are left alone because the game camera cuts them away anyway.
  • Placed features come from a jittered lattice of anchors snapped onto walls and floors: blast craters and gouges in battle-scarred areas; niches with glyph panels, plaques and pipe runs as decoration; and dense imperfections, about one anchor every 3 m. On walls those are cracks, spalls, soot and water streaks, rubble at the base and vents. On floors they are potholes, sunken slabs and puddle stains, kept low so they never block a path.

The layer is resolution-aware: a feature is only added when the grid being meshed can represent it. The 1.25 m world barely changes; the 0.3125 m chunks around the player get all of it.

6. Plants: grown on the finished surface

The last layer grows plants on the fine field of each chunk, after the surface layer, so they follow the actual relief. Ivy crawls along walls and floors, pulled back onto the surface at every step, wraps over edges, branches and dangles where it runs off a ledge. Grass and fern tufts grow along wall bases, moss in damp patches (some of it glowing), mushrooms in corners and bracket fungi on wall bases. A slowly varying damp field drives growth, and each 32 m biome cell picks its own palette and how much glows. Sunlit ground gets wind grass, wildflowers, reeds and small succulents instead.

Like the surface features, every plant hangs on a global lattice and belongs to exactly one chunk, so nothing is grown twice and neighbouring chunks agree at their seams.

A four-way junction in the city sewers: brick vaults with a drain channel, vines and fungi; the vault on the camera's side is cut away

The main collector at −120 m: a vaulted run with a drain channel down its middle, with the debug overlay showing streamed detail: 502 chunks, 82 fps

An underground court: broken walls, a sunken passage across the floor and doorways at different heights

Flying just under the surface at −17 m, no cut: the underside of the city at the top, the underworld's rooms below, tunnels threading between them and a spiral-stair shaft on the left

Generated on the fly, preheated ahead of the player

The world is a function, not a file. The exported GLB is only one meshing of it, at 1.25 m voxels: fine for architecture, too coarse for anything you look at from 20 m. Close to the player the game asks the generator itself, and that is where layers 5 and 6 run.

On demand. A detail server rebuilds the same SDF program from the world’s config (it is deterministic, so it is the same world) and meshes it at 0.3125 m in 10 m chunks, with everything the surface and plant layers add. Everything is hashed from world position, so a chunk comes out identical whichever way it is reached, and neighbours agree at the seams. Chunks that are pure rock or pure air are answered without meshing. On the viewer side, chunks become meshes within a 5 ms per-frame budget, and only chunks within 14 m get walking collision. The shaders draw the fine mesh inside the “ready radius”, the largest region around the player whose chunks have all arrived, and the coarse world outside it, so there are no holes while detail streams in.

Preheated. Meshing a chunk takes hundreds of milliseconds (about 0.2 s of geometry and 0.37 s of plants per chunk on the test map), so the server works ahead of the player. The viewer sends its smoothed velocity with its position, and the server loads around where the player will be (mapgen/detail_server.py):

# look ahead: load around where the player will be in ~0.7 s (at most 15 m ahead)
ahead = vel * 0.7
if np.linalg.norm(ahead) > 15.0:
    ahead *= 15.0 / np.linalg.norm(ahead)
near = self.wanted(pos, min(radius, 15.0))
want = near + [k for k in self.wanted(pos + ahead, radius) if k not in set(near)]

The nearest 15 m always comes first, then the full radius around the predicted position. Idle workers fill a wider ring beyond it. Finished chunks stay in a RAM cache and a disk cache whose key hashes the generator code, the config and the detail settings, so changing any of them starts a new cache by itself and revisits load at once. Running at 14 m/s with a warm cache, fine detail was ready within 15 m of the player in 98 % of frames. Navigation is preheated the same way: the walkable graph is prepared 55 m around the player and 55 m around where they will be in four seconds, and gameplay code can call navigation.preheat(destination, radius) before an NPC sets off.

Baked for the demo. The demo is pre-cooked: every detail chunk of the map, with every crack, decoration and plant, is generated in advance and written in the same frame format the server streams. That is why the download is 2.7 GB for a 640 m world. Baking about 34,000 chunks takes 16 minutes on 14 workers. Done live on a player’s machine, the same work would keep every core busy for a long time after the game opens, and the detail would arrive late wherever the player went. The viewer reads the baked chunks from disk instead of a socket, so the download also runs without Python. Fine and coarse collision use separate layers, and the player stays on fine collision while fine floor is under the feet. A fall recorder (the last 20 s of input, position and collision state, saved on any 12 m drop) found the cause of falls through the floor before the build went out: a key handler reset the collision mask to coarse for one physics step, and a player standing in fine-only relief dropped through the coarse floor below it.

Towards an unbounded world. Evaluation and detail already work per chunk, with nothing stored. What still assumes one finite region is the planning: terrain erosion, the surface layout and the underground plan are each solved over the whole 640 m map before anything is meshed. The planned route is a hierarchy of lazily planned cells (16 km provinces, 2 km regions, 256 m blocks, 10 m chunks). Each cell decides only what fits inside it, from its seed and coordinates, and anything that crosses a border is fixed one level up as a contract: where a trunk route enters, the terrain height along the edge. Any cell could then be generated in any order, on demand, with the same result. It is a design so far, not code. The self-contained steps come first: chunk keys without a fixed region, the coarse world streamed as LOD rings instead of one GLB, and origin shifting in the viewer.

Try it

The demo (2.7 GB) is a Linux x86_64 build (any GPU with Vulkan; the bundled official Godot 4.7.1 needs glibc 2.28 or newer). Unpack it and run ./run.sh; the first start takes 10–20 s. It opens in a visitor mode: the arrow keys jump between curated places (the city, its sewers, the ruins far below), F switches to free flight with its own set of views, G toggles god vision and H shows the controls. Windows and macOS builds aren’t made yet.

Where it goes next

The open items are concrete, and each starts from a captured pose:

The open surface with god vision on: the cut solids show as dark stone with pulsing veins, but at this grazing angle the cut faces through the terrain don't yet line up cleanly

  • Cut faces at grazing angles. The dark section stone is a back-face shading trick that keeps the real depth. It does not generate cap geometry, so grazing views and disjoint solids along one ray can show seams (above). Generated cross-section caps are the next step.
  • Concave rooms. Footprints are treated as intersections of at most 16 half-planes. The current maps have at most 14 corners per surface room, but a concave room needs a decomposition, and a fixture for it comes before the change.
  • A walking audit. The tests cover selected rooms and transitions. Next are scripted walks with orbits through doorways, ramps and stacked rooms across the whole map, plus a check that a court never reveals the sewer vault below it.

The workflow stays the same: a failing representative case first, one bounded change, the same poses captured before and after, and the cost measured on the same hardware.