Move closed audits, task briefs, design rounds, perf reports, the OPSTACK plan/progress logs and WORK-NEXT.md into Docs/archive/ (git mv, history kept). Whitelist Docs/ in the strict .gitignore, ignore Tools/**/__pycache__/, and remove the tracked .pyc. Repoint references in the kept docs and in non-test source comments to the new paths.
403 lines
34 KiB
Markdown
403 lines
34 KiB
Markdown
# AUDIT-SOL-2026-09-11.md — read-only audit of experimental @ aa62971
|
||
|
||
Performed by Codex **gpt-5.6-sol**, reasoning effort xhigh, **sandbox read-only** — no file was
|
||
created, modified or deleted. Commissioned by Jahni after the experimental branch showed two
|
||
regressions against main: generation much slower, and the player falling through the world.
|
||
|
||
Verbatim report follows.
|
||
|
||
---
|
||
|
||
# Audit conclusion
|
||
|
||
The two regressions share a cause, but they are not the same bug:
|
||
|
||
1. **The remaining generation slowdown is primarily in density evaluation.** The composable operator representation is still being interpreted in the sampling hot path, with rich per-sample state, block-wide operator passes, and several corrective passes afterward. The outer tile classifier is not where the original ~8× went.
|
||
|
||
2. **There is no “collision-ready before player-ready” invariant.** Neither branch explicitly had one. `main` was fast enough—and had simpler spawn geometry—so the race remained hidden. `experimental` made the race long enough to become visible, especially because the new origin room’s air and supporting floor can occupy different tiles.
|
||
|
||
I disagree with the inference that the old behavior proves there was a correctness barrier. The observation is valid, but the code shows a timing dependency masquerading as a guarantee.
|
||
|
||
## 1. Density evaluation is the dominant performance regression
|
||
|
||
**Status: Verified architecture; strong attribution. Highest expected performance impact.**
|
||
|
||
A streamed LOD0 tile asks for a 35³ haloed density grid: **42,875 sample positions** ([VoxelMarchingCubesMesher.cpp:227](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:227>)).
|
||
|
||
At `34f06df`, that hot path was approximately:
|
||
|
||
- Resolve one cached chunk parameter set.
|
||
- Enter one direct archetype switch.
|
||
- Keep most intermediate values in local variables.
|
||
- Skip the detailed cave suite when `bNearCaveSurface` was false—historically around 70% of deep-solid samples.
|
||
- Apply disturbances/differences and return.
|
||
|
||
The relevant historical path is `34f06df:Source/VoxelForge/Private/VoxelGenerator.cpp:438-631` and `:947-1621`.
|
||
|
||
Current `experimental` instead:
|
||
|
||
- Allocates/initializes an `FVoxelOpSample` for every position, containing density, SDF, floor metadata, tunnel metadata, pointers and indices ([VoxelDensityOp.h:327](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelDensityOp.h:327>)).
|
||
- Determines a block-wide active-operator list.
|
||
- Traverses the sample array once per active operator.
|
||
- Uses scalar per-sample fallback implementations for most concrete operators ([VoxelDensityOpStack.cpp:51](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:51>)).
|
||
- Copies block scratch into the session cache and later back into output.
|
||
- Still calls the scalar `GetDensityAt` wrapper for every grid point, so routing, coordinate setup and the post-stack structural tail remain per-sample ([VoxelGenerator.cpp:1690](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1690>), [VoxelGenerator.cpp:2165](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2165>)).
|
||
|
||
Loop inversion removed much of the virtual dispatch cost, but it did not restore the old locality or short-circuiting. An operator whose interval intersects the surface anywhere in the block can be run across the whole block; the old path could decide per sample that expensive detail work was irrelevant.
|
||
|
||
That is the architectural mistake: **the composable authoring model was carried directly into execution instead of being compiled/lowered into a fused evaluator.** Composition is not itself the problem. Interpreting it through rich mutable sample records is.
|
||
|
||
The 128³ comparison strongly supports this conclusion: the editor export path owns a shared density grid and does not force the streaming outer-classifier path ([VoxelForgeExploreCommandlet.cpp:1700](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1700>)), yet it still takes approximately 1.04 seconds versus 0.130 seconds before the operator-stack wire-in.
|
||
|
||
The later features add cost, particularly the D-shaped floors, but the history indicates roughly a 17% net penalty there—not anything close to the remaining 8×.
|
||
|
||
## 2. The player can become active before any supporting collision exists
|
||
|
||
**Status: Verified. Highest correctness impact.**
|
||
|
||
`BeginPlay` initializes systems, but it does not synchronously request a spawn support tile, wait for a mesh, or gate pawn gravity/control ([VoxelWorld.cpp:988](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:988>)).
|
||
|
||
Streaming begins from `Tick`, and only when `GetPlayerPosition()` is not exactly `FVector::ZeroVector` ([VoxelWorld.cpp:1167](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1167>), [VoxelWorld.cpp:1359](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1359>)). Thus an exact world-origin pawn is indistinguishable from “no pawn.” Even when Z is nonzero and that specific sentinel is avoided, there is still no bootstrap barrier.
|
||
|
||
The supplied play trace shows:
|
||
|
||
- `BeginPlay` finished at about **50 ms**.
|
||
- The first desired-tile pass did not begin until about **439 ms**.
|
||
- The first LOD0 nonempty/mixed result was not ready until about **1.093 s**.
|
||
- That result still represented mesh/collision submission, not collision-cook completion.
|
||
|
||
At ordinary gravity, one second is enough to fall roughly five metres.
|
||
|
||
There are three additional gaps:
|
||
|
||
- A tile is inserted into `LoadedTiles` before its empty/nonempty application path completes ([VoxelWorld.cpp:1532](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1532>)).
|
||
- Only LOD0 receives collision ([VoxelWorld.cpp:3109](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3109>)). Coarser visible terrain can therefore be present while the corresponding collidable LOD0 tile is still queued.
|
||
- Current architecture documentation says RMC collision is asynchronously cooked ([ARCHITECTURE.md:817](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:817>)), but `VoxelWorld` records no cook-complete state. Its `apply_s` measurement ends after submission and says nothing about when physics can actually collide.
|
||
|
||
I compared the equivalent paths on `main`. It also:
|
||
|
||
- Starts streaming from `Tick`.
|
||
- Uses the zero-position guard.
|
||
- Marks a tile loaded before handling its empty result.
|
||
- Enables collision only for LOD0.
|
||
- Has no spawn collision barrier or collision-cook acknowledgement.
|
||
|
||
So no explicit guarantee was removed inside `VoxelWorld`. What `experimental` broke was the **implicit timing budget** that had hidden this race.
|
||
|
||
The new origin-room geometry makes it worse. The first central LOD0 request in the trace is uniformly air and correctly receives no component. That is not itself an error: the player occupies air, while collision belongs to the neighboring floor/wall surface tile. With the current default origin geometry, the floor can lie near or below the lower tile boundary ([VoxelPassageGeometry.h:365](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelPassageGeometry.h:365>)). The supporting tile therefore has to arrive and finish cooking independently.
|
||
|
||
`CollisionOnlyTiles` does not close this gap. It controls presentation of already generated LOD0 components for non-player anchors; it does not synthesize or reserve collision underneath the local pawn. The supplied trace records zero collision-only tiles.
|
||
|
||
## 3. The mesher’s nested block classifier is probably self-inflicted overhead
|
||
|
||
**Status: Mechanism verified; net cost strongly suspected. High performance impact.**
|
||
|
||
The code contradicts its own guidance:
|
||
|
||
- The mesher header says the optional 8³ refinement should remain disabled during streaming because repeated proofs may cost more than they save ([VoxelMarchingCubesMesher.h:150](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:150>)).
|
||
- `VoxelWorld` unconditionally enables it ([VoxelWorld.cpp:1102](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1102>)).
|
||
|
||
For every outer-Mixed tile, the mesher may run up to 64 additional `ClassifyTile` calls ([VoxelMarchingCubesMesher.cpp:254](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:254>)). On cache misses, each call builds another operator stack ([VoxelGenerator.cpp:5779](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:5779>)).
|
||
|
||
In `MyPlay`:
|
||
|
||
- 298 LOD0 tiles were outer-Mixed.
|
||
- All 298 produced geometry.
|
||
- Therefore every one eventually populated the full density grid.
|
||
- The nested classifier could only avoid marching-cubes cell processing in uniform sub-blocks; it could not avoid the main density grid once any surface sub-block was found.
|
||
|
||
This is precisely the workload where the header warns it may lose.
|
||
|
||
There is also an instrumentation trap: the outer cache counters are snapped before meshing. Nested classifier calls occur inside the measured `mesh_s` interval ([VoxelWorld.cpp:2762](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:2762>)). Consequently, the reported “cache hits 0” does **not** describe classifier activity originating inside the mesher.
|
||
|
||
This deserves the first controlled A/B test: identical trace and seed with only `bUseBlockEarlyOut` changed, while separately counting outer and nested classifier calls.
|
||
|
||
## 4. The outer whole-tile classifier is probably earning its keep
|
||
|
||
**Status: Verified workload behavior; exact counterfactual unmeasured.**
|
||
|
||
I would not remove the outer classifier based on the 95 ms figure.
|
||
|
||
It rejected **547 of 845 LOD0 tiles**, or 64.7%, before meshing:
|
||
|
||
- 484 uniformly solid.
|
||
- 63 uniformly air.
|
||
- 298 mixed, all of which produced geometry.
|
||
|
||
A full 35³ grid for those rejected tiles would represent up to 23.45 million density positions. Previous history also shows that the outer classifier eliminated multi-second empty-tile fallbacks.
|
||
|
||
The 95 ms is not a clean CPU cost. It is elapsed wall time inside a `BackgroundNormal` worker task, so it includes preemption and contention. A second trace in the repository from the same current code has an LOD0 classifier median around **8.45 ms**, versus **94.7 ms** in the moving/rendering owner session. The real play number is important for latency, but it should not be interpreted as intrinsic classifier compute.
|
||
|
||
My assessment is:
|
||
|
||
- **Outer whole-tile classification:** valuable under the current expensive evaluator.
|
||
- **Nested 8³ block classification:** likely negative in this workload.
|
||
- **Long-term:** making density evaluation cheap again may change the outer classifier’s break-even point.
|
||
|
||
## 5. Movement does not cancel obsolete generation work
|
||
|
||
**Status: Verified mechanism; contribution to running falls strongly suspected.**
|
||
|
||
The abort predicate only notices shutdown or an explicit generation pause ([VoxelWorld.h:764](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:764>)). It does not abort a tile because the player has moved and the tile is no longer desired.
|
||
|
||
Desired work is globally distance-sorted using an artificial chunk-center position rather than the exact pawn position, with no strong collision-critical/L0 priority ([VoxelWorld.cpp:1864](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1864>)). Up to 14 background tasks can therefore be occupied by old, coarse, or non-supporting work while the player’s new LOD0 floor waits.
|
||
|
||
By the steady point in the supplied session there were 1,481 loaded tiles for 899 currently desired tiles, with roughly 582 on the unload/transition side. Retaining old rendered tiles is not automatically wrong, but generation already launched for the old neighborhood is not preempted in favor of collision-critical tiles.
|
||
|
||
This is a credible explanation for falls while running. The trace cannot prove it because it omits tile coordinates, pawn position and collision-cook completion.
|
||
|
||
## 6. The verdict cache is mismatched to normal streaming
|
||
|
||
**Status: Implementation verified; negative startup ROI suspected. Medium impact.**
|
||
|
||
The cache:
|
||
|
||
- Is global and protected by one lock.
|
||
- Advances a clock and linearly scans entries.
|
||
- Holds only 512 proofs ([VoxelGenerator.cpp:1512](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1512>)).
|
||
- Keys include sample step, limiting reuse across LODs.
|
||
- Does not store a useful outer proof for Mixed tiles.
|
||
|
||
Fresh streaming normally visits unique regions, which explains the zero outer hits in both supplied traces. Nested block classifiers can then insert many short-lived proofs, pay the lock/scan costs, and evict one another—without appearing in the trace’s cache telemetry.
|
||
|
||
It demonstrably helped a deliberately repeated commandlet case, but that access pattern is not representative of a moving player. I would treat it as unproven or likely negative during startup until nested calls are measured.
|
||
|
||
## 7. Field semantics are now implemented in several overlapping places
|
||
|
||
**Status: Verified duplication; cost not isolated. Medium impact and high maintenance risk.**
|
||
|
||
The effective field is no longer simply “the operator stack”:
|
||
|
||
1. The operator stack evaluates the composable field.
|
||
2. The classifier maintains parallel interval/proof semantics.
|
||
3. `GetDensityAt` performs a post-stack repair pipeline for origin floors, passage structure, passage floors and room floors.
|
||
|
||
For example, `FRoomGraphSource` evaluates the cached room graph SDF and then separately traverses tunnels again for metadata ([VoxelDensityOpStack.cpp:4234](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:4234>)). The common tail then reasserts origin and passage structure/floors ([VoxelGenerator.cpp:2244](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2244>)).
|
||
|
||
This is the clearest sign of overengineering: later correctness fixes are compensating for where an operation sits in the pipeline. It creates duplicated traversal, makes interval proofs harder to trust, and prevents the stack from being the single semantic source of truth.
|
||
|
||
Runtime also does not use the commandlet’s shared density-grid ownership, so adjacent tiles repeat halo/boundary samples. Earlier measurement put this duplication around 18%. It existed in the old architecture too, so it did not cause the original regression, but every repeated sample is now much more expensive.
|
||
|
||
## 8. There are two lower-confidence ways to get a permanent hole
|
||
|
||
**Status: Verified risk paths; not demonstrated by this trace.**
|
||
|
||
First, whole-tile AllAir/AllSolid verdicts are trusted without exact validation. They become loaded `bEmpty` tiles with no component. Internal block verdicts are later checked against the full grid when meshing occurs, but an outer uniform verdict has no equivalent safety net.
|
||
|
||
The supplied aggregate argues against a widespread problem—every LOD0 Mixed tile produced geometry—but it cannot establish whether the floor tile beneath the pawn was incorrectly declared uniform because tile coordinates were not logged.
|
||
|
||
Second, `ApplyMeshToTile` has no success return. If runtime-mesh initialization fails, the function returns while the tile remains recorded as loaded, so it will not naturally retry ([VoxelWorld.cpp:3064](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3064>)). I found no trace evidence that this occurred.
|
||
|
||
The normal `bAborted` result path is not the likely culprit: it removes the pending record without marking the tile loaded, allowing a later desired pass to reschedule it. There is, however, an editor pause corner case where a worker can exit without enqueuing any result and leave its pending entry behind if pause acquisition times out. That is a real bug, but it does not match this epoch-zero play session.
|
||
|
||
## What I would prove next
|
||
|
||
In order:
|
||
|
||
1. A/B the forced nested block classifier on the exact owner trace, recording outer versus nested calls, proof outcomes, stack builds and cache probes.
|
||
2. Add collision-specific timestamps: requested, mesh submitted, collision cook started/completed, component registered, plus tile and pawn coordinates.
|
||
3. Force exact meshing/validation for every supposedly uniform LOD0 tile in the spawn neighborhood to rule a false classifier proof in or out.
|
||
4. Benchmark a compiled/fused evaluator that retains the authored operator graph but restores per-sample short-circuiting and avoids spilling `FVoxelOpSample` through memory for every operator.
|
||
5. Prioritize a small LOD0 support neighborhood and require collision-cook completion before releasing the pawn. Faster generation alone cannot make the current lifecycle correct.
|
||
|
||
The blunt version: **the operator abstraction leaked into the innermost loop, and streaming treats “work submitted/applied” as “safe to stand on.”** The classifier work then became a collection of compensators for the expensive evaluator, some useful and some apparently duplicative.
|
||
|
||
This was a read-only audit. No files were created, modified or deleted; `git status` remains clean on `experimental` at `aa62971`.
|
||
tokens used
|
||
782 274
|
||
# Audit conclusion
|
||
|
||
The two regressions share a cause, but they are not the same bug:
|
||
|
||
1. **The remaining generation slowdown is primarily in density evaluation.** The composable operator representation is still being interpreted in the sampling hot path, with rich per-sample state, block-wide operator passes, and several corrective passes afterward. The outer tile classifier is not where the original ~8× went.
|
||
|
||
2. **There is no “collision-ready before player-ready” invariant.** Neither branch explicitly had one. `main` was fast enough—and had simpler spawn geometry—so the race remained hidden. `experimental` made the race long enough to become visible, especially because the new origin room’s air and supporting floor can occupy different tiles.
|
||
|
||
I disagree with the inference that the old behavior proves there was a correctness barrier. The observation is valid, but the code shows a timing dependency masquerading as a guarantee.
|
||
|
||
## 1. Density evaluation is the dominant performance regression
|
||
|
||
**Status: Verified architecture; strong attribution. Highest expected performance impact.**
|
||
|
||
A streamed LOD0 tile asks for a 35³ haloed density grid: **42,875 sample positions** ([VoxelMarchingCubesMesher.cpp:227](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:227>)).
|
||
|
||
At `34f06df`, that hot path was approximately:
|
||
|
||
- Resolve one cached chunk parameter set.
|
||
- Enter one direct archetype switch.
|
||
- Keep most intermediate values in local variables.
|
||
- Skip the detailed cave suite when `bNearCaveSurface` was false—historically around 70% of deep-solid samples.
|
||
- Apply disturbances/differences and return.
|
||
|
||
The relevant historical path is `34f06df:Source/VoxelForge/Private/VoxelGenerator.cpp:438-631` and `:947-1621`.
|
||
|
||
Current `experimental` instead:
|
||
|
||
- Allocates/initializes an `FVoxelOpSample` for every position, containing density, SDF, floor metadata, tunnel metadata, pointers and indices ([VoxelDensityOp.h:327](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelDensityOp.h:327>)).
|
||
- Determines a block-wide active-operator list.
|
||
- Traverses the sample array once per active operator.
|
||
- Uses scalar per-sample fallback implementations for most concrete operators ([VoxelDensityOpStack.cpp:51](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:51>)).
|
||
- Copies block scratch into the session cache and later back into output.
|
||
- Still calls the scalar `GetDensityAt` wrapper for every grid point, so routing, coordinate setup and the post-stack structural tail remain per-sample ([VoxelGenerator.cpp:1690](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1690>), [VoxelGenerator.cpp:2165](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2165>)).
|
||
|
||
Loop inversion removed much of the virtual dispatch cost, but it did not restore the old locality or short-circuiting. An operator whose interval intersects the surface anywhere in the block can be run across the whole block; the old path could decide per sample that expensive detail work was irrelevant.
|
||
|
||
That is the architectural mistake: **the composable authoring model was carried directly into execution instead of being compiled/lowered into a fused evaluator.** Composition is not itself the problem. Interpreting it through rich mutable sample records is.
|
||
|
||
The 128³ comparison strongly supports this conclusion: the editor export path owns a shared density grid and does not force the streaming outer-classifier path ([VoxelForgeExploreCommandlet.cpp:1700](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1700>)), yet it still takes approximately 1.04 seconds versus 0.130 seconds before the operator-stack wire-in.
|
||
|
||
The later features add cost, particularly the D-shaped floors, but the history indicates roughly a 17% net penalty there—not anything close to the remaining 8×.
|
||
|
||
## 2. The player can become active before any supporting collision exists
|
||
|
||
**Status: Verified. Highest correctness impact.**
|
||
|
||
`BeginPlay` initializes systems, but it does not synchronously request a spawn support tile, wait for a mesh, or gate pawn gravity/control ([VoxelWorld.cpp:988](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:988>)).
|
||
|
||
Streaming begins from `Tick`, and only when `GetPlayerPosition()` is not exactly `FVector::ZeroVector` ([VoxelWorld.cpp:1167](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1167>), [VoxelWorld.cpp:1359](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1359>)). Thus an exact world-origin pawn is indistinguishable from “no pawn.” Even when Z is nonzero and that specific sentinel is avoided, there is still no bootstrap barrier.
|
||
|
||
The supplied play trace shows:
|
||
|
||
- `BeginPlay` finished at about **50 ms**.
|
||
- The first desired-tile pass did not begin until about **439 ms**.
|
||
- The first LOD0 nonempty/mixed result was not ready until about **1.093 s**.
|
||
- That result still represented mesh/collision submission, not collision-cook completion.
|
||
|
||
At ordinary gravity, one second is enough to fall roughly five metres.
|
||
|
||
There are three additional gaps:
|
||
|
||
- A tile is inserted into `LoadedTiles` before its empty/nonempty application path completes ([VoxelWorld.cpp:1532](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1532>)).
|
||
- Only LOD0 receives collision ([VoxelWorld.cpp:3109](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3109>)). Coarser visible terrain can therefore be present while the corresponding collidable LOD0 tile is still queued.
|
||
- Current architecture documentation says RMC collision is asynchronously cooked ([ARCHITECTURE.md:817](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:817>)), but `VoxelWorld` records no cook-complete state. Its `apply_s` measurement ends after submission and says nothing about when physics can actually collide.
|
||
|
||
I compared the equivalent paths on `main`. It also:
|
||
|
||
- Starts streaming from `Tick`.
|
||
- Uses the zero-position guard.
|
||
- Marks a tile loaded before handling its empty result.
|
||
- Enables collision only for LOD0.
|
||
- Has no spawn collision barrier or collision-cook acknowledgement.
|
||
|
||
So no explicit guarantee was removed inside `VoxelWorld`. What `experimental` broke was the **implicit timing budget** that had hidden this race.
|
||
|
||
The new origin-room geometry makes it worse. The first central LOD0 request in the trace is uniformly air and correctly receives no component. That is not itself an error: the player occupies air, while collision belongs to the neighboring floor/wall surface tile. With the current default origin geometry, the floor can lie near or below the lower tile boundary ([VoxelPassageGeometry.h:365](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelPassageGeometry.h:365>)). The supporting tile therefore has to arrive and finish cooking independently.
|
||
|
||
`CollisionOnlyTiles` does not close this gap. It controls presentation of already generated LOD0 components for non-player anchors; it does not synthesize or reserve collision underneath the local pawn. The supplied trace records zero collision-only tiles.
|
||
|
||
## 3. The mesher’s nested block classifier is probably self-inflicted overhead
|
||
|
||
**Status: Mechanism verified; net cost strongly suspected. High performance impact.**
|
||
|
||
The code contradicts its own guidance:
|
||
|
||
- The mesher header says the optional 8³ refinement should remain disabled during streaming because repeated proofs may cost more than they save ([VoxelMarchingCubesMesher.h:150](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:150>)).
|
||
- `VoxelWorld` unconditionally enables it ([VoxelWorld.cpp:1102](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1102>)).
|
||
|
||
For every outer-Mixed tile, the mesher may run up to 64 additional `ClassifyTile` calls ([VoxelMarchingCubesMesher.cpp:254](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:254>)). On cache misses, each call builds another operator stack ([VoxelGenerator.cpp:5779](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:5779>)).
|
||
|
||
In `MyPlay`:
|
||
|
||
- 298 LOD0 tiles were outer-Mixed.
|
||
- All 298 produced geometry.
|
||
- Therefore every one eventually populated the full density grid.
|
||
- The nested classifier could only avoid marching-cubes cell processing in uniform sub-blocks; it could not avoid the main density grid once any surface sub-block was found.
|
||
|
||
This is precisely the workload where the header warns it may lose.
|
||
|
||
There is also an instrumentation trap: the outer cache counters are snapped before meshing. Nested classifier calls occur inside the measured `mesh_s` interval ([VoxelWorld.cpp:2762](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:2762>)). Consequently, the reported “cache hits 0” does **not** describe classifier activity originating inside the mesher.
|
||
|
||
This deserves the first controlled A/B test: identical trace and seed with only `bUseBlockEarlyOut` changed, while separately counting outer and nested classifier calls.
|
||
|
||
## 4. The outer whole-tile classifier is probably earning its keep
|
||
|
||
**Status: Verified workload behavior; exact counterfactual unmeasured.**
|
||
|
||
I would not remove the outer classifier based on the 95 ms figure.
|
||
|
||
It rejected **547 of 845 LOD0 tiles**, or 64.7%, before meshing:
|
||
|
||
- 484 uniformly solid.
|
||
- 63 uniformly air.
|
||
- 298 mixed, all of which produced geometry.
|
||
|
||
A full 35³ grid for those rejected tiles would represent up to 23.45 million density positions. Previous history also shows that the outer classifier eliminated multi-second empty-tile fallbacks.
|
||
|
||
The 95 ms is not a clean CPU cost. It is elapsed wall time inside a `BackgroundNormal` worker task, so it includes preemption and contention. A second trace in the repository from the same current code has an LOD0 classifier median around **8.45 ms**, versus **94.7 ms** in the moving/rendering owner session. The real play number is important for latency, but it should not be interpreted as intrinsic classifier compute.
|
||
|
||
My assessment is:
|
||
|
||
- **Outer whole-tile classification:** valuable under the current expensive evaluator.
|
||
- **Nested 8³ block classification:** likely negative in this workload.
|
||
- **Long-term:** making density evaluation cheap again may change the outer classifier’s break-even point.
|
||
|
||
## 5. Movement does not cancel obsolete generation work
|
||
|
||
**Status: Verified mechanism; contribution to running falls strongly suspected.**
|
||
|
||
The abort predicate only notices shutdown or an explicit generation pause ([VoxelWorld.h:764](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:764>)). It does not abort a tile because the player has moved and the tile is no longer desired.
|
||
|
||
Desired work is globally distance-sorted using an artificial chunk-center position rather than the exact pawn position, with no strong collision-critical/L0 priority ([VoxelWorld.cpp:1864](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1864>)). Up to 14 background tasks can therefore be occupied by old, coarse, or non-supporting work while the player’s new LOD0 floor waits.
|
||
|
||
By the steady point in the supplied session there were 1,481 loaded tiles for 899 currently desired tiles, with roughly 582 on the unload/transition side. Retaining old rendered tiles is not automatically wrong, but generation already launched for the old neighborhood is not preempted in favor of collision-critical tiles.
|
||
|
||
This is a credible explanation for falls while running. The trace cannot prove it because it omits tile coordinates, pawn position and collision-cook completion.
|
||
|
||
## 6. The verdict cache is mismatched to normal streaming
|
||
|
||
**Status: Implementation verified; negative startup ROI suspected. Medium impact.**
|
||
|
||
The cache:
|
||
|
||
- Is global and protected by one lock.
|
||
- Advances a clock and linearly scans entries.
|
||
- Holds only 512 proofs ([VoxelGenerator.cpp:1512](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1512>)).
|
||
- Keys include sample step, limiting reuse across LODs.
|
||
- Does not store a useful outer proof for Mixed tiles.
|
||
|
||
Fresh streaming normally visits unique regions, which explains the zero outer hits in both supplied traces. Nested block classifiers can then insert many short-lived proofs, pay the lock/scan costs, and evict one another—without appearing in the trace’s cache telemetry.
|
||
|
||
It demonstrably helped a deliberately repeated commandlet case, but that access pattern is not representative of a moving player. I would treat it as unproven or likely negative during startup until nested calls are measured.
|
||
|
||
## 7. Field semantics are now implemented in several overlapping places
|
||
|
||
**Status: Verified duplication; cost not isolated. Medium impact and high maintenance risk.**
|
||
|
||
The effective field is no longer simply “the operator stack”:
|
||
|
||
1. The operator stack evaluates the composable field.
|
||
2. The classifier maintains parallel interval/proof semantics.
|
||
3. `GetDensityAt` performs a post-stack repair pipeline for origin floors, passage structure, passage floors and room floors.
|
||
|
||
For example, `FRoomGraphSource` evaluates the cached room graph SDF and then separately traverses tunnels again for metadata ([VoxelDensityOpStack.cpp:4234](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:4234>)). The common tail then reasserts origin and passage structure/floors ([VoxelGenerator.cpp:2244](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2244>)).
|
||
|
||
This is the clearest sign of overengineering: later correctness fixes are compensating for where an operation sits in the pipeline. It creates duplicated traversal, makes interval proofs harder to trust, and prevents the stack from being the single semantic source of truth.
|
||
|
||
Runtime also does not use the commandlet’s shared density-grid ownership, so adjacent tiles repeat halo/boundary samples. Earlier measurement put this duplication around 18%. It existed in the old architecture too, so it did not cause the original regression, but every repeated sample is now much more expensive.
|
||
|
||
## 8. There are two lower-confidence ways to get a permanent hole
|
||
|
||
**Status: Verified risk paths; not demonstrated by this trace.**
|
||
|
||
First, whole-tile AllAir/AllSolid verdicts are trusted without exact validation. They become loaded `bEmpty` tiles with no component. Internal block verdicts are later checked against the full grid when meshing occurs, but an outer uniform verdict has no equivalent safety net.
|
||
|
||
The supplied aggregate argues against a widespread problem—every LOD0 Mixed tile produced geometry—but it cannot establish whether the floor tile beneath the pawn was incorrectly declared uniform because tile coordinates were not logged.
|
||
|
||
Second, `ApplyMeshToTile` has no success return. If runtime-mesh initialization fails, the function returns while the tile remains recorded as loaded, so it will not naturally retry ([VoxelWorld.cpp:3064](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3064>)). I found no trace evidence that this occurred.
|
||
|
||
The normal `bAborted` result path is not the likely culprit: it removes the pending record without marking the tile loaded, allowing a later desired pass to reschedule it. There is, however, an editor pause corner case where a worker can exit without enqueuing any result and leave its pending entry behind if pause acquisition times out. That is a real bug, but it does not match this epoch-zero play session.
|
||
|
||
## What I would prove next
|
||
|
||
In order:
|
||
|
||
1. A/B the forced nested block classifier on the exact owner trace, recording outer versus nested calls, proof outcomes, stack builds and cache probes.
|
||
2. Add collision-specific timestamps: requested, mesh submitted, collision cook started/completed, component registered, plus tile and pawn coordinates.
|
||
3. Force exact meshing/validation for every supposedly uniform LOD0 tile in the spawn neighborhood to rule a false classifier proof in or out.
|
||
4. Benchmark a compiled/fused evaluator that retains the authored operator graph but restores per-sample short-circuiting and avoids spilling `FVoxelOpSample` through memory for every operator.
|
||
5. Prioritize a small LOD0 support neighborhood and require collision-cook completion before releasing the pawn. Faster generation alone cannot make the current lifecycle correct.
|
||
|
||
The blunt version: **the operator abstraction leaked into the innermost loop, and streaming treats “work submitted/applied” as “safe to stand on.”** The classifier work then became a collection of compensators for the expensive evaluator, some useful and some apparently duplicative.
|
||
|
||
This was a read-only audit. No files were created, modified or deleted; `git status` remains clean on `experimental` at `aa62971`.
|