Skip to content

Far rendering log

The dated record of the far field's construction: what landed at each rung of the plan, what was measured and what went wrong, oldest first. It is a log, so an entry describes the code as it was on its date. How the far field works today is in Far rendering.

New entries go at the end, under a ## heading that ends with the date.

What landed at rungs 2 and 3, 2026-08-21

src/goanna_lod.{h,cpp} holds the chain and the region mesher, free of Godot and of the session so the store can feed it later and so it can be tested offline. GoannaClient (lodAssign, lodBuildRegion, lodRebuild in src/goanna_client.cpp) keeps the membership, the dirty set and the region meshes, and converts the output to surfaces. The single tier, single block mesher it replaces (meshBlockLod) is gone.

Measured against a local Mineclonia server, Luanti 5.16.1, Godot 4.5.1, from one fixed viewpoint 55 nodes above a jungle, view range 12: full detail drew 234 blocks in 3,454 draw calls at 287 fps; with tiers starting two blocks out, 15 blocks stayed at full detail and 220 were drawn at tiers 1 to 3 in 10 region meshes, 31 surfaces, 306 draw calls, 377 fps. A region build costs about 1 to 2 ms at any tier. The two frames are from two runs of the same player at the same view, so the streamed set differs slightly; the draw call ratio is the result, the frame rate is indicative.

Three things were wrong on the first run, and each is worth keeping:

  • Every cell was drawn inside out. The old mesher's quads were wound counter clockwise, which Godot culls as back faces, so what rendered was the underside of every cell seen through its missing top. Flat colour and fog had hidden it for as long as that mesher existed; the array shader showed it at once as a world of slate grey undersides lit only by sky. Painting the light channels as emission (debug_nodelight) is what told the two apart: the channels were right, the geometry was not.
  • A cell with anything in it drew as a full cube. At cell 16 a single leaf made a cliff, and a jungle canopy became a wall of cubes. A cell now draws when half a layer's worth of nodes fills it, which keeps a floor, a canopy and a two by two trunk and drops a post and a stray branch, and it blocks light when a full layer does. Both thresholds are named constants in buildLodChain waiting to be calibrated on the chart, as this file already asked.
  • Leaves did not count. Luanti classes leaves and glass as solidness 0 with visual_solidness 1, and the first chain used solidness alone, as the old mesher and the near field occlusion do. A forest at range is its canopy, and without it the jungle meshed as the log tops the trunks had been standing on. What draws is now solid, or cube shaped, or liquid; what blocks light is still solid alone.

And one limit, which is the reason rung 5 exists and was worth seeing plainly: the server sends only the blocks it has generated, within the view cone the client reports, so from a height the far tiers show holes where no block was ever sent, and a view turned away from the reported look direction shows nothing at all. The tiers draw what was received. Making the vista continuous is the store's job, not the mesher's.

Not done here: the fill and occlusion thresholds have not been calibrated against a brute force count; the far field occlusion is visibly weak on the jungle floor and the reason is not yet separated between the thresholds, the radius and the terrain; animated tiles (water, lava) have no array texture and fall back to a flat average colour per face, which is what rung 6 is for.

What landed at rung 5, 2026-08-21

