This commit is contained in:
Fr0zka committed 2026-06-16 03:39:22 +02:00
1 parent f030eec08a
commit db558d9e14
18 files changed
+1570 -264

No files matched your search

+113 -39
View File
@@ -94,14 +94,10 @@ void VoxelCaveMorphology::BuildChunkCache(
const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f);
//=========================================================================
// STORE region — what we keep for the per-voxel loop.
// STORE decision — what we keep for the per-voxel loop.
//=========================================================================
// A room/tunnel whose influence overlaps the search box is RELEVANT to this
// chunk. STORE box = search box + MaxInfluence. (Per-voxel culling refines this.)
const float StoreMinX = SearchMinX - MaxInfluence;
const float StoreMinY = SearchMinY - MaxInfluence;
const float StoreMaxX = SearchMaxX + MaxInfluence;
const float StoreMaxY = SearchMaxY + MaxInfluence;
// A room/tunnel is stored iff its OWN influence sphere can overlap the search box
// (RoomReachesSearchBox / tunnel bounding spheres below). Per-voxel culling refines.
//=========================================================================
// COLLECT region — what we hash into existence for the connectivity decision.
@@ -142,13 +138,19 @@ void VoxelCaveMorphology::BuildChunkCache(
const float BlendK = Params.SDFBlendRadius;
// Conservative "this room can reach the STORE box" test. A room's cull sphere
// radius is <= MaxInfluence, and STORE box already includes a MaxInfluence
// margin, so testing the center against the STORE box is a correct superset.
auto CenterInStoreBox = [&](const FVector& C) -> bool
// "This room can reach the search box" test, using the room's OWN reach — the same
// extent formula as its per-voxel cull sphere (1.5x radius for capsule-stretched
// variants + blend margin). The old test compared the center against a box inflated
// by the SHARED MaxInfluence, which silently assumed every room's reach <= MaxInfluence.
// That's FALSE for the origin room (OriginRoomRadius >> MaxRoomRadius) — chunks inside
// the big room but > MaxInfluence from (0,0) didn't store it, so its carve clipped at an
// arbitrary chunk-aligned radius — and slightly false even for hash rooms (1.5x stretch).
auto RoomReachesSearchBox = [&](const FVector& C, float RadiusXY, float RadiusZ) -> bool
{
return C.X >= StoreMinX && C.X <= StoreMaxX
&& C.Y >= StoreMinY && C.Y <= StoreMaxY;
const float Reach = FMath::Max(RadiusXY * 1.5f, RadiusZ) + BlendK * 3.0f;
const float dx = FMath::Max3((float)(SearchMinX - C.X), 0.0f, (float)(C.X - SearchMaxX));
const float dy = FMath::Max3((float)(SearchMinY - C.Y), 0.0f, (float)(C.Y - SearchMaxY));
return (dx * dx + dy * dy) <= Reach * Reach;
};
// Sphere-vs-search-box test in XY (treated as infinite in Z; per-voxel culling
@@ -170,8 +172,14 @@ void VoxelCaveMorphology::BuildChunkCache(
int32 OriginIdx = -1;
if (Params.OriginRoomRadius > 0.0f)
{
if (0.0f >= CollectMinX && 0.0f <= CollectMaxX &&
0.0f >= CollectMinY && 0.0f <= CollectMaxY)
// Collected when (0,0) is in the COLLECT region (connectivity) OR when the room's
// own body can reach this chunk (store) — its radius may exceed the collect margin.
const bool bOriginInCollect =
0.0f >= CollectMinX && 0.0f <= CollectMaxX &&
0.0f >= CollectMinY && 0.0f <= CollectMaxY;
const float OriginRZ = Params.OriginRoomRadius * Params.RoomHeightRatio;
if (bOriginInCollect ||
RoomReachesSearchBox(FVector(0.0f, 0.0f, StrateCenterZ), Params.OriginRoomRadius, OriginRZ))
{
FBuildRoom OriginRoom;
OriginRoom.CellX = INT32_MAX; // Sentinel — never matches a real grid cell
@@ -181,7 +189,7 @@ void VoxelCaveMorphology::BuildChunkCache(
OriginRoom.RadiusXY = Params.OriginRoomRadius;
OriginRoom.RadiusZ = Params.OriginRoomRadius * Params.RoomHeightRatio;
OriginRoom.bIsOrigin = true;
OriginRoom.bStore = CenterInStoreBox(OriginRoom.Center);
OriginRoom.bStore = RoomReachesSearchBox(OriginRoom.Center, OriginRoom.RadiusXY, OriginRoom.RadiusZ);
OriginIdx = BuildRooms.Add(OriginRoom);
}
}
@@ -216,7 +224,7 @@ void VoxelCaveMorphology::BuildChunkCache(
Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor);
Room.RadiusZ = Room.RadiusXY * Params.RoomHeightRatio;
Room.bIsOrigin = false;
Room.bStore = CenterInStoreBox(Room.Center);
Room.bStore = RoomReachesSearchBox(Room.Center, Room.RadiusXY, Room.RadiusZ);
BuildRooms.Add(Room);
}
@@ -226,35 +234,70 @@ void VoxelCaveMorphology::BuildChunkCache(
if (NumRooms == 0) return;
//=========================================================================
// Window-invariant nearest-neighbor backbone
// Window-invariant guaranteed backbone
//=========================================================================
// Each room's nearest neighbor (among rooms within MaxTunnelLength) is a
// GUARANTEED tunnel connection. Filtering candidates to <= MaxTunnelLength is
// what makes the result identical across chunks: rooms beyond MaxTunnelLength
// can never be a tunnel anyway, so excluding them removes the only source of
// window dependence. This builds a connected tree backbone; TunnelDensity adds
// loops on top.
// Each room gets ONE guaranteed link, chosen among candidates within MaxTunnelLength
// (that reach filter is what keeps the decision identical across chunk windows).
//
// bTunnelsFlowTowardOrigin = true (default): the link target is the best candidate
// among rooms STRICTLY CLOSER to (0,0) in XY. Every chain of links then descends in
// origin-distance and terminates at the origin room → the network is a TREE ROOTED AT
// THE SPINE HUB: every room is reachable, tunnels flow inward like tributaries.
// (Frontier rooms with no closer candidate in reach fall back to plain NN — a far
// cluster stays internally chained even when it can't bridge to the origin side.)
//
// bTunnelsFlowTowardOrigin = false (legacy): plain nearest-neighbor pairing. NOTE:
// despite what this comment used to claim, an NN-graph is a FOREST of small clusters,
// not a connected tree — isolated cave pockets are expected in this mode.
//
// Selection metric (not the reach filter) penalizes vertical separation via
// TunnelHorizontalBias, so the GUARANTEED links also prefer walkable slopes —
// previously only the random TunnelDensity extras were biased, which is why
// backbone tunnels could come out absurdly steep.
TArray<int32, TInlineAllocator<64>> NearestNeighbor;
NearestNeighbor.SetNumUninitialized(NumRooms);
const float MaxTunnelLenSq = MaxTunnelLen * MaxTunnelLen;
const bool bFlowToOrigin = Params.bTunnelsFlowTowardOrigin;
auto LinkMetric = [&](int32 I, int32 J) -> float
{
const float D = FVector::Dist(BuildRooms[I].Center, BuildRooms[J].Center);
const float VertSep = FMath::Abs(BuildRooms[I].Center.Z - BuildRooms[J].Center.Z);
return D + VertSep * Params.TunnelHorizontalBias * 5.0f;
};
// Squared XY distance to the (0,0) spine — the "inward" ordering. Purely positional,
// so it is window-invariant by construction.
auto OriginKeySq = [&](int32 I) -> float
{
const FVector& C = BuildRooms[I].Center;
return C.X * C.X + C.Y * C.Y;
};
// ExcludeJ: used by the origin-cap redirect below (re-pick ignoring the origin room).
auto PickNeighbor = [&](int32 I, int32 ExcludeJ) -> int32
{
const float MyKeySq = OriginKeySq(I);
float BestInward = FLT_MAX; int32 BestInwardJ = -1;
float BestAny = FLT_MAX; int32 BestAnyJ = -1;
for (int32 J = 0; J < NumRooms; J++)
{
if (J == I || J == ExcludeJ) continue;
const float DSq = FVector::DistSquared(BuildRooms[I].Center, BuildRooms[J].Center);
if (DSq > MaxTunnelLenSq) continue; // out of reach — never a tunnel
const float M = LinkMetric(I, J);
if (M < BestAny) { BestAny = M; BestAnyJ = J; }
if (bFlowToOrigin && OriginKeySq(J) < MyKeySq && M < BestInward)
{
BestInward = M; BestInwardJ = J;
}
}
return (bFlowToOrigin && BestInwardJ != -1) ? BestInwardJ : BestAnyJ;
};
for (int32 I = 0; I < NumRooms; I++)
{
float BestDistSq = FLT_MAX;
int32 BestJ = -1;
for (int32 J = 0; J < NumRooms; J++)
{
if (I == J) continue;
const float DSq = FVector::DistSquared(BuildRooms[I].Center, BuildRooms[J].Center);
if (DSq > MaxTunnelLenSq) continue; // out of reach — never a tunnel
if (DSq < BestDistSq)
{
BestDistSq = DSq;
BestJ = J;
}
}
NearestNeighbor[I] = BestJ;
NearestNeighbor[I] = PickNeighbor(I, /*ExcludeJ=*/INDEX_NONE);
}
//=========================================================================
@@ -293,6 +336,19 @@ void VoxelCaveMorphology::BuildChunkCache(
{
OriginDowngraded.Add(OriginLinks[R].Value);
}
// REDIRECT, don't strand: a downgraded room whose guaranteed link pointed at the
// origin re-picks its best target EXCLUDING origin. It keeps a guaranteed link
// (chains to the hub through another room instead of directly), which matters
// doubly now that rooms with zero connections are culled below. Deterministic:
// same candidate set, same metric, one exclusion.
for (int32 DowngradedI : OriginDowngraded)
{
if (NearestNeighbor[DowngradedI] == OriginIdx)
{
NearestNeighbor[DowngradedI] = PickNeighbor(DowngradedI, /*ExcludeJ=*/OriginIdx);
}
}
}
//=========================================================================
@@ -304,6 +360,15 @@ void VoxelCaveMorphology::BuildChunkCache(
// 2. The pair hash passes TunnelDensity (random extra loops).
// Both must pass the distance check (MaxTunnelLength). Only tunnels whose bounding
// sphere reaches the search box are stored for the per-voxel loop.
// Tracks whether each room ends up with at least one tunnel — DECIDED connections,
// independent of whether the tunnel itself is stored for this chunk (a room near the
// window edge may have all its tunnels outside the box; it's still "connected").
// Stored rooms with zero connections are culled at emission: they'd be sealed air
// pockets no tunnel ever reaches. Window-invariant: a stored room's full candidate
// set (and each candidate's own candidates) lies inside the COLLECT region.
TArray<bool, TInlineAllocator<64>> RoomConnected;
RoomConnected.Init(false, NumRooms);
for (int32 I = 0; I < NumRooms; I++)
{
for (int32 J = I + 1; J < NumRooms; J++)
@@ -347,6 +412,10 @@ void VoxelCaveMorphology::BuildChunkCache(
if (ConnectChance >= Params.TunnelDensity) continue;
}
// Connection DECIDED (backbone or density roll) — both rooms are reachable.
RoomConnected[I] = true;
RoomConnected[J] = true;
// --- TUNNEL HASH (for deriving all tunnel properties) ---
const uint32 TunnelHash = VoxelHash::Pair(
RoomA.CellX, RoomA.CellY,
@@ -451,10 +520,15 @@ void VoxelCaveMorphology::BuildChunkCache(
OutCache.Rooms.Reserve(NumRooms);
for (const FBuildRoom& BR : BuildRooms)
for (int32 RoomIdx = 0; RoomIdx < NumRooms; RoomIdx++)
{
const FBuildRoom& BR = BuildRooms[RoomIdx];
if (!BR.bStore) continue; // Far room — collected for connectivity only
// Sealed-bubble cull: a room no tunnel ever reaches would be an isolated air
// pocket — don't carve it at all. The origin room is always kept (spine hub).
if (!RoomConnected[RoomIdx] && !BR.bIsOrigin) continue;
FCachedRoom CR;
CR.Center = BR.Center;
CR.RadiusXY = BR.RadiusXY;