potential perf regression
This commit is contained in:
+13
-16
@@ -92,17 +92,17 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
|
|||||||
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
|
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
|
||||||
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
|
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
|
||||||
|
|
||||||
### 3.3 Chunk identity — `Public/VoxelChunk.h`
|
### 3.3 Chunk identity
|
||||||
`FVoxelChunk` (USTRUCT, line 19): just a `ChunkCoord` + `GetWorldPosition()`. In a
|
`VoxelChunk.h` (the old `FVoxelChunk` coord wrapper) was DELETED — dead since the tile
|
||||||
density-only world the chunk stores no voxels — it's a coord wrapper. Room to cache
|
redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
||||||
per-chunk info later.
|
|
||||||
|
|
||||||
### 3.4 Settings — `Public/VoxelSettings.h`
|
### 3.4 Settings — `Public/VoxelSettings.h`
|
||||||
`UVoxelSettings : UPrimaryDataAsset` — the single tuning asset assigned on `AVoxelWorld`.
|
`UVoxelSettings : UPrimaryDataAsset` — the single tuning asset assigned on `AVoxelWorld`.
|
||||||
| Group | Fields (line) |
|
| Group | Fields (line) |
|
||||||
|-------|---------------|
|
|-------|---------------|
|
||||||
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) |
|
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) |
|
||||||
| LOD | `LOD0Distance=4`, `LOD1Distance=8` |
|
| Clipmap | `ClipRadius`, `MaxClipLevel`, `FullResClipLevels`, `CoarseTileCells`, skirts (the old `LOD0/1Distance` + `ContentMaxLevel` were dead → removed) |
|
||||||
|
| Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
|
||||||
| Rendering | `VoxelMaterial` (61) |
|
| Rendering | `VoxelMaterial` (61) |
|
||||||
| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) |
|
| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) |
|
||||||
| Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. |
|
| Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. |
|
||||||
@@ -125,9 +125,7 @@ per-chunk info later.
|
|||||||
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
|
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
|
||||||
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. |
|
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. |
|
||||||
| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. |
|
| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. |
|
||||||
| `GetPlayerPosition` | 231 | Pawn position or zero. |
|
| `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) |
|
||||||
| `GetLODForChunk` / `LODToStep` | 242 / 268 | Distance→LOD (0/1/2) → step (1/2/4). |
|
|
||||||
| `IsChunkInRange` | 275 | View-distance test. |
|
|
||||||
| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; **discards stale epochs**; applies meshes. |
|
| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; **discards stale epochs**; applies meshes. |
|
||||||
| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. |
|
| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. |
|
||||||
| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. |
|
| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. |
|
||||||
@@ -257,14 +255,12 @@ atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to ve
|
|||||||
| `GetTotalModificationCount` / `GetModifiedChunkCount` | 182 / 192 | Stats. |
|
| `GetTotalModificationCount` / `GetModifiedChunkCount` | 182 / 192 | Stats. |
|
||||||
|
|
||||||
### 3.10 Mesher — `Public/VoxelMarchingCubesMesher.h` + `.cpp`
|
### 3.10 Mesher — `Public/VoxelMarchingCubesMesher.h` + `.cpp`
|
||||||
`UVoxelMarchingCubesMesher : UObject` (h:21). Holds `Generator` ptr, `IsoLevel=0`,
|
`UVoxelMarchingCubesMesher : UObject` (h:21). Holds `Generator` ptr, `IsoLevel=0`, skirt params.
|
||||||
`GradientOffset=1`.
|
(The dead trio `GetDensity`/`InterpolateEdge`/`ComputeGradientNormal` + `GradientOffset` was
|
||||||
|
removed — since T1.b the pre-sampled grid supplies positions AND gradients inline.)
|
||||||
| Method | .cpp line | Role |
|
| Method | .cpp line | Role |
|
||||||
|--------|-----------|------|
|
|--------|-----------|------|
|
||||||
| `GetDensity` | 11 | Local coord → world → `Generator->GetDensityAt`. |
|
| **`GenerateMesh`** | ~15 | The MC loop over cells; `Step` controls LOD sampling. Edge `t` + grid-gradient normals computed inline (`SampleG`/`GradAt`). Optional `OutCaptureGrid` (4th arg) = CAPTURE-DURING-MESHING: when non-null + full-res (`CellsPerAxis==CHUNK_SIZE`), copies the already-sampled `CHUNK_SIZE³` density grid (quantized via `VF_QuantizeDensity`, VoxelTypes.h) so the density clipmap reuses it instead of re-sampling `GetDensityAt`. Pure read of the grid — §8.10 untouched. |
|
||||||
| `InterpolateEdge` | 28 | Linear edge crossing between two corner densities. |
|
|
||||||
| `ComputeGradientNormal` | 48 | Central-difference gradient → smooth normal. |
|
|
||||||
| **`GenerateMesh`** | 75 | The MC loop over cells; `Step` controls LOD sampling. |
|
|
||||||
|
|
||||||
**`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul
|
**`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul
|
||||||
Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing.
|
Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing.
|
||||||
@@ -272,8 +268,9 @@ Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs ed
|
|||||||
### 3.11 Per-chunk content & per-strate atmosphere (2026 redesign — see §8)
|
### 3.11 Per-chunk content & per-strate atmosphere (2026 redesign — see §8)
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — distance-based world-grid decoration scatter (no LOD pop, surface-snapped via `GetDensityAt`) + level-0 water planes. Owned by `AVoxelWorld`. §8.5. |
|
| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — distance-based world-grid decoration scatter (no LOD pop, surface-snapped via `GetDensityAt`) + level-0 water planes. **TWO streaming grids** (`FDecoGrid` Near/Far, picked per entry via `FStrateDecoration::StreamTier`): NearGrid = short radius + fine column grid (groundcover); FarGrid = full radius + coarse grid (cheap rare/large props). **Plus `UpdateLandmarks`** — rare far-visible objects (the "mini-suns") on a coarse HASH LATTICE (`FStrateLandmark`, cell = SpacingChunks chunks → cheap at any radius, no per-chunk freeze); synchronous, deterministic, strate-wide. Owned by `AVoxelWorld`. §8.5. |
|
||||||
| `Public/Private/VoxelAtmosphereManager.h/.cpp` | `UVoxelAtmosphereManager` — per-strate fog/skylight + persistent ceiling/floor layer actors + full `AtmosphereActor` override. Owned by `AVoxelWorld`. §8.6. |
|
| `Public/Private/VoxelAtmosphereManager.h/.cpp` | `UVoxelAtmosphereManager` — per-strate fog/skylight + persistent ceiling/floor layer actors + full `AtmosphereActor` override. Owned by `AVoxelWorld`. §8.6. |
|
||||||
|
| `Public/Private/VoxelDensityVolume.h/.cpp` | `UVoxelDensityVolume` — player-centred DENSITY CLIPMAP (N toroidal R8 levels, fine near / coarse far) streamed to GPU `UVolumeTexture`s for the mini-sun raymarched shadow march. Fills run on ONE dedicated thread (`FVoxelDensityFillRunnable`, off the task pool); level 0 is mostly fed by CAPTURE-DURING-MESHING (mesher grid reuse, gated by `IsTileCaptureUseful` so only tiles near the shadow window pay the capture). Carve → `MarkDirtyVoxelBox` refills locally. `VolumeEpoch` drops stale fills. Owned by `AVoxelWorld` (`bEnableDensityVolume`); shader params pushed via shared per-base-material MIDs (`AVoxelWorld::UpdateTerrainMaterialParams`, change-detected). |
|
||||||
|
|
||||||
> The big 2026 redesign (8 archetypes, (0,0) spine, inter-strate gap, per-strate passages,
|
> The big 2026 redesign (8 archetypes, (0,0) spine, inter-strate gap, per-strate passages,
|
||||||
> disturbances, content/atmosphere, brush shapes, perf invariants) is documented in **§8** —
|
> disturbances, content/atmosphere, brush shapes, perf invariants) is documented in **§8** —
|
||||||
@@ -334,7 +331,7 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
|||||||
| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (StrateTypes.h:844). |
|
| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (StrateTypes.h:844). |
|
||||||
| Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). |
|
| Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). |
|
||||||
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
|
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
|
||||||
| Mesh smoothness / normals | `UVoxelMarchingCubesMesher::ComputeGradientNormal` :48, `IsoLevel`/`GradientOffset` (h:51/55). |
|
| Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). |
|
||||||
| New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). |
|
| New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). |
|
||||||
| Biome placement / layout | `BiomeMapParams` on the strate (cell size, warp, climate freqs) + each biome's climate box. Bake `AVoxelWorld::BakeBiomePreview` to tune. §8.14. |
|
| Biome placement / layout | `BiomeMapParams` on the strate (cell size, warp, climate freqs) + each biome's climate box. Bake `AVoxelWorld::BakeBiomePreview` to tune. §8.14. |
|
||||||
| What a biome does to terrain | A full archetype param override on the biome (`bOverrideTerrain` + `SurfaceParams`); surface output-blends dominant/neighbour heights in `GetSurfaceDensity`. Caves = content/atmosphere only (determinism, §8.14). |
|
| What a biome does to terrain | A full archetype param override on the biome (`bOverrideTerrain` + `SurfaceParams`); surface output-blends dominant/neighbour heights in `GetSurfaceDensity`. Caves = content/atmosphere only (determinism, §8.14). |
|
||||||
|
|||||||
@@ -70,12 +70,23 @@ void UVoxelContentManager::NotifyShutdown()
|
|||||||
FPlatformProcess::Yield();
|
FPlatformProcess::Yield();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DrainDecoResults();
|
||||||
|
ResetGridBuildState(NearGrid);
|
||||||
|
ResetGridBuildState(FarGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::DrainDecoResults()
|
||||||
|
{
|
||||||
FDecoCellResult Discard;
|
FDecoCellResult Discard;
|
||||||
while (DecoResults.Dequeue(Discard)) {}
|
while (DecoResults.Dequeue(Discard)) {}
|
||||||
RegionBuilds.Reset();
|
}
|
||||||
CompletedRegions.Reset();
|
|
||||||
PendingLaunch.Reset();
|
void UVoxelContentManager::ResetGridBuildState(FDecoGrid& G)
|
||||||
InFlightCells.Reset();
|
{
|
||||||
|
G.Builds.Reset();
|
||||||
|
G.Completed.Reset();
|
||||||
|
G.PendingLaunch.Reset();
|
||||||
|
G.InFlightCells.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -175,12 +186,28 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
|||||||
// Strate biome field (XY-global → resolved once; the worker picks the dominant biome per COLUMN).
|
// Strate biome field (XY-global → resolved once; the worker picks the dominant biome per COLUMN).
|
||||||
CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
|
CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
|
||||||
|
|
||||||
// Build the decoration palette ONCE for this update. With biomes, concatenate every biome's deco list
|
// Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read
|
||||||
// and tag each entry with its context-biome index; the worker resolves a column's biome and rolls only
|
// every frame so live edits to the data asset take effect; the grids themselves persist across updates.
|
||||||
// the entries it owns → borders follow the warped-Voronoi field, not the 8 m cell grid (Task 1, §8.5).
|
NearGrid.Tier = EDecoStreamTier::Near;
|
||||||
// Without biomes, fall back to the strate's single list tagged -1 (always matches → legacy behaviour).
|
NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
|
||||||
CurrentEntries.Reset();
|
NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
||||||
CurrentEntryBiome.Reset();
|
FarGrid.Tier = EDecoStreamTier::Far;
|
||||||
|
FarGrid.Radius = FMath::Max(1, Settings->DecorationRadiusChunks);
|
||||||
|
FarGrid.Spacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
|
||||||
|
|
||||||
|
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every
|
||||||
|
// biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome
|
||||||
|
// and rolls only the entries it owns → borders follow the warped-Voronoi field, not the 8 m cell grid
|
||||||
|
// (Task 1, §8.5). Without biomes, fall back to the strate's single list tagged -1 (always matches). Each
|
||||||
|
// entry routes to NearGrid/FarGrid by its StreamTier, so each grid marches only its own subset.
|
||||||
|
NearGrid.Entries.Reset(); NearGrid.EntryBiome.Reset();
|
||||||
|
FarGrid.Entries.Reset(); FarGrid.EntryBiome.Reset();
|
||||||
|
auto AddEntry = [&](const FStrateDecoration& D, int32 ci)
|
||||||
|
{
|
||||||
|
FDecoGrid& G = (D.StreamTier == EDecoStreamTier::Near) ? NearGrid : FarGrid;
|
||||||
|
G.Entries.Add(D);
|
||||||
|
G.EntryBiome.Add(ci);
|
||||||
|
};
|
||||||
if (CurrentCtx.Def)
|
if (CurrentCtx.Def)
|
||||||
{
|
{
|
||||||
if (CurrentCtx.BiomeCtx.IsValid())
|
if (CurrentCtx.BiomeCtx.IsValid())
|
||||||
@@ -194,20 +221,12 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
|||||||
// index so it only fires inside that biome's columns — no cross-biome bleed).
|
// index so it only fires inside that biome's columns — no cross-biome bleed).
|
||||||
const TArray<FStrateDecoration>& Src =
|
const TArray<FStrateDecoration>& Src =
|
||||||
(Bio && Bio->Decorations.Num() > 0) ? Bio->Decorations : CurrentCtx.Def->Decorations;
|
(Bio && Bio->Decorations.Num() > 0) ? Bio->Decorations : CurrentCtx.Def->Decorations;
|
||||||
for (const FStrateDecoration& D : Src)
|
for (const FStrateDecoration& D : Src) { AddEntry(D, ci); }
|
||||||
{
|
|
||||||
CurrentEntries.Add(D);
|
|
||||||
CurrentEntryBiome.Add(ci);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations)
|
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations) { AddEntry(D, -1); } // -1 → matches ColBiome -1
|
||||||
{
|
|
||||||
CurrentEntries.Add(D);
|
|
||||||
CurrentEntryBiome.Add(-1); // no biome field → matches the column's ColBiome (-1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,22 +240,26 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
|||||||
|
|
||||||
if (PlayerCell != LastDecoCell)
|
if (PlayerCell != LastDecoCell)
|
||||||
{
|
{
|
||||||
RebuildDesiredCells(PlayerCell);
|
RebuildDesiredCells(NearGrid, PlayerCell);
|
||||||
|
RebuildDesiredCells(FarGrid, PlayerCell);
|
||||||
LastDecoCell = PlayerCell;
|
LastDecoCell = PlayerCell;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks);
|
// Both grids share ONE concurrency budget; throttle each against the other's current in-flight count.
|
||||||
LaunchDecoTasks(PlayerCell);
|
const int32 MaxConc = Settings->MaxConcurrentDecorationTasks;
|
||||||
ProcessDecoResults(PlayerCell, FarR);
|
LaunchDecoTasks(NearGrid, PlayerCell, FarGrid.InFlightCells.Num(), MaxConc);
|
||||||
|
LaunchDecoTasks(FarGrid, PlayerCell, NearGrid.InFlightCells.Num(), MaxConc);
|
||||||
|
ProcessDecoResults(PlayerCell);
|
||||||
}
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
|
void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& PlayerCell)
|
||||||
{
|
{
|
||||||
// REGION-granular streaming. Decoration cells are grouped into RxR regions; a region is the load/
|
// REGION-granular streaming, per grid. Decoration cells are grouped into RxR regions; a region is the
|
||||||
// unload unit and shares ONE HISM per mesh, so the render thread walks ~R^2 fewer components. A
|
// load/unload unit and shares ONE HISM per mesh, so the render thread walks ~R^2 fewer components. A
|
||||||
// region, once desired, marches ALL of its cells (so it is self-contained and NEVER re-streamed in
|
// region, once desired, marches ALL of its cells (so it is self-contained and NEVER re-streamed in
|
||||||
// place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region).
|
// place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region). The
|
||||||
const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks);
|
// radius is G.Radius (this grid's tier), so Near and Far stream to different distances independently.
|
||||||
|
const int32 FarR = G.Radius;
|
||||||
const int32 R = RegionSize();
|
const int32 R = RegionSize();
|
||||||
|
|
||||||
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
|
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
|
||||||
@@ -255,10 +278,10 @@ void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
|
|||||||
|
|
||||||
// Unload loaded regions no longer desired (plain DestroyComponent — no per-instance removal).
|
// Unload loaded regions no longer desired (plain DestroyComponent — no per-instance removal).
|
||||||
{
|
{
|
||||||
TArray<FIntPoint> Loaded; DecoRegions.GetKeys(Loaded);
|
TArray<FIntPoint> Loaded; G.Regions.GetKeys(Loaded);
|
||||||
for (const FIntPoint& K : Loaded)
|
for (const FIntPoint& K : Loaded)
|
||||||
{
|
{
|
||||||
if (!DesiredRegions.Contains(K)) ClearDecorationRegion(K);
|
if (!DesiredRegions.Contains(K)) ClearDecorationRegion(G, K);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,39 +291,38 @@ void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
|
|||||||
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
|
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
|
||||||
for (const FIntPoint& Region : DesiredRegions)
|
for (const FIntPoint& Region : DesiredRegions)
|
||||||
{
|
{
|
||||||
if (DecoRegions.Contains(Region)) continue; // already applied → leave it (no re-stream)
|
if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
|
||||||
if (RegionBuilds.Contains(Region)) continue; // already marching its cells
|
if (G.Builds.Contains(Region)) continue; // already marching its cells
|
||||||
|
|
||||||
FDecoRegionBuild& Build = RegionBuilds.Add(Region);
|
FDecoRegionBuild& Build = G.Builds.Add(Region);
|
||||||
Build.BuildId = NextBuildId++;
|
Build.BuildId = G.NextBuildId++;
|
||||||
Build.CellsRemaining = R * R;
|
Build.CellsRemaining = R * R;
|
||||||
|
|
||||||
const int32 BaseX = Region.X * R, BaseY = Region.Y * R;
|
const int32 BaseX = Region.X * R, BaseY = Region.Y * R;
|
||||||
for (int32 cy = 0; cy < R; ++cy)
|
for (int32 cy = 0; cy < R; ++cy)
|
||||||
for (int32 cx = 0; cx < R; ++cx)
|
for (int32 cx = 0; cx < R; ++cx)
|
||||||
{
|
{
|
||||||
PendingLaunch.Add(FIntPoint(BaseX + cx, BaseY + cy));
|
G.PendingLaunch.Add(FIntPoint(BaseX + cx, BaseY + cy));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nearest-first so the region under the player fills in before the fringe. Stale entries (cells whose
|
// Nearest-first so the region under the player fills in before the fringe. Stale entries (cells whose
|
||||||
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
|
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
|
||||||
PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
|
G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
|
||||||
{
|
{
|
||||||
return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell);
|
return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc)
|
||||||
{
|
{
|
||||||
if (!CurrentCtx.Def || !Generator) return;
|
if (!CurrentCtx.Def || !Generator) return;
|
||||||
const int32 MaxConc = Settings->MaxConcurrentDecorationTasks;
|
|
||||||
if (MaxConc <= 0)
|
if (MaxConc <= 0)
|
||||||
{
|
{
|
||||||
// Decorations disabled at runtime — drop all queued/pending build state so nothing is stranded.
|
// Decorations disabled at runtime — drop THIS grid's queued/pending build state so nothing is stranded.
|
||||||
PendingLaunch.Reset();
|
G.PendingLaunch.Reset();
|
||||||
RegionBuilds.Reset();
|
G.Builds.Reset();
|
||||||
CompletedRegions.Reset();
|
G.Completed.Reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,46 +331,54 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
||||||
|
|
||||||
const int32 R = RegionSize();
|
const int32 R = RegionSize();
|
||||||
const int32 Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
const int32 Spacing = G.Spacing; // fine (Near) or coarse (Far) — the per-grid column grid
|
||||||
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||||||
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
||||||
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||||||
|
const EDecoStreamTier GridTier = G.Tier; // stamped on each result so it routes back to this grid
|
||||||
|
|
||||||
while (PendingLaunch.Num() > 0 && InFlightCells.Num() < MaxConc)
|
// Throttle against the COMBINED in-flight count (this grid + the other) so both grids share MaxConc.
|
||||||
|
// Drain from the head by INDEX — RemoveAt(0) per pop shifted the whole array every time (O(N) each,
|
||||||
|
// quadratic on a long queue); now it's one compaction at the end. A cell still in flight from a
|
||||||
|
// PREVIOUS build (its build was dropped while the task was airborne — e.g. the MaxConc==0 reset path)
|
||||||
|
// is DEFERRED instead of dropped: dropping it would leave the NEW build waiting forever for a cell
|
||||||
|
// that never reports (a permanently blank, never-reapplied region).
|
||||||
|
int32 Head = 0;
|
||||||
|
TArray<FIntPoint> Deferred;
|
||||||
|
while (Head < G.PendingLaunch.Num() && (G.InFlightCells.Num() + OtherInFlight) < MaxConc)
|
||||||
{
|
{
|
||||||
const FIntPoint Cell = PendingLaunch[0];
|
const FIntPoint Cell = G.PendingLaunch[Head++];
|
||||||
PendingLaunch.RemoveAt(0);
|
|
||||||
|
|
||||||
if (InFlightCells.Contains(Cell)) continue;
|
if (G.InFlightCells.Contains(Cell)) { Deferred.Add(Cell); continue; }
|
||||||
|
|
||||||
// The cell's region build drives completion. If it's gone (region applied or discarded since this
|
// The cell's region build drives completion. If it's gone (region applied or discarded since this
|
||||||
// cell was queued), drop the cell — no range check here: a region intentionally marches all its
|
// cell was queued), drop the cell — no range check here: a region intentionally marches all its
|
||||||
// cells (some sit just past FarR), and discarding the build is the only "no longer wanted" signal.
|
// cells (some sit just past FarR), and discarding the build is the only "no longer wanted" signal.
|
||||||
const FIntPoint Region = CellToRegion(Cell, R);
|
const FIntPoint Region = CellToRegion(Cell, R);
|
||||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||||||
if (!Build) continue;
|
if (!Build) continue;
|
||||||
const uint32 BuildId = Build->BuildId;
|
const uint32 BuildId = Build->BuildId;
|
||||||
|
|
||||||
// The decoration palette (all biomes' lists, flattened + tagged) is built ONCE per update in
|
// This grid's palette (its tier's entries, flattened + biome-tagged) is built ONCE per update in
|
||||||
// UpdateDecorations; the per-COLUMN biome pick happens on the worker. Snapshot the flat list +
|
// UpdateDecorations; the per-COLUMN biome pick happens on the worker. Snapshot the flat list +
|
||||||
// tags for this cell's task (the biome context rides in Ctx).
|
// tags for this cell's task (the biome context rides in Ctx).
|
||||||
if (CurrentEntries.Num() == 0)
|
if (G.Entries.Num() == 0)
|
||||||
{
|
{
|
||||||
MarkCellDone(Region, Cell, BuildId); // empty cell still counts toward the region's completion
|
MarkCellDone(G, Region, Cell, BuildId); // empty cell still counts toward the region's completion
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
TArray<FStrateDecoration> EntriesCopy = CurrentEntries; // snapshot for the worker + the spawner
|
TArray<FStrateDecoration> EntriesCopy = G.Entries; // snapshot for the worker + the spawner
|
||||||
TArray<int32> EntryBiomeCopy = CurrentEntryBiome; // parallel: ctx-biome owner per entry
|
TArray<int32> EntryBiomeCopy = G.EntryBiome; // parallel: ctx-biome owner per entry
|
||||||
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
|
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
|
||||||
const uint32 LocalSeed = (uint32)Seed;
|
const uint32 LocalSeed = (uint32)Seed;
|
||||||
UVoxelGenerator* Gen = Generator;
|
UVoxelGenerator* Gen = Generator;
|
||||||
|
|
||||||
InFlightCells.Add(Cell);
|
G.InFlightCells.Add(Cell);
|
||||||
GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed);
|
GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
UE::Tasks::Launch(TEXT("DecoMarch"),
|
UE::Tasks::Launch(TEXT("DecoMarch"),
|
||||||
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId,
|
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
|
||||||
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
||||||
{
|
{
|
||||||
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
|
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
|
||||||
@@ -358,6 +388,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
FDecoCellResult Result;
|
FDecoCellResult Result;
|
||||||
Result.Cell = Cell;
|
Result.Cell = Cell;
|
||||||
Result.BuildId = BuildId;
|
Result.BuildId = BuildId;
|
||||||
|
Result.Grid = GridTier;
|
||||||
Result.Entries = MoveTemp(Entries);
|
Result.Entries = MoveTemp(Entries);
|
||||||
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed,
|
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed,
|
||||||
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
|
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
|
||||||
@@ -368,6 +399,9 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
}
|
}
|
||||||
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Head > 0) { G.PendingLaunch.RemoveAt(0, Head); }
|
||||||
|
G.PendingLaunch.Append(Deferred); // retry next update, once the old task frees the cell
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
|
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
|
||||||
@@ -387,6 +421,20 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
|
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
|
||||||
int32 TotalActors = 0;
|
int32 TotalActors = 0;
|
||||||
|
|
||||||
|
// Per-entry slope-gate cosines, hoisted out of PlaceAtCrossing (they were recomputed per crossing
|
||||||
|
// × entry). Same cos of the same angle → bit-identical gating. Sentinel < 0 = gate disabled
|
||||||
|
// (default angles), so the common case still costs no trig and never rejects.
|
||||||
|
TArray<float> CosMaxSlope, CosMinSlope;
|
||||||
|
CosMaxSlope.SetNumUninitialized(Entries.Num());
|
||||||
|
CosMinSlope.SetNumUninitialized(Entries.Num());
|
||||||
|
for (int32 e = 0; e < Entries.Num(); ++e)
|
||||||
|
{
|
||||||
|
CosMaxSlope[e] = (Entries[e].MaxSlopeAngle < 89.99f)
|
||||||
|
? FMath::Cos(FMath::DegreesToRadians(Entries[e].MaxSlopeAngle)) : -1.0f;
|
||||||
|
CosMinSlope[e] = (Entries[e].MinSlopeAngle > 0.01f)
|
||||||
|
? FMath::Cos(FMath::DegreesToRadians(Entries[e].MinSlopeAngle)) : -1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
|
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
|
||||||
// (one rebuild per chunk footprint), so resolving the dominant biome at every column in this cell is
|
// (one rebuild per chunk footprint), so resolving the dominant biome at every column in this cell is
|
||||||
// cheap. The cache is local to this worker task (determinism-safe — pure function of XY/seed/Ctx).
|
// cheap. The cache is local to this worker task (determinism-safe — pure function of XY/seed/Ctx).
|
||||||
@@ -438,22 +486,11 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
// wall decals. Applies whenever the point IS a wall (independent of Floor/Wall/Any setting).
|
// wall decals. Applies whenever the point IS a wall (independent of Floor/Wall/Any setting).
|
||||||
if (bWall && Deco.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue;
|
if (bWall && Deco.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue;
|
||||||
|
|
||||||
// Surface-tilt gate: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). Skip surfaces steeper than
|
// Surface-tilt gates: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). |N.Z| < cos(MaxSlope) ⇔
|
||||||
// MaxSlopeAngle. cos is monotone-decreasing, so |N.Z| < cos(MaxSlope) ⇔ tilt > MaxSlope.
|
// tilt > MaxSlope (skip steeper); |N.Z| > cos(MinSlope) ⇔ tilt < MinSlope (skip flatter).
|
||||||
// Guarded so the default (90°, cos = 0) costs no trig and never rejects anything.
|
// Cosines are precomputed per entry above; < 0 = gate disabled (default angles).
|
||||||
if (Deco.MaxSlopeAngle < 89.99f &&
|
if (CosMaxSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) < CosMaxSlope[EntryIdx]) continue;
|
||||||
FMath::Abs(NormalWorld.Z) < FMath::Cos(FMath::DegreesToRadians(Deco.MaxSlopeAngle)))
|
if (CosMinSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) > CosMinSlope[EntryIdx]) continue;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lower-bound tilt gate (companion to the above): skip surfaces FLATTER than MinSlopeAngle.
|
|
||||||
// tilt < MinSlope ⇔ |N.Z| > cos(MinSlope). Guarded so the default (0°, cos = 1) never rejects.
|
|
||||||
if (Deco.MinSlopeAngle > 0.01f &&
|
|
||||||
FMath::Abs(NormalWorld.Z) > FMath::Cos(FMath::DegreesToRadians(Deco.MinSlopeAngle)))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
|
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
|
||||||
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
|
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
|
||||||
@@ -601,49 +638,56 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- GAME THREAD: drain finished marches → merge into region builds, apply completed regions budgeted. ----
|
// ---- GAME THREAD: drain finished marches → merge into region builds, apply completed regions budgeted. ----
|
||||||
void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR)
|
void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell)
|
||||||
{
|
{
|
||||||
// Drain every finished cell march and fold it into its region build. Merging is cheap (transform
|
// Drain every finished cell march and route it to its grid by Result.Grid, folding it into that grid's
|
||||||
// appends) so it isn't budgeted; the expensive HISM build is budgeted below at region granularity.
|
// region build. Merging is cheap (transform appends) so it isn't budgeted; the expensive HISM build is
|
||||||
|
// budgeted below at region granularity.
|
||||||
FDecoCellResult R;
|
FDecoCellResult R;
|
||||||
while (DecoResults.Dequeue(R))
|
while (DecoResults.Dequeue(R))
|
||||||
{
|
{
|
||||||
InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters
|
FDecoGrid& G = (R.Grid == EDecoStreamTier::Near) ? NearGrid : FarGrid;
|
||||||
MergeCellResult(R);
|
G.InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters
|
||||||
|
MergeCellResult(G, R);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply completed regions (one batched HISM-per-mesh build), budgeted. A region whose build finished
|
// Apply completed regions across BOTH grids under ONE shared frame budget (one batched HISM-per-mesh
|
||||||
// but is no longer desired (player moved on while it marched) is discarded instead of applied — that
|
// build per region). A region whose build finished but is no longer desired (player moved on while it
|
||||||
// keeps an out-of-range region from flashing in for a frame before the next unload pass.
|
// marched) is discarded instead of applied — keeps an out-of-range region from flashing in for a frame.
|
||||||
const int32 R_ = RegionSize();
|
const int32 R_ = RegionSize();
|
||||||
const int32 Budget = FMath::Max(1, Settings->MaxDecorationCellsPerFrame);
|
const int32 Budget = FMath::Max(1, Settings->MaxDecorationCellsPerFrame);
|
||||||
int32 Applied = 0;
|
int32 Applied = 0;
|
||||||
while (CompletedRegions.Num() > 0 && Applied < Budget)
|
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||||||
{
|
{
|
||||||
const FIntPoint Region = CompletedRegions[0];
|
FDecoGrid& G = *GP;
|
||||||
CompletedRegions.RemoveAt(0);
|
while (G.Completed.Num() > 0 && Applied < Budget)
|
||||||
|
{
|
||||||
|
const FIntPoint Region = G.Completed[0];
|
||||||
|
G.Completed.RemoveAt(0);
|
||||||
|
|
||||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||||||
if (!Build) continue; // already cleared
|
if (!Build) continue; // already cleared
|
||||||
|
|
||||||
if (!IsRegionDesired(Region, PlayerCell, FarR, R_))
|
if (!IsRegionDesired(Region, PlayerCell, G.Radius, R_))
|
||||||
{
|
{
|
||||||
RegionBuilds.Remove(Region); // wandered out of range while building → drop it unbuilt
|
G.Builds.Remove(Region); // wandered out of range while building → drop it unbuilt
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyRegion(Region, *Build);
|
ApplyRegion(G, Region, *Build);
|
||||||
RegionBuilds.Remove(Region);
|
G.Builds.Remove(Region);
|
||||||
++Applied;
|
++Applied;
|
||||||
}
|
}
|
||||||
|
if (Applied >= Budget) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fold one finished cell's spawns into its region build, then mark the cell accounted for. A result whose
|
// Fold one finished cell's spawns into its region build, then mark the cell accounted for. A result whose
|
||||||
// region build is gone or whose BuildId no longer matches (region was cleared + re-marched) is discarded.
|
// region build is gone or whose BuildId no longer matches (region was cleared + re-marched) is discarded.
|
||||||
void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
|
void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult& Result)
|
||||||
{
|
{
|
||||||
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
|
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
|
||||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||||||
if (!Build || Build->BuildId != Result.BuildId)
|
if (!Build || Build->BuildId != Result.BuildId)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -677,15 +721,15 @@ void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MarkCellDone(Region, Result.Cell, Result.BuildId);
|
MarkCellDone(G, Region, Result.Cell, Result.BuildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account one cell against its region — IDEMPOTENT per cell, so a duplicate task for the same cell can't
|
// Account one cell against its region — IDEMPOTENT per cell, so a duplicate task for the same cell can't
|
||||||
// double-decrement and apply the region early (which left a permanently-empty chunk until a regen). Queues
|
// double-decrement and apply the region early (which left a permanently-empty chunk until a regen). Queues
|
||||||
// the region for apply once every distinct cell has reported.
|
// the region for apply once every distinct cell has reported.
|
||||||
void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId)
|
void UVoxelContentManager::MarkCellDone(FDecoGrid& G, const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId)
|
||||||
{
|
{
|
||||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||||||
if (!Build || Build->BuildId != BuildId) return;
|
if (!Build || Build->BuildId != BuildId) return;
|
||||||
|
|
||||||
bool bAlreadyAccounted = false;
|
bool bAlreadyAccounted = false;
|
||||||
@@ -694,13 +738,13 @@ void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, const FIntPoint
|
|||||||
|
|
||||||
if (--Build->CellsRemaining <= 0)
|
if (--Build->CellsRemaining <= 0)
|
||||||
{
|
{
|
||||||
CompletedRegions.Add(Region); // ready for budgeted apply in ProcessDecoResults
|
G.Completed.Add(Region); // ready for budgeted apply in ProcessDecoResults
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the region's components: one HISM per mesh (all cells merged → one batched AddInstances), actors
|
// Build the region's components: one HISM per mesh (all cells merged → one batched AddInstances), actors
|
||||||
// spawned inline. Moves the region into DecoRegions; the build is removed by the caller.
|
// spawned inline. Moves the region into G.Regions; the build is removed by the caller.
|
||||||
void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build)
|
void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FDecoRegionBuild& Build)
|
||||||
{
|
{
|
||||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoApply); // total game-thread cost to apply one region
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoApply); // total game-thread cost to apply one region
|
||||||
|
|
||||||
@@ -709,7 +753,7 @@ void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild
|
|||||||
UWorld* World = OwnerActor->GetWorld();
|
UWorld* World = OwnerActor->GetWorld();
|
||||||
if (!World) return;
|
if (!World) return;
|
||||||
|
|
||||||
FDecoRegionContent& Content = DecoRegions.Add(Region);
|
FDecoRegionContent& Content = G.Regions.Add(Region);
|
||||||
|
|
||||||
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
|
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
|
||||||
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
|
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
|
||||||
@@ -773,9 +817,9 @@ void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::ClearDecorationRegion(const FIntPoint& Region)
|
void UVoxelContentManager::ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region)
|
||||||
{
|
{
|
||||||
FDecoRegionContent* Content = DecoRegions.Find(Region);
|
FDecoRegionContent* Content = G.Regions.Find(Region);
|
||||||
if (!Content) return;
|
if (!Content) return;
|
||||||
|
|
||||||
for (const TWeakObjectPtr<AActor>& A : Content->Actors)
|
for (const TWeakObjectPtr<AActor>& A : Content->Actors)
|
||||||
@@ -786,22 +830,338 @@ void UVoxelContentManager::ClearDecorationRegion(const FIntPoint& Region)
|
|||||||
{
|
{
|
||||||
if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) { Comp->DestroyComponent(); }
|
if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) { Comp->DestroyComponent(); }
|
||||||
}
|
}
|
||||||
DecoRegions.Remove(Region);
|
G.Regions.Remove(Region);
|
||||||
}
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::ClearAllDecorations()
|
void UVoxelContentManager::ClearAllDecorations()
|
||||||
{
|
{
|
||||||
TArray<FIntPoint> Keys; DecoRegions.GetKeys(Keys);
|
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||||||
for (const FIntPoint& K : Keys) ClearDecorationRegion(K);
|
{
|
||||||
|
FDecoGrid& G = *GP;
|
||||||
RegionBuilds.Reset(); // abandon any in-progress builds
|
TArray<FIntPoint> Keys; G.Regions.GetKeys(Keys);
|
||||||
CompletedRegions.Reset();
|
for (const FIntPoint& K : Keys) ClearDecorationRegion(G, K);
|
||||||
PendingLaunch.Reset();
|
ResetGridBuildState(G); // abandon any in-progress builds
|
||||||
InFlightCells.Reset();
|
}
|
||||||
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
|
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
|
||||||
// gone from RegionBuilds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
|
// gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
|
||||||
FDecoCellResult Discard;
|
DrainDecoResults();
|
||||||
while (DecoResults.Dequeue(Discard)) {}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// LANDMARKS — rare large objects on a coarse hash lattice (the "mini-suns")
|
||||||
|
//=============================================================================
|
||||||
|
// Cost scales with the NUMBER of landmarks in range, not the area: cell = SpacingChunks chunks, so a huge
|
||||||
|
// StreamRadiusChunks is only ~(radius/spacing)² candidates. Placement is synchronous (so few candidates it
|
||||||
|
// never hitches) and deterministic (hash of cell+entry+seed → pop-free). Strate-bounded like decorations.
|
||||||
|
|
||||||
|
// Single-column surface find for a landmark. SurfaceWorld → height oracle (floor TerrainZ / ceiling CeilSurf
|
||||||
|
// by Surf); else ray-march the strate band top-down for the first crossing whose orientation matches Surf.
|
||||||
|
bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||||||
|
const FDecoContext& Ctx, float VX, float VY, ESurfaceType Surf,
|
||||||
|
float Step, float ColDepth, float& OutZ, FVector& OutNormal)
|
||||||
|
{
|
||||||
|
if (!Gen) return false;
|
||||||
|
|
||||||
|
if (Ctx.bSurfaceWorld)
|
||||||
|
{
|
||||||
|
float hC, cC;
|
||||||
|
if (!Gen->GetSurfaceHeightAt(VX, VY, Ctx.RepChunkZ, hC, cC)) return false;
|
||||||
|
|
||||||
|
if (Surf == ESurfaceType::Ceiling)
|
||||||
|
{
|
||||||
|
if (!(cC > hC + 1.0f && cC <= Ctx.TopVoxelZ)) return false;
|
||||||
|
float d, cXp, cXm, cYp, cYm;
|
||||||
|
Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, d, cXp);
|
||||||
|
Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, d, cXm);
|
||||||
|
Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, d, cYp);
|
||||||
|
Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, d, cYm);
|
||||||
|
FVector N = OwnerXf.TransformVectorNoScale(
|
||||||
|
FVector((cXp - cXm) * 0.5f, (cYp - cYm) * 0.5f, -1.0f)).GetSafeNormal();
|
||||||
|
if (N.IsNearlyZero()) N = FVector::DownVector;
|
||||||
|
OutZ = cC; OutNormal = N; return true;
|
||||||
|
}
|
||||||
|
// Floor / Wall / Any → the terrain top.
|
||||||
|
if (!(hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ)) return false;
|
||||||
|
if (Gen->GetDensityAt(VX, VY, hC) > 0.5f) return false; // carved away (passage/spine/diff)
|
||||||
|
float d, hXp, hXm, hYp, hYm;
|
||||||
|
Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, hXp, d);
|
||||||
|
Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, hXm, d);
|
||||||
|
Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, hYp, d);
|
||||||
|
Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, hYm, d);
|
||||||
|
FVector N = OwnerXf.TransformVectorNoScale(
|
||||||
|
FVector(-(hXp - hXm) * 0.5f, -(hYp - hYm) * 0.5f, 1.0f)).GetSafeNormal();
|
||||||
|
if (N.IsNearlyZero()) N = FVector::UpVector;
|
||||||
|
OutZ = hC; OutNormal = N; return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cave/shaft/island archetypes: march the column from the top for the first matching crossing.
|
||||||
|
// Bounded by ColDepth like the decoration march (this runs SYNCHRONOUSLY on the game thread):
|
||||||
|
// once past open air, a solid run longer than ColDepth means bedrock down to the strate floor —
|
||||||
|
// stop instead of paying GetDensityAt across the whole remaining band.
|
||||||
|
float PrevD = Gen->GetDensityAt(VX, VY, Ctx.TopVoxelZ);
|
||||||
|
bool bSeenAir = (PrevD >= 0.0f);
|
||||||
|
float SolidRun = 0.0f;
|
||||||
|
for (float Z = Ctx.TopVoxelZ - Step; Z >= Ctx.BottomVoxelZ; Z -= Step)
|
||||||
|
{
|
||||||
|
const float Dz = Gen->GetDensityAt(VX, VY, Z);
|
||||||
|
if ((PrevD >= 0.0f) != (Dz >= 0.0f)) // air ↔ solid crossing
|
||||||
|
{
|
||||||
|
float ZLo = Z, ZHi = Z + Step, DHi = PrevD, DLo = Dz;
|
||||||
|
for (int32 It = 0; It < 4; ++It)
|
||||||
|
{
|
||||||
|
const float ZM = 0.5f * (ZLo + ZHi);
|
||||||
|
const float DM = Gen->GetDensityAt(VX, VY, ZM);
|
||||||
|
if ((DM >= 0.0f) == (DHi >= 0.0f)) { ZHi = ZM; DHi = DM; }
|
||||||
|
else { ZLo = ZM; DLo = DM; }
|
||||||
|
}
|
||||||
|
const float Denom = (DLo - DHi);
|
||||||
|
const float T = (FMath::Abs(Denom) > KINDA_SMALL_NUMBER) ? (DLo / Denom) : 0.5f;
|
||||||
|
const float ZC = ZLo + (ZHi - ZLo) * T;
|
||||||
|
|
||||||
|
const FVector LocalGrad(
|
||||||
|
Gen->GetDensityAt(VX + 1.0f, VY, ZC) - Gen->GetDensityAt(VX - 1.0f, VY, ZC),
|
||||||
|
Gen->GetDensityAt(VX, VY + 1.0f, ZC) - Gen->GetDensityAt(VX, VY - 1.0f, ZC),
|
||||||
|
Gen->GetDensityAt(VX, VY, ZC + 1.0f) - Gen->GetDensityAt(VX, VY, ZC - 1.0f));
|
||||||
|
FVector N = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
|
||||||
|
if (N.IsNearlyZero()) N = FVector::UpVector;
|
||||||
|
|
||||||
|
const bool bFloor = N.Z > 0.5f;
|
||||||
|
const bool bCeiling = N.Z < -0.5f;
|
||||||
|
const bool bWall = !bFloor && !bCeiling;
|
||||||
|
const bool bMatch =
|
||||||
|
(Surf == ESurfaceType::Floor && bFloor) ||
|
||||||
|
(Surf == ESurfaceType::Ceiling && bCeiling) ||
|
||||||
|
(Surf == ESurfaceType::Wall && bWall) ||
|
||||||
|
(Surf == ESurfaceType::Any);
|
||||||
|
if (bMatch) { OutZ = ZC; OutNormal = N; return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Dz >= 0.0f) { bSeenAir = true; SolidRun = 0.0f; }
|
||||||
|
else { SolidRun += Step; }
|
||||||
|
if (bSeenAir && SolidRun > ColDepth) break; // long bedrock below open space → nothing deeper
|
||||||
|
|
||||||
|
PrevD = Dz;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
|
||||||
|
const FTransform& OwnerXf, AActor* OwnerActor,
|
||||||
|
float LocalX, float LocalY, float Step, float ColDepth,
|
||||||
|
FLandmarkInstance& Out)
|
||||||
|
{
|
||||||
|
if (!Generator) return;
|
||||||
|
const float VX = LocalX / VOXEL_SIZE;
|
||||||
|
const float VY = LocalY / VOXEL_SIZE;
|
||||||
|
|
||||||
|
// Biome filter (resolved at the candidate XY, same field the density/deco paths use).
|
||||||
|
if (L.RequiredBiome)
|
||||||
|
{
|
||||||
|
const UVoxelBiomeDefinition* Bio = Generator->GetDominantBiomeAt(VX, VY, Ctx.RepChunkZ);
|
||||||
|
if (Bio != L.RequiredBiome) return; // leaves Out empty → evaluated, nothing placed
|
||||||
|
}
|
||||||
|
|
||||||
|
float ZC; FVector N;
|
||||||
|
if (!FindLandmarkColumn(Generator, OwnerXf, Ctx, VX, VY, L.SurfacePlacement, Step, ColDepth, ZC, N))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig).
|
||||||
|
if (L.MaxSlopeAngle < 89.99f &&
|
||||||
|
FMath::Abs(N.Z) < FMath::Cos(FMath::DegreesToRadians(L.MaxSlopeAngle))) return;
|
||||||
|
if (L.MinSlopeAngle > 0.01f &&
|
||||||
|
FMath::Abs(N.Z) > FMath::Cos(FMath::DegreesToRadians(L.MinSlopeAngle))) return;
|
||||||
|
|
||||||
|
const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE);
|
||||||
|
if (L.bRequireWaterRelative && Ctx.bHasWater)
|
||||||
|
{
|
||||||
|
const bool bBelowWater = (LocalPos.Z < Ctx.WaterLocalZ);
|
||||||
|
if (bBelowWater != L.bPlaceBelowWater) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation: optional surface-align → fixed offset → per-axis hash random.
|
||||||
|
FQuat Q = L.bAlignToSurface ? FRotationMatrix::MakeFromZ(N).ToQuat() : FQuat::Identity;
|
||||||
|
Q = Q * L.RotationOffset.Quaternion();
|
||||||
|
if (!L.RandomRotation.IsNearlyZero())
|
||||||
|
{
|
||||||
|
const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * L.RandomRotation.Pitch;
|
||||||
|
const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * L.RandomRotation.Yaw;
|
||||||
|
const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * L.RandomRotation.Roll;
|
||||||
|
Q = Q * FRotator(rp, ry, rr).Quaternion();
|
||||||
|
}
|
||||||
|
|
||||||
|
const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E777u));
|
||||||
|
const float Scale = FMath::Lerp(L.MinScale, L.MaxScale, ScaleT);
|
||||||
|
|
||||||
|
// World-space position + XYZ offset (e.g. +Z lifts a sun off the sky-cap into the cavern).
|
||||||
|
const FVector WorldPos = OwnerXf.TransformPosition(LocalPos) + L.LocationOffset;
|
||||||
|
const FTransform Xf(Q, WorldPos, FVector(Scale));
|
||||||
|
|
||||||
|
// Mini-sun light orb: record world-space data for the terrain material's raymarched shadows. Distances
|
||||||
|
// convert voxels→cm (×VOXEL_SIZE); the emitter radius scales with the instance scale too.
|
||||||
|
if (L.bIsLightOrb)
|
||||||
|
{
|
||||||
|
Out.bIsOrb = true;
|
||||||
|
Out.Orb.WorldPos = WorldPos;
|
||||||
|
Out.Orb.Color = L.OrbColor;
|
||||||
|
Out.Orb.Intensity = L.OrbIntensity;
|
||||||
|
Out.Orb.RadiusWorld = L.OrbRadiusVoxels * VOXEL_SIZE * Scale;
|
||||||
|
Out.Orb.FalloffWorld = L.OrbFalloffVoxels * VOXEL_SIZE;
|
||||||
|
Out.Orb.MaxShadowDistWorld = L.OrbMaxShadowDistanceVoxels * VOXEL_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (L.ActorClass)
|
||||||
|
{
|
||||||
|
UWorld* World = OwnerActor->GetWorld();
|
||||||
|
if (!World) return;
|
||||||
|
FActorSpawnParameters SP;
|
||||||
|
SP.Owner = OwnerActor;
|
||||||
|
SP.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||||||
|
if (AActor* A = World->SpawnActor<AActor>(L.ActorClass, Xf, SP)) { Out.Actor = A; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (L.InstancedMesh)
|
||||||
|
{
|
||||||
|
UStaticMeshComponent* C = NewObject<UStaticMeshComponent>(OwnerActor);
|
||||||
|
C->SetStaticMesh(L.InstancedMesh);
|
||||||
|
C->SetMobility(EComponentMobility::Static); // placed once, never moves → cached draw + VSM shadow
|
||||||
|
C->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||||||
|
C->SetCastShadow(L.bCastShadow);
|
||||||
|
if (L.CullDistance > 0.0f) { C->SetCullDistance(L.CullDistance); } // 0 = never cull (far sun)
|
||||||
|
C->SetWorldTransform(Xf);
|
||||||
|
C->RegisterComponent();
|
||||||
|
C->AttachToComponent(OwnerActor->GetRootComponent(), FAttachmentTransformRules::KeepWorldTransform);
|
||||||
|
Out.Component = C;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const
|
||||||
|
{
|
||||||
|
OutOrbs.Reset();
|
||||||
|
for (const TPair<FIntVector, FLandmarkInstance>& Pair : LandmarkInstances)
|
||||||
|
{
|
||||||
|
if (Pair.Value.bIsOrb) { OutOrbs.Add(Pair.Value.Orb); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::DestroyLandmarkInstance(FLandmarkInstance& Inst)
|
||||||
|
{
|
||||||
|
if (AActor* A = Inst.Actor.Get()) { A->Destroy(); }
|
||||||
|
if (UStaticMeshComponent* C = Inst.Component.Get()) { C->DestroyComponent(); }
|
||||||
|
Inst.Actor = nullptr;
|
||||||
|
Inst.Component = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::ClearAllLandmarks()
|
||||||
|
{
|
||||||
|
for (TPair<FIntVector, FLandmarkInstance>& Pair : LandmarkInstances) { DestroyLandmarkInstance(Pair.Value); }
|
||||||
|
LandmarkInstances.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos)
|
||||||
|
{
|
||||||
|
if (!StrateManager || !Generator || !Settings) return;
|
||||||
|
AActor* OwnerActor = Owner.Get();
|
||||||
|
if (!OwnerActor) return;
|
||||||
|
|
||||||
|
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
||||||
|
const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos);
|
||||||
|
|
||||||
|
float TopZ, BotZ;
|
||||||
|
const bool bInStrate = StrateManager->GetStrateUnrealZRange(LocalPlayer.Z, TopZ, BotZ);
|
||||||
|
const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(LocalPlayer.Z) : INT32_MIN;
|
||||||
|
|
||||||
|
if (!bInStrate)
|
||||||
|
{
|
||||||
|
if (LandmarkInstances.Num() > 0) { ClearAllLandmarks(); }
|
||||||
|
LastLandmarkStrate = INT32_MIN;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (StrateIndex != LastLandmarkStrate)
|
||||||
|
{
|
||||||
|
ClearAllLandmarks();
|
||||||
|
LastLandmarkStrate = StrateIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float ChunkWorld = (float)CHUNK_SIZE * VOXEL_SIZE; // one chunk footprint in cm
|
||||||
|
|
||||||
|
// Shared strate context (a strate is a horizontal slab → same everywhere this update).
|
||||||
|
FDecoContext Ctx;
|
||||||
|
Ctx.TopVoxelZ = TopZ / VOXEL_SIZE;
|
||||||
|
Ctx.BottomVoxelZ = BotZ / VOXEL_SIZE;
|
||||||
|
Ctx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||||||
|
const FIntVector RepChunk(FMath::FloorToInt(LocalPlayer.X / ChunkWorld),
|
||||||
|
FMath::FloorToInt(LocalPlayer.Y / ChunkWorld), Ctx.RepChunkZ);
|
||||||
|
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(RepChunk);
|
||||||
|
if (!Def || Def->Landmarks.Num() == 0)
|
||||||
|
{
|
||||||
|
if (LandmarkInstances.Num() > 0) { ClearAllLandmarks(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ctx.Def = Def;
|
||||||
|
Ctx.bSurfaceWorld = (StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld);
|
||||||
|
{
|
||||||
|
const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk);
|
||||||
|
Ctx.bHasWater = (Wv != -FLT_MAX);
|
||||||
|
Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||||||
|
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||||||
|
const uint32 LocalSeed = (uint32)Seed;
|
||||||
|
|
||||||
|
// Walk each entry's lattice within its radius (a tiny box), spawn newly-entered cells, drop exited ones.
|
||||||
|
TSet<FIntVector> Desired;
|
||||||
|
for (int32 EntryIdx = 0; EntryIdx < Def->Landmarks.Num(); ++EntryIdx)
|
||||||
|
{
|
||||||
|
const FStrateLandmark& L = Def->Landmarks[EntryIdx];
|
||||||
|
if (!L.ActorClass && !L.InstancedMesh) continue;
|
||||||
|
|
||||||
|
const float SpacingChunks = FMath::Max(1.0f, L.SpacingChunks);
|
||||||
|
const int32 RadiusChunks = FMath::Max(1, L.StreamRadiusChunks);
|
||||||
|
const float CellWorld = SpacingChunks * ChunkWorld; // lattice cell size in cm
|
||||||
|
const float RadiusWorld = (float)RadiusChunks * ChunkWorld;
|
||||||
|
const float JitterRange = FMath::Clamp(L.JitterFraction, 0.0f, 1.0f);
|
||||||
|
|
||||||
|
const FIntPoint PlayerLCell(FMath::FloorToInt(LocalPlayer.X / CellWorld),
|
||||||
|
FMath::FloorToInt(LocalPlayer.Y / CellWorld));
|
||||||
|
const int32 CellRange = FMath::CeilToInt((float)RadiusChunks / SpacingChunks);
|
||||||
|
|
||||||
|
for (int32 dy = -CellRange; dy <= CellRange; ++dy)
|
||||||
|
for (int32 dx = -CellRange; dx <= CellRange; ++dx)
|
||||||
|
{
|
||||||
|
const FIntPoint LCell(PlayerLCell.X + dx, PlayerLCell.Y + dy);
|
||||||
|
|
||||||
|
// Existence roll for this lattice cell + entry.
|
||||||
|
const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u);
|
||||||
|
if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue;
|
||||||
|
|
||||||
|
// Jittered position inside the cell (centred so two neighbours stay ≥ Spacing·(1-Jitter) apart).
|
||||||
|
const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange;
|
||||||
|
const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange;
|
||||||
|
const float LocalX = ((float)LCell.X + 0.5f + jx) * CellWorld;
|
||||||
|
const float LocalY = ((float)LCell.Y + 0.5f + jy) * CellWorld;
|
||||||
|
|
||||||
|
// Radius is a true disk (the lattice box corners would otherwise overshoot it).
|
||||||
|
const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y;
|
||||||
|
if (ddx * ddx + ddy * ddy > RadiusWorld * RadiusWorld) continue;
|
||||||
|
|
||||||
|
const FIntVector Key(LCell.X, LCell.Y, EntryIdx);
|
||||||
|
Desired.Add(Key);
|
||||||
|
if (LandmarkInstances.Contains(Key)) continue; // already evaluated (spawned OR empty)
|
||||||
|
|
||||||
|
FLandmarkInstance Inst;
|
||||||
|
SpawnLandmarkInstance(L, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Inst);
|
||||||
|
LandmarkInstances.Add(Key, Inst); // stored even if empty → never re-evaluated while in range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop instances no longer desired (player moved away, strate's list shrank, etc.).
|
||||||
|
for (auto It = LandmarkInstances.CreateIterator(); It; ++It)
|
||||||
|
{
|
||||||
|
if (Desired.Contains(It.Key())) continue;
|
||||||
|
DestroyLandmarkInstance(It.Value());
|
||||||
|
It.RemoveCurrent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -883,14 +1243,16 @@ void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos)
|
|||||||
void UVoxelContentManager::ClearAll()
|
void UVoxelContentManager::ClearAll()
|
||||||
{
|
{
|
||||||
ClearAllDecorations();
|
ClearAllDecorations();
|
||||||
|
ClearAllLandmarks();
|
||||||
|
|
||||||
if (WaterPlane) { WaterPlane->DestroyComponent(); WaterPlane = nullptr; }
|
if (WaterPlane) { WaterPlane->DestroyComponent(); WaterPlane = nullptr; }
|
||||||
LastWaterZ = -FLT_MAX;
|
LastWaterZ = -FLT_MAX;
|
||||||
LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN);
|
LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||||
|
|
||||||
// Force a full decoration rebuild on the next update.
|
// Force a full decoration + landmark rebuild on the next update.
|
||||||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||||
LastStrateIndex = INT32_MIN;
|
LastStrateIndex = INT32_MIN;
|
||||||
|
LastLandmarkStrate = INT32_MIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -918,7 +1280,12 @@ void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bAppl
|
|||||||
const float CellMinX = (float)Cell.X * CellWorld, CellMaxX = CellMinX + CellWorld;
|
const float CellMinX = (float)Cell.X * CellWorld, CellMaxX = CellMinX + CellWorld;
|
||||||
const float CellMinY = (float)Cell.Y * CellWorld, CellMaxY = CellMinY + CellWorld;
|
const float CellMinY = (float)Cell.Y * CellWorld, CellMaxY = CellMinY + CellWorld;
|
||||||
|
|
||||||
if (const FDecoRegionContent* Content = DecoRegions.Find(Region))
|
// Probe BOTH grids: a point is covered by a Far region always, plus a Near region when close. Aggregate
|
||||||
|
// applied instances + per-cell counts + building state across the two.
|
||||||
|
for (const FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||||||
|
{
|
||||||
|
const FDecoGrid& G = *GP;
|
||||||
|
if (const FDecoRegionContent* Content = G.Regions.Find(Region))
|
||||||
{
|
{
|
||||||
bApplied = true;
|
bApplied = true;
|
||||||
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
|
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
|
||||||
@@ -940,28 +1307,36 @@ void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bAppl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (const FDecoRegionBuild* Build = RegionBuilds.Find(Region))
|
if (const FDecoRegionBuild* Build = G.Builds.Find(Region))
|
||||||
{
|
{
|
||||||
bBuilding = true;
|
bBuilding = true;
|
||||||
CellsAccounted = Build->AccountedCells.Num();
|
CellsAccounted += Build->AccountedCells.Num();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate
|
// PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate context
|
||||||
// context (set each UpdateDecorations). Same inputs the worker uses → byte-identical result, so it
|
// (set each UpdateDecorations), for BOTH grids' palettes summed. Same inputs the worker uses →
|
||||||
// reports exactly what the scatter decides for this cell right now. Only valid when the probed point
|
// byte-identical result, so it reports exactly what the scatter decides for this cell right now. Only
|
||||||
// shares the player's current strate (CurrentCtx/CurrentEntries reflect that); else leave -1.
|
// valid when the probed point shares the player's current strate (CurrentCtx reflects that); else -1.
|
||||||
AActor* OwnerActor = Owner.Get();
|
AActor* OwnerActor = Owner.Get();
|
||||||
if (Generator && Settings && OwnerActor && CurrentCtx.Def && CurrentEntries.Num() > 0)
|
if (Generator && Settings && OwnerActor && CurrentCtx.Def)
|
||||||
{
|
{
|
||||||
const int32 Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
|
||||||
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||||||
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
||||||
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||||||
|
|
||||||
|
int32 Total = 0; bool bAny = false;
|
||||||
|
for (const FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||||||
|
{
|
||||||
|
const FDecoGrid& G = *GP;
|
||||||
|
if (G.Entries.Num() == 0) continue;
|
||||||
|
bAny = true;
|
||||||
TArray<FDecoSpawn> Spawns;
|
TArray<FDecoSpawn> Spawns;
|
||||||
BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx,
|
BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx,
|
||||||
CurrentEntries, CurrentEntryBiome, (uint32)Seed,
|
G.Entries, G.EntryBiome, (uint32)Seed,
|
||||||
Spacing, Step, MaxCross, ColDepth, Spawns);
|
G.Spacing, Step, MaxCross, ColDepth, Spawns);
|
||||||
LiveMarchSpawns = Spawns.Num();
|
Total += Spawns.Num();
|
||||||
|
}
|
||||||
|
if (bAny) { LiveMarchSpawns = Total; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,769 @@
|
|||||||
|
// VoxelDensityVolume.cpp — see VoxelDensityVolume.h for the design.
|
||||||
|
// Step 1a: CPU density clipmap + worker fills + toroidal streaming + carve dirty + debug draw.
|
||||||
|
// The GPU upload (UploadDirtyRegionToGPU) is the step-1b seam and is a no-op here.
|
||||||
|
|
||||||
|
#include "VoxelDensityVolume.h"
|
||||||
|
#include "VoxelGenerator.h"
|
||||||
|
#include "VoxelSettings.h"
|
||||||
|
#include "GameFramework/Actor.h"
|
||||||
|
#include "HAL/Runnable.h"
|
||||||
|
#include "HAL/RunnableThread.h"
|
||||||
|
#include "HAL/Event.h"
|
||||||
|
#include "HAL/PlatformProcess.h"
|
||||||
|
#include "Math/UnrealMathUtility.h"
|
||||||
|
#include "Engine/VolumeTexture.h"
|
||||||
|
#include "TextureResource.h"
|
||||||
|
#include "RenderingThread.h" // ENQUEUE_RENDER_COMMAND
|
||||||
|
#include "RHICommandList.h" // FRHICommandListImmediate::UpdateTexture3D
|
||||||
|
#if ENABLE_DRAW_DEBUG
|
||||||
|
#include "DrawDebugHelpers.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Dedicated fill thread: drains FillQueue (Spsc, game thread → here), re-evaluates GetDensityAt via
|
||||||
|
// the owner's ProcessOneFill, pushes FFillResult into the owner's Results (Mpsc, drained game-side).
|
||||||
|
// Sleeps on FillWakeEvent when idle. Off the UE::Tasks pool by design — so volume fills run at full
|
||||||
|
// speed on their own core WITHOUT contending with mesh-gen (the old BackgroundLow path starved).
|
||||||
|
class FVoxelDensityFillRunnable : public FRunnable
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FVoxelDensityFillRunnable(UVoxelDensityVolume* InOwner) : Owner(InOwner) {}
|
||||||
|
|
||||||
|
virtual uint32 Run() override
|
||||||
|
{
|
||||||
|
while (!Owner->bFillThreadStop.load(std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
UVoxelDensityVolume::FPendingFill F;
|
||||||
|
bool bDidWork = false;
|
||||||
|
while (Owner->FillQueue.Dequeue(F))
|
||||||
|
{
|
||||||
|
bDidWork = true;
|
||||||
|
Owner->ProcessOneFill(F);
|
||||||
|
if (Owner->bFillThreadStop.load(std::memory_order_relaxed)) break;
|
||||||
|
}
|
||||||
|
// Sleep until the game thread queues more (or asks us to stop). The Trigger() always
|
||||||
|
// follows the Enqueue(), so a trigger landing here is latched by the auto-reset event →
|
||||||
|
// no missed wakeup.
|
||||||
|
if (!bDidWork && Owner->FillWakeEvent)
|
||||||
|
{
|
||||||
|
Owner->FillWakeEvent->Wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void Stop() override
|
||||||
|
{
|
||||||
|
Owner->bFillThreadStop.store(true, std::memory_order_release);
|
||||||
|
if (Owner->FillWakeEvent) { Owner->FillWakeEvent->Trigger(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
UVoxelDensityVolume* Owner;
|
||||||
|
};
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// Lifecycle
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::Initialize(AActor* InOwner, UVoxelGenerator* InGenerator, UVoxelSettings* InSettings)
|
||||||
|
{
|
||||||
|
Owner = InOwner;
|
||||||
|
Generator = InGenerator;
|
||||||
|
Settings = InSettings;
|
||||||
|
bShuttingDown.store(false, std::memory_order_relaxed);
|
||||||
|
bInitialized = true;
|
||||||
|
// Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change
|
||||||
|
// (resolution / level count) before play picks up cleanly.
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::BeginDestroy()
|
||||||
|
{
|
||||||
|
// Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread.
|
||||||
|
bShuttingDown.store(true, std::memory_order_release);
|
||||||
|
StopFillThread();
|
||||||
|
Super::BeginDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::NotifyShutdown()
|
||||||
|
{
|
||||||
|
bShuttingDown.store(true, std::memory_order_release);
|
||||||
|
|
||||||
|
// Stop the dedicated fill thread — Kill(true) blocks until Run() returns, so it can't read the
|
||||||
|
// Generator after this (the owner tears UObjects down next). Then drop any queued/finished work.
|
||||||
|
StopFillThread();
|
||||||
|
|
||||||
|
FFillResult Discard;
|
||||||
|
while (Results.Dequeue(Discard)) {}
|
||||||
|
PendingFills.Reset();
|
||||||
|
CaptureCache.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::Reset()
|
||||||
|
{
|
||||||
|
// Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults.
|
||||||
|
++VolumeEpoch;
|
||||||
|
PendingFills.Reset();
|
||||||
|
FFillResult Discard;
|
||||||
|
while (Results.Dequeue(Discard)) {}
|
||||||
|
|
||||||
|
// Drop all data → next Update full-refills every level (origin sentinel + bHasData false).
|
||||||
|
for (FClipLevel& Lv : Levels)
|
||||||
|
{
|
||||||
|
Lv.OriginCells = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||||
|
Lv.bHasData = false;
|
||||||
|
Lv.bGPUDirty = true; // upload the cleared (zero) data; the refill then re-uploads real data
|
||||||
|
if (Lv.Density.Num() > 0) { FMemory::Memzero(Lv.Density.GetData(), Lv.Density.Num()); }
|
||||||
|
}
|
||||||
|
CaptureCache.Empty(); // pre-reset grids belong to the old world (epoch bumped)
|
||||||
|
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// Helpers
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
int32 UVoxelDensityVolume::ResPerAxis() const
|
||||||
|
{
|
||||||
|
return FMath::Clamp(Settings ? Settings->DensityVolumeResolution : 128, 32, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 UVoxelDensityVolume::NumLevels() const
|
||||||
|
{
|
||||||
|
return FMath::Clamp(Settings ? Settings->DensityVolumeLevels : 3, 1, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE int32 UVoxelDensityVolume::FloorDiv(int32 A, int32 B)
|
||||||
|
{
|
||||||
|
// True floor division (B > 0). FMath::DivideAndRoundDown truncates toward zero for negatives —
|
||||||
|
// a footgun the content manager hit too (see FloorDivPos there). Cells span the origin, so floor.
|
||||||
|
return (A >= 0) ? (A / B) : -(((-A) + B - 1) / B);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE uint8 UVoxelDensityVolume::Quantize(float MCDensity)
|
||||||
|
{
|
||||||
|
// Single source of truth (VoxelTypes.h) — MUST stay bit-identical with the mesher's
|
||||||
|
// capture-during-meshing path (UVoxelMarchingCubesMesher::GenerateMesh OutCaptureGrid),
|
||||||
|
// so an ingested tile capture equals a worker fill of the same cells byte-for-byte.
|
||||||
|
return VF_QuantizeDensity(MCDensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::EnsureAllocated()
|
||||||
|
{
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const int32 N = NumLevels();
|
||||||
|
if (AllocatedRes == Res && Levels.Num() == N) return; // already sized
|
||||||
|
|
||||||
|
Levels.Reset();
|
||||||
|
Levels.SetNum(N);
|
||||||
|
const int32 Count = Res * Res * Res;
|
||||||
|
for (int32 L = 0; L < N; ++L)
|
||||||
|
{
|
||||||
|
FClipLevel& Lv = Levels[L];
|
||||||
|
Lv.Step = 1 << L;
|
||||||
|
Lv.OriginCells = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||||
|
Lv.bHasData = false;
|
||||||
|
Lv.Density.SetNumZeroed(Count); // start all-air (0)
|
||||||
|
}
|
||||||
|
AllocatedRes = Res;
|
||||||
|
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); // force a recenter
|
||||||
|
|
||||||
|
EnsureTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// GPU upload (step 1b-i) — per-level R8 volume textures + full re-upload of dirty levels
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::EnsureTextures()
|
||||||
|
{
|
||||||
|
if (!Settings || !Settings->bEnableDensityVolume || !Settings->bDensityVolumeGPUUpload) return;
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const int32 N = NumLevels();
|
||||||
|
if (LevelTextures.Num() == N && AllocatedTexRes == Res) return; // already sized
|
||||||
|
|
||||||
|
for (TObjectPtr<UVolumeTexture>& T : LevelTextures)
|
||||||
|
{
|
||||||
|
if (T) { T->ReleaseResource(); }
|
||||||
|
}
|
||||||
|
LevelTextures.Reset();
|
||||||
|
LevelTextures.SetNum(N);
|
||||||
|
|
||||||
|
const int64 Count = (int64)Res * Res * Res;
|
||||||
|
for (int32 L = 0; L < N; ++L)
|
||||||
|
{
|
||||||
|
UVolumeTexture* T = NewObject<UVolumeTexture>(this);
|
||||||
|
T->SRGB = false;
|
||||||
|
T->Filter = TF_Trilinear; // smooth iso crossing (sub-voxel crisp edge)
|
||||||
|
T->CompressionSettings = TC_Grayscale; // single-channel
|
||||||
|
T->NeverStream = true;
|
||||||
|
T->MipGenSettings = TMGS_NoMipmaps; // 1b-i: base mip only; solidity mips come with the march
|
||||||
|
|
||||||
|
// Runtime platform data: one R8 (PF_G8) mip, zero-initialised. NOTE (UE5.7 API surface — flag if
|
||||||
|
// the build rejects any of these): FTexturePlatformData / SetNumSlices / SetPlatformData /
|
||||||
|
// FTexture2DMipMap(with SizeZ for volumes). If the names drifted, this whole GPU path is gated by
|
||||||
|
// bDensityVolumeGPUUpload — turn it off to fall back to the validated CPU volume while we fix it.
|
||||||
|
FTexturePlatformData* PD = new FTexturePlatformData();
|
||||||
|
PD->SizeX = Res;
|
||||||
|
PD->SizeY = Res;
|
||||||
|
PD->PixelFormat = PF_G8;
|
||||||
|
PD->SetNumSlices(Res);
|
||||||
|
|
||||||
|
FTexture2DMipMap* Mip = new FTexture2DMipMap();
|
||||||
|
Mip->SizeX = Res;
|
||||||
|
Mip->SizeY = Res;
|
||||||
|
Mip->SizeZ = Res;
|
||||||
|
Mip->BulkData.Lock(LOCK_READ_WRITE);
|
||||||
|
void* Dst = Mip->BulkData.Realloc(Count);
|
||||||
|
FMemory::Memzero(Dst, Count);
|
||||||
|
Mip->BulkData.Unlock();
|
||||||
|
PD->Mips.Add(Mip);
|
||||||
|
|
||||||
|
T->SetPlatformData(PD);
|
||||||
|
T->UpdateResource();
|
||||||
|
LevelTextures[L] = T;
|
||||||
|
}
|
||||||
|
AllocatedTexRes = Res;
|
||||||
|
|
||||||
|
// New textures are zeroed → mark every level dirty so the current CPU data uploads.
|
||||||
|
for (FClipLevel& Lv : Levels) { Lv.bGPUDirty = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::UploadDirtyTextures()
|
||||||
|
{
|
||||||
|
if (!Settings || !Settings->bDensityVolumeGPUUpload) return;
|
||||||
|
EnsureTextures(); // cheap early-out when sized; covers bDensityVolumeGPUUpload toggled ON at runtime
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
for (int32 L = 0; L < Levels.Num(); ++L)
|
||||||
|
{
|
||||||
|
FClipLevel& Lv = Levels[L];
|
||||||
|
if (!Lv.bGPUDirty) continue;
|
||||||
|
if (!LevelTextures.IsValidIndex(L) || !LevelTextures[L]) continue;
|
||||||
|
FTextureResource* Resource = LevelTextures[L]->GetResource();
|
||||||
|
if (!Resource) continue;
|
||||||
|
Lv.bGPUDirty = false;
|
||||||
|
|
||||||
|
// The CPU toroidal array IS the texture's memory layout (texel (tx,ty,tz) = array[(tz*Res+ty)*Res+tx]),
|
||||||
|
// so a FULL re-upload from it is correct with no wrap-splitting. ~Res³ bytes/level (2 MB at 128) — only
|
||||||
|
// when the level actually changed (idle = no upload). Sub-box upload (with toroidal wrap-splitting) is
|
||||||
|
// a later optimisation. Copy the source for the render thread (the CPU array keeps mutating).
|
||||||
|
TArray<uint8> Src = Lv.Density;
|
||||||
|
ENQUEUE_RENDER_COMMAND(VoxelDensityVolumeUpload)(
|
||||||
|
[Resource, SrcData = MoveTemp(Src), Res](FRHICommandListImmediate& RHICmdList) mutable
|
||||||
|
{
|
||||||
|
FRHITexture* Tex = Resource->GetTextureRHI();
|
||||||
|
if (!Tex) return;
|
||||||
|
const FUpdateTextureRegion3D Region(0, 0, 0, 0, 0, 0, Res, Res, Res);
|
||||||
|
// R8: row pitch = Res bytes, depth (slice) pitch = Res*Res bytes.
|
||||||
|
RHICmdList.UpdateTexture3D(Tex, 0, Region, (uint32)Res, (uint32)(Res * Res), SrcData.GetData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UVolumeTexture* UVoxelDensityVolume::GetLevelTexture(int32 Level) const
|
||||||
|
{
|
||||||
|
return LevelTextures.IsValidIndex(Level) ? LevelTextures[Level].Get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UVoxelDensityVolume::GetLevelShaderParams(int32 Level, FIntVector& OutOriginCells, float& OutStep, int32& OutRes) const
|
||||||
|
{
|
||||||
|
if (!Levels.IsValidIndex(Level) || !Levels[Level].bHasData) return false;
|
||||||
|
OutOriginCells = Levels[Level].OriginCells;
|
||||||
|
OutStep = (float)Levels[Level].Step;
|
||||||
|
OutRes = ResPerAxis();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// Update — recentre, queue, launch, drain
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos)
|
||||||
|
{
|
||||||
|
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return;
|
||||||
|
AActor* O = Owner.Get();
|
||||||
|
if (!O) return;
|
||||||
|
|
||||||
|
EnsureAllocated();
|
||||||
|
|
||||||
|
// Player world → actor-local voxel coords (GetDensityAt is in actor-local voxel space, same as the
|
||||||
|
// decoration march). The actor is Static at the origin, but do it properly via the transform.
|
||||||
|
const FTransform Xf = O->GetActorTransform();
|
||||||
|
const FVector Local = Xf.InverseTransformPosition(PlayerWorldPos);
|
||||||
|
const FIntVector PlayerVoxel(
|
||||||
|
FMath::RoundToInt(Local.X / VOXEL_SIZE),
|
||||||
|
FMath::RoundToInt(Local.Y / VOXEL_SIZE),
|
||||||
|
FMath::RoundToInt(Local.Z / VOXEL_SIZE));
|
||||||
|
|
||||||
|
if (PlayerVoxel != LastPlayerVoxel)
|
||||||
|
{
|
||||||
|
LastPlayerVoxel = PlayerVoxel;
|
||||||
|
const int32 N = Levels.Num();
|
||||||
|
for (int32 L = 0; L < N; ++L)
|
||||||
|
{
|
||||||
|
RecenterLevel(L, PlayerVoxel); // cheap no-op for a level whose origin didn't move
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchPendingFills(); // drain the queue under the task budget
|
||||||
|
DrainResults(); // apply finished worker fills into the toroidal arrays (marks levels GPU-dirty)
|
||||||
|
UploadDirtyTextures(); // push changed levels to the GPU (render-thread RHIUpdateTexture3D)
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::RecenterLevel(int32 L, const FIntVector& PlayerVoxel)
|
||||||
|
{
|
||||||
|
if (!Levels.IsValidIndex(L)) return;
|
||||||
|
FClipLevel& Lv = Levels[L];
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const int32 Step = Lv.Step;
|
||||||
|
const int32 Half = Res / 2;
|
||||||
|
|
||||||
|
const FIntVector PlayerCell(FloorDiv(PlayerVoxel.X, Step),
|
||||||
|
FloorDiv(PlayerVoxel.Y, Step),
|
||||||
|
FloorDiv(PlayerVoxel.Z, Step));
|
||||||
|
FIntVector NewOrigin = PlayerCell - FIntVector(Half, Half, Half);
|
||||||
|
|
||||||
|
// LEVEL 0 is capture-fed (capture-during-meshing). Snap the window origin to the level-0 TILE grid
|
||||||
|
// (CHUNK_SIZE cells) so newly-exposed slabs align to whole captured tiles, and the window only
|
||||||
|
// scrolls on CHUNK crossings (≈CHUNK_SIZE× fewer recenters + GPU re-uploads than the per-voxel
|
||||||
|
// path). The player still stays ≥CHUNK_SIZE cells from any window edge, so the small origin offset
|
||||||
|
// is invisible to the shadow march. Coarser levels keep the per-cell worker-fill path unchanged.
|
||||||
|
const bool bCapture = (L == 0);
|
||||||
|
if (bCapture)
|
||||||
|
{
|
||||||
|
NewOrigin = FIntVector(FloorDiv(NewOrigin.X, CHUNK_SIZE) * CHUNK_SIZE,
|
||||||
|
FloorDiv(NewOrigin.Y, CHUNK_SIZE) * CHUNK_SIZE,
|
||||||
|
FloorDiv(NewOrigin.Z, CHUNK_SIZE) * CHUNK_SIZE);
|
||||||
|
}
|
||||||
|
const FIntVector Dim(Res, Res, Res);
|
||||||
|
|
||||||
|
if (Lv.bHasData && NewOrigin == Lv.OriginCells) return; // didn't move → nothing to refill
|
||||||
|
|
||||||
|
if (!Lv.bHasData)
|
||||||
|
{
|
||||||
|
Lv.OriginCells = NewOrigin;
|
||||||
|
Lv.bHasData = true; // toroidal slots are stale until the fills land (transient)
|
||||||
|
if (bCapture) { FillBoxFromCacheOrQueue(NewOrigin, Dim); EvictFarCaptures(); }
|
||||||
|
else { QueueFillSplit(L, NewOrigin, Dim); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incremental: refill only the slabs that scrolled into view (new window minus old window). The
|
||||||
|
// toroidal slots for cells still in view keep their valid data — no copy/move needed.
|
||||||
|
TArray<TPair<FIntVector, FIntVector>> Boxes;
|
||||||
|
BoxDifference(NewOrigin, Dim, Lv.OriginCells, Dim, Boxes);
|
||||||
|
Lv.OriginCells = NewOrigin;
|
||||||
|
for (const TPair<FIntVector, FIntVector>& B : Boxes)
|
||||||
|
{
|
||||||
|
if (bCapture) FillBoxFromCacheOrQueue(B.Key, B.Value);
|
||||||
|
else QueueFillSplit(L, B.Key, B.Value);
|
||||||
|
}
|
||||||
|
if (bCapture) EvictFarCaptures();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::BoxDifference(const FIntVector& NewMin, const FIntVector& NewDim,
|
||||||
|
const FIntVector& OldMin, const FIntVector& OldDim,
|
||||||
|
TArray<TPair<FIntVector, FIntVector>>& OutBoxes)
|
||||||
|
{
|
||||||
|
const FIntVector NMax = NewMin + NewDim; // exclusive
|
||||||
|
const FIntVector OMax = OldMin + OldDim;
|
||||||
|
const FIntVector IMin(FMath::Max(NewMin.X, OldMin.X), FMath::Max(NewMin.Y, OldMin.Y), FMath::Max(NewMin.Z, OldMin.Z));
|
||||||
|
const FIntVector IMax(FMath::Min(NMax.X, OMax.X), FMath::Min(NMax.Y, OMax.Y), FMath::Min(NMax.Z, OMax.Z));
|
||||||
|
|
||||||
|
// No overlap → the whole new window is new.
|
||||||
|
if (IMin.X >= IMax.X || IMin.Y >= IMax.Y || IMin.Z >= IMax.Z)
|
||||||
|
{
|
||||||
|
OutBoxes.Add(TPair<FIntVector, FIntVector>(NewMin, NewDim));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto Add = [&](int32 x0, int32 x1, int32 y0, int32 y1, int32 z0, int32 z1)
|
||||||
|
{
|
||||||
|
if (x1 > x0 && y1 > y0 && z1 > z0)
|
||||||
|
{
|
||||||
|
OutBoxes.Add(TPair<FIntVector, FIntVector>(FIntVector(x0, y0, z0), FIntVector(x1 - x0, y1 - y0, z1 - z0)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// X slabs span the full new Y,Z; Y slabs span the overlap X + full new Z; Z slabs span the overlap X,Y.
|
||||||
|
// Together these are disjoint and cover (new \ old) exactly.
|
||||||
|
Add(NewMin.X, IMin.X, NewMin.Y, NMax.Y, NewMin.Z, NMax.Z);
|
||||||
|
Add(IMax.X, NMax.X, NewMin.Y, NMax.Y, NewMin.Z, NMax.Z);
|
||||||
|
Add(IMin.X, IMax.X, NewMin.Y, IMin.Y, NewMin.Z, NMax.Z);
|
||||||
|
Add(IMin.X, IMax.X, IMax.Y, NMax.Y, NewMin.Z, NMax.Z);
|
||||||
|
Add(IMin.X, IMax.X, IMin.Y, IMax.Y, NewMin.Z, IMin.Z);
|
||||||
|
Add(IMin.X, IMax.X, IMin.Y, IMax.Y, IMax.Z, NMax.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::QueueFillSplit(int32 L, const FIntVector& MinCells, const FIntVector& DimCells)
|
||||||
|
{
|
||||||
|
if (DimCells.X <= 0 || DimCells.Y <= 0 || DimCells.Z <= 0) return;
|
||||||
|
const int32 Slab = FMath::Clamp(Settings ? Settings->DensityVolumeFillSlabCells : 8, 1, 64);
|
||||||
|
for (int32 z0 = 0; z0 < DimCells.Z; z0 += Slab)
|
||||||
|
{
|
||||||
|
const int32 dz = FMath::Min(Slab, DimCells.Z - z0);
|
||||||
|
FPendingFill F;
|
||||||
|
F.Level = L;
|
||||||
|
F.MinCells = FIntVector(MinCells.X, MinCells.Y, MinCells.Z + z0);
|
||||||
|
F.DimCells = FIntVector(DimCells.X, DimCells.Y, dz);
|
||||||
|
F.Epoch = VolumeEpoch;
|
||||||
|
PendingFills.Add(MoveTemp(F));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// Capture-during-meshing (level-0): cache-fed fills, ingest, eviction
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::FillBoxFromCacheOrQueue(const FIntVector& MinCells, const FIntVector& DimCells)
|
||||||
|
{
|
||||||
|
if (DimCells.X <= 0 || DimCells.Y <= 0 || DimCells.Z <= 0) return;
|
||||||
|
const FIntVector BoxMax = MinCells + DimCells; // exclusive (level 0: cell coord == voxel coord)
|
||||||
|
|
||||||
|
// Iterate the level-0 tiles overlapping the box (a tile spans CHUNK_SIZE cells). Cached tiles blit
|
||||||
|
// straight from the captured grid (no GetDensityAt); uncached tiles fall back to a worker fill of
|
||||||
|
// just the box∩tile region (cold start, vertical strate gaps, evicted tiles).
|
||||||
|
const FIntVector TMin(FloorDiv(MinCells.X, CHUNK_SIZE), FloorDiv(MinCells.Y, CHUNK_SIZE), FloorDiv(MinCells.Z, CHUNK_SIZE));
|
||||||
|
const FIntVector TMax(FloorDiv(BoxMax.X - 1, CHUNK_SIZE), FloorDiv(BoxMax.Y - 1, CHUNK_SIZE), FloorDiv(BoxMax.Z - 1, CHUNK_SIZE));
|
||||||
|
|
||||||
|
for (int32 tz = TMin.Z; tz <= TMax.Z; ++tz)
|
||||||
|
for (int32 ty = TMin.Y; ty <= TMax.Y; ++ty)
|
||||||
|
for (int32 tx = TMin.X; tx <= TMax.X; ++tx)
|
||||||
|
{
|
||||||
|
const FIntVector T(tx, ty, tz);
|
||||||
|
if (const TArray<uint8>* Grid = CaptureCache.Find(T))
|
||||||
|
{
|
||||||
|
BlitCaptureToWindow(T, *Grid); // writes all of T's in-window cells (idempotent)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const FIntVector Org = T * CHUNK_SIZE;
|
||||||
|
const FIntVector IMin(FMath::Max(MinCells.X, Org.X), FMath::Max(MinCells.Y, Org.Y), FMath::Max(MinCells.Z, Org.Z));
|
||||||
|
const FIntVector IMax(FMath::Min(BoxMax.X, Org.X + CHUNK_SIZE),
|
||||||
|
FMath::Min(BoxMax.Y, Org.Y + CHUNK_SIZE),
|
||||||
|
FMath::Min(BoxMax.Z, Org.Z + CHUNK_SIZE)); // exclusive
|
||||||
|
QueueFillSplit(0, IMin, FIntVector(IMax.X - IMin.X, IMax.Y - IMin.Y, IMax.Z - IMin.Z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UVoxelDensityVolume::BlitCaptureToWindow(const FIntVector& L0TileCoord, const TArray<uint8>& Grid)
|
||||||
|
{
|
||||||
|
if (!Levels.IsValidIndex(0)) return false;
|
||||||
|
FClipLevel& Lv = Levels[0];
|
||||||
|
if (!Lv.bHasData) return false;
|
||||||
|
if (Grid.Num() < CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE) return false;
|
||||||
|
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const FIntVector W0 = Lv.OriginCells;
|
||||||
|
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res); // exclusive
|
||||||
|
const FIntVector Org = L0TileCoord * CHUNK_SIZE; // tile min cell == min voxel (step 1)
|
||||||
|
|
||||||
|
// Clamp the tile to the window once (per axis); reject if fully outside.
|
||||||
|
const int32 cx0 = FMath::Max(W0.X, Org.X), cx1 = FMath::Min(W1.X, Org.X + CHUNK_SIZE);
|
||||||
|
const int32 cy0 = FMath::Max(W0.Y, Org.Y), cy1 = FMath::Min(W1.Y, Org.Y + CHUNK_SIZE);
|
||||||
|
const int32 cz0 = FMath::Max(W0.Z, Org.Z), cz1 = FMath::Min(W1.Z, Org.Z + CHUNK_SIZE);
|
||||||
|
if (cx0 >= cx1 || cy0 >= cy1 || cz0 >= cz1) return false;
|
||||||
|
|
||||||
|
// Toroidal walk: one modulo per ROW, then the X run increments tx and wraps manually (this is on
|
||||||
|
// the game thread and batches a whole tile per chunk crossing — per-cell modulo would spike).
|
||||||
|
uint8* RESTRICT Dst = Lv.Density.GetData();
|
||||||
|
const uint8* RESTRICT Src = Grid.GetData();
|
||||||
|
const int32 tx0 = ((cx0 % Res) + Res) % Res;
|
||||||
|
const int32 gx0 = cx0 - Org.X;
|
||||||
|
for (int32 cz = cz0; cz < cz1; ++cz)
|
||||||
|
{
|
||||||
|
const int32 tz = ((cz % Res) + Res) % Res;
|
||||||
|
const int32 gz = cz - Org.Z;
|
||||||
|
for (int32 cy = cy0; cy < cy1; ++cy)
|
||||||
|
{
|
||||||
|
const int32 ty = ((cy % Res) + Res) % Res;
|
||||||
|
const int32 DstRow = (tz * Res + ty) * Res;
|
||||||
|
const int32 SrcRow = (gz * CHUNK_SIZE + (cy - Org.Y)) * CHUNK_SIZE;
|
||||||
|
int32 tx = tx0, gx = gx0;
|
||||||
|
for (int32 cx = cx0; cx < cx1; ++cx)
|
||||||
|
{
|
||||||
|
Dst[DstRow + tx] = Src[SrcRow + gx];
|
||||||
|
++gx;
|
||||||
|
if (++tx == Res) tx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Lv.bGPUDirty = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UVoxelDensityVolume::GetCaptureKeepBounds(FIntVector& OutLo, FIntVector& OutHi) const
|
||||||
|
{
|
||||||
|
if (!Levels.IsValidIndex(0) || !Levels[0].bHasData) return false;
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const FIntVector W0 = Levels[0].OriginCells;
|
||||||
|
|
||||||
|
// Tiles overlapping the window, +1 tile margin (keep the lead shell so a just-loaded tile isn't
|
||||||
|
// dropped before the window scrolls onto it).
|
||||||
|
OutLo = FIntVector(FloorDiv(W0.X, CHUNK_SIZE) - 1, FloorDiv(W0.Y, CHUNK_SIZE) - 1, FloorDiv(W0.Z, CHUNK_SIZE) - 1);
|
||||||
|
OutHi = FIntVector(FloorDiv(W0.X + Res - 1, CHUNK_SIZE) + 1, FloorDiv(W0.Y + Res - 1, CHUNK_SIZE) + 1, FloorDiv(W0.Z + Res - 1, CHUNK_SIZE) + 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::EvictFarCaptures()
|
||||||
|
{
|
||||||
|
if (CaptureCache.Num() == 0) return;
|
||||||
|
FIntVector TLo, THi;
|
||||||
|
if (!GetCaptureKeepBounds(TLo, THi)) return;
|
||||||
|
for (auto It = CaptureCache.CreateIterator(); It; ++It)
|
||||||
|
{
|
||||||
|
const FIntVector& T = It.Key();
|
||||||
|
if (T.X < TLo.X || T.X > THi.X || T.Y < TLo.Y || T.Y > THi.Y || T.Z < TLo.Z || T.Z > THi.Z)
|
||||||
|
{
|
||||||
|
It.RemoveCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UVoxelDensityVolume::IsTileCaptureUseful(const FIntVector& L0TileCoord) const
|
||||||
|
{
|
||||||
|
FIntVector TLo, THi;
|
||||||
|
if (!GetCaptureKeepBounds(TLo, THi)) return true; // no window yet → keep (cold start)
|
||||||
|
return L0TileCoord.X >= TLo.X && L0TileCoord.X <= THi.X
|
||||||
|
&& L0TileCoord.Y >= TLo.Y && L0TileCoord.Y <= THi.Y
|
||||||
|
&& L0TileCoord.Z >= TLo.Z && L0TileCoord.Z <= THi.Z;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::IngestTileCapture(const FIntVector& L0TileCoord, TArray<uint8>&& Grid)
|
||||||
|
{
|
||||||
|
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume) return;
|
||||||
|
if (Grid.Num() < CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE) return;
|
||||||
|
|
||||||
|
// Only cache tiles inside the keep bounds (window + lead-shell margin). The level-0 STREAMING ring
|
||||||
|
// is much larger than the shadow window — most streamed tiles can never blit and would only sit in
|
||||||
|
// the cache (32 KB each) until the next recenter evicted them. Same policy EvictFarCaptures applies.
|
||||||
|
// (LoadTile already pre-gates the capture with this test; this re-check is authoritative in case
|
||||||
|
// the window scrolled while the tile's gen task was in flight.)
|
||||||
|
if (!IsTileCaptureUseful(L0TileCoord)) return;
|
||||||
|
|
||||||
|
// Store (overwrite) — the cache is RecenterLevel(0)'s fill source and survives until the tile
|
||||||
|
// scrolls out of the window. Blit now so cells already in view refresh immediately (a tile that
|
||||||
|
// finished after the window exposed it, or a re-mesh after a carve).
|
||||||
|
TArray<uint8>& Slot = CaptureCache.FindOrAdd(L0TileCoord);
|
||||||
|
Slot = MoveTemp(Grid);
|
||||||
|
BlitCaptureToWindow(L0TileCoord, Slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::EnsureFillThread()
|
||||||
|
{
|
||||||
|
if (FillThread) return;
|
||||||
|
if (!Settings || !Settings->bEnableDensityVolume) return;
|
||||||
|
bFillThreadStop.store(false, std::memory_order_release);
|
||||||
|
if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset
|
||||||
|
FillRunnable = new FVoxelDensityFillRunnable(this);
|
||||||
|
FillThread = FRunnableThread::Create(FillRunnable, TEXT("VoxelDensityFill"), 0, TPri_Normal);
|
||||||
|
if (!FillThread) // creation failed → don't leak the runnable; fills just won't drain (no crash)
|
||||||
|
{
|
||||||
|
delete FillRunnable;
|
||||||
|
FillRunnable = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::StopFillThread()
|
||||||
|
{
|
||||||
|
bFillThreadStop.store(true, std::memory_order_release);
|
||||||
|
if (FillWakeEvent) { FillWakeEvent->Trigger(); } // wake it so it sees the stop
|
||||||
|
if (FillThread)
|
||||||
|
{
|
||||||
|
FillThread->Kill(true); // calls Stop() + blocks until Run() returns (no more Generator reads)
|
||||||
|
delete FillThread;
|
||||||
|
FillThread = nullptr;
|
||||||
|
}
|
||||||
|
if (FillRunnable) { delete FillRunnable; FillRunnable = nullptr; }
|
||||||
|
if (FillWakeEvent) { FPlatformProcess::ReturnSynchEventToPool(FillWakeEvent); FillWakeEvent = nullptr; }
|
||||||
|
FPendingFill Discard;
|
||||||
|
while (FillQueue.Dequeue(Discard)) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::LaunchPendingFills()
|
||||||
|
{
|
||||||
|
if (PendingFills.Num() == 0) return;
|
||||||
|
EnsureFillThread();
|
||||||
|
for (FPendingFill& F : PendingFills)
|
||||||
|
{
|
||||||
|
FillQueue.Enqueue(MoveTemp(F)); // Spsc: game thread is the only producer
|
||||||
|
}
|
||||||
|
PendingFills.Reset();
|
||||||
|
if (FillWakeEvent) { FillWakeEvent->Trigger(); } // wake the fill thread (after the Enqueues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RUNS ON THE FILL THREAD. Reads only the Generator (thread-safe, deterministic — same contract as the
|
||||||
|
// old worker tasks) and pushes the result into the Mpsc Results queue. Step = 1<<Level (never read from
|
||||||
|
// the game-thread-mutated Levels array). Bails on shutdown so the Generator can be torn down after Kill.
|
||||||
|
void UVoxelDensityVolume::ProcessOneFill(const FPendingFill& F)
|
||||||
|
{
|
||||||
|
UVoxelGenerator* Gen = Generator;
|
||||||
|
if (!Gen) return;
|
||||||
|
if (bShuttingDown.load(std::memory_order_relaxed) || bFillThreadStop.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
const int32 Step = 1 << F.Level;
|
||||||
|
FFillResult R;
|
||||||
|
R.Level = F.Level;
|
||||||
|
R.Epoch = F.Epoch;
|
||||||
|
R.MinCells = F.MinCells;
|
||||||
|
R.DimCells = F.DimCells;
|
||||||
|
const int32 Count = F.DimCells.X * F.DimCells.Y * F.DimCells.Z;
|
||||||
|
if (Count <= 0) return;
|
||||||
|
R.Data.SetNumUninitialized(Count);
|
||||||
|
|
||||||
|
int32 i = 0;
|
||||||
|
for (int32 z = 0; z < F.DimCells.Z; ++z)
|
||||||
|
{
|
||||||
|
if (bFillThreadStop.load(std::memory_order_relaxed)) return; // periodic bail on big boxes
|
||||||
|
const float WZ = (float)((F.MinCells.Z + z) * Step);
|
||||||
|
for (int32 y = 0; y < F.DimCells.Y; ++y)
|
||||||
|
{
|
||||||
|
const float WY = (float)((F.MinCells.Y + y) * Step);
|
||||||
|
for (int32 x = 0; x < F.DimCells.X; ++x)
|
||||||
|
{
|
||||||
|
const float WX = (float)((F.MinCells.X + x) * Step);
|
||||||
|
R.Data[i++] = Quantize(Gen->GetDensityAt(WX, WY, WZ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bFillThreadStop.load(std::memory_order_relaxed)) return;
|
||||||
|
Results.Enqueue(MoveTemp(R));
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::DrainResults()
|
||||||
|
{
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
FFillResult R;
|
||||||
|
while (Results.Dequeue(R))
|
||||||
|
{
|
||||||
|
if (R.Epoch != VolumeEpoch) continue; // stale (regen/season reset) → drop
|
||||||
|
if (!Levels.IsValidIndex(R.Level)) continue;
|
||||||
|
FClipLevel& Lv = Levels[R.Level];
|
||||||
|
if (!Lv.bHasData) continue;
|
||||||
|
|
||||||
|
const FIntVector W0 = Lv.OriginCells;
|
||||||
|
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res); // exclusive
|
||||||
|
|
||||||
|
int32 i = 0;
|
||||||
|
for (int32 z = 0; z < R.DimCells.Z; ++z)
|
||||||
|
{
|
||||||
|
const int32 cz = R.MinCells.Z + z;
|
||||||
|
for (int32 y = 0; y < R.DimCells.Y; ++y)
|
||||||
|
{
|
||||||
|
const int32 cy = R.MinCells.Y + y;
|
||||||
|
for (int32 x = 0; x < R.DimCells.X; ++x)
|
||||||
|
{
|
||||||
|
const int32 cx = R.MinCells.X + x;
|
||||||
|
const uint8 v = R.Data[i++];
|
||||||
|
// Skip cells that scrolled out of the window since launch — their toroidal slot now
|
||||||
|
// belongs to a different cell (which has its own pending fill). In-window cells own
|
||||||
|
// their slot, so writing is always correct. (A pre-carve fill landing after the
|
||||||
|
// carve's own re-fill is a rare 1-frame transient — both sample GetDensityAt incl.
|
||||||
|
// the diff, so it self-heals on the next refill of that cell.)
|
||||||
|
if (cx < W0.X || cx >= W1.X || cy < W0.Y || cy >= W1.Y || cz < W0.Z || cz >= W1.Z) continue;
|
||||||
|
const int32 tx = ((cx % Res) + Res) % Res;
|
||||||
|
const int32 ty = ((cy % Res) + Res) % Res;
|
||||||
|
const int32 tz = ((cz % Res) + Res) % Res;
|
||||||
|
Lv.Density[(tz * Res + ty) * Res + tx] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lv.bGPUDirty = true; // a fill landed → re-upload this level to the GPU next UploadDirtyTextures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// Carve invalidation
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::MarkDirtyVoxelBox(const FIntVector& MinVoxel, const FIntVector& MaxVoxel)
|
||||||
|
{
|
||||||
|
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume) return;
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const int32 N = Levels.Num();
|
||||||
|
|
||||||
|
// Capture invalidation (level 0): the cached grids hold PRE-carve density. Drop the ones the carve
|
||||||
|
// touched so a later RecenterLevel(0) can't blit stale rock over the edit. The worker fill queued
|
||||||
|
// below is the backstop until RemeshDirtyChunks re-meshes the tile and re-ingests a fresh (post-
|
||||||
|
// diff) capture. ±1 tile margin to match the carve-falloff bleed used for the cell box below.
|
||||||
|
if (CaptureCache.Num() > 0)
|
||||||
|
{
|
||||||
|
const FIntVector TLo(FloorDiv(MinVoxel.X, CHUNK_SIZE) - 1, FloorDiv(MinVoxel.Y, CHUNK_SIZE) - 1, FloorDiv(MinVoxel.Z, CHUNK_SIZE) - 1);
|
||||||
|
const FIntVector THi(FloorDiv(MaxVoxel.X, CHUNK_SIZE) + 1, FloorDiv(MaxVoxel.Y, CHUNK_SIZE) + 1, FloorDiv(MaxVoxel.Z, CHUNK_SIZE) + 1);
|
||||||
|
for (int32 tz = TLo.Z; tz <= THi.Z; ++tz)
|
||||||
|
for (int32 ty = TLo.Y; ty <= THi.Y; ++ty)
|
||||||
|
for (int32 tx = TLo.X; tx <= THi.X; ++tx)
|
||||||
|
{
|
||||||
|
CaptureCache.Remove(FIntVector(tx, ty, tz));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int32 L = 0; L < N; ++L)
|
||||||
|
{
|
||||||
|
FClipLevel& Lv = Levels[L];
|
||||||
|
if (!Lv.bHasData) continue;
|
||||||
|
const int32 Step = Lv.Step;
|
||||||
|
|
||||||
|
// Voxel box → cell box, with a ±1 cell margin (carve falloff bleeds past the exact box).
|
||||||
|
FIntVector CMin(FloorDiv(MinVoxel.X, Step) - 1, FloorDiv(MinVoxel.Y, Step) - 1, FloorDiv(MinVoxel.Z, Step) - 1);
|
||||||
|
FIntVector CMax(FloorDiv(MaxVoxel.X, Step) + 1, FloorDiv(MaxVoxel.Y, Step) + 1, FloorDiv(MaxVoxel.Z, Step) + 1); // inclusive
|
||||||
|
|
||||||
|
// Clip to the level's current window [Origin, Origin+Res).
|
||||||
|
const FIntVector W0 = Lv.OriginCells;
|
||||||
|
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res); // exclusive
|
||||||
|
CMin = FIntVector(FMath::Max(CMin.X, W0.X), FMath::Max(CMin.Y, W0.Y), FMath::Max(CMin.Z, W0.Z));
|
||||||
|
CMax = FIntVector(FMath::Min(CMax.X, W1.X - 1), FMath::Min(CMax.Y, W1.Y - 1), FMath::Min(CMax.Z, W1.Z - 1));
|
||||||
|
if (CMax.X < CMin.X || CMax.Y < CMin.Y || CMax.Z < CMin.Z) continue; // no overlap with this level
|
||||||
|
|
||||||
|
QueueFillSplit(L, CMin, FIntVector(CMax.X - CMin.X + 1, CMax.Y - CMin.Y + 1, CMax.Z - CMin.Z + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// Debug visualization (step 1a verification — no GPU)
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
#if ENABLE_DRAW_DEBUG
|
||||||
|
void UVoxelDensityVolume::DebugDraw() const
|
||||||
|
{
|
||||||
|
if (!Settings || !Settings->bDebugDrawDensityVolume) return;
|
||||||
|
AActor* O = Owner.Get();
|
||||||
|
if (!O || Levels.Num() == 0) return;
|
||||||
|
const FClipLevel& Lv = Levels[0]; // level 0 = step 1 → cell coord == voxel coord
|
||||||
|
if (!Lv.bHasData) return;
|
||||||
|
UWorld* W = O->GetWorld();
|
||||||
|
if (!W) return;
|
||||||
|
|
||||||
|
const int32 Res = ResPerAxis();
|
||||||
|
const FTransform Xf = O->GetActorTransform();
|
||||||
|
const int32 R = FMath::Clamp(Settings->DensityVolumeDebugRadiusCells, 1, 32);
|
||||||
|
// Draw a THIN horizontal slab through the player (not a full 3D ball) — far cheaper and it reads
|
||||||
|
// as the cave silhouette around you. A full sphere of DrawDebugBox is thousands of boxes/frame =
|
||||||
|
// tens of thousands of line segments → big game-thread lag. The volume itself is off-thread.
|
||||||
|
const int32 ZBand = 2; // ±2 cells (5 layers) around the player
|
||||||
|
const FIntVector PC = LastPlayerVoxel; // level-0 cell == voxel
|
||||||
|
const FIntVector W0 = Lv.OriginCells;
|
||||||
|
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res);
|
||||||
|
const float Half = VOXEL_SIZE * 0.5f;
|
||||||
|
|
||||||
|
int32 Drawn = 0;
|
||||||
|
const int32 Cap = 4000; // bound the debug-draw cost
|
||||||
|
for (int32 dz = -ZBand; dz <= ZBand; ++dz)
|
||||||
|
for (int32 dy = -R; dy <= R; ++dy)
|
||||||
|
for (int32 dx = -R; dx <= R; ++dx)
|
||||||
|
{
|
||||||
|
const int32 cx = PC.X + dx, cy = PC.Y + dy, cz = PC.Z + dz;
|
||||||
|
if (cx < W0.X || cx >= W1.X || cy < W0.Y || cy >= W1.Y || cz < W0.Z || cz >= W1.Z) continue;
|
||||||
|
const int32 tx = ((cx % Res) + Res) % Res;
|
||||||
|
const int32 ty = ((cy % Res) + Res) % Res;
|
||||||
|
const int32 tz = ((cz % Res) + Res) % Res;
|
||||||
|
if (Lv.Density[(tz * Res + ty) * Res + tx] <= 128) continue; // air (iso ≈ 128)
|
||||||
|
|
||||||
|
const FVector LocalCenter((cx + 0.5f) * VOXEL_SIZE, (cy + 0.5f) * VOXEL_SIZE, (cz + 0.5f) * VOXEL_SIZE);
|
||||||
|
const FVector WorldC = Xf.TransformPosition(LocalCenter);
|
||||||
|
DrawDebugBox(W, WorldC, FVector(Half), FColor::Cyan, false, -1.0f, 0, 1.0f);
|
||||||
|
if (++Drawn >= Cap) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -4,77 +4,17 @@
|
|||||||
#include "VoxelMarchingCubesMesher.h"
|
#include "VoxelMarchingCubesMesher.h"
|
||||||
#include "MarchingCubesTables.h"
|
#include "MarchingCubesTables.h"
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// DENSITY SAMPLING
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
float UVoxelMarchingCubesMesher::GetDensity(const FVoxelChunk& Chunk, int32 X, int32 Y, int32 Z) const
|
|
||||||
{
|
|
||||||
// On n'utilise plus de stockage de blocs — densité demandée directement
|
|
||||||
// au générateur, qui produit la valeur pour TOUTE coordonnée monde.
|
|
||||||
// Si le générateur manque, le chunk est considéré tout-air (IsoLevel par défaut = 0).
|
|
||||||
if (!Generator) return 0.0f;
|
|
||||||
|
|
||||||
const float WorldX = Chunk.ChunkCoord.X * CHUNK_SIZE + X;
|
|
||||||
const float WorldY = Chunk.ChunkCoord.Y * CHUNK_SIZE + Y;
|
|
||||||
const float WorldZ = Chunk.ChunkCoord.Z * CHUNK_SIZE + Z;
|
|
||||||
return Generator->GetDensityAt(WorldX, WorldY, WorldZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// EDGE INTERPOLATION
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
FVector UVoxelMarchingCubesMesher::InterpolateEdge(
|
|
||||||
const FVector& P1, const FVector& P2,
|
|
||||||
float D1, float D2) const
|
|
||||||
{
|
|
||||||
// Densités quasi-égales → on prend le milieu (évite division par ~0).
|
|
||||||
if (FMath::Abs(D2 - D1) < KINDA_SMALL_NUMBER)
|
|
||||||
{
|
|
||||||
return (P1 + P2) * 0.5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// t = 0 → surface en P1; t = 1 → surface en P2.
|
|
||||||
float T = (IsoLevel - D1) / (D2 - D1);
|
|
||||||
T = FMath::Clamp(T, 0.0f, 1.0f);
|
|
||||||
return P1 + T * (P2 - P1);
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// NORMAL (gradient central de densité)
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
FVector UVoxelMarchingCubesMesher::ComputeGradientNormal(float WorldX, float WorldY, float WorldZ) const
|
|
||||||
{
|
|
||||||
// Convention: densité négative = solide, positive = air.
|
|
||||||
// Le gradient pointe solide→air = vers l'extérieur de la surface.
|
|
||||||
// Pas de négation à faire.
|
|
||||||
const float Dx = Generator->GetDensityAt(WorldX + GradientOffset, WorldY, WorldZ)
|
|
||||||
- Generator->GetDensityAt(WorldX - GradientOffset, WorldY, WorldZ);
|
|
||||||
const float Dy = Generator->GetDensityAt(WorldX, WorldY + GradientOffset, WorldZ)
|
|
||||||
- Generator->GetDensityAt(WorldX, WorldY - GradientOffset, WorldZ);
|
|
||||||
const float Dz = Generator->GetDensityAt(WorldX, WorldY, WorldZ + GradientOffset)
|
|
||||||
- Generator->GetDensityAt(WorldX, WorldY, WorldZ - GradientOffset);
|
|
||||||
|
|
||||||
FVector Normal(Dx, Dy, Dz);
|
|
||||||
Normal.Normalize();
|
|
||||||
|
|
||||||
// Fallback si le gradient est dégénéré (zone plate).
|
|
||||||
if (Normal.IsNearlyZero())
|
|
||||||
{
|
|
||||||
Normal = FVector(0.0f, 0.0f, 1.0f);
|
|
||||||
}
|
|
||||||
return Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// MAIN ALGORITHM
|
// MAIN ALGORITHM
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
// (L'ancien trio GetDensity / InterpolateEdge / ComputeGradientNormal a été retiré :
|
||||||
|
// mort depuis T1.b — la grille pré-échantillonnée fournit positions ET gradients.)
|
||||||
|
|
||||||
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis)
|
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis,
|
||||||
|
TArray<uint8>* OutCaptureGrid)
|
||||||
{
|
{
|
||||||
FVoxelMeshData MeshData;
|
FVoxelMeshData MeshData;
|
||||||
|
if (OutCaptureGrid) { OutCaptureGrid->Reset(); }
|
||||||
if (!Generator) return MeshData;
|
if (!Generator) return MeshData;
|
||||||
|
|
||||||
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
|
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
|
||||||
@@ -212,6 +152,27 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CAPTURE-DURING-MESHING ──
|
||||||
|
// Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1<<Level,
|
||||||
|
// donc chaque point de grille = exactement une cellule du clipmap de densité), on recopie les
|
||||||
|
// CHUNK_SIZE³ points INTÉRIEURS (g=0..CHUNK_SIZE-1, on exclut le point frontière +1 — il
|
||||||
|
// appartient à la tuile voisine — et l'anneau de marge ±1) dans OutCaptureGrid, quantifiés.
|
||||||
|
// UVoxelDensityVolume réutilise ces octets au lieu de re-sampler GetDensityAt. Pure lecture de
|
||||||
|
// DensityGrid : la forme de grille, la boucle deux passes, l'anneau de marge et la réutilisation
|
||||||
|
// thread_local restent intacts (§8.10). Ordre X→Y→Z (x rapide) = layout attendu par l'ingest.
|
||||||
|
if (OutCaptureGrid && CellsPerAxis == CHUNK_SIZE)
|
||||||
|
{
|
||||||
|
OutCaptureGrid->SetNumUninitialized(CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE);
|
||||||
|
uint8* Cap = OutCaptureGrid->GetData();
|
||||||
|
int32 ci = 0;
|
||||||
|
for (int32 gz = 0; gz < CHUNK_SIZE; ++gz)
|
||||||
|
for (int32 gy = 0; gy < CHUNK_SIZE; ++gy)
|
||||||
|
for (int32 gx = 0; gx < CHUNK_SIZE; ++gx)
|
||||||
|
{
|
||||||
|
Cap[ci++] = VF_QuantizeDensity(DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lecture grille (avec offset de marge) + gradient central depuis la grille.
|
// Lecture grille (avec offset de marge) + gradient central depuis la grille.
|
||||||
auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float
|
auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float
|
||||||
{
|
{
|
||||||
@@ -269,9 +230,9 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
Gradients[i] = GradAt(GX, GY, GZ);
|
Gradients[i] = GradAt(GX, GY, GZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpolation des positions + normales sur les arêtes traversées. Le t est
|
// Interpolation des positions + normales sur les arêtes traversées. t = point de
|
||||||
// calculé exactement comme InterpolateEdge → positions bit-identiques (topologie
|
// traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ;
|
||||||
// inchangée) ; la normale interpole les gradients de coin par le même t.
|
// la normale interpole les gradients de coin par le même t.
|
||||||
FVector EdgeVertices[12];
|
FVector EdgeVertices[12];
|
||||||
FVector EdgeNormals[12];
|
FVector EdgeNormals[12];
|
||||||
for (int32 i = 0; i < 12; i++)
|
for (int32 i = 0; i < 12; i++)
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ void UVoxelStrateManager::GeneratePassages()
|
|||||||
Passage.ControlRadii.Add(RadiusAt(T));
|
Passage.ControlRadii.Add(RadiusAt(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
Passage.bHasMidPoint = false;
|
|
||||||
Passage.UpperPoint = Passage.ControlPoints[0];
|
Passage.UpperPoint = Passage.ControlPoints[0];
|
||||||
Passage.LowerPoint = Passage.ControlPoints.Last();
|
Passage.LowerPoint = Passage.ControlPoints.Last();
|
||||||
Passage.Radius = FMath::Max(Cfg.MouthRadius, Cfg.MidRadius); // fallback / bounds
|
Passage.Radius = FMath::Max(Cfg.MouthRadius, Cfg.MidRadius); // fallback / bounds
|
||||||
@@ -309,6 +308,7 @@ void UVoxelStrateManager::GeneratePassages()
|
|||||||
MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, CP));
|
MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, CP));
|
||||||
const float R = FMath::Sqrt(MaxDistSq) + Passage.Radius + 4.0f;
|
const float R = FMath::Sqrt(MaxDistSq) + Passage.Radius + 4.0f;
|
||||||
Passage.BoundCenter = Center;
|
Passage.BoundCenter = Center;
|
||||||
|
Passage.BoundRadius = R;
|
||||||
Passage.BoundRadiusSq = R * R;
|
Passage.BoundRadiusSq = R * R;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,11 +336,11 @@ void UVoxelStrateManager::GeneratePassages()
|
|||||||
// past the seal into the interior, so the seal at (0,0) is breached.
|
// past the seal into the interior, so the seal at (0,0) is breached.
|
||||||
Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE);
|
Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE);
|
||||||
Entry.LowerPoint = FVector(0.0f, 0.0f, TopZ - CHUNK_SIZE);
|
Entry.LowerPoint = FVector(0.0f, 0.0f, TopZ - CHUNK_SIZE);
|
||||||
Entry.bHasMidPoint = false;
|
|
||||||
{
|
{
|
||||||
const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f;
|
const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f;
|
||||||
const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f;
|
const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f;
|
||||||
Entry.BoundCenter = C;
|
Entry.BoundCenter = C;
|
||||||
|
Entry.BoundRadius = R;
|
||||||
Entry.BoundRadiusSq = R * R;
|
Entry.BoundRadiusSq = R * R;
|
||||||
}
|
}
|
||||||
Passages.Add(Entry);
|
Passages.Add(Entry);
|
||||||
@@ -396,7 +396,7 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float
|
|||||||
for (int32 i = 0; i < Passages.Num(); ++i)
|
for (int32 i = 0; i < Passages.Num(); ++i)
|
||||||
{
|
{
|
||||||
const FVoxelPassage& P = Passages[i];
|
const FVoxelPassage& P = Passages[i];
|
||||||
const float Reach = FMath::Sqrt(P.BoundRadiusSq) + ChunkR;
|
const float Reach = P.BoundRadius + ChunkR;
|
||||||
if (FVector::DistSquared(CCenter, P.BoundCenter) <= Reach * Reach)
|
if (FVector::DistSquared(CCenter, P.BoundCenter) <= Reach * Reach)
|
||||||
{
|
{
|
||||||
SL_Nearby.Add(i);
|
SL_Nearby.Add(i);
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
#include "VoxelBiomeDefinition.h"
|
#include "VoxelBiomeDefinition.h"
|
||||||
#include "VoxelTerrainOpDefinition.h"
|
#include "VoxelTerrainOpDefinition.h"
|
||||||
#include "VoxelContentManager.h"
|
#include "VoxelContentManager.h"
|
||||||
|
#include "VoxelDensityVolume.h"
|
||||||
|
#include "Materials/MaterialInstanceDynamic.h"
|
||||||
|
#include "Materials/MaterialParameterCollection.h"
|
||||||
|
#include "Kismet/KismetMaterialLibrary.h"
|
||||||
|
#include "Engine/VolumeTexture.h"
|
||||||
#include "VoxelAtmosphereManager.h"
|
#include "VoxelAtmosphereManager.h"
|
||||||
#include "DrawDebugHelpers.h"
|
#include "DrawDebugHelpers.h"
|
||||||
#include "IImageWrapper.h"
|
#include "IImageWrapper.h"
|
||||||
@@ -89,6 +94,9 @@ void AVoxelWorld::RegenerateAllChunks()
|
|||||||
// Decorations/water are keyed per level-0 chunk — clear them all.
|
// Decorations/water are keyed per level-0 chunk — clear them all.
|
||||||
if (ContentManager) { ContentManager->ClearAll(); }
|
if (ContentManager) { ContentManager->ClearAll(); }
|
||||||
|
|
||||||
|
// Density volume: bump epoch (drop in-flight fills) + drop data → full refill next Tick.
|
||||||
|
if (DensityVolume) { DensityVolume->Reset(); }
|
||||||
|
|
||||||
// Clear pending set — stale tasks will be discarded by the epoch check.
|
// Clear pending set — stale tasks will be discarded by the epoch check.
|
||||||
PendingTiles.Empty();
|
PendingTiles.Empty();
|
||||||
// Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
|
// Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
|
||||||
@@ -249,6 +257,12 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
|||||||
ContentManager->NotifyShutdown();
|
ContentManager->NotifyShutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop + drain the density-volume fill tasks (they read the Generator) before UObject teardown.
|
||||||
|
if (DensityVolume)
|
||||||
|
{
|
||||||
|
DensityVolume->NotifyShutdown();
|
||||||
|
}
|
||||||
|
|
||||||
// Destroy any spawned atmosphere layer actors.
|
// Destroy any spawned atmosphere layer actors.
|
||||||
if (AtmosphereManager)
|
if (AtmosphereManager)
|
||||||
{
|
{
|
||||||
@@ -328,6 +342,13 @@ void AVoxelWorld::BeginPlay()
|
|||||||
AtmosphereManager->Initialize(this, StrateManager, Generator);
|
AtmosphereManager->Initialize(this, StrateManager, Generator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Density volume — player-centred clipmap streamed to the GPU for mini-sun raymarched shadows.
|
||||||
|
if (Settings->bEnableDensityVolume)
|
||||||
|
{
|
||||||
|
DensityVolume = NewObject<UVoxelDensityVolume>(this);
|
||||||
|
DensityVolume->Initialize(this, Generator, Settings);
|
||||||
|
}
|
||||||
|
|
||||||
#if WITH_EDITOR
|
#if WITH_EDITOR
|
||||||
// Listen for data asset edits during PIE so live edit can detect
|
// Listen for data asset edits during PIE so live edit can detect
|
||||||
// strate definition changes (PostEditChangeProperty only fires for
|
// strate definition changes (PostEditChangeProperty only fires for
|
||||||
@@ -354,14 +375,31 @@ void AVoxelWorld::Tick(float DeltaTime)
|
|||||||
// Distance-based decoration streaming (no LOD pop). Cheap no-op unless the player crosses
|
// Distance-based decoration streaming (no LOD pop). Cheap no-op unless the player crosses
|
||||||
// a decoration cell boundary or changes strate; otherwise just drains the spawn budget.
|
// a decoration cell boundary or changes strate; otherwise just drains the spawn budget.
|
||||||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); }
|
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); }
|
||||||
|
// Rare hash-lattice landmarks (the "mini-suns") — cheap at any radius (scales with count, not area).
|
||||||
|
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateLandmarks); ContentManager->UpdateLandmarks(PlayerLastPos); }
|
||||||
// One strate-global ocean plane following the player (water at every LOD, to the horizon).
|
// One strate-global ocean plane following the player (water at every LOD, to the horizon).
|
||||||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); }
|
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); }
|
||||||
}
|
}
|
||||||
|
if (DensityVolume)
|
||||||
|
{
|
||||||
|
// Density clipmap for mini-sun shadows: recentre + queue/launch/drain worker fills.
|
||||||
|
// Cheap unless the player crossed a level-0 cell boundary or a carve dirtied cells.
|
||||||
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDensityVolume);
|
||||||
|
DensityVolume->Update(PlayerLastPos);
|
||||||
|
// Push the clipmap transform + nearest orb to the terrain MIDs (the material's shadow march).
|
||||||
|
UpdateTerrainMaterialParams();
|
||||||
|
}
|
||||||
|
// Bounded-directional mini-sun lighting: stream the nearest orbs into the Light Function MPC.
|
||||||
|
// Independent of the density volume (self-guards on OrbLightMPC); this is the replacement path.
|
||||||
|
UpdateOrbLightMPC();
|
||||||
}
|
}
|
||||||
ProcessPendingChunks();
|
ProcessPendingChunks();
|
||||||
ProcessUnloadQueue();
|
ProcessUnloadQueue();
|
||||||
|
|
||||||
#if ENABLE_DRAW_DEBUG
|
#if ENABLE_DRAW_DEBUG
|
||||||
|
// Density-volume overlay (step 1a): cyan boxes for solid level-0 cells near the player.
|
||||||
|
if (DensityVolume) { DensityVolume->DebugDraw(); }
|
||||||
|
|
||||||
// Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints).
|
// Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints).
|
||||||
// Points are in voxel coords → world units (×VOXEL_SIZE) → actor space.
|
// Points are in voxel coords → world units (×VOXEL_SIZE) → actor space.
|
||||||
if (bDebugDrawPassages && StrateManager)
|
if (bDebugDrawPassages && StrateManager)
|
||||||
@@ -380,11 +418,6 @@ void AVoxelWorld::Tick(float DeltaTime)
|
|||||||
for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j)
|
for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j)
|
||||||
DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]);
|
DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]);
|
||||||
}
|
}
|
||||||
else if (P.bHasMidPoint)
|
|
||||||
{
|
|
||||||
DrawSeg(P.UpperPoint, P.MidPoint);
|
|
||||||
DrawSeg(P.MidPoint, P.LowerPoint);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
DrawSeg(P.UpperPoint, P.LowerPoint);
|
DrawSeg(P.UpperPoint, P.LowerPoint);
|
||||||
@@ -408,65 +441,6 @@ FVector AVoxelWorld::GetPlayerPosition() const
|
|||||||
return FVector::ZeroVector;
|
return FVector::ZeroVector;
|
||||||
}
|
}
|
||||||
|
|
||||||
int32 AVoxelWorld::GetLODForChunk(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const
|
|
||||||
{
|
|
||||||
// Chebyshev distance (max of absolute differences on each axis)
|
|
||||||
// This gives a cubic LOD zone instead of spherical — simpler and
|
|
||||||
// matches how chunks are loaded (cubic view distance).
|
|
||||||
FIntVector Delta = ChunkCoord - CenterChunk;
|
|
||||||
int32 Distance = FMath::Max3(
|
|
||||||
FMath::Abs(Delta.X),
|
|
||||||
FMath::Abs(Delta.Y),
|
|
||||||
FMath::Abs(Delta.Z)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Distance <= Settings->LOD0Distance)
|
|
||||||
{
|
|
||||||
return 0; // Full resolution
|
|
||||||
}
|
|
||||||
else if (Distance <= Settings->LOD1Distance)
|
|
||||||
{
|
|
||||||
return 1; // Half resolution
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return 2; // Quarter resolution
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int32 AVoxelWorld::LODToStep(int32 LODLevel)
|
|
||||||
{
|
|
||||||
// LOD0 → 1, LOD1 → 2, LOD2 → 4
|
|
||||||
// Using bit shift: 1 << LODLevel
|
|
||||||
return 1 << FMath::Clamp(LODLevel, 0, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AVoxelWorld::IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const
|
|
||||||
{
|
|
||||||
const int32 ViewXY = Settings->ViewDistanceXY;
|
|
||||||
const int32 ViewUp = Settings->ViewDistanceUp;
|
|
||||||
const int32 ViewDown = Settings->ViewDistanceDown;
|
|
||||||
FIntVector Range = ChunkCoord - CenterChunk;
|
|
||||||
|
|
||||||
if ((FMath::Abs(Range.X) <= ViewXY) and (FMath::Abs(Range.Y) <= ViewXY)) {
|
|
||||||
if (Range.Z > 0)
|
|
||||||
{
|
|
||||||
if (FMath::Abs(Range.Z) <= ViewUp)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (FMath::Abs(Range.Z) <= ViewDown)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void AVoxelWorld::ProcessPendingChunks()
|
void AVoxelWorld::ProcessPendingChunks()
|
||||||
{
|
{
|
||||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
||||||
@@ -509,6 +483,14 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
// Mark the tile loaded (even if empty — so we don't re-submit it).
|
// Mark the tile loaded (even if empty — so we don't re-submit it).
|
||||||
LoadedTiles.Add(DequeuedChunk.Tile);
|
LoadedTiles.Add(DequeuedChunk.Tile);
|
||||||
|
|
||||||
|
// CAPTURE-DURING-MESHING: hand the mesher's captured density grid to the clipmap BEFORE the
|
||||||
|
// empty-tile early-out — all-air / all-solid tiles are exactly the uniform cells the volume
|
||||||
|
// needs, and they carry a valid CaptureGrid even though they render nothing.
|
||||||
|
if (DensityVolume && DequeuedChunk.CaptureGrid.Num() > 0)
|
||||||
|
{
|
||||||
|
DensityVolume->IngestTileCapture(DequeuedChunk.Tile.Coord, MoveTemp(DequeuedChunk.CaptureGrid));
|
||||||
|
}
|
||||||
|
|
||||||
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
||||||
if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams)
|
if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams)
|
||||||
{
|
{
|
||||||
@@ -807,13 +789,22 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
|
|||||||
const int32 Step = FMath::Max(1, Extent / Cells);
|
const int32 Step = FMath::Max(1, Extent / Cells);
|
||||||
const uint32 TaskEpoch = GenerationEpoch;
|
const uint32 TaskEpoch = GenerationEpoch;
|
||||||
|
|
||||||
|
// CAPTURE-DURING-MESHING: only level-0 full-res tiles map 1:1 onto a density-clipmap level
|
||||||
|
// (Step == 1<<Level, Cells == CHUNK_SIZE). When the density volume is active, ask the mesher to
|
||||||
|
// emit the captured R8 grid so the volume reuses it instead of re-sampling GetDensityAt. Gated to
|
||||||
|
// tiles the volume can actually consume (its shadow window is much smaller than the streaming
|
||||||
|
// ring) — the rest shouldn't pay the quantize + 32 KB queue payload for a grid it would refuse.
|
||||||
|
const bool bWantCapture = (Tile.Level == 0) && (Cells == CHUNK_SIZE)
|
||||||
|
&& DensityVolume != nullptr && Settings && Settings->bEnableDensityVolume
|
||||||
|
&& DensityVolume->IsTileCaptureUseful(Tile.Coord);
|
||||||
|
|
||||||
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
|
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
// BackgroundNormal priority: gen runs on background workers that YIELD to foreground
|
// BackgroundNormal priority: gen runs on background workers that YIELD to foreground
|
||||||
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare
|
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare
|
||||||
// core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom).
|
// core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom).
|
||||||
// At background priority the frame keeps its cores; gen just fills in around it.
|
// At background priority the frame keeps its cores; gen just fills in around it.
|
||||||
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch]()
|
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture]()
|
||||||
{
|
{
|
||||||
// RAII: decrement the counter on every exit path.
|
// RAII: decrement the counter on every exit path.
|
||||||
struct FTaskGuard
|
struct FTaskGuard
|
||||||
@@ -831,7 +822,8 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
|
|||||||
FVoxelMeshData MeshData;
|
FVoxelMeshData MeshData;
|
||||||
{
|
{
|
||||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||||||
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
|
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells,
|
||||||
|
bWantCapture ? &Result.CaptureGrid : nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
||||||
@@ -925,6 +917,17 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FReal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mini-sun shadows: route the resolved base material through a shared MID that binds the density-volume
|
||||||
|
// textures + per-frame shadow params (the material marches them for raymarched orb shadows). One MID
|
||||||
|
// per base material, so all tiles of a base still share one material (no batching cost).
|
||||||
|
if (DensityVolume && Settings && Settings->bEnableDensityVolume)
|
||||||
|
{
|
||||||
|
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(ChunkMaterial))
|
||||||
|
{
|
||||||
|
ChunkMaterial = MID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it.
|
// The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it.
|
||||||
// Vertices are world-space; the component sits at the actor origin.
|
// Vertices are world-space; the component sits at the actor origin.
|
||||||
|
|
||||||
@@ -1015,39 +1018,25 @@ FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) con
|
|||||||
// TERRAIN MODIFICATION — player carving & filling
|
// TERRAIN MODIFICATION — player carving & filling
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
|
// All brush entry points below build an FVoxelModification and funnel through ApplyModification
|
||||||
|
// (diff layer + re-mesh). Strength sign convention: NEGATIVE = carve (air), POSITIVE = fill (solid).
|
||||||
|
|
||||||
void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength)
|
void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength)
|
||||||
{
|
{
|
||||||
if (!DiffLayer) return;
|
|
||||||
|
|
||||||
// Convert world position (Unreal units) to voxel space.
|
|
||||||
// VOXEL_SIZE = 25 in VoxelForge, so divide by it.
|
|
||||||
const FVector VoxelPos = Position / VOXEL_SIZE;
|
|
||||||
|
|
||||||
// Carve = negative strength (subtracts density → creates air)
|
|
||||||
FVoxelModification Mod;
|
FVoxelModification Mod;
|
||||||
Mod.Center = VoxelPos;
|
Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space
|
||||||
Mod.Radius = Radius;
|
Mod.Radius = Radius;
|
||||||
Mod.Strength = -FMath::Abs(Strength); // Force negative for carving
|
Mod.Strength = -FMath::Abs(Strength); // force negative for carving
|
||||||
|
ApplyModification(Mod);
|
||||||
TArray<FIntVector> AffectedChunks = DiffLayer->ApplyModification(Mod);
|
|
||||||
RemeshDirtyChunks(AffectedChunks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
|
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
|
||||||
{
|
{
|
||||||
if (!DiffLayer) return;
|
|
||||||
|
|
||||||
// Convert world position to voxel space
|
|
||||||
const FVector VoxelPos = Position / VOXEL_SIZE;
|
|
||||||
|
|
||||||
// Fill = positive strength (adds density → creates solid)
|
|
||||||
FVoxelModification Mod;
|
FVoxelModification Mod;
|
||||||
Mod.Center = VoxelPos;
|
Mod.Center = Position / VOXEL_SIZE;
|
||||||
Mod.Radius = Radius;
|
Mod.Radius = Radius;
|
||||||
Mod.Strength = FMath::Abs(Strength); // Force positive for filling
|
Mod.Strength = FMath::Abs(Strength); // force positive for filling
|
||||||
|
ApplyModification(Mod);
|
||||||
TArray<FIntVector> AffectedChunks = DiffLayer->ApplyModification(Mod);
|
|
||||||
RemeshDirtyChunks(AffectedChunks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AVoxelWorld::ApplyModification(const FVoxelModification& Modification)
|
void AVoxelWorld::ApplyModification(const FVoxelModification& Modification)
|
||||||
@@ -1342,6 +1331,180 @@ void AVoxelWorld::RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords)
|
|||||||
LoadTile(Tile);
|
LoadTile(Tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Density volume: refill the clipmap cells overlapping each carved chunk so the shadow march
|
||||||
|
// sees the edit (GetDensityAt includes the diff layer). Cheap + local; covers all carve shapes.
|
||||||
|
if (DensityVolume)
|
||||||
|
{
|
||||||
|
for (const FIntVector& Coord : DirtyCoords)
|
||||||
|
{
|
||||||
|
const FIntVector MinV = Coord * CHUNK_SIZE;
|
||||||
|
const FIntVector MaxV = MinV + FIntVector(CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE);
|
||||||
|
DensityVolume->MarkDirtyVoxelBox(MinV, MaxV);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d pending"),
|
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d pending"),
|
||||||
DirtyCoords.Num(), PendingTiles.Num());
|
DirtyCoords.Num(), PendingTiles.Num());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UVolumeTexture* AVoxelWorld::GetDensityVolumeTexture(int32 Level) const
|
||||||
|
{
|
||||||
|
return DensityVolume ? DensityVolume->GetLevelTexture(Level) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// TERRAIN MATERIAL — density-volume / orb shadow params (MID-driven, see ApplyMeshToTile)
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
UMaterialInstanceDynamic* AVoxelWorld::GetOrCreateTerrainMID(UMaterialInterface* Base)
|
||||||
|
{
|
||||||
|
if (!Base) return nullptr;
|
||||||
|
if (TObjectPtr<UMaterialInstanceDynamic>* Found = TerrainMIDs.Find(Base))
|
||||||
|
{
|
||||||
|
return Found->Get();
|
||||||
|
}
|
||||||
|
UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(Base, this);
|
||||||
|
if (MID)
|
||||||
|
{
|
||||||
|
TerrainMIDs.Add(Base, MID);
|
||||||
|
SetVolumeParamsOnMID(MID); // seed with the current frame's params
|
||||||
|
}
|
||||||
|
return MID;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AVoxelWorld::SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const
|
||||||
|
{
|
||||||
|
if (!MID) return;
|
||||||
|
// Static FNames — this runs per MID on every param change; no per-call FName construction.
|
||||||
|
static const FName VolPNames[10] = {
|
||||||
|
FName("VolP0"), FName("VolP1"), FName("VolP2"), FName("VolP3"), FName("VolP4"),
|
||||||
|
FName("VolP5"), FName("VolP6"), FName("VolP7"), FName("VolP8"), FName("VolP9") };
|
||||||
|
static const FName VolTexNames[3] = { FName("VolTex0"), FName("VolTex1"), FName("VolTex2") };
|
||||||
|
|
||||||
|
const FLinearColor* TVPs[10] = { &TVP0, &TVP1, &TVP2, &TVP3, &TVP4, &TVP5, &TVP6, &TVP7, &TVP8, &TVP9 };
|
||||||
|
for (int32 i = 0; i < 10; ++i) { MID->SetVectorParameterValue(VolPNames[i], *TVPs[i]); }
|
||||||
|
if (DensityVolume)
|
||||||
|
{
|
||||||
|
for (int32 L = 0; L < 3; ++L)
|
||||||
|
{
|
||||||
|
if (UVolumeTexture* T = DensityVolume->GetLevelTexture(L)) { MID->SetTextureParameterValue(VolTexNames[L], T); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AVoxelWorld::UpdateTerrainMaterialParams()
|
||||||
|
{
|
||||||
|
if (!DensityVolume || !Settings || !Settings->bEnableDensityVolume) return;
|
||||||
|
|
||||||
|
// --- Per-level clipmap transforms (L0 = finest/near; L1-2 = coarser for shadow REACH) ---
|
||||||
|
// For each level the material maps WorldPos → RelPos = WorldPos - WindowOrigin → cellF = RelPos/Cell →
|
||||||
|
// toroidal UVW = frac((OriginMod + cellF + 0.5)/Res). OriginMod = OriginCells mod Res precomputed here
|
||||||
|
// so the shader never touches the large absolute cell coord (no float precision loss). The shader
|
||||||
|
// derives each level's cell size from L0's (cell_L = L0Cell * 2^L); Res is shared across levels.
|
||||||
|
const FTransform Xf = GetActorTransform();
|
||||||
|
int32 Res = 0;
|
||||||
|
bool bHave = false;
|
||||||
|
float CellWorldSize = VOXEL_SIZE; // L0 cm per cell
|
||||||
|
|
||||||
|
FLinearColor OriginC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
|
||||||
|
FLinearColor ModC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
|
||||||
|
for (int32 L = 0; L < 3; ++L)
|
||||||
|
{
|
||||||
|
FIntVector OriginCells(0, 0, 0);
|
||||||
|
float StepF = 1.0f;
|
||||||
|
int32 LRes = 0;
|
||||||
|
if (DensityVolume->GetLevelShaderParams(L, OriginCells, StepF, LRes) && LRes > 0)
|
||||||
|
{
|
||||||
|
const FVector OriginLocalCm = FVector(OriginCells.X, OriginCells.Y, OriginCells.Z) * (StepF * VOXEL_SIZE);
|
||||||
|
const FVector OW = Xf.TransformPosition(OriginLocalCm);
|
||||||
|
auto Mod = [LRes](int32 v) { const int32 m = v % LRes; return (float)((m < 0) ? m + LRes : m); };
|
||||||
|
OriginC[L] = FLinearColor(OW.X, OW.Y, OW.Z, 0.0f);
|
||||||
|
ModC[L] = FLinearColor(Mod(OriginCells.X), Mod(OriginCells.Y), Mod(OriginCells.Z), 0.0f);
|
||||||
|
if (L == 0) { bHave = true; Res = LRes; CellWorldSize = VOXEL_SIZE * StepF; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Track whether anything actually changed — the push below enqueues render-thread updates per MID,
|
||||||
|
// so on the (common) idle frames where the window didn't scroll and the orb didn't change, skip it.
|
||||||
|
bool bDirty = false;
|
||||||
|
auto SetTVP = [&bDirty](FLinearColor& Dst, const FLinearColor& V)
|
||||||
|
{
|
||||||
|
if (Dst != V) { Dst = V; bDirty = true; }
|
||||||
|
};
|
||||||
|
SetTVP(TVP0, OriginC[0]); SetTVP(TVP1, ModC[0]);
|
||||||
|
SetTVP(TVP6, OriginC[1]); SetTVP(TVP7, ModC[1]);
|
||||||
|
SetTVP(TVP8, OriginC[2]); SetTVP(TVP9, ModC[2]);
|
||||||
|
|
||||||
|
// --- Nearest active orb ---
|
||||||
|
FVoxelActiveOrb Best;
|
||||||
|
bool bHaveOrb = false;
|
||||||
|
if (ContentManager)
|
||||||
|
{
|
||||||
|
TArray<FVoxelActiveOrb> Orbs;
|
||||||
|
ContentManager->GetActiveOrbs(Orbs);
|
||||||
|
if (Orbs.Num() > 0)
|
||||||
|
{
|
||||||
|
const FVector P = GetPlayerPosition();
|
||||||
|
float BestD = FLT_MAX;
|
||||||
|
for (const FVoxelActiveOrb& O : Orbs)
|
||||||
|
{
|
||||||
|
const float D = FVector::DistSquared(O.WorldPos, P);
|
||||||
|
if (D < BestD) { BestD = D; Best = O; bHaveOrb = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All data lives in .xyz (a Vector Parameter only delivers float3 into a Custom node). Intensity is
|
||||||
|
// premultiplied into the colour; Res / CellWorldSize / Enable go in TVP5.
|
||||||
|
const float Enable = (bHave && bHaveOrb && Res > 0) ? 1.0f : 0.0f;
|
||||||
|
const float Steps = (float)FMath::Clamp(Settings->DensityVolumeMarchSteps, 4, 256);
|
||||||
|
SetTVP(TVP2, FLinearColor(Best.WorldPos.X, Best.WorldPos.Y, Best.WorldPos.Z, 0.0f));
|
||||||
|
SetTVP(TVP3, FLinearColor(Best.Color.R * Best.Intensity, Best.Color.G * Best.Intensity, Best.Color.B * Best.Intensity, 0.0f));
|
||||||
|
SetTVP(TVP4, FLinearColor(Best.MaxShadowDistWorld, Best.FalloffWorld, Steps, 0.0f));
|
||||||
|
SetTVP(TVP5, FLinearColor((float)FMath::Max(Res, 0), CellWorldSize, Enable, 0.0f));
|
||||||
|
|
||||||
|
// Re-push if the L0 texture object itself was recreated (resolution change) even when the packed
|
||||||
|
// params happen to be identical — otherwise the MIDs would keep sampling the dropped texture.
|
||||||
|
UVolumeTexture* Tex0 = DensityVolume->GetLevelTexture(0);
|
||||||
|
if (LastBoundVolTex0.Get() != Tex0) { LastBoundVolTex0 = Tex0; bDirty = true; }
|
||||||
|
if (!bDirty) return;
|
||||||
|
|
||||||
|
// Push to every terrain MID (new MIDs are seeded on creation in GetOrCreateTerrainMID).
|
||||||
|
for (TPair<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>>& Pair : TerrainMIDs)
|
||||||
|
{
|
||||||
|
SetVolumeParamsOnMID(Pair.Value.Get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AVoxelWorld::UpdateOrbLightMPC()
|
||||||
|
{
|
||||||
|
if (!OrbLightMPC || !ContentManager) return;
|
||||||
|
|
||||||
|
TArray<FVoxelActiveOrb> Orbs;
|
||||||
|
ContentManager->GetActiveOrbs(Orbs);
|
||||||
|
|
||||||
|
// Nearest-first so Orb0..3 are the 4 closest orbs (the Light Function unions their pools; 4 is
|
||||||
|
// plenty since only nearby pools are visible and the player sits inside one or two at a time).
|
||||||
|
const FVector P = GetPlayerPosition();
|
||||||
|
Orbs.Sort([&P](const FVoxelActiveOrb& A, const FVoxelActiveOrb& B)
|
||||||
|
{
|
||||||
|
return FVector::DistSquared(A.WorldPos, P) < FVector::DistSquared(B.WorldPos, P);
|
||||||
|
});
|
||||||
|
|
||||||
|
static const FName OrbNames[4] = { FName("Orb0"), FName("Orb1"), FName("Orb2"), FName("Orb3") };
|
||||||
|
for (int32 i = 0; i < 4; ++i)
|
||||||
|
{
|
||||||
|
// (x,y,z) = orb WORLD position, .w = reach radius in cm (FalloffWorld = how far the pool
|
||||||
|
// extends). Unused slots = all-zero → radius 0 → the mask yields no pool for them.
|
||||||
|
FLinearColor V(0.f, 0.f, 0.f, 0.f);
|
||||||
|
if (i < Orbs.Num())
|
||||||
|
{
|
||||||
|
const FVoxelActiveOrb& O = Orbs[i];
|
||||||
|
V = FLinearColor((float)O.WorldPos.X, (float)O.WorldPos.Y, (float)O.WorldPos.Z, O.FalloffWorld);
|
||||||
|
}
|
||||||
|
// Orbs are static once placed, so most frames change nothing — skip the MPC write (it
|
||||||
|
// dirties the collection's uniform buffer for every material that reads it).
|
||||||
|
if (LastOrbMPC[i] == V) continue;
|
||||||
|
LastOrbMPC[i] = V;
|
||||||
|
UKismetMaterialLibrary::SetVectorParameterValue(this, OrbLightMPC, OrbNames[i], V);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
// VoxelChunk.h
|
|
||||||
// Identifiant léger de chunk.
|
|
||||||
//
|
|
||||||
// Rôle: dans un monde density-only (pas de blocs), le chunk n'a plus rien
|
|
||||||
// à stocker — la densité est évaluée à la volée par le générateur à partir
|
|
||||||
// des coordonnées monde. On garde un struct fin pour:
|
|
||||||
// - Servir de clé/valeur dans les collections de AVoxelWorld (Chunks, FChunkResult)
|
|
||||||
// - Fournir l'helper GetWorldPosition() au mesher
|
|
||||||
// - Laisser une place si on veut cacher des infos par chunk plus tard
|
|
||||||
// (index de strate, LOD courant, etc.)
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
|
||||||
#include "VoxelTypes.h"
|
|
||||||
#include "VoxelChunk.generated.h"
|
|
||||||
|
|
||||||
USTRUCT(BlueprintType)
|
|
||||||
struct FVoxelChunk
|
|
||||||
{
|
|
||||||
GENERATED_BODY()
|
|
||||||
|
|
||||||
// Coordonnée de chunk dans la grille mondiale (peut être négative).
|
|
||||||
FIntVector ChunkCoord = FIntVector::ZeroValue;
|
|
||||||
|
|
||||||
FVoxelChunk() = default;
|
|
||||||
explicit FVoxelChunk(const FIntVector& InCoord) : ChunkCoord(InCoord) {}
|
|
||||||
|
|
||||||
// Coin (0,0,0) du chunk en espace monde (cm).
|
|
||||||
FVector GetWorldPosition() const
|
|
||||||
{
|
|
||||||
return ChunkToWorldPos(ChunkCoord);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -24,9 +24,17 @@
|
|||||||
// 2) WATER — ONE strate-global ocean plane that follows the player (UpdateWater). Terrain pokes
|
// 2) WATER — ONE strate-global ocean plane that follows the player (UpdateWater). Terrain pokes
|
||||||
// through it, so it reads as water at every LOD / to the horizon with no per-tile gaps. One draw.
|
// through it, so it reads as water at every LOD / to the horizon with no per-tile gaps. One draw.
|
||||||
//
|
//
|
||||||
// RENDERING PATHS / DISTANCE TIERS per entry (FStrateDecoration): non-instanced ActorClass entries with
|
// TWO STREAMING GRIDS (FStrateDecoration::StreamTier, §8.5). To stay flicker-free the stream RADIUS must
|
||||||
// MaxLODLevel==0 are near-only (DecorationActorRadiusChunks — pricey actors stay close); InstancedMesh
|
// be a property of the grid, not the entry (mixing radii in one grid would re-stream a region in place as
|
||||||
// (HISM) entries + MaxLODLevel>=1 actor entries are any-distance (DecorationRadiusChunks).
|
// the player crosses an entry's radius — the old tier system's flicker bug). So there are exactly two
|
||||||
|
// self-contained region streams, and an entry picks one:
|
||||||
|
// • FarGrid — DecorationRadiusChunks radius + DecorationFarSpacingVoxels (COARSE) grid. Default. Cheap
|
||||||
|
// for rare/large props visible everywhere (sparse marching across the full radius).
|
||||||
|
// • NearGrid — DecorationNearRadiusChunks radius + DecorationSpacingVoxels (FINE) grid. Dense groundcover
|
||||||
|
// near the player only; bounding its radius keeps far-region HISM build + memory small.
|
||||||
|
// Each grid owns its own region/build/queue-routing state (FDecoGrid) and its own subset of the palette;
|
||||||
|
// the two never share a HISM, so crossing the near boundary loads/unloads a near region without touching
|
||||||
|
// the far one (no flicker). A given world XY is covered by a far region always, plus a near region when close.
|
||||||
//
|
//
|
||||||
// DETERMINISM: same seed + world ⇒ identical placement. Spawning runs on the game thread.
|
// DETERMINISM: same seed + world ⇒ identical placement. Spawning runs on the game thread.
|
||||||
|
|
||||||
@@ -49,6 +57,19 @@ class UStaticMeshComponent;
|
|||||||
class UHierarchicalInstancedStaticMeshComponent;
|
class UHierarchicalInstancedStaticMeshComponent;
|
||||||
class UMaterialInterface;
|
class UMaterialInterface;
|
||||||
|
|
||||||
|
// An active mini-sun light orb (a placed FStrateLandmark with bIsLightOrb). The terrain material marches
|
||||||
|
// the density volume toward the nearest of these for raymarched shadows. Plain struct (not reflected);
|
||||||
|
// distances are in WORLD cm (already converted from the landmark's voxel units). See FStrateLandmark.
|
||||||
|
struct FVoxelActiveOrb
|
||||||
|
{
|
||||||
|
FVector WorldPos = FVector::ZeroVector;
|
||||||
|
FLinearColor Color = FLinearColor::White;
|
||||||
|
float Intensity = 1.0f;
|
||||||
|
float RadiusWorld = 400.0f; // cm
|
||||||
|
float FalloffWorld = 50000.0f; // cm
|
||||||
|
float MaxShadowDistWorld = 25000.0f; // cm
|
||||||
|
};
|
||||||
|
|
||||||
UCLASS()
|
UCLASS()
|
||||||
class VOXELFORGE_API UVoxelContentManager : public UObject
|
class VOXELFORGE_API UVoxelContentManager : public UObject
|
||||||
{
|
{
|
||||||
@@ -78,6 +99,20 @@ public:
|
|||||||
* launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */
|
* launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */
|
||||||
void UpdateDecorations(const FVector& PlayerWorldPos);
|
void UpdateDecorations(const FVector& PlayerWorldPos);
|
||||||
|
|
||||||
|
//--- LANDMARKS (rare large objects on a coarse hash lattice — the "mini-suns") -----------
|
||||||
|
/** Stream rare landmark objects around the player. Unlike decorations, these sit on a COARSE hash
|
||||||
|
* lattice (cell = `FStrateLandmark::SpacingChunks` chunks), so cost scales with the number of
|
||||||
|
* landmarks in range, not the area — a huge StreamRadiusChunks stays cheap (no per-chunk enumeration,
|
||||||
|
* no freeze). Synchronous game-thread placement (a surface-find runs only when a NEW lattice cell
|
||||||
|
* enters range; there are very few). Deterministic (hash of cell+entry+seed) → pop-free. Call every
|
||||||
|
* Tick. Strate-bounded like decorations (wiped + rebuilt on strate change). */
|
||||||
|
void UpdateLandmarks(const FVector& PlayerWorldPos);
|
||||||
|
|
||||||
|
/** Collect the currently-placed mini-sun light orbs (landmarks with bIsLightOrb). Cheap — iterates
|
||||||
|
* the small LandmarkInstances map. AVoxelWorld picks the nearest to feed the terrain material's
|
||||||
|
* raymarched shadows. */
|
||||||
|
void GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const;
|
||||||
|
|
||||||
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
|
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
|
||||||
* epoch so any in-flight march tasks' results are discarded. */
|
* epoch so any in-flight march tasks' results are discarded. */
|
||||||
void ClearAll();
|
void ClearAll();
|
||||||
@@ -109,6 +144,7 @@ public:
|
|||||||
{
|
{
|
||||||
FIntPoint Cell = FIntPoint::ZeroValue;
|
FIntPoint Cell = FIntPoint::ZeroValue;
|
||||||
uint32 BuildId = 0; // identity of the region build this cell belongs to
|
uint32 BuildId = 0; // identity of the region build this cell belongs to
|
||||||
|
EDecoStreamTier Grid = EDecoStreamTier::Far; // which grid (Near/Far) this result routes back to
|
||||||
TArray<FStrateDecoration> Entries; // snapshot the game thread spawns from (by EntryIdx)
|
TArray<FStrateDecoration> Entries; // snapshot the game thread spawns from (by EntryIdx)
|
||||||
TArray<FDecoSpawn> Spawns;
|
TArray<FDecoSpawn> Spawns;
|
||||||
};
|
};
|
||||||
@@ -152,6 +188,29 @@ private:
|
|||||||
TSet<FIntPoint> AccountedCells;
|
TSet<FIntPoint> AccountedCells;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// All per-grid streaming state, instantiated once per tier (NearGrid / FarGrid). Each grid is a fully
|
||||||
|
// self-contained region stream: its own loaded regions, in-progress builds, launch/in-flight queues,
|
||||||
|
// completed list, build-id counter, palette subset, and (radius, spacing) config. The two grids never
|
||||||
|
// share a HISM, so they load/unload independently with no cross-tier flicker (see the file header).
|
||||||
|
struct FDecoGrid
|
||||||
|
{
|
||||||
|
EDecoStreamTier Tier = EDecoStreamTier::Far; // identity (stamped on results so they route back here)
|
||||||
|
int32 Radius = 6; // stream radius in cells (= chunks)
|
||||||
|
int32 Spacing = 4; // march column spacing in voxels (fine for Near, coarse for Far)
|
||||||
|
|
||||||
|
TMap<FIntPoint, FDecoRegionContent> Regions; // loaded regions
|
||||||
|
TMap<FIntPoint, FDecoRegionBuild> Builds; // regions being marched
|
||||||
|
TArray<FIntPoint> PendingLaunch; // cells awaiting a march task (nearest-first)
|
||||||
|
TSet<FIntPoint> InFlightCells; // cells with a task in flight
|
||||||
|
TArray<FIntPoint> Completed; // regions whose last cell landed, awaiting apply
|
||||||
|
uint32 NextBuildId = 1; // monotonic build id (per grid)
|
||||||
|
|
||||||
|
// Palette subset for THIS tier, rebuilt each update. Entries[i] is owned by context-biome
|
||||||
|
// EntryBiome[i] (-1 = strate fallback, always matches). EntryIdx in a result indexes this snapshot.
|
||||||
|
TArray<FStrateDecoration> Entries;
|
||||||
|
TArray<int32> EntryBiome;
|
||||||
|
};
|
||||||
|
|
||||||
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
|
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
|
||||||
// only PODs/Z-bounds so it is safe to copy into a worker task (no UObject deref on the worker).
|
// only PODs/Z-bounds so it is safe to copy into a worker task (no UObject deref on the worker).
|
||||||
struct FDecoContext
|
struct FDecoContext
|
||||||
@@ -170,6 +229,19 @@ private:
|
|||||||
FBiomeContext BiomeCtx;
|
FBiomeContext BiomeCtx;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// One spawned landmark (rare hash-lattice object). Weak — the owner actor keeps it alive. BOTH null
|
||||||
|
// means the cell was evaluated but placed nothing (gate failed) — kept so we don't re-evaluate it.
|
||||||
|
struct FLandmarkInstance
|
||||||
|
{
|
||||||
|
TWeakObjectPtr<AActor> Actor; // set when the entry uses ActorClass
|
||||||
|
TWeakObjectPtr<UStaticMeshComponent> Component; // set when the entry uses InstancedMesh
|
||||||
|
|
||||||
|
// Mini-sun light orb data (set in SpawnLandmarkInstance when the landmark has bIsLightOrb). The
|
||||||
|
// terrain material consumes the nearest active orb for raymarched shadows (see GetActiveOrbs).
|
||||||
|
bool bIsOrb = false;
|
||||||
|
FVoxelActiveOrb Orb;
|
||||||
|
};
|
||||||
|
|
||||||
/** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle
|
/** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle
|
||||||
* (cheap, O(1)/column); other archetypes ray-march the density column. No UObject access except
|
* (cheap, O(1)/column); other archetypes ray-march the density column. No UObject access except
|
||||||
* Generator (thread-safe). Determinism-critical. Resolves the dominant biome PER COLUMN
|
* Generator (thread-safe). Determinism-critical. Resolves the dominant biome PER COLUMN
|
||||||
@@ -182,17 +254,38 @@ private:
|
|||||||
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
||||||
TArray<FDecoSpawn>& OutSpawns);
|
TArray<FDecoSpawn>& OutSpawns);
|
||||||
|
|
||||||
void LaunchDecoTasks(const FIntPoint& PlayerCell);
|
// Each step operates on ONE grid (G = NearGrid or FarGrid). LaunchDecoTasks throttles against the
|
||||||
void ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR);
|
// COMBINED in-flight count (OtherInFlight = the other grid's in-flight cells) so the two grids share
|
||||||
void MergeCellResult(const FDecoCellResult& Result); // fold one cell's spawns into its region build
|
// one concurrency budget. ProcessDecoResults drains the shared result queue, routing each result to its
|
||||||
void MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId); // idempotent per-cell accounting
|
// grid by FDecoCellResult::Grid, then applies both grids' completed regions under one frame budget.
|
||||||
void ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build);
|
void LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc);
|
||||||
void RebuildDesiredCells(const FIntPoint& PlayerCell);
|
void ProcessDecoResults(const FIntPoint& PlayerCell);
|
||||||
void ClearDecorationRegion(const FIntPoint& Region);
|
void MergeCellResult(FDecoGrid& G, const FDecoCellResult& Result); // fold one cell's spawns into its region build
|
||||||
|
void MarkCellDone(FDecoGrid& G, const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId); // idempotent per-cell accounting
|
||||||
|
void ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FDecoRegionBuild& Build);
|
||||||
|
void RebuildDesiredCells(FDecoGrid& G, const FIntPoint& PlayerCell);
|
||||||
|
void ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region);
|
||||||
void ClearAllDecorations();
|
void ClearAllDecorations();
|
||||||
|
void DrainDecoResults(); // discard every queued march result
|
||||||
|
static void ResetGridBuildState(FDecoGrid& G); // drop builds/queues (loaded regions untouched)
|
||||||
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
|
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
|
||||||
int32 RegionSize() const;
|
int32 RegionSize() const;
|
||||||
|
|
||||||
|
//--- LANDMARKS (hash-lattice rare objects) -------------------------------------------------
|
||||||
|
// Evaluate ONE lattice cell's landmark: biome/surface/slope/water gates, then spawn the actor/mesh.
|
||||||
|
// Leaves Out empty (null) when the cell is "evaluated but nothing placed" so it is never re-evaluated
|
||||||
|
// while it stays in range. H = the cell's existence hash (drives jitter/rotation/scale determinism).
|
||||||
|
void SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
|
||||||
|
const FTransform& OwnerXf, AActor* OwnerActor,
|
||||||
|
float LocalX, float LocalY, float Step, float ColDepth, FLandmarkInstance& Out);
|
||||||
|
void DestroyLandmarkInstance(FLandmarkInstance& Inst);
|
||||||
|
void ClearAllLandmarks();
|
||||||
|
// Single-column surface find for a landmark (voxel XY): SurfaceWorld → height oracle, else ray-march the
|
||||||
|
// strate band for the first crossing whose orientation matches Surf. Fills Z (voxel) + outward world normal.
|
||||||
|
static bool FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||||||
|
const FDecoContext& Ctx, float VX, float VY, ESurfaceType Surf,
|
||||||
|
float Step, float ColDepth, float& OutZ, FVector& OutNormal);
|
||||||
|
|
||||||
|
|
||||||
TWeakObjectPtr<AActor> Owner;
|
TWeakObjectPtr<AActor> Owner;
|
||||||
|
|
||||||
@@ -211,27 +304,22 @@ private:
|
|||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
UStaticMesh* PlaneMesh = nullptr;
|
UStaticMesh* PlaneMesh = nullptr;
|
||||||
|
|
||||||
// Loaded decoration regions (FIntPoint = region XY). One HISM per mesh per region. Not a UPROPERTY
|
// The two streaming grids. Each owns its loaded regions, in-progress builds, launch/in-flight queues,
|
||||||
// (weak ptrs inside; the owner actor keeps the components alive).
|
// completed list, build-id counter, palette subset, and (radius, spacing) config — see FDecoGrid. The
|
||||||
TMap<FIntPoint, FDecoRegionContent> DecoRegions;
|
// (radius, spacing) are refreshed from VoxelSettings each update; the regions are NOT UPROPERTYs (weak
|
||||||
|
// ptrs inside; the owner actor keeps the spawned components alive).
|
||||||
|
FDecoGrid NearGrid;
|
||||||
|
FDecoGrid FarGrid;
|
||||||
|
|
||||||
// Regions currently being marched cell-by-cell; merged here until every cell reports, then applied.
|
// Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer). SHARED across both grids;
|
||||||
TMap<FIntPoint, FDecoRegionBuild> RegionBuilds;
|
// each result carries its FDecoCellResult::Grid so ProcessDecoResults routes it to the right grid.
|
||||||
|
|
||||||
// Cells that are desired but need a march task launched (nearest-first).
|
|
||||||
TArray<FIntPoint> PendingLaunch;
|
|
||||||
// Cells with a march task in flight (awaiting a result).
|
|
||||||
TSet<FIntPoint> InFlightCells;
|
|
||||||
|
|
||||||
// Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer).
|
|
||||||
TQueue<FDecoCellResult, EQueueMode::Mpsc> DecoResults;
|
TQueue<FDecoCellResult, EQueueMode::Mpsc> DecoResults;
|
||||||
// Regions whose last cell just landed, awaiting budgeted game-thread apply (HISM build + actor spawn).
|
|
||||||
TArray<FIntPoint> CompletedRegions;
|
|
||||||
|
|
||||||
// Monotonic id stamped on each region build + the cell tasks it launches. A cell result merges only
|
// Spawned landmarks, keyed by FIntVector(latticeCellX, latticeCellY, entryIndex) — FIntVector already
|
||||||
// if its BuildId still matches the live build for that region → a region that was cleared and later
|
// hashes, so no custom key type is needed. An entry with both ptrs null = "evaluated, nothing placed"
|
||||||
// re-marched (same coords, new BuildId) never absorbs a stale in-flight cell from its prior life.
|
// (kept until the cell leaves range so the surface-find isn't repeated). Strate-bounded.
|
||||||
uint32 NextBuildId = 1;
|
TMap<FIntVector, FLandmarkInstance> LandmarkInstances;
|
||||||
|
int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks
|
||||||
|
|
||||||
// Set in BeginDestroy; worker tasks check it before touching us.
|
// Set in BeginDestroy; worker tasks check it before touching us.
|
||||||
std::atomic<bool> bShuttingDown{false};
|
std::atomic<bool> bShuttingDown{false};
|
||||||
@@ -241,16 +329,13 @@ private:
|
|||||||
int32 LastStrateIndex = INT32_MIN;
|
int32 LastStrateIndex = INT32_MIN;
|
||||||
|
|
||||||
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
|
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
|
||||||
// copies the PODs into each task).
|
// copies the PODs into each task). Same for both grids — a strate is a horizontal slab.
|
||||||
FDecoContext CurrentCtx;
|
FDecoContext CurrentCtx;
|
||||||
|
|
||||||
// Decoration palette for the current update, built ONCE (a strate's biome field is XY-global, so the
|
// The decoration palette is built ONCE per update (a strate's biome field is XY-global, so the flat
|
||||||
// flat list is the same for every cell — only the per-COLUMN biome pick varies). CurrentEntries is the
|
// list is the same for every cell — only the per-COLUMN biome pick varies) and PARTITIONED by tier into
|
||||||
// concatenation of every biome's decoration list (or the strate's when a biome has none / biomes are
|
// NearGrid.Entries / FarGrid.Entries (with parallel EntryBiome). Each list is the concatenation of every
|
||||||
// disabled); CurrentEntryBiome[i] is the context-biome index that owns entry i (-1 = strate fallback,
|
// biome's decoration entries of that tier (or the strate's when a biome has none / biomes are disabled).
|
||||||
// always matches). The worker resolves a column's dominant biome and rolls only the entries it owns.
|
|
||||||
TArray<FStrateDecoration> CurrentEntries;
|
|
||||||
TArray<int32> CurrentEntryBiome;
|
|
||||||
|
|
||||||
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
|
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
|
||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// VoxelDensityVolume.h
|
||||||
|
// Player-centred DENSITY CLIPMAP — the GPU-bound prerequisite for the mini-sun raymarched
|
||||||
|
// shadow system (forward rendering). Density is CPU-only (UVoxelGenerator::GetDensityAt), so to
|
||||||
|
// shadow-march on the GPU we stream the density field into a clipmap of 3D textures centred on
|
||||||
|
// the player: fine near, coarse far — exactly what shadow rays want (the crisp edge lives near
|
||||||
|
// the shaded surface; far along the ray, coarse is invisible).
|
||||||
|
//
|
||||||
|
// FORMAT (committed): single-channel R8 storing QUANTIZED SIGNED density (solid = high, air =
|
||||||
|
// low, iso at ~0.5), trilinear-filterable so the iso crossing stays sub-voxel crisp. A "solidity"
|
||||||
|
// MIP pyramid (max-downsample) is built for empty-space skipping in the march. NOT a true SDF:
|
||||||
|
// digging is the core verb, and an SDF would need re-distancing (JFA / Eikonal) on every carve &
|
||||||
|
// streaming refill, whereas the mip pyramid just re-maxes a few blocks — trivially correct + local.
|
||||||
|
//
|
||||||
|
// CLIPMAP MODEL: N levels. Level L samples every (1<<L) voxels and holds a Res³ grid of CELLS,
|
||||||
|
// TOROIDALLY addressed (data for cell C lives at C mod Res), so recentring on movement only needs
|
||||||
|
// to refill the newly-exposed slabs — not the whole volume. A carve marks its voxel box dirty →
|
||||||
|
// the overlapping cells refill locally.
|
||||||
|
//
|
||||||
|
// THREADING: fills run on ONE DEDICATED thread (FVoxelDensityFillRunnable, off the UE::Tasks pool —
|
||||||
|
// the old BackgroundLow pool path STARVED behind mesh-gen, ~10 s to resolve). The game thread enqueues
|
||||||
|
// FPendingFill (Spsc FillQueue) + triggers FillWakeEvent; the thread re-evaluates GetDensityAt
|
||||||
|
// (thread-safe, deterministic — READS the Generator only, checks bFillThreadStop) and returns filled
|
||||||
|
// sub-boxes via the Mpsc Results queue; the game thread writes them into the toroidal arrays. Each fill
|
||||||
|
// carries a VolumeEpoch — stale results (after a regen/season reset) are discarded. CAPTURE-DURING-
|
||||||
|
// MESHING (level 0) short-circuits most fills: the mesher's already-sampled grid is cached by tile
|
||||||
|
// coord and blitted on the game thread, so the dedicated thread is only the BACKSTOP (cache misses:
|
||||||
|
// vertical strate gaps, cold start, carves). EndPlay → NotifyShutdown → StopFillThread Kill(true)s the
|
||||||
|
// thread (blocks until it stops reading the Generator) before UObject teardown. Determinism preserved
|
||||||
|
// (GetDensityAt + the diff layer, the only non-deterministic overlay, same as the terrain).
|
||||||
|
//
|
||||||
|
// STATUS: step 1a = CPU clipmap + worker fills + toroidal streaming + carve dirty + a DEBUG-DRAW
|
||||||
|
// visualization (no GPU yet). Step 1b adds the Texture3D upload + the in-material Custom-HLSL march.
|
||||||
|
// The GPU-upload seam is marked below (UploadDirtyRegionsToGPU).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "Containers/Queue.h"
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include "VoxelDensityVolume.generated.h"
|
||||||
|
|
||||||
|
class UVoxelGenerator;
|
||||||
|
class UVoxelSettings;
|
||||||
|
class UVolumeTexture;
|
||||||
|
class FVoxelDensityFillRunnable; // dedicated fill thread (VoxelDensityVolume.cpp)
|
||||||
|
class FRunnableThread;
|
||||||
|
class FEvent;
|
||||||
|
|
||||||
|
UCLASS()
|
||||||
|
class VOXELFORGE_API UVoxelDensityVolume : public UObject
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
/** Wire up services. Owner is the AVoxelWorld actor (its transform maps world↔voxel, same as the
|
||||||
|
* content manager); Generator supplies GetDensityAt; Settings supplies the clipmap tunables. */
|
||||||
|
void Initialize(AActor* InOwner, UVoxelGenerator* InGenerator, UVoxelSettings* InSettings);
|
||||||
|
|
||||||
|
/** Each Tick: recentre the clipmap on the player, queue fills for newly-exposed cells + any
|
||||||
|
* carve-dirtied cells, launch them under the task budget, and drain finished fills. Cheap when
|
||||||
|
* the player hasn't crossed a level-0 cell boundary and nothing is dirty. */
|
||||||
|
void Update(const FVector& PlayerWorldPos);
|
||||||
|
|
||||||
|
/** A carve/fill touched this VOXEL box (inclusive, voxel coords) → refill the overlapping
|
||||||
|
* clipmap cells next Update. GetDensityAt already includes the diff layer, so re-sampling
|
||||||
|
* picks the edit up. Cheap + local. */
|
||||||
|
void MarkDirtyVoxelBox(const FIntVector& MinVoxel, const FIntVector& MaxVoxel);
|
||||||
|
|
||||||
|
/** CAPTURE-DURING-MESHING (level-0 only). The mesher already sampled this level-0 tile's density
|
||||||
|
* grid while building its mesh; instead of re-evaluating GetDensityAt in a worker fill, we reuse
|
||||||
|
* those samples. Grid = CHUNK_SIZE³ R8 (X-fast, then Y, then Z), quantized by VF_QuantizeDensity
|
||||||
|
* (bit-identical to a worker fill). Stored in CaptureCache keyed by tile coord and blitted into
|
||||||
|
* level 0's toroidal window now (immediate freshness) + on RecenterLevel(0) (so a window scroll
|
||||||
|
* fills from the cache, not a re-sample). The worker fill stays as the backstop for cache misses
|
||||||
|
* (vertical strate gaps, cold start, evicted tiles). Game-thread only (called from
|
||||||
|
* AVoxelWorld::ProcessPendingChunks). Moves Grid. */
|
||||||
|
void IngestTileCapture(const FIntVector& L0TileCoord, TArray<uint8>&& Grid);
|
||||||
|
|
||||||
|
/** True if a level-0 tile's capture could be consumed (inside the shadow window + lead-shell
|
||||||
|
* margin, or no window yet). AVoxelWorld checks this BEFORE asking the mesher to capture, so the
|
||||||
|
* many level-0 tiles streaming outside the small shadow window don't pay the quantize+copy for a
|
||||||
|
* grid IngestTileCapture would refuse anyway. Game-thread only. */
|
||||||
|
bool IsTileCaptureUseful(const FIntVector& L0TileCoord) const;
|
||||||
|
|
||||||
|
/** Season reset / full regen: bump the epoch (drops in-flight fills), drop all data, force a
|
||||||
|
* full refill on the next Update. */
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
/** EndPlay: flag shutdown + spin-wait for in-flight fills (they read the Generator) before the
|
||||||
|
* owner tears UObjects down. */
|
||||||
|
void NotifyShutdown();
|
||||||
|
|
||||||
|
virtual void BeginDestroy() override;
|
||||||
|
|
||||||
|
#if ENABLE_DRAW_DEBUG
|
||||||
|
/** Step-1a visual check: draw boxes for solid level-0 cells near the player (gated + capped). */
|
||||||
|
void DebugDraw() const;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
//--- GPU accessors (step 1b: the material march / debug visualization sample these) ----------
|
||||||
|
/** The R8 volume texture for a clip level (null if GPU upload is off / not yet created). */
|
||||||
|
UVolumeTexture* GetLevelTexture(int32 Level) const;
|
||||||
|
|
||||||
|
/** Shader params for a level: OriginCells (min cell coord), Step (voxels/cell), Res (cells/axis).
|
||||||
|
* The material maps WorldPos → local voxel → cell C = floor(localVoxel/Step), then samples at
|
||||||
|
* UVW = (C + 0.5)/Res with WRAP addressing (the texture is toroidal). Returns false if the level
|
||||||
|
* has no data yet. (Feeds the MPC in 1b-ii.) */
|
||||||
|
bool GetLevelShaderParams(int32 Level, FIntVector& OutOriginCells, float& OutStep, int32& OutRes) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// One concentric clip level. Step = 1<<L voxels; the grid covers Res cells (= Res*Step voxels).
|
||||||
|
struct FClipLevel
|
||||||
|
{
|
||||||
|
int32 Step = 1; // voxel sampling step (1<<L)
|
||||||
|
FIntVector OriginCells = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); // min CELL coord; sentinel = no data
|
||||||
|
TArray<uint8> Density; // Res³ R8, toroidally addressed
|
||||||
|
bool bHasData = false;
|
||||||
|
bool bGPUDirty = false; // CPU data changed → re-upload the texture
|
||||||
|
};
|
||||||
|
|
||||||
|
// A fill request (game-thread queue, drained under the task budget). Box is in CELL coords.
|
||||||
|
struct FPendingFill
|
||||||
|
{
|
||||||
|
int32 Level = 0;
|
||||||
|
FIntVector MinCells = FIntVector::ZeroValue;
|
||||||
|
FIntVector DimCells = FIntVector::ZeroValue;
|
||||||
|
uint32 Epoch = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Worker → game-thread result: one filled sub-box, linear row-major (X fastest, then Y, then Z).
|
||||||
|
struct FFillResult
|
||||||
|
{
|
||||||
|
int32 Level = 0;
|
||||||
|
uint32 Epoch = 0;
|
||||||
|
FIntVector MinCells = FIntVector::ZeroValue;
|
||||||
|
FIntVector DimCells = FIntVector::ZeroValue;
|
||||||
|
TArray<uint8> Data;
|
||||||
|
};
|
||||||
|
|
||||||
|
void EnsureAllocated();
|
||||||
|
int32 ResPerAxis() const; // clamped Settings->DensityVolumeResolution
|
||||||
|
int32 NumLevels() const; // clamped Settings->DensityVolumeLevels
|
||||||
|
|
||||||
|
// Recentre one level on the player voxel; push fills for the slabs that scrolled into view.
|
||||||
|
void RecenterLevel(int32 L, const FIntVector& PlayerVoxel);
|
||||||
|
// new\old box subtraction → up to 6 disjoint cell-boxes (the toroidal slabs to refill).
|
||||||
|
static void BoxDifference(const FIntVector& NewMin, const FIntVector& NewDim,
|
||||||
|
const FIntVector& OldMin, const FIntVector& OldDim,
|
||||||
|
TArray<TPair<FIntVector, FIntVector>>& OutBoxes);
|
||||||
|
// Split a cell-box into Z-slabs + enqueue as FPendingFills (so no single task is huge).
|
||||||
|
void QueueFillSplit(int32 L, const FIntVector& MinCells, const FIntVector& DimCells);
|
||||||
|
|
||||||
|
// CAPTURE-DURING-MESHING (level 0). Fill a newly-exposed cell box from the capture cache where a
|
||||||
|
// tile is present (game-thread memcpy, no GetDensityAt); queue a worker fill for the rest (backstop).
|
||||||
|
void FillBoxFromCacheOrQueue(const FIntVector& MinCells, const FIntVector& DimCells);
|
||||||
|
// Write a cached tile's in-window cells into level 0's toroidal array (+ GPU dirty). Idempotent.
|
||||||
|
bool BlitCaptureToWindow(const FIntVector& L0TileCoord, const TArray<uint8>& Grid);
|
||||||
|
// The tile-coord box worth caching: level 0's window +1 tile margin (the lead shell). False = no window yet.
|
||||||
|
bool GetCaptureKeepBounds(FIntVector& OutLo, FIntVector& OutHi) const;
|
||||||
|
// Drop cache entries whose tile is fully outside the keep bounds.
|
||||||
|
void EvictFarCaptures();
|
||||||
|
void LaunchPendingFills(); // flush PendingFills onto the dedicated fill thread
|
||||||
|
void DrainResults(); // apply finished fills into the toroidal arrays (+ GPU dirty)
|
||||||
|
|
||||||
|
// DEDICATED FILL THREAD. The volume fill used to run on the shared UE::Tasks pool (BackgroundLow),
|
||||||
|
// where it STARVED behind mesh-gen (10 s to resolve shadows at a fresh spot). It now runs on its own
|
||||||
|
// thread (off the pool) so it's fast AND never steals a core from mesh-gen. Game thread enqueues
|
||||||
|
// FPendingFill (Spsc), the thread re-evaluates GetDensityAt and pushes FFillResult into Results
|
||||||
|
// (existing Mpsc, drained on the game thread by DrainResults). Capture still short-circuits most of
|
||||||
|
// this (cache hits blit on the game thread, no fill); the thread is the backstop for misses.
|
||||||
|
friend class FVoxelDensityFillRunnable;
|
||||||
|
void ProcessOneFill(const FPendingFill& F); // RUNS ON THE FILL THREAD (reads Generator only)
|
||||||
|
void EnsureFillThread();
|
||||||
|
void StopFillThread();
|
||||||
|
|
||||||
|
// GPU upload (step 1b-i). EnsureTextures (re)creates the per-level R8 volume textures when the
|
||||||
|
// resolution / level count changes; UploadDirtyTextures enqueues a render command per dirty level
|
||||||
|
// that RHIUpdateTexture3D's the whole level from the CPU array (the array IS the toroidal texture
|
||||||
|
// layout, so a full re-upload is correct without wrap-splitting; sub-box upload is a later optim).
|
||||||
|
void EnsureTextures();
|
||||||
|
void UploadDirtyTextures();
|
||||||
|
|
||||||
|
static FORCEINLINE uint8 Quantize(float MCDensity); // MC density (neg=solid) → R8 (solid=high)
|
||||||
|
static FORCEINLINE int32 FloorDiv(int32 A, int32 B); // true floor division (B>0)
|
||||||
|
|
||||||
|
TWeakObjectPtr<AActor> Owner;
|
||||||
|
|
||||||
|
UPROPERTY()
|
||||||
|
UVoxelGenerator* Generator = nullptr;
|
||||||
|
|
||||||
|
UPROPERTY()
|
||||||
|
UVoxelSettings* Settings = nullptr;
|
||||||
|
|
||||||
|
TArray<FClipLevel> Levels;
|
||||||
|
int32 AllocatedRes = 0; // resolution the arrays were sized for (realloc on change)
|
||||||
|
|
||||||
|
// Per-level R8 volume textures (GPU). UPROPERTY so they're GC-kept; contents updated via RHI.
|
||||||
|
UPROPERTY()
|
||||||
|
TArray<TObjectPtr<UVolumeTexture>> LevelTextures;
|
||||||
|
int32 AllocatedTexRes = 0; // resolution the textures were created at (recreate on change)
|
||||||
|
|
||||||
|
TArray<FPendingFill> PendingFills; // cell-boxes staged on the game thread, flushed to FillQueue
|
||||||
|
TQueue<FPendingFill, EQueueMode::Spsc> FillQueue; // game thread → fill thread
|
||||||
|
TQueue<FFillResult, EQueueMode::Mpsc> Results; // fill thread enqueues, game thread drains
|
||||||
|
|
||||||
|
// Dedicated fill thread handles (see EnsureFillThread / StopFillThread / ProcessOneFill).
|
||||||
|
FVoxelDensityFillRunnable* FillRunnable = nullptr;
|
||||||
|
FRunnableThread* FillThread = nullptr;
|
||||||
|
FEvent* FillWakeEvent = nullptr;
|
||||||
|
std::atomic<bool> bFillThreadStop{ false };
|
||||||
|
|
||||||
|
// CAPTURE-DURING-MESHING (level-0 only): tile coord → CHUNK_SIZE³ R8 captured density. Populated by
|
||||||
|
// IngestTileCapture (free — the mesher already sampled it), consumed by RecenterLevel(0) to fill
|
||||||
|
// exposed cells without re-sampling. Game-thread only; evicted by window distance (EvictFarCaptures).
|
||||||
|
TMap<FIntVector, TArray<uint8>> CaptureCache;
|
||||||
|
|
||||||
|
std::atomic<bool> bShuttingDown{ false };
|
||||||
|
uint32 VolumeEpoch = 1; // bumped on Reset → stale fills discarded
|
||||||
|
|
||||||
|
FIntVector LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||||
|
bool bInitialized = false;
|
||||||
|
};
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc.
|
#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc.
|
||||||
#include "VoxelChunk.h"
|
|
||||||
#include "VoxelGenerator.h"
|
#include "VoxelGenerator.h"
|
||||||
#include "VoxelMarchingCubesMesher.generated.h"
|
#include "VoxelMarchingCubesMesher.generated.h"
|
||||||
|
|
||||||
@@ -31,8 +30,15 @@ public:
|
|||||||
* @param CellsPerAxis - Nombre de cellules par axe. Les tuiles GROSSIÈRES en utilisent MOINS
|
* @param CellsPerAxis - Nombre de cellules par axe. Les tuiles GROSSIÈRES en utilisent MOINS
|
||||||
* (gen moins chère, maillage plus grossier au loin) tout en couvrant la
|
* (gen moins chère, maillage plus grossier au loin) tout en couvrant la
|
||||||
* même étendue (extent = CellsPerAxis*Step). Niveau 0 = CHUNK_SIZE.
|
* même étendue (extent = CellsPerAxis*Step). Niveau 0 = CHUNK_SIZE.
|
||||||
|
* @param OutCaptureGrid - CAPTURE-DURING-MESHING (optionnel). Si non-null ET CellsPerAxis==CHUNK_SIZE
|
||||||
|
* (tuile pleine résolution, Step==1<<Level, donc 1:1 avec une cellule du clipmap
|
||||||
|
* de densité), on y recopie les CHUNK_SIZE³ points intérieurs de la grille de
|
||||||
|
* densité déjà échantillonnée, quantifiés via VF_QuantizeDensity. Cela évite à
|
||||||
|
* UVoxelDensityVolume de re-sampler GetDensityAt pour ces cellules (le mesher
|
||||||
|
* les a déjà calculées). Vidé puis rempli ; reste vide si non éligible.
|
||||||
*/
|
*/
|
||||||
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE);
|
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE,
|
||||||
|
TArray<uint8>* OutCaptureGrid = nullptr);
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// SERVICES (injectés par AVoxelWorld)
|
// SERVICES (injectés par AVoxelWorld)
|
||||||
@@ -52,10 +58,6 @@ public:
|
|||||||
// Convention MC: densité < IsoLevel = solide, >= = air.
|
// Convention MC: densité < IsoLevel = solide, >= = air.
|
||||||
float IsoLevel = 0.0f;
|
float IsoLevel = 0.0f;
|
||||||
|
|
||||||
// Distance d'échantillonnage (en voxels) pour calculer la normale par
|
|
||||||
// différence centrée du gradient. Plus petit = plus détaillé mais bruité.
|
|
||||||
float GradientOffset = 1.0f;
|
|
||||||
|
|
||||||
// SKIRTS — bouchent les fissures aux frontières de tuiles entre niveaux de clipmap voisins
|
// SKIRTS — bouchent les fissures aux frontières de tuiles entre niveaux de clipmap voisins
|
||||||
// (résolutions différentes → les iso-surfaces ne se rejoignent pas exactement). Une jupe
|
// (résolutions différentes → les iso-surfaces ne se rejoignent pas exactement). Une jupe
|
||||||
// (mur court) est extrudée vers le solide depuis chaque arête de surface posée sur une des 6
|
// (mur court) est extrudée vers le solide depuis chaque arête de surface posée sur une des 6
|
||||||
@@ -64,19 +66,4 @@ public:
|
|||||||
// Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). ~2 cellules couvrent
|
// Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). ~2 cellules couvrent
|
||||||
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
|
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
|
||||||
float SkirtCells = 2.0f;
|
float SkirtCells = 2.0f;
|
||||||
|
|
||||||
protected:
|
|
||||||
//=========================================================================
|
|
||||||
// DENSITY + NORMAL SAMPLING
|
|
||||||
//=========================================================================
|
|
||||||
|
|
||||||
// Lit la densité à une position locale (via le générateur en coords monde).
|
|
||||||
float GetDensity(const FVoxelChunk& Chunk, int32 X, int32 Y, int32 Z) const;
|
|
||||||
|
|
||||||
// Normale lissée: gradient central du champ de densité (pointe solide→air).
|
|
||||||
FVector ComputeGradientNormal(float WorldX, float WorldY, float WorldZ) const;
|
|
||||||
|
|
||||||
// Interpolation linéaire le long d'une arête: trouve où la surface
|
|
||||||
// traverse entre P1 (densité D1) et P2 (densité D2).
|
|
||||||
FVector InterpolateEdge(const FVector& P1, const FVector& P2, float D1, float D2) const;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,22 +81,7 @@ public:
|
|||||||
int32 CeilingBandChunks = 4;
|
int32 CeilingBandChunks = 4;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// LOD
|
// CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance box above)
|
||||||
//=========================================================================
|
|
||||||
|
|
||||||
// Distance en chunks pour LOD0 (pleine résolution, step=1).
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD")
|
|
||||||
int32 LOD0Distance = 4;
|
|
||||||
|
|
||||||
// Distance en chunks pour LOD1 (demi-résolution, step=2). Au-delà → LOD2 (quart-rés,
|
|
||||||
// step=4). LOD2 = le plus lointain ; ces chunks ne projettent PLUS d'ombre (cf.
|
|
||||||
// ApplyMeshToChunk) → rapprocher LOD0/LOD1 pousse plus de chunks dans la bande
|
|
||||||
// LOD2 sans-ombre = moins de draws (levier fps gratuit, à doser visuellement).
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD")
|
|
||||||
int32 LOD1Distance = 8;
|
|
||||||
|
|
||||||
//=========================================================================
|
|
||||||
// CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance/LOD box above)
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// Streaming loads concentric shells of tiles: level 0 = full-res chunks near the player,
|
// Streaming loads concentric shells of tiles: level 0 = full-res chunks near the player,
|
||||||
// each coarser level doubles tile size (and reach). Total tile/draw/gen count stays ~flat
|
// each coarser level doubles tile size (and reach). Total tile/draw/gen count stays ~flat
|
||||||
@@ -138,13 +123,6 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0"))
|
||||||
float SkirtCells = 2.0f;
|
float SkirtCells = 2.0f;
|
||||||
|
|
||||||
// LEGACY / WATER ONLY. Decorations no longer ride clipmap tiles (see Voxel|Content below —
|
|
||||||
// they stream on a fixed world grid by distance, so they don't pop on LOD swaps). This now only
|
|
||||||
// bounds the tile level at which the level-0 WATER plane is considered (water is level-0 anyway,
|
|
||||||
// so its practical effect is nil). Left in place; safe to ignore.
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
|
|
||||||
int32 ContentMaxLevel = 2;
|
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// CONTENT — distance-based decoration grid (no LOD pop)
|
// CONTENT — distance-based decoration grid (no LOD pop)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -154,11 +132,19 @@ public:
|
|||||||
// density field and snapped to the real surface — so a given prop keeps the SAME world position at
|
// density field and snapped to the real surface — so a given prop keeps the SAME world position at
|
||||||
// every LOD (no teleport/pop on tile swaps). Decorations exist only in the player's current strate.
|
// every LOD (no teleport/pop on tile swaps). Decorations exist only in the player's current strate.
|
||||||
|
|
||||||
// Far stream radius in cells (= chunks) for "any-distance" entries (instanced/HISM visual props,
|
// FAR-tier stream radius in cells (= chunks): how far FStrateDecoration entries set to EDecoStreamTier::Far
|
||||||
// and actor entries with MaxLODLevel >= 1). Bigger = props visible farther + more spawn/march cost.
|
// (the default — trees, landmarks, rare props) stream out. Bigger = props visible farther + more
|
||||||
|
// spawn/march cost (but the far grid is COARSE — see DecorationFarSpacingVoxels — so far cost is cheap).
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
||||||
int32 DecorationRadiusChunks = 6;
|
int32 DecorationRadiusChunks = 6;
|
||||||
|
|
||||||
|
// NEAR-tier stream radius in cells (= chunks): how far EDecoStreamTier::Near entries (dense groundcover
|
||||||
|
// like grass) stream out. Keep this SHORT — near entries use the FINE grid (DecorationSpacingVoxels), so
|
||||||
|
// their cost is the steep one; bounding their radius keeps the far-region HISM build + memory small.
|
||||||
|
// (Repurposes the old vestigial DecorationActorRadiusChunks; same default.)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
||||||
|
int32 DecorationNearRadiusChunks = 3;
|
||||||
|
|
||||||
// Decoration cells are grouped into REGIONS of RxR cells, and ALL placements in a region share ONE
|
// Decoration cells are grouped into REGIONS of RxR cells, and ALL placements in a region share ONE
|
||||||
// HISM per mesh (instead of one HISM per cell per mesh). Regions load/unload as a unit, so clearing
|
// HISM per mesh (instead of one HISM per cell per mesh). Regions load/unload as a unit, so clearing
|
||||||
// stays a plain DestroyComponent — no per-instance index remapping. This is the render-thread lever:
|
// stays a plain DestroyComponent — no per-instance index remapping. This is the render-thread lever:
|
||||||
@@ -168,19 +154,20 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
|
||||||
int32 DecorationRegionSizeCells = 4;
|
int32 DecorationRegionSizeCells = 4;
|
||||||
|
|
||||||
// LEGACY / UNUSED. The near/far tier system was removed (it re-streamed cells at the tier boundary
|
// NEAR-tier column spacing (in voxels) within a cell — the FINE grid. MUST divide CHUNK_SIZE (32):
|
||||||
// as the player moved → decoration flicker). All entries now stream within DecorationRadiusChunks and
|
// 4 → 8×8=64 columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity rolls
|
||||||
// a loaded cell is never re-streamed in place. Kept only to avoid breaking the asset; safe to ignore.
|
// per column-crossing. Used by EDecoStreamTier::Near entries (and is the legacy single-grid spacing).
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
|
||||||
int32 DecorationActorRadiusChunks = 3;
|
|
||||||
|
|
||||||
// Spacing (in voxels) of candidate columns within a cell. MUST divide CHUNK_SIZE (32): 4 → 8×8=64
|
|
||||||
// columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity then rolls per
|
|
||||||
// column-crossing (NOTE: this changes the meaning of SpawnDensity vs the old per-vertex scatter —
|
|
||||||
// expect to re-tune decoration densities once).
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
|
||||||
int32 DecorationSpacingVoxels = 4;
|
int32 DecorationSpacingVoxels = 4;
|
||||||
|
|
||||||
|
// FAR-tier column spacing (in voxels) within a cell — the COARSE grid. MUST divide CHUNK_SIZE (32):
|
||||||
|
// 16 → 2×2=4 columns/cell (16× fewer worker ray-marches than a spacing-4 grid). This is the lever that
|
||||||
|
// makes a RARE prop visible at every distance cheap: the far grid samples sparsely, so supporting a
|
||||||
|
// low-SpawnDensity landmark across the full radius costs a fraction of the fine grid. DEFAULTS to the
|
||||||
|
// fine value (4) so existing worlds are byte-identical until you raise it; bump to 8–16 for cheap far props.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
|
||||||
|
int32 DecorationFarSpacingVoxels = 4;
|
||||||
|
|
||||||
// COARSE vertical march step (in voxels) when searching a column for surface crossings. The crossing
|
// COARSE vertical march step (in voxels) when searching a column for surface crossings. The crossing
|
||||||
// Z is then bisection-refined, so accuracy is independent of this — raise it (4-8) to cut the scan
|
// Z is then bisection-refined, so accuracy is independent of this — raise it (4-8) to cut the scan
|
||||||
// cost (the column is ray-marched on a WORKER thread, but a smaller step still means more samples).
|
// cost (the column is ray-marched on a WORKER thread, but a smaller step still means more samples).
|
||||||
@@ -211,6 +198,64 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0"))
|
||||||
int32 MaxConcurrentDecorationTasks = 4;
|
int32 MaxConcurrentDecorationTasks = 4;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// LIGHTING — DENSITY VOLUME (mini-sun raymarched shadows)
|
||||||
|
//=========================================================================
|
||||||
|
// A player-centred CLIPMAP of the density field, uploaded to the GPU so the terrain
|
||||||
|
// material can RAYMARCH it toward the "mini-sun" orbs → from-the-orb, crisp, dynamic
|
||||||
|
// shadows under FORWARD rendering (Lumen/DF off the table). The volume is the load-bearing
|
||||||
|
// prerequisite: density is CPU-only (GetDensityAt), so we stream it onto the GPU here.
|
||||||
|
// Concentric levels: level 0 = full-res near the player (step 1), each level up doubles the
|
||||||
|
// sampling step & reach (fine near / coarse far — exactly what shadow rays want). Filled on
|
||||||
|
// WORKER threads (re-evaluating GetDensityAt → deterministic, carves auto-picked-up), with
|
||||||
|
// toroidal incremental refill on movement and localized refill on carve. See VoxelDensityVolume.
|
||||||
|
|
||||||
|
// Master switch. OFF = no volume built, no fill tasks, no GPU cost (terrain unlit by orbs).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
|
||||||
|
bool bEnableDensityVolume = true;
|
||||||
|
|
||||||
|
// Per-axis resolution of EACH clip level (cells). Memory per level ≈ Res³ bytes (R8). 128 →
|
||||||
|
// ~2 MB/level; 192 → ~7 MB; 256 → ~16 MB. Higher = crisper near shadows + bigger startup fill.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "32", ClampMax = "256"))
|
||||||
|
int32 DensityVolumeResolution = 128;
|
||||||
|
|
||||||
|
// Number of concentric clip levels. Level L samples every (1<<L) voxels and covers
|
||||||
|
// Res·(1<<L) voxels. 3 levels at Res=128 → near 32 m (full-res) out to ~128 m (coarse).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "1", ClampMax = "5"))
|
||||||
|
int32 DensityVolumeLevels = 3;
|
||||||
|
|
||||||
|
// DEPRECATED / unused: the volume fill no longer runs on the shared UE::Tasks pool (where it
|
||||||
|
// starved behind mesh-gen). It now runs on ONE dedicated thread off the pool, so there's no task
|
||||||
|
// budget to cap. Kept only so existing saved assets don't error; safe to ignore.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "1", ClampMax = "16"))
|
||||||
|
int32 DensityVolumeMaxTasks = 4;
|
||||||
|
|
||||||
|
// A fill box is split into Z-slabs of at most this many cells per task, so no single task is
|
||||||
|
// huge (a full level refill on startup/teleport fans out across workers). Lower = more, smaller
|
||||||
|
// tasks (better parallelism / latency); higher = fewer, fatter tasks.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "1", ClampMax = "64"))
|
||||||
|
int32 DensityVolumeFillSlabCells = 8;
|
||||||
|
|
||||||
|
// Per-pixel shadow-march step count toward the orb (the terrain material reads this). More = crisper
|
||||||
|
// occlusion at grazing angles but higher GPU cost. 64 is a sane start; tune against the look/cost.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "4", ClampMax = "256"))
|
||||||
|
int32 DensityVolumeMarchSteps = 64;
|
||||||
|
|
||||||
|
// Upload the clipmap to GPU R8 volume textures (so the terrain material can march it). OFF = the
|
||||||
|
// CPU volume still streams (debug-draw works) but nothing reaches the GPU — the safe fallback if
|
||||||
|
// the runtime Texture3D RHI path misbehaves on a given engine build.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
|
||||||
|
bool bDensityVolumeGPUUpload = true;
|
||||||
|
|
||||||
|
// DEBUG (step 1a verification, no GPU): draw small boxes for SOLID cells of level 0 within
|
||||||
|
// DensityVolumeDebugRadiusCells of the player, so you can confirm the volume holds terrain-shaped
|
||||||
|
// solidity, follows you, and updates on carve — BEFORE the GPU upload + material march land.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting|Debug")
|
||||||
|
bool bDebugDrawDensityVolume = false;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting|Debug", meta = (ClampMin = "1", ClampMax = "32"))
|
||||||
|
int32 DensityVolumeDebugRadiusCells = 6;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// RENDERING
|
// RENDERING
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|||||||
@@ -323,6 +323,12 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
|
||||||
TArray<FStrateDecoration> Decorations;
|
TArray<FStrateDecoration> Decorations;
|
||||||
|
|
||||||
|
// Landmarks: RARE, large, far-visible objects placed on a coarse hash lattice (the underground
|
||||||
|
// "mini-suns" etc.). Strate-wide; each entry has its own spacing/biome/placement/transform settings.
|
||||||
|
// Cheap at any radius — see FStrateLandmark / §8.5. (NOT part of the per-chunk decoration grid.)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
|
||||||
|
TArray<FStrateLandmark> Landmarks;
|
||||||
|
|
||||||
// Ambient actors: things floating in cave space (fog volumes, particles, lights)
|
// Ambient actors: things floating in cave space (fog volumes, particles, lights)
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
|
||||||
TArray<FStrateAmbientActor> AmbientActors;
|
TArray<FStrateAmbientActor> AmbientActors;
|
||||||
|
|||||||
@@ -48,19 +48,10 @@ struct FVoxelPassage
|
|||||||
FVector UpperPoint = FVector::ZeroVector; // Entry in upper strate
|
FVector UpperPoint = FVector::ZeroVector; // Entry in upper strate
|
||||||
FVector LowerPoint = FVector::ZeroVector; // Exit in lower strate
|
FVector LowerPoint = FVector::ZeroVector; // Exit in lower strate
|
||||||
|
|
||||||
// An optional midpoint for non-straight passages (sloped, curved).
|
|
||||||
// Used by SlopedTunnel and CrackCrevice types. Ignored when ControlPoints is populated.
|
|
||||||
FVector MidPoint = FVector::ZeroVector;
|
|
||||||
|
|
||||||
// Passage dimensions — how wide the carved tunnel is (in voxels).
|
// Passage dimensions — how wide the carved tunnel is (in voxels).
|
||||||
// Varies by type: VerticalShaft ~7-8, SpiralDescent ~4, CrackCrevice ~2-3, others ~5.
|
// Varies by type: VerticalShaft ~7-8, SpiralDescent ~4, CrackCrevice ~2-3, others ~5.
|
||||||
float Radius = 5.0f;
|
float Radius = 5.0f;
|
||||||
|
|
||||||
// Whether this passage uses a midpoint (curved/sloped) or is straight.
|
|
||||||
// Only relevant when ControlPoints is empty — if ControlPoints has entries,
|
|
||||||
// the passage is evaluated as a capsule chain along those points instead.
|
|
||||||
bool bHasMidPoint = false;
|
|
||||||
|
|
||||||
// The shape/style of this passage. Determines how control points are generated
|
// The shape/style of this passage. Determines how control points are generated
|
||||||
// and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.).
|
// and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.).
|
||||||
EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel;
|
EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel;
|
||||||
@@ -80,7 +71,9 @@ struct FVoxelPassage
|
|||||||
// Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords.
|
// Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords.
|
||||||
// Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with
|
// Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with
|
||||||
// a single squared-distance test instead of walking every segment per voxel.
|
// a single squared-distance test instead of walking every segment per voxel.
|
||||||
|
// BoundRadius (linear) feeds the per-chunk shortlist reach; BoundRadiusSq the per-voxel test.
|
||||||
FVector BoundCenter = FVector::ZeroVector;
|
FVector BoundCenter = FVector::ZeroVector;
|
||||||
|
float BoundRadius = 0.0f;
|
||||||
float BoundRadiusSq = 0.0f;
|
float BoundRadiusSq = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
#include "GameplayTagContainer.h"
|
#include "GameplayTagContainer.h"
|
||||||
#include "VoxelStrateTypes.generated.h"
|
#include "VoxelStrateTypes.generated.h"
|
||||||
|
|
||||||
|
class UVoxelBiomeDefinition; // FStrateLandmark::RequiredBiome (optional per-landmark biome filter)
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// ENUMS
|
// ENUMS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -83,6 +85,32 @@ enum class ESurfaceType : uint8
|
|||||||
Any UMETA(DisplayName = "Any surface")
|
Any UMETA(DisplayName = "Any surface")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDecoStreamTier — Which of the two decoration streaming grids an entry uses (§8.5).
|
||||||
|
*
|
||||||
|
* Decorations stream on a fixed world XY grid by distance (no LOD pop). To keep that flicker-free,
|
||||||
|
* the STREAM RADIUS is a property of the GRID, never of an entry — mixing radii inside one grid would
|
||||||
|
* force a region to re-stream in place when the player crosses an entry's radius (the old tier system's
|
||||||
|
* flicker bug). So there are exactly two grids, and an entry just PICKS one:
|
||||||
|
*
|
||||||
|
* Far — full radius (VoxelSettings::DecorationRadiusChunks) + COARSE column spacing
|
||||||
|
* (DecorationFarSpacingVoxels). The coarse grid is what makes a RARE prop you want visible at
|
||||||
|
* every distance cheap: the worker ray-march cost scales with column count, and a sparse prop
|
||||||
|
* does not need the dense near grid. Default — and the far spacing defaults to the fine value,
|
||||||
|
* so existing assets are byte-identical until you opt in to a coarser far grid. Trees, landmarks.
|
||||||
|
*
|
||||||
|
* Near — short radius (DecorationNearRadiusChunks) + FINE column spacing (DecorationSpacingVoxels).
|
||||||
|
* For dense groundcover (grass, small clutter) that only needs to exist near the player: keeping
|
||||||
|
* it out of the far regions saves their HISM cluster-tree build + instance memory. Pair with the
|
||||||
|
* per-entry CullDistance (GPU draw bound) for the full picture.
|
||||||
|
*/
|
||||||
|
UENUM(BlueprintType)
|
||||||
|
enum class EDecoStreamTier : uint8
|
||||||
|
{
|
||||||
|
Far UMETA(DisplayName = "Far (full radius, coarse grid — trees/landmarks/rare props)"),
|
||||||
|
Near UMETA(DisplayName = "Near (short radius, fine grid — dense groundcover)")
|
||||||
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// NOISE TYPE
|
// NOISE TYPE
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -1702,7 +1730,8 @@ struct VOXELFORGE_API FStrateDecoration
|
|||||||
|
|
||||||
// The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster).
|
// The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster).
|
||||||
// Real actors: lights, logic, interaction. They cost game-thread time per instance —
|
// Real actors: lights, logic, interaction. They cost game-thread time per instance —
|
||||||
// keep MaxLODLevel at 0 for these, and prefer InstancedMesh for pure visual props.
|
// prefer InstancedMesh for pure visual props, and consider the Far tier so the coarse grid keeps
|
||||||
|
// their spawn count down.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
||||||
TSubclassOf<AActor> ActorClass;
|
TSubclassOf<AActor> ActorClass;
|
||||||
|
|
||||||
@@ -1713,11 +1742,13 @@ struct VOXELFORGE_API FStrateDecoration
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
||||||
UStaticMesh* InstancedMesh = nullptr;
|
UStaticMesh* InstancedMesh = nullptr;
|
||||||
|
|
||||||
// LEGACY / UNUSED by the world-grid decoration system (§8.5). It once meant a clipmap tile level,
|
// Which of the two decoration streaming grids this entry uses (§8.5). Far (default) = full radius +
|
||||||
// then a near/far distance tier — both removed. All decorations now stream within a single radius
|
// coarse column grid (cheap for rare/large props visible everywhere); Near = short radius + fine
|
||||||
// (VoxelSettings::DecorationRadiusChunks) and never re-stream in place. Kept to avoid breaking assets.
|
// column grid (dense groundcover near the player only). The radius/spacing presets live on
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "8"))
|
// VoxelSettings; this only PICKS a grid. Defaults reproduce the legacy single-radius fine grid until
|
||||||
int32 MaxLODLevel = 0;
|
// you opt into a coarser far spacing or move an entry to Near. (Replaces the old vestigial MaxLODLevel.)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
||||||
|
EDecoStreamTier StreamTier = EDecoStreamTier::Far;
|
||||||
|
|
||||||
// Which surface type this decoration can be placed on
|
// Which surface type this decoration can be placed on
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
||||||
@@ -1818,6 +1849,158 @@ struct VOXELFORGE_API FStrateDecoration
|
|||||||
bool bCastShadow = true;
|
bool bCastShadow = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FStrateLandmark — A RARE, large, far-visible object placed on a coarse HASH LATTICE (§8.5).
|
||||||
|
*
|
||||||
|
* This is the right primitive for things like the underground "mini-suns" (in-lore light sources): one
|
||||||
|
* object per ~`SpacingChunks` lattice cell, so the work scales with how MANY landmarks are in range
|
||||||
|
* (a handful), NOT with the streamed area. That makes a HUGE stream radius (e.g. visible 16 km out so it
|
||||||
|
* never pops) cheap — unlike the per-chunk decoration grid, which enumerates every chunk in the disk and
|
||||||
|
* freezes at large radius. Placement is deterministic (pure hash of cell + entry + seed → no pop, same
|
||||||
|
* landmark in the same place forever), evaluated synchronously on the game thread only when a NEW lattice
|
||||||
|
* cell enters range (there are so few candidates this never hitches). Strate-wide (listed on the strate
|
||||||
|
* definition), with an optional per-landmark biome filter. Foliage-style transform tweaks are exposed.
|
||||||
|
*/
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct VOXELFORGE_API FStrateLandmark
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
// ----- What to spawn (one of these; ActorClass wins if both set) -----
|
||||||
|
|
||||||
|
// Real actor — use this for a sun that carries its own LIGHT / logic. Rare, so the per-actor cost is fine.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark")
|
||||||
|
TSubclassOf<AActor> ActorClass;
|
||||||
|
|
||||||
|
// OR a plain static mesh (spawned as one StaticMeshComponent — no actor/tick overhead). An emissive
|
||||||
|
// material glows at distance without a light. Ignored if ActorClass is set.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark")
|
||||||
|
UStaticMesh* InstancedMesh = nullptr;
|
||||||
|
|
||||||
|
// ----- Rarity / spacing (the hash lattice — this is what makes it cheap) -----
|
||||||
|
|
||||||
|
// Average spacing between landmarks, IN CHUNKS. This is the lattice cell size: exactly one candidate is
|
||||||
|
// considered per SpacingChunks×SpacingChunks cell, so cost scales with (radius/spacing)². This is also
|
||||||
|
// the primary "distance between two instances" control. Large = rare & far apart.
|
||||||
|
// 16 → fairly frequent landmarks · 64 → sparse (good default) · 256+ → one every few km
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1.0"))
|
||||||
|
float SpacingChunks = 64.0f;
|
||||||
|
|
||||||
|
// How far within its cell a candidate may wander (0 = dead-centre grid, 1 = anywhere in the cell).
|
||||||
|
// The effective MINIMUM spacing between two instances ≈ SpacingChunks·(1 − JitterFraction); keep it
|
||||||
|
// below 1 to preserve a spacing guarantee while still breaking up the grid regularity.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float JitterFraction = 0.5f;
|
||||||
|
|
||||||
|
// Probability that a lattice cell actually contains this landmark (0-1). Combine with SpacingChunks for
|
||||||
|
// "rare AND well-spaced": SpacingChunks sets the grid, SpawnProbability sets how many slots fill.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float SpawnProbability = 1.0f;
|
||||||
|
|
||||||
|
// How far out (in chunks) landmarks stream / stay visible. CHEAP to make large here (the lattice means a
|
||||||
|
// 2048-chunk radius is still only ~(2048/Spacing)² candidates). Set big enough that a massive object
|
||||||
|
// never pops in at a jarring distance.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1"))
|
||||||
|
int32 StreamRadiusChunks = 256;
|
||||||
|
|
||||||
|
// ----- Placement restriction (mirrors the base decoration gates) -----
|
||||||
|
|
||||||
|
// Optional: only place inside this biome (resolved at the candidate XY). Null = any biome in the strate.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement")
|
||||||
|
UVoxelBiomeDefinition* RequiredBiome = nullptr;
|
||||||
|
|
||||||
|
// Which surface to snap to. Suns typically sit on the sky-cap CEILING; set Floor for ground monuments,
|
||||||
|
// Any for the first surface found. Wall-leaning surfaces are matched by the same normal test as decos.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement")
|
||||||
|
ESurfaceType SurfacePlacement = ESurfaceType::Ceiling;
|
||||||
|
|
||||||
|
// Surface-tilt band (deg from flat = acos(|normal.Z|); 0 = flat, 90 = vertical). MaxSlopeAngle rejects
|
||||||
|
// surfaces STEEPER than it (90 = no filter); MinSlopeAngle rejects surfaces FLATTER than it (0 = none).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||||
|
float MaxSlopeAngle = 90.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||||
|
float MinSlopeAngle = 0.0f;
|
||||||
|
|
||||||
|
// Water-relative gate (ignored unless the strate has a water table): place only below (true) / above
|
||||||
|
// (false) the water line when bRequireWaterRelative is set.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement")
|
||||||
|
bool bRequireWaterRelative = false;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (EditCondition = "bRequireWaterRelative"))
|
||||||
|
bool bPlaceBelowWater = false;
|
||||||
|
|
||||||
|
// ----- Transform tweaks (foliage-style) -----
|
||||||
|
|
||||||
|
// Rotate the object so its up-axis follows the surface normal. OFF by default — a sun usually wants to
|
||||||
|
// stay world-upright regardless of the ceiling tilt. ON makes it lie against the surface.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
|
||||||
|
bool bAlignToSurface = false;
|
||||||
|
|
||||||
|
// WORLD-space position offset (cm) added after the surface snap. E.g. +Z lifts a sun up off the
|
||||||
|
// sky-cap into the open cavern; use X/Y to nudge it off the exact column.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
|
||||||
|
FVector LocationOffset = FVector::ZeroVector;
|
||||||
|
|
||||||
|
// Fixed rotation applied on top of the (optional) surface alignment.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
|
||||||
|
FRotator RotationOffset = FRotator::ZeroRotator;
|
||||||
|
|
||||||
|
// Per-axis RANDOM rotation range (degrees) — each instance gets a hash-deterministic ±value/2 on each
|
||||||
|
// axis (Pitch/Yaw/Roll). 0 on an axis = no randomisation there. Yaw alone = spin variety; all three =
|
||||||
|
// tumbled debris look.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
|
||||||
|
FRotator RandomRotation = FRotator::ZeroRotator;
|
||||||
|
|
||||||
|
// Uniform scale range (hash-random per instance).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
|
||||||
|
float MinScale = 1.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
|
||||||
|
float MaxScale = 1.0f;
|
||||||
|
|
||||||
|
// ----- Render tuning (the InstancedMesh / StaticMeshComponent path) -----
|
||||||
|
|
||||||
|
// Distance (cm) past which the mesh stops drawing. 0 = NEVER cull (the right choice for a far-visible
|
||||||
|
// sun). Only affects the InstancedMesh path.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Performance", meta = (ClampMin = "0.0"))
|
||||||
|
float CullDistance = 0.0f;
|
||||||
|
|
||||||
|
// Whether the mesh casts a shadow. Only affects the InstancedMesh path.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Performance")
|
||||||
|
bool bCastShadow = true;
|
||||||
|
|
||||||
|
// ----- MINI-SUN LIGHT ORB (feeds the terrain material's raymarched shadows) -----
|
||||||
|
// When set, this landmark is also a LIGHT SOURCE: the terrain material marches the density volume
|
||||||
|
// toward it for from-the-orb, crisp, dynamic shadows (forward rendering). The visible glowing mesh is
|
||||||
|
// still the InstancedMesh/ActorClass above — this just declares the lighting. NOT a UE light actor.
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb")
|
||||||
|
bool bIsLightOrb = false;
|
||||||
|
|
||||||
|
// Light colour of the orb.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb"))
|
||||||
|
FLinearColor OrbColor = FLinearColor(1.0f, 0.95f, 0.85f, 1.0f);
|
||||||
|
|
||||||
|
// Overall brightness multiplier.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "0.0"))
|
||||||
|
float OrbIntensity = 3.0f;
|
||||||
|
|
||||||
|
// Orb emitter radius in VOXELS (visual/softness reference; falloff origin).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "0.0"))
|
||||||
|
float OrbRadiusVoxels = 16.0f;
|
||||||
|
|
||||||
|
// Distance in VOXELS over which the orb's light falls to zero. YOU author this (no inverse-square
|
||||||
|
// blowout) — bigger = lights a wider area. (1 voxel = 25 cm.)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "1.0"))
|
||||||
|
float OrbFalloffVoxels = 2000.0f;
|
||||||
|
|
||||||
|
// Max distance in VOXELS along the shadow ray we test for occlusion (bounds the per-pixel march cost;
|
||||||
|
// past this the point is treated as lit). Keep ≤ the level-0 volume reach for crisp contact shadows.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "1.0"))
|
||||||
|
float OrbMaxShadowDistanceVoxels = 1000.0f;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FStrateAmbientActor — An actor that spawns in open cave space.
|
* FStrateAmbientActor — An actor that spawns in open cave space.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ constexpr int32 CHUNK_VOLUME = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 32
|
|||||||
|
|
||||||
constexpr float VOXEL_SIZE = 25.0f;
|
constexpr float VOXEL_SIZE = 25.0f;
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// DENSITY → R8 QUANTIZATION (density clipmap / mini-sun shadows)
|
||||||
|
//=============================================================================
|
||||||
|
//
|
||||||
|
// MC convention: NÉGATIF = solide, POSITIF = air, 0 = isosurface. On encode la densité
|
||||||
|
// dans un R8 où SOLIDE = HAUT, AIR = BAS, iso ≈ 0.5 (128), clampé ±1 autour de la surface
|
||||||
|
// (loin de la surface ⇒ sature plein solide / plein air). Le trilinear garde la traversée
|
||||||
|
// iso sub-voxel nette.
|
||||||
|
//
|
||||||
|
// SHARED entre deux producteurs qui DOIVENT rester bit-identiques :
|
||||||
|
// 1. UVoxelDensityVolume (fill worker re-évaluant GetDensityAt),
|
||||||
|
// 2. UVoxelMarchingCubesMesher (capture-during-meshing : réutilise la grille déjà
|
||||||
|
// échantillonnée par le mesher au lieu de re-sampler — voir GenerateMesh OutCaptureGrid).
|
||||||
|
// Même densité d'entrée ⇒ même octet. Ne PAS dupliquer cette formule ailleurs.
|
||||||
|
FORCEINLINE uint8 VF_QuantizeDensity(float MCDensity)
|
||||||
|
{
|
||||||
|
const float S = FMath::Clamp(0.5f - 0.5f * MCDensity, 0.0f, 1.0f);
|
||||||
|
return (uint8)FMath::RoundToInt(S * 255.0f);
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// FACE DIRECTIONS
|
// FACE DIRECTIONS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
#include "GameFramework/Actor.h"
|
#include "GameFramework/Actor.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include "VoxelTypes.h"
|
#include "VoxelTypes.h"
|
||||||
#include "VoxelChunk.h"
|
|
||||||
#include "VoxelGenerator.h"
|
#include "VoxelGenerator.h"
|
||||||
#include "VoxelMarchingCubesMesher.h"
|
#include "VoxelMarchingCubesMesher.h"
|
||||||
#include "VoxelSettings.h"
|
#include "VoxelSettings.h"
|
||||||
@@ -21,7 +20,11 @@ class URealtimeMeshSimple;
|
|||||||
class UVoxelDiffLayer;
|
class UVoxelDiffLayer;
|
||||||
class UVoxelContentManager;
|
class UVoxelContentManager;
|
||||||
class UVoxelAtmosphereManager;
|
class UVoxelAtmosphereManager;
|
||||||
|
class UVoxelDensityVolume;
|
||||||
|
class UMaterialParameterCollection;
|
||||||
|
class UVolumeTexture;
|
||||||
class UMaterialInterface;
|
class UMaterialInterface;
|
||||||
|
class UMaterialInstanceDynamic;
|
||||||
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +56,11 @@ struct FChunkResult
|
|||||||
// view the way a game-thread height-oracle sample did (it misclassified coarse far tiles). The
|
// view the way a game-thread height-oracle sample did (it misclassified coarse far tiles). The
|
||||||
// game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow.
|
// game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow.
|
||||||
bool bIsCeiling = false;
|
bool bIsCeiling = false;
|
||||||
|
// CAPTURE-DURING-MESHING: the tile's CHUNK_SIZE³ R8 density grid, captured by the mesher (no extra
|
||||||
|
// GetDensityAt). Non-empty only for capture-eligible tiles (level 0, full-res). The game thread hands
|
||||||
|
// it to UVoxelDensityVolume::IngestTileCapture so the density clipmap reuses the mesher's samples
|
||||||
|
// instead of re-sampling. Moved (not copied) through the MPSC queue. See UVoxelDensityVolume.
|
||||||
|
TArray<uint8> CaptureGrid;
|
||||||
};
|
};
|
||||||
|
|
||||||
UCLASS()
|
UCLASS()
|
||||||
@@ -102,6 +110,20 @@ public:
|
|||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
UVoxelAtmosphereManager* AtmosphereManager;
|
UVoxelAtmosphereManager* AtmosphereManager;
|
||||||
|
|
||||||
|
/** Player-centred density CLIPMAP streamed onto the GPU for the mini-sun raymarched shadow
|
||||||
|
* system (forward rendering). Created in BeginPlay when Settings->bEnableDensityVolume is on.
|
||||||
|
* Filled on worker threads (re-evaluating GetDensityAt), recentred toroidally as the player
|
||||||
|
* moves, refilled locally on carve. See UVoxelDensityVolume. */
|
||||||
|
UPROPERTY()
|
||||||
|
UVoxelDensityVolume* DensityVolume;
|
||||||
|
|
||||||
|
/** Shared Material Instance Dynamics that bind the density-volume textures + per-frame shadow params
|
||||||
|
* (clipmap transform + nearest orb) onto the terrain material(s). Keyed by BASE material so every
|
||||||
|
* tile of a given base shares ONE MID (no batching cost). Created lazily in ApplyMeshToTile,
|
||||||
|
* refreshed each Tick by UpdateTerrainMaterialParams. */
|
||||||
|
UPROPERTY()
|
||||||
|
TMap<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>> TerrainMIDs;
|
||||||
|
|
||||||
/** When true, VoxelForge spawns & drives its own height fog + skylight + ceiling/floor
|
/** When true, VoxelForge spawns & drives its own height fog + skylight + ceiling/floor
|
||||||
* layer actors from each strate's settings. Turn OFF if you manage fog/lighting
|
* layer actors from each strate's settings. Turn OFF if you manage fog/lighting
|
||||||
* yourself in the level (avoids a duplicate ExponentialHeightFog). */
|
* yourself in the level (avoids a duplicate ExponentialHeightFog). */
|
||||||
@@ -249,6 +271,50 @@ public:
|
|||||||
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
|
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
|
||||||
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const;
|
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// LIGHTING — DENSITY VOLUME (debug / material wiring)
|
||||||
|
//=========================================================================
|
||||||
|
|
||||||
|
/** The GPU R8 density volume texture for a clip level (0 = finest, near the player). Null until the
|
||||||
|
* volume has streamed in / if GPU upload is off. STEP 1b-i validation: in a debug BP, create a
|
||||||
|
* dynamic material instance of a Volume-Texture-sampling material and SetTextureParameterValue from
|
||||||
|
* this — you should see the density field, centred on the player, updating as you move & carve. */
|
||||||
|
UFUNCTION(BlueprintCallable, Category = "Voxel World|Lighting")
|
||||||
|
UVolumeTexture* GetDensityVolumeTexture(int32 Level = 0) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/** Get/create the shared MID wrapping a base terrain material (binds volume textures + shadow params).
|
||||||
|
* Returns Base unchanged-wrapped, or nullptr if Base is null. */
|
||||||
|
UMaterialInstanceDynamic* GetOrCreateTerrainMID(UMaterialInterface* Base);
|
||||||
|
|
||||||
|
/** Recompute the packed volume/orb shader params (TVP0..4) from the density volume + nearest orb, and
|
||||||
|
* push them (and the volume textures) onto every terrain MID. Called each Tick. */
|
||||||
|
void UpdateTerrainMaterialParams();
|
||||||
|
|
||||||
|
/** Apply the current TVP0..4 + level-0 volume texture to one MID (also used on MID creation). */
|
||||||
|
void SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const;
|
||||||
|
|
||||||
|
// Packed shader params, recomputed each Tick. ALL meaningful data is in .xyz — a material Vector
|
||||||
|
// Parameter only delivers float3 (RGB) into a Custom node (the alpha is dropped), so we never use .w.
|
||||||
|
// TVP0 = L0 WindowOrigin.xyz (world cm) TVP1 = L0 OriginMod.xyz (cells)
|
||||||
|
// TVP2 = OrbPos.xyz (world cm) TVP3 = OrbColor.rgb * OrbIntensity (premultiplied)
|
||||||
|
// TVP4 = (OrbMaxDist, OrbFalloff, MarchSteps) TVP5 = (Res, L0 CellWorldSize, OrbEnable)
|
||||||
|
// TVP6 = L1 WindowOrigin.xyz TVP7 = L1 OriginMod.xyz
|
||||||
|
// TVP8 = L2 WindowOrigin.xyz TVP9 = L2 OriginMod.xyz
|
||||||
|
// Coarser levels' cell size is derived in-shader (cell_L = L0Cell * 2^L); Res is shared.
|
||||||
|
FLinearColor TVP0 = FLinearColor::Black, TVP1 = FLinearColor::Black, TVP2 = FLinearColor::Black,
|
||||||
|
TVP3 = FLinearColor::Black, TVP4 = FLinearColor::Black, TVP5 = FLinearColor::Black,
|
||||||
|
TVP6 = FLinearColor::Black, TVP7 = FLinearColor::Black,
|
||||||
|
TVP8 = FLinearColor::Black, TVP9 = FLinearColor::Black;
|
||||||
|
|
||||||
|
// Change-detection for the per-Tick pushes: MID vector/texture sets and MPC writes each enqueue
|
||||||
|
// render-thread updates, so skip them entirely on the (common) frames where nothing moved.
|
||||||
|
TWeakObjectPtr<UVolumeTexture> LastBoundVolTex0; // re-push MIDs if the L0 texture was recreated
|
||||||
|
FLinearColor LastOrbMPC[4] = { FLinearColor(FLT_MAX, 0, 0, 0), FLinearColor(FLT_MAX, 0, 0, 0),
|
||||||
|
FLinearColor(FLT_MAX, 0, 0, 0), FLinearColor(FLT_MAX, 0, 0, 0) };
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// LIVE EDIT (debug tuning in PIE)
|
// LIVE EDIT (debug tuning in PIE)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -409,6 +475,17 @@ public:
|
|||||||
*/
|
*/
|
||||||
void ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling);
|
void ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling);
|
||||||
|
|
||||||
|
/** Mini-sun lighting (bounded directional). Each frame writes the nearest 4 active orbs' WORLD
|
||||||
|
* positions (+ reach radius in .w) into OrbLightMPC's Orb0..3 vector params; the Directional
|
||||||
|
* Light's Light Function material reads them to mask its contribution into a pool around each
|
||||||
|
* orb. No-op until OrbLightMPC is assigned. Replaces the density-volume raymarch. */
|
||||||
|
void UpdateOrbLightMPC();
|
||||||
|
|
||||||
|
/** The Material Parameter Collection (MPC_VoxelOrbs) the orb Light Function reads. Assign in the
|
||||||
|
* AVoxelWorld details. Params expected: Vector Orb0,Orb1,Orb2,Orb3 = (x,y,z, reachRadiusCm). */
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
|
||||||
|
UMaterialParameterCollection* OrbLightMPC = nullptr;
|
||||||
|
|
||||||
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
|
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
|
||||||
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
|
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
|
||||||
|
|
||||||
@@ -424,31 +501,8 @@ public:
|
|||||||
/** Get the current player position (or zero if no player) */
|
/** Get the current player position (or zero if no player) */
|
||||||
FVector GetPlayerPosition() const;
|
FVector GetPlayerPosition() const;
|
||||||
|
|
||||||
/** Check if a chunk coordinate is within view distance of a center chunk */
|
// (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap
|
||||||
bool IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const;
|
// streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.)
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine LOD level for a chunk based on its distance from the center.
|
|
||||||
*
|
|
||||||
* LOD CONCEPT:
|
|
||||||
* Chunks close to the player get full resolution (LOD0, Step=1).
|
|
||||||
* Chunks further away get coarser resolution (LOD1=Step 2, LOD2=Step 4).
|
|
||||||
* This dramatically reduces triangle count for distant terrain without
|
|
||||||
* visible quality loss (they're far away!).
|
|
||||||
*
|
|
||||||
* @param ChunkCoord - The chunk to evaluate
|
|
||||||
* @param CenterChunk - The player's current chunk
|
|
||||||
* @return LOD level: 0 (full), 1 (half), 2 (quarter)
|
|
||||||
*/
|
|
||||||
int32 GetLODForChunk(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert LOD level to marching cubes step size.
|
|
||||||
* LOD0 → Step 1 (every voxel)
|
|
||||||
* LOD1 → Step 2 (every 2nd voxel)
|
|
||||||
* LOD2 → Step 4 (every 4th voxel)
|
|
||||||
*/
|
|
||||||
static int32 LODToStep(int32 LODLevel);
|
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// ASYNC
|
// ASYNC
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ public class VoxelForge : ModuleRules
|
|||||||
PrivateDependencyModuleNames.AddRange(new string[]
|
PrivateDependencyModuleNames.AddRange(new string[]
|
||||||
{
|
{
|
||||||
"ImageWrapper", // PNG encode for the biome-map preview bake (BakeBiomePreview)
|
"ImageWrapper", // PNG encode for the biome-map preview bake (BakeBiomePreview)
|
||||||
|
"RHI", // Texture3D create + RHIUpdateTexture3D for the density volume (mini-sun shadows)
|
||||||
|
"RenderCore", // ENQUEUE_RENDER_COMMAND for the volume upload
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user