src/goanna_store.{h,cpp} is the store. One directory per server under the root main.gd passes in (user://goanna_store/<host>_<port>), one file per 16 by 16 by 16 blocks (r.<x>.<y>.<z>.gbs): a 64 KB index of offset, length, time stamp and serialisation version per slot, then the payloads appended. A block is written as the server serialised it, on the session thread, as it arrives; a rewrite leaves dead bytes that are folded when they outweigh the live ones; a cap (512 MB, GOANNA_STORE_CAP_MB) evicts whole region files oldest first. There is no new format for a block, only for the file around it, and the format is plain enough to read with a hex editor.

The session (saveBlockToStore, loadStoredBlock, storedRegionMask) writes on receipt, marks blocks edited by ADDNODE and REMOVENODE, and writes those back when they are pruned or the session stops, so the store holds the world as last seen rather than as first sent. farRenderingGrant reads the far_rendering and far_rendering_distance options the server mod announced; without them the store still writes (it is the player's own record of what they were sent, like a screenshot folder, and costs a capped directory) and nothing reads it.

The client (lodUpdateFar, lodChain) is the consumer. Every two seconds or when the player crosses a block, it asks the store which blocks exist within the lesser of the grant and far_distance (512 nodes by default, GOANNA_FAR_DISTANCE), skips the ones the server is sending, assigns the rest to tiers exactly as received blocks are assigned, and lets go of those that pass out of range. The region mesher does not know the difference: lodChain builds a block's chain from the live block if there is one and from the store if not, and frees the stored block as soon as the chain exists. A live block arriving for a stored one takes over on the next poll. The far field occlusion reaches beyond the live range the same way, because the tracer reads chains.

Staleness is marked, not hidden. A chain built from the store sets CUSTOM0.a on its faces to 96 rather than 255 (docs/systems/mesh-attributes.md), and the node shaders pull such surfaces toward grey by stale_strength (0.6 by default, a material strength channel like the rest), so remembered terrain reads as remembered.

Measured against the test Mineclonia server, which grants 512 nodes: a 25 second visit to the spawn wrote 331 blocks (688 KB); a second run that went 400 nodes away and looked back found 306 of them in the store, drew them as tier 3 from the store while the server sent the new surroundings live, with no errors, and the frame shows the spawn jungle as grey green cells beyond the live edge. That is the first frame of this client that drew terrain the server was not sending.

Goanna's own local server grants it: project/net/local_server.gd writes goanna_far_rendering = true and a distance of 1024 into the conf it launches with, and copies goanna_server_mod into the world's worldmods so the grant reaches the client over the channel.

Not done: a settings entry for the far distance and the stale strength (both are environment variables and bound methods today); the store's contents are not pruned against a world reset, only aged out by the cap; and the scan over stored regions is linear in the region count, which is fine at 512 nodes (125 regions) and would want an index at 4000.

What landed at rung 7, 2026-08-21

The store can only ever show you where you have been, and that is a real limit on the idea: the first thing anyone tries is looking at a horizon they have not walked to, and seeing nothing there. Distant Horizons answers that by generating the terrain locally, which this project rejects outright, and Voxy does not answer it at all, being client side. The answer that fits the boundary rule is the server: it already holds the terrain, so let it give what it chooses to give.

goanna_server_mod answers farsum? requests. It reads already generated map through a VoxelManip, never generating any, and replies with 21 bytes per mapblock, protocol version 2 since 2026-08-22: occludes, lit, liquid, a 4 by 4 grid of surface heights over the block's footprint (one byte each, 0 to 16), the commonest top and side node (as an index into a name list in the same message), and the light levels. Version 1 sent one height for the whole block, so a slope summarised as a stepped box; see goanna_server_mod/README.md and the protocol comment above GoannaClient::lodTakeSummaries for what changed and why an old mod and a new client refuse each other's messages rather than misreading them. An 8 by 8 by 8 block area is about 14 KB on the wire against roughly a megabyte at full resolution. Requests beyond goanna_far_rendering_distance of the asking player are refused, and the work is paced by goanna_far_summary_blocks_per_step (32 mapblocks a step), so an area costs a second or two of wall clock and a small slice of each step; the larger record does not change that bound, since it is still one VoxelManip read per block.

The client (lodRequestSummaries, lodTakeSummaries in src/goanna_client.cpp) asks for the nearest area that is not entirely live and not entirely known already, four in flight, never twice, skipping anything already fully covered by the live range or the store. A reply becomes a chain per mapblock at whichever tier's cell is the finest this client can use at 4 nodes or coarser (cell 4 at the default lod_cell), marked stored so it renders with the same staleness treatment, and the region mesher draws it through the same path as everything else: tiers, merging, water, occlusion, the fade, and now a real slope instead of a flat top. No new rendering code at all, which is what the chain was built for.

Measured against a Mineclonia server holding terrain a previous player had explored: a fresh client with an empty store, a 96 node live range and the 512 node grant drew 1087 blocks of terrain it had never visited, at 139 fps, with the worst poll at 7.6 ms. Areas the server has not generated come back as holes and stay holes.

Two limits are worth knowing. A mod channel has no unicast, so replies are broadcast and filtered by the requester name they carry; the mod README says what that means for an operator. And a summary carries no data finer than 4 node cells, so distant terrain from this path is coarser than the same ground would be from the store, which keeps the full block; walking there replaces it with the real thing.

Pregeneration, 2026-08-22

A third limit turned up the first time a world was launched from the client: a summary can only describe terrain that exists, and a server generates only within the range its client asks for (max_block_generate_distance is capped by the client's wanted range in clientiface.cpp, and Goanna asks for 12 blocks). So a fresh world has a 192 node horizon whatever the grant says, and the player who just created it, the one most likely to look, sees nothing at all past the live edge.

goanna_far_pregenerate is the server's answer, off by default because it spends mapgen time and map memory on terrain no one has visited. When on, the mod generates outward from each connected player, one 128 node area at a time, nearest first, with goanna_far_pregenerate_interval seconds between areas, out to the far rendering distance. An area is emerged a slice at a time rather than all at once, for the reason the next section gives. Each finished area is summarised for every client within range without being asked, because a client that asked while it was still ungenerated was told there was nothing and does not ask twice. The client needs no change for this: an unsolicited summary is taken exactly like an answered one. The local server Goanna launches turns it on (project/net/local_server.gd); a public operator decides for themselves.

It stays inside the boundary: the server generates its own world on its own schedule, as it would for a player walking there, and the client never generates or asks for anything a vanilla client could not.

Pregeneration yields to the player, 2026-08-22

Pregeneration shipped in the morning and the near field, the blocks around the player, got worse the same day. The near field is what the client is for, so this is the more serious half of what pregeneration was added to fix, and it was traded away without anyone measuring it.

Why it happened. An area is 8 by 8 by 8 mapblocks and the mod called core.emerge_area on the whole of it. Lua's emerge sets BLOCK_EMERGE_FORCE_QUEUE (luanti/src/script/lua_api/l_env.cpp), which EmergeManager::pushBlockEmergeData reads as "skip every queue limit", so none of emergequeue_limit_total, _diskonly or _generate applied. Each emerge thread's queue is a plain std::queue, and a player's own block request goes through RemoteClient::GetNextBlocks into the same queue. So 512 mapblocks of terrain nobody had asked for sat in front of the blocks the player was waiting for, every goanna_far_pregenerate_interval seconds. Logged from inside the mod, one such batch was 0.0 to 3.1 seconds of mapgen on mineclonia, and the server's get_server_max_lag rose from 0.11 to 0.34 while a batch and its summary ran.

Two smaller wastes turned up in the same read. The area search ranked candidates by horizontal distance only, and the three vertical layers it searches are all at the same horizontal distance, so the tie always went to whichever the loop reached first: the layer below the player. Every column was generated deep stone first and surface last. And a client's own farsum? was appended to the same queue as the summaries pregeneration offers unasked, up to eight of them, each 512 blocks of VoxelManip; worse, the queue limit that refuses a request counted those offers, so pregeneration could make the server silently refuse a player's own ask, and lodRequestSummaries does not ask twice.

What changed, all in goanna_server_mod/init.lua:

  • An area is emerged goanna_far_pregenerate_slice mapblocks on a side at a time, 4 by default, and the next slice starts from the previous one's completion callback rather than on a clock. The queue holds 64 blocks where it held 512.
  • Independent area streams are pipelined by goanna_far_pregenerate_concurrency (two by default and three on the bundled local server). Each stream still has only one slice queued, so mapgen workers can remain occupied without returning to a 512-block forced batch. This was added after testing at flying speed exposed the single stream as the sustained frontier limit.
  • goanna_far_pregenerate_interval now paces areas rather than protecting the queue, so its default drops from 3 seconds to 1. The slices within an area run back to back.
  • The area search ranks the player's own vertical layer ahead of the two beside it.
  • goanna_far_pregenerate_lag stops pregeneration when core.get_server_max_lag() is above it. The default is 0.5 s, five times the server step, and the retry is 0.5 s rather than the full interval: that number is a running maximum which halves every minute (Server::AsyncRunStep), not an average, so a threshold near the step length reads as "behind" long after one slow step. At 0.2 the mod's own summary pass tripped it and pregeneration ran at a third of its rate on a server that was fine.
  • A completed pregeneration offer is queued ahead of speculative farsum? scans, half the summary budget is reserved for freshly generated blocks, and only asked requests count towards the limit that refuses one. This prevents empty-area scans from starving mapgen's completed output. Duplicate jobs for the same client and area collapse into one.

The numbers. Godot 4.5.1, mineclonia 0.90 on Luanti 5.16.1, the local server project/net/local_server.gd starts, on a shared and loaded machine (one minute load average 1.8 to 5.6 across the runs, three other agents working). Each run is a brand new world on a fixed map seed, joined through the menu, 28 seconds to settle, then a teleport to (3000, 90, 3000), which no run has visited, with the camera placed and aimed identically. The far tiers were drawn throughout, at the client's default lod_distance, which matters because turning them off would also have stopped the summary requests: lodRequestSummaries is called at the end of lodUpdateFar, and that returns early when m_lod_distance <= 0. Three or four runs per condition, interleaved so the load is comparable.

Condition +400 blocks after the jump far blocks at 45 s
goanna_far_pregenerate off 3.2, 5.3, 3.6 s 0, 0, 0
on, before 4.9, 5.7, 5.3 s 3548, 3551, 3551
on, after 4.4, 4.4, 3.7, 3.6 s 3937, 4126, 4127, 4128

Far region meshes at the same moment, which is what the far blocks turn into: 0 with pregeneration off, 69 in all three before runs, 96 then 100, 100, 100 after.

Pregeneration off is the closest thing to the vanilla comparison the user made: the same server with the mod's pregeneration loop taken out. It cost 1.3 seconds on a 4 second near field fill before, and costs nothing now, while the horizon fills faster than it did rather than slower. The 45 second far block count is the client's render_stats().far_remote, blocks known only from summaries.

The variance is real and the machine was busy: the off condition's own three runs span 3.2 to 5.3 seconds. Read the run means, 4.0 off, 5.3 before, 4.0 after, rather than any single pair. The far block counts are much steadier because they are paced by the mod rather than by the machine.

What this does not fix. docs/develop/launch-target.md task 8 still stands: the client asks for four area layers either side of it and most of what comes back is sky or interior stone. Ranking the vertical only changes the order, not the volume. The mod also still offers a summary for an area only when its own pregeneration made it, so on a real server, where players walking about generate most of the map, terrain that comes into existence any other way is never offered to anyone. core.register_on_generated is the obvious answer and is not implemented here; it would want the same discipline as above, one area at a time, only outside the live send range, and behind the same lag guard.

The launch target's four defects, closed 2026-08-22

docs/develop/launch-target.md task 2 named four things wrong with the picture rather than the mechanism, found from screenshots rather than from the numbers above, and closed them in the order the task set: each one's result is what the next was judged against.

2a, the seam. lodRequestSummaries skipped an area whenever its centre, not its farthest corner, was inside the live range, and skipped it again whenever any one sampled cell was already known, so a ring straddling the live edge, or one the player had crossed a corner of, was never asked about. Both are now farthest-corner and fully-known tests, and the vertical window widened from one area either side of the player to four, capped rather than matched to the horizontal radius so a large grant does not turn into a scan of mostly sky and stone. No mod change was needed for "the ring around spawn generated by ordinary play": the loosened ask already reaches it, because goanna_server_mod answers farsum? for anything generated regardless of how it got that way.

2b, the surface. The record grew from 6 bytes (one height for the whole block) to 21 (a 4 by 4 grid of them), protocol version 2, and lodTakeSummaries builds the chain at whichever tier's cell is the finest this client can use at 4 nodes or coarser, rather than always at cell 16, so the region mesher's existing per-cell height logic (meshLodRegion, "Cells are not cubes" below) draws a slope from a summary exactly as it already did from a stored block. goanna_server_mod/README.md and the protocol comment above block_summary and lodTakeSummaries carry the wire format and the cost per area (14 KB now, was 3 KB).

2c, the shading. A merged region quad's UV still repeats once per node, which is right at the near end of a tier but aliases into a shimmer once the quad is only a few hundred pixels on screen and no anisotropic filtering is in the sampler. Past a distance in nodes (64 to 256 by default, lod_flatten_near/lod_flatten_far in nodes_array_common.gdshaderinc and water.gdshader) the sampled colour blends toward the tile's own average, the same getTextureAverageColor value the no-array fallback material already used, carried per array layer in a lod_avg_colour uniform set only on LOD materials (GoannaClient::materialFor) so the near mesh's shader, which is the same compiled resource, never engages it. Water at a tier stops waving (there is nothing at 16 node cells for a per node ripple to be) and gets the same colour blend. tools/dev/shotcheck.py --far-band measures the result: Laplacian energy in a horizon shot's far band, low for a flat blended surface, high for an aliased one.

2d, the horizon. m_far_distance now defaults to whatever the server's far rendering grant turns out to be (GoannaClient::lodUpdateFar), rather than to a fixed 512 regardless of a larger grant, and an explicit choice (the settings panel's new "Far draw distance", or GOANNA_FAR_DISTANCE) turns the auto-tracking off. cam.far and the fog density, already tied to the draw distance since rung 4 above, follow the same number and so now reach as far as the grant does rather than stalling at the old default's edge. A second new settings entry, "Remembered terrain tint", exposes stale_strength through the existing generic mat_* mechanism, no new plumbing needed. A HUD line reads "Generating distant terrain (N cells so far)" while render_stats().far_remote is still changing, fading a few seconds after it stops, so a fresh world's first minute or two does not read as broken. And the purple cells: found by reading lodTakeSummaries alongside NodeDefManager::get(name), which defaults to CONTENT_UNKNOWN when a name will not resolve; the summary path defaulted to CONTENT_AIR instead, so a name this client could not resolve became an invisible hole rather than Luanti's own magenta and black unknown_node.png. Now it defaults to CONTENT_UNKNOWN like the rest of the codebase does, so an unresolved name is honestly ugly instead of silently absent.

Cost, measured on this machine (Luanti 5.16.1 flatpak, Godot 4.5.1) against a fresh local Mineclonia world at the default 1024 node grant, through tools/test/test-launch-target.sh: two minutes after joining, far_remote was 6656 blocks across 159 regions and 359 surfaces, 847 draw calls total, the worst poll_blocks call 0.43 ms, and 137 blocks still at full detail near the player. docs/play/requirements.md's draw call budget is not threatened by this; 1024 nodes is the number recorded as affordable on this machine at the default settings, and it is also, not coincidentally, the number project/net/local_server.gd has granted since before this task.

Why distant patches never filled in, 2026-08-22

Reported as terrain that is "only ever partially created": sitting still for minutes, the shattered plates and the gaps in the distant land never close. They were not slow. They were permanent, and the cause was on this side.

A summary describes terrain the server has already generated and reports the rest as ungenerated, which the client drops rather than inventing. That is right. What was wrong is that the client asked for each area exactly once. lodRequestSummaries recorded every area it had asked about and never asked again, so an area that was half generated at the moment it was asked kept that half for the whole session. The only thing that ever corrected one was the mod pushing an unsolicited summary for an area its own pregeneration had just finished, which covers the areas it generates and nothing else.

Worse, the blocks that did arrive hid the rest. The request loop skips an area once its sampled cells are all known, which is the right shortcut for an area the store already covers. A partly generated area has chains for the part that exists, so it could pass that test on the strength of the very blocks that proved it incomplete, and be skipped for ever.

So an ask is now remembered with what came back rather than merely that it happened: GoannaClient::FarAsk holds when the area was last asked and whether every record in the reply was generated. A complete area is finished with and never asked again. An incomplete one is asked again once kFarRetryMs (20 seconds) has passed, and the known looking sample no longer skips it, because an incomplete answer beats a sample that looks known.

Twenty seconds is chosen so that a server generating steadily is not asked the same question every second, and a player standing still watches gaps close rather than waiting out the session. It costs one request per stale area per twenty seconds, against a queue that already holds at most four in flight.

Not measured live. The machine was running four agents, five servers and their clients at the time, and a client launched to watch the retry was killed out from under the measurement, so what is written here is the mechanism and the reasoning rather than an observed before and after. The observation to take is simple and worth taking before this is trusted: stand still with GOANNA_DEBUG_LOD=1 and watch whether an area that first reported few blocks is asked again and reports more.

Background, overlay, foreground, 2026-08-22

The four defects above were closed and a fresh world still read as broken. What was left was not a defect in any panel but the absences between and beyond them: the world was seen ending. A panel that has not arrived is a hole showing sky through the ground, and the outermost panels stop at a line, in clear air, with nothing past them.

Correct panels do not add up to a cohesive field, because the eye reads the gaps as well as the geometry. So the far view is three layers rather than one, and only the middle layer is terrain:

Background, always complete. The sky's lower hemisphere holds a broad band of the horizon's own colour under the horizon line (sky.gdshader's ground_curve, 3.0, set from main.gd), and only darkens toward the ground colour when the camera looks steeply down, where terrain is always loaded. Nothing is invented by this: it claims no terrain, it is air. It costs nothing and it is there from the first frame, which is what the far field cannot be.

Overlay, as much as we have. The tiers, drawn over that background and revealed through the haze as they arrive.

Foreground. The live range.

The layer that joins them is the haze, and the thing that was wrong is what its distances were tied to. Every one of them followed the distance the server permits us to draw. The far field reaches only as far as the store and the summaries have filled, which on a new world is very little and grows for minutes, so the haze was closing hundreds of nodes past the last panel and the edge stood in clear air. Measured on a fresh profile against the test server: far_blocks 0, terrain stopping at the live edge of 192 nodes, fog set to close at the 512 node grant.

So GoannaClient reports far_extent, recomputed on each far rescan, and the haze closes there, floored at the live range and capped by the grant. Where there is nothing to show there is haze rather than an edge, and the view opens as terrain arrives, which reads as weather clearing rather than as a world being built.

The first version of that measurement was one ring histogram around the player, taken at the ninetieth percentile so a straggler across a gap could not report a horizon that was not there. It was not enough, and the way it failed is worth keeping. The frontier is ragged: the store and the summaries fill outward at whatever rate the server generates, so for most of the time a world is filling, the field reaches several times further one way than another. One radius describes the directions holding the most blocks, which are exactly the directions that least need hiding, and leaves the sparse ones ending in clear air. The haze looked right when the field happened to be even and wrong the rest of the time, which is how it was reported: it looks great when it works, and it does not work all that often.

It is now measured per direction: eight sectors, each with its own ring histogram and its own ninetieth percentile, and the extent is the lower quartile across the sectors that hold anything, so it says how far you can see in a poor direction rather than a good one. Sectors holding nothing are skipped, since the live range floor already covers them. Measured at the same spot on the same world, the old figure was 512 nodes and the new one 240, which is the size of the raggedness rather than a change of policy.

The curve was opened up with it, since the same complaint was that there was too little haze: fog_clear_fraction 0.5 to 0.3 and fog_curve 3.0 to 1.6, so the haze begins nearer and builds steadily instead of holding off and then closing hard over the last fifth.

The fog is a depth curve rather than exponential. An exponential cannot be both clear in the foreground and closed at the edge: the density that hides the far edge puts most of its extinction on the mid ground and lays a veil over everything, which is what the first attempt did. Depth fog takes a begin, an end and a curve, so it is clear to half the extent, closes over the last part and is complete at the edge (fog_clear_fraction 0.5 and fog_curve 3.0 in main.gd, swept with GOANNA_FOG_CLEAR and GOANNA_FOG_CURVE). Under water the fog stays exponential, because that murk is a property of the water and starts at the eye.

Measured on the pregenerated world at a 1024 node grant: 4898 far blocks, far_extent 512, haze from 256 to 512, and the far edge dissolving with no boundary visible in the frame.

What this does not fix, and does not pretend to: the panels are still blocky, they still take minutes to fill, and their side faces still read darker than their tops at a low sun. The background makes those less visible rather than untrue. docs/develop/launch-target.md R1 and R3 hold the rest.

Unknown is not air, 2026-08-22

Screenshots of a world mid pregeneration showed free standing vertical slabs of terrain out in the far field, and one pitch black mass standing above the horizon with holes in it. Both are the same fault, and it is a one line reading of a flag the mesher already had.

LodLevel::kKnown marks a cell that contains at least one node which is not CONTENT_IGNORE, so it separates "air" from "never seen". It was set faithfully everywhere, by buildLodChain and by the summary reader, and then never read. meshLodRegion asked only whether the neighbouring cell was filled, and a cell we know nothing about answers no, exactly as air does. So every filled cell along the frontier of what the store and the summaries had filled grew a side face, and the frontier is a plane: a wall. Where the cells behind that wall were underground, their light is zero and their faces are the interior of the ground, so the wall was black.

A side face is now drawn only against a neighbour we know to be empty. Nothing is drawn at the frontier instead, which is the honest answer and the same rule as never inventing terrain: we do not know that surface is there. The haze closes at that frontier anyway, so what is left is air rather than a hole.

Top and bottom faces keep the old rule deliberately. The block above a surface is often one we have never been sent, and applying this there would take the ground's own top face away, which would make the far field invisible from above rather than merely walled.

That held for the rest of the day and then stopped holding. Once a summary marked its own air as air, a cell could carry its own top face without asking the cell above, and the exception's only remaining effect was a lid over every solid column at a mapgen chunk boundary. See "Lids, layers and the vertical walk" below for the narrower rule that replaced it.

The trade this makes, stated plainly: a block missing from the middle of otherwise known terrain now shows a gap where it used to show a wall. A gap in the haze is the better of the two, and the walls were never true, but neither is right and the answer to both is the vertical extent work in docs/develop/launch-target.md task 8, which stops asking for the areas that produce them.

Measured against the pregenerated world at a 1024 node grant, 3034 far blocks and far_extent 800: no free standing slabs and no black masses in frame, and the far field still draws from above.

One light, from your feet to the horizon, 2026-08-22

This is the open half of R1 in docs/develop/launch-target.md, and the reading it opened with was wrong, so the record starts with what the instrument said rather than with the fix.

There is no baked hour, and there never was. The task was framed on goanna_lod.cpp taking a face's light from a cell's stored day and night, so a far tier would carry whatever the light was when that data was made. It does not. LIGHTBANK_DAY is Luanti's sunlight propagation, a visibility term that says how much of the sky reaches a node, and it does not move with the clock; the vanilla client blends it against the night bank by the day/night ratio at draw time, and Goanna does the same job in the shader with goanna_sky_fill and the sky radiance, which main.gd's _apply_sky sets from the time of day. Both meshers store exactly that same value, the near one per vertex in goanna_light.cpp, the far one per cell in goanna_lod.cpp, and the shader is the same shader. Measured, on one running client through the control channel: the CUSTOM0 bytes read straight off the live meshes were identical at time 0.5, 0.25 and 0.0, to the sampled vertex, while the frame changed from noon to midnight. So there was nothing to fix in the mesher's idea of time, and any fix that had moved the stored value would have been moving the wrong number.

What the same instrument did find is two populations where the world has one surface. Reading CUSTOM0.g off every mesh under the client and splitting it by whether the material has lod_flatten set, which is what separates a far tier from the near mesh, on a fresh Mineclonia world at a 1024 node grant with about 9000 far blocks resident:

near mesh far tiers
share of sampled vertices at 255 96.4 per cent 42.6 per cent
share at 238 0.2 per cent 40.9 per cent
mean 247.8 229.5

238 is quant16(decode_light(14)). GoannaClient::lodTakeSummaries clamped the wire's raw light level to LIGHT_MAX, 14, before decoding it, and an open sky column on the wire carries LIGHT_SUN, 15. So two fifths of the far field was reading 234 of 255 sky visibility where the same open ground reads 255 the moment it comes inside the live range, and that 7 per cent went straight into the sky fill and the sky ambient. LIGHT_MAX is the cap for a light source, not for sunlight, and decode_light clamps to LIGHT_SUN itself, so the clamp is gone.

The louder fault was the same defect seen from the geometry side. A summary block wrote a cell only where its heightfield said something was there, and left every cell above the surface at the default flags, which is kKnown clear: never seen. "Unknown is not air" above then culled every side face against them, so a summarised hillside drew one cell of skirt at a step of any height and nothing under it. The far field came out as floating tops with daylight through them, which no amount of shading was going to rescue. A generated block's air is air, and lodTakeSummaries now says so, marking those cells known, and lit with the block's light where the record reports any. It also puts a summary top face's light back on the same footing as the live mesher's, taking it from the air in front of the face rather than falling through to the block's own average.

That costs what the missing faces were saving. On the same world and pose, lod_quads per far block went from 2.9 to 4.6, a 59 per cent increase, and the far mesher's own moving average, lod_ms, from 0.68 to 0.85 ms per poll. That is the price of the cliffs and it is not optional: they are the terrain.

Judged in the frame from 180 nodes up looking down at a ridge 260 nodes out, at midnight so the sky fill is doing the work: before, the ridge is a spray of separate tops with night sky visible between and under them, and it reads as confetti rather than as a hill. After, it is one landscape with cliff faces, and the near ground at the bottom of the frame runs into it with no step at the join.

One change was made, measured and taken out again. The mesher's fallback for a face it has no light for is day = 255, full sky exposure, and the obvious reading is that it should be 0 for a face buried in solid ground, the way BlockLightField::sample answers for the near mesh when every neighbour is solid. Answering 0 whenever the cell in front is known and unlit put 53 per cent of far vertices at sky light 0, against 4 per cent before it, and painted black patches across the far field in the very next frame captured. The reason is that the far mesher never draws a buried face: a top face needs an unfilled cell in front of it, a side face needs a shorter one, so the case that branch was written for does not arise, and what it caught instead was air cells with no light record. docs/systems/mesh-attributes.md's neutral of 255, "a missing attribute should look unremarkable", is right and stays.

Measured after, same world, same viewpoint, same three times: far vertices at 255 went from 42.6 per cent to 74.2 per cent and the 238 population fell from 40.9 per cent to 3.2 per cent, which is the level 14 light that is really there under a tree edge rather than a clamp. Far mean sky visibility 229.5 to 232.9 against a near mesh whose own mode is 255 in both runs. shotcheck.py --launch-target continuity across the live/far boundary, at time 0.5, 0.25 and 0.0, before and after:

time luminance diff before after chroma before after
0.5 0.68 0.58 0.10 0.03
0.25 0.22 1.30 0.28 0.29
0.0 3.69 0.61 1.15 0.28

Midnight is where it shows, and that is the expected shape: the sky fill is most of the light at night, it is scaled by exactly this channel, and the sun swamps a 7 per cent difference at noon. The 0.25 row moving the wrong way by one luminance unit is noise; R4 measured the same-tier floor at 0.35 to 4.93 luminance, so every number in that table except the 3.69 is inside it. Every one of the six runs passed the harness thresholds, before and after: the check was never failing, which is why the vertex bytes rather than the frames are what found this.

tools/test/test-launch-target.sh itself, unmodified, on a fresh profile and a fresh world: passes. Two earlier attempts failed, both on the close shot's normal map response (detail 3.72 and 3.24 against a floor of 5.0), and neither is this change: the harness poses that shot 1.2 nodes above the ground looking 31 degrees down at a point two nodes ahead, so when the settled camera stays exactly where it was put, a single node face fills the whole frame and there is no detail in it to measure. The passing runs are the ones where the spawn put something further away in front of the camera, or where the camera drifted during the settle. That is a fragility in the pose rather than in what it is looking at, and it is worth a fixed distance from the surface rather than a fixed distance from the player.

Measured and deliberately left alone. Each of the far tiers' own shading terms was swept on a running client by setting its uniform on every material with lod_flatten, camera not moved between shots, and read as the change in mean luminance of the far band. Two shots of the same settings a minute apart drift by 0.90 at midnight and 0.15 at noon while the world streams, so that is the floor these sit against:

term set to 0 on the far tiers far band at midnight at noon
block_light_emission +0.04 -0.03
sky_light_strength +0.25 +0.10
vertex_ao_strength +1.07 +1.40
sky_fill_strength -5.94 -3.55

block_light_emission is 1.0 on LOD materials and 0 on the near mesh, which makes it a per tier term by construction: a far tier adds Luanti's block light as unshaded emission, being past the reach of the node light pool the near mesh uses instead. On this world it does not reach the frame at all, at either time, and it is recorded here rather than changed. sky_fill is the loudest term in the far band and it is not per tier: it is the same goanna_sky_fill the near mesh takes, which is exactly why the sky light channel it is scaled by had to be right.

The one that is still open and is worth a number: the far tiers' own traced occlusion is much heavier than the near field's, CUSTOM0.b mean 113 against 205 on the same world and frame. The far tracer runs at 6 * cell nodes of radius, up to 64, against a field where a cell is filled if half a layer of it is, so a hillside occludes a great deal more of the hemisphere than the same hillside does node by node. Occlusion multiplies ambient only, so the frame cost is the one to one and a half luminance units in the table above, which is the largest remaining per tier term in this channel and still a small one. It wants calibrating on lighting_chart.tscn against the near tracer rather than adjusting by eye, which is what docs/design/pbr-plan.md step 3 says about all of these.

Two notes for whoever measures here next. The harness's horizon pose puts the camera 150 nodes above the player looking out at a shallow angle, which at a sea level spawn lands most of the far field in the thickest part of the haze: the frames are nearly all fog and the continuity bands read fog against fog. A steeper look down is what shows the far tiers. And the first person body (GoannaClient::m_show_body, on by default) draws the player's own model, which in fly mode sits at the camera and can fill a third of the frame at the harness pose; EntityRenderer sets its visibility every frame, so hiding the node does not hold and client.set_show_body(false) is what works.

Stipple and close tiling, closed 2026-08-22

Two more defects, both found in screenshots of a world mid pregeneration rather than in the numbers: the arrival dither reading as a permanent stipple, and a far tier panel tiling into a regular pattern close to the camera.

Stipple. GoannaClient::startFade opens every fresh region's mesh through the interleaved gradient noise dither in the node shaders over a third of a second (advanceFades), which reads well for one region appearing at a time. While a world pregenerates, hundreds of regions arrive at once, so a large fraction of the far field was mid fade at any instant and the whole distance read as a dotted, flickering texture rather than as terrain, which is what the user described as two different textures alternating.

A region well inside the haze is already mostly hidden by fog, so dithering it in over a third of a second buys nothing visible. lodBuildRegion now skips the fade for a fresh region whose horizontal distance from the camera is past three tenths of far_extent, the same fraction main.gd's fog_clear_fraction uses to begin closing the haze (mirrored here rather than plumbed through, since only the shape of the cutoff matters, and far_extent is already the per direction lower quartile rather than one radius, so this follows the same ragged frontier the haze itself follows). A region past that line pops under the haze instead of fading; close to the camera, where a pop would read as a wall of terrain, the fade still runs exactly as before. This was chosen over a shorter fade or a randomised dither because it removes the wasted case outright rather than shrinking it: a region nobody can really see fading in is not worth spending a third of a second dithering, whatever the dither looks like or how long it runs.

Measured on a fresh, actively pregenerating local Mineclonia world (a scratch XDG_DATA_HOME profile, Luanti 5.16.1 flatpak, Godot 4.5.1), with a temporary counter added for the measurement and removed afterward: over a fifteen second window from a fresh connection, the unmodified code started a fade on every one of 68 newly created regions; with the fix, 46 of 85 started a fade, the remaining 39 popping under haze instead. The existing pop metric (tools/dev/shotcheck.py --launch-target, run through tools/test/test-launch-target.sh) still reads 0.0 across eleven frame pairs on this machine, unchanged from before this landed. That is expected rather than evidence either way: the metric watches one fixed camera cone (docs/develop/launch-target.md R4's own finding), this fix specifically removes fades outside whatever cone happens to be in frame at the time, and it must not regress the metric, which it does not. The fade count comparison above is what actually exercises the change.

Close tiling. A far tier quad is one greedy merged face from meshLodRegion, and its UV repeats the tile once per node (src/goanna_lod.cpp's uv switch). lod_flatten blends the sampled colour toward the tile's average between lod_flatten_near and lod_flatten_far nodes of camera distance, which handles a quad seen from far away. It did nothing for a panel drawn close to the camera, which happens wherever the server has not streamed a block but the store or a summary has: the panel is still a single wide merged quad, so the tile visibly repeats at point blank range, and screenshots showed a wall of regular rectangles a few nodes from the eye.

The merge loop in meshLodRegion already knows how wide each quad it builds is, in the same node units the UV switch repeats the tile in (w and h, cell units, times cell). LodRegionMesh now carries the widest span seen while building a region, max_span, and GoannaClient::lodBuildRegion hands it to that region's MeshInstance3D as a new instance uniform, lod_repeat. The node shaders' flatten now fires on either how far away a fragment is or how wide the quad it sits on actually is: smoothstep(lod_flatten_near, lod_flatten_far, length(VERTEX)) for distance, smoothstep(lod_repeat_near, lod_repeat_far, lod_repeat) for size, combined with max. The size test gets its own thresholds, 16 to 64 nodes rather than the distance test's 64 to 256, because a merge only a few tens of nodes across already reads as a repeat once it fills much of a close view, well inside where the distance test would ever fire on its own; a small quad still only flattens at range, as before.

Flattening the colour was not enough on its own. Auto bump (docs/design/pbr-plan.md) infers a normal map from the diffuse texture at 0.35 strength by default, on every material, with no resource pack needed, and a strongly patterned diffuse tile infers a matching relief: a fully flattened albedo still shaded like the tile pattern under the sun, bumps and all, because the normal map sample was untouched by any of this. The same flatten value now also blends the sampled relief and the pack's baked occlusion toward neutral (normal_strength * (1.0 - flatten), ao_strength * (1.0 - flatten)), so a flattened panel reads as flat under lighting as well as in colour, in both node array shaders.

Measured on a merged far tier quad, camera posed away from the player so the panel stayed LOD rather than streaming live, close and at an oblique angle (a stress case chosen to show the defect, not the ordinary horizon shot 2c above was judged against): with no size based flattening at all, tools/dev/shotcheck.py --far-band on the shot read 29.65 to 30.92 across three separate readings, comfortably above the 9.0 the harness wants; with the size test engaged, sharing the distance thresholds and touching colour only, it read 22.53 in the same session against the same underlying mesh, a genuine drop that proves the mechanism. The independent, tighter thresholds and the normal map fade above are a further refinement of the same mechanism, reasoned through rather than measured against that exact figure: this machine's clients crashed or hung repeatedly partway through the follow up runs needed to confirm it (silent, no crash log, the same shape of fault docs/develop/launch-target.md's R4 section already records on this machine under concurrent load), and the one merged quad this session could reach repeatably turned out to sit on a terraced, banded rock formation whose own stepped geometry, not its texture, dominates --far-band's reading there; a flat panel was needed to isolate tiling from geometry and this session did not hold one still long enough for a second controlled reading before the client went again. What did land cleanly: the mechanism proof above, and test-launch-target.sh's own horizon shot, at the ordinary range 2c was judged against, still reads 0.67 on --far-band, unchanged and well under 9.0, so the far range case this extends is not regressed by any of it.

Lids, layers and the vertical walk, 2026-08-22

Three complaints from one afternoon's screenshots: panels floating in the sky over savanna, a large magenta mass over desert, and distant panels still not joined to each other. Two of them share a cause, and the third was never the far field at all.

The magenta was not this. The summary reader defaults an unresolved name to CONTENT_UNKNOWN on purpose, so a name this client cannot resolve draws as unknown_node.png rather than as an invisible hole, and that made it the obvious suspect. It is not the culprit. lodTakeSummaries now counts the names in each reply it cannot resolve and reports them under GOANNA_DEBUG_LOD; over roughly two hundred replies on a Mineclonia world, including an unexplored area at (3000, 90, 3000) reached by teleport, the count was zero in every reply. No magenta appeared in any far field frame taken here. The mass in those screenshots sits at the camera and is the first person body model, not a summary. The counter stays, because it is the instrument that settles the question in one line next time.

Panels in the sky are lids, and a mapgen chunk is where they form. The region mesher drew a top or bottom face wherever the cell beyond was not filled, and treated a cell it knows nothing about as not filled. That was deliberate ("Unknown is not air", above): the block above a surface is often one nobody has sent, and culling there would take the ground's own top face away and leave the far field invisible from above.

The cost of that exception is a lid. A column of solid rock that reaches the top of its block, with the block above unknown, grows a top face across the whole column, and the greedy mesher merges those into one region sized quad. Luanti generates in chunks five blocks tall while a summary area is eight, so a generated chunk under an ungenerated one is the ordinary case, not a corner case: the result is a flat plate at the chunk boundary, hanging in clear air with no terrain under it, exactly the shape reported.

The exception is now narrowed rather than removed, and what makes that safe is a change made earlier the same day: a generated summary block marks the air above its surface as air, so an ordinary hillside carries its own top face inside its own cells. A cell whose content stops part way up is known from that cell alone and keeps its top face whatever is above it. Only a cell filled right to its ceiling leans on the cell above, and a bottom face always leans on the cell below, because the chain fills a cell from its floor. Those two are now culled against an unknown neighbour like the sides. The far field still draws from above, which is what the exception existed to protect.

Nine vertical layers, of which at most two hold anything. lodRequestSummaries asked for a fixed window of area layers, four either side of the player, each 128 nodes tall and 512 blocks in it. Of the nine, one or two hold the surface. The rest are sky the server has not generated, which never comes back complete and so is asked again at every retry for the whole session, and solid rock, which does come back complete and answers with 512 buried blocks nobody can ever see.

Measured on the client's own request log, standing on a pregenerating Mineclonia server at a 1024 node grant: of 91 areas asked in the first 150 seconds, three were in the layer the player was standing in. The other 88 were sky between 256 and 768 nodes up, or rock between 256 and 512 nodes down, and the same handful were asked over and over as their retries came due. A longer run of the same build put 3 of 237 in the player's layer, so the ratio gets worse the longer you stand there, not better. That is why the horizontal frontier stood still while a player watched, and it is most of "still not contiguous": the summaries were not slow, the requests were going somewhere else.

The window is now a bound on a walk rather than a window. The layer the player is in is always eligible. A layer above becomes eligible only once the layer below it has answered that terrain reaches its top face, and a layer below only once the layer above it has answered that its floor is not solid everywhere. Both flags fall out of the heights already in the reply, and a layer that is all rock along its floor is neither, so the walk stops at the ground rather than tunnelling to bedrock. The vertical offset also joins the distance the request loop sorts on, as a tie break, because the loop order was choosing for us and it chose the deepest layer of each column first.

A block the server has not generated is not known to be solid, so it does not close the walk downward. Without that guard a player flying above a column the server had never made would get an ungenerated answer for their own layer and never ask about the ground under it. Upward asks for evidence instead, terrain reaching the top face, because nothing is the usual answer up there and taking it as a reason to climb is the scan this replaces.

After, same server, same grant, same 150 seconds, same 91 areas asked: 71 were the player's own layer, 7 the one below, 13 above. From 3 per cent useful to 78 per cent.

One tier at every distance. lodTakeSummaries built one chain level, the 4 node one, and assigned every summarised block to the tier that uses it, at any distance. A region reads its own tier's cell size out of a block's chain, and a chain holding one level answers "nothing known" at every other, so a coarse region beside summarised terrain culled its whole boundary against it and the two never joined. Every level from 4 nodes up is now built from the same 4 by 4 heights, and a summarised block takes the tier its distance calls for, the same as a stored one.

Both builds run against the same world from the same fresh connection, 150 seconds in, same camera at (3000, 130, 3000), same time of day override, same show_body 0, Mineclonia on Luanti 5.16.1, Godot 4.5.1, a 1024 node grant with pregeneration on:

Before After
Far blocks 23176 34845
Far blocks at tier 1, 2, 3 23185, 0, 0 2007, 9395, 23452
Far regions 451 187
Draw calls 731 381
Cell faces before merging 191049 84454
Primitives 203509 154895
far_extent 832 944

Half again as much far terrain, reaching further, in a quarter of the cell faces and half the draw calls. The blocks that went were buried and the ones that came are surface, and drawing them at the tier their distance calls for is where the rest of the saving is.

Looking north at a shallow angle, the before frame is one island of terrain around the player with a single panel hanging in clear air off to the left and nothing else out of the fog; the after frame is a continuous landscape to the horizon. Looking down from 260 nodes, the before frame has a void across the whole centre with two fragments floating in it, and two disconnected patches at the bottom corners; the after frame has terrain across the lower half with no void.

What this does not fix. Small fragments still hang over the far field, a few cells each, where a treetop or a snow drift sits in a block whose neighbours have not arrived; they close as the field fills rather than staying. The vertical walk needs one round trip per layer, so a player who flies a long way straight up waits a layer at a time for the ground to reappear. And a partially generated area is still asked again every twenty seconds for as long as it is in range, which is right when a server is generating and pure cost when it has stopped.

Where the summary budget actually went, 2026-08-23

Three complaints, in the project owner's words after a day of fixes that each closed one symptom and left the whole still wrong: the fog is applied nearer than the far land; there is a gap between near and far; and the far land is permanently filled with holes. Under all three, the sentence that is the real brief: "the whole intent of Voxy is to have a single continuous terrain off into the distance, not to be pulling in one panel at a time for 100k separate panels."

That last one is not a matter of taste. It is arithmetic, and this section is the arithmetic, because every fix below follows from it.

The far view fills at one rate and it is a small one. The mod's summary queue is one job at a time. A job is an 8 by 8 by 8 block area, 512 mapblocks, paced at goanna_far_summary_blocks_per_step, which was 32. That is 16 server steps, about 1.6 seconds an area, or 320 mapblocks a second for every client on the server put together. A 1024 node grant is 128 by 128 mapblocks around a player, so one horizontal layer of it is 16384 blocks and takes eight and a half minutes. The player watching that is watching panels arrive one at a time, because that is exactly what is happening, and no amount of meshing or shading work changes it.

So the question worth asking is not how to draw the panels better. It is where that budget goes.

Two thirds of it went to layers the player cannot see. Measured off the client's own GOANNA_DEBUG_LOD request log, one session against the test Mineclonia world at a 1024 node grant:

Area layer, relative to the player's Asks Generated records Blocks drawn
+2 8 1536 2
+1 2 39936 153
0 105 92165 18151
-1 25 44638 41369
-2 21 1477 45
-3 9 0 0
-4 8 0 0

105 of 178 requests were the layer the player was standing in. Seventeen went to the two lowest layers and every one of those came back with 0 of 512 records generated: 8704 mapblocks of VoxelManip read for nothing, on a queue that fills the horizon at half an area a second. The rows below the player are solid rock between 16 and 128 nodes down, drawing 41369 blocks that are buried and mesh to no faces at all.

Two separate causes, and both are one line each.

The walk had no floor. "Lids, layers and the vertical walk" above set the rule that a block the server has not generated is not known to be solid, so it does not stop the walk downward. That was written for a player flying above ground the server had never made. Its unintended reading is that an area that is entirely ungenerated, which is what everything below the bottom of the world is, opens the area under it, which is also entirely ungenerated, and so on until the vertical bound runs out. FarAsk now records whether a reply had any generated record at all, and an area with none is not evidence about anything beyond it. The retry brings it back once the server has made something there, and then its answer means something.

The vertical tie break was not a tie break. The request loop sorted on dx * dx + dz * dz + dy * dy / 64 with dy in blocks. A layer four down is 32 blocks, which is 16 after the division, against 64 for the very next area sideways. So a whole column, five layers of 512 blocks each, was always asked before the next ring out. The vertical offset is now priced in areas: kLayerCost of 4 means one layer off the player's own ranks like four areas of horizontal distance, so the ground in front of the player fills out to four areas before anything above or below it is asked about, and a hill or a valley near the player is still reached long before a distant one.

Measured after, same world, same server, same four minutes from a cold profile: 182 of 195 asks in the player's own layer, 11 in the one below, 2 above, and none at all below that. From 59 per cent of the budget on the frontier to 93 per cent.

The rate itself was too low, and the reason it was low was reversible. goanna_far_summary_blocks_per_step was set at 32 as a bounded slice of a server step with no guard behind it, so it had to be cautious enough for the worst case. It now has the same lag guard pregeneration has, goanna_far_summary_lag, which pauses a part finished area rather than abandoning it, and the default rises to 96. The rate a server can afford is now what it does rather than what somebody guessed.

The gap between near and far, 2026-08-23

lodRequestSummaries skipped any area whose farthest corner fell inside the client's wanted range plus two blocks, on the assumption that the server is sending every block in there. It is not, and the assumption is not close.

A Luanti server sends only what is inside the player's view cone (RemoteClient::GetNextBlocks skips anything isBlockInSight refuses beyond one block) and only what is generated, and it sends it a few blocks a step. Measured on the test world at a 192 node wanted range, four and a half minutes after joining and not moving: 402 resident blocks, of which 174 were near enough for the full detail mesh. A filled 12 block disc is thousands. So the band just inside the live edge was neither live nor summarised, and it is a ring of nothing exactly where the eye goes, which is what the player reported between the near field and the far one.

The assumption is gone. What replaces it is a test of what is actually there: a block that is live, or that already has a chain from the store or an earlier summary, is covered, and anything else is worth asking about however near it is. The sample runs the diagonal of the area's footprint at both the layer's floor and its ceiling, so a column that is live near the ground does not vouch for the sky above it. In the same four minutes, seven requests now go to areas that the old rule would have skipped outright.

This costs requests near the player, which is where they are worth spending, and it is bounded: an area that comes back fully generated is finished with and never asked again.

Haze over the ragged frontier, 2026-08-23

Two things were wrong with the fog and they pull in opposite directions.

It began inside the near field. fog_depth_begin was fog_clear_fraction (0.3) times the drawn extent, and nothing else. On a fresh connection with a 192 node live range and the far field not yet filled, that is 149 nodes: the haze started 43 nodes inside the live edge, over the one layer of the picture that is always complete and always worth seeing. The near field is the foreground layer in docs/develop/launch-target.md's three layer model and it is meant to be clear. The fraction is now a floor rather than the answer, and the live range is the other candidate; whichever is further out wins.

It closed well inside terrain that was there. The haze ended at far_extent, which is the lower quartile of the eight sector histogram: how far the field reaches in a poor direction. That number exists for a good reason, and "Background, overlay, foreground" above has it: a single radius describes the directions holding the most blocks, which are the ones that least need hiding, and leaves the sparse ones ending in clear air. But a single radius cannot describe a ragged frontier in either direction. Measured on the test world at a 1024 node grant, far_extent was 496 nodes while the field itself reached past 900, so nearly half of what the client had asked for, chained, meshed, uploaded and drawn was behind solid fog. That is the fog being nearer than the far land, and it is also several hundred draw calls spent on nothing.

A depth fog has a begin and an end, so it can have both numbers. GoannaClient now reports far_reach alongside far_extent, the upper quartile of the same histogram. The haze opens where the sparse directions run out and closes where the rich ones do. A sparse bearing is fully hazed at its own edge, as before; a rich one keeps its terrain.

Two bounds keep that honest. The begin is floored at the live range, above. And it is capped at three fifths of the end, because once a field fills evenly the extent catches the reach up and the haze collapses: measured at 958 to 1008 on a field that had reached the whole grant, which is a hard edge rather than a horizon. Two fifths of the drawn depth is always haze.

Buried blocks no longer set the horizon. The sector histogram counted every block in m_far_blocks, and the table above shows that 41369 of 58257 of them were solid rock under the ground. A block whose ceiling is filled and whose neighbour above is also filled draws no faces at all: every one of them is culled. Counting them measured how far the ground goes down rather than how far the view goes out. They are skipped now, which is worth about a 5 per cent longer reported extent on a third of the blocks.

The frames. Same pose (208, 84, 144) looking north at 6 degrees down, same time 0.0 and time 0.5 overrides, show_body 0, fresh XDG_DATA_HOME profile each side, four and a half minutes from joining, Mineclonia on Luanti 5.16.1, Godot 4.5.1, a 1024 node grant on a pregenerated world, both arms against the same server with the same mod. Before: fog from 216 to 720 nodes, and the far land stops in a bright band a third of the way up the frame with flat fog past it. After: fog from 605 to 1008, the terrain runs to a lower and further horizon, and the near field runs into the mid ground with no step. At noon the difference is starker: the whole mid ground had been milky and is now in colour.

Cost, same two runs: 294 far regions against 348, 2599 draw calls against 3136, and the far mesher's moving average 3.9 ms against 4.8. The far field reaches further for less, because the budget stopped going underground.

Strips, and what the merge can and cannot do, 2026-08-23

Reported as "it really often seems like the far terrain has either vertical or horizontal strips, but not both", which is a good observation because nothing about terrain is anisotropic.

LodRegionMesh now counts partial: side faces that do not span their whole cell vertically. Those are the ones that carry FaceKey::row, which is what keeps the greedy merge from joining two faces that sit at different heights inside different cells, and the effect of a row id is that such a face can only ever merge along one axis. Measured on the same frame as above: 67514 of 133648 faces, just over half, and the merge ratio is 1.27 faces per quad. The merge is achieving almost nothing.

That is not a bug in the merge. It is what a heightfield costs when it is drawn as boxes. A summary carries a 4 by 4 grid of surface heights per block, so at cell 4 every cell has its own height; adjacent cells on a slope differ, so their top faces have different heights and cannot be one rectangle, and the step between them is a side face one or two nodes tall that belongs to its own cell row. One quad per cell edge is the floor for this representation, and the mesher is at it.

So the strips are terracing, and the merge cannot fix terracing. Two things could, and neither is a tonight change:

  • Coarse levels take the maximum height of their group. lodTakeSummaries builds cell 8 and cell 16 from the same 4 by 4 wire grid by taking the tallest column in each group. That makes a coarse tier as tall as the tallest thing in every 16 node square, which exaggerates roughness exactly where the cells are biggest, creates steps the terrain does not have, and blocks merges that would otherwise happen on flat ground. A mean or a median would flatten the coarse tiers toward the real surface and merge far better. It would also lose thin spikes, which at a 16 node cell is correct. This wants measuring against the frame before it is trusted, since a coarse cell reading shorter than the finer tier beside it puts a step at the tier boundary.
  • Draw the far field as a surface rather than as boxes. The chain already holds a per cell height. A connected triangle mesh over those heights has no risers at all, so the whole partial face population disappears, and a slope is one strip of triangles instead of a staircase of quads and steps. This is a real change to meshLodRegion and it interacts with the residency rule, the water surface and the occlusion trace, so it is a piece of work rather than an edit.

The counter stays either way, because it is the instrument: lod_partial against lod_faces says whether a tier is meshing a surface or a staircase, in one number, without a screenshot.

Is the per area request model the obstacle, 2026-08-23

Asked directly by the project owner, and the answer measured above is: partly, and not in the way the shape of the request suggests.

The store is already Voxy shaped. lodChain reads a block's chain from the live block, the store or a summary without the region mesher knowing which, and the regions draw continuously from whatever is resident. Nothing about that assembles a mosaic. What arrives as a mosaic is the summaries, and the reason is the rate, not the granularity: 320 mapblocks a second against a grant that wants 16384 for one layer. An area is a sensible unit; asking for smaller ones more often would not change the total.

Three things would, in the order they are worth doing:

  1. Stop paying for what cannot be seen. Two thirds of the budget was going to buried rock and empty sky, and that is now fixed. This was the largest single factor by a wide margin and it needed no protocol change.
  2. Raise the rate the server will actually stand. Done, with a guard, and the guard is what makes the number arguable rather than fixed. The next step here is the mod's own cost per block: block_summary allocates one VoxelManip per mapblock and copies 4096 content ids and 4096 light bytes into Lua tables for each. Reading a whole 8 block column in one VoxelManip is the same volume of data through an eighth of the calls, and it is a contained change to one function that does not touch the wire format.
  3. Send the surface rather than the volume. An area is 512 mapblocks of which the client draws, on the frontier layer, roughly 30. The record is already a heightfield; the request is still a volume. A request for a column, "the surface over these 8 by 8 block columns, wherever it is", would let the server find the surface once per column instead of reading every block in a 128 node cube. That is a protocol version 3 and it wants its own day.

None of those is a rearchitecture of the store, which is the part that was already right.

The far field's albedo, 2026-08-23

The far field's own colour, not its light. This is R1 in docs/develop/launch-target.md again, opened on a report that at night the far field glows while the near ground is black, so the frame reads as two worlds under two skies. The light turned out to be right and the tile colour turned out to be wrong, on the far tiers only, by whichever tile happens to sit one array layer back.

The one line. A node face carries its array layer in UV2.x, the same integer at every vertex of the face, and the node shaders read it two ways: the texture samplers take it as a coordinate, and lod_avg_colour and layer_class index a uniform array with int(UV2.x). Interpolation delivers that integer to the fragment a hair either side of itself, and int() truncates, so layer 16 arrives as 15 on about half the fragments. The samplers never showed it because GLSL rounds an array layer coordinate itself. Only the hand written indexes were affected, and one of them is the colour the far tiers flatten toward, so a far surface took a neighbouring tile's average on half its pixels and its own on the rest. It is now int(round(UV2.x)), computed once, in both node shaders.

The colour half of it is the far tiers' alone: lod_flatten is false on the near mesh, so the flatten branch never runs there, which is what made this a per tier signal rather than a global one. The class half is not. It was harmless while detail_strength defaulted to 0, and 25e8247 turned the surface treatment on for everyone, so from that commit the same truncation also decides whether a fragment is treated as granular, on the near mesh as well: a sand face whose neighbour layer is planks would have taken the treatment on half its pixels and not the other half. One rounded index fixes both, and it went in on the same day the treatment did.

Measured offline first. project/studies/material_field.tscn renders strips of real pack textures through nodes_array.gdshader with no server, and it already had every uniform this needed. Two modes were added to material_field.gd, GOANNA_FIELD_LOD and GOANNA_FIELD_LAYER, described under "Instruments" below. With the tile at array layer 0, which is where the fixture put it, the fault is invisible, because element 0 is the one element a truncation cannot miss. With the tile at layer 16 and a different colour in every other lod_avg_colour entry, each strip drew in horizontal bands of its own colour and its neighbour's. The same run with the fix is byte identical to the layer 0 run on every strip, and within a few units of the near case:

strip near mesh far tiers, before far tiers, after
sand 222.8, 198.6, 166.7 194.8, 146.2, 192.7 218.0, 192.0, 161.1
stone 138.4, 132.8, 138.6 154.4, 119.0, 179.4 141.0, 136.0, 142.0
gravel 114.6, 108.0, 109.6 140.7, 111.9, 157.2 125.2, 119.0, 121.0
dirt 107.7, 82.7, 69.7 132.9, 90.4, 129.5 111.0, 85.0, 71.0
snow 219.0, 222.9, 231.6 191.4, 156.2, 226.7 218.0, 223.0, 231.0
planks 221.1, 167.2, 161.8 193.0, 128.6, 193.0 221.0, 162.0, 158.0

Then on one surface with a server, and this is the number that matters. A world with mg_name = flat and mcl_superflat_classic, so the ground is one tile at one height under open sky in every direction, is the only place a near and a far reading are the same thing measured twice. Camera at 40 looking down 9 degrees, midnight, one client, the shader reloaded in place between the two readings so nothing else moved. Near and far pixels were separated by difference masking rather than by guessing at rows: only the far materials were switched to the debug channel for one frame, so a pixel that moved is a far pixel, and the near ones follow from switching both. In the band of rows where both are at the same distance:

far tiers near mesh difference
before, luminance 42.38 55.86 -13.48
after, luminance 54.18 55.77 -1.59
before, rgb 28.3, 46.2, 46.0 36.1, 61.6, 57.2
after, rgb 35.1, 59.5, 57.6 36.0, 61.5, 57.2

The residual 1.59 is the haze: the far pixels in that band really are further away. Every channel is now within two of the near mesh where it was out by eight to fifteen.

What was eliminated, with numbers, before the albedo was suspected. Each far shading term was set to 0 on a running client with the camera not moved, read as the change in mean luminance of the far pixels. On the flat world at midnight, block_light_emission 0.29 and vertex_ao_strength 0.18; on a natural world at midnight, block_light_emission 0.53, vertex_ao_strength 2.2 and sky_light_strength 0.04. None of them is the two worlds. sky_fill_strength is much larger, 8.96 on the far tiers against 15.65 on the near mesh, but that is the fill doing its job on two different albedos, which is the same fault seen through the light rather than a fault in the light.

The light itself was read directly and is the same on both. Painting CUSTOM0 straight to the screen with debug_nodelight_strength, the far tiers give 48.3, 205.4, 203.8 and the near mesh 45.2, 207.0, 206.3, which is block light, sky light and traced occlusion agreeing to about one part in a hundred on ground that is the same on both sides. The two populations of sky light that "One light, from your feet to the horizon" closed on 2026-08-22 are closed and stayed closed.

Driving the sky fill to white, so the frame is very nearly albedo times sky light and nothing else, isolates it completely: far 64.4, 78.3, 61.0 against near 78.8, 100.6, 76.7, and the same far pixels with the flatten blend taken out of the path entirely read 78.7, 99.2, 76.6. The far tiers had the right light and the wrong paint.

Why the reported symptom was a glow in one frame and a shadow in another. The colour a far surface took was a neighbouring array layer's average, and array layers are in whatever order the mesher grouped them, so the error is a different colour and a different sign for every tile and every world. On the superflat's grass it was about a fifth too dark. On a frozen sea it put a warm grey where the near mesh had deep blue: the same ground measured near and far, from one camera, came out 46.79 against 47.77 in luminance, which is nothing, and 11.4, 51.6, 103.6 against 32.0, 49.6, 75.8 in colour, which is a different material. (That pair was taken on the tip before cb2b735, by pulling view_range in so the far tiers redrew ground the near mesh had just drawn; the offline fixture reproduces the same thing deterministically and is the evidence to trust.) That is why it reads as two worlds rather than as one world at two brightnesses, and why no per tier constant could have fixed it.

tools/dev/shotcheck.py --launch-target at time 0.5, 0.25 and 0.0, same world, same viewpoint, one client, the shader reloaded between the two sets, on the Mineclonia world r1tod1 at a 1024 node grant with 87000 to 94000 far cells, Luanti 5.16.1 flatpak, Godot 4.5.1:

time luminance diff before after chroma before after
0.5 6.61 4.47 1.31 0.89
0.25 2.39 0.81 0.79 0.67
0.0 1.95 1.10 1.24 0.54

All six runs passed every threshold, before and after, including the pop check once the world had stopped growing. The check was never going to find this, and it is worth saying exactly why, because it passed while the frame was plainly wrong. It compares the mean luminance and mean chroma of two thin bands either side of one computed row. The defect is a per fragment coin flip between two tiles, so it is a speckle rather than a step, and averaging a band is precisely the operation that removes it: the mean of half right and half wrong pixels sits between the two and moves the band mean by a couple of units, well inside a threshold of 8. It is also worst where the two bands are least alike in content and best where they are most alike, which is the opposite of what a boundary check is sensitive to. A metric that would have caught it is variance, or a nearest neighbour colour distance, within the far band, not a difference of means across the boundary.

The whole frame moved by a mean absolute 9.3 to 12.7 of 255 per pixel at the three times, which is the speckle going away; the mean luminance moved by +6.39 at midnight and -4.18 at dawn on the same pose, in opposite directions, which is again the error's sign following the tile.

Not changed, and why. block_light_emission at 1.0 on LOD materials and 0 on the near mesh is still a per tier term by construction and is still worth 0.3 to 0.5 luminance on these worlds, which is inside the drift between two shots of the same settings. The far tracer's occlusion is still much heavier than the near field's and still wants calibrating on the chart. water.gdshader was not touched and is not the cause, but it is worth recording what it does at range, because it was one of the two explanations this task opened with and it is a real difference: the far tier water plane has waving false, and the screen space reflection is gated on waving, so far water has no reflection at all where near water does; and its thick, the distance through the water to the depth buffer, is near zero on a flat tier plane sitting on the tier terrain, so exp(-absorption * thick) is near 1 and far water does not absorb where near water does. Both are real and both are R2's, not this task's. Both closed 2026-08-28; see "The near/far water hand-off" below.

Also found, measured, and deliberately not fixed here: a Mineclonia weather sky puts the night out entirely. _apply_sky derives goanna_sky_fill as sky["night_horizon"] times a number, and radiance_floor likewise. Mineclonia's mcl_weather skycolor.lua sets night_sky and night_horizon to the darkest layer of its weather ramp, which is pure black, whenever it is raining or thundering where the player is, and holds the world up with override_day_night_ratio at a floor of 0.2 instead, which the vanilla client reads as node light and Goanna does not read at all. Reproduced deliberately with /weather thunder and /time 0:00: the server sends night_horizon (0, 0, 0) and day_horizon a 0.5294 grey, the fill is exactly (0, 0, 0), and the frame is left to the moon. Mean luminance 1.34 of 255 with 81 per cent of the frame at pure black from 140 nodes up, and 1.85 with 67 per cent from 40 nodes up. A guard that takes the night's level from the day horizon where the server gives no night colour was written, measured and taken out again: it moved the frame from 1.34 to 2.02, which is not a fix, and the number that would make it one has to be calibrated on lighting_chart.tscn against the vanilla client rather than guessed at two in the morning. The honest fix is probably that the night share's level should follow the server's day_night_ratio, which Goanna already receives and already uses for ambient_light_energy, rather than the brightness of a colour the server is free to set to black. That is a change to a calibrated line and it wants the chart.

Four traps, all of which cost time here.

time <t> on the control channel moves the client's clock and nothing else, and Mineclonia computes its sky colours from the server's clock, so a client overridden to midnight against a server at noon is lit by a daytime sky's night colours. Three of this task's first night measurements were taken that way and had to be thrown out. Use time <t> server=true for anything a time of day is supposed to mean.

reload_shader re-reads the named .gdshader from disk and keeps the cached .gdshaderinc, so a uniform added to the include is unknown to the recompiled shader, the compile fails, and the material is silently left as it was.

Killing the flatpak wrapper's pid leaves luanti.bin running and holding the port. ps aux | grep '[l]uanti.bin --server' finds the real one.

Lowering view_range prunes blocks and raising it again does not bring them back, because the server's per client sent set still says it sent them. A teleport away and back does not clear it either. To compare a patch of ground drawn near against the same patch drawn far, take the near reading first.

The far field emptied to the wanted range, 2026-08-23

Reported as far terrain arriving half complete (a distant hill with its top present and its flanks missing) and the sides of the view never filling in while flying. Both were one change.

"Stop coarsening blocks the server is actually sending" (commit 5fb6e83) set the first tier threshold to the wanted range for every block, not only for live ones. lodTierFor answering 0 is read by lodUpdateFar and update_lod as "the server will send it", so a block from the store or a summary inside that radius was never drawn, and one that had been assigned was forgotten on the next update. At the user's 40 block view range, on a server whose max_block_send_distance is 32, that was a 656 node disc drawn from the server's cone alone: measured on a probe client, the nearest far region centre sat at 575 nodes, the band from 512 to 656 was empty in every direction, and inside it the only terrain was what the server chose to send, which is the view cone (narrowed by up to half while flying forward, RemoteClient::GetNextBlocks), minus whatever its occlusion test culled from the player's position, minus whatever had not streamed yet. The hilltops that stand above the intervening ground pass the occlusion test and their flanks do not, which is the half complete hill.

Fixed by giving lodTierFor a live argument. A live block inside the wanted range is never coarsened, which keeps that commit's intent. A block that is not live is tiered by the detail distance as before, and inside the detail distance it now draws at the finest tier rather than not at all, because the alternative was what the request loop's "gap between near and far" rule was asking for and then throwing away: a summary for an area inside the detail distance was taken, assigned at tier 1, forgotten by the next update_lod, and, being complete, never asked for again. Measured on the same probe after the change, from ground level: nearest far region centre 315 nodes, 36 regions in the 256 ring and 61 in the 384 ring where there had been none, and the far field meets the live field at the sides of the frame instead of stopping at the cone.

Two smaller things found on the way. _report_fov ran at startup and when the field of view slider moved, so a window dragged wider afterwards was still reported at its old width and the server culled the new edges; it now runs on every viewport resize. And the request walk priced every layer of vertical offset as four areas of horizontal distance, which under a player at 260 nodes put the ground behind some eighty areas of sky: measured as 115 asks in two minutes with the column under the player still unasked. An area the server has answered as nothing but air (FarAsk::air) now costs nothing to walk through, so columns are asked top to bottom nearest first, and the ground under a flying player is asked right after its own sky.

Both measurements were against the user's own local server (Mineclonia, Luanti 5.16.1, Godot 4.5.1, 1024 node grant, pregeneration on) with a second player, which also means they competed with the user's client for the one summary queue; see below.

Why it is still not contiguous, 2026-08-23

Asked directly, after the above: Distant Horizons shows the whole world in every direction within half a minute, Goanna can sit for an hour and not. Even vanilla Luanti fills the near field reliably and this client does not always. That is the right complaint and the fixes above do not answer it. What does, in order of how much each is worth:

The summary queue is the bottleneck, and it is the shape of the mod. The mod holds no summaries. Every request reads every block of its area through a VoxelManip at the moment it is asked, at goanna_far_summary_blocks_per_step (96) per server step, one job at a time for every client on the server. That is about 0.5 s per area at best, and a 1024 node grant is 256 areas per layer with the walk visiting two or three layers per column: five to ten minutes for one client when the server is otherwise idle, repeated from nothing for every client that joins and every time one rejoins, and competing with mapgen when the world is being pregenerated. Distant Horizons on a server does not compute on request: its server side keeps an LOD store that is filled as chunks are generated and changed, and a client is sent data that already exists. The mod can do the same, in Lua, without touching the protocol: summarise a block once in register_on_generated (the VoxelManip is already in hand there), keep the per block record in mod storage or a file, refresh it on node changes, and answer a request from the store. A request then costs a lookup, and a whole grant can go out as fast as the wire takes it, to every client. This is the change that makes the field arrive in seconds rather than minutes. It is a rewrite of the mod's data path and a day's work, with the wire format kept so the client does not change.

The live field is the server's cone, and the far field has to fill it. A Luanti server sends blocks inside the reported field of view, only where its occlusion test from the player's eye passes, only where generated, a few a step. Vanilla hides the rest behind fog at the view range. Goanna's far layers exist to stand in for whatever the server does not send, at any distance, and the change above restores that inside the live range; the near field then reads as complete as the summaries it is standing on, which is the previous point again.

The far field is boxes, and boxes do not join. Each tier draws cells as columns with a height, and a slope is a staircase; two tiers meet at a step; a live block meets its coarse neighbour at a change of shading and of shape. The "Strips" section above measured it: half of all far faces are partial risers, and the merge achieves 1.27 faces a quad. The contiguous look the owner is describing is what a connected surface gives: one triangle mesh over the per cell heights, skirts only at tier boundaries and the live edge, and the colour and light sampled from the same place the live mesh gets them. meshLodRegion already has the heights; this is the rewrite it was built to make possible, and it is the second day's work.

Nothing here needs the boundary rule bent. The server keeps deciding what it sends, and a summary store describes terrain that exists.

The cone was upside down in fly mode, 2026-08-23

Reported as detail arriving in a patch around the centre of vision out in the distance rather than around the player, with the foreground left at the far tiers, and as the sides of the frame never sharpening. Luanti's pitch is positive looking down and Godot's rotation.x is positive looking up; step_player negates it for the walking path and set_player_pose, the fly path, did not. So the server culled its sends for a camera tilted the other way: a flyer looking 20 degrees down was served as one looking 20 degrees up, the ground under them fell outside the cone, and whatever sat at the horizon inside it was what arrived at full detail. Measured on the terrain diffusion world from 2420 nodes looking 20 degrees down, 90 s after joining: 1845 blocks resident before, 3210 after, and the slope in front of and below the camera at full detail instead of the hillside across the valley.

What is left after that is the server's own behaviour, which vanilla has too and hides behind nearer fog: it narrows the cone by up to half while the player moves in the direction they look (camera_fov / (1 + dot / 300) in RemoteClient::GetNextBlocks), so a fast flyer is sent the middle of the frame only until they stop; it sends nothing behind or beside the cone until the player turns; and on a world whose terrain does not exist yet, the order detail arrives in is the order the mapgen makes it, nearest ring first and sky rings included from altitude. max_block_generate_distance decides how much of that cone the mapgen is asked for; with the far surface covering the rest, a small value puts the mapgen's effort where the player is.

Terraces or slopes, islands, canopies, night haze, 2026-08-23

Five things from looking at the surface in play, in the order they came up.

The haze at night. Reported as hills glowing at night. The haze colour was right (the horizon band, dimmed with the sky), but a hazed mountain stands above that band against the black of the night sky and at the band's own brightness it reads as a pale silhouette. Blending the haze toward the sky behind it (fog_aerial_perspective at 1) made it worse, because Godot blends toward the sky's radiance, which _apply_sky lifts at night so the ground is lit at all. The haze colour is now halved by full night on top of the dimming, so a hazed hill is a dark shape below the band. A haze that truly takes the colour of the sky behind each pixel is a far tier shader doing its own fog, and that is the next piece of work, together with a far water that is continuous with the near water (the server's sent rectangle of a lake is outlined against the far water today, and no blue does both).

Overhangs and islands. A floating island's top was taken as the column's surface and the heightfield draped from its rim to the real ground as a cone. A block whose ground does not rest on the block below it (the one below not filled to its ceiling, the block itself not filled to its own) is now a floating run: its cells are drawn as boxes and the surface goes on down to the ground. The block below being unknown keeps the old answer, so the frontier does not get holes.

Skirts only as deep as the step. Three cells of skirt at the coarsest tier were 48 node plates on every mountainside ("dominoes"). A skirt now drops to the lowest corner of the neighbour it disagrees with, plus one node; one cell where the neighbour is unknown.

Forests at coarse tiers. Trees at cell 8 and 16 were piles of cubes. At those tiers the highest vegetation cell in a column, when it stands above the ground, is now the column's surface with the canopy's colour, so a forest is a green roof rolling with the land, and the vegetation boxes under it are not drawn. At the finest far tier trees stay boxes, since there they are still individual trees.

Terraces or slopes. "Nothing in Minecraft is rounded." The smoothed surface is a look choice and not the only honest one: the blocks under it step. lod_terrace in the settings panel (Video, "Terraced far terrain") draws each cell flat at its own height with risers between neighbours that differ, the same pass with the corner averaging off and the skirt logic doing the risers. Both were captured on r1tod1 from (27, 140, -22) looking north and either reads as a world, the terraced one as a block world; the smoothed one is what reads as melted at a cliff or a coastline (see "Stop the far surface averaging across cliffs" below), so terracing is on by default from 2026-08-24.

The summary store, 2026-08-23

Part 1 of the answer to "why is it still not contiguous" above, and it was the arithmetic. The mod now keeps a store of records, in areas of 8 by 8 by 8 mapblocks (the unit a client asks for), persisted in mod storage under keys that carry the protocol version, and a request for an area the store knows is answered in the same server step by lookup. The store fills without being asked: every freshly generated block is summarised within a few steps of register_on_generated, while it is still in memory; a changed block is summarised again a few seconds after register_on_mapblocks_changed; and when the queues are idle the nearest unsettled area to any player is read in the background, which is how a world older than the store gets read once. An area is settled once every block in it is known or has been found ungenerated, and is not read again. goanna_server_mod/README.md has the details and the settings.

The client had to change with it: lodRequestSummaries ran once per lodUpdateFar, which is every two seconds when the player stands still, and issued one request, so a server that answered at once would have been held to a request every two seconds, twenty minutes for a grant. It now keeps four in flight, scanning a few times a second while there is room.

Measured on the r1tod1 Mineclonia world (Luanti 5.16.1, Godot 4.5.1, 1024 node grant, view range 40, detail distance 23), fresh profile each time:

replies at 10 s 20 s 30 s far_extent at 30 s reach
Cold store, first client ever 224 at 60 s
Warm store, same server run 219 384 455 992 1024
After a server restart, from storage 187 314 377 992 992

Against five to ten minutes before, and it no longer depends on how many clients are on the server or how many times they rejoin.

The far field as a surface, 2026-08-23

Part 3. Protocol version 3 first, because the version 2 record could not carry it: per block it now holds, per 4 node cell, the terrain height (the highest filled node that is not vegetation), the vegetation base and top, and the terrain top content, then the vegetation content, the side content and the light. Vegetation is by node group, the same list in the mod (VEG_GROUPS) and in lodIsVegetation in src/goanna_lod.cpp: tree, leaves, cactus, bamboo and the plant groups. Two things were wrong with version 2 that this fixes: a canopy crossing a block boundary was drawn as a slab resting on that block's floor (the pink plates floating over the cherry grove in the report), since only a top height was sent, and a tree was part of the ground, so a surface drawn over the heights would have made a tent of every one.

LodLevel carries terrain per column and the ground cells are flagged kTerrain. meshLodRegion draws the ground as one quad per cell over the heights at the cell's four corners, where a corner is the mean of the surfaces of the same tier, non water columns around it and a water cell keeps its own level, so the sea is flat to the shore. Both regions either side of a boundary compute a shared corner from the same columns, which is what makes one tier seamless without the regions knowing about each other. Where a cell's edge meets something that does not share its heights, a hole, the shore, a block drawn at another tier or at full detail, or the edge of what is known, a skirt drops three cells from the edge. Ground cells emit no box faces; the box pass draws only what is not ground, which is the vegetation from its real base to its top. Normals follow the ground and the occlusion trace runs per corner, so the shading is continuous too.

Coarse tiers take the mean of the wire cells they group rather than the maximum, which the "Strips" section asked for.

Measured effects: lod_partial, the count of partial risers that could only merge one way, now counts only vegetation boxes. Ground level and from 120 nodes up on r1tod1 the horizon reads as continuous slopes; on the terrain diffusion world a 2200 node mountain with a lake at its top draws as smooth snowfields with the lake flat to its shore. Remaining visible seams: the shore skirts at coarse tiers stand as a palisade where the land falls steeply into water, and a cell's colour is still its own, so a snow and stone mountainside is patches at cell 16.

A far surface from the mapgen, 2026-08-23

"Must it be explored" was the owner's next question, and on an ordinary world the answer is still pregeneration, which the mod does and which is paced by mapgen. But the far field only needs one thing per column, where the ground is and what it is made of, and a mapgen that can say that for any (x, z) without generating anything can say it directly. Distant Horizons' server side does the same thing, asking the world generator at reduced detail. So the mod has a hook, goanna_register_far_surface(fn, opts), with fn(x, z) returning the surface height, the top node name, the water surface height if any and the node under the surface. When the store finds a block ungenerated and a provider is registered, it asks for the 16 columns of each of the area's blocks, one sample per 4 node cell, and serves the record made from that as known. A record made that way is remembered; when the block is really generated, register_on_generated replaces it with the real one and the area is offered again to the clients near it, which take a newer summary over a chain built from an older one (BlockLodChain::summary is what lets a summary chain be replaced where a live or stored one would not be).

The terrain diffusion mapgen registers one in tdl_far.lua: elevation from the tiles, the biome classifier's material, rivers and lakes from the water field, the sea at sea level. What it leaves out, against the generated terrain, is the detail noise (metres on a slope, nothing on a plain), the dither, the ragged soil line, caves and the trees, none of which is visible at four nodes a cell and hundreds of nodes away; the trees arrive when the blocks do.

Measured on a copy of the tdl_test world (122.9 km across, 1 m per node), joining at the explored mountain at (-26130, 2350, -9990) with a fresh profile: 848 node extent at 30 s, 944 at 60 s, the full 1024 grant at two minutes, on a server that had 31 live blocks resident at the time. Nothing was generated for any of it.

Found on the way and fixed: a synthesised column whose surface node sat in a block's top layer was treated as buried and painted with the filler, which drew contour lines of dirt across every snowfield.

The boundary rule holds: the server decides to offer this, from its own mapgen, and the client asks for exactly the summaries it asked for before.

Pregeneration walks vertically, 2026-08-23

Pregeneration only generated the player's 128 node layer and the one either side, which on ordinary terrain is everything that can be seen and on the terrain diffusion world is not: from a 500 node mountain the valleys were three layers down and were never made. Beyond the one layer either side, an area is now a candidate only when the area nearer the player's layer is generated and the mod's own store says the terrain carries on that way, ground at its ceiling for upward and air along its floor for downward, the same rule the client's request walk uses. Sky above a plain and rock under it are still never asked for.

How terrain arrives, and what it cost to make that smooth

The first build of the tiers arrived in walls: a region mesh appeared whole, and the frame stalled while it was made. Two things caused that, both measured with the worst poll_blocks call per second that render_stats now reports as poll_max_ms (the GOANNA_PERF line prints it).

A region build used to build every block chain it touched, in the same poll. A tier 3 region reads its 16 by 16 by 16 member blocks and a margin of four more each side for the occlusion radius, nearly fourteen thousand blocks at 0.3 ms each live or 0.5 ms from the store: seconds in one frame when a far area first came into range. Chains are now built from a queue, a few per poll inside a time budget; a region builds with the chains that exist, asks for the ones it lacks, and is dirtied again only when one of those is actually built. Blocks with no source at all, neither live nor stored, are remembered as missing so a region touching one does not rebuild every quarter second for nothing, which the first draft of the queue did: dirty never reached zero.

The near mesh had the same shape of problem without any store: poll_blocks took up to 24 blocks a call, at about 5 ms each meshed and uploaded, so the arrival of a new area was a 120 ms frame. It now stops at 6 ms (GOANNA_POLL_MS to change it) and requeues the rest for the next frame, and the region work takes what is left of that budget.

Regions were also halved for the near tiers: a region is now its cell size in blocks (4 blocks at cell 4, 16 at cell 16), so tier 1 arrives in 64 node pieces and a coarse tier still covers a lot of ground per draw call.

The original arrival fade used a shader instance uniform on every terrain MeshInstance. That is not a scalable place for transition state: an hour-long run exhausted Godot's instance buffer after roughly 3,800 retained block meshes, then emitted about 145,000 allocation errors and collapsed frame performance. Terrain now swaps atomically: its grouped replacement is built before the old exact mesh is released, and it uses no per-instance fade slot.

Measured against the test server with the store holding the spawn area and the camera 400 nodes away: the worst poll per second is 7 to 10 ms while an area streams, 0 to 1.4 ms at rest, and 126 ms once when the first materials and texture arrays are built; the one large figure left is content preparation itself (node visuals, the classifier, the first array textures) at 1.2 to 1.9 s, which happens before anything is drawn. Region meshing itself is still on the main thread at 0.3 to 1.3 ms a region, inside the budget; moving it to a worker is the next step if a finer budget is wanted.

Three-dimensional voxel summaries, 2026-08-25

Protocol version 6 replaces the per-column terrain and vegetation heights with 64 coarse voxels per mapblock, a 4 by 4 by 4 field at cell size 4. Each entry is known air, unknown, or a content index into the area's name list. The record is 83 bytes including packed two-bit liquid tops and block-level day and night light. The liquid tops stop a one-node sea from rising to the top of its 4, 8 or 16-node voxel. Per-voxel light is deliberately deferred because it would take one 8 cubed area's base64 reply past the mod channel's 16-bit string limit.

The flags separately say that a record has emerged data and that every node in it has emerged. A partial reply is published as it stands and stays due for retry until its 8 cubed area is complete (2026-08-28; the records that are missing stay unknown rather than invented, and a later reply's summary chains replace the earlier ones). It was published only once the whole area was complete, a rule added because publishing the complete mapblocks from one pregeneration slice while the other slices were absent produced floating horizontal panels on a new server. But an area holds 512 blocks and Luanti generates in 5 block tall chunks, so one ungenerated block at a chunk boundary is the normal state, and the atomic rule held the other 511 back for the whole session: the horizon was full of 128 node holes that never closed. The panels the rule was written against were the store and provider paths' absence of retry, which the retry now covers. Version 5 used one flag for both meanings, so one known cell could make a partly emerged mapblock permanent and preserve its unknown cells as strips through a mountain. Version 6 uses a new fs6: store prefix so those stale partial records cannot survive the upgrade.

Live nodes, stored nodes and summaries now feed the same recursive 2 by 2 by 2 reducer for cell 8 and cell 16. Any occupied child keeps its parent occupied, an opaque child wins over a non-opaque one, and a stable corner order breaks ties. This is the Voxy-shaped invariant the old heightfield did not have: every tier is still an XYZ occupancy field. The region box mesher therefore emits an underside wherever an occupied voxel has air below, with no floating-island heuristic. It also closes a face at an unknown section boundary and remeshes it when the neighbour arrives, matching Voxy's section-side strategy instead of exposing the volume's interior while streaming. Caves and gaps disappear only when they become smaller than the selected voxel, not because Y was discarded.

The recursive levels remain useful occupancy summaries, but presentation no longer spends resolution by coarsening exposed faces. A full block received live or recovered from the store retains an exact cell-1 boundary. Its 4096 node occupancy values are compacted to bit masks after the cell 2/4/8/16 mips are built; only exposed filled nodes and adjacent lighting samples retain full cell records. A one-node trunk, leaf crown, cave wall or floating-island underside therefore stays on the node grid without retaining a dense cell-1 material volume.

Server summaries still begin at cell 4 because that is all protocol version 6 transmits. Exact data uses a world-aligned cell 1, 2, 4 progression across the three distance tiers, while summary-only blocks remain cell 4. A region can therefore have two ownership passes. Their surfaces are folded into the same texture batches before upload, and the mixed-resolution boundary test culls a face only when the geometry actually drawn by the other pass covers it. Region batching also grows with distance; deep regions remain capped at eight mapblocks per edge.

Tier seams cull against the voxel size actually drawn by the neighbouring block. In particular, a coarse face touching four finer voxels is removed only when all four cover it; an occupied but undrawn coarse mip of that neighbour cannot suppress the face. This conservatively permits some hidden overlap at a mixed-detail edge, but it cannot open a strip through the terrain.

All six boundary directions must also agree on winding after Luanti Z is mirrored into Godot Z. The Z-facing tables once used the X/Y index order and were consequently wound inward: the CPU reported a complete six-sided voxel, but backface culling removed two sides and hills appeared as disconnected horizontal panels. goanna_lod_test now compares every triangle's winding to its outward normal, rather than merely counting faces.

The smooth terrain surface remains in the mesher for old/internal callers but is no longer authoritative for chains built by the client or protocol v6. Those chains leave terrain empty and draw their confirmed occupancy on all six sides. Source precedence is deterministic as well: stored full nodes upgrade a summary even if the summary arrived first, and live nodes upgrade both.

Live blocks are ingested into this chain even while the full-detail renderer owns them. Pruning a near block transfers that retained chain directly to a far region instead of calling lodForget; approaching a hole therefore cannot teach the client correct terrain only for it to discard that knowledge again on retreat. The request scanner no longer lets 16 diagonal samples claim that all 512 blocks of an area are covered.

Every processed live mapblock is registered as tier zero even when its near mesh has no triangles. A fully enclosed solid block quite correctly emits no near faces, but it still carries occupancy the far volume needs after pruning. Tracking only blocks with a MeshInstance3D omitted those interiors during the near-to-far transfer and reopened holes in terrain the client had already visited.

Visible full-detail mapblock meshes remain only while their MapBlocks are resident. On pruning, their persistent 4-node occupancy chains transfer into grouped volumetric regions; each replacement region is uploaded before the old exact meshes are freed, so looking back cannot expose a one-frame hole. Retaining the exact meshes throughout the far grant was tried and rejected: the measured hour-long run held 6,161 individual block meshes, exhausted Godot's shader-instance buffer after 104 seconds, and reduced the client to a crawl. The retained chain database is audited directly during pruning rather than trusting presentation bookkeeping to enumerate every known block.

For terrain not previously seen at full detail, ownership is still decided per mapblock: a resident block is near and any non-resident block with a chain is far. A configured view radius cannot serve as a floor because generation, view-cone and server occlusion filtering do not guarantee that every block inside its circle was actually delivered; using it as one cut the visible circular trench between the two renderers. On approach, the full-detail block is made opaque before an old volumetric region drops its member.

Far materials use the same array shader, normal/specular companions and PBR path as near blocks. Their former colour discontinuity came from two inputs, not a separate non-PBR material: length(VERTEX) measured distance from the world origin because Goanna vertices are already absolute, and the widest merged quad in a region forced every face in that region to its tile-average colour. Flattening now uses camera-to-world-fragment distance only and starts beyond 384 nodes, leaving the near/far boundary unflattened. Water makes the same gradual transition instead of switching straight to its last mip. The distance ramp flattens the whole texture-scale material response together: normal relief, roughness, metalness, specular and leaf backlighting approach a fully rough dielectric along with the averaged albedo. Flattening colour and normals alone left a smooth averaged canopy carrying the close tile's sharp highlight, so distant trees became more reflective at precisely the point their visible detail disappeared. Summary lighting also no longer paints a block-wide maximum over solid interiors: skylight is reconstructed through known open columns, and the unlocated block-light maximum is not allowed to turn an entire distant hill emissive.

Night distance fog uses the blended night horizon, not a server's commonly day-authored fixed fog override. Godot aerial perspective samples sky radiance; Goanna deliberately lifts that radiance at night for ambient light, so using it as the fog destination faded distant terrain toward pale grey. Aerial perspective now belongs to daylight and twilight, while night retains the separately coloured and dimmed depth fog.

Regional full-detail batches, 2026-08-25

Tier-zero geometry keeps one CPU surface cache per resident mapblock, but depth-writing surfaces are uploaded in 4 by 4 by 4 block regions grouped by their exact material key. Water, glass and other alpha-blended surfaces stay block-local because Godot sorts transparent instances as whole objects. The regional node uses its complete ownership cube as a conservative visibility bound, including a margin for bevels and vegetation sway, so an incomplete region rebuild cannot cull a member at the edge of the camera frustum.

This preserves block-level remeshing and the existing near-to-far ownership handoff while removing the one-GPU-instance-per-mapblock cost. In measured fresh worlds, about 1,700 source surfaces became 288 regional/special surfaces across 26 batches, with roughly 105 visible draws. render_stats() and the performance overlay report source surfaces, presented surfaces, batch count and regional rebuild time separately.

The server's live-block view cone now follows the actual frame from the first player-position packet. Godot creates the camera before it creates a network session, so the computed enclosing FOV is retained by GoannaClient and applied on connection rather than being discarded. At 1600 by 900 and a 70 degree vertical FOV this reports a 110 degree circular cone, enough to include the frame corners; the former 70 degree session default cut vertical wedges from both screen edges until a window resize happened to resend the setting.

Terrain occlusion, 2026-08-25

Frustum culling cannot tell that a far region is behind a cave wall. Regional near meshes therefore also publish ArrayOccluder3D geometry for their fully opaque, backface-culled triangles. Alpha-blended tiles, cut-out foliage and liquids never occlude. Array textures are classified by the triangle's actual layer, not by the whole array: a stone layer remains an occluder when a leaf layer elsewhere in the same array contains transparent texels. Godot culls a region only when the occlusion buffer covers its complete AABB, so incomplete or partly visible far terrain remains visible.

The Video settings include a live Terrain occlusion toggle for an exact A/B. The performance overlay reports active occluder regions and triangles, render setup CPU time, viewport render CPU time and measured GPU time. On Mineclonia with Godot 4.5.1, a fixed outdoor view with 4,094 far blocks changed from 841 draws and 912 objects with occlusion off to 707 draws and 778 objects with it on. This open view is deliberately a weak case: measured render CPU rose from about 0.52 to 0.71 ms while GPU time changed from 3.02 to 2.94 ms. Enclosed spaces are the intended case, because their nearby walls can cover the far regions that frustum culling alone still submits.

Retained summaries are knowledge, not visible work. A settled client may hold tens of thousands of mapblocks while only a few hundred regions contribute to the frame. Tier reassessment therefore runs after the camera crosses a mapblock boundary or a tier setting changes, not over the full retained map on every stationary frame. The HUD samples render_stats() four times a second, and its optional tier histogram is built only for GOANNA_PERF; otherwise the performance counter itself scaled with the summary database. The overlay separates complete LOD-frame time, tier scan, summary ingestion, request scan, periodic far audit and statistics collection so future growth is attributable.

The near/far water hand-off, 2026-08-28

The far tier water plane used to run water.gdshader with waving off and the reflection march gated on waving, so far water had no reflection at all where near water was nearly all reflection, and its thick was near zero on a plane sitting on the tier terrain, so it did not absorb either (recorded under "The far field's albedo" above as an R2). At sea the result was a dark band across the horizon with a hard edge wherever the near mesh ended, and no sunrise or sunset on the far water.

Both materials now carry the same parameters (waving true, lod_flatten true, near mesh included), and every difference between near and far is a continuous function of view distance inside the shader, never of which mesh a pixel came from. Two materials that agree at every distance cannot draw a seam, wherever the hand-off falls, and the client's view range no longer enters into it. The distance rules:

  • Waves already dropped octaves over 24 to 200 nodes; the remaining detail now also fades to the flat face over 500 to 1200 nodes, where even the longest train is below a pixel and only shimmers.
  • The screen space march fades out over reflect_range (default 512 nodes) and the sky fallback stands alone beyond it; the fallback itself runs at every distance, far tiers included.
  • The fallback draws the sun's disc and halo from two new globals, goanna_sun_dir and goanna_sun_glow (set in _apply_sky beside the sky gradient, zero at night and under weather skies that hide the disc), with the same weights the sky shader uses. This is what lays the sunrise and sunset out across the sea to the horizon.
  • Before settling for the gradient, the fallback samples the frame's own sky: the opaque pass has already rendered the sky shader, raymarched clouds and all, into the screen texture the water samples anyway, and a reflected direction projects to the screen point showing that direction with one matrix multiply, because the camera is the view space origin. Where the depth buffer says sky at that point, the real pixel is used (edge faded back to the gradient), so the water reflects the actual cloud deck; water near the horizon reflects sky just above the horizon, which is on screen in exactly the views where it matters. Two texture fetches per water pixel, against the 24-step march already running.
  • Absorption is forced to full depth over 160 to 420 nodes: at eye height the view ray out there is near grazing and the near mesh is fully absorbed anyway, and the forcing gives the far tier the same answer its zero measured thickness cannot.

Verified offline in project/studies/water_seam.tscn, a fixture that draws one sheet of water split at 240 nodes into a near strip and a far tier strip under a sunset (GOANNA_SEAM_OLD=1 reproduces the old material set for a before shot): before, the far strip is a flat dark band with a hard edge; after, the sea is continuous to the horizon with a glitter path under the sun and the split is not findable in the frame. Godot 4.5.1, no server. Not yet observed against a live server.

The far field was half a node adrift, 2026-08-28

The region mesher builds a cell for node k across [k, k+1] on every axis; the near mesh centres node k on k, spanning [k-0.5, k+0.5], and Luanti Z mirrors into Godot with the sign flipped. So the whole far field rendered half a node high and half a node sideways. Terrain hid it: skirts are half a cell deep and hillsides are not flat. Flat water told on it, twice: the far sea rode above the near sea as a bright lip wherever the hand-off crossed open water, and far water lay level with far ice tops it should sit half a node under, so a frozen sheet's edge in the far field flickered between the water's colour and the ice's (both reported with screenshots, 2026-08-28).

The correction is one transform: the published far region node sits at (-0.5, -0.5, +0.5) in Godot space, and every vertex the mesher emits keeps its internal corner convention. UVs are derived from the same positions, so the tiling moves with the geometry and stays aligned with the near mesh's per node repeat. Verified against the live server by day (a storm took the dusk): the lip at the water junction is gone and the junction reads as one sheet. The frozen sheet at far range was not re-verified before the storm closed in; if its edge still misbehaves, look at the hand-off freeze counters before the geometry.

The far field was off in every session, 2026-08-30

Reported as terrain disappearing when the player leaves it rather than degrading to a coarse surface, and, after waiting for the horizon to fill, the whole vacated region vanishing: far field 0 blocks, reach 0/0, requests 0, while the HUD showed a 4096 node grant. On the same frames, thousands of far region meshes with stale-drawn in the thousands and oldest past seven minutes: nearly everything visible in the distance was a zombie mesh whose members had already been forgotten, dissolving as rebuilds landed.

The cause was one clamp. The graphics profiles store far_distance = -1 meaning "whatever the server grants". The profile apply loop knew to skip it, but the profile picker saved it raw into goanna.cfg, and the stored settings loop applied it raw on every later launch: set_far_distance(-1) clamped to 0 and set the explicit flag, so lodUpdateFar ran with a far radius of one mapblock for the whole session. Every pruned near block was handed to the far field and forgotten within two seconds as out of range; no summary was ever requested; the store was never scanned. Everything in docs since the profiles landed that reads "the horizon never fills" wants re-judging against a build with this fixed.

set_far_distance now treats a negative as the auto choice: it clears the explicit flag so the grant is tracked again, and the profile apply loop passes -1 through instead of skipping it, which also clears a numeric cap left by an earlier profile. A cfg holding -1 is valid and keeps meaning "the grant".

Measured against the local Mineclonia test world (Luanti 5.16.1, Godot 4.5.1, 512 node grant), the user's own profile with the poisoned cfg: 20 s after joining, 22047 far blocks, requests in flight, extent 288 and climbing; 45 s after a teleport 800 nodes away, extent 480 of the 512 grant, stale-drawn 0, oldest 0.0 s, and the look back shows the vacated area as a continuous coarse surface to the horizon.

Left open: the settings panel shows "Custom" instead of "Rich" whenever far_distance tracks the grant, because far_distance() reports the tracked number and the profile compares it against -1. Cosmetic, and it predates this fix.

Ten million triangles, and terrain from the wrong world, 2026-08-30

Four fixes from one report against the terrain diffusion world at a 4096 node grant: 17 fps sinking to 2, dawn shining through a hill, holes in the hills, and giant mismatched panels, all at 10M primitives and 15k draws.

The tier ladder scaled with the detail distance. Coarse thresholds doubled from lod_distance * 16 itself, so the rich profile's detail of 32 blocks pushed cell 4 geometry out to a kilometre, cell 8 to two, and the cell 16 tier past the grant entirely, in tier 1 regions two mapblocks across. The doubling base is now capped at 256 nodes (the detail distance still floors the first band), which changes nothing at the old default of 12 and returns the coarse tiers to their intended job above it.

Every far rescan walked every retained block. The out-of-range prune, the summary cache prune, the store scan and the membership repair pass in lodUpdateFar each visited the full sets, under the map lock, every two seconds; at half a million retained blocks that was over 30 ms of main thread per rescan. All four are now bounded sweeps with resume cursors (16384 entries or 4096 store regions per rescan). Leaving range is memory hygiene and can be lazy; a block turning live is still released at once by poll_blocks, and prune_blocks still hands pruned ground straight to a far region.

Measured at the same viewpoint two minutes after joining, before and after: far regions 14596 to 5749, GPU 14.0 to 4.5 ms, far rescan EMA to 0.004 ms, 39 to 57 fps. At the filled state (716k far blocks, reach 2880): 5.6k draws, 2.1M primitives, GPU 4.7 ms. The median fps while the field actively streams is still ~30 with spikes from mesh production, so production pacing is the next candidate, but the render side is settled.

Dawn shone through hills because the sun disc was in the radiance cubemap. fog_aerial_perspective blends a fogged pixel toward the sky's radiance in that pixel's direction, and the sky shader drew the sun disc at three times unity, plus its halo, in the cubemap pass too, so a hazed hill in front of a low sun glowed with the disc behind it. The discs and halos (moon too) are now picture-pass only; their solid angle is far too small to matter to ambient energy. Extending the sun's 200 node shadow distance was measured as an alternative (the far meshes do cast): 1200 and 2400 nodes moved the glow band by two luminance units of 98 while costing 10 to 20 fps, so it stays at 200; the remaining broad dawn haze is bloom plus fog in-scatter, which is weather rather than a defect.

The giant panels and the permanent holes were another world's store. The block store is one directory per host and port, which is right for a remote server and wrong for the local flow, where every world a player ever launches lives at 127.0.0.1 on the same port. Stored blocks deliberately beat summaries, so a slab of savanna stored while playing one world drew inside the mesa world at the savanna's own elevation, a floating foreign panel, and air stored in one world cut holes in the other's hills that no retry could ever fill. Reproduced by copying the mixed store under a test key: a patch of jungle spawn, structures and all, stood in the diffusion plain; the same viewpoint with a clean store shows the world's own ground. A locally launched world is the one case where the client knows the world, so main.gd now roots the store at goanna_store/world_<name>/ when GOANNA_SP_MATCH names one. Existing mixed directories are left alone; local worlds simply stop reading them. A remote server that swaps worlds still shows stale terrain until looked at, as recorded above, because the wire still does not name the world.

After the radiance fix: the pre-crest brightness and the rays, 2026-08-30

Two observations followed the sun-disc-out-of-radiance fix: the world brightens before the disc clears a ridge, and no rays stream when it does. Both were investigated on the diffusion world; one led to a measurement and a rejection, the other to a new pass.

The early brightness is not unshadowed sun. At a pre-crest time the frame was identical at a 200 and a 1536 node shadow distance: the light on the land is the sky fill and the dawn ambient, which ramp with the clock, plus the dusk hold that runs the sun at 0.4 energy while the disc is still below the geometric horizon (main.gd, "Hold the sun through the golden hour"). Longer shadows cannot dim any of that. The glow that used to sit on the terrain toward the sun was the radiance defect itself, and it had been reading as "the sun arriving"; what remains is a calibration question for the chart, not a shadowing one. Extending the shadow distance was measured anyway: 1536 nodes triples draw calls (4k to 13k, the far regions drawn into the cascades) and costs about 3 ms of GPU on this machine, for two luminance units of change in the dawn frames. It stays at 200.

Godot's froxel fog cannot draw the rays this world wants. A ray at the horizon is shaped by a ridge hundreds of nodes out; the froxel volume is metres per cell at that range and the 200 node shadow map never contains the ridge. So the rays are now screen space: shaders/light_shafts.gdshader, a full screen additive quad on the camera that marches the depth buffer from each pixel toward the sun's screen position, the classic crepuscular pass. Every silhouette at every distance occludes it for free, far tiers included. It reads the same goanna_sun_dir and goanna_sun_glow globals as the water's fallback reflection (so night and weather already zero it), projects with the same matrix idiom water.gdshader has proven against the screen, and its strength follows the shafts slider, the sun's elevation, the cloud deck gate and the underwater cut in _apply_sky.

Not yet run. Every client launch after the code landed died at vkCreateDevice; the kernel log shows NV_ERR_RESET_REQUIRED from NVRM on each new channel allocation. The cause was an rpm-ostree install run on the box meanwhile: new NVIDIA userspace under the still running kernel module, so no new process could create a device while already running ones kept theirs (a bare Godot with no project reproduced it). A reboot into the new deployment clears it. main.gd passes --check-only; the shader is built from constructs the water shader already compiles. First run after the reset should check: a dawn sun half hidden by the mesa east of (130, 37, 313), rays swinging as the camera pans, no mirror-image rays (which would mean the projection's Y convention differs from the water path after all), and the pass gone at noon, at night, in storms and under water.

The shafts pass drew nothing at all, 2026-08-30

Run for the first time after the reboot, against the checklist above. It failed the first item: at the mesa at dawn the frame was the same with the pass on and off.

unshaded discards EMISSION. The fragment ended ALBEDO = vec3(0.0); EMISSION = col;, and under render_mode unshaded Godot takes the fragment colour from ALBEDO alone and never runs the lighting step that would apply emission. The quad was therefore adding black, on every frame, since the pass landed. ALBEDO = col is the whole fix.

Measured at (130, 37, 313) looking east, time_of_day 0.22, the slider at its shipped 0.8, by hiding shaft_quad rather than moving the slider: the slider also scales volumetric_fog_density, so an A/B on it measures the froxel fog and not this pass, which is how the fault survived a first look.

signed mean, /255 pixels differing by more than 2
two shots, pass hidden both times (the noise floor) -0.09 279,981
before the fix, pass shown against hidden +0.01 200,551
before the fix, slider forced to 8.0, ten times shipped +0.09 57,696
after the fix, pass shown against hidden +3.11 1,105,440

The middle row is the point: at ten times the shipped strength the pass was still an order of magnitude under the frame-to-frame noise of the animated clouds and foliage. Anything measured against a moving sky needs its own noise floor taken the same way, or a null result reads as a small one.

After the fix the checklist passes: lanes of light through the gaps in the mesa silhouette, fanning across the canopy; the fan swings to the screen edge and falls off as the camera pans away; nothing on the far side (the dir.z < -0.05 guard leaves col at zero, and the measured difference with the sun behind the camera sits at the noise floor); and shaft_strength is 0.0 from time_of_day 0.278 to 0.72, so noon costs nothing.

Animated tiles in the far tiers, 2026-09-19

The note under rungs 2 and 3 that animated tiles have no array texture and fall back to a flat average colour is no longer true of every animated tile. Cube-like animated tiles (magma, prismarine, sea lantern and the like) now live in Goanna's own animation arrays, and tileFor in goanna_lod.cpp gives a far cell of one of them that array and its first frame's layer. The far material is the ordinary tier copy of the near array material, so the tile stays textured, picks its frame in the shader with the same clock as the near mesh, and flattens toward each frame's own average colour. The hand-off therefore has no texture to lose and no colour to jump to. Double sided and special shader tiles (fire, torches, plants, portals) keep the flat colour fallback, and water and lava stay on rung 6's materials. docs/systems/node-animation.md has the rest.

Checked on a fresh Mineclonia world (Luanti 5.17.0, Godot 4.5.1) by setting the LOD distance to one mapblock so a test wall 38 nodes away drew from a far tier, which the missing torches, lanterns and campfires confirm, since far cells draw only cubes. Its magma, sea lantern, prismarine and sculk were textured; between clock times 0.0 and 0.5 s the magma and sea lantern changed (8 and 11 per cent of their pixels) and the slower prismarine and sculk and the still netherrack and sand did not. With animation switched off and the tiers rebuilt, the same four cubes drew as flat colours. Not measured: the draw call cost at a real vista full of animated cubes, such as an ocean monument, which a fresh world did not have in reach.

Summaries off the server thread, 2026-10-10

Folding a mapblock into its far summary was Lua on the server thread, the one every mod's globalsteps and every player's movement share. On Luanti 5.9 and later the mod now reads each block there and hands a copy of its VoxelManip to Luanti's async workers, which run far_summary.lua in jobs of 16 blocks, several at once; the server thread packs the results into the store when the jobs come back. Nothing about the records changed: 400 randomised blocks, generated, part generated, ungenerated and with unnamed contents, packed byte for byte the same as the old block_summary under LuaJIT. goanna_server_mod/README.md has the settings, goanna_far_summary_async and goanna_far_summary_async_jobs.

Measured with the bundled Luanti 5.17.0 server on a copy of the explored Mineclonia world fdfd3w with the mod's summary store emptied, so backfill summarised the explored world around one Goanna client (Godot 4.5.1, under --headless, 1024 node grant, pregeneration off). Four minute runs, with goanna_far_log_stats counting the server thread time of the summary pass and of filing results, per 30 seconds, over the windows after the join:

Run Blocks read per 30 s Server thread ms per 30 s Per block Mean max lag
Server thread, run 1 47,201 17,291 (58%) 366 us 0.105 s
Server thread, run 2 49,270 14,322 (48%) 291 us 0.091 s
Async, cap checked once a step (up to 13 jobs) 38,043 3,014 (10%) 79 us 0.060 s
Async, strict cap of 8 jobs 30,020 2,185 (7%) 73 us 0.051 s
Async, strict cap of 16 jobs, 3 windows only 37,854 3,375 (11%) 89 us 0.069 s

So backfill had been taking about half of the server thread, and now takes about a tenth, for roughly four fifths of the throughput at 16 jobs. The cap of 16 chosen from this was not what bound it; see the second sweep below. The remaining server thread cost is the map read and filing the record, of which set_record rebuilding the area's 47 KB blob string for every record is the obvious next target.

The machine was shared with other sessions' servers throughout, and the 16 job run was cut short when the machine began swapping (load average over 200) and stalled every process on it for 16 minutes; only its three windows before that are counted. One run per row, with the server thread rows repeated. Not yet measured: a server with several players, and Kythen or VoxeLibre. fine.lua's full detail replies and the far surface provider's synthesis still run on the server thread.

The same evening, with the machine idle (load average under 5 at every start), a second sweep of the same setup, 190 seconds a run, averaging the four full windows after each join:

Blocks per step Job cap Blocks read per 30 s Server thread ms per 30 s Per block
96 16 (twice) 37,408 and 38,086 3,152 and 2,875 (10%) 84 and 75 us
96 32 (twice) 37,959 and 38,054 3,291 and 2,480 (10%) 87 and 65 us
96 64 37,176 2,949 (10%) 79 us
192 32 (twice) 60,478 and 60,354 3,804 and 3,816 (13%) 63 us
384 64 (twice) 68,161 and 67,450 4,434 and 4,210 (14%) 65 and 62 us

Mean max lag was 0.051 to 0.055 s in every run. At 96 blocks a step no run ever had more than 12 jobs out, whatever the cap: the per step read budget was the limit, not the workers. Doubling it to 192 gave 1.6 times the throughput, a quarter more than the server thread alone managed, for a little more server thread time. At 384 the jobs out sat at the 32 or 33 the 14 workers (Luanti's automatic pool on 16 cores) could keep in hand, so the workers had become the limit and every core was busy for 12% more. With summaries on the workers, the defaults are therefore 192 blocks a step and a cap of 32 jobs, and a server that sets goanna_far_summary_blocks_per_step keeps its own value. Still one client and one game.

Where the server thread time goes, and filing records

goanna_far_log_stats now splits the summary pass into the map reads (summary_read_ms) and handing them to the workers (summary_dispatch_ms), and the landing into packing and filing the records (summary_file_ms). Filing had spliced each record into its area's 47 KB blob string, copying the whole string per record; records now wait in a table on the area and the blob is built once, when a reply, a save or the face scan wants all of it. 300 randomised areas of up to 1200 filings each built the same blob byte for byte as splicing.

Same setup as the sweep above, default settings (192 blocks a step, 32 jobs), the old and new filing alternated, four full windows each:

Filing Blocks per 30 s Read Dispatch File Read per block File per block
Splice, run 1 56,527 2,760 ms 298 ms 1,205 ms 49 us 21 us
Splice, run 2 58,356 2,568 ms 265 ms 1,128 ms 44 us 19 us
Deferred, run 1 53,608 3,005 ms 345 ms 215 ms 56 us 4.0 us
Deferred, run 2 51,613 3,446 ms 377 ms 278 ms 67 us 5.4 us

Filing fell by about four fifths, a fifth of the summary's server thread time. The reads in the deferred runs are not comparable: another session started a model server on four cores during them (load average 6 rising to 19), and their reads per block climbed window by window, from 47 us to 116 us in the last, while filing stayed at 4 to 6 us. On a quiet machine the map read is about 45 to 50 us a block, three quarters of what summaries still cost the server thread, so reading several blocks with one VoxelManip is the next thing to measure.

Reads, worker code and detail replies, 2026-10-10

Reading several blocks with one VoxelManip is not worth doing. A test mod timed 1024 groups of 2 by 2 by 2 blocks on a copy of fdfd3w: a block read for the first time, loaded from the map database as backfill loads it, cost 56 to 63 us read singly and 53 to 61 us as part of a group; a block already in memory, 12 us against 9. The cold cost is Luanti loading and decoding the block, which a group does not share. core.emerge_area would load blocks on the emerge threads, but from Lua it always allows generation, and summaries never generate. The cold read is paid once per block of a world older than the store; freshly generated and changed blocks are in memory already.

far_summary.lua's summarise_vm was rewritten for speed: index arithmetic in place of VoxelArea:index, one cached kind per content id, and surface_material.lua's vote written inline instead of through a sampling closure. Under LuaJIT on terrain shaped blocks it takes 77 to 92 us against 214 to 290 us, and it gave the same summary on 1000 test blocks, including emerged areas larger than the block, part generated ones and missing light values, and the same records as the original server thread code on the 400 block test. On the server at 384 blocks a step, where the workers had been the limit, the old and new code alternated twice with the machine shared (load average 5 to 12):

Worker code Blocks per 30 s, last three windows Jobs out
Old, run 1 57,445, 48,776, 59,019 16 to 33
Old, run 2 55,909, 66,234, 64,109 0 to 33
New, run 1 75,939, 76,590, 76,096 0 to 32
New, run 2 75,406, 76,111, 75,516 1 to 32

The new code keeps up with the step's budget, 384 blocks a step, so the workers are no longer the limit there. The default stays at 192 a step, which leaves the cores to the game's own work.

fine.lua's full detail replies now encode on the async workers too, with up to 16 out at once; the step reads the block and sends the reply. One headless client in the same setup asked for about 550 detail blocks per 30 seconds either way. Encoded in the step they cost 240 to 280 ms of the server thread per 30 seconds, about 0.45 ms each and up to 2 ms in a step; on the workers, 14 to 17 ms. A small saving on one client, but it no longer grows with the number of players asking.

Partial summary retries and completion counts, 2026-10-13

The coastal village comparison exposed a slow fill at a 2048-node grant. A CPU-only probe against a disposable copy of goanna_coastal_reference_20261013, Luanti 5.17.0, Mineclonia release 38561 and Godot 4.5.1, confirmed that async summaries were enabled. The client mesh queue stayed empty. In one 30-second server window the serial map reads took 10.4 seconds and maximum lag reached 3.3 seconds, while the worker queue was below its cap. Moving the folding work to workers had not removed the serial read cost.

There were two faults in handling partial records. set_record incremented its complete-record count whenever the old record was incomplete, even if the replacement was incomplete too. Rewriting one partial record 512 times could therefore settle an entire area. It now counts changes in the complete bit, including a complete record becoming partial. The persisted background-scan index uses a new key; useful summary blobs are retained.

Each client's retry also re-read partial mapblocks even when no terrain had changed. With register_on_mapblocks_changed available, a partial read is now cached for the server session. Generation bypasses that cached check, and node changes force a new read after the existing cooling period. The wire still says partial. Servers without the change callback retain the old retry behaviour. Background settlement means every record has been checked, not that every record is complete.

luajit tools/test/test-summary-store.lua reproduces the count fault and checks repeated partial reads, complete/partial transitions, generation, forced refreshes and the older-server fallback. On a real disposable Mineclonia server, block (-120, -8, 40) stayed partial and a second read was skipped; editing a known node refreshed it through the actual change callback; emerging its area then replaced the partial record with a complete one. The test restored its node edit before generation and never changed the source world.

Two 120-second CPU-only runs used the same world source, 192 reads per step, 2048-node grant and village pose, old code first. At their last matching sample, 110.8 seconds after posing:

Code Requested areas Remote blocks Lower/upper sector reach Mesh queue
Old 1053 251433 1104 / 1840 nodes 0
Patched 1384 307713 1104 / 1840 nodes 0

This is an initial throughput observation, not a settled performance verdict or proof that the full horizon arrived. There was one run per variant, filesystem cache and background activity were not controlled, and no rendered frame timing was measured. First reads still run on the server thread, large grants still request substantial volumes, and the same distant extent in both runs leaves further loading work to measure.

Cliff sides and crown undersides, 2026-10-13

The western village comparison exposed a second issue after summary loading was corrected: the ground surface pass suppressed voxel sides even when the column top belonged to another vertical region. That region had no surface edge from which to draw a skirt, leaving the cliff open. Actual ground now keeps exposed voxel sides. Skirts still join different resolutions, and provider skins retain skirts because they have no solid volume underneath.

Coarse vegetation no longer replaces the ground with a canopy height surface. It retains its voxel volume and crown undersides. A separate regression found that a completely filled mapblock floating over known air also lost its underside: its full height bypassed the floating-column classification. The box pass now retains that lower face when the lower cell is known, while preserving the unknown ground frontier behaviour.

Native regressions reproduce the missing cliff side, missing crown underside and missing full-mapblock underside. The complete native suite passes, including mixed-resolution joins and provider skins.

The cliff and crown changes were rendered at both western village angles on a copy of goanna_coastal_reference_20261013, with Luanti 5.17.0, Mineclonia release 38561, Godot 4.5.1 and an RTX 3090 through the render service. The main mountain's sides were more continuous. These frames preceded the full-mapblock underside correction. Coverage stabilised at a 2000-node reach for the first angle, but mesh quiet timed out. This is visual evidence, not a frame-time measurement or proof of a complete horizon. A read of server nodes behind one remaining vertical strip found a real steep stone edge, so vertical faces alone are not evidence of gaps.

The final geometry build also rendered both angles on the same server and renderer. The mountain remained visible, but the amount of refinement varied between captures. Inspection found thousands of dirty regions and a populated worker queue after coverage had stabilised. The capture's coverage check therefore does not establish mesh stability.

Reusing unchanged summary chains, 2026-10-13

Partial-area retries could return the same known records repeatedly. The client still rebuilt their chains and dirtied their own and neighbouring mesh regions each time. A chain now retains a SHA-256 fingerprint of its wire record and palette. An identical reply with the same stored/live provenance reuses that immutable chain without invalidating meshes. A changed record or palette is applied normally; a fresh reply still replaces provisional disk shading. The fingerprint is runtime metadata and does not change the persistent format. far_summaries_reused counts records skipped this way.

The native suite passes. A controlled protocol fixture ran through the render service's real Luanti 5.17.0 server, Mineclonia release 38561 and Godot 4.5.1. Normal summary requests were temporarily disabled through the server announcement so their counters could not mask the result. Repeated records incremented reuse exactly once; palette and voxel changes did not; replacing solids with air and then restoring them also bypassed reuse. Repeating the restored record incremented it again. The fixture sent synthetic records at one distant mapblock, removed its synthetic chain afterwards and restored ordinary summary requests. It changed no world nodes. Normal landscape traffic also exercised the reuse path.

This removes redundant invalidations, not the cost of first reads, newly received terrain or changing detail tiers. A populated worker queue after coverage settles remains a separate observation from a stable horizon.

A subsequent panorama on a copy of the same coastal world showed more continuous terrain behind the left-hand trees and along the distant shoreline. During that run, sampled dirty-region counts fell from 5304 to 22 and the tier scan finished; 74 mesh jobs were still queued at the latter sample. The capture then recorded stable coverage at a 2000-node reach. The library fingerprint was 22fb63753615bb03, built from the dirty tree based on 082ef495, with the same server, game and renderer as above. This is a visual and queue observation, not a controlled performance comparison or proof that every distant boundary is closed.