The chain no longer dies at the room source. The criterion is the per-voxel cull
lifted from point to box, which can fail for exactly one reason: Eval starts at
MinSDF = FLT_MAX and only lowers it through a primitive that survives its own
cull, so if no cached primitive survives its cull anywhere in the box, Sdf stays
FLT_MAX across the whole box.
FSdfConvertOp already returned Identity ("la source a repondu pour la paire") and
the twelve detail modifiers already inherited through VF_NoCaveOverBox. One
function learned to answer and fourteen operators became provable -- what the C1
wiring was built for.
Three deliberate choices, all erring toward CPU rather than toward a hole:
- |Perlin3D| <= 2, derived from GradDot + the convex hull of a trilinear lerp,
instead of the header's observed "~[-1,1]". A verdict resting on an observation
is the hole this file spends its life avoiding.
- the op pool is passed to BuildChunkCache, not nullptr: the bake reads OpParams
to place pits and chimneys, so nullptr would under-bound the cache and could
return Identity over a real pit.
- the search box is wider than Eval's, giving a superset of primitives.
The verdict is memoised per box (all twelve modifiers ask the same question), and
the cache is a SECOND per-worker cache so classification cannot disturb a live
generation's hot cache.
AUDIT C2, confirmed 2026-07-28, is fixed on the switch path in the same breath:
GetDensityWithParams now takes required ParamsFingerprint + LayoutVersion. The
alternative this audit section used to recommend -- add chunk Z to the key -- is
insufficient (Interleaved makes Alpha depend on chunk XY too) and destructive
(chunk XY is deliberately absent so gradient probes don't thrash the box, ARCH
8.10). The CRC is taken once per chunk where the params memo already lives, so
the per-voxel cost is two integer compares. The three test call sites pass it
too, so the oracle stops sharing the defect it tests.
Check 4 of the tunnel test no longer asserts "0 proved" -- that assertion would
now forbid the gain. It brute-forces every proved tile voxel by voxel instead and
reports the count, because a false verdict leaves no geometry and no collision
behind it.
The second debt (per-room ops can raise a modifier's amplitude above the strate
params a box bound reads) turned out to be DORMANT, not live: where the source
proves Identity the modifiers' gate never opens, and where it answers Both it
supplies no MaxCarveOverBox so nothing is provable anyway. It goes live the day
the source gains one. Written at the site.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
60 KiB
VoxelForge — Independent Audit, 2026-07-26
Scope: every file under Source/VoxelForge/ (~16,400 lines, 30 files) + all five design docs, read in
full. Axes requested: correctness & threading, perf & algorithm choice, architecture &
maintainability. Bar: fitness for purpose for VoxelM — not marketplace-grade generality.
⚠️ This file is currently untracked — the strict-whitelist
.gitignoreonly keepsSource/,CODEMAP.mdand the.uplugin. See P1.
0. Verdict
This is good work. Well above the median for a solo UE plugin: the perf reasoning is real and measured (Insights traces cited with numbers), the invariants are named and defended, the caches are box-validated rather than key-validated for the right reasons, and almost every expensive path is budgeted. The clipmap + trivial-tile-reject + worker-built streams architecture is the correct shape for this game. Nothing in here reads as cargo-cult.
And it is not in a state where you can answer "does it work?" Three separate things have quietly gone wrong at the process layer, and they compound:
- ~3 weeks of work sits in one commit called
tmp(3,903 insertions / 1,029 deletions across 20 files). No bisect, no per-feature revert, no "when did seed X stop matching". - The design record isn't in version control.
ARCHITECTURE.md,fable-idea.md,REVIEW_FINDINGS.md,CLAUDE.mdare all excluded by.gitignore. OnlyCODEMAP.mdsurvives. A stack of "CODE-COMPLETE, PENDING BUILD"...CORRECTED 2026-07-26 (Jahni): everything has been built and verified working. The documents are stale, not the code —fable-idea.mdandARCHITECTURE.mdstill carry "PENDING BUILD" markers dated 07-04/-06/-08 that were resolved and never ticked. That's a doc-hygiene item, not a project-state risk. The uncommitted tree is deliberate: the current 3D work is experimental (see §7).
For a codebase whose central promise is determinism, having no per-change history is the sharpest internal contradiction in the project. That's the headline.
The code findings below are secondary to that, but there are real ones — including one latent bug that will silently flatten your terrain the day you pass a large seed.
1. Correctness & threading
C1 — Large seeds destroy the noise field ⚠️ highest-severity latent bug
VoxelGenerator.cpp uses const float SeedF = (float)Seed; and then adds SeedF * k (k up to
97.7) directly into Perlin coordinates — ~40 call sites, e.g.:
// VoxelGenerator.cpp:1390 (Scallop)
CellularNoise3D(FVector(WorldX * SF + SeedF * 83.1f, ..., EffectiveZ * SF + SeedF * 97.7f));
Float has a 24-bit mantissa. At magnitude V the ULP is V · 2⁻²³:
Seed |
SeedF · 97.7 |
ULP there | Effect on a coordinate stepping by ~0.02/voxel |
|---|---|---|---|
| 1,000 | 9.8e4 | 0.012 | fine |
| 100,000 | 9.8e6 | 1.2 | noise lattice-snaps; detail gone |
| 10,000,000 | 9.8e8 | 117 | the coordinate term is completely absorbed → that noise field becomes constant |
ChangeSeed(int32) is BlueprintCallable. The moment game code calls it with anything like
FMath::Rand() (up to 2³¹), whole noise terms collapse to a constant and terrain goes flat or
degenerate. It works today only because your seeds are small.
fable-idea.md flagged this exact issue in T2.a ("seed offsets like SeedF * 7.3f can reach 1e8+
where float precision is ~64 units — hash the seed into a bounded offset range (e.g. [0, 16k])") and it
was never done.
Fix (one line, one world re-tune):
const float SeedF = (float)(VoxelHash::Mix((uint32)Seed) & 0x3FFF); // bounded [0, 16383]
Applied consistently at all SeedF definitions (:682, :1756, :2066, :2208, :2275, :2687).
Verify: set Seed = 1234567 in the data asset and look at the terrain. If it's flat, this is live.
⛔ CORRECTION 2026-07-27 — the fix above is WRONG. Do not apply it.
It bounds SeedF but leaves the multiplier in place, and the multiplier is where the magnitude
comes from. SeedF · 97.7 with SeedF ≤ 16383 still reaches 1.6e6, where the float ULP is:
| max coord term | ULP there | vs the ~0.02/voxel coordinate step | |
|---|---|---|---|
Seed = 1000 today |
9.8e4 | 0.012 | 0.6× — fine |
| audit's proposed fix, worst case | 1.6e6 | 0.19 | 9.5× — still lattice-snaps |
So the "fix" would have made the bug less catastrophic while leaving it live, and — worse — closed the ticket. It would have cost a build and a world re-roll to discover that mid-range seeds still degrade. (Found by doing the arithmetic before applying it, not after.)
The actual fix: bound the OFFSET, not the seed — i.e. delete the multipliers.
The · 7.3f … · 97.7f multipliers exist only to decorrelate the ~40 noise call sites from each
other. That is a hashing job, and hashing does it better: give each site its own offset drawn from
the full seed entropy, already in final units, with no multiplier to re-inflate it.
// VoxelHash — one definition, so the bound cannot drift per site.
// Site-salted, final units, bounded: ULP at 16384 is 0.002 = 10% of a voxel step.
FORCEINLINE float SeedOffset(int32 Seed, uint32 Site)
{
return (float)(Mix((uint32)Seed ^ (Site * 2654435761u)) & 0x3FFF);
}
WorldX * SF + SeedF * 83.1f → WorldX * SF + VoxelHash::SeedOffset(Seed, kSiteScallopX).
Why this is strictly better, not just smaller: bounding SeedF alone would squeeze all seeds
through 16384 distinct noise offsets shared across every site, so two colliding seeds would give
identical noise everywhere. Per-site salting means two seeds must collide at all ~40 sites to
produce the same world — which is never.
Scope: ~40 call sites, mechanical, plus the same bug now inherited by the operator stack
(FSlabVoidSource::SeedF, VoxelDensityOpStack.cpp). Must land in both paths in one change, or
the equivalence tests will (correctly) scream. Cost of delay: every archetype ported copies it
again.
⚠️ REOPENED AND RE-CLOSED 2026-07-28 — the sweep missed one site, and the search pattern is why
§C1 was reported fixed on 2026-07-27 (85 sites, "0 left behind"), and
VoxelForge.Determinism.LargeSeedSurvives went green on seeds up to 2e9. One site had survived,
found by reading GetFloatingIslandDensity line by line to port it:
// VoxelGenerator.cpp — the floating-island domain warp
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, ...));
Why the sweep missed it: the sweep matched the SeedF * K spelling. This site spells the same
thing (float)S * K, where S is the archetype's salted seed. A textual sweep finds a spelling,
not a bug — and the green property test could not compensate, because LargeSeedSurvives asserts
that the heightfield still varies, and this site perturbs an island outline. Neither the
comparison oracle nor the property oracle covered it; a human read did.
Impact, before the fix: at Seed = 2e9 the term reaches ~1.4e6, where the float ULP is 0.125
against a per-voxel step of 0.04 — the warp flattens and every island silhouette snaps back to a
perfect circle. Cosmetic rather than catastrophic (the C1 failure mode for a heightfield is a flat
world), which is exactly why nothing screamed.
Fixed in both paths in one pass (VoxelHash::SeedOffset(S, 0.0007f) in GetFloatingIslandDensity
and in FIslandBlobSource), so FloatingIslandEquivalence stays a valid oracle.
One sharp edge recorded: SeedOffset quantises the site key by ×100 + 0.5, so 0.0007f maps to
site 0. That is unique today — every other key in the plugin is ≥ 0.19 — but the next sub-0.005
key will collide silently. Two sites sharing an offset is a correlation, not a collapse; still, it is
a footgun in a helper whose whole job is decorrelation.
Lesson, and it is the session's third instance of the same one: verify the premise before
reasoning from it. "C1 is closed" was load-bearing for two days and was 1 site short. grep over a
spelling is evidence about the spelling.
C2 — Per-chunk parameter caches have no layout key (stale after live-edit) ⚠️ real, reproducible
VoxelGenerator.cpp:503-524:
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local ECaveGeneratorType CP_GenType;
thread_local FStrateGenerationParams CP_Tunnel; // + CP_Slab, CP_Maze, CP_Surface, CP_Vert,
// CP_Float, CP_Dist, CP_BiomeCtx, ...
if (ChunkCoord != CP_Chunk) { /* refetch everything */ }
The key is ChunkCoord alone — no seed, no layout version. After RebuildStrates() or an
OnObjectModifiedInEditor live edit, StrateManager::Initialize rebuilds the layout and bumps
PassagesVersion, but a pooled worker thread whose CP_Chunk still equals the chunk it's now asked to
regenerate skips the refetch and generates with the old params. Since RegenerateAllChunks reloads
the same tile coords, often on the same workers, this is likely rather than exotic.
Same pattern, same gap:
OC_Chunk— theGetSurfaceHeightAtoracle cache (:2420)BM_Chunk—GetBiomeMaterialAt(:3045)FChunkBiomeCache::Contains— keyed on(box, ChunkZ, Seed)but not the biome context, so editing a strate'sBiomes[]leaves stale cells
Note the asymmetry: the SDF cache (CachedSeed) and the surface-column cache (GSurfColCache, keyed on
Seed) are seed-guarded, and the strate-index memo is version-guarded (SI_Version). So
ChangeSeed mostly survives; live-edit is where this bites. Symptom: "I tweaked the strate asset,
regenerated, and one patch kept the old shape."
✅ CONFIRMED BY READING, 2026-07-28 — the SDF cache DOES serve stale rooms within a strate
Raised as a suspicion while porting TunnelNetwork, then checked rather than believed. It holds, on default settings, with no live edit and no unusual asset configuration.
The chain, each link read in the code rather than inferred:
GetGenerationParamsreally does vary within one strate. Its Gradient branch computesAlpha = 1 − DistFromBottom / EffectiveBlendwhereDistFromBottom = ChunkCoord.Z − Slot.BottomChunkZ, then returnsFStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha).Alphais a function of chunk Z inside the slot, so two chunk Zs in the same slot get genuinely different params. The top boundary mirrors it. (VoxelStrateManager.cpp,GetGenerationParams, thecase EVoxelStrateTransition::Gradient:arms — both of them.)- It varies in exactly the fields that place rooms.
Lerpexpands theVF_STRATE_PARAM_FIELDSX-macro, which listsRoomSpacing,RoomDensity,MinRoomRadius,MaxRoomRadius,RoomHeightRatio,RoomShapeVariety,SDFBlendRadius,CaveWarpStrength, the tunnel fields — every inputBuildChunkCachereads. - And it is the DEFAULT.
UVoxelStrateDefinition::TransitionType = GradientandTransitionBlendChunks = 2. So at each end of every strate there are two chunk-Z layers with distinct params (Alpha1.0 and 0.5), out of the box. StrateIndexcannot save it. The memo resolves the slot index from the band centre, so it is identical for every chunk Z within the slot. The cache key is(XY box, StrateIndex, Seed)and the XY box does not change when the worker moves down a column. No rebuild happens.
⇒ A worker that generates (X, Y, Z1) and then (X, Y, Z2) in the same strate and the same XY box
evaluates the second chunk against the room list baked from the first chunk's params.
Two consequences, and the second is the serious one:
- a visible seam / wrong room sizes in the transition band, and
- the result depends on which chunk that worker happened to build first. That is a window-
invariance break (
ARCHITECTURE §8.4) rather than a cosmetic bug, and in multiplayer it means two peers can generate different geometry for the same chunk from the same seed — a directOPSTACK-PLAN §2.6.1violation on the original path.
Why nothing caught it: the test fixture sets TransitionType = Hard on every strate on purpose
(so "which archetype owns this chunk" stays unambiguous), which switches the blend off entirely.
The one configuration the tests never build is the default one.
✅ FIXED 2026-07-28 (the SDF-cache half) — pending build
The params now travel into the key, and the shape of the fix is worth recording because the obvious version of it was the wrong one.
GetDensityWithParams takes two new required arguments, ParamsFingerprint and LayoutVersion,
and both go into the bNeedRebuild test next to the existing (box, strate, seed).
- Required, not defaulted. A caller that forgets must fail to compile rather than silently
inherit the hole — the same discipline that put
LayoutVersioninsideFVoxelOpContext. - The CRC is computed once per chunk, not per voxel.
FCrc::MemCrc32over ~300 bytes on the hottest path in the plugin would have been a real regression; instead it is taken where the params memo already lives (CP_TunnelFP, refreshed in the same block that refetchesCP_Tunnel), so the per-voxel cost is two integer compares. - Why not "add chunk Z to the key" (the alternative this section used to offer): it is not
actually cheaper and it is not sufficient.
InterleavedmakesAlphadepend on chunk XY too, and chunk XY is not pinned by the existing key — the box deliberately outlives the chunk so thatWorldX ± 1gradient probes don't thrash it (ARCHITECTURE §8.10). Pinning chunk XY to fix the params would have destroyed that invariant. The fingerprint fixes the cause and leaves the box reuse intact: what forces a rebuild now is a real params change, once per chunk in a transition band, which is the number of rebuilds this cache should always have done.
The three test call sites pass VF_FP(P) so the oracle no longer shares the defect under test —
see the rewritten note at check 3 of VoxelForgeOpStackTunnelTest.cpp.
Still open in this section: the OC_Chunk / BM_Chunk / FChunkBiomeCache sites listed above.
Those are the live-edit staleness half, not the determinism half, and they are untouched.
The operator-stack port does not inherit this: FRoomGraphSource folds a FCrc::MemCrc32
fingerprint of the params (plus LayoutVersion) into its key, so differing params force a rebuild.
VoxelForge.OpStack.TunnelNetworkSpineEquivalence check 3 exercises exactly that — and deliberately
does not compare against the original there, because the original would fail it.
Fix: the getter already exists and is already used elsewhere —
const uint32 LV = StrateManager->GetLayoutVersion();
if (ChunkCoord != CP_Chunk || LV != CP_Version) { CP_Version = LV; /* refetch */ }
Three sites. Cheap, and it closes a whole class of "why didn't my edit apply".
C3 — FMath::PerlinNoise2D survived the SIMD migration, on the per-voxel hot path
VoxelCaveMorphology.cpp:799-800, inside EvaluateSDFCached's per-room, per-voxel loop:
float N = FMath::PerlinNoise2D(FVector2D(Pos.X * RF + SF, Pos.Y * RF + SF * 1.7f)) * 0.65f
+ FMath::PerlinNoise2D(FVector2D(Pos.X * RF * 2.3f + SF * 3.1f, ...)) * 0.35f;
Two double-precision, permutation-table UE Perlin calls per voxel per in-range room. The entire T2.a
pass existed to remove exactly this from the density path, and ARCHITECTURE §8.10 states outright:
"Don't reintroduce FMath::PerlinNoise3D on the density path." The 2D sibling slipped through the net.
Gated by FloorReliefStrength > 0 (default 0), so it's dormant today — but the moment you author a
room floor with relief (the tooltip recommends 5-10 for "clear hills and basins — interesting to walk
across"), every TunnelNetwork voxel near a room pays up to ~6 double-precision Perlin evals. That is
plausibly larger than everything T2.a saved.
Fix: VoxelNoise::Perlin3D(x, y, 0.0f) (or add a VoxelNoise::Perlin2D). One-time visual re-tune of
the floor relief; nothing else moves.
C4 — Streaming stops if the pawn is at exactly the origin, or before a pawn exists
VoxelWorld.cpp:453:
FVector PlayerLastPos = GetPlayerPosition(); // returns ZeroVector when no PC/pawn
if ((PlayerLastPos != FVector::ZeroVector)) { UpdateChunksAroundPosition(...); ... }
Two problems with using a value as a validity sentinel:
- (0,0,0) is a real, designed location — it's the (0,0) descent spine, the landing column in every strate. A pawn resting exactly there freezes chunk streaming, atmosphere, decorations and the density volume for that frame.
- No pawn ⇒ permanent silent stall. Early
BeginPlay, a cinematic without a possessed pawn, or a future headless/dedicated path (§9 explicitly plans for this) gets a world that never generates and logs nothing.
Fix: bool bHasPlayer out-param on GetPlayerPosition, or return TOptional<FVector>.
C5 — Shutdown spin-waits time out and then destroy objects workers are still reading
AVoxelWorld::EndPlay (:319-330) waits 3 s on ActiveTaskCount, logs a warning, then falls through
to teardown while a worker may still be inside GenerateTileResult holding Generator/Mesher/
StrateManager. UVoxelContentManager::NotifyShutdown does the same (3 s, then break), and its task
lambda captures raw this (a UObject) — so after the timeout, bShuttingDown.load() and
DecoResults.Enqueue() touch freed memory.
UVoxelDensityVolume gets this right (FillThread->Kill(true) — unbounded, blocks until Run()
returns). The other two should either be unbounded too, or hold a shared-ownership handle the workers
keep alive.
Low probability. High confusion cost when it does fire — it presents as a random PIE-exit crash.
C6 — The diff layer is an unbounded append-only list of brush strokes
UVoxelDiffLayer stores each FVoxelModification in every chunk its AABB overlaps, and
EvaluateMods iterates all of a chunk's mods per voxel:
for (const FVoxelModification& Mod : Mods) { /* sphere/box/capsule falloff */ }
MaxModificationsdefaults to 0 = unlimited.- No compaction, no rasterization to a sparse voxel grid, no spatial subdivision within a chunk.
- No serialization (
fable-ideaF4 save/load is unbuilt), so the entire player footprint dies with the session.
Cost of re-meshing a chunk grows linearly in the number of strokes ever made in it, forever. For a game whose stated core verb is digging, this is the most significant architectural gap in the plugin. It's invisible today because nobody has dug 500 times in one chunk.
The threading is correct (FRWLock + the version-stamped snapshot cache is a genuinely nice design —
~27 lock ops per tile instead of ~86k). It's the data structure that won't scale, not the concurrency.
When it matters: the moment mining becomes a real loop. The natural answer is a per-chunk sparse
uint8/int8 delta grid that strokes rasterize into (O(1) per voxel, bounded memory, trivially
serializable, and it's the same representation MP §9.5's "compacted diff snapshot" wants).
C7 — The density volume runs by default for a feature that's switched off
bEnableDensityVolume = true (VoxelSettings.h:268). What that buys, per frame while streaming:
- a dedicated OS thread (
FVoxelDensityFillRunnable), - a
CHUNK_SIZE³quantize + 32 KB queue payload per capture-eligible level-0 tile, - a
TMap<FIntVector, TArray<uint8>>capture cache, - and the big one —
UploadDirtyTextures()copies the wholeRes³array and issues a fullRHIUpdateTexture3Dper dirty level.BlitCaptureToWindowsetsbGPUDirty = trueon every tile ingest, so level 0 is dirty essentially every frame a tile lands.
At the default Res = 128 that's 2 MB CPU copy + 2 MB GPU upload per level per frame during
streaming; at 192 it's ~7 MB. The code itself flags it: "Sub-box upload (with toroidal wrap-splitting)
is a later optimisation."
CONFIRMED 2026-07-26 (Jahni): the lighting system is deprecated for now. So this is not speculative — the volume, the fill thread, the capture path and the per-frame texture upload are all running to feed a consumer that no longer exists.
Action: set bEnableDensityVolume = false. Straight cost deletion, no behavioural change. If the
volume is revived later, the sub-box upload (with toroidal wrap-splitting) must land first — a full
Res³ re-upload per frame was never meant to be the steady state.
Also worth deleting while it's cold rather than leaving it half-live: UpdateTerrainMaterialParams
still recomputes and pushes TVP0..TVP9 onto every terrain MID each Tick, and ApplyMeshToTile still
routes every material through GetOrCreateTerrainMID when the volume is enabled. With bEnable... off,
both self-disable — but the code paths stay as a trap for whoever reads them next.
C8 — Documented invariant that isn't checked: OriginRoomRadius vs the COLLECT margin
BuildChunkCache guarantees window-invariance by collecting rooms within
CollectMargin = 2·MaxTunnelLength + MaxInfluence. The origin room is special-cased: it's also added when
RoomReachesSearchBox(...) succeeds even if (0,0) is outside the collect box. In that case
NearestNeighbor[OriginIdx] and the OriginDowngraded ranking are computed over a different candidate
set than a chunk near (0,0) computes → different tunnel decisions → a discontinuity ring at roughly
CollectMargin from origin.
Cannot fire at defaults (OriginRoomRadius = 20, MaxTunnelLength = 200 ⇒ margin ≈ 434). It becomes
reachable only with a very large origin room and short tunnels. Flagging it because the tooltip invites
"30+ → massive starting cavern" and there's nothing stopping someone typing 500.
Fix: an ensureMsgf(OriginRoomRadius < CollectMargin) in BuildChunkCache, plus a line in
ARCHITECTURE §8.4.
C9 — The build uses /fp:fast, and the MP plan assumes bit-reproducible terrain 🔴 NOW THE TOP OPEN RISK — promoted 2026-07-27
Why this outranks everything else in this document now. Jahni stated the actual acceptance bar: "only having it 99.99% at worst reproducible if two people share the same seed, since everyone rebuilds it on multiplayer." That is precisely and only the guarantee this entry says
/fp:fastweakens. Fidelity to the old world was dropped as a requirement (§C10); peer agreement replaced it as the single bar. This entry is now load-bearing.The concrete case to decide: a Linux dedicated server generating collision geometry and nav against Windows clients compiles the same density code under opposite float models (
ClangToolChain→ precise,VCToolChain→/fp:fast). That is not a theoretical divergence. Same-platform peers on the same build are fine.✅ HALF FIXED 2026-07-27 — and the other half is a different mechanism
FPSemantics = Preciseis set (IWYU debt cleared to allow it). MSVC now gets/fp:precise, Clang gets-ffp-contract=off; both are IEEE-754 with no contraction, so the compiler half is closed by construction. It also dissolved§C10entirely.⚠️ THE LIBRARY HALF IS STILL OPEN, and I nearly missed it by assuming. After setting Precise I reasoned the cross-platform risk was gone. It is not:
sinf/cosfare not specified by IEEE-754. MSVC's CRT and glibc's libm may legitimately return different results (typically ≤ 1 ULP). AndFMath::Sin/FMath::Cosare used throughout the density path — layer lines (VoxelGenerator.cpp:2275,VoxelHeightOpStack.cpp:232), ribs (:1357), room placement (:1520), rotations (:362,:408,:1663). So a Windows host and a Linux client can still disagree, just by ~1 ULP instead of by a whole reassociation.Measured, not assumed:
CrossPlatformDigestnow reports a profile — samples within 1e-4 / 1e-5 / 1e-6 of the isosurface. Only the tight band matters (a libm delta is ~1e-6 absolute on densities of magnitude ~10), and the first run showed 2 / 115 000 within 1e-4 — i.e. the wide band, which over-states the risk by ~100×. The tight-band number is the one to watch.If it must be zero: a deterministic in-house
sin/cosin the density path (a polynomial with defined rounding), which costs one more world re-tune. Not another build flag — no compiler setting can make two different libm implementations agree.
Found while chasing a 1-ULP difference between GetMazeDensity and its operator-stack port. The
difference survived a full bisect down to code that is character-for-character identical, which
pointed at the toolchain rather than the port. It is:
// UnrealBuildTool/Platform/Windows/VCToolChain.cs
case FPSemanticsMode.Default: // Default is imprecise FP semantics.
case FPSemanticsMode.Imprecise: Arguments.Add("/fp:fast"); break;
and UBT's own doc for that mode: "FP math isn't IEEE-754 compliant: the compiler is allowed to
transform math expressions in ways that might result in differently rounded results from what
IEEE-754 requires." The plugin sets no FPSemantics override, so it gets the default.
⚠️ And the default is not the same default everywhere. ClangToolChain.cs — used by Linux, Mac,
and Windows-with-Clang:
case FPSemanticsMode.Default: // Default to precise FP semantics.
case FPSemanticsMode.Precise:
Arguments.Add("-ffp-contract=off");
VCToolChain additionally forces Precise when the Windows compiler is Clang. So the same
FPSemanticsMode.Default resolves to opposite float models per toolchain, and Windows/MSVC is the
only imprecise configuration in the engine's defaults. Two builds of identical source are therefore
not merely permitted to diverge — they are compiled under different rules.
Two consequences, one benign and one not:
Benign — refactors are not bit-identical in practice. See §C10 for the measured detail: the
Maze port reproduces the original's SDF bit for bit but its final density differs by 1-2 ULP on
~2% of samples, with zero isosurface crossings. The acceptance criterion is encoded in
VoxelForge.OpStack.MazeEquivalence: hard-fail on any isosurface crossing, tolerate ULP-scale
deltas, warn on anything larger.
Not benign — ARCHITECTURE §9.1's multiplayer model rests on this. The plan is "replicate the
seed + layout + diff, never the geometry; every peer regenerates identically." That guarantee is
only as strong as the floating-point reproducibility of the density path, and under /fp:fast it
holds only between bit-identical binaries. Same build, same platform: fine — DensityPurity
proves the field is pure across threads and query order. But a different compiler version, a
different optimisation level (Debug vs Shipping), or a different platform's toolchain may produce a
subtly different world from the same seed.
The docs already reject GPU density partly because "cross-GPU float determinism is fatal for replicate-the-seed". The same argument applies to the CPU across build variants, and had not been stated.
How much it matters depends on a design question that is Jahni's, not the audit's:
- If listen-server only, one binary per platform, and clients never regenerate authoritative geometry — this is a non-issue. Ship as is.
- If a dedicated server on Linux and Windows clients both regenerate terrain and compare — this is a real desync source, and it will present as rare, unreproducible, geometry-only divergence: approximately the worst bug class to diagnose.
Calibration, so this isn't over-feared. In 20,000 Maze samples, zero crossed the isosurface under a 1-ULP perturbation. Divergence would therefore be occasional single-voxel surface differences, not visibly different terrain — sub-voxel, near-certainly imperceptible for collision-vs-visual mismatch. The case that would genuinely bite is topological: a cave pinch-point that connects on one build and not the other. Rare — but "rare and unreproducible" is the expensive kind of rare.
Even Precise everywhere would not buy guaranteed cross-platform bit-identity. FMath::Sqrt is
IEEE-exact and VoxelNoise's hash-gradient core is integer + basic arithmetic, so those are safe.
But FMath::Sin/Cos (disturbance bridge/ridge angles) route to platform libm, which is not
bit-standardised across OSes. Aligning the FP model closes the large gap, not every gap.
The knob, if it turns out to matter: ModuleRules.FPSemantics = FPSemanticsMode.Precise in
VoxelForge.Build.cs, module-scoped. Note it would align Windows with every other platform's
default rather than being a one-sided cost. Still do not do this speculatively — the density
path is the plugin's hot loop, precise semantics block exactly the vectorisation and contraction
T2.a's SIMD noise work was chasing, and the cost is unmeasured. Decision needs a profile in hand and
a confirmed cross-platform requirement.
Verification, when it becomes relevant: the cross-build claim above is inferred, not measured —
no Linux build has been made. The cheap decisive test is to compile the plugin once with
FPSemantics = Precise on Windows and re-run VoxelForge.OpStack.MazeEquivalence: if the residual
454-sample difference vanishes, the FP model is confirmed as the sole cause and the cross-toolchain
risk is real. That is one build, and it settles it.
C10 — The op-stack ULP residue: PARKED CLOSED PERMANENTLY, 2026-07-27
✅ SOLVED, 2026-07-27 — and the cause was
/fp:fast, exactly as hypothesis 3 said.Setting
FPSemantics = Precise(see§C9) made bothMazeEquivalenceandSlabEquivalencereport BIT-IDENTICAL. The residue is gone — not tolerated, gone.So hypothesis 3 had the right mechanism and every isolation experiment built on it was doomed. Under
/fp:fastthe compiler may reassociate and contract based on surrounding context, with no single isolable axis — which is precisely why five carefully-designed one-variable tests (FVector round-trip, transcription, cross-TU, inlining, const-vs-runtime) all came back negative while the difference stayed. There was no variable to find. Removing the permission removed the difference.The lesson, and it is not the one I expected
The answer arrived for free, from work done for an unrelated reason. Nobody solved C10; C9 got fixed because Jahni wants Linux/Windows cross-play, and C10 fell out of it. Six more builds spent hunting would have found nothing, because the thing that resolved it was a build setting nobody was looking at while I was busy bisecting source code.
The decision to stop was therefore right on its own terms and right in hindsight — the information was not obtainable by continuing along that path at any price. Park a question whose every consequence is measured and benign; it may well be answered later by something else.
What this changes going forward: ports can now aim for and achieve bit-identity, so the equivalence tests are much sharper instruments than they were — any diff at all is now a real finding rather than noise to be graded. The ULP-grading machinery in both tests is kept, because it is what would tell us if this ever regresses.
Status: accepted and closed by decision (Jahni), not by explanation. Do not reopen this without reading the whole entry — five hypotheses have already been measured and refuted, and re-deriving them costs a build each.
The observation. VoxelForge.OpStack.MazeEquivalence: the ported Maze operator stack differs
from GetMazeDensity on ~2% of samples (454/20000) by 1-2 ULP. Deterministic — same samples, same
delta, same coordinates on every run.
What is PROVEN by measurement, and is the reason this is benign:
- Zero isosurface crossings out of 20000. Not one triangle would move. The two are geometrically identical.
- The SDF is reproduced BIT FOR BIT — 126/126 of the mismatches, and
stack SDF != verbatim SDFcounted unconditionally came back 0. So the lattice sweep, the edge hashes, the{-1,0}³node set andVoxelSDF::Capsuleare all exactly correct. The port has no logic error in the part that shapes the world. - The entire difference is born in the final SDF→density conversion, amplified because
Blend - Sdfcancels catastrophically at the edge of the blend shell.
What was tested and REFUTED (each cost a build; listed so nobody repeats them):
| # | Hypothesis | Refuted by |
|---|---|---|
| 1 | FVector float→double→float round-trip in the noise coords |
identical result after the change |
| 2 | A transcription slip in the roughness window / carve blend / octaves | a four-stage bisect: residue survives into corridors + carve ONLY |
| 3 | /fp:fast reassociating across translation units |
three-way test: generator TU == test TU exactly (0 differ) |
| 4 | Different inlining context (virtual call vs straight-line) | FORCEINLINE vs FORCENOINLINE in one TU: 0 differ |
| 5 | Compile-time-constant Blend vs runtime member |
const and runtime forms bit-identical to each other; both miss the verbatim on the same 126 |
Where that leaves it. Two carve implementations, character-identical, in the same translation
unit, fed a provably identical input, produce outputs differing by 1 ULP on 126 of 5000. For
deterministic code that is only possible if they compile to different instruction sequences — which
is exactly what /fp:fast permits, based on surrounding context, with no single isolable axis. So
hypothesis 3 was right about the mechanism and wrong about every clean variable proposed for it.
The one experiment that would settle it is building this module with
FPSemantics = FPSemanticsMode.Precise. It is blocked: doing so costs VoxelForge the engine's
shared PCH and exposes ~30 missing includes across seven files (see the note in VoxelForge.Build.cs).
Clearing that IWYU debt is worth doing on its own terms; it is not worth doing to chase 1 ULP.
The operational rule that DOES matter, and is the real takeaway:
never run the archetype switch and the operator stack in the same world, and never compare their
outputs for equality. A half-migrated strate would produce a seam. This is not a client-desync
risk — within one binary the field is proven bit-pure across threads and query order
(VoxelForge.Determinism.DensityPurity) and every peer runs the same path. The genuine
cross-platform concern is §C9, which stands independently.
Threading — what's right, for the record
Worth stating plainly, because it's the part that's easy to get wrong and this doesn't:
ProcessQueueis correctlyMpsc, with the failure mode documented at the declaration.- The
FTaskGuardRAII decrement covers every early-exit path inLoadTile. - Epoch carrying is consistent across all four async paths (tile gen, deco march, volume fill, band re-gen).
DiffLayer'sFRWLock+ lock-freebHasAnyModsfast-reject + per-(chunk,version) snapshot is a genuinely good piece of engineering, and the comment records the AV it fixed.BuildCellSpawnsis correctlystatic— no implicitthison the worker.VoxelGenLOD::OctaveBiasisTGuardValue-scoped per tile, so deco snapping and volume fills can't inherit a stale bias.
2. Performance & algorithm choice
Is Marching Cubes still the right call in 2026? — Yes, for this game.
- Transvoxel (transvoxel.org) remains the canonical answer to LOD seams,
and
fable-ideaalready schedules it correctly ("whenever LOD cracks become the loudest remaining flaw"). Your skirts are a legitimate substitute: they cost extra verts and some overdraw on boundary contour edges, but they're ~80 lines instead of a transition-cell rewrite, and they work for any level delta rather than only ±1. - Surface Nets / Dual Contouring would cut triangle count meaningfully and are faster per chunk, but naive Surface Nets can't hold a sharp 90° feature and DC brings QEF solving + self-intersection handling. You'd trade a rewrite for a benefit you aren't currently bottlenecked on. Correct to skip.
- GPU meshing —
fable-idearejects it for readback latency, CPU-side collision, and cross-GPU float determinism. All three are still true, and the last one is fatal for a game whose MP model is "replicate the seed, regenerate identically on every peer". Right call, right reasons.
The verdict is: your mesher isn't the problem. T1.a–f and T2.a–e genuinely took per-tile generation into diminishing returns. The remaining cost lives elsewhere (C3, C7) and in the scaling cliffs below.
Scaling cliffs nobody has hit yet
BuildChunkCache is O(N²) in rooms, and N grows quadratically with the tunnel/spacing ratio.
PickNeighbor is O(N) per room and the connection loop is O(N²), over the COLLECT box which spans
2·MaxTunnelLength + MaxInfluence. So room count N ≈ ((2L + I)/S)² and the graph build is
O((L/S)⁴):
MaxTunnelLength |
RoomSpacing |
≈ rooms in COLLECT | pair iterations |
|---|---|---|---|
| 200 (default) | 80 (default) | ~40 | ~800 |
| 300 | 60 | ~140 | ~10,000 |
| 400 | 40 | ~500 | ~125,000 |
Per cache rebuild, per worker. Amortised over 32,768 voxels it's survivable at defaults; at row 3 it
isn't. Worth a comment on MaxTunnelLength and a soft clamp.
AnyPassageNearBox walks every passage per tile with no spatial index. Fine at 10-20 passages; grows
linearly with TotalStrates × Connections.
FindSlotIndexForChunkZ is a linear scan called extremely often — per lattice-Z in ClassifyTile,
per archetype param getter, on every GetDensityAt chunk change. The comment acknowledges it ("with
~10-20 strates this is fine"). It is. But it's the classic death-by-a-thousand-cuts profile entry.
GetDominantBiomeAt is fully uncached: it builds an FBiomeContext (heap-allocating a TArray) and
runs the warped Voronoi + up to 2 ClassifyBiomeAtSite (4 fBM evals). AtmosphereManager::UpdateForPlayer
calls it every Tick, unconditionally. Small, but it's a per-frame game-thread allocation for a value
that changes maybe once a minute.
The external development you should know about: RealtimeMeshComponent 6.0
You're on RMC 5.3.2. RMC 6.0.0 shipped June 2026 with:
- Runtime Nanite — "real cluster groups, full LOD DAG, per-page streaming. No cooked bake, no
UStaticMeshround-trip." - Lumen card generation + distance field support (Pro tier, $49 one-time).
- UE 5.2–5.7 support (you're on 5.7).
This matters more to VoxelForge than to most plugins, because it potentially subsumes three separate hand-built systems:
| Your system | What RMC 6.0 offers instead |
|---|---|
Clipmap levels + CoarseTileCells + LODOctaveDrop + skirts |
Nanite's own LOD DAG, no seams by construction |
F18 sheet ring (GenerateSheetMesh, XY hole, band re-queue) |
far-field falls out of Nanite streaming |
| Density volume + raymarched mini-sun shadows (currently gated off) | runtime Lumen cards + distance fields |
I am not saying "rewrite on Nanite" — runtime Nanite on procedurally-remeshed geometry is new, and digging (constant re-meshing) is the adversarial case for cluster building. But this is the single biggest change in the landscape since your architecture was designed, and it argues strongly for a timeboxed spike before building any more LOD machinery. Measure: build time per tile, re-mesh cost on carve, and whether skirts/sheets become unnecessary.
(For reference on the alternative path: Voxel Plugin 2.0 now targets 5.6/5.7 and its VoxelCore module is open-source — worth knowing exists, not worth switching to. Your strate/archetype model is the whole game; theirs isn't.)
3. Architecture & maintainability
The structural risk: ~30 hand-rolled thread_local caches
The density field is a pure function by convention, and that convention is enforced by roughly thirty hand-written memo caches, each with a hand-written invalidation key:
GSurfColCache · CP_Chunk+9 param slots · SDFCache+CachedSMinX/Y+CachedStrate+CachedSeed ·
SI_ChunkZ/SI_Version · DiffSlots[64] · CH_* chasms · BR_* bridges · RG_* ridges · SC_* slab
columns · MZ_* maze edges · VS_* shafts · FI_* islands · OC_* oracle · BM_* biome material ·
TC_BiomeCache · Slots[2] · SL_* passage shortlist · the mesher's VertexMap/DensityGrid/
SurfColMemo/VertexClasses/GroundTris/CapTris/SheetCols/GroundIdx/CapIdx.
Every one is individually justified and individually documented. Collectively they mean the correctness of the entire world rests on ~30 key lists each enumerating every input, audited by hand. Two are provably incomplete (C2). This isn't a bug count — it's an unbounded bug surface that grows with every feature, and the failure mode is the worst kind: a seam or hole that appears only under a particular thread/edit interleaving.
The existing mitigation, ValidateDeterminism, is the right instinct but a narrow test: one axis (X
boundary), near the player, one alignment pair, on the game thread. It would not have caught C2
(worker-thread caches) or C8 (origin ring).
What would actually help, in rough order of value-per-hour:
- A
VF_CACHE_KEY(...)macro that bundles(coord, seed, layoutVersion)so a new cache can't forget the standard fields. - A debug-only "no-cache" mode (
#if VF_VALIDATE_PURITY) where every memo is bypassed, plus an automation test that samples N random points in both modes and asserts bit-equality. That converts all "bit-identical, I promise" claims — of which the docs make dozens — into a machine check. - Extend
ValidateDeterminismto run its samples across multiple worker threads in shuffled order.
No tests, no automation
Zero test files. UE ships an automation framework you're not using. Three tests would cover most of the risk surface:
- Density purity: sample 10k points, shuffle the query order, re-sample, assert bit-equality.
ClassifyTilesoundness: for random tiles, if the verdict isAllSolid/AllAir, brute-force the lattice and assert every sample agrees. A false verdict is a hole — this is the highest-consequence function in the plugin and it's validated only by reasoning.DiffLayerunder contention: N reader threads + a writer, assert no crash and monotonic version.
Function size (the open item that stays open)
REVIEW_FINDINGS.md has listed these splits as open through three passes:
| Function | Lines | Note |
|---|---|---|
GetDensityWithParams |
~1,080 | listed as "~600-1000 L" — it has grown since |
BuildChunkCache |
~640 | |
UpdateChunksAroundPosition |
~300 | + 5 nested lambdas, 3 of them capturing by reference |
BuildCellSpawns |
~350 | contains a 175-line nested lambda (PlaceAtCrossing) |
ClassifyTile |
~200 |
An item that survives three review passes isn't going to get done voluntarily. Either accept it
explicitly (write "we are keeping these monolithic, here's why" in REVIEW_FINDINGS) or bind it to the
next feature that touches each one. Leaving it as a permanent open checkbox just makes the checklist
lie.
Smaller structural notes
FStrateGenerationParamsis a 74-field god-struct, and blend correctness depends on remembering to add every new field toVF_STRATE_PARAM_FIELDS. The X-macro was the right fix; make drift a compile error by puttingstatic_assert(sizeof(FStrateGenerationParams) == N, "add the new field to VF_STRATE_PARAM_FIELDS")right next to it.- Chunk-quantised param blending.
GetGenerationParamscomputes the Gradient/Interleaved blendAlphafromChunkCoord, so within a transition band the density field has a step at every chunk boundary. MC samples corners from both sides, so it won't tear, but small ledges at chunk borders in Gradient zones are expected behaviour, not a mystery, if you ever see them. - Dead knobs still shipping:
ViewDistanceXY(onlyUp/Downare read),CeilingViewMultiplierandCeilingBandChunks(the wide-ceiling system was removed by the clipmap),DensityVolumeMaxTasks(deprecated in its own tooltip). Each is a lever a designer will pull and get nothing. EVoxelPassageTypevsEVoxelPassageStyle— two overlapping passage-shape enums, both live. Already open inREVIEW_FINDINGS; still open.- Leftover tutorial scaffolding in
VoxelWorld.h/.cpp:// CHUNK MANAGEMENT - YOU IMPLEMENT THESE,// This one is tricky with Unreal's API, so I'll give you more help:, and a 20-lineSTEPS: 1. Try to dequeue...block inside the now-finishedProcessPendingChunks. Cosmetic, but it's the first thing a reader hits. .upluginmetadata:"CreatedBy": "You","Description": "...built from scratch to learn".
The documentation, honestly
The prose is genuinely excellent — better than most commercial plugins. ARCHITECTURE §8.10 in
particular is a real asset: it records why each optimisation exists and what regressing it costs.
Two problems:
- The numbers have drifted.
CODEMAP.mdheader says "~8,300 lines of C++ across 25 files"; reality is ~16,400 across 30. §3.5's line anchors are badly stale (LoadChunk :445→ actually ~1336;ApplyModification :691→ ~1816). SinceCLAUDE.mdalready instructs "trust symbol names over line numbers", I'd delete the line column entirely — it's a maintenance tax that buys nothing and actively misleads. - The comment density is optimised for one kind of reader. Blocks like the F17 row in
ARCHITECTURE §8.1(a single table cell running ~40 lines, narrating v1 → v2 → v3 with rationale and quoted feedback) are superb context for an AI collaborator picking the file up cold, and a genuine liability for a human trying to find the code. You've made a real trade here; it's worth making it knowingly. AHISTORY.mdfor the "we tried X, it failed because Y" narrative would let the source comments shrink to what the code does now.
4. Process — what I'd fix first
P1 — Track the design docs. Today. ⚠️
.gitignore: * → !/Source/ !VoxelForge.uplugin !CODEMAP.md
git ls-files confirms ARCHITECTURE.md, fable-idea.md, REVIEW_FINDINGS.md, CLAUDE.md and this
audit are all untracked. fable-idea.md even says so in its own header — "the strict-whitelist
.gitignore will ignore this file. Add !fable-idea.md if you want it tracked" — and it never was.
These documents are the project's memory. Losing them loses more than losing the code, because the code can be re-read and the reasoning can't. Two lines:
!*.md
P2 — Stop batching three weeks into tmp
| Commit | Date | Content |
|---|---|---|
69fa73e tmp |
2026-07-26 | +3,903 / −1,029 across 20 files — F17, F18, F19 anchors, F20 ph.1+2, companions, landmark merge, strate cut, sheet ring |
cb61c8b potential perf regression |
2026-07-04 | |
6875614 Fix Decoration Placement |
2026-06-26 | |
e6cd852 Another pass |
2026-06-23 | |
db558d9 j |
2026-06-16 |
Seven commits in seven weeks, and the message quality (j, tmp, Another pass) means even the
existing history isn't navigable. You don't need discipline theatre — one commit per feature, with the
feature's name in the subject, would be enough to bisect "which change broke seed 42".
This matters disproportionately here because determinism is the product. When a world stops matching, "which commit changed the field" is the only question, and right now it's unanswerable.
P3 — Reconcile the "pending build" backlog before adding anything
fable-idea.md and ARCHITECTURE.md currently carry, unreconciled:
- F20 phase 1 — "CODE-COMPLETE, PENDING BUILD (2026-07-08)"
- F20 phase 2 — "CODE-COMPLETE, PENDING BUILD (2026-07-08)"
- §8.14 biome full-param redesign — "code-complete, pending build"
- §8.15 F6 vertex palette — "C++ code-complete, pending in-editor build + the master material graph"
REVIEW_FINDINGSperf pass 2 + batch 3 — "CODE-COMPLETE, pending build"
That's three weeks of unverified code stacked on unverified code, on a system where the failure modes (seams, holes, stale caches) are visual and only appear in-editor. The honest statement of project state is: unknown for everything after 2026-07-04.
Suggested order before any new feature:
- Build. Fix compile errors.
- Run
ValidateDeterminismat several locations (including far from origin). bEnableDensityVolume = false→ trace → compare (C7).- Set
Seed = 1234567→ look (C1). - Live-edit a strate asset, regenerate, look for patches that kept old params (C2).
- Commit, per feature, with real messages.
5. Priority list
Re-ordered 2026-07-26 around the live 3D work.
| # | Item | Cost | Why |
|---|---|---|---|
| 1 | §6.1 — ClassifyTile guard for 3D caves |
~30 lines | blocks the current feature; a false AllSolid is an invisible, collision-less cave |
| 2 | P1 — track the .mds (!*.md) |
2 min | irreplaceable if lost; the docs are the design record |
| 3 | C7 — bEnableDensityVolume = false |
1 min | confirmed dead weight — lighting is deprecated |
| 4 | §6.6 — branch the experimental 3D work | 1 min | restores a known-good world to diff against |
| 5 | C1 — bound SeedF |
1 line + re-tune | silent terrain collapse on large seeds |
| 6 | C2 — add GetLayoutVersion() to 3 cache keys |
~10 lines | closes "my edit didn't apply" |
| 7 | §6.2 — keep 3D caves placed, not fielded | a design call | decides whether T1.d keeps its 44 % |
| 8 | C4 — explicit no-player flag | ~5 lines | origin-standing / no-pawn stall |
| 9 | C5 — unbounded joins on shutdown | ~20 lines | random PIE-exit crashes |
| 10 | Automation tests (purity, ClassifyTile, DiffLayer) |
~a day | ClassifyTile is now the highest-consequence function in the plugin |
| 11 | P2 — per-feature commits | ongoing | determinism without history is a contradiction |
| 12 | C3 — PerlinNoise2D off the SDF hot path |
~5 lines | dormant, but violates a stated invariant |
| 13 | RMC 6.0 spike (runtime Nanite / Lumen cards) | timeboxed | may subsume clipmap LOD + sheets + density volume |
| 14 | C6 — diff-layer delta grid | a design pass | the scaling wall for the game's core verb |
| 15 | Tick the stale "PENDING BUILD" markers in the docs | 20 min | they currently misreport project state |
| 16 | Drop line numbers from CODEMAP §3; fix the header count | 30 min | the map misstates its own scale (8.3k → 16.4k) |
| 17 | Delete dead settings + tutorial scaffolding | 30 min | knobs that do nothing mislead |
6. Going 3D — what the current architecture will fight you on
Added 2026-07-26 after Jahni confirmed the live direction: 3D density features — overhangs, caves
inside mountains, volumetric generation inside SurfaceWorld. This is the section that matters most
right now; everything above is maintenance by comparison.
6.1 ClassifyTile will delete your caves. This is the one to get right first. ⚠️
T1.d proves a tile AllSolid/AllAir and skips GenerateMesh entirely. For SurfaceWorld the
proof is (VoxelGenerator.cpp, TestColumn):
if (Z >= TerrainZ && Z <= CeilSurf) { bCanSolid = false; } // air side
else { bCanAir = false; } // below terrain, or in the cap
So any point below TerrainZ is treated as provably solid. A tile entirely inside a mountain
returns AllSolid, emits no mesh, and never calls the density function. Carve a cave in there and:
- the cave's walls are never meshed — you get an unrendered void,
- there is no collision (level-0 collision is per-section, and there are no sections),
- and it is invisible until you enter it from a passage, at which point you fall through nothing.
The code comment states the rule exactly: "A false Mixed only costs CPU; the code must NEVER emit a
false AllSolid/AllAir (that's a hole)." This is the same failure that got T1.d v1 reverted on
2026-06-26 — a global analytic ceiling bound that wasn't conservative.
The existing guards are the template — each kills one hypothesis, cheaply, per tile:
| Guard | Kills | Mechanism |
|---|---|---|
AnyPassageNearBox |
AllSolid |
sphere-vs-AABB over the passage list |
| origin spine | AllSolid |
circle-vs-box in XY |
| chasm disturbances | AllSolid |
ChasmDensity > 0 on the strate |
| bridges / ridges | AllAir |
density > 0 on the strate |
HasAnyModInChunkRange |
both | one key walk of the diff map |
| F20 overhang | both, in a band | (TerrainZ, TerrainZ + OverhangMargin] ⇒ Mixed, upward only |
Every new 3D feature needs one of these before it ships. For hash-placed cave systems that means a
per-tile shortlist exactly like EvaluateModifierSDF's passage shortlist: collect the feature bounds
that reach this tile once, and if the list is non-empty, bCanSolid = false. fable-idea's own F20
phase-3 GOTCHA already says this for spikes/holes — it applies verbatim to caves.
Test that catches it: generate a mountain with caves, then stand outside and fly the camera through the mountain volume. Any cave that renders from inside a passage but shows nothing when approached through rock is a false
AllSolid.
6.2 The T1.d economics invert as caves get more volumetric
The 2026-07-05 trace justified T1.d with: 84 % of GenerateMesh calls produced empty tiles
(83,925 gens vs 13,227 meshes, ~500 s of 617 s worker CPU wasted), and shipping it cut worker CPU 44 %.
The bulk of those AllSolid tiles is deep rock below TerrainZ — precisely the volume "caves inside
mountains" occupies. Every tile a cave touches must now generate. So there's a direct, quantifiable
tension:
- Bounded caves (hash-placed systems with bounding volumes, like rooms/tunnels already are) wake only the tiles they actually intersect. The shortlist proves the rest of the mountain still solid. T1.d keeps most of its win.
- A global 3D noise field (
if (noise3D(x,y,z) > t) carve) is unprovable everywhere below terrain → every deep tile becomesMixed→ you hand back a large slice of that 44 %, plus you now mesh and cook collision for tiles that are 99 % solid rock.
Recommendation: make 3D caves placed, not fielded. Same discipline as FCachedRoom/FCachedTunnel —
they exist at hash-determined locations with known bounds. That keeps the classifier effective, makes the
shortlist guard trivial to write, and gives you authored control over cave density per biome. It's also
the only version that stays cheap at the render-distance ring.
6.3 Do not put Z-dependent data in FSurfaceColumn
FSurfaceColumnBox is keyed (XY box, StrateKey, Seed) with no ChunkZ, held as a 6-box LRU, and
deliberately shared down the entire vertical chunk stack — that's the whole T1.a win (the heightfield
was being recomputed once per altitude). ARCHITECTURE §8.10 states it: "ZERO Z dependence... Don't
re-introduce a ChunkZ key."
So the rule for every new 3D feature is:
- XY-pure data (a per-column gate, amplitude, direction, a cave-system ID) → cache on
FSurfaceColumn. - Z-dependent evaluation → do it per-voxel in
SurfaceDensityFromColumn, reading the cached column.
Your F20 phase-2 overhang is the correct template and worth copying deliberately: OverhangAmp (strength ×
slope-gate) and the unit uphill (DirX, DirY) are resolved once per column in ComputeSurfaceColumn;
the actual warped-terrain union is per-voxel. Anything that violates this silently corrupts every chunk in
the vertical stack, and ValidateDeterminism — which samples along an X boundary — would not catch it.
6.4 Window invariance: pick the cheap pattern where you can
Two existing precedents, and the difference is worth being deliberate about:
Maze— edge identity is a pure hash of(lower node, axis). Two adjacent chunks cannot disagree. No cache, no COLLECT region, no seam risk, zero invariant maintenance.BuildChunkCache— because tunnel existence depends on a connectivity decision over a neighbourhood, it needs the two-region COLLECT/STORE split (§8.4), and that invariant is delicate enough to have its own documentation section.
If your cave systems need connectivity (a cave network that must be traversable), you inherit the second
pattern and its COLLECT-margin discipline. If each cave is independently placed, use the first — a pure
hash(cell, seed) placement with per-voxel SDF evaluation and no cross-chunk decision at all.
Pick the Maze pattern unless connectivity is a gameplay requirement. The complexity delta is large and it's all seam-risk.
6.5 Smaller things that will bite
- Route new per-voxel volumetric noise through
VoxelGenLOD::Eff(N). That's the T2.b contract: per-voxel 3D noise gets the LOD octave drop, XY-field noise deliberately does not. New 3D code that callsFractalNoise3D(..., 3)directly silently opts out of far-tile savings. - LOD-gate the expensive 3D ops. Your own note on F20 phase 2 flags it: "KNOWN v1 LIMIT: applies at ALL
LODs (may alias far) — gate to fine tiles later." With one op that's a wart; with several it's a far-field
cost multiplier. A
Step > Nearly-out per volumetric op is a few lines and compounds. - F17 already handles cave roofs correctly — the surface class is semantic, not geometric: down-facing but
below
TerrainZ⇒ ground/rock, not sky-cap. This was designed for exactly your current case and needs nothing. Good call at the time. - The F18 sheet ring won't show caves — sheets are two heightfields, so carved features don't exist there. Already an accepted trade-off; just don't expect a cave mouth to be visible at render-distance.
SurfaceDensityFromColumnis now the hot path, not the SDF morphology. The overhang block already re-runsSampleSurfaceStructuralZper lip voxel. Each new volumetric op that re-samples structural height per voxel stacks on that. Budget it consciously — the per-column cache saved you ~33×, and per-voxel structural re-evals spend it back.
6.6 Since it's experimental and uncommitted
You said the tree is uncommitted because the 3D work is experimental. That's a reasonable instinct pointed at the wrong tool — experimental work is exactly what branches are for:
git switch -c 3d-density # experiment freely, commit noisily
git switch main # a known-good world is always one command away
The specific thing you lose right now: when a seed stops matching or a hole appears mid-experiment, there is no known-good state to diff against, and the 3D features are the highest-risk changes in the project for exactly that failure mode (§7.1). A branch costs nothing and gives you the bisect back.
7. What I'd tell someone joining this project
The generation core is strong and the perf work is honest and measured — the person who wrote this knows what they're doing and has the traces to prove it. The weakness is everything around verification: no history you can bisect, no tests, design docs outside version control, and a habit of stacking features on top of unbuilt features. Nothing in the code list above is hard to fix; the process items are the ones that decide whether the next three weeks are as productive as the last three.
Sources consulted for the external comparison: transvoxel.org · triaxis.games/realtime-mesh · TriAxis-Games/RealtimeMeshComponent · docs.voxelplugin.com · VoxelPlugin/VoxelCore · UE 5.7 release notes