feat(strate): passages aim at open space -- for half the archetypes, measurably
Tier 1. GeneratePassages placed every passage at a random angle, distance and reach, never consulting the destination strate. The carved tube is air by construction, so nothing looked wrong -- but whether the lower mouth joined the destination's connected cave space was luck. A player could descend and arrive in a sealed pocket. The primordial law was a hope. VF_SuggestOpenPointZ is a pure free function of (archetype, params, seed, XY): no operator stack, no StrateManager, no cache. That is deliberate. This runs inside Initialize, where the layout is half-built and LayoutVersion is in flight, so anything holding a manager pointer there is a re-entrancy trap. Coverage is four archetypes of eight, and the number is the deliverable: 7 passages, 3 aimed at generator-confirmed open space with 48/48 ring samples air, 4 falling back. The hole is not closed. It is measured, where this morning it was invisible. Two corrections from Codex, both mine and both would have shipped confident nonsense: room placement is seeded by MakeStrateSeed, not the passage salt, so my version would have queried a cave graph that does not exist; and slabs need the world seed because that is what GetSlabDensity uses. And one of mine caught in review: the first test sampled GetDensityAt at the mouth and asserted air, which is true by construction because the passage carve is a structural-post invariant. It could not fail. Replaced with a 16-point ring outside the carve radius requiring 8/16 air -- a proxy for connectivity, not a proof. The proof is a flood fill, which is Tier 2. Logging: my first spec asked for a per-passage warning on fallback. It fired 572 times and turned 14 clean tests yellow, which is precisely the anti-pattern OPSTACK-HANDOFF.md warns about -- a warning that always means "this is fine" teaches people to ignore warnings. Now one sorted Log line per layout build. Built clean, suite green: 17 succeeded, 0 with warnings, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
This commit is contained in:
1 parent
10a8bbc677
commit
1ab8c0cae4
7 files changed
+806
-4
No files matched your search
@@ -290,6 +290,16 @@ is a simple straight tube. Placement, reach, and shape draws for inter-strate pa
|
|||||||
connection count cannot shift any later passage. Global passage settings were removed from
|
connection count cannot shift any later passage. Global passage settings were removed from
|
||||||
`VoxelSettings`.
|
`VoxelSettings`.
|
||||||
|
|
||||||
|
After a passage's XY is selected, `GeneratePassages` asks the **destination** strate for a lower-mouth
|
||||||
|
height through the pure free function `VF_SuggestOpenPointZ` (`VoxelCaveMorphology`). TunnelNetwork and
|
||||||
|
Underwater use the nearest hash-placed room centre, with `MakeStrateSeed(world-seed, strate-index)`
|
||||||
|
matching the room graph's existing identity; FlatPlain and CrystalChamber recompute their slab void
|
||||||
|
band and reject column-overlap points. Maze, SurfaceWorld, VerticalShafts, and FloatingIslands remain
|
||||||
|
explicitly unanswerable for this tier: they keep the historical random reach and emit a warning. This
|
||||||
|
query is source-level only — it does not construct an operator stack, touch a cache, or call back into
|
||||||
|
`UVoxelStrateManager` while `Initialize` is building the layout. The resulting lower endpoint is
|
||||||
|
clamped to the destination's seal-free interior.
|
||||||
|
|
||||||
### 8.9 Carving — brush shapes + editor controls
|
### 8.9 Carving — brush shapes + editor controls
|
||||||
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
|
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
|
||||||
`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk
|
`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk
|
||||||
|
|||||||
+8
-1
@@ -318,6 +318,12 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
|||||||
| `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. |
|
| `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. |
|
||||||
| `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. |
|
| `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. |
|
||||||
|
|
||||||
|
`MakeStrateSeed` (h:~477) is the shared pure world-seed/strate-index salt used by both
|
||||||
|
`BuildChunkCache` and destination landing queries. `VF_SuggestOpenPointZ` (h:~550, implemented in
|
||||||
|
`VoxelCaveMorphology.cpp`) is the pure landing-site query: nearest hash room centre for
|
||||||
|
TunnelNetwork/Underwater, slab void midpoint for FlatPlain/CrystalChamber, and `false` for the other
|
||||||
|
archetypes. It never touches a generator, operator stack, manager, cache, or mutable state.
|
||||||
|
|
||||||
Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per
|
Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per
|
||||||
voxel is the single biggest CPU win.
|
voxel is the single biggest CPU win.
|
||||||
|
|
||||||
@@ -354,7 +360,7 @@ Maps depth→strate at runtime; owns passages.
|
|||||||
| Method | .cpp line | Role |
|
| Method | .cpp line | Role |
|
||||||
|--------|-----------|------|
|
|--------|-----------|------|
|
||||||
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + pool sorted by soft-asset path, then unchanged Fisher-Yates shuffle), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. |
|
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + pool sorted by soft-asset path, then unchanged Fisher-Yates shuffle), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. |
|
||||||
| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. |
|
| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. After XY placement, the destination's pure `VF_SuggestOpenPointZ` targets the lower mouth and the result is clamped outside the destination seal bands; unsupported answers warn and preserve the old random reach. |
|
||||||
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
|
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
|
||||||
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
|
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
|
||||||
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
|
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
|
||||||
@@ -454,6 +460,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
|
|||||||
| `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. |
|
| `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`). |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
## 4. The density pipeline (most-edited hot path)
|
## 4. The density pipeline (most-edited hot path)
|
||||||
|
|
||||||
|
|||||||
+49
-1
@@ -343,7 +343,7 @@ the parameter named "max".
|
|||||||
It proves anything below `TerrainZ` solid and skips meshing. Any future 3D generation inside mountains
|
It proves anything below `TerrainZ` solid and skips meshing. Any future 3D generation inside mountains
|
||||||
needs a guard, or the caves vanish with no error.
|
needs a guard, or the caves vanish with no error.
|
||||||
|
|
||||||
### ⚠️ 6.4 The primordial law has a real hole TODAY
|
### 🔨 6.4 The primordial law hole — HALF FIXED (4 of 8 archetypes), built + green
|
||||||
|
|
||||||
`UVoxelStrateManager::GeneratePassages` places every inter-strate passage as independently salted
|
`UVoxelStrateManager::GeneratePassages` places every inter-strate passage as independently salted
|
||||||
hashes of `(seed, upper-strate index, connection index)` → random angle around the (0,0) spine →
|
hashes of `(seed, upper-strate index, connection index)` → random angle around the (0,0) spine →
|
||||||
@@ -561,3 +561,51 @@ acceptance conditions. Both now met:
|
|||||||
|
|
||||||
⚠️ **Read the PROVED counts as measurements, never as contracts.** What is asserted is that none of
|
⚠️ **Read the PROVED counts as measurements, never as contracts.** What is asserted is that none of
|
||||||
them is *wrong* — a false verdict leaves no geometry and no collision behind it.
|
them is *wrong* — a false verdict leaves no geometry and no collision behind it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Tier 1 status — passages aim at real open space (PARTIAL, built + green)
|
||||||
|
|
||||||
|
**Landed 2026-08-17.** `VF_SuggestOpenPointZ` (in `VoxelCaveMorphology`) is a **pure free function** of
|
||||||
|
(archetype, params, seed, XY) — it touches no operator stack, no `UVoxelStrateManager`, no cache. That
|
||||||
|
matters: `GeneratePassages` runs inside `Initialize`, when the layout is half-built and `LayoutVersion`
|
||||||
|
is mid-flight, so constructing anything holding a manager pointer there is a re-entrancy trap.
|
||||||
|
|
||||||
|
`GeneratePassages` now asks the **destination** strate for an open Z near the chosen XY and aims the
|
||||||
|
lower mouth there, clamped strictly inside the interior (never into a seal band). Where the archetype
|
||||||
|
cannot answer, **today's random reach is preserved unchanged**.
|
||||||
|
|
||||||
|
### ⚠️ Coverage is PARTIAL and the number is the point
|
||||||
|
Measured by `VoxelForge.Determinism.PassageLandsInOpenSpace`:
|
||||||
|
|
||||||
|
> *7 inter-strate passages, 3 checked, 48/48 ring samples air, 4 query-false (4 unique archetypes;
|
||||||
|
> 4 unsupported, 0 supported-but-no-point).*
|
||||||
|
|
||||||
|
✅ **Answering:** TunnelNetwork · Underwater · FlatPlain · CrystalChamber.
|
||||||
|
❌ **Not answering:** Maze · VerticalShafts · SurfaceWorld (and by extension FloatingIslands).
|
||||||
|
|
||||||
|
⇒ **The primordial law is guaranteed for four archetypes of eight.** The improvement over this morning
|
||||||
|
is not that the hole is closed — it is that the gap is now **measured instead of invisible**. Follow-on
|
||||||
|
work: Maze can answer from its lattice nodes, VerticalShafts from its shafts, SurfaceWorld from just
|
||||||
|
above `TerrainZ`, FloatingIslands from just above a blob top.
|
||||||
|
|
||||||
|
### Two seed contracts, do not mix them
|
||||||
|
- **TunnelNetwork / Underwater** → `VoxelCaveMorphology::MakeStrateSeed` (the strate's own room seed).
|
||||||
|
- **Slab archetypes** → the generator **world** seed, which is what `GetSlabDensity` uses.
|
||||||
|
|
||||||
|
Seeding the query from the passage salt hash — which my spec originally asked for — would inspect a
|
||||||
|
cave graph that **does not exist** and return confident answers about imaginary rooms. Codex caught it.
|
||||||
|
|
||||||
|
### The test is a proxy, and says so
|
||||||
|
The first version sampled `GetDensityAt` at the mouth and asserted air — **vacuous**, because
|
||||||
|
`VF_ApplyPassageCarving` is a structural-post invariant, so the tube is air *at its own mouth by
|
||||||
|
construction*. It passed whether the aiming worked or not. Replaced with a **16-point lateral ring** at
|
||||||
|
`MouthRadius + 2 × PassageBlend` (~14 voxels at defaults), requiring ≥ 8/16 air. A mouth in a real room
|
||||||
|
has open space around it; a mouth in bedrock has only the tube it dug itself.
|
||||||
|
⚠️ **It is a proxy for connectivity, not a proof.** The proof is a flood fill — Tier 2.
|
||||||
|
|
||||||
|
### ⚠️ A forked placement envelope, guarded by that test
|
||||||
|
`VF_FindNearestHashRoomZ` re-derives the room vertical placement envelope that `BuildChunkCache` also
|
||||||
|
computes. Calling the per-chunk cache builder from `Initialize` is impractical, so this is a deliberate
|
||||||
|
second copy. **Both sites now carry a PLACEMENT CONTRACT comment naming the other**, and the ring test
|
||||||
|
is the only thing standing between a placement change and silent bedrock passages.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// VoxelForgePassageOpenSpaceTest.cpp
|
||||||
|
// Vérifie que les bouches basses des passages ciblées tombent dans l'air de leur strate destination.
|
||||||
|
// Verifies that targeted lower passage mouths land in air in their destination strate.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelCaveMorphology.h"
|
||||||
|
#include "VoxelDensityPrimitives.h"
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgePassageLandsInOpenSpaceTest,
|
||||||
|
"VoxelForge.Determinism.PassageLandsInOpenSpace",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// The query and production density path use different seed identities for room graphs and
|
||||||
|
// slabs. Keep the test's oracle explicit so a future seed change cannot silently inspect a
|
||||||
|
// different room layout. / La requête et la densité utilisent des identités de seed différentes
|
||||||
|
// pour les graphes de salles et les slabs ; garder l'oracle explicite évite une dérive silencieuse.
|
||||||
|
int32 OpenPointSeedFor(
|
||||||
|
const UVoxelStrateDefinition& Definition,
|
||||||
|
const FStrateSlot& Destination,
|
||||||
|
int32 WorldSeed)
|
||||||
|
{
|
||||||
|
const bool bUsesCaveRooms =
|
||||||
|
Definition.GeneratorType == ECaveGeneratorType::TunnelNetwork
|
||||||
|
|| Definition.GeneratorType == ECaveGeneratorType::Underwater;
|
||||||
|
return bUsesCaveRooms
|
||||||
|
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
||||||
|
static_cast<uint32>(WorldSeed), Destination.StrateIndex))
|
||||||
|
: WorldSeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString ArchetypeName(ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
if (const UEnum* ArchetypeEnum = StaticEnum<ECaveGeneratorType>())
|
||||||
|
{
|
||||||
|
return ArchetypeEnum->GetNameStringByValue(static_cast<int64>(Archetype));
|
||||||
|
}
|
||||||
|
return FString::Printf(TEXT("Value_%d"), static_cast<int32>(Archetype));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
FTestWorld World;
|
||||||
|
// Seed 4468 deliberately gives the supported Underwater destination a mouth near the centre
|
||||||
|
// of a real hash room, while the old random lower reach places that same mouth well outside
|
||||||
|
// the room's vertical extent. That makes disabling the Z aiming fail deterministically instead
|
||||||
|
// of relying on a lucky seed.
|
||||||
|
World.Build(/*InSeed=*/4468);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TArray<FStrateSlot>& Layout = World.StrateManager->GetLayout();
|
||||||
|
const TArray<FVoxelPassage>& Passages = World.StrateManager->GetPassages();
|
||||||
|
const int32 WorldSeed = World.Settings->Seed;
|
||||||
|
|
||||||
|
int32 NumInterStratePassages = 0;
|
||||||
|
int32 NumChecked = 0;
|
||||||
|
int32 NumQueryFalse = 0;
|
||||||
|
int32 NumUnsupported = 0;
|
||||||
|
int32 NumSupportedWithoutPoint = 0;
|
||||||
|
int32 NumRingAirSamples = 0;
|
||||||
|
int32 NumRingSamples = 0;
|
||||||
|
TSet<uint8> FalseArchetypes;
|
||||||
|
bool bAllAnswerableMouthRingsHaveAir = true;
|
||||||
|
bool bAllAnswerableEndpointsMatchQuery = true;
|
||||||
|
|
||||||
|
for (const FVoxelPassage& Passage : Passages)
|
||||||
|
{
|
||||||
|
// The surface entry is a same-strate shaft and is not an inter-strate destination query.
|
||||||
|
// Le puits de surface relie la même strate ; ce n'est pas un passage inter-strates à tester.
|
||||||
|
if (Passage.UpperStrateIndex == Passage.LowerStrateIndex)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++NumInterStratePassages;
|
||||||
|
if (!Layout.IsValidIndex(Passage.LowerStrateIndex))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage lower endpoint references invalid destination strate index %d."),
|
||||||
|
Passage.LowerStrateIndex));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FStrateSlot& Destination = Layout[Passage.LowerStrateIndex];
|
||||||
|
const UVoxelStrateDefinition* Definition = Destination.Definition;
|
||||||
|
if (!Definition)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage destination strate %d has no definition."),
|
||||||
|
Destination.StrateIndex));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Layout.IsValidIndex(Passage.UpperStrateIndex))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage to strate %d references invalid source strate index %d."),
|
||||||
|
Destination.StrateIndex,
|
||||||
|
Passage.UpperStrateIndex));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FStrateSlot& Source = Layout[Passage.UpperStrateIndex];
|
||||||
|
const UVoxelStrateDefinition* SourceDefinition = Source.Definition;
|
||||||
|
if (!SourceDefinition)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage source strate %d has no definition."),
|
||||||
|
Source.StrateIndex));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FStratePassageConfig& PassageConfig = SourceDefinition->PassageConfig;
|
||||||
|
if (!FMath::IsFinite(PassageConfig.MouthRadius) || PassageConfig.MouthRadius <= 0.0f)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage to strate %d (%s) has invalid mouth radius %.9g."),
|
||||||
|
Destination.StrateIndex,
|
||||||
|
*ArchetypeName(Definition->GeneratorType),
|
||||||
|
PassageConfig.MouthRadius));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float StrateTopZ = (float)(Destination.TopChunkZ + 1) * CHUNK_SIZE;
|
||||||
|
const float StrateBottomZ = (float)Destination.BottomChunkZ * CHUNK_SIZE;
|
||||||
|
const int32 QuerySeed = OpenPointSeedFor(*Definition, Destination, WorldSeed);
|
||||||
|
|
||||||
|
float SuggestedZ = 0.0f;
|
||||||
|
const bool bCanAnswer = VF_SuggestOpenPointZ(
|
||||||
|
Definition->GeneratorType,
|
||||||
|
Definition->GenerationParams,
|
||||||
|
Definition->SlabParams,
|
||||||
|
QuerySeed,
|
||||||
|
StrateTopZ,
|
||||||
|
StrateBottomZ,
|
||||||
|
Passage.LowerPoint.X,
|
||||||
|
Passage.LowerPoint.Y,
|
||||||
|
SuggestedZ);
|
||||||
|
|
||||||
|
if (!bCanAnswer)
|
||||||
|
{
|
||||||
|
++NumQueryFalse;
|
||||||
|
FalseArchetypes.Add(static_cast<uint8>(Definition->GeneratorType));
|
||||||
|
|
||||||
|
const bool bExpectedToBeSupported =
|
||||||
|
Definition->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
||||||
|
|| Definition->GeneratorType == ECaveGeneratorType::Underwater
|
||||||
|
|| Definition->GeneratorType == ECaveGeneratorType::FlatPlain
|
||||||
|
|| Definition->GeneratorType == ECaveGeneratorType::CrystalChamber;
|
||||||
|
if (bExpectedToBeSupported)
|
||||||
|
{
|
||||||
|
++NumSupportedWithoutPoint;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++NumUnsupported;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++NumChecked;
|
||||||
|
|
||||||
|
// Control-point endpoints are anchored (vertical wobble has a zero envelope at a mouth),
|
||||||
|
// so the generated endpoint must be exactly the query result.
|
||||||
|
// Les extrémités sont ancrées (la wobble verticale vaut zéro à une bouche) : l'endpoint
|
||||||
|
// généré doit donc être exactement le résultat de la requête.
|
||||||
|
// A mouth's sine envelope is intentionally zero; allow only the tiny float envelope
|
||||||
|
// produced by evaluating that zero at PI, never a meaningful targeting miss.
|
||||||
|
// L'enveloppe de bouche est intentionnellement nulle ; tolérer seulement l'infime erreur
|
||||||
|
// flottante de son évaluation à PI, jamais un échec de visée significatif.
|
||||||
|
if (!FMath::IsNearlyEqual(Passage.LowerPoint.Z, SuggestedZ, 0.01f))
|
||||||
|
{
|
||||||
|
bAllAnswerableEndpointsMatchQuery = false;
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage to strate %d (%s) missed its suggested open Z: endpoint %.9g, query %.9g."),
|
||||||
|
Destination.StrateIndex,
|
||||||
|
*ArchetypeName(Definition->GeneratorType),
|
||||||
|
Passage.LowerPoint.Z,
|
||||||
|
SuggestedZ));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A center sample is VACUOUS: VF_ApplyPassageCarving deliberately makes the mouth air,
|
||||||
|
// even when the destination archetype is solid there. Probe a lateral ring instead.
|
||||||
|
// The ring is Cfg.MouthRadius + two complete PassageBlend bands (4 voxels each): the
|
||||||
|
// first clears the passage's carve/blend reach, and the second is a safety margin beyond
|
||||||
|
// EvaluateModifierSDF's 3-voxel junction smoothing. At this radius the passage itself
|
||||||
|
// cannot supply the air being counted; a nearby destination room/void must do so.
|
||||||
|
// Require 8 of 16 samples (50%): that is a meaningful open circumference for a mouth,
|
||||||
|
// while a mouth in bedrock has zero air once its own tube is outside the ring. This is a
|
||||||
|
// connectivity PROXY, not proof that the mouth is connected to the room: a disconnected
|
||||||
|
// pocket can pass it. A real proof needs a flood fill (Tier 2).
|
||||||
|
// Un échantillon au centre serait VACU : le carve structurel force la bouche à l'air.
|
||||||
|
// L'anneau latéral doit donc trouver de l'air dans la strate de destination elle-même.
|
||||||
|
constexpr int32 RingSampleCount = 16;
|
||||||
|
constexpr int32 MinimumAirSamples = 8;
|
||||||
|
const float RingRadius = PassageConfig.MouthRadius
|
||||||
|
+ 2.0f * VoxelDensityReach::PassageBlend;
|
||||||
|
|
||||||
|
int32 AirSamples = 0;
|
||||||
|
for (int32 RingSample = 0; RingSample < RingSampleCount; ++RingSample)
|
||||||
|
{
|
||||||
|
const float Angle = 2.0f * PI * (float)RingSample / (float)RingSampleCount;
|
||||||
|
const float SampleX = Passage.LowerPoint.X + FMath::Cos(Angle) * RingRadius;
|
||||||
|
const float SampleY = Passage.LowerPoint.Y + FMath::Sin(Angle) * RingRadius;
|
||||||
|
const float Density = World.Generator->GetDensityAt(
|
||||||
|
SampleX,
|
||||||
|
SampleY,
|
||||||
|
Passage.LowerPoint.Z);
|
||||||
|
|
||||||
|
++NumRingSamples;
|
||||||
|
if (Density >= 0.0f)
|
||||||
|
{
|
||||||
|
++AirSamples;
|
||||||
|
++NumRingAirSamples;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AirSamples < MinimumAirSamples)
|
||||||
|
{
|
||||||
|
bAllAnswerableMouthRingsHaveAir = false;
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage to strate %d (%s) has too little destination air around its mouth: %d/%d ring samples at radius %.3f are air; mouth (%.3f, %.3f, %.3f)."),
|
||||||
|
Destination.StrateIndex,
|
||||||
|
*ArchetypeName(Definition->GeneratorType),
|
||||||
|
AirSamples,
|
||||||
|
RingSampleCount,
|
||||||
|
RingRadius,
|
||||||
|
Passage.LowerPoint.X,
|
||||||
|
Passage.LowerPoint.Y,
|
||||||
|
Passage.LowerPoint.Z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Passage open-space check: %d inter-strate passages, %d checked, %d/%d ring samples air, %d query-false (%d unique archetypes; %d unsupported, %d supported-but-no-point)."),
|
||||||
|
NumInterStratePassages,
|
||||||
|
NumChecked,
|
||||||
|
NumRingAirSamples,
|
||||||
|
NumRingSamples,
|
||||||
|
NumQueryFalse,
|
||||||
|
FalseArchetypes.Num(),
|
||||||
|
NumUnsupported,
|
||||||
|
NumSupportedWithoutPoint));
|
||||||
|
|
||||||
|
// A test that inspected nothing is not evidence of the invariant. Fail loudly in both cases.
|
||||||
|
// Un test qui n'a rien inspecté ne prouve pas l'invariant : échouer explicitement dans les deux cas.
|
||||||
|
if (NumInterStratePassages == 0)
|
||||||
|
{
|
||||||
|
AddError(TEXT("VACUOUS: the fixture generated zero inter-strate passages."));
|
||||||
|
}
|
||||||
|
if (NumChecked == 0)
|
||||||
|
{
|
||||||
|
AddError(TEXT("VACUOUS: zero answerable passage mouths were checked; the passage invariant was not exercised."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NumInterStratePassages > 0
|
||||||
|
&& NumChecked > 0
|
||||||
|
&& bAllAnswerableMouthRingsHaveAir
|
||||||
|
&& bAllAnswerableEndpointsMatchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
#include "VoxelCaveMorphology.h"
|
#include "VoxelCaveMorphology.h"
|
||||||
#include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01
|
#include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01
|
||||||
#include "VoxelStrateTypes.h"
|
#include "VoxelStrateTypes.h"
|
||||||
|
#include "VoxelNoise.h" // Pure FBM used by the slab landing query
|
||||||
#include "VoxelTerrainOpDefinition.h"
|
#include "VoxelTerrainOpDefinition.h"
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -87,6 +88,313 @@ static void BakeRoomFeature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Find the nearest hash room's vertical centre without constructing a cache. The search is
|
||||||
|
// deliberately bounded: an empty neighbourhood is an honest "no answer", not a guessed Z.
|
||||||
|
// Cherche le centre vertical de la salle hachée la plus proche sans construire de cache. La
|
||||||
|
// recherche est bornée : un voisinage vide signifie "pas de réponse", jamais un Z inventé.
|
||||||
|
bool VF_FindNearestHashRoomZ(
|
||||||
|
const FStrateGenerationParams& Params,
|
||||||
|
int32 Seed,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float& OutZ)
|
||||||
|
{
|
||||||
|
if (!FMath::IsFinite(StrateTopZ) || !FMath::IsFinite(StrateBottomZ)
|
||||||
|
|| !FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY)
|
||||||
|
|| StrateTopZ <= StrateBottomZ)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float CellSize = Params.RoomSpacing;
|
||||||
|
const float RadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
|
||||||
|
if (!FMath::IsFinite(CellSize) || !FMath::IsFinite(Params.RoomDensity)
|
||||||
|
|| !FMath::IsFinite(Params.MinRoomRadius) || !FMath::IsFinite(Params.MaxRoomRadius)
|
||||||
|
|| !FMath::IsFinite(Params.RoomHeightRatio)
|
||||||
|
|| !FMath::IsFinite(Params.BoundarySealThickness)
|
||||||
|
|| !FMath::IsFinite(Params.MaxTunnelLength)
|
||||||
|
|| CellSize <= 0.0f || Params.RoomDensity <= 0.0f
|
||||||
|
|| Params.MaxTunnelLength <= 0.0f
|
||||||
|
|| RadiusEnvelope <= 0.0f || Params.RoomHeightRatio <= 0.0f
|
||||||
|
|| Params.BoundarySealThickness < 0.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLACEMENT CONTRACT: this envelope MUST remain identical to the room-center placement
|
||||||
|
// code in VoxelCaveMorphology::BuildChunkCache below. The pure query deliberately does
|
||||||
|
// not call that per-chunk cache builder during Initialize, so this is intentionally a
|
||||||
|
// second copy.
|
||||||
|
// VoxelForge.Determinism.PassageLandsInOpenSpace is the fixed ring test that catches drift.
|
||||||
|
// The extra room-height buffer keeps the generated room body away from both seal bands.
|
||||||
|
const float RoomZBuffer = RadiusEnvelope * Params.RoomHeightRatio;
|
||||||
|
const float StrateMinZ = StrateBottomZ + Params.BoundarySealThickness + RoomZBuffer;
|
||||||
|
const float StrateMaxZ = StrateTopZ - Params.BoundarySealThickness - RoomZBuffer;
|
||||||
|
const float StrateRangeZ = StrateMaxZ - StrateMinZ;
|
||||||
|
if (!FMath::IsFinite(StrateRangeZ) || StrateRangeZ <= 0.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A passage is placed within the configured reach from the spine. Start the search a little
|
||||||
|
// farther than that reach, then expand deterministic rings until the nearest candidate is
|
||||||
|
// proven; cap pathological asset values so Initialize cannot become an unbounded grid scan.
|
||||||
|
const float SearchDistance = FMath::Max(CellSize * 2.0f,
|
||||||
|
FMath::Max(Params.MaxTunnelLength, 0.0f));
|
||||||
|
const float SearchCells = SearchDistance / CellSize;
|
||||||
|
const int32 InitialSearchRadius = FMath::Min(
|
||||||
|
32,
|
||||||
|
FMath::Max(2, FMath::CeilToInt(SearchCells) + 1));
|
||||||
|
constexpr int32 MaxSearchRadius = 32;
|
||||||
|
|
||||||
|
const int32 BaseCellX = FMath::FloorToInt(WorldX / CellSize);
|
||||||
|
const int32 BaseCellY = FMath::FloorToInt(WorldY / CellSize);
|
||||||
|
|
||||||
|
float BestDistSq = FLT_MAX;
|
||||||
|
float BestZ = 0.0f;
|
||||||
|
int32 BestCellX = 0;
|
||||||
|
int32 BestCellY = 0;
|
||||||
|
bool bFound = false;
|
||||||
|
|
||||||
|
auto ConsiderCell = [&](int32 CellX, int32 CellY)
|
||||||
|
{
|
||||||
|
// Keep this roll byte-for-byte aligned with BuildChunkCache's room placement.
|
||||||
|
const uint32 CellHash = VoxelHash::Cell(CellX, CellY, (uint32)Seed);
|
||||||
|
if (VoxelHash::ToFloat01(CellHash) >= Params.RoomDensity) return;
|
||||||
|
|
||||||
|
const float JitterX = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x12345678u));
|
||||||
|
const float JitterY = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u));
|
||||||
|
const float JitterZ = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x55AA55AAu));
|
||||||
|
|
||||||
|
const float RoomX = (CellX + 0.15f + JitterX * 0.7f) * CellSize;
|
||||||
|
const float RoomY = (CellY + 0.15f + JitterY * 0.7f) * CellSize;
|
||||||
|
const float RoomZ = StrateMinZ + JitterZ * FMath::Max(StrateRangeZ, 1.0f);
|
||||||
|
const float DX = RoomX - WorldX;
|
||||||
|
const float DY = RoomY - WorldY;
|
||||||
|
const float DistSq = DX * DX + DY * DY;
|
||||||
|
|
||||||
|
const bool bCloser = DistSq < BestDistSq;
|
||||||
|
const bool bTie = DistSq == BestDistSq
|
||||||
|
&& (CellX < BestCellX || (CellX == BestCellX && CellY < BestCellY));
|
||||||
|
if (!bCloser && !bTie) return;
|
||||||
|
|
||||||
|
BestDistSq = DistSq;
|
||||||
|
BestZ = RoomZ;
|
||||||
|
BestCellX = CellX;
|
||||||
|
BestCellY = CellY;
|
||||||
|
bFound = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto ScanRing = [&](int32 Radius)
|
||||||
|
{
|
||||||
|
for (int32 CellY = BaseCellY - Radius; CellY <= BaseCellY + Radius; ++CellY)
|
||||||
|
{
|
||||||
|
for (int32 CellX = BaseCellX - Radius; CellX <= BaseCellX + Radius; ++CellX)
|
||||||
|
{
|
||||||
|
if (FMath::Max(FMath::Abs(CellX - BaseCellX), FMath::Abs(CellY - BaseCellY)) != Radius)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ConsiderCell(CellX, CellY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The nearest possible room centre in any cell outside this square is at least this far
|
||||||
|
// from the query point (the placement jitter is bounded to [0.15, 0.85] cell).
|
||||||
|
auto IsNearestProven = [&](int32 Radius)
|
||||||
|
{
|
||||||
|
if (!bFound) return false;
|
||||||
|
const float OutsideDistance = ((float)Radius + 0.15f) * CellSize;
|
||||||
|
return BestDistSq < OutsideDistance * OutsideDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int32 CellY = BaseCellY - InitialSearchRadius;
|
||||||
|
CellY <= BaseCellY + InitialSearchRadius;
|
||||||
|
++CellY)
|
||||||
|
{
|
||||||
|
for (int32 CellX = BaseCellX - InitialSearchRadius;
|
||||||
|
CellX <= BaseCellX + InitialSearchRadius;
|
||||||
|
++CellX)
|
||||||
|
{
|
||||||
|
ConsiderCell(CellX, CellY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bNearestProven = IsNearestProven(InitialSearchRadius);
|
||||||
|
for (int32 Radius = InitialSearchRadius + 1;
|
||||||
|
Radius <= MaxSearchRadius && !bNearestProven;
|
||||||
|
++Radius)
|
||||||
|
{
|
||||||
|
ScanRing(Radius);
|
||||||
|
bNearestProven = IsNearestProven(Radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bFound || !bNearestProven) return false;
|
||||||
|
|
||||||
|
// The placement envelope above normally makes this automatic. Keep the explicit check so
|
||||||
|
// malformed but finite parameters never turn a boundary value into an open-point claim.
|
||||||
|
const float InnerTop = StrateTopZ - Params.BoundarySealThickness;
|
||||||
|
const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness;
|
||||||
|
if (!FMath::IsFinite(BestZ) || BestZ <= InnerBottom || BestZ >= InnerTop)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutZ = BestZ;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slab source is an XY height band. Evaluate the same two pure height fields as
|
||||||
|
// GetSlabDensity, then choose their midpoint after intersecting the seal-free interior.
|
||||||
|
// La source slab est une bande de hauteurs XY : on recalcule les deux champs purs comme
|
||||||
|
// GetSlabDensity, puis on prend leur milieu après intersection avec l'intérieur sans seal.
|
||||||
|
bool VF_SuggestSlabOpenPointZ(
|
||||||
|
const FSlabGenerationParams& Params,
|
||||||
|
int32 Seed,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float& OutZ)
|
||||||
|
{
|
||||||
|
if (!FMath::IsFinite(StrateTopZ) || !FMath::IsFinite(StrateBottomZ)
|
||||||
|
|| !FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY)
|
||||||
|
|| StrateTopZ <= StrateBottomZ)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FMath::IsFinite(Params.FloorRelativeHeight)
|
||||||
|
|| !FMath::IsFinite(Params.CeilingRelativeHeight)
|
||||||
|
|| !FMath::IsFinite(Params.FloorRoughness)
|
||||||
|
|| !FMath::IsFinite(Params.FloorRoughnessFrequency)
|
||||||
|
|| !FMath::IsFinite(Params.CeilingRoughness)
|
||||||
|
|| !FMath::IsFinite(Params.CeilingRoughnessFrequency)
|
||||||
|
|| !FMath::IsFinite(Params.ColumnDensity)
|
||||||
|
|| !FMath::IsFinite(Params.ColumnMinRadius)
|
||||||
|
|| !FMath::IsFinite(Params.ColumnMaxRadius)
|
||||||
|
|| !FMath::IsFinite(Params.ColumnSpacing)
|
||||||
|
|| !FMath::IsFinite(Params.BoundarySealThickness)
|
||||||
|
|| !FMath::IsFinite(Params.BaseDensity)
|
||||||
|
|| Params.BoundarySealThickness < 0.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float StrateHeight = StrateTopZ - StrateBottomZ;
|
||||||
|
const uint32 SeedU = (uint32)Seed;
|
||||||
|
|
||||||
|
const float FloorZ = StrateBottomZ + StrateHeight * Params.FloorRelativeHeight;
|
||||||
|
float FloorNoise = 0.0f;
|
||||||
|
if (Params.FloorRoughness > 0.0f)
|
||||||
|
{
|
||||||
|
const float FF = Params.FloorRoughnessFrequency;
|
||||||
|
FloorNoise = VoxelNoise::FBM(
|
||||||
|
WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f),
|
||||||
|
WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f),
|
||||||
|
0.0f, 3
|
||||||
|
) * VOXEL_NOISE_SCALE * Params.FloorRoughness;
|
||||||
|
}
|
||||||
|
const float FloorSurface = FloorZ + FloorNoise;
|
||||||
|
|
||||||
|
const float CeilZ = StrateBottomZ + StrateHeight * Params.CeilingRelativeHeight;
|
||||||
|
float CeilNoise = 0.0f;
|
||||||
|
if (Params.CeilingRoughness > 0.0f)
|
||||||
|
{
|
||||||
|
const float CF = Params.CeilingRoughnessFrequency;
|
||||||
|
const float RawNoise = VoxelNoise::FBM(
|
||||||
|
WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f,
|
||||||
|
WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f,
|
||||||
|
3000.0f, 3
|
||||||
|
) * VOXEL_NOISE_SCALE;
|
||||||
|
CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness;
|
||||||
|
}
|
||||||
|
const float CeilSurface = FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f);
|
||||||
|
|
||||||
|
// VF_ApplyBoundarySeal's bands are [Top-Thickness, Top) and
|
||||||
|
// (Bottom, Bottom+Thickness]. The midpoint below stays strictly inside both limits.
|
||||||
|
const float InnerTop = StrateTopZ - Params.BoundarySealThickness;
|
||||||
|
const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness;
|
||||||
|
const float OpenBottom = FMath::Max(FloorSurface, InnerBottom);
|
||||||
|
const float OpenTop = FMath::Min(CeilSurface, InnerTop);
|
||||||
|
if (!FMath::IsFinite(OpenBottom) || !FMath::IsFinite(OpenTop) || OpenTop <= OpenBottom)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slab columns are infinite-height. If one reaches this XY, no Z in the slab void is a
|
||||||
|
// confident pre-passage landing point, so report "unanswerable" instead of guessing.
|
||||||
|
if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f)
|
||||||
|
{
|
||||||
|
const float Spacing = Params.ColumnSpacing;
|
||||||
|
const int32 ColumnCellX = FMath::FloorToInt(WorldX / Spacing);
|
||||||
|
const int32 ColumnCellY = FMath::FloorToInt(WorldY / Spacing);
|
||||||
|
|
||||||
|
for (int32 DY = -1; DY <= 1; ++DY)
|
||||||
|
{
|
||||||
|
for (int32 DX = -1; DX <= 1; ++DX)
|
||||||
|
{
|
||||||
|
const int32 CellX = ColumnCellX + DX;
|
||||||
|
const int32 CellY = ColumnCellY + DY;
|
||||||
|
const uint32 H = VoxelHash::Cell(CellX, CellY, SeedU ^ 0xC01C01u);
|
||||||
|
if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue;
|
||||||
|
|
||||||
|
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u));
|
||||||
|
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u));
|
||||||
|
const float ColumnX = (CellX + 0.15f + JX * 0.7f) * Spacing;
|
||||||
|
const float ColumnY = (CellY + 0.15f + JY * 0.7f) * Spacing;
|
||||||
|
const float Radius = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius,
|
||||||
|
VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu)));
|
||||||
|
const float ColumnDX = WorldX - ColumnX;
|
||||||
|
const float ColumnDY = WorldY - ColumnY;
|
||||||
|
if (FMath::Sqrt(ColumnDX * ColumnDX + ColumnDY * ColumnDY) - Radius < 2.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutZ = (OpenBottom + OpenTop) * 0.5f;
|
||||||
|
return FMath::IsFinite(OutZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VF_SuggestOpenPointZ(
|
||||||
|
ECaveGeneratorType Archetype,
|
||||||
|
const FStrateGenerationParams& CaveParams,
|
||||||
|
const FSlabGenerationParams& SlabParams,
|
||||||
|
int32 Seed,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float& OutZ)
|
||||||
|
{
|
||||||
|
switch (Archetype)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
return VF_FindNearestHashRoomZ(CaveParams, Seed, StrateTopZ, StrateBottomZ,
|
||||||
|
WorldX, WorldY, OutZ);
|
||||||
|
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
return VF_SuggestSlabOpenPointZ(SlabParams, Seed, StrateTopZ, StrateBottomZ,
|
||||||
|
WorldX, WorldY, OutZ);
|
||||||
|
|
||||||
|
default:
|
||||||
|
// No confident source-level answer for Maze, SurfaceWorld, shafts, or islands yet.
|
||||||
|
// Pas de réponse source sûre pour Maze, SurfaceWorld, shafts ou islands pour le moment.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// PHASE 1: BUILD CHUNK CACHE
|
// PHASE 1: BUILD CHUNK CACHE
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -113,7 +421,7 @@ void VoxelCaveMorphology::BuildChunkCache(
|
|||||||
OutCache.Columns.Reset();
|
OutCache.Columns.Reset();
|
||||||
|
|
||||||
// Combine world seed with strate index so each strate gets unique caves
|
// Combine world seed with strate index so each strate gets unique caves
|
||||||
const uint32 StrateSeed = VoxelHash::Mix(Seed ^ (uint32)(StrateIndex * 7919 + 104729));
|
const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex);
|
||||||
|
|
||||||
const float CellSize = Params.RoomSpacing;
|
const float CellSize = Params.RoomSpacing;
|
||||||
if (CellSize <= 0.0f) return;
|
if (CellSize <= 0.0f) return;
|
||||||
@@ -167,6 +475,11 @@ void VoxelCaveMorphology::BuildChunkCache(
|
|||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// Vertical range for room CENTER placement.
|
// Vertical range for room CENTER placement.
|
||||||
|
// PLACEMENT CONTRACT: this formula MUST remain identical to
|
||||||
|
// VF_FindNearestHashRoomZ above. The pure landing query deliberately does not call this
|
||||||
|
// per-chunk cache builder during Initialize, so the fixed
|
||||||
|
// VoxelForge.Determinism.PassageLandsInOpenSpace ring test is the guard that catches drift
|
||||||
|
// between the two sites.
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// Buffer = seal thickness + max room half-height.
|
// Buffer = seal thickness + max room half-height.
|
||||||
// This guarantees the tallest possible room (RoomRadiusEnvelope * RoomHeightRatio)
|
// This guarantees the tallest possible room (RoomRadiusEnvelope * RoomHeightRatio)
|
||||||
|
|||||||
@@ -266,6 +266,19 @@ void UVoxelStrateManager::GeneratePassages()
|
|||||||
constexpr uint32 PassageSaltPhase = 0xA1100009u;
|
constexpr uint32 PassageSaltPhase = 0xA1100009u;
|
||||||
constexpr uint32 PassageSaltBendFreq = 0xA110000Au;
|
constexpr uint32 PassageSaltBendFreq = 0xA110000Au;
|
||||||
|
|
||||||
|
int32 TotalPassages = 0;
|
||||||
|
int32 NumAimedAtOpenPoint = 0;
|
||||||
|
TSet<FString> NoQueryArchetypes;
|
||||||
|
|
||||||
|
const auto ArchetypeName = [](ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
if (const UEnum* ArchetypeEnum = StaticEnum<ECaveGeneratorType>())
|
||||||
|
{
|
||||||
|
return ArchetypeEnum->GetNameStringByValue(static_cast<int64>(Archetype));
|
||||||
|
}
|
||||||
|
return FString::Printf(TEXT("Value_%d"), static_cast<int32>(Archetype));
|
||||||
|
};
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// INTER-STRATE PASSAGES: tunnels connecting consecutive strates.
|
// INTER-STRATE PASSAGES: tunnels connecting consecutive strates.
|
||||||
// Each passage is randomly assigned one of 5 types, which determines
|
// Each passage is randomly assigned one of 5 types, which determines
|
||||||
@@ -317,13 +330,92 @@ void UVoxelStrateManager::GeneratePassages()
|
|||||||
const float PX = FMath::Cos(Angle) * Distance;
|
const float PX = FMath::Cos(Angle) * Distance;
|
||||||
const float PY = FMath::Sin(Angle) * Distance;
|
const float PY = FMath::Sin(Angle) * Distance;
|
||||||
|
|
||||||
|
++TotalPassages;
|
||||||
|
|
||||||
|
// Ask the destination archetype for a source-level open Z at this XY. This is pure
|
||||||
|
// and safe during Initialize: it does not construct an operator stack or call back
|
||||||
|
// into the manager. Cave room graphs use the same strate seed as BuildChunkCache;
|
||||||
|
// slab fields use the world seed consumed by GetSlabDensity.
|
||||||
|
float SuggestedLowerZ = 0.0f;
|
||||||
|
bool bAimedAtOpenPoint = false;
|
||||||
|
const UVoxelStrateDefinition* LowerDef = Lower.Definition;
|
||||||
|
if (LowerDef)
|
||||||
|
{
|
||||||
|
const bool bUsesRoomSeed =
|
||||||
|
LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
||||||
|
|| LowerDef->GeneratorType == ECaveGeneratorType::Underwater;
|
||||||
|
const int32 LowerQuerySeed = bUsesRoomSeed
|
||||||
|
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
||||||
|
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
|
||||||
|
: CachedSeed;
|
||||||
|
|
||||||
|
bAimedAtOpenPoint = VF_SuggestOpenPointZ(
|
||||||
|
LowerDef->GeneratorType,
|
||||||
|
LowerDef->GenerationParams,
|
||||||
|
LowerDef->SlabParams,
|
||||||
|
LowerQuerySeed,
|
||||||
|
LowerTopZ,
|
||||||
|
(float)(Lower.BottomChunkZ) * CHUNK_SIZE,
|
||||||
|
PX,
|
||||||
|
PY,
|
||||||
|
SuggestedLowerZ);
|
||||||
|
|
||||||
|
if (bAimedAtOpenPoint)
|
||||||
|
{
|
||||||
|
++NumAimedAtOpenPoint;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NoQueryArchetypes.Add(ArchetypeName(LowerDef->GeneratorType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LENGTH: reach into each strate, capped to the interior.
|
// LENGTH: reach into each strate, capped to the interior.
|
||||||
const float UpperReach = FMath::Min(
|
const float UpperReach = FMath::Min(
|
||||||
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax);
|
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax);
|
||||||
const float LowerReach = FMath::Min(
|
const float LowerReach = FMath::Min(
|
||||||
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax);
|
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax);
|
||||||
const float TopZ = UpperBottomZ + UpperReach;
|
const float TopZ = UpperBottomZ + UpperReach;
|
||||||
const float BottomZ = LowerTopZ - LowerReach;
|
float BottomZ = LowerTopZ - LowerReach;
|
||||||
|
|
||||||
|
if (bAimedAtOpenPoint)
|
||||||
|
{
|
||||||
|
float LowerSealThickness = 0.0f;
|
||||||
|
switch (LowerDef->GeneratorType)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
LowerSealThickness = LowerDef->GenerationParams.BoundarySealThickness;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
LowerSealThickness = LowerDef->SlabParams.BoundarySealThickness;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float LowerBottomZ = (float)(Lower.BottomChunkZ) * CHUNK_SIZE;
|
||||||
|
const float InnerBottomZ = LowerBottomZ + LowerSealThickness;
|
||||||
|
const float InnerTopZ = LowerTopZ - LowerSealThickness;
|
||||||
|
if (FMath::IsFinite(SuggestedLowerZ)
|
||||||
|
&& FMath::IsFinite(InnerBottomZ)
|
||||||
|
&& FMath::IsFinite(InnerTopZ)
|
||||||
|
&& InnerBottomZ < InnerTopZ)
|
||||||
|
{
|
||||||
|
// VF_SuggestOpenPointZ already guarantees a strict interior answer. Keep a
|
||||||
|
// tiny margin in the final clamp so a future source query cannot land on a
|
||||||
|
// seal boundary through rounding.
|
||||||
|
const float StrictMargin = FMath::Min(
|
||||||
|
KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f);
|
||||||
|
BottomZ = FMath::Clamp(
|
||||||
|
SuggestedLowerZ,
|
||||||
|
InnerBottomZ + StrictMargin,
|
||||||
|
InnerTopZ - StrictMargin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48);
|
const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48);
|
||||||
Passage.ControlPoints.Reset();
|
Passage.ControlPoints.Reset();
|
||||||
@@ -422,6 +514,25 @@ void UVoxelStrateManager::GeneratePassages()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TArray<FString> SortedNoQueryArchetypes;
|
||||||
|
for (const FString& Archetype : NoQueryArchetypes)
|
||||||
|
{
|
||||||
|
SortedNoQueryArchetypes.Add(Archetype);
|
||||||
|
}
|
||||||
|
SortedNoQueryArchetypes.Sort();
|
||||||
|
FString NoQueryList = TEXT("none");
|
||||||
|
if (SortedNoQueryArchetypes.Num() > 0)
|
||||||
|
{
|
||||||
|
NoQueryList = FString::Join(SortedNoQueryArchetypes, TEXT(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
UE_LOG(LogTemp, Log,
|
||||||
|
TEXT("[StrateManager] Passage landing sites: %d/%d aimed at generator-confirmed open space, %d fell back to random reach (archetypes with no query: %s)."),
|
||||||
|
NumAimedAtOpenPoint,
|
||||||
|
TotalPassages,
|
||||||
|
TotalPassages - NumAimedAtOpenPoint,
|
||||||
|
*NoQueryList);
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection.
|
// SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection.
|
||||||
// A straight vertical shaft at (0,0) piercing the TOP seal of the topmost
|
// A straight vertical shaft at (0,0) piercing the TOP seal of the topmost
|
||||||
|
|||||||
@@ -33,8 +33,10 @@
|
|||||||
|
|
||||||
// Forward declarations
|
// Forward declarations
|
||||||
struct FStrateGenerationParams;
|
struct FStrateGenerationParams;
|
||||||
|
struct FSlabGenerationParams;
|
||||||
struct FStrateTerrainOpEntry;
|
struct FStrateTerrainOpEntry;
|
||||||
class UVoxelTerrainOpDefinition;
|
class UVoxelTerrainOpDefinition;
|
||||||
|
enum class ECaveGeneratorType : uint8;
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// SDF PRIMITIVES
|
// SDF PRIMITIVES
|
||||||
@@ -468,6 +470,15 @@ struct FChunkSDFCache
|
|||||||
|
|
||||||
namespace VoxelCaveMorphology
|
namespace VoxelCaveMorphology
|
||||||
{
|
{
|
||||||
|
// The room graph's seed is the world seed salted by the strate index. Keep this formula
|
||||||
|
// shared by BuildChunkCache and landing-site queries so a query cannot inspect another
|
||||||
|
// strate's room layout. / La seed du graphe des salles est la seed monde salée par l'index
|
||||||
|
// de strate. La formule est partagée entre BuildChunkCache et les requêtes d'atterrissage.
|
||||||
|
FORCEINLINE uint32 MakeStrateSeed(uint32 WorldSeed, int32 StrateIndex)
|
||||||
|
{
|
||||||
|
return VoxelHash::Mix(WorldSeed ^ (uint32)(StrateIndex * 7919 + 104729));
|
||||||
|
}
|
||||||
|
|
||||||
// PHASE 1: Build the SDF cache for a rectangular region.
|
// PHASE 1: Build the SDF cache for a rectangular region.
|
||||||
// Collects all rooms, computes nearest-neighbor backbone, decides tunnel
|
// Collects all rooms, computes nearest-neighbor backbone, decides tunnel
|
||||||
// connections, and pre-computes all tunnel geometry (radii, offsets, midpoints).
|
// connections, and pre-computes all tunnel geometry (radii, offsets, midpoints).
|
||||||
@@ -523,3 +534,30 @@ namespace VoxelCaveMorphology
|
|||||||
uint32 Seed, int32 StrateIndex
|
uint32 Seed, int32 StrateIndex
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest a deterministic open-space Z for an inter-strate passage mouth.
|
||||||
|
*
|
||||||
|
* This is deliberately a pure source query: it does not touch a generator, an operator stack,
|
||||||
|
* a strate manager, a cache, or mutable state. For TunnelNetwork/Underwater, Seed must be the
|
||||||
|
* strate-specific room seed returned by VoxelCaveMorphology::MakeStrateSeed; for slab archetypes,
|
||||||
|
* Seed is the generator world seed used by GetSlabDensity.
|
||||||
|
*
|
||||||
|
* Propose un Z d'espace ouvert déterministe pour la bouche d'un passage inter-strates. Cette
|
||||||
|
* requête ne touche ni générateur, ni pile d'opérateurs, ni StrateManager, ni cache, ni état
|
||||||
|
* mutable. Pour TunnelNetwork/Underwater, Seed est la seed de salles de la strate; pour les
|
||||||
|
* archétypes slab, c'est la seed monde utilisée par GetSlabDensity.
|
||||||
|
*
|
||||||
|
* @return true when the archetype can provide a confident point; false when it cannot.
|
||||||
|
*/
|
||||||
|
VOXELFORGE_API bool VF_SuggestOpenPointZ(
|
||||||
|
ECaveGeneratorType Archetype,
|
||||||
|
const FStrateGenerationParams& CaveParams,
|
||||||
|
const FSlabGenerationParams& SlabParams,
|
||||||
|
int32 Seed,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float& OutZ
|
||||||
|
);
|
||||||
Reference in new issue
Block a user