diff --git a/CODEMAP.md b/CODEMAP.md index 29341d4..ad4e3ea 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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]. | | `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` -`FVoxelChunk` (USTRUCT, line 19): just a `ChunkCoord` + `GetWorldPosition()`. In a -density-only world the chunk stores no voxels — it's a coord wrapper. Room to cache -per-chunk info later. +### 3.3 Chunk identity +`VoxelChunk.h` (the old `FVoxelChunk` coord wrapper) was DELETED — dead since the tile +redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). ### 3.4 Settings — `Public/VoxelSettings.h` `UVoxelSettings : UPrimaryDataAsset` — the single tuning asset assigned on `AVoxelWorld`. | Group | Fields (line) | |-------|---------------| | 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) | | 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. | @@ -125,9 +125,7 @@ per-chunk info later. | `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | | `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. | | `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. | -| `GetPlayerPosition` | 231 | Pawn position or zero. | -| `GetLODForChunk` / `LODToStep` | 242 / 268 | Distance→LOD (0/1/2) → step (1/2/4). | -| `IsChunkInRange` | 275 | View-distance test. | +| `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) | | `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. | | `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. | ### 3.10 Mesher — `Public/VoxelMarchingCubesMesher.h` + `.cpp` -`UVoxelMarchingCubesMesher : UObject` (h:21). Holds `Generator` ptr, `IsoLevel=0`, -`GradientOffset=1`. +`UVoxelMarchingCubesMesher : UObject` (h:21). Holds `Generator` ptr, `IsoLevel=0`, skirt params. +(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 | |--------|-----------|------| -| `GetDensity` | 11 | Local coord → world → `Generator->GetDensityAt`. | -| `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. | +| **`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. | **`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul 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) | 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/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, > 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). | | Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). | | 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). | | 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). | diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp index 92eb980..26d72c9 100644 --- a/Source/VoxelForge/Private/VoxelContentManager.cpp +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -70,12 +70,23 @@ void UVoxelContentManager::NotifyShutdown() FPlatformProcess::Yield(); } + DrainDecoResults(); + ResetGridBuildState(NearGrid); + ResetGridBuildState(FarGrid); +} + +void UVoxelContentManager::DrainDecoResults() +{ FDecoCellResult Discard; while (DecoResults.Dequeue(Discard)) {} - RegionBuilds.Reset(); - CompletedRegions.Reset(); - PendingLaunch.Reset(); - InFlightCells.Reset(); +} + +void UVoxelContentManager::ResetGridBuildState(FDecoGrid& G) +{ + 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). CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk); - // Build the decoration palette ONCE for this update. 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 → legacy behaviour). - CurrentEntries.Reset(); - CurrentEntryBiome.Reset(); + // Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read + // every frame so live edits to the data asset take effect; the grids themselves persist across updates. + NearGrid.Tier = EDecoStreamTier::Near; + NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks); + NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE); + 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.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). const TArray& Src = (Bio && Bio->Decorations.Num() > 0) ? Bio->Decorations : CurrentCtx.Def->Decorations; - for (const FStrateDecoration& D : Src) - { - CurrentEntries.Add(D); - CurrentEntryBiome.Add(ci); - } + for (const FStrateDecoration& D : Src) { AddEntry(D, ci); } } } else { - for (const FStrateDecoration& D : CurrentCtx.Def->Decorations) - { - CurrentEntries.Add(D); - CurrentEntryBiome.Add(-1); // no biome field → matches the column's ColBiome (-1) - } + for (const FStrateDecoration& D : CurrentCtx.Def->Decorations) { AddEntry(D, -1); } // -1 → matches ColBiome -1 } } @@ -221,22 +240,26 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos) if (PlayerCell != LastDecoCell) { - RebuildDesiredCells(PlayerCell); + RebuildDesiredCells(NearGrid, PlayerCell); + RebuildDesiredCells(FarGrid, PlayerCell); LastDecoCell = PlayerCell; } - const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks); - LaunchDecoTasks(PlayerCell); - ProcessDecoResults(PlayerCell, FarR); + // Both grids share ONE concurrency budget; throttle each against the other's current in-flight count. + const int32 MaxConc = Settings->MaxConcurrentDecorationTasks; + 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/ - // unload unit and shares ONE HISM per mesh, so the render thread walks ~R^2 fewer components. A + // REGION-granular streaming, per grid. Decoration cells are grouped into RxR regions; a region is the + // 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 - // place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region). - const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks); + // place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region). The + // 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(); // 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). { - TArray Loaded; DecoRegions.GetKeys(Loaded); + TArray Loaded; G.Regions.GetKeys(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). for (const FIntPoint& Region : DesiredRegions) { - if (DecoRegions.Contains(Region)) continue; // already applied → leave it (no re-stream) - if (RegionBuilds.Contains(Region)) continue; // already marching its cells + if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream) + if (G.Builds.Contains(Region)) continue; // already marching its cells - FDecoRegionBuild& Build = RegionBuilds.Add(Region); - Build.BuildId = NextBuildId++; + FDecoRegionBuild& Build = G.Builds.Add(Region); + Build.BuildId = G.NextBuildId++; Build.CellsRemaining = R * R; const int32 BaseX = Region.X * R, BaseY = Region.Y * R; for (int32 cy = 0; cy < R; ++cy) 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 // 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); }); } -void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell) +void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc) { if (!CurrentCtx.Def || !Generator) return; - const int32 MaxConc = Settings->MaxConcurrentDecorationTasks; if (MaxConc <= 0) { - // Decorations disabled at runtime — drop all queued/pending build state so nothing is stranded. - PendingLaunch.Reset(); - RegionBuilds.Reset(); - CompletedRegions.Reset(); + // Decorations disabled at runtime — drop THIS grid's queued/pending build state so nothing is stranded. + G.PendingLaunch.Reset(); + G.Builds.Reset(); + G.Completed.Reset(); return; } @@ -309,46 +331,54 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell) const FTransform OwnerXf = OwnerActor->GetActorTransform(); 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 int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn); 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 Deferred; + while (Head < G.PendingLaunch.Num() && (G.InFlightCells.Num() + OtherInFlight) < MaxConc) { - const FIntPoint Cell = PendingLaunch[0]; - PendingLaunch.RemoveAt(0); + const FIntPoint Cell = G.PendingLaunch[Head++]; - 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 // 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. const FIntPoint Region = CellToRegion(Cell, R); - FDecoRegionBuild* Build = RegionBuilds.Find(Region); + FDecoRegionBuild* Build = G.Builds.Find(Region); if (!Build) continue; 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 + // 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; } - TArray EntriesCopy = CurrentEntries; // snapshot for the worker + the spawner - TArray EntryBiomeCopy = CurrentEntryBiome; // parallel: ctx-biome owner per entry + TArray EntriesCopy = G.Entries; // snapshot for the worker + the spawner + TArray EntryBiomeCopy = G.EntryBiome; // parallel: ctx-biome owner per entry const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker const uint32 LocalSeed = (uint32)Seed; UVoxelGenerator* Gen = Generator; - InFlightCells.Add(Cell); + G.InFlightCells.Add(Cell); GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed); 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 { struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard; @@ -358,6 +388,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell) FDecoCellResult Result; Result.Cell = Cell; Result.BuildId = BuildId; + Result.Grid = GridTier; Result.Entries = MoveTemp(Entries); BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed, Spacing, Step, MaxCross, ColDepth, Result.Spawns); @@ -368,6 +399,9 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell) } }, 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. ---- @@ -387,6 +421,20 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr TArray EntryCount; EntryCount.Init(0, Entries.Num()); 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 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 // (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). @@ -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). if (bWall && Deco.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue; - // Surface-tilt gate: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). Skip surfaces steeper than - // MaxSlopeAngle. cos is monotone-decreasing, so |N.Z| < cos(MaxSlope) ⇔ tilt > MaxSlope. - // Guarded so the default (90°, cos = 0) costs no trig and never rejects anything. - if (Deco.MaxSlopeAngle < 89.99f && - FMath::Abs(NormalWorld.Z) < FMath::Cos(FMath::DegreesToRadians(Deco.MaxSlopeAngle))) - { - 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; - } + // Surface-tilt gates: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). |N.Z| < cos(MaxSlope) ⇔ + // tilt > MaxSlope (skip steeper); |N.Z| > cos(MinSlope) ⇔ tilt < MinSlope (skip flatter). + // Cosines are precomputed per entry above; < 0 = gate disabled (default angles). + if (CosMaxSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) < CosMaxSlope[EntryIdx]) continue; + if (CosMinSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) > CosMinSlope[EntryIdx]) continue; const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u); 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. ---- -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 - // appends) so it isn't budgeted; the expensive HISM build is budgeted below at region granularity. + // Drain every finished cell march and route it to its grid by Result.Grid, folding it into that grid's + // region build. Merging is cheap (transform appends) so it isn't budgeted; the expensive HISM build is + // budgeted below at region granularity. FDecoCellResult R; while (DecoResults.Dequeue(R)) { - InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters - MergeCellResult(R); + FDecoGrid& G = (R.Grid == EDecoStreamTier::Near) ? NearGrid : FarGrid; + 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 - // but is no longer desired (player moved on while it marched) is discarded instead of applied — that - // keeps an out-of-range region from flashing in for a frame before the next unload pass. + // Apply completed regions across BOTH grids under ONE shared frame budget (one batched HISM-per-mesh + // build per region). A region whose build finished but is no longer desired (player moved on while it + // marched) is discarded instead of applied — keeps an out-of-range region from flashing in for a frame. const int32 R_ = RegionSize(); const int32 Budget = FMath::Max(1, Settings->MaxDecorationCellsPerFrame); int32 Applied = 0; - while (CompletedRegions.Num() > 0 && Applied < Budget) + for (FDecoGrid* GP : { &NearGrid, &FarGrid }) { - const FIntPoint Region = CompletedRegions[0]; - CompletedRegions.RemoveAt(0); - - FDecoRegionBuild* Build = RegionBuilds.Find(Region); - if (!Build) continue; // already cleared - - if (!IsRegionDesired(Region, PlayerCell, FarR, R_)) + FDecoGrid& G = *GP; + while (G.Completed.Num() > 0 && Applied < Budget) { - RegionBuilds.Remove(Region); // wandered out of range while building → drop it unbuilt - continue; - } + const FIntPoint Region = G.Completed[0]; + G.Completed.RemoveAt(0); - ApplyRegion(Region, *Build); - RegionBuilds.Remove(Region); - ++Applied; + FDecoRegionBuild* Build = G.Builds.Find(Region); + if (!Build) continue; // already cleared + + if (!IsRegionDesired(Region, PlayerCell, G.Radius, R_)) + { + G.Builds.Remove(Region); // wandered out of range while building → drop it unbuilt + continue; + } + + ApplyRegion(G, Region, *Build); + G.Builds.Remove(Region); + ++Applied; + } + if (Applied >= Budget) break; } } // 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. -void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result) +void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult& Result) { const FIntPoint Region = CellToRegion(Result.Cell, RegionSize()); - FDecoRegionBuild* Build = RegionBuilds.Find(Region); + FDecoRegionBuild* Build = G.Builds.Find(Region); if (!Build || Build->BuildId != Result.BuildId) { 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 // 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. -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; bool bAlreadyAccounted = false; @@ -694,13 +738,13 @@ void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, const FIntPoint 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 -// spawned inline. Moves the region into DecoRegions; the build is removed by the caller. -void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build) +// spawned inline. Moves the region into G.Regions; the build is removed by the caller. +void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FDecoRegionBuild& Build) { 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(); 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 // (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; for (const TWeakObjectPtr& A : Content->Actors) @@ -786,22 +830,338 @@ void UVoxelContentManager::ClearDecorationRegion(const FIntPoint& Region) { if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) { Comp->DestroyComponent(); } } - DecoRegions.Remove(Region); + G.Regions.Remove(Region); } void UVoxelContentManager::ClearAllDecorations() { - TArray Keys; DecoRegions.GetKeys(Keys); - for (const FIntPoint& K : Keys) ClearDecorationRegion(K); - - RegionBuilds.Reset(); // abandon any in-progress builds - CompletedRegions.Reset(); - PendingLaunch.Reset(); - InFlightCells.Reset(); + for (FDecoGrid* GP : { &NearGrid, &FarGrid }) + { + FDecoGrid& G = *GP; + TArray Keys; G.Regions.GetKeys(Keys); + for (const FIntPoint& K : Keys) ClearDecorationRegion(G, K); + ResetGridBuildState(G); // abandon any in-progress builds + } // 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. - FDecoCellResult Discard; - while (DecoResults.Dequeue(Discard)) {} + // gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds. + DrainDecoResults(); +} + +//============================================================================= +// 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(L.ActorClass, Xf, SP)) { Out.Actor = A; } + return; + } + if (L.InstancedMesh) + { + UStaticMeshComponent* C = NewObject(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& OutOrbs) const +{ + OutOrbs.Reset(); + for (const TPair& 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& 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 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() { ClearAllDecorations(); + ClearAllLandmarks(); if (WaterPlane) { WaterPlane->DestroyComponent(); WaterPlane = nullptr; } LastWaterZ = -FLT_MAX; LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN); - // Force a full decoration rebuild on the next update. - LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); - LastStrateIndex = INT32_MIN; + // Force a full decoration + landmark rebuild on the next update. + LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); + LastStrateIndex = INT32_MIN; + LastLandmarkStrate = INT32_MIN; } //============================================================================= @@ -918,50 +1280,63 @@ void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bAppl const float CellMinX = (float)Cell.X * CellWorld, CellMaxX = CellMinX + 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 }) { - bApplied = true; - for (const TWeakObjectPtr& C : Content->Instances) + const FDecoGrid& G = *GP; + if (const FDecoRegionContent* Content = G.Regions.Find(Region)) { - const UHierarchicalInstancedStaticMeshComponent* Comp = C.Get(); - if (!Comp) continue; - const int32 N = Comp->GetInstanceCount(); - InstanceCount += N; - // Count the ones actually inside the probed cell → tells a blank cell apart from a blank region. - for (int32 i = 0; i < N; ++i) + bApplied = true; + for (const TWeakObjectPtr& C : Content->Instances) { - FTransform Xf; - if (!Comp->GetInstanceTransform(i, Xf, /*bWorldSpace=*/false)) continue; - const FVector P = Xf.GetLocation(); - if (P.X >= CellMinX && P.X < CellMaxX && P.Y >= CellMinY && P.Y < CellMaxY) + const UHierarchicalInstancedStaticMeshComponent* Comp = C.Get(); + if (!Comp) continue; + const int32 N = Comp->GetInstanceCount(); + InstanceCount += N; + // Count the ones actually inside the probed cell → tells a blank cell apart from a blank region. + for (int32 i = 0; i < N; ++i) { - ++InstancesInCell; + FTransform Xf; + if (!Comp->GetInstanceTransform(i, Xf, /*bWorldSpace=*/false)) continue; + const FVector P = Xf.GetLocation(); + if (P.X >= CellMinX && P.X < CellMaxX && P.Y >= CellMinY && P.Y < CellMaxY) + { + ++InstancesInCell; + } } } } - } - if (const FDecoRegionBuild* Build = RegionBuilds.Find(Region)) - { - bBuilding = true; - CellsAccounted = Build->AccountedCells.Num(); + if (const FDecoRegionBuild* Build = G.Builds.Find(Region)) + { + bBuilding = true; + CellsAccounted += Build->AccountedCells.Num(); + } } - // PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate - // context (set each UpdateDecorations). Same inputs the worker uses → byte-identical result, so it - // reports exactly what the scatter decides for this cell right now. Only valid when the probed point - // shares the player's current strate (CurrentCtx/CurrentEntries reflect that); else leave -1. + // PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate context + // (set each UpdateDecorations), for BOTH grids' palettes summed. Same inputs the worker uses → + // byte-identical result, so it reports exactly what the scatter decides for this cell right now. Only + // valid when the probed point shares the player's current strate (CurrentCtx reflects that); else -1. 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 int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn); const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels); - TArray Spawns; - BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx, - CurrentEntries, CurrentEntryBiome, (uint32)Seed, - Spacing, Step, MaxCross, ColDepth, Spawns); - LiveMarchSpawns = Spawns.Num(); + 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 Spawns; + BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx, + G.Entries, G.EntryBiome, (uint32)Seed, + G.Spacing, Step, MaxCross, ColDepth, Spawns); + Total += Spawns.Num(); + } + if (bAny) { LiveMarchSpawns = Total; } } } diff --git a/Source/VoxelForge/Private/VoxelDensityVolume.cpp b/Source/VoxelForge/Private/VoxelDensityVolume.cpp new file mode 100644 index 0000000..10dbe39 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelDensityVolume.cpp @@ -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& 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(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 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> Boxes; + BoxDifference(NewOrigin, Dim, Lv.OriginCells, Dim, Boxes); + Lv.OriginCells = NewOrigin; + for (const TPair& 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>& 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(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(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* 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& 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&& 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& 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<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 diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 1dc02f9..a0a6c3c 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -4,77 +4,17 @@ #include "VoxelMarchingCubesMesher.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 //============================================================================= +// (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* OutCaptureGrid) { FVoxelMeshData MeshData; + if (OutCaptureGrid) { OutCaptureGrid->Reset(); } if (!Generator) return MeshData; // 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<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. auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float { @@ -269,9 +230,9 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, Gradients[i] = GradAt(GX, GY, GZ); } - // Interpolation des positions + normales sur les arêtes traversées. Le t est - // calculé exactement comme InterpolateEdge → positions bit-identiques (topologie - // inchangée) ; la normale interpole les gradients de coin par le même t. + // Interpolation des positions + normales sur les arêtes traversées. t = point de + // traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ; + // la normale interpole les gradients de coin par le même t. FVector EdgeVertices[12]; FVector EdgeNormals[12]; for (int32 i = 0; i < 12; i++) diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 5d0ab15..3199345 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -294,7 +294,6 @@ void UVoxelStrateManager::GeneratePassages() Passage.ControlRadii.Add(RadiusAt(T)); } - Passage.bHasMidPoint = false; Passage.UpperPoint = Passage.ControlPoints[0]; Passage.LowerPoint = Passage.ControlPoints.Last(); 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)); const float R = FMath::Sqrt(MaxDistSq) + Passage.Radius + 4.0f; Passage.BoundCenter = Center; + Passage.BoundRadius = 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. Entry.UpperPoint = 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 float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f; Entry.BoundCenter = C; + Entry.BoundRadius = R; Entry.BoundRadiusSq = R * R; } Passages.Add(Entry); @@ -396,7 +396,7 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float for (int32 i = 0; i < Passages.Num(); ++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) { SL_Nearby.Add(i); diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 2e7df91..19bc9b4 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -10,6 +10,11 @@ #include "VoxelBiomeDefinition.h" #include "VoxelTerrainOpDefinition.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 "DrawDebugHelpers.h" #include "IImageWrapper.h" @@ -89,6 +94,9 @@ void AVoxelWorld::RegenerateAllChunks() // Decorations/water are keyed per level-0 chunk — clear them all. 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. PendingTiles.Empty(); // 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(); } + // Stop + drain the density-volume fill tasks (they read the Generator) before UObject teardown. + if (DensityVolume) + { + DensityVolume->NotifyShutdown(); + } + // Destroy any spawned atmosphere layer actors. if (AtmosphereManager) { @@ -328,6 +342,13 @@ void AVoxelWorld::BeginPlay() AtmosphereManager->Initialize(this, StrateManager, Generator); } + // Density volume — player-centred clipmap streamed to the GPU for mini-sun raymarched shadows. + if (Settings->bEnableDensityVolume) + { + DensityVolume = NewObject(this); + DensityVolume->Initialize(this, Generator, Settings); + } + #if WITH_EDITOR // Listen for data asset edits during PIE so live edit can detect // 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 // a decoration cell boundary or changes strate; otherwise just drains the spawn budget. { 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). { 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(); ProcessUnloadQueue(); #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). // Points are in voxel coords → world units (×VOXEL_SIZE) → actor space. if (bDebugDrawPassages && StrateManager) @@ -380,11 +418,6 @@ void AVoxelWorld::Tick(float DeltaTime) for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j) DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]); } - else if (P.bHasMidPoint) - { - DrawSeg(P.UpperPoint, P.MidPoint); - DrawSeg(P.MidPoint, P.LowerPoint); - } else { DrawSeg(P.UpperPoint, P.LowerPoint); @@ -408,65 +441,6 @@ FVector AVoxelWorld::GetPlayerPosition() const 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() { 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). 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". if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams) { @@ -807,13 +789,22 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile) const int32 Step = FMath::Max(1, Extent / Cells); const uint32 TaskEpoch = GenerationEpoch; + // CAPTURE-DURING-MESHING: only level-0 full-res tiles map 1:1 onto a density-clipmap level + // (Step == 1<bEnableDensityVolume + && DensityVolume->IsTileCaptureUseful(Tile.Coord); + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); // BackgroundNormal priority: gen runs on background workers that YIELD to foreground // (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). // 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. struct FTaskGuard @@ -831,7 +822,8 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile) FVoxelMeshData MeshData; { 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 @@ -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. // 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 //============================================================================= +// 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) { - 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; - Mod.Center = VoxelPos; + Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space Mod.Radius = Radius; - Mod.Strength = -FMath::Abs(Strength); // Force negative for carving - - TArray AffectedChunks = DiffLayer->ApplyModification(Mod); - RemeshDirtyChunks(AffectedChunks); + Mod.Strength = -FMath::Abs(Strength); // force negative for carving + ApplyModification(Mod); } 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; - Mod.Center = VoxelPos; + Mod.Center = Position / VOXEL_SIZE; Mod.Radius = Radius; - Mod.Strength = FMath::Abs(Strength); // Force positive for filling - - TArray AffectedChunks = DiffLayer->ApplyModification(Mod); - RemeshDirtyChunks(AffectedChunks); + Mod.Strength = FMath::Abs(Strength); // force positive for filling + ApplyModification(Mod); } void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) @@ -1342,6 +1331,180 @@ void AVoxelWorld::RemeshDirtyChunks(const TArray& DirtyCoords) 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"), 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* 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 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>& Pair : TerrainMIDs) + { + SetVolumeParamsOnMID(Pair.Value.Get()); + } +} + +void AVoxelWorld::UpdateOrbLightMPC() +{ + if (!OrbLightMPC || !ContentManager) return; + + TArray 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); + } +} diff --git a/Source/VoxelForge/Public/VoxelChunk.h b/Source/VoxelForge/Public/VoxelChunk.h deleted file mode 100644 index ea5334f..0000000 --- a/Source/VoxelForge/Public/VoxelChunk.h +++ /dev/null @@ -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); - } -}; diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h index 4e82077..13c43a2 100644 --- a/Source/VoxelForge/Public/VoxelContentManager.h +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -24,9 +24,17 @@ // 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. // -// RENDERING PATHS / DISTANCE TIERS per entry (FStrateDecoration): non-instanced ActorClass entries with -// MaxLODLevel==0 are near-only (DecorationActorRadiusChunks — pricey actors stay close); InstancedMesh -// (HISM) entries + MaxLODLevel>=1 actor entries are any-distance (DecorationRadiusChunks). +// TWO STREAMING GRIDS (FStrateDecoration::StreamTier, §8.5). To stay flicker-free the stream RADIUS must +// be a property of the grid, not the entry (mixing radii in one grid would re-stream a region in place as +// 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. @@ -49,6 +57,19 @@ class UStaticMeshComponent; class UHierarchicalInstancedStaticMeshComponent; 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() class VOXELFORGE_API UVoxelContentManager : public UObject { @@ -78,6 +99,20 @@ public: * launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */ 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& OutOrbs) const; + /** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco * epoch so any in-flight march tasks' results are discarded. */ void ClearAll(); @@ -109,6 +144,7 @@ public: { FIntPoint Cell = FIntPoint::ZeroValue; 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 Entries; // snapshot the game thread spawns from (by EntryIdx) TArray Spawns; }; @@ -152,6 +188,29 @@ private: TSet 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 Regions; // loaded regions + TMap Builds; // regions being marched + TArray PendingLaunch; // cells awaiting a march task (nearest-first) + TSet InFlightCells; // cells with a task in flight + TArray 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 Entries; + TArray EntryBiome; + }; + // 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). struct FDecoContext @@ -170,6 +229,19 @@ private: 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 Actor; // set when the entry uses ActorClass + TWeakObjectPtr 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 * (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 @@ -182,17 +254,38 @@ private: int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth, TArray& OutSpawns); - void LaunchDecoTasks(const FIntPoint& PlayerCell); - void ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR); - void MergeCellResult(const FDecoCellResult& Result); // fold one cell's spawns into its region build - void MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId); // idempotent per-cell accounting - void ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build); - void RebuildDesiredCells(const FIntPoint& PlayerCell); - void ClearDecorationRegion(const FIntPoint& Region); + // Each step operates on ONE grid (G = NearGrid or FarGrid). LaunchDecoTasks throttles against the + // COMBINED in-flight count (OtherInFlight = the other grid's in-flight cells) so the two grids share + // one concurrency budget. ProcessDecoResults drains the shared result queue, routing each result to its + // grid by FDecoCellResult::Grid, then applies both grids' completed regions under one frame budget. + void LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc); + void ProcessDecoResults(const FIntPoint& PlayerCell); + 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 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. 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 Owner; @@ -211,27 +304,22 @@ private: UPROPERTY() UStaticMesh* PlaneMesh = nullptr; - // Loaded decoration regions (FIntPoint = region XY). One HISM per mesh per region. Not a UPROPERTY - // (weak ptrs inside; the owner actor keeps the components alive). - TMap DecoRegions; + // The two streaming grids. Each owns its loaded regions, in-progress builds, launch/in-flight queues, + // completed list, build-id counter, palette subset, and (radius, spacing) config — see FDecoGrid. The + // (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. - TMap RegionBuilds; - - // Cells that are desired but need a march task launched (nearest-first). - TArray PendingLaunch; - // Cells with a march task in flight (awaiting a result). - TSet InFlightCells; - - // Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer). + // Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer). SHARED across both grids; + // each result carries its FDecoCellResult::Grid so ProcessDecoResults routes it to the right grid. TQueue DecoResults; - // Regions whose last cell just landed, awaiting budgeted game-thread apply (HISM build + actor spawn). - TArray CompletedRegions; - // Monotonic id stamped on each region build + the cell tasks it launches. A cell result merges only - // if its BuildId still matches the live build for that region → a region that was cleared and later - // re-marched (same coords, new BuildId) never absorbs a stale in-flight cell from its prior life. - uint32 NextBuildId = 1; + // Spawned landmarks, keyed by FIntVector(latticeCellX, latticeCellY, entryIndex) — FIntVector already + // hashes, so no custom key type is needed. An entry with both ptrs null = "evaluated, nothing placed" + // (kept until the cell leaves range so the surface-find isn't repeated). Strate-bounded. + TMap LandmarkInstances; + int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks // Set in BeginDestroy; worker tasks check it before touching us. std::atomic bShuttingDown{false}; @@ -241,16 +329,13 @@ private: int32 LastStrateIndex = INT32_MIN; // 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; - // Decoration palette for the current update, built ONCE (a strate's biome field is XY-global, so the - // flat list is the same for every cell — only the per-COLUMN biome pick varies). CurrentEntries is the - // concatenation of every biome's decoration list (or the strate's when a biome has none / biomes are - // disabled); CurrentEntryBiome[i] is the context-biome index that owns entry i (-1 = strate fallback, - // always matches). The worker resolves a column's dominant biome and rolls only the entries it owns. - TArray CurrentEntries; - TArray CurrentEntryBiome; + // The decoration palette is built ONCE per update (a strate's biome field is XY-global, so the flat + // list is the same for every cell — only the per-COLUMN biome pick varies) and PARTITIONED by tier into + // NearGrid.Entries / FarGrid.Entries (with parallel EntryBiome). Each list is the concatenation of every + // biome's decoration entries of that tier (or the strate's when a biome has none / biomes are disabled). // Single strate-global ocean plane, repositioned to follow the player (see UpdateWater). UPROPERTY() diff --git a/Source/VoxelForge/Public/VoxelDensityVolume.h b/Source/VoxelForge/Public/VoxelDensityVolume.h new file mode 100644 index 0000000..8d873df --- /dev/null +++ b/Source/VoxelForge/Public/VoxelDensityVolume.h @@ -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< +#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&& 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< 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 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>& 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& 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 Owner; + + UPROPERTY() + UVoxelGenerator* Generator = nullptr; + + UPROPERTY() + UVoxelSettings* Settings = nullptr; + + TArray 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> LevelTextures; + int32 AllocatedTexRes = 0; // resolution the textures were created at (recreate on change) + + TArray PendingFills; // cell-boxes staged on the game thread, flushed to FillQueue + TQueue FillQueue; // game thread → fill thread + TQueue 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 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> CaptureCache; + + std::atomic bShuttingDown{ false }; + uint32 VolumeEpoch = 1; // bumped on Reset → stale fills discarded + + FIntVector LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + bool bInitialized = false; +}; diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index f025518..84ee231 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -13,7 +13,6 @@ #include "CoreMinimal.h" #include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc. -#include "VoxelChunk.h" #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.generated.h" @@ -31,8 +30,15 @@ public: * @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 * 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<* OutCaptureGrid = nullptr); //========================================================================= // SERVICES (injectés par AVoxelWorld) @@ -52,10 +58,6 @@ public: // Convention MC: densité < IsoLevel = solide, >= = air. 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 // (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 @@ -64,19 +66,4 @@ public: // 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. 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; }; diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index d25614f..8952cf2 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -81,22 +81,7 @@ public: int32 CeilingBandChunks = 4; //========================================================================= - // LOD - //========================================================================= - - // 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) + // CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance box above) //========================================================================= // 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 @@ -138,13 +123,6 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0")) 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) //========================================================================= @@ -154,11 +132,19 @@ public: // 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. - // Far stream radius in cells (= chunks) for "any-distance" entries (instanced/HISM visual props, - // and actor entries with MaxLODLevel >= 1). Bigger = props visible farther + more spawn/march cost. + // FAR-tier stream radius in cells (= chunks): how far FStrateDecoration entries set to EDecoStreamTier::Far + // (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")) 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 // 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: @@ -168,19 +154,20 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16")) int32 DecorationRegionSizeCells = 4; - // LEGACY / UNUSED. The near/far tier system was removed (it re-streamed cells at the tier boundary - // as the player moved → decoration flicker). All entries now stream within DecorationRadiusChunks and - // a loaded cell is never re-streamed in place. Kept only to avoid breaking the asset; safe to ignore. - 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). + // NEAR-tier column spacing (in voxels) within a cell — the FINE grid. MUST divide CHUNK_SIZE (32): + // 4 → 8×8=64 columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity rolls + // per column-crossing. Used by EDecoStreamTier::Near entries (and is the legacy single-grid spacing). UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32")) 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 // 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). @@ -211,6 +198,64 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0")) 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< 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 Landmarks; + // Ambient actors: things floating in cave space (fog volumes, particles, lights) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") TArray AmbientActors; diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index cb0e676..344055e 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -48,19 +48,10 @@ struct FVoxelPassage FVector UpperPoint = FVector::ZeroVector; // Entry in upper 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). // Varies by type: VerticalShaft ~7-8, SpiralDescent ~4, CrackCrevice ~2-3, others ~5. 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 // and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.). EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel; @@ -80,7 +71,9 @@ struct FVoxelPassage // Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords. // Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with // 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; + float BoundRadius = 0.0f; float BoundRadiusSq = 0.0f; }; diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index 233ed0c..f275011 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -16,6 +16,8 @@ #include "GameplayTagContainer.h" #include "VoxelStrateTypes.generated.h" +class UVoxelBiomeDefinition; // FStrateLandmark::RequiredBiome (optional per-landmark biome filter) + //============================================================================= // ENUMS //============================================================================= @@ -83,6 +85,32 @@ enum class ESurfaceType : uint8 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 //============================================================================= @@ -1702,7 +1730,8 @@ struct VOXELFORGE_API FStrateDecoration // The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster). // 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") TSubclassOf ActorClass; @@ -1713,11 +1742,13 @@ struct VOXELFORGE_API FStrateDecoration UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") UStaticMesh* InstancedMesh = nullptr; - // LEGACY / UNUSED by the world-grid decoration system (§8.5). It once meant a clipmap tile level, - // then a near/far distance tier — both removed. All decorations now stream within a single radius - // (VoxelSettings::DecorationRadiusChunks) and never re-stream in place. Kept to avoid breaking assets. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "8")) - int32 MaxLODLevel = 0; + // Which of the two decoration streaming grids this entry uses (§8.5). Far (default) = full radius + + // coarse column grid (cheap for rare/large props visible everywhere); Near = short radius + fine + // column grid (dense groundcover near the player only). The radius/spacing presets live on + // VoxelSettings; this only PICKS a grid. Defaults reproduce the legacy single-radius fine grid until + // 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 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") @@ -1818,6 +1849,158 @@ struct VOXELFORGE_API FStrateDecoration 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 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. * diff --git a/Source/VoxelForge/Public/VoxelTypes.h b/Source/VoxelForge/Public/VoxelTypes.h index 5035d10..7071ab7 100644 --- a/Source/VoxelForge/Public/VoxelTypes.h +++ b/Source/VoxelForge/Public/VoxelTypes.h @@ -28,6 +28,26 @@ constexpr int32 CHUNK_VOLUME = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 32 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 //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 748faf1..dca9fc4 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -7,7 +7,6 @@ #include "GameFramework/Actor.h" #include #include "VoxelTypes.h" -#include "VoxelChunk.h" #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.h" #include "VoxelSettings.h" @@ -21,7 +20,11 @@ class URealtimeMeshSimple; class UVoxelDiffLayer; class UVoxelContentManager; class UVoxelAtmosphereManager; +class UVoxelDensityVolume; +class UMaterialParameterCollection; +class UVolumeTexture; class UMaterialInterface; +class UMaterialInstanceDynamic; 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 // game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow. 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 CaptureGrid; }; UCLASS() @@ -102,6 +110,20 @@ public: UPROPERTY() 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> TerrainMIDs; + /** 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 * yourself in the level (avoids a duplicate ExponentialHeightFog). */ @@ -249,6 +271,50 @@ public: UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome") 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 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) //========================================================================= @@ -409,6 +475,17 @@ public: */ 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. */ void BuildDesiredTiles(const FIntVector& CenterChunkCoord); @@ -424,31 +501,8 @@ public: /** Get the current player position (or zero if no player) */ FVector GetPlayerPosition() const; - /** Check if a chunk coordinate is within view distance of a center chunk */ - bool IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const; - - /** - * 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); + // (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap + // streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.) //========================================================================= // ASYNC diff --git a/Source/VoxelForge/VoxelForge.Build.cs b/Source/VoxelForge/VoxelForge.Build.cs index e989300..0352c41 100644 --- a/Source/VoxelForge/VoxelForge.Build.cs +++ b/Source/VoxelForge/VoxelForge.Build.cs @@ -31,6 +31,8 @@ public class VoxelForge : ModuleRules PrivateDependencyModuleNames.AddRange(new string[] { "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 }); } }