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:
1 parent
58e61cdefa
commit
7a20eeed0d
6 files changed
+607
-90
No files matched your search
+22
-15
@@ -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
@@ -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));
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user