feat(shafts): a drainage tree -- the field goes from 22% to 97% connected

The plateau was the tell. Roughness changed the component COUNT (it
manufactures noise pockets) but the giant component sat at ~0.22 in every
variant, and widening connectors past the proven roughness bound barely
moved it. That is a random graph below its percolation threshold: 0.6
sites, 0.35 bonds, and a 1.6-cell pair cutoff that drops many neighbour
pairs once jitter spreads them 1.7 (orthogonal) to 2.4 (diagonal) cells
apart.

So the lever was topology. Every shaft now emits a deterministic connector
to the nearest shaft STRICTLY closer to the origin, so chains drain inward
and terminate at the spine. The probabilistic connectors stay as texture
and loops -- the tree guarantees you can get out, the noise keeps it from
reading as a diagram.

Deliberately NOT tuned: raising CrossConnectChance/ShaftDensity/the cutoff
toward the percolation threshold buys a world connected at one seed and
shattered at the next, which is exactly the 1-in-16 behaviour we had. A
phase transition is not a knob.

  seed 1337   components 30 -> 17,  largest share .220 -> .975
  16 seeds    spine is the largest component in 16/16 (was 8/16)
              largest share .58 - .9999

Seam safety, because a parent computed from a 3x3 window would differ
between chunks and the connector would exist in one and not the other:
collect over 7x7, emit only for the inner 3x3, whose 5x5 candidate windows
are then fully contained. 154 seam probes per path, 0 mismatches.

Box verdicts re-checked against the new air: 27 proved, 33 mixed,
0 violations. Stack mirror bit-identical over 20,000 samples.
Perf: 1.634 us forced rebuild, 0.100 us hot call -- the 49 rolls happen
only on cache rebuild, never per voxel.

⚠️ ACCEPTANCE STILL FAILS: arrival->departure 4/16 (was 1/16). Reported,
not tuned. With 97% of the air in one component, two mouths that both land
IN the field would essentially always connect -- so the remaining failure
is placement, not topology. Next.

Build clean, 20 succeeded / 0 warnings / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-08-30 17:26:20 +02:00
1 parent 58e61cdefa
commit 7a20eeed0d
6 files changed
+607 -90

No files matched your search

+22 -15
View File
@@ -46,23 +46,27 @@ what the world IS, and both paths compute it.
`UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before
every `ApplyBoundarySeal`.
- `VerticalShafts` treats that structural column as a **connector endpoint**, not as a second
density primitive: while rebuilding its memoised 3×3 shaft neighbourhood, an origin-adjacent
query adds `(0,0,OriginSpineRadius)` to the local shaft set for pair generation only. The
existing `VoxelHash::Pair` gate and `Spacing*1.6` neighbour cutoff remain in force, so the link
is a pure function of the shaft cell and seed and is independent of evaluation order. Spine
links use a radius above the proven `sup|FBM|=1.5` roughness envelope (`max(ConnectorRadius,
SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`); the ordinary connector radius and ordinary shaft
roughness are unchanged. `BuildVerticalShaftStack` receives the same runtime spine radius and
mirrors this source rule; `VF_ApplyOriginSpine` remains the sole owner of the column itself.
density primitive. A thread-local rebuild collects a 7×7 cell window once, emits tree links only
for the inner 3×3 cells, and resolves each parent from its complete 5×5 candidate window. Each
shaft chooses the nearest shaft whose jittered distance to (0,0) is strictly smaller; ties use
the lowest cell index. A local minimum links directly to the spine. The wider collection is
therefore fully contained for every emitted shaft and the parent is independent of chunk or
evaluation-cell boundaries. The existing probabilistic `VoxelHash::Pair` links and
`Spacing*1.6` cutoff remain as texture and loops; they are additive, not the connectivity law.
Tree links use a radius strictly above the proven `sup|FBM|=1.5` roughness envelope
(`max(ConnectorRadius, SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`).
`BuildVerticalShaftStack` mirrors the same tree, random links, windows, hashes, and radius;
`VF_ApplyOriginSpine` remains the sole owner of the vertical column itself.
- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened
connection is the **surface entry shaft** at (0,0) through the top of strate 0
(`GeneratePassages`, `bOpenSurfaceEntry`).
- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0).
Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape).
This connector is local topology, not a promise that independently placed shortcut mouths are
globally connected. The acceptance measurement for `VerticalShafts` remains the actual
arrival→departure flood-fill at the passage endpoints.
The tree is a construction guarantee for the shaft field: every seeded shaft has one inward
edge, and every chain strictly decreases distance to the origin until it reaches a local minimum
that links to the spine. It does not alter passage placement. The acceptance measurement remains
the actual arrival→departure flood-fill at the independently placed passage endpoints.
### 8.3 Disturbance layer (the "wow" post-process)
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
@@ -337,10 +341,13 @@ driven by `EditorBrush*` props.
no loads AND no LOD mismatches outstanding.
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
sampling must not thrash the (expensive) rebuild.
- **VerticalShafts field cache**: shaft rolls and pair decisions, including the structural-spine
connector endpoint, are rebuilt only when the thread-local centre cell or a geometry-affecting
parameter changes. They must not be re-hashed in the per-voxel loop; the operator-stack source
follows the same cache contract.
- **VerticalShafts field cache**: shaft rolls and connector decisions, including the structural
tree and structural-spine connector endpoint, are rebuilt only when the thread-local centre cell
or a geometry-affecting parameter changes. Each rebuild rolls 49 cells (7×7); it stores/emits
only the inner 3×3 shafts and their capsules for the per-voxel loop. The complete 5×5 parent
windows are contained by that 7×7 collection, so no parent decision depends on the evaluation
cell. No cell or pair hash may move into the per-voxel loop; the operator-stack source follows
the same cache contract.
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
+3 -3
View File
@@ -151,7 +151,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised 3×3 shaft/capsule source. In origin-adjacent query cells it adds the runtime `(0,0)` spine radius as a connector-only endpoint; `VoxelHash::Pair` and the existing cutoff/gate remain deterministic. Spine capsules use a `sup|FBM|=1.5` roughness-safe radius, while the structural post still owns the column and the ledge modifier ignores the synthetic endpoint. `EffectOverBox` mirrors the rule conservatively. |
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild collects 7×7 cells so every emitted inner shaft has its complete 5×5 candidate window; the nearest strictly more-central shaft is the deterministic parent, and a local minimum links to the runtime `(0,0)` spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`; the structural post still owns the column and the ledge modifier ignores the synthetic endpoint. `EffectOverBox` mirrors both tree and random capsules conservatively. |
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
@@ -481,13 +481,13 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only 7×7/5×5 windows, plus the old random links. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now tests tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. |
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
| `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). |
| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. |
| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test explicitly checks margins 0/2/4/8 at step 2/radius 192, reports the `(0,0)` spine's full-window/interior vertical run and component for all eight fixture strates plus arrival/departure→spine and arrival→departure enums, then repeats those spine/mouth measurements across 16 VerticalShafts seeds and prints the post-fix acceptance count. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test explicitly checks margins 0/2/4/8 at step 2/radius 192, reports components/largest-component share, the `(0,0)` spine's full-window/interior vertical run and component, plus arrival/departure→spine and arrival→departure enums across 16 VerticalShafts seeds. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. |
## 4. The density pipeline (most-edited hot path)
@@ -22,6 +22,7 @@
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformTime.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
@@ -173,8 +174,9 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
//=========================================================================
// 2. INVARIANCE DE FENÊTRE
//=========================================================================
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est
// exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2).
// La source garde un cache inner 3×3 `thread_local`; chaque rebuild collecte 7×7 pour les
// fenêtres d'arbre 5×5. La clé est le jeu de params : c'est exactement le genre d'endroit où
// une clé incomplète produit une couture (AUDIT §C2).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
@@ -201,6 +203,51 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
Impure.load(), 0);
}
//==========================================================================
// 2b. CACHE COST
//==========================================================================
{
// Force one distinct centre cell per call, then hold one cell constant. This measures the
// widened collection where it belongs (cache rebuild), separately from the hot per-voxel
// path. The implementation deliberately performs 49 cell rolls for the former and zero
// cell rolls for the latter; only the cached inner 3×3/capsule SDFs remain hot.
constexpr int32 NumRebuildSamples = 256;
constexpr int32 NumHotSamples = 20000;
const float PerfSpacing = FMath::Max(P.ShaftSpacing, 1.0f);
const float PerfZ = 0.5f * (P.StrateBottomWorldZ + P.StrateTopWorldZ);
volatile float Sink = 0.0f;
const double RebuildStart = FPlatformTime::Seconds();
float LastX = 0.0f;
float LastY = 0.0f;
for (int32 i = 0; i < NumRebuildSamples; ++i)
{
const int32 CellX = (i % 16) - 8;
const int32 CellY = (i / 16) - 8;
LastX = (static_cast<float>(CellX) + 0.37f) * PerfSpacing;
LastY = (static_cast<float>(CellY) + 0.61f) * PerfSpacing;
Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P);
}
const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart;
const double HotStart = FPlatformTime::Seconds();
for (int32 i = 0; i < NumHotSamples; ++i)
{
Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P);
}
const double HotSeconds = FPlatformTime::Seconds() - HotStart;
AddInfo(FString::Printf(
TEXT("VerticalShafts cache perf: %d forced rebuilds at %.3f us/call and %d hot calls "
"at %.3f us/call (7x7=49 cell rolls only on rebuild; inner 3x3/capsule SDF "
"only on hot calls; sink=%.9g)."),
NumRebuildSamples,
RebuildSeconds * 1.0e6 / static_cast<double>(NumRebuildSamples),
NumHotSamples,
HotSeconds * 1.0e6 / static_cast<double>(NumHotSamples),
static_cast<float>(Sink)));
}
//=========================================================================
// 3. LE VERDICT DE BOÎTE
//=========================================================================
@@ -19,6 +19,11 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(
"VoxelForge.Generation.StrateConnectivityRefinement",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeVerticalShaftSeamTest,
"VoxelForge.Generation.VerticalShaftSeamFreedom",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
struct FArchetypeReport
@@ -448,6 +453,7 @@ namespace
FVoxelConnectivityDiagnostics SpineComponent;
FVoxelConnectivityDiagnostics ArrivalToSpineFacts;
FVoxelConnectivityDiagnostics DepartureToSpineFacts;
FVoxelStrateMetrics Topology;
};
bool FindDiagnosticMouths(
@@ -613,6 +619,142 @@ namespace
}
}
bool CheckVerticalShaftSeams(
VoxelForgeTest::FTestWorld& World,
int32& OutChecked,
int32& OutMismatches,
FString& OutFirstMismatch)
{
OutChecked = 0;
OutMismatches = 0;
OutFirstMismatch.Reset();
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(
VoxelForgeTest::FTestWorld::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ))
{
return false;
}
const FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk(
FIntVector(0, 0, (TopVoxelZ + BottomVoxelZ) / (2 * CHUNK_SIZE)));
const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f);
const float Z = static_cast<float>((TopVoxelZ + BottomVoxelZ) / 2);
const UVoxelGenerator* Generator = World.Generator.Get();
// A point is sampled immediately on both sides of many shaft-cell boundaries. For each point,
// two calls in distinct chunk contexts warm the per-chunk state in opposite orders before the
// same point is evaluated again. A different parent window would then show up as a bit change.
for (int32 CellY = -3; CellY <= 3; ++CellY)
{
for (int32 CellX = -5; CellX <= 5; ++CellX)
{
const float CellBoundaryX = static_cast<float>(CellX + 1) * Spacing;
const float Y = (static_cast<float>(CellY) + 0.37f) * Spacing;
for (const float X : { CellBoundaryX - 0.25f, CellBoundaryX + 0.25f })
{
const FVector Probe(X, Y, Z);
const int32 ProbeChunkX = FMath::FloorToInt(X / static_cast<float>(CHUNK_SIZE));
const int32 ProbeChunkY = FMath::FloorToInt(Y / static_cast<float>(CHUNK_SIZE));
const FVector ContextA(
static_cast<float>(ProbeChunkX * CHUNK_SIZE + 1),
static_cast<float>(ProbeChunkY * CHUNK_SIZE + 1), Z);
const FVector ContextB(
static_cast<float>((ProbeChunkX + 1) * CHUNK_SIZE + 1),
static_cast<float>((ProbeChunkY - 1) * CHUNK_SIZE + 1), Z);
const float Reference = Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
if (((CellX + CellY) & 1) == 0)
{
Generator->GetDensityAt(ContextA.X, ContextA.Y, ContextA.Z);
Generator->GetDensityAt(ContextB.X, ContextB.Y, ContextB.Z);
}
else
{
Generator->GetDensityAt(ContextB.X, ContextB.Y, ContextB.Z);
Generator->GetDensityAt(ContextA.X, ContextA.Y, ContextA.Z);
}
const float Got = Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
++OutChecked;
if (!SameFloatBits(Reference, Got))
{
if (OutMismatches == 0)
{
OutFirstMismatch = FString::Printf(
TEXT("at (%.3f, %.3f, %.3f): reference %.9g [0x%08X] vs after "
"neighbouring chunk contexts %.9g [0x%08X]"),
Probe.X,
Probe.Y,
Probe.Z,
Reference,
*reinterpret_cast<const uint32*>(&Reference),
Got,
*reinterpret_cast<const uint32*>(&Got));
}
++OutMismatches;
}
}
}
}
return OutChecked > 0;
}
bool FVoxelForgeVerticalShaftSeamTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
bool bAllChecksPassed = true;
FString Summary = TEXT("VerticalShafts seam-freedom check (cell-boundary probes with two "
"neighbouring chunk-context orders):\n");
for (const bool bUseOperatorStack : { false, true })
{
FTestWorld World;
World.Build(/*InSeed=*/1337, /*InGapChunks=*/2, bUseOperatorStack);
if (!World.IsValid())
{
AddError(FString::Printf(
TEXT("HARD FAILURE: could not build the %s seam-test fixture."),
bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy")));
bAllChecksPassed = false;
continue;
}
int32 Checked = 0;
int32 Mismatches = 0;
FString FirstMismatch;
if (!CheckVerticalShaftSeams(World, Checked, Mismatches, FirstMismatch))
{
AddError(FString::Printf(
TEXT("HARD FAILURE: the %s seam test sampled no VerticalShafts positions."),
bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy")));
bAllChecksPassed = false;
continue;
}
Summary += FString::Printf(
TEXT(" %s path: %d same-position re-evaluations, %d bit mismatches%s%s.\n"),
bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"),
Checked,
Mismatches,
Mismatches > 0 ? TEXT("; first ") : TEXT(""),
Mismatches > 0 ? *FirstMismatch : TEXT(""));
if (Mismatches != 0)
{
AddError(FString::Printf(
TEXT("SEAM: %s VerticalShafts density changed after different chunk-context "
"orders: %s"),
bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"),
*FirstMismatch));
bAllChecksPassed = false;
}
}
AddInfo(Summary);
return bAllChecksPassed;
}
bool FVoxelForgeStrateConnectivityTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
@@ -1936,6 +2078,11 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
int32 SeedSpineNoOpenCell = 0;
float SeedSpineShareMin = FLT_MAX;
float SeedSpineShareMax = -FLT_MAX;
int32 SeedTopologyInvalid = 0;
int32 SeedTopologyComponentsMin = INT32_MAX;
int32 SeedTopologyComponentsMax = 0;
float SeedTopologyLargestShareMin = FLT_MAX;
float SeedTopologyLargestShareMax = -FLT_MAX;
TArray<FSpineDiagnosisReport> SeedReports;
SeedReports.Reserve(UE_ARRAY_COUNT(SeedCases));
const double SeedSweepStartSeconds = FPlatformTime::Seconds();
@@ -1972,6 +2119,51 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
}
SeedReport.Seed = Seed;
const FVoxelStrateMetrics& Topology = SeedReport.Topology = VF_MeasureStrate(
*SeedWorld.Generator,
*SeedWorld.StrateManager,
VerticalShaftsIndex,
DiagnosticSettings);
const bool bTopologyUsable =
Topology.bValid
&& Topology.NumSampled > 0
&& Topology.NumAir > 0
&& Topology.NumSolid > 0
&& Topology.NumAirComponents > 0
&& Topology.LargestComponentCells > 0
&& Topology.LargestComponentCells <= Topology.NumAir
&& FMath::IsFinite(Topology.LargestComponentShare)
&& Topology.LargestComponentShare >= 0.0f
&& Topology.LargestComponentShare <= 1.0f;
if (!bTopologyUsable)
{
++SeedTopologyInvalid;
AddError(FString::Printf(
TEXT("HARD FAILURE: seed %d VerticalShafts topology measurement is not "
"usable (valid=%s, sampled=%lld, air=%lld, solid=%lld, components=%d, "
"largest share=%.9g, reason='%s')."),
Seed,
Topology.bValid ? TEXT("true") : TEXT("false"),
Topology.NumSampled,
Topology.NumAir,
Topology.NumSolid,
Topology.NumAirComponents,
Topology.LargestComponentShare,
*Topology.RefusalReason));
bAllChecksPassed = false;
}
else
{
SeedTopologyComponentsMin = FMath::Min(
SeedTopologyComponentsMin, Topology.NumAirComponents);
SeedTopologyComponentsMax = FMath::Max(
SeedTopologyComponentsMax, Topology.NumAirComponents);
SeedTopologyLargestShareMin = FMath::Min(
SeedTopologyLargestShareMin, Topology.LargestComponentShare);
SeedTopologyLargestShareMax = FMath::Max(
SeedTopologyLargestShareMax, Topology.LargestComponentShare);
}
if (!SeedReport.bHasChainArrival || !SeedReport.bHasDeparture
|| !SeedReport.ArrivalToDeparture.bChecked)
{
@@ -2030,7 +2222,8 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
}
const EVoxelConnectivityResult SeedResult = SeedReport.ArrivalToDeparture.Result;
if (SeedResult == EVoxelConnectivityResult::Connected
if (bTopologyUsable
&& SeedResult == EVoxelConnectivityResult::Connected
&& !SeedReport.ArrivalToDeparture.bStartSnapped
&& !SeedReport.ArrivalToDeparture.bGoalSnapped)
{
@@ -2071,6 +2264,15 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
SeedSpineNoOpenCell,
SeedSpineShareMin == FLT_MAX ? 0.0f : SeedSpineShareMin,
SeedSpineShareMax == -FLT_MAX ? 0.0f : SeedSpineShareMax);
Summary += FString::Printf(
TEXT(" seed topology: components range [%d, %d], largest-component share range "
"[%.9g, %.9g], invalid measurements=%d; spine-component share is reported "
"per seed below.\n"),
SeedTopologyComponentsMin == INT32_MAX ? 0 : SeedTopologyComponentsMin,
SeedTopologyComponentsMax,
SeedTopologyLargestShareMin == FLT_MAX ? 0.0f : SeedTopologyLargestShareMin,
SeedTopologyLargestShareMax == -FLT_MAX ? 0.0f : SeedTopologyLargestShareMax,
SeedTopologyInvalid);
Summary += FString::Printf(
TEXT(" seed result breakdown — arrival->spine: CONNECTED=%d, "
"NOT_CONNECTED_AT_THIS_RESOLUTION=%d, START_CELL_SOLID=%d, GOAL_CELL_SOLID=%d, "
@@ -2103,8 +2305,9 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
SeedArrivalToSpineSnapEvents,
SeedDepartureToSpineSnapEvents,
SeedArrivalToDepartureSnapEvents);
Summary += TEXT(" seed | spine air | full run | one open run | spine share | spine largest | "
"arrival->spine | departure->spine | arrival->departure\n");
Summary += TEXT(" seed | components | largest share | spine air | spine share | spine largest | "
"full run | one open run | arrival->spine | departure->spine | "
"arrival->departure\n");
for (int32 SeedIndex = 0; SeedIndex < SeedReports.Num(); ++SeedIndex)
{
const FSpineDiagnosisReport& SeedReport = SeedReports[SeedIndex];
@@ -2115,14 +2318,22 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
const FString SpineLargest = SeedReport.SpineComponent.GoalComponentCells > 0
? (SeedReport.SpineComponent.bGoalComponentIsLargest ? TEXT("YES") : TEXT("NO"))
: TEXT("N/A");
const FString Components = SeedReport.Topology.bValid
? FString::Printf(TEXT("%d"), SeedReport.Topology.NumAirComponents)
: TEXT("N/A");
const FString LargestShare = SeedReport.Topology.bValid
? FString::Printf(TEXT("%.9g"), SeedReport.Topology.LargestComponentShare)
: TEXT("N/A");
Summary += FString::Printf(
TEXT(" %d | %.9g | %s | %s | %s | %s | %s | %s | %s\n"),
TEXT(" %d | %s | %s | %.9g | %s | %s | %s | %s | %s | %s | %s\n"),
SeedReport.Seed,
*Components,
*LargestShare,
SeedReport.Spine.AirFraction,
SeedReport.Spine.bFullWindowRun ? TEXT("YES") : TEXT("NO"),
SeedReport.Spine.bAirSamplesFormOneRun ? TEXT("YES") : TEXT("NO"),
*SpineShare,
*SpineLargest,
SeedReport.Spine.bFullWindowRun ? TEXT("YES") : TEXT("NO"),
SeedReport.Spine.bAirSamplesFormOneRun ? TEXT("YES") : TEXT("NO"),
*ConnectivityProbeText(SeedReport.ArrivalToSpine),
*ConnectivityProbeText(SeedReport.DepartureToSpine),
*ConnectivityProbeText(SeedReport.ArrivalToDeparture));
+211 -45
View File
@@ -1444,13 +1444,14 @@ namespace
//=========================================================================
// RÔLE 1 — SOURCE : PUITS VERTICAUX / VERTICAL SHAFTS
//=========================================================================
// Cylindres infinis sur une grille XY jitterée + connecteurs horizontaux entre paires proches,
// ouverts par un hash de paire symétrique. Écrit le canal SDF uniquement.
// Cylindres infinis sur une grille XY jitterée + un arbre de drainage déterministe, complété
// par des connecteurs horizontaux entre paires proches ouverts par un hash de paire. Écrit le
// canal SDF uniquement.
//
// ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §6`, qui suggérait DEUX sources (colonnes XY-pures
// + connecteurs) pour que la moitié cylindrique reçoive le traitement du cache de colonne et un
// `ClassifyBox` exact en XY. Gardé en UN opérateur, et voici pourquoi :
// • les connecteurs se dérivent de la MÊME liste 3×3 que les puits (il faut les paires), donc
// • les connecteurs se dérivent de la MÊME liste inner 3×3 que les puits (il faut les paires), donc
// séparer imposerait soit de rouler les cellules deux fois, soit un cache partagé entre
// deux ops — c'est-à-dire la complexité qu'on voulait éviter ;
// • le `FShaftLedgeMod` en aval a de toute façon besoin de la liste des puits, donc il faut
@@ -1459,9 +1460,10 @@ namespace
// gardé : un `EffectOverBox` conservatif qui teste cercles ET capsules, ce que la version
// séparée aurait dû faire aussi. À revoir si le profil montre que ça compte.
//
// Kept as ONE op against §6's suggestion: the connectors derive from the same 3×3 roll as the
// shafts, and the downstream ledge mod needs the shaft list anyway. What is forfeited is an
// exact XY box verdict on the cylinder half alone.
// Kept as ONE op against §6's suggestion: the per-voxel connectors derive from the same inner
// 3×3 roll as the shafts, while a rebuild-only 7×7 collection resolves each 5×5 tree window;
// the downstream ledge mod needs the inner shaft list anyway. What is forfeited is an exact XY
// box verdict on the cylinder half alone.
class FShaftFieldSource final : public IVoxelDensityOp
{
public:
@@ -1473,7 +1475,7 @@ namespace
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
struct FShaft { float X, Y, R; bool bOriginSpine; };
struct FShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; };
struct FConn { FVector A, B; float Radius; };
// ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie. Un type imbriqué doit exister au
@@ -1485,7 +1487,7 @@ namespace
struct FCells
{
TArray<FShaft, TInlineAllocator<10>> Shafts;
TArray<FConn, TInlineAllocator<16>> Conns;
TArray<FConn, TInlineAllocator<24>> Conns;
};
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
@@ -1545,6 +1547,105 @@ namespace
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; }
}
//-----------------------------------------------------------------
// THE STRUCTURAL TREE — enumerate every inner cell that the box can query, then
// collect its complete 5×5 parent window. This is the EffectOverBox mirror of GetCells:
// it may over-enumerate, but it must never miss a cached tree capsule.
//-----------------------------------------------------------------
constexpr int32 CandidateRadius = 2;
constexpr uint32 TreeSalt = 0x7A11u;
const int32 EmitX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1;
const int32 EmitX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1;
const int32 EmitY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1;
const int32 EmitY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1;
TArray<FShaft, TInlineAllocator<64>> TreeCandidates;
TArray<FShaft, TInlineAllocator<32>> TreeEmit;
for (int32 cy = EmitY0 - CandidateRadius; cy <= EmitY1 + CandidateRadius; ++cy)
for (int32 cx = EmitX0 - CandidateRadius; cx <= EmitX1 + CandidateRadius; ++cx)
{
FShaft Sh;
if (!RollShaft(cx, cy, Sh)) { continue; }
TreeCandidates.Add(Sh);
if (cx >= EmitX0 && cx <= EmitX1 && cy >= EmitY0 && cy <= EmitY1)
{
TreeEmit.Add(Sh);
}
}
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
const float TreeRadius = SpineConnectorRadius();
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X),
0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y));
const float HalfDiagXY = 0.5f * FMath::Sqrt(
FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) +
FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y)));
auto ConnectorMayReachBox = [&](const FConn& Conn)
{
const float ConnReach = Conn.Radius + ExtraReach;
const float Zc = Conn.A.Z;
if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { return false; }
const float DistXY = VF_DistPointSegment2D(
CtrXY, FVector2D(Conn.A.X, Conn.A.Y), FVector2D(Conn.B.X, Conn.B.Y));
return DistXY - HalfDiagXY < ConnReach;
};
for (const FShaft& Child : TreeEmit)
{
const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y);
const FShaft* Parent = nullptr;
float BestDistanceSq = FLT_MAX;
for (const FShaft& Candidate : TreeCandidates)
{
if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY)
{
continue;
}
if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius
|| FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius)
{
continue;
}
const float CandidateOriginSq = FMath::Square(Candidate.X)
+ FMath::Square(Candidate.Y);
if (!(CandidateOriginSq < ChildOriginSq))
{
continue;
}
const float DistanceSq = FMath::Square(Child.X - Candidate.X)
+ FMath::Square(Child.Y - Candidate.Y);
const bool bLowerCell = Parent == nullptr
|| Candidate.CellY < Parent->CellY
|| (Candidate.CellY == Parent->CellY
&& Candidate.CellX < Parent->CellX);
if (DistanceSq < BestDistanceSq
|| (DistanceSq == BestDistanceSq && bLowerCell))
{
BestDistanceSq = DistanceSq;
Parent = &Candidate;
}
}
const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0;
const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0;
const uint32 LinkHash = VoxelHash::Pair(
Child.CellX, Child.CellY, ParentCellX, ParentCellY, Salt ^ TreeSalt);
const float Zc = FMath::Lerp(
P.StrateBottomWorldZ + P.BoundarySealThickness,
P.StrateTopWorldZ - P.BoundarySealThickness,
VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)));
const FVector ParentPoint = Parent != nullptr
? FVector(Parent->X, Parent->Y, Zc)
: FVector(0.0f, 0.0f, Zc);
const FConn TreeConn{
FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius };
if (ConnectorMayReachBox(TreeConn)) { return EVoxelOpEffect::CarveOnly; }
}
//-----------------------------------------------------------------
// LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin »
//-----------------------------------------------------------------
@@ -1558,11 +1659,11 @@ namespace
// construit, et tester la capsule réelle.
//
// ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs
// du voisinage 3×3 de la cellule DE SA REQUÊTE. Une paire visible depuis un point de la
// boîte a donc ses deux puits dans un même 3×3 centré sur une cellule que la boîte
// touche ⇒ les deux sont dans [cellules de la boîte] ± 1, qui est exactement la plage
// balayée ici. On peut produire des paires que personne ne voit jamais : c'est du
// `CarveOnly` en trop, pas un trou.
// du voisinage inner 3×3 de la cellule DE SA REQUÊTE. Une paire random visible depuis
// un point de la boîte a donc ses deux puits dans [cellules de la boîte] ± 1, qui est
// exactement la plage balayée ici. Les connecteurs de l'arbre sont testés séparément
// au-dessus avec leur collecte 7×7/émission inner 3×3. On peut produire des paires que
// personne ne voit jamais : c'est du `CarveOnly` en trop, pas un trou.
//
// ⚠️ ET POURQUOI L'ORDRE (A,B) EST LE MÊME QUE CELUI DE `GetCells`. Le hash de paire est
// pris sur (A puis B) dans l'ordre d'insertion, et `GetCells` insère en `(dy, dx)`,
@@ -1573,15 +1674,15 @@ namespace
//
// Was: return CarveOnly as soon as any shaft EXISTED within Spacing*1.6 + Pad, which at
// ShaftSpacing 55 / ShaftDensity 0.6 is true everywhere -- 0 of 60 tiles proved. Now it
// rebuilds the connectors the way GetCells does and tests the real capsule. The pair
// rebuilds the random links the way GetCells does and tests the real capsule. The pair
// enumeration is a superset (safe), and the row-major cell order reproduces GetCells'
// insertion order, so each pair gets the same hash without assuming Pair() is symmetric.
if (P.CrossConnectChance > 0.0f)
{
const int32 QX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1;
const int32 QX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1;
const int32 QY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1;
const int32 QY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1;
const int32 QX0 = EmitX0;
const int32 QX1 = EmitX1;
const int32 QY0 = EmitY0;
const int32 QY1 = EmitY1;
TArray<FShaft, TInlineAllocator<32>> Near;
for (int32 cy = QY0; cy <= QY1; ++cy)
@@ -1598,21 +1699,11 @@ namespace
&& QX0 <= 1 && QX1 >= -1
&& QY0 <= 1 && QY1 >= -1)
{
Near.Add({0.0f, 0.0f, SpineRadius, true});
Near.Add({0.0f, 0.0f, SpineRadius, 0, 0, true});
}
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
// Z est traité EXACTEMENT (le connecteur est une capsule horizontale à `Zc`), XY de
// façon conservative. Séparer les deux est bien plus serré qu'une demi-diagonale 3D.
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X),
0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y));
const float HalfDiagXY = 0.5f * FMath::Sqrt(
FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) +
FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y)));
for (int32 i = 0; i < Near.Num(); ++i)
for (int32 j = i + 1; j < Near.Num(); ++j)
{
@@ -1632,17 +1723,11 @@ namespace
const float ConnRadius = bSpineConnector
? SpineConnectorRadius() : P.ConnectorRadius;
const float ConnReach = ConnRadius + ExtraReach;
const float Zc = FMath::Lerp(BottomZ, TopZ,
VoxelHash::ToFloat01(VoxelHash::Mix(PH)));
if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { continue; }
const float DistXY = VF_DistPointSegment2D(
CtrXY, FVector2D(A.X, A.Y), FVector2D(B.X, B.Y));
if (DistXY - HalfDiagXY >= ConnReach) { continue; }
return EVoxelOpEffect::CarveOnly;
const FConn RandomConn{
FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnRadius };
if (ConnectorMayReachBox(RandomConn)) { return EVoxelOpEffect::CarveOnly; }
}
}
@@ -1664,6 +1749,8 @@ namespace
Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing;
Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu)));
Out.CellX = nx;
Out.CellY = ny;
Out.bOriginSpine = false;
return true;
}
@@ -1678,7 +1765,8 @@ namespace
return FMath::Max(P.ConnectorRadius, RoughnessReach + 1.0f);
}
/** Le voisinage 3×3 + ses connecteurs, mémoïsés par worker. Clé = cellule + tous les params
/** Le voisinage inner 3×3 + ses connecteurs, mémoïsés par worker. Le rebuild collecte un
* 7×7 temporaire pour résoudre les fenêtres parent 5×5. Clé = cellule + tous les params
* qui influent (comme l'original) : stable à travers les reconstructions de pile, ce qui
* est la leçon retenue du mémo de colonne de SurfaceWorld. */
const FCells& GetCells(float WorldX, float WorldY) const
@@ -1711,23 +1799,101 @@ namespace
Cache.Shafts.Reset();
Cache.Conns.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
constexpr int32 CollectRadius = 3; // 7×7: inner 3×3 + one 5×5 candidate halo
constexpr int32 EmitRadius = 1; // 3×3: the only cells allowed to emit tree links
constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft
constexpr uint32 TreeSalt = 0x7A11u;
TArray<FShaft, TInlineAllocator<49>> CollectedShafts;
// Rebuild-only wide collection. `Cache.Shafts` remains the inner 3×3 working set
// used by Eval and the ledge modifier, so the 7×7 cost is not paid per voxel.
for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++)
for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++)
{
FShaft Sh;
if (RollShaft(CX + dx, CY + dy, Sh)) { Cache.Shafts.Add(Sh); }
if (!RollShaft(CX + dx, CY + dy, Sh)) { continue; }
CollectedShafts.Add(Sh);
if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius)
{
Cache.Shafts.Add(Sh);
}
}
const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1;
if (bOriginNearby && SpineRadius > 0.0f)
{
Cache.Shafts.Add({0.0f, 0.0f, SpineRadius, true});
Cache.Shafts.Add({0.0f, 0.0f, SpineRadius, 0, 0, true});
}
if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2)
{
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
const float TreeRadius = SpineConnectorRadius();
auto EmitTreeConnector = [&](const FShaft& Child, const FShaft* Parent)
{
const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0;
const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0;
const uint32 LinkHash = VoxelHash::Pair(
Child.CellX, Child.CellY, ParentCellX, ParentCellY, Salt ^ TreeSalt);
const float Zc = FMath::Lerp(
BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)));
const FVector ParentPoint = Parent != nullptr
? FVector(Parent->X, Parent->Y, Zc)
: FVector(0.0f, 0.0f, Zc);
Cache.Conns.Add({ FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius });
};
// Structural drainage tree. Every real shaft emitted by this cache chooses the
// nearest strictly more-central shaft in its complete 5×5 window; a local minimum
// drains to the spine. The explicit row-major cell tie-break is independent of
// array iteration order, and the synthetic spine is not a candidate shaft.
for (const FShaft& Child : Cache.Shafts)
{
if (Child.bOriginSpine) continue;
const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y);
const FShaft* Parent = nullptr;
float BestDistanceSq = FLT_MAX;
for (const FShaft& Candidate : CollectedShafts)
{
if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY)
{
continue;
}
if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius
|| FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius)
{
continue;
}
const float CandidateOriginSq = FMath::Square(Candidate.X)
+ FMath::Square(Candidate.Y);
if (!(CandidateOriginSq < ChildOriginSq))
{
continue;
}
const float DistanceSq = FMath::Square(Child.X - Candidate.X)
+ FMath::Square(Child.Y - Candidate.Y);
const bool bLowerCell = Parent == nullptr
|| Candidate.CellY < Parent->CellY
|| (Candidate.CellY == Parent->CellY
&& Candidate.CellX < Parent->CellX);
if (DistanceSq < BestDistanceSq
|| (DistanceSq == BestDistanceSq && bLowerCell))
{
BestDistanceSq = DistanceSq;
Parent = &Candidate;
}
}
EmitTreeConnector(Child, Parent);
}
// Existing random links remain the texture/loop layer and stay on the inner 3×3
// set. Only the deterministic parent lookup uses the 7×7 collection.
if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2)
{
for (int32 i = 0; i < Cache.Shafts.Num(); i++)
for (int32 j = i + 1; j < Cache.Shafts.Num(); j++)
{
+105 -19
View File
@@ -3679,9 +3679,9 @@ void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float World
//=============================================================================
// VERTICAL-SHAFT GENERATOR (ECaveGeneratorType::VerticalShafts)
//=============================================================================
// Solid rock carved by hash-placed full-height vertical shafts (cylinders) with
// occasional horizontal connector tunnels between neighbouring shafts and partial
// ledges inside them. Emphasises climbing and falling.
// Solid rock carved by hash-placed full-height vertical shafts (cylinders), a deterministic
// drainage tree of horizontal connector tunnels rooted at the origin spine, additive random
// connector loops, and partial ledges inside them. Emphasises climbing and falling.
float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float WorldZ,
const FVerticalShaftParams& Params) const
@@ -3698,36 +3698,50 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float
const int32 CX = FMath::FloorToInt(WorldX / Spacing);
const int32 CY = FMath::FloorToInt(WorldY / Spacing);
// Shafts + cross-connectors of the 3×3 neighbourhood are pure functions of (cell, seed,
// params) yet were re-hashed PER VOXEL (9 cell rolls + a pair hash per shaft pair). Bake
// them once per centre cell (thread_local); per-voxel work = the cylinder/capsule SDFs.
struct FLocalShaft { float X, Y, R; bool bOriginSpine; };
// The shaft topology is pure, but it is deliberately split into two windows:
// * COLLECT 7×7 cells once during this thread-local cache rebuild. This is the source data
// needed to resolve every 5×5 parent window for the emitted cells below.
// * EMIT/EVALUATE only the INNER 3×3 cells. A shaft in that inner window has its whole
// 5×5 candidate window inside the collected 7×7, so two neighbouring evaluation cells
// compute the same parent for the same shaft. The 7×7 work is therefore rebuild-only;
// the per-voxel loop still sees at most the inner 3×3 shafts plus their cached capsules.
struct FLocalShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; };
struct FLocalConn { FVector A, B; float Radius; };
thread_local TArray<FLocalShaft, TInlineAllocator<10>> Shafts;
thread_local TArray<FLocalConn, TInlineAllocator<16>> Conns;
thread_local TArray<FLocalConn, TInlineAllocator<24>> Conns;
thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX;
thread_local uint32 VS_Seed = 0xFFFFFFFFu;
thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, VS_MaxR = -1.0f,
VS_Cross = -1.0f, VS_ConnR = -1.0f, VS_Rough = -1.0f,
VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX;
VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX,
VS_Seal = -1.0f;
if (CX != VS_CX || CY != VS_CY || S != VS_Seed || Spacing != VS_Spacing ||
Params.ShaftDensity != VS_Dens || Params.ShaftMinRadius != VS_MinR || Params.ShaftMaxRadius != VS_MaxR ||
Params.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR ||
Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR ||
Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ)
Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ ||
Params.BoundarySealThickness != VS_Seal)
{
VS_CX = CX; VS_CY = CY; VS_Seed = S; VS_Spacing = Spacing;
VS_Dens = Params.ShaftDensity; VS_MinR = Params.ShaftMinRadius; VS_MaxR = Params.ShaftMaxRadius;
VS_Cross = Params.CrossConnectChance; VS_ConnR = Params.ConnectorRadius;
VS_Rough = Params.SurfaceRoughness; VS_SpineR = OriginSpineRadius;
VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ;
VS_Seal = Params.BoundarySealThickness;
Shafts.Reset();
Conns.Reset();
// Collect shafts in the 3x3 neighbourhood (XY).
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
constexpr int32 CollectRadius = 3; // 7×7: inner 3×3 + one 5×5 candidate halo
constexpr int32 EmitRadius = 1; // 3×3: the only cells allowed to emit tree links
constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft
constexpr uint32 TreeSalt = 0x7A11u;
TArray<FLocalShaft, TInlineAllocator<49>> CollectedShafts;
// Collect the wider deterministic neighbourhood once. Only the inner cells are retained
// in `Shafts`, because that is the per-voxel working set and the seam-free emission set.
for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++)
for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++)
{
const int32 nx = CX + dx, ny = CY + dy;
const uint32 Hh = VoxelHash::Cell(nx, ny, S);
@@ -3740,9 +3754,15 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float
Sh.Y = (ny + 0.15f + JY * 0.7f) * Spacing;
Sh.R = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu)));
Sh.CellX = nx;
Sh.CellY = ny;
Sh.bOriginSpine = false;
CollectedShafts.Add(Sh);
if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius)
{
Shafts.Add(Sh);
}
}
// The structural (0,0) spine is not re-carved here. It participates only as a
// connector endpoint. The local 3x3 set is origin-adjacent when its centre cell is in
@@ -3750,14 +3770,81 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float
const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1;
if (bOriginNearby && OriginSpineRadius > 0.0f)
{
Shafts.Add({0.0f, 0.0f, OriginSpineRadius, true});
Shafts.Add({0.0f, 0.0f, OriginSpineRadius, 0, 0, true});
}
// Horizontal connectors between nearby shaft pairs (hash-gated).
if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2)
{
const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness;
const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness;
const float RoughnessReach = FMath::Max(Params.SurfaceRoughness, 0.0f)
* VOXEL_NOISE_SCALE * 1.5f;
// A tree link must remain open at the centreline after the SDF roughness pass. The +1
// margin makes the radius strictly greater than the proven roughness supremum.
const float TreeConnectorRadius = FMath::Max(Params.ConnectorRadius, RoughnessReach + 1.0f);
auto EmitTreeConnector = [&](const FLocalShaft& Child, const FLocalShaft* Parent)
{
const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0;
const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0;
const uint32 LinkHash = VoxelHash::Pair(
Child.CellX, Child.CellY, ParentCellX, ParentCellY, S ^ TreeSalt);
const float Zc = FMath::Lerp(BottomZ, TopZ,
VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)));
const FVector ParentPoint = Parent != nullptr
? FVector(Parent->X, Parent->Y, Zc)
: FVector(0.0f, 0.0f, Zc);
Conns.Add({ FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius });
};
// Structural drainage tree: every real shaft in the inner 3×3 selects one strictly
// more-central shaft from its complete 5×5 window. A local minimum has no such parent and
// drains directly to the (0,0) spine. The explicit cell-coordinate tie-break makes this
// independent of array/iteration order; the synthetic spine is not a candidate shaft.
for (const FLocalShaft& Child : Shafts)
{
if (Child.bOriginSpine) continue;
const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y);
const FLocalShaft* Parent = nullptr;
float BestDistanceSq = FLT_MAX;
for (const FLocalShaft& Candidate : CollectedShafts)
{
if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY)
{
continue;
}
if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius
|| FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius)
{
continue;
}
const float CandidateOriginSq = FMath::Square(Candidate.X)
+ FMath::Square(Candidate.Y);
if (!(CandidateOriginSq < ChildOriginSq))
{
continue;
}
const float DistanceSq = FMath::Square(Child.X - Candidate.X)
+ FMath::Square(Child.Y - Candidate.Y);
const bool bLowerCell = Parent == nullptr
|| Candidate.CellY < Parent->CellY
|| (Candidate.CellY == Parent->CellY && Candidate.CellX < Parent->CellX);
if (DistanceSq < BestDistanceSq
|| (DistanceSq == BestDistanceSq && bLowerCell))
{
BestDistanceSq = DistanceSq;
Parent = &Candidate;
}
}
EmitTreeConnector(Child, Parent);
}
// Existing probabilistic links remain as texture and loops. They intentionally stay on
// the cached inner 3×3 working set; only the structural parent lookup needs the 7×7 halo.
if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2)
{
for (int32 i = 0; i < Shafts.Num(); i++)
for (int32 j = i + 1; j < Shafts.Num(); j++)
{
@@ -3777,8 +3864,7 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float
const float ConnectorR = bSpineConnector
? FMath::Max(
Params.ConnectorRadius,
FMath::Max(Params.SurfaceRoughness, 0.0f)
* VOXEL_NOISE_SCALE * 1.5f + 1.0f)
RoughnessReach + 1.0f)
: Params.ConnectorRadius;
Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR });
}