2316 lines
108 KiB
C++
2316 lines
108 KiB
C++
// VoxelWorld.cpp
|
||
// Implementation of the voxel world manager
|
||
|
||
#include "VoxelWorld.h"
|
||
#include "VoxelDiffLayer.h"
|
||
#include "RealtimeMeshComponent.h"
|
||
#include "RealtimeMeshSimple.h"
|
||
#include "VoxelMarchingCubesMesher.h"
|
||
#include "VoxelStrateDefinition.h"
|
||
#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"
|
||
#include "IImageWrapperModule.h"
|
||
#include "Modules/ModuleManager.h"
|
||
#include "Misc/FileHelper.h"
|
||
#include "Misc/Paths.h"
|
||
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0)
|
||
|
||
AVoxelWorld::AVoxelWorld()
|
||
{
|
||
PrimaryActorTick.bCanEverTick = true;
|
||
}
|
||
|
||
//=============================================================================
|
||
// T1.f — build the RMC geometry buffers OFF the game thread.
|
||
//=============================================================================
|
||
// FRealtimeMeshStreamSet is plain CPU data; the per-vertex/per-triangle builder loop used to run
|
||
// in ApplyMeshToTile ON THE GAME THREAD, where it was the dominant streaming cost (game thread
|
||
// >6 ms while moving, GPU/draw idle). It touches ONLY the POD MeshData arrays — no UObject, no
|
||
// generator — so it's safe on the gen worker. The game thread then just uploads the finished
|
||
// streams (CreateSectionGroup). Byte-identical geometry; the only thing that moved is WHERE it runs.
|
||
static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, const FVoxelMeshData& MeshData)
|
||
{
|
||
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
||
Builder.EnableTangents();
|
||
Builder.EnableTexCoords();
|
||
Builder.EnableColors(); // masques matériau F6 (palette biome / pente / fondu) — voir le mesher
|
||
Builder.EnablePolyGroups();
|
||
|
||
const int32 NumVertices = MeshData.Vertices.Num();
|
||
Builder.ReserveAdditionalVertices(NumVertices);
|
||
for (int32 i = 0; i < NumVertices; i++)
|
||
{
|
||
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
|
||
if (MeshData.Normals.IsValidIndex(i))
|
||
{
|
||
Vertex.SetNormalAndTangent((FVector3f)MeshData.Normals[i], FVector3f(1, 0, 0));
|
||
}
|
||
if (MeshData.UVs.IsValidIndex(i))
|
||
{
|
||
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
||
}
|
||
if (MeshData.Colors.IsValidIndex(i))
|
||
{
|
||
Vertex.SetColor(MeshData.Colors[i]);
|
||
}
|
||
}
|
||
|
||
// F17 — the mesher packs the index buffer as [ground run | sky-cap run] (see
|
||
// FVoxelMeshData::NumCeilingTriangles): polygroup 0 = ground, 1 = sky-cap ceiling.
|
||
// RMC derives one section per contiguous group run (material slot = group index).
|
||
const int32 NumIndices = MeshData.Triangles.Num();
|
||
const int32 FirstCapIndex = NumIndices - MeshData.NumCeilingTriangles * 3;
|
||
Builder.ReserveAdditionalTriangles(NumIndices / 3);
|
||
for (int32 i = 0; i < NumIndices; i += 3)
|
||
{
|
||
Builder.AddTriangle((uint32)MeshData.Triangles[i],
|
||
(uint32)MeshData.Triangles[i + 1],
|
||
(uint32)MeshData.Triangles[i + 2],
|
||
(i >= FirstCapIndex) ? 1 : 0 /*poly group*/);
|
||
}
|
||
}
|
||
|
||
//=============================================================================
|
||
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::RegenerateAllChunks()
|
||
{
|
||
// Bump the generation epoch so in-flight async tasks become stale.
|
||
// ProcessPendingChunks will discard any result with an old epoch.
|
||
GenerationEpoch++;
|
||
|
||
// Tear down every tile component, then clear all tile state. Components are GC-safe via
|
||
// actor ownership. T2.c: PARK them instead of destroying — the reload right after this
|
||
// is exactly the burst the pool exists for (overflow past the cap is destroyed).
|
||
const int32 Count = LoadedTiles.Num();
|
||
for (auto& Pair : TileComponents) { if (Pair.Value) ReleaseTileComponent(Pair.Value); }
|
||
TileComponents.Empty();
|
||
LoadedTiles.Empty();
|
||
|
||
// 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
|
||
// try to UnloadTile coords that no longer exist.
|
||
PendingUnload.Empty();
|
||
// Re-mesh queues reference now-unloaded tiles — drop them (they'd be skipped anyway).
|
||
DirtyRemeshQueue.Empty();
|
||
BandRemeshQueue.Empty();
|
||
// §9.4 collision-only tracking references destroyed tiles — clear (rebuilt on the next crossing).
|
||
CollisionOnlyTiles.Empty();
|
||
PrevCollisionOnlyTiles.Empty();
|
||
|
||
// Reset streaming state so the next Tick rebuilds the desired set and reloads.
|
||
LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||
bAllChunksLoaded = false;
|
||
DesiredSorted.Reset();
|
||
DesiredStamped.Reset();
|
||
TransitionHold.Reset();
|
||
TransitionHoldQueue.Reset();
|
||
TransitionHoldCursor = 0;
|
||
|
||
// Tick will reload all tiles on the next frame with fresh params.
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): cleared %d tiles"), GenerationEpoch, Count);
|
||
}
|
||
|
||
void AVoxelWorld::RebuildStrates()
|
||
{
|
||
if (StrateManager && Settings)
|
||
{
|
||
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
|
||
StrateManager->Initialize(Settings, Settings->Seed);
|
||
}
|
||
if (AtmosphereManager) AtmosphereManager->Reset();
|
||
if (ContentManager) ContentManager->ClearAll();
|
||
|
||
// Reload all chunks against the rebuilt strate data.
|
||
RegenerateAllChunks();
|
||
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RebuildStrates: strate layout + passages rebuilt from settings."));
|
||
}
|
||
|
||
void AVoxelWorld::ValidateDeterminism()
|
||
{
|
||
// F2 — window-invariance regression test (§8.4). The density function must return the SAME
|
||
// value for a coordinate no matter which chunk's thread_local caches (SDF rooms, strate
|
||
// memo, biome grid, surface columns, lattice bakes) happen to be warm. Historically THE
|
||
// source of chunk seams — and the invariant every "bit-identical" hot-path refactor claims
|
||
// to preserve. This runs on the game thread, whose caches are isolated from the workers.
|
||
if (!Generator)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelForge] ValidateDeterminism: no Generator — run during PIE."));
|
||
return;
|
||
}
|
||
|
||
const FIntVector CenterChunk = WorldToChunkCoord(GetPlayerPosition());
|
||
|
||
float MaxRepeatDelta = 0.0f; // same alignment sampled twice — must be 0 (statelessness)
|
||
float MaxWindowDelta = 0.0f; // left-warmed vs right-warmed — must be 0 (window invariance)
|
||
FVector WorstP = FVector::ZeroVector;
|
||
int32 Mismatches = 0, Points = 0;
|
||
|
||
// Points hugging the X boundary between chunk (CX,CY) and (CX+1,CY): they sit inside BOTH
|
||
// chunks' cache search boxes (box = chunk extent + margin), so either alignment may legally
|
||
// serve them — exactly the cross-window case that seams when an invariant breaks.
|
||
const float BoundaryX = (float)((CenterChunk.X + 1) * CHUNK_SIZE);
|
||
for (int32 iy = 0; iy < 16; ++iy)
|
||
{
|
||
for (int32 iz = 0; iz < 8; ++iz)
|
||
{
|
||
const float Y = (float)(CenterChunk.Y * CHUNK_SIZE) + (float)iy * 2.0f + 0.5f;
|
||
const float Z = (float)(CenterChunk.Z * CHUNK_SIZE) + (float)iz * 4.0f + 0.5f;
|
||
for (const float Side : { -0.5f, 0.5f }) // just left / just right of the boundary
|
||
{
|
||
const float X = BoundaryX + Side;
|
||
++Points;
|
||
|
||
// Warm every cache from the LEFT chunk's middle, sample the point twice.
|
||
Generator->GetDensityAt(BoundaryX - (float)CHUNK_SIZE * 0.5f, Y, Z);
|
||
const float DLeft = Generator->GetDensityAt(X, Y, Z);
|
||
const float DLeft2 = Generator->GetDensityAt(X, Y, Z);
|
||
|
||
// Re-warm from the RIGHT chunk (rebuilds the boxes centred there), resample.
|
||
Generator->GetDensityAt(BoundaryX + (float)CHUNK_SIZE * 0.5f, Y, Z);
|
||
const float DRight = Generator->GetDensityAt(X, Y, Z);
|
||
|
||
MaxRepeatDelta = FMath::Max(MaxRepeatDelta, FMath::Abs(DLeft - DLeft2));
|
||
const float WDelta = FMath::Abs(DLeft - DRight);
|
||
if (WDelta > MaxWindowDelta)
|
||
{
|
||
MaxWindowDelta = WDelta;
|
||
WorstP = FVector(X, Y, Z);
|
||
}
|
||
if (WDelta > 0.0f) { ++Mismatches; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (MaxWindowDelta == 0.0f && MaxRepeatDelta == 0.0f)
|
||
{
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelForge] ValidateDeterminism: OK — %d boundary points at chunk (%d,%d,%d), window delta 0, repeat delta 0."),
|
||
Points, CenterChunk.X, CenterChunk.Y, CenterChunk.Z);
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelForge] ValidateDeterminism: FAIL — %d/%d points mismatch, max window delta %.6f (repeat %.6f) at voxel (%.1f, %.1f, %.1f). Window-invariance regression — see ARCHITECTURE §8.4."),
|
||
Mismatches, Points, MaxWindowDelta, MaxRepeatDelta, WorstP.X, WorstP.Y, WorstP.Z);
|
||
}
|
||
}
|
||
|
||
#if WITH_EDITOR
|
||
void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
|
||
{
|
||
Super::PostEditChangeProperty(PropertyChangedEvent);
|
||
|
||
// During PIE with live edit on, regenerate when the actor's own properties change
|
||
// (e.g., Settings reference, bLiveEditStrates toggle, etc.)
|
||
// Data asset edits (strate definitions) are handled separately by OnObjectModifiedInEditor.
|
||
if (bLiveEditStrates && GetWorld() && GetWorld()->IsPlayInEditor())
|
||
{
|
||
RegenerateAllChunks();
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
||
{
|
||
// Only react during PIE with live edit enabled
|
||
if (!bLiveEditStrates || !GetWorld() || !GetWorld()->IsPlayInEditor()) return;
|
||
|
||
// Only care about strate definition and terrain op definition edits.
|
||
// (This delegate fires for EVERY UObject modification in the editor.)
|
||
bool bIsRelevant = false;
|
||
FString AssetName;
|
||
|
||
// Case 1: A strate definition was modified
|
||
if (UVoxelStrateDefinition* ModifiedStrate = Cast<UVoxelStrateDefinition>(ModifiedObject))
|
||
{
|
||
if (!Settings) return;
|
||
AssetName = ModifiedStrate->GetName();
|
||
|
||
// Check the strate pool
|
||
for (const TSoftObjectPtr<UVoxelStrateDefinition>& PoolEntry : Settings->StratePool)
|
||
{
|
||
if (PoolEntry.Get() == ModifiedStrate) { bIsRelevant = true; break; }
|
||
}
|
||
// Check fixed strates
|
||
if (!bIsRelevant)
|
||
{
|
||
for (const auto& FixedEntry : Settings->FixedStrates)
|
||
{
|
||
if (FixedEntry.Value.Get() == ModifiedStrate) { bIsRelevant = true; break; }
|
||
}
|
||
}
|
||
}
|
||
// Case 2: A terrain op definition was modified — check if any strate references it
|
||
else if (UVoxelTerrainOpDefinition* ModifiedOp = Cast<UVoxelTerrainOpDefinition>(ModifiedObject))
|
||
{
|
||
if (!Settings || !StrateManager) return;
|
||
AssetName = ModifiedOp->GetName();
|
||
|
||
// Check every strate definition's terrain op list
|
||
for (const TSoftObjectPtr<UVoxelStrateDefinition>& PoolEntry : Settings->StratePool)
|
||
{
|
||
UVoxelStrateDefinition* Def = PoolEntry.Get();
|
||
if (!Def) continue;
|
||
for (const FStrateTerrainOpEntry& Entry : Def->TerrainOperations)
|
||
{
|
||
if (Entry.Operation.Get() == ModifiedOp) { bIsRelevant = true; break; }
|
||
}
|
||
if (bIsRelevant) break;
|
||
}
|
||
if (!bIsRelevant)
|
||
{
|
||
for (const auto& FixedEntry : Settings->FixedStrates)
|
||
{
|
||
UVoxelStrateDefinition* Def = FixedEntry.Value.Get();
|
||
if (!Def) continue;
|
||
for (const FStrateTerrainOpEntry& Entry : Def->TerrainOperations)
|
||
{
|
||
if (Entry.Operation.Get() == ModifiedOp) { bIsRelevant = true; break; }
|
||
}
|
||
if (bIsRelevant) break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!bIsRelevant) return;
|
||
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Live edit: '%s' modified, regenerating..."),
|
||
*AssetName);
|
||
|
||
// Re-initialize the strate manager so it picks up the changed definition values,
|
||
// then regenerate all chunks with the updated params.
|
||
if (StrateManager)
|
||
{
|
||
StrateManager->Initialize(Settings, Settings->Seed);
|
||
}
|
||
if (Generator)
|
||
{
|
||
Generator->InitializeSettings(Settings);
|
||
}
|
||
|
||
RegenerateAllChunks();
|
||
}
|
||
#endif
|
||
|
||
void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||
{
|
||
// Signal all async tasks to bail out ASAP
|
||
bShuttingDown.store(true, std::memory_order_release);
|
||
|
||
// Wait for all running tasks to finish before destroying UObjects.
|
||
// Tasks check bShuttingDown and exit early, so this should be fast.
|
||
// Timeout after 3 seconds to avoid hanging the editor.
|
||
const double Deadline = FPlatformTime::Seconds() + 3.0;
|
||
while (ActiveTaskCount.load(std::memory_order_relaxed) > 0)
|
||
{
|
||
if (FPlatformTime::Seconds() > Deadline)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EndPlay: %d tasks still running after 3s timeout"),
|
||
ActiveTaskCount.load(std::memory_order_relaxed));
|
||
break;
|
||
}
|
||
FPlatformProcess::Yield(); // Give CPU to other threads
|
||
}
|
||
|
||
// Drain any queued results
|
||
FChunkResult Discard;
|
||
while (ProcessQueue.Dequeue(Discard)) {}
|
||
PendingTiles.Empty();
|
||
PendingUnload.Empty();
|
||
DirtyRemeshQueue.Empty();
|
||
BandRemeshQueue.Empty();
|
||
|
||
// Stop + drain the decoration march tasks (they read the Generator) before UObject teardown.
|
||
if (ContentManager)
|
||
{
|
||
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)
|
||
{
|
||
AtmosphereManager->Reset();
|
||
}
|
||
|
||
// Unbind the data asset monitoring delegate
|
||
#if WITH_EDITOR
|
||
if (OnObjectModifiedHandle.IsValid())
|
||
{
|
||
FCoreUObjectDelegates::OnObjectModified.Remove(OnObjectModifiedHandle);
|
||
OnObjectModifiedHandle.Reset();
|
||
}
|
||
#endif
|
||
|
||
Super::EndPlay(EndPlayReason);
|
||
}
|
||
|
||
void AVoxelWorld::BeginPlay()
|
||
{
|
||
Super::BeginPlay();
|
||
bShuttingDown.store(false, std::memory_order_relaxed);
|
||
|
||
if (!Settings)
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] No Settings assigned — world won't generate."));
|
||
return;
|
||
}
|
||
|
||
// Tiles never move once generated, so make the actor root STATIC. RMC already requests the
|
||
// cached static DRAW path (section group DrawType defaults to Static), but a Movable parent
|
||
// forces every child back to Movable — which also defeats Virtual Shadow Map caching (Movable
|
||
// geometry re-renders its shadow every frame; that's the cost that made us turn VSM off). With
|
||
// a Static root + Static tile components, draws cache AND VSM can cache the terrain's shadows,
|
||
// so VSM can be turned back on cheaply. Content decoration HISMs are likewise Static (placed once,
|
||
// never move). Movable children (atmosphere fog/sky, water planes — these follow the player) under a
|
||
// Static root are allowed. NOTE: a Static actor can't be moved in-editor —
|
||
// VoxelWorld is expected to sit at the origin.
|
||
if (USceneComponent* Root = GetRootComponent())
|
||
{
|
||
Root->SetMobility(EComponentMobility::Static);
|
||
}
|
||
|
||
// Générateur + mesher (UObjects légers)
|
||
Generator = NewObject<UVoxelGenerator>(this);
|
||
Mesher = NewObject<UVoxelMarchingCubesMesher>(this);
|
||
|
||
Generator->InitializeSettings(Settings);
|
||
Mesher->SetGenerator(Generator);
|
||
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
|
||
Mesher->SkirtCells = Settings->SkirtCells;
|
||
Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off
|
||
|
||
// Système de strates — piloté par le pool et les fixed entries dans Settings.
|
||
if (Settings->StratePool.Num() > 0)
|
||
{
|
||
StrateManager = NewObject<UVoxelStrateManager>(this);
|
||
StrateManager->Initialize(Settings, Settings->Seed);
|
||
Generator->SetStrateManager(StrateManager);
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"),
|
||
StrateManager->GetNumStrates());
|
||
}
|
||
|
||
// Diff layer — stocke les modifications du joueur par dessus la densité
|
||
// procédurale. Créé systématiquement (coût nul tant qu'il n'y a pas d'édit).
|
||
DiffLayer = NewObject<UVoxelDiffLayer>(this);
|
||
DiffLayer->SetBudget(Settings->MaxModifications, Settings->MaxBrushRadius, Settings->MaxTotalVolume);
|
||
Generator->SetDiffLayer(DiffLayer);
|
||
|
||
// Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes.
|
||
ContentManager = NewObject<UVoxelContentManager>(this);
|
||
ContentManager->Initialize(this, StrateManager, Generator, Settings, Settings->Seed);
|
||
|
||
// Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers.
|
||
if (bManageAtmosphere && StrateManager)
|
||
{
|
||
AtmosphereManager = NewObject<UVoxelAtmosphereManager>(this);
|
||
AtmosphereManager->Initialize(this, StrateManager, Generator);
|
||
}
|
||
|
||
// Density volume — player-centred clipmap streamed to the GPU for mini-sun raymarched shadows.
|
||
if (Settings->bEnableDensityVolume)
|
||
{
|
||
DensityVolume = NewObject<UVoxelDensityVolume>(this);
|
||
DensityVolume->Initialize(this, Generator, Settings);
|
||
}
|
||
|
||
#if WITH_EDITOR
|
||
// Listen for data asset edits during PIE so live edit can detect
|
||
// strate definition changes (PostEditChangeProperty only fires for
|
||
// properties on this actor itself, not on referenced data assets).
|
||
OnObjectModifiedHandle = FCoreUObjectDelegates::OnObjectModified.AddUObject(
|
||
this, &AVoxelWorld::OnObjectModifiedInEditor);
|
||
#endif
|
||
}
|
||
|
||
void AVoxelWorld::Tick(float DeltaTime)
|
||
{
|
||
Super::Tick(DeltaTime);
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_Tick); // game-thread streaming orchestration breakdown
|
||
FVector PlayerLastPos = GetPlayerPosition();
|
||
|
||
if ((PlayerLastPos != FVector::ZeroVector)) {
|
||
UpdateChunksAroundPosition(PlayerLastPos);
|
||
if (AtmosphereManager)
|
||
{
|
||
AtmosphereManager->UpdateForPlayer(PlayerLastPos);
|
||
}
|
||
if (ContentManager)
|
||
{
|
||
// 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).
|
||
// Landmarks now cover F7 set-pieces too (AnchorMode HashLattice/PassageMouth + exclusion).
|
||
{ 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)
|
||
{
|
||
const FTransform Xf = GetActorTransform();
|
||
auto ToWorld = [&](const FVector& VoxelPt) { return Xf.TransformPosition(VoxelPt * VOXEL_SIZE); };
|
||
auto DrawSeg = [&](const FVector& A, const FVector& B)
|
||
{
|
||
DrawDebugLine(GetWorld(), ToWorld(A), ToWorld(B), FColor::Cyan, false, -1.0f, 0, 30.0f);
|
||
};
|
||
|
||
for (const FVoxelPassage& P : StrateManager->GetPassages())
|
||
{
|
||
if (P.ControlPoints.Num() >= 2)
|
||
{
|
||
for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j)
|
||
DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]);
|
||
}
|
||
else
|
||
{
|
||
DrawSeg(P.UpperPoint, P.LowerPoint);
|
||
}
|
||
|
||
DrawDebugSphere(GetWorld(), ToWorld(P.UpperPoint), P.Radius * VOXEL_SIZE, 12, FColor::Green, false, -1.0f, 0, 4.0f);
|
||
DrawDebugSphere(GetWorld(), ToWorld(P.LowerPoint), P.Radius * VOXEL_SIZE, 12, FColor::Red, false, -1.0f, 0, 4.0f);
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
FVector AVoxelWorld::GetPlayerPosition() const
|
||
{
|
||
// This one is tricky with Unreal's API, so I'll give you more help:
|
||
APlayerController* PC = GetWorld()->GetFirstPlayerController();
|
||
if (PC && PC->GetPawn())
|
||
{
|
||
return PC->GetPawn()->GetActorLocation();
|
||
}
|
||
return FVector::ZeroVector;
|
||
}
|
||
|
||
void AVoxelWorld::ProcessPendingChunks()
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
||
// This runs on the game thread, called from Tick.
|
||
//
|
||
// STEPS:
|
||
// 1. Try to dequeue a result from ProcessQueue
|
||
// TQueue has a Dequeue(OutItem) method that returns true if it got something
|
||
//
|
||
// 2. If we got a result:
|
||
// a. Store the chunk data in our Chunks map
|
||
// b. Apply the mesh so it becomes visible
|
||
// c. Remove the coord from PendingChunkCoord (it's no longer "in progress")
|
||
//
|
||
// 3. You can process multiple results per frame with a while loop,
|
||
// or limit to a few per frame to avoid stutters (e.g., max 4 per tick)
|
||
//
|
||
// TOOLS:
|
||
// - ProcessQueue.Dequeue(Result) — returns bool, fills Result if true
|
||
// - Chunks.Add(Key, Value)
|
||
// - ApplyMeshToChunk(ChunkCoord, MeshData)
|
||
// - PendingChunkCoord.Remove(ChunkCoord)
|
||
// Drain the process queue up to the per-frame budget.
|
||
// This prevents stutters from applying too many meshes in one frame.
|
||
// Budget limits how many VISIBLE mesh applies we do per frame (GPU upload cost).
|
||
// Empty meshes and stale results are free to drain — don't count them.
|
||
const int32 MaxApplies = Settings ? Settings->MaxMeshAppliesPerFrame : 4;
|
||
int32 MeshesApplied = 0;
|
||
FChunkResult DequeuedChunk;
|
||
while (ProcessQueue.Dequeue(DequeuedChunk))
|
||
{
|
||
PendingTiles.Remove(DequeuedChunk.Tile);
|
||
|
||
// ApplyTileResult does epoch check, mark-loaded, capture ingest, empty-release / mesh upload.
|
||
// Only a real (visible) upload counts against the per-frame budget — stale/empty drain free.
|
||
if (ApplyTileResult(DequeuedChunk))
|
||
{
|
||
if (++MeshesApplied >= MaxApplies)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// Game-thread apply for one gen result. Shared by ProcessPendingChunks (async drain) and
|
||
// SyncRemeshTile (synchronous carve). Returns true iff a visible mesh was uploaded (budget).
|
||
bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
|
||
{
|
||
// Discard results from a previous generation epoch (stale).
|
||
if (Result.Epoch != GenerationEpoch)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Mark the tile loaded (even if empty — so we don't re-submit it).
|
||
LoadedTiles.Add(Result.Tile);
|
||
|
||
// Une tuile en vol n'est JAMAIS annulée : si le desired set a bougé pendant sa gen, elle
|
||
// arrive ici hors desired — le delta cull ne re-scanne plus tout, donc on l'inscrit en
|
||
// TransitionHold pour qu'elle soit re-considérée au prochain crossing (ou au settled cull).
|
||
if (!IsDesired(Result.Tile)) { AddToTransitionHold(Result.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 && Result.CaptureGrid.Num() > 0)
|
||
{
|
||
DensityVolume->IngestTileCapture(Result.Tile.Coord, MoveTemp(Result.CaptureGrid));
|
||
}
|
||
|
||
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
||
if (Result.bEmpty || !Result.Streams)
|
||
{
|
||
// Une RE-GEN (BandRemeshQueue / RemeshDirtyChunks) peut passer de "contenu" à "vide" :
|
||
// bande déplacée hors de la tuile, ou skip cellule-plus-haute-que-la-bande après un
|
||
// changement de strate (LoadTile). L'ancien composant doit tomber, sinon sa vieille
|
||
// géométrie (l'autre strate !) reste affichée. Première gen vide : Find rate, no-op.
|
||
if (URealtimeMeshComponent** OldComp = TileComponents.Find(Result.Tile))
|
||
{
|
||
if (*OldComp) { ReleaseTileComponent(*OldComp); }
|
||
TileComponents.Remove(Result.Tile);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Apply mesh (GPU upload). The vertex/index buffers were already built (T1.f, on the worker for
|
||
// the async path or inline for the sync carve path); the game thread only uploads them here.
|
||
ApplyMeshToTile(Result);
|
||
return true;
|
||
}
|
||
|
||
// Same-frame level-0 re-mesh on the game thread (see header). Mirrors LoadTile's level-0 parameters
|
||
// (Cells = CHUNK_SIZE, Step = 1) + the strate content band; skips density-volume capture (the volume
|
||
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
|
||
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
|
||
{
|
||
if (!Generator || !Mesher || bShuttingDown.load(std::memory_order_relaxed)) return;
|
||
|
||
const FIntVector OriginVoxels = Tile.OriginVoxels();
|
||
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
|
||
const int32 Step = 1; // Extent(=CHUNK_SIZE) / Cells
|
||
|
||
// STRATE CONTENT CUT — identical to LoadTile (Tile.Level >= CutMin; for a level-0 tile inside the
|
||
// player strate the clamp is a no-op, but keep it bit-identical to the async path). Too-coarse
|
||
// skip never fires at Step 1.
|
||
int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX;
|
||
int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32;
|
||
const int32 CutMin = Settings ? Settings->StrateContentCutMinLevel : 9;
|
||
if (Tile.Level >= CutMin && MeshBandChunkLo != MIN_int32)
|
||
{
|
||
BandChunkLo = MeshBandChunkLo;
|
||
BandChunkHi = MeshBandChunkHi;
|
||
BandVoxLo = MeshBandChunkLo * CHUNK_SIZE;
|
||
BandVoxHi = (MeshBandChunkHi + 1) * CHUNK_SIZE - 1;
|
||
}
|
||
|
||
FChunkResult Result;
|
||
GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false,
|
||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||
/*bSheetTile*/ false, /*SheetChunkZ*/ 0,
|
||
/*Hole*/ 0, 0, 0, 0, Result); // hole unused (not a sheet tile)
|
||
|
||
ApplyTileResult(Result);
|
||
}
|
||
|
||
void AVoxelWorld::ProcessUnloadQueue()
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessUnload);
|
||
// Budgeted teardown: destroy at most a few tiles' components + content actors per frame, so a
|
||
// fast traversal's whole-shell cull (dozens of UnloadTile in one frame) doesn't spike the game
|
||
// thread. The budget auto-scales with the backlog (PendingUnload/4) so it never falls far behind.
|
||
if (PendingUnload.Num() == 0) return;
|
||
|
||
// Drain the floor budget, scaling up with the backlog so we never fall far behind, but capped
|
||
// at 4× the floor so a huge backlog (extreme speed) can't itself become a one-frame spike — the
|
||
// excess just lingers a few more frames (it's all behind the player, out of view).
|
||
const int32 Floor = Settings ? FMath::Max(1, Settings->MaxUnloadsPerFrame) : 6;
|
||
int32 DestroyBudget = FMath::Clamp(PendingUnload.Num() / 4, Floor, Floor * 4);
|
||
|
||
TArray<FVoxelTileKey> Dequeued; // removed from the queue this frame (destroyed OR cancelled)
|
||
for (const FVoxelTileKey& T : PendingUnload)
|
||
{
|
||
if (IsDesired(T))
|
||
{
|
||
// Re-desired before its turn (player reversed) — keep it; it's still loaded, just drop
|
||
// it from the queue. Doesn't count against the destroy budget.
|
||
Dequeued.Add(T);
|
||
continue;
|
||
}
|
||
UnloadTile(T);
|
||
Dequeued.Add(T);
|
||
if (--DestroyBudget <= 0) break;
|
||
}
|
||
for (const FVoxelTileKey& T : Dequeued) PendingUnload.Remove(T);
|
||
}
|
||
|
||
// Integer floor-division (correct for negatives), scalar + vector.
|
||
static FORCEINLINE int32 VF_FloorDiv(int32 V, int32 D)
|
||
{
|
||
return V >= 0 ? (V / D) : -(((-V) + D - 1) / D);
|
||
}
|
||
static FORCEINLINE FIntVector VF_FloorDiv(const FIntVector& V, int32 D)
|
||
{
|
||
return FIntVector(VF_FloorDiv(V.X, D), VF_FloorDiv(V.Y, D), VF_FloorDiv(V.Z, D));
|
||
}
|
||
|
||
// RENDER DISTANCE — rayon (en tuiles niveau-MaxLevel) de la coquille EXTERNE : ClipRadius, élargi
|
||
// si `RenderDistanceChunks` demande une portée horizontale au-delà du naturel R·2^MaxLevel. Partagé
|
||
// par BuildDesiredTiles (le desired set) et IsTileInClipRange (le même horizon pour le cull).
|
||
static FORCEINLINE int32 VF_OuterShellRadius(const UVoxelSettings* Settings, int32 R, int32 MaxLevel)
|
||
{
|
||
const int32 Dist = Settings ? Settings->RenderDistanceChunks : 0;
|
||
if (Dist <= 0) return R;
|
||
return FMath::Max(R, (Dist + (1 << MaxLevel) - 1) >> MaxLevel); // ceil(Dist / 2^MaxLevel)
|
||
}
|
||
|
||
// F18 — la coquille LA PLUS EXTERNE : niveau + rayon. Sans anneau feuille = (MaxLevel, rayon
|
||
// render-distance). Avec (`bFarSheetRing` et distance > portée naturelle) = l'anneau FEUILLE :
|
||
// niveau MaxLevel + FarSheetSpanLevels (une feuille couvre 2^span empreintes MC par axe), rayon
|
||
// re-dérivé à ce niveau. Partagé par BuildDesiredTiles et IsTileInClipRange (même horizon).
|
||
static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, int32 MaxLevel,
|
||
int32& OutLevel, int32& OutRadius)
|
||
{
|
||
OutLevel = MaxLevel;
|
||
OutRadius = VF_OuterShellRadius(Settings, R, MaxLevel);
|
||
if (Settings && Settings->bFarSheetRing && OutRadius > R)
|
||
{
|
||
OutLevel = MaxLevel + FMath::Clamp(Settings->FarSheetSpanLevels, 1, 4);
|
||
OutRadius = FMath::Max(1, (Settings->RenderDistanceChunks + (1 << OutLevel) - 1) >> OutLevel);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, TArray<FVoxelTileKey>& OutLeavers)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles);
|
||
DesiredSorted.Reset();
|
||
OutLeavers.Reset();
|
||
CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below
|
||
++DesiredStamp; // les upserts ci-dessous marquent le crossing courant
|
||
|
||
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
|
||
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
|
||
// RENDER DISTANCE (`RenderDistanceChunks`) : la coquille EXTERNE continue au-delà du rayon
|
||
// naturel jusqu'à couvrir la distance demandée — en tuiles MC niveau-MaxClipLevel, ou (F18,
|
||
// `bFarSheetRing`) en tuiles FEUILLE plus grandes (niveau MaxLevel+span, deux heightfields
|
||
// sol/cap au lieu de marching cubes — cf. GenerateSheetMesh). IsTileInClipRange partage
|
||
// VF_OuterShell pour que le cull voie le même horizon.
|
||
const int32 ROuter = VF_OuterShellRadius(Settings, R, MaxLevel);
|
||
int32 SheetLevel = MaxLevel, RSheet = ROuter;
|
||
VF_OuterShell(Settings, R, MaxLevel, SheetLevel, RSheet);
|
||
const bool bSheetRing = SheetLevel > MaxLevel;
|
||
|
||
// Strate-aware VERTICAL band (in level-0 chunk-Z). Without this the clipmap generates the
|
||
// occluded volume above/below (sealed strates are light-tight, §8.7) AND the full underground
|
||
// depth — a huge column of invisible solid rock = the gen lag. So clamp the vertical reach to
|
||
// the player's strate ± margin (open-ceiling strates extend UP to the sky-cap). The clipmap
|
||
// stays full-reach HORIZONTALLY (the horizon) but limited vertically. ZLo/ZHi span ⇒ no clamp.
|
||
int32 ZLo = MIN_int32, ZHi = MAX_int32;
|
||
if (Settings && Settings->bClampViewToStrate && StrateManager)
|
||
{
|
||
const int32 Margin = Settings->StrateViewMarginChunks;
|
||
ZLo = Center.Z - Settings->ViewDistanceDown;
|
||
ZHi = Center.Z + Settings->ViewDistanceUp;
|
||
int32 StrTopZ = 0, StrBotZ = 0;
|
||
if (StrateManager->GetStrateChunkZBounds(Center.Z, StrTopZ, StrBotZ))
|
||
{
|
||
const ECaveGeneratorType GenType = StrateManager->GetGeneratorTypeForChunk(Center);
|
||
const bool bOpen = (GenType == ECaveGeneratorType::SurfaceWorld
|
||
|| GenType == ECaveGeneratorType::FloatingIslands);
|
||
ZLo = FMath::Max(ZLo, StrBotZ - Margin);
|
||
ZHi = bOpen ? (StrTopZ + Margin) : FMath::Min(ZHi, StrTopZ + Margin);
|
||
}
|
||
// else: in the bedrock gap → keep the player window (see both sides while descending).
|
||
}
|
||
|
||
// Concentric shells: level 0 near the player, each coarser level a 2× larger shell beyond.
|
||
// A level-L tile is dropped if it's fully covered by the finer (L-1) level's box — that's
|
||
// the inner hole, so the shells tile space without big gaps.
|
||
for (int32 L = 0; L <= MaxLevel; ++L)
|
||
{
|
||
const int32 Pow = 1 << L; // level-L tile = 2^L chunks
|
||
const FIntVector CL = VF_FloorDiv(Center, Pow); // player's level-L tile coord
|
||
const FIntVector CF = (L > 0) ? VF_FloorDiv(Center, Pow >> 1) : FIntVector::ZeroValue;
|
||
|
||
// Rayon de CE niveau : R partout, sauf la coquille externe (render distance) — qui, si
|
||
// l'anneau FEUILLE est actif (F18), est émise à part plus bas (le niveau MaxLevel reste
|
||
// alors à R). Le test "covered by finer" garde R (le niveau plus fin n'est jamais étendu).
|
||
const int32 RL = (L == MaxLevel && !bSheetRing) ? ROuter : R;
|
||
|
||
// Anneau étendu : le balayage naïf serait (2·RL+1)³ — on restreint dz à la fenêtre de la
|
||
// clamp verticale AVANT la boucle (mêmes tuiles retenues : le `continue` Z ci-dessous
|
||
// rejetterait tout le reste). Sentinelles MIN/MAX (pas de clamp) ⇒ balayage plein.
|
||
int32 DzMin = -RL, DzMax = RL;
|
||
if (RL > R && ZLo != MIN_int32)
|
||
{
|
||
DzMin = FMath::Max(DzMin, VF_FloorDiv(ZLo, Pow) - CL.Z);
|
||
DzMax = FMath::Min(DzMax, VF_FloorDiv(ZHi, Pow) - CL.Z);
|
||
}
|
||
|
||
for (int32 dz = DzMin; dz <= DzMax; ++dz)
|
||
for (int32 dy = -RL; dy <= RL; ++dy)
|
||
for (int32 dx = -RL; dx <= RL; ++dx)
|
||
{
|
||
const FIntVector T = CL + FIntVector(dx, dy, dz);
|
||
|
||
// Strate-aware vertical clamp: drop tiles whose level-0 chunk-Z footprint doesn't
|
||
// overlap [ZLo, ZHi] (the occluded strate above/below / deep underground).
|
||
const int32 TZLo = T.Z << L;
|
||
const int32 TZHi = ((T.Z + 1) << L) - 1;
|
||
if (TZHi < ZLo || TZLo > ZHi) continue;
|
||
|
||
if (L > 0)
|
||
{
|
||
// Covered by the finer level iff the level-(L-1) tiles 2T..2T+1 (per axis)
|
||
// all lie inside the finer box [CF-R, CF+R].
|
||
const bool bCovered =
|
||
(2 * T.X >= CF.X - R) && (2 * T.X + 1 <= CF.X + R) &&
|
||
(2 * T.Y >= CF.Y - R) && (2 * T.Y + 1 <= CF.Y + R) &&
|
||
(2 * T.Z >= CF.Z - R) && (2 * T.Z + 1 <= CF.Z + R);
|
||
if (bCovered) continue;
|
||
}
|
||
const FVoxelTileKey Key(T, L);
|
||
DesiredSorted.Add(Key);
|
||
DesiredStamped.FindOrAdd(Key) = DesiredStamp;
|
||
}
|
||
}
|
||
|
||
// F18 — ANNEAU FEUILLE : la portée render-distance est couverte par des tuiles feuille
|
||
// (niveau SheetLevel > MaxLevel, LoadTile route niveau > MaxClipLevel vers GenerateSheetMesh).
|
||
// Trou intérieur = la boîte MC niveau-MaxLevel (rayon R), pas le niveau SheetLevel−1.
|
||
if (bSheetRing)
|
||
{
|
||
const int32 SPow = 1 << SheetLevel;
|
||
const FIntVector CS = VF_FloorDiv(Center, SPow);
|
||
const FIntVector CM = VF_FloorDiv(Center, 1 << MaxLevel);
|
||
const int32 K = SheetLevel - MaxLevel; // 1 feuille = 2^K tuiles MC par axe
|
||
|
||
int32 DzMin = -RSheet, DzMax = RSheet;
|
||
if (ZLo != MIN_int32)
|
||
{
|
||
DzMin = FMath::Max(DzMin, VF_FloorDiv(ZLo, SPow) - CS.Z);
|
||
DzMax = FMath::Min(DzMax, VF_FloorDiv(ZHi, SPow) - CS.Z);
|
||
}
|
||
|
||
for (int32 dz = DzMin; dz <= DzMax; ++dz)
|
||
for (int32 dy = -RSheet; dy <= RSheet; ++dy)
|
||
for (int32 dx = -RSheet; dx <= RSheet; ++dx)
|
||
{
|
||
const FIntVector T = CS + FIntVector(dx, dy, dz);
|
||
const int32 TZLo = T.Z << SheetLevel;
|
||
const int32 TZHi = ((T.Z + 1) << SheetLevel) - 1;
|
||
if (TZHi < ZLo || TZLo > ZHi) continue;
|
||
|
||
// Couverte par la boîte MC (empreinte entièrement dans [CM−R, CM+R] au niveau MaxLevel).
|
||
const bool bCovered =
|
||
((T.X << K) >= CM.X - R) && ((((T.X + 1) << K) - 1) <= CM.X + R) &&
|
||
((T.Y << K) >= CM.Y - R) && ((((T.Y + 1) << K) - 1) <= CM.Y + R) &&
|
||
((T.Z << K) >= CM.Z - R) && ((((T.Z + 1) << K) - 1) <= CM.Z + R);
|
||
if (bCovered) continue;
|
||
|
||
const FVoxelTileKey Key(T, SheetLevel);
|
||
DesiredSorted.Add(Key);
|
||
DesiredStamped.FindOrAdd(Key) = DesiredStamp;
|
||
}
|
||
}
|
||
|
||
// Streaming anchors (AI / remote players, §9.3): fold each one's small level-0 box into the SAME
|
||
// desired set BEFORE the leaver sweep, so the delta cull releases an anchor's tiles automatically
|
||
// once it moves away or is unregistered. No-op (zero cost) when there are no anchors.
|
||
AddAnchorDesiredTiles();
|
||
|
||
// Balayage UNIQUE de la map : les entrées à stamp périmé viennent de quitter le desired set —
|
||
// ce sont les seuls candidats au cull de ce crossing (avec la TransitionHold). On les retire
|
||
// ici même (RemoveCurrent est sûr en itérant), la map reste donc == desired set courant.
|
||
for (auto It = DesiredStamped.CreateIterator(); It; ++It)
|
||
{
|
||
if (It.Value() != DesiredStamp)
|
||
{
|
||
OutLeavers.Add(It.Key());
|
||
It.RemoveCurrent();
|
||
}
|
||
}
|
||
|
||
// Nearest-first (by tile-centre distance to the player), so the closest tiles stream first.
|
||
const FVector PlayerVoxel = (FVector(Center) + FVector(0.5f, 0.5f, 0.5f)) * (float)CHUNK_SIZE;
|
||
DesiredSorted.Sort([&PlayerVoxel](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||
{
|
||
const FVector CA = A.CenterCm() / VOXEL_SIZE;
|
||
const FVector CB = B.CenterCm() / VOXEL_SIZE;
|
||
return FVector::DistSquared(CA, PlayerVoxel) < FVector::DistSquared(CB, PlayerVoxel);
|
||
});
|
||
}
|
||
|
||
// Fold every registered anchor's small level-0 box into the current desired set (§9.3). Runs inside
|
||
// BuildDesiredTiles after the player clipmap + sheet ring, keyed on the same DesiredStamp so the leaver
|
||
// sweep + delta cull handle anchor tiles leaving. Level-0 only (collision lives on level-0 tiles); empty
|
||
// tiles in the box are ~free (the trivial-tile reject skips gen). Dedup vs the player clipmap by stamp.
|
||
// §9.4: a tile the player clipmap did NOT stamp (bNew) that only a CollisionOnly anchor wants goes into
|
||
// CollisionOnlyTiles → hidden at apply. A FullVisual anchor (or the clipmap) forces it rendered.
|
||
void AVoxelWorld::AddAnchorDesiredTiles()
|
||
{
|
||
for (const FVoxelStreamingAnchor& Anchor : StreamingAnchors)
|
||
{
|
||
if (!Anchor.Actor.IsValid()) continue; // dead ptr — pruned in UpdateChunksAroundPosition
|
||
const FIntVector AC = Anchor.LastChunk; // set this Tick by the move-detection pass
|
||
const int32 RXY = FMath::Clamp(Anchor.XYRadiusChunks, 0, 4); // guard the box small
|
||
const int32 RZLo = FMath::Clamp(Anchor.ZBelowChunks, 0, 4);
|
||
const int32 RZHi = FMath::Clamp(Anchor.ZAboveChunks, 0, 4);
|
||
const bool bColl = (Anchor.Policy == EVoxelAnchorPolicy::CollisionOnly);
|
||
for (int32 dz = -RZLo; dz <= RZHi; ++dz)
|
||
for (int32 dy = -RXY; dy <= RXY; ++dy)
|
||
for (int32 dx = -RXY; dx <= RXY; ++dx)
|
||
{
|
||
const FVoxelTileKey Key(AC + FIntVector(dx, dy, dz), 0);
|
||
uint32& S = DesiredStamped.FindOrAdd(Key);
|
||
const bool bNew = (S != DesiredStamp); // false ⇒ already desired (clipmap / earlier anchor)
|
||
if (bNew)
|
||
{
|
||
S = DesiredStamp;
|
||
DesiredSorted.Add(Key);
|
||
}
|
||
if (bColl)
|
||
{
|
||
// Collision-only only if NOTHING full-visual claimed this exact level-0 tile this
|
||
// crossing (bNew). If the clipmap or a FullVisual anchor stamped it first, leave it rendered.
|
||
if (bNew) { CollisionOnlyTiles.Add(Key); }
|
||
}
|
||
else
|
||
{
|
||
CollisionOnlyTiles.Remove(Key); // FullVisual anchor → force rendered (undo a prior coll mark)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// §9.4 — apply visibility flips to ALREADY-LOADED tiles when a tile changed render↔collision-only this
|
||
// crossing (player walked toward/away from a CollisionOnly cluster). Bounded by the collision-only set
|
||
// (small); a no-op when there are no CollisionOnly anchors. Newly-loaded tiles get their state at apply
|
||
// (ApplyMeshToTile reads CollisionOnlyTiles). Called after BuildDesiredTiles rebuilt CollisionOnlyTiles.
|
||
void AVoxelWorld::ReconcileAnchorTileVisibility()
|
||
{
|
||
if (CollisionOnlyTiles.Num() == 0 && PrevCollisionOnlyTiles.Num() == 0) return; // fast path
|
||
|
||
// Became collision-only → hide (if loaded).
|
||
for (const FVoxelTileKey& Key : CollisionOnlyTiles)
|
||
{
|
||
if (!PrevCollisionOnlyTiles.Contains(Key))
|
||
{
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) { Comp->SetVisibility(false); }
|
||
}
|
||
}
|
||
// Stopped being collision-only → show, but only if still desired (else it's a leaver being culled —
|
||
// don't flash it visible on its way out).
|
||
for (const FVoxelTileKey& Key : PrevCollisionOnlyTiles)
|
||
{
|
||
if (!CollisionOnlyTiles.Contains(Key) && IsDesired(Key))
|
||
{
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) { Comp->SetVisibility(true); }
|
||
}
|
||
}
|
||
PrevCollisionOnlyTiles = CollisionOnlyTiles;
|
||
}
|
||
|
||
void AVoxelWorld::RegisterStreamingAnchor(AActor* Actor, EVoxelAnchorPolicy Policy,
|
||
int32 XYRadiusChunks, int32 ZBelowChunks, int32 ZAboveChunks)
|
||
{
|
||
if (!Actor) return;
|
||
for (FVoxelStreamingAnchor& Existing : StreamingAnchors)
|
||
{
|
||
if (Existing.Actor.Get() == Actor) // already registered → update policy/box in place
|
||
{
|
||
Existing.Policy = Policy;
|
||
Existing.XYRadiusChunks = XYRadiusChunks;
|
||
Existing.ZBelowChunks = ZBelowChunks;
|
||
Existing.ZAboveChunks = ZAboveChunks;
|
||
bForceDesiredRebuild = true;
|
||
return;
|
||
}
|
||
}
|
||
FVoxelStreamingAnchor A;
|
||
A.Actor = Actor;
|
||
A.Policy = Policy;
|
||
A.XYRadiusChunks = XYRadiusChunks;
|
||
A.ZBelowChunks = ZBelowChunks;
|
||
A.ZAboveChunks = ZAboveChunks;
|
||
// LastChunk stays at its sentinel → next Tick's move detection sets it + triggers the rebuild.
|
||
StreamingAnchors.Add(A);
|
||
bForceDesiredRebuild = true;
|
||
}
|
||
|
||
void AVoxelWorld::UnregisterStreamingAnchor(AActor* Actor)
|
||
{
|
||
if (!Actor) return;
|
||
for (int32 i = StreamingAnchors.Num() - 1; i >= 0; --i)
|
||
{
|
||
if (StreamingAnchors[i].Actor.Get() == Actor)
|
||
{
|
||
StreamingAnchors.RemoveAtSwap(i);
|
||
bForceDesiredRebuild = true; // rebuild next Tick so its now-unwanted tiles become leavers
|
||
}
|
||
}
|
||
}
|
||
|
||
bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& Center) const
|
||
{
|
||
// In range = the tile's centre falls within the OUTERMOST shell (VF_OuterShell — the
|
||
// render-distance/sheet-ring level+radius, same as BuildDesiredTiles). A loaded-but-not-
|
||
// desired tile in range is mid-LOD-transition (wait for its replacement); one out of range
|
||
// has left the view entirely (cull immediately).
|
||
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
|
||
const int32 MaxLevelBase = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
int32 MaxLevel = MaxLevelBase, ROuter = R;
|
||
VF_OuterShell(Settings, R, MaxLevelBase, MaxLevel, ROuter);
|
||
const int32 PowMax = 1 << MaxLevel;
|
||
const FIntVector CMax = VF_FloorDiv(Center, PowMax);
|
||
|
||
const FVector CV = Tile.CenterCm() / VOXEL_SIZE; // tile centre in voxels
|
||
const int32 SizeMax = CHUNK_SIZE * PowMax;
|
||
const FIntVector TMax(
|
||
VF_FloorDiv(FMath::FloorToInt(CV.X), SizeMax),
|
||
VF_FloorDiv(FMath::FloorToInt(CV.Y), SizeMax),
|
||
VF_FloorDiv(FMath::FloorToInt(CV.Z), SizeMax));
|
||
|
||
return FMath::Abs(TMax.X - CMax.X) <= ROuter
|
||
&& FMath::Abs(TMax.Y - CMax.Y) <= ROuter
|
||
&& FMath::Abs(TMax.Z - CMax.Z) <= ROuter;
|
||
}
|
||
|
||
int32 AVoxelWorld::GetMaxConcurrentTasks() const
|
||
{
|
||
// T2.d — the asset value, capped to the spare LOGICAL cores. BackgroundNormal priority
|
||
// (see LoadTile) already stops gen from starving the frame; this cap stops a flat 16 from
|
||
// thrashing context switches on small CPUs where 16 > the machine's spare parallelism.
|
||
const int32 Asset = Settings ? Settings->MaxConcurrentTasks : 16;
|
||
const int32 SpareCores = FMath::Max(2, FPlatformMisc::NumberOfCoresIncludingHyperthreads() - 2);
|
||
return FMath::Clamp(Asset, 1, SpareCores);
|
||
}
|
||
|
||
void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
|
||
const int32 MaxTasks = GetMaxConcurrentTasks();
|
||
|
||
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); // player's level-0 tile
|
||
CurrentCenterChunk = CenterChunk;
|
||
|
||
// Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the
|
||
// desired set rebuilds when an anchor moves (its box of collision tiles follows it). Cheap: a few
|
||
// WorldToChunkCoord per anchor, and empty (no anchors) is a zero-iteration loop.
|
||
bool bAnchorsMoved = false;
|
||
for (int32 i = StreamingAnchors.Num() - 1; i >= 0; --i)
|
||
{
|
||
AActor* A = StreamingAnchors[i].Actor.Get();
|
||
if (!A)
|
||
{
|
||
StreamingAnchors.RemoveAtSwap(i); // destroyed → drop it; its tiles must be culled
|
||
bAnchorsMoved = true;
|
||
continue;
|
||
}
|
||
const FIntVector AC = WorldToChunkCoord(A->GetActorLocation());
|
||
if (AC != StreamingAnchors[i].LastChunk)
|
||
{
|
||
StreamingAnchors[i].LastChunk = AC;
|
||
bAnchorsMoved = true;
|
||
}
|
||
}
|
||
|
||
//=========================================================================
|
||
// Rebuild the desired tile set when the player crosses a level-0 tile boundary — or when a
|
||
// streaming anchor moved / (un)registered (bAnchorsMoved / bForceDesiredRebuild).
|
||
//=========================================================================
|
||
if (CenterChunk != LastUpdateCenter || bAnchorsMoved || bForceDesiredRebuild)
|
||
{
|
||
LastUpdateCenter = CenterChunk;
|
||
bAllChunksLoaded = false;
|
||
bForceDesiredRebuild = false; // consumed
|
||
|
||
// DELTA CULL: BuildDesiredTiles renvoie les LEAVERS (désirées au crossing précédent, plus
|
||
// maintenant). Seuls candidats au cull : ces leavers + la TransitionHold (retenues des
|
||
// crossings passés). Fini le re-scan de TOUTES les tuiles chargées à chaque crossing —
|
||
// c'était le spike CullTiles ~1.6 ms/crossing (trace 2026-07-05). Les tuiles en vol qui
|
||
// finissent hors desired sont capturées à l'apply (ProcessPendingChunks → TransitionHold),
|
||
// et le settled cull (tout chargé) reste le filet de sécurité plein-scan.
|
||
TArray<FVoxelTileKey> Leavers;
|
||
BuildDesiredTiles(CenterChunk, Leavers);
|
||
|
||
// §9.4 — toggle visibility on already-loaded tiles that flipped render↔collision-only this
|
||
// crossing (a CollisionOnly cluster the player just walked toward/away from). No-op w/o anchors.
|
||
ReconcileAnchorTileVisibility();
|
||
|
||
// STRATE CONTENT CUT — the band coarse tiles are meshed to = the player strate's EXACT
|
||
// chunk-Z bounds (no margin: that's the view clamp's job — selection vs content). In the
|
||
// inter-strate gap: no band (full tiles, you can see both sides through the descent).
|
||
// On band change (strate transition), re-queue the loaded coarse tiles whose mesh depends
|
||
// on it — fully inside BOTH bands ⇒ identical either way; fully outside both ⇒ empty
|
||
// either way; everything else re-gens in place via BandRemeshQueue (no visual pop).
|
||
{
|
||
const int32 CutMin = Settings ? Settings->StrateContentCutMinLevel : 9;
|
||
// F18 — l'anneau feuille dépend aussi de la bande (sa strate de référence) : on l'arme
|
||
// dès que les feuilles sont actives, même si la coupe de contenu MC est désactivée.
|
||
const bool bSheetsWantBand = Settings && Settings->bFarSheetRing
|
||
&& Settings->RenderDistanceChunks > 0;
|
||
const int32 TopMC = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
int32 NewLo = MIN_int32, NewHi = MAX_int32;
|
||
if ((CutMin <= 8 || bSheetsWantBand) && StrateManager)
|
||
{
|
||
int32 StrTopZ = 0, StrBotZ = 0;
|
||
if (StrateManager->GetStrateChunkZBounds(CenterChunk.Z, StrTopZ, StrBotZ))
|
||
{
|
||
NewLo = StrBotZ;
|
||
NewHi = StrTopZ;
|
||
}
|
||
}
|
||
if (NewLo != MeshBandChunkLo || NewHi != MeshBandChunkHi)
|
||
{
|
||
// Diagnostic volontairement VISIBLE : si cette ligne n'apparaît JAMAIS dans
|
||
// l'Output Log, la coupe de contenu ne s'est jamais armée (bounds de strate
|
||
// introuvables pour la position du PION → tout se maille plein, comme avant).
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelWorld] Strate content band -> chunks [%d..%d] (was [%d..%d]), CutMinLevel=%d, pawn chunk Z=%d"),
|
||
NewLo, NewHi, MeshBandChunkLo, MeshBandChunkHi, CutMin, CenterChunk.Z);
|
||
for (const FVoxelTileKey& T : LoadedTiles)
|
||
{
|
||
// Feuilles (niveau > MaxClipLevel) : toujours dépendantes de la bande.
|
||
if (T.Level < CutMin && T.Level <= TopMC) continue;
|
||
const int32 CLo = T.Coord.Z << T.Level;
|
||
const int32 CHi = ((T.Coord.Z + 1) << T.Level) - 1;
|
||
const bool bInOld = CLo >= MeshBandChunkLo && CHi <= MeshBandChunkHi;
|
||
const bool bInNew = CLo >= NewLo && CHi <= NewHi;
|
||
const bool bOutOld = CHi < MeshBandChunkLo || CLo > MeshBandChunkHi;
|
||
const bool bOutNew = CHi < NewLo || CLo > NewHi;
|
||
if ((bInOld && bInNew) || (bOutOld && bOutNew)) continue; // même contenu
|
||
if (!IsDesired(T)) continue; // sera cull, pas re-gen
|
||
BandRemeshQueue.Add(T);
|
||
}
|
||
MeshBandChunkLo = NewLo;
|
||
MeshBandChunkHi = NewHi;
|
||
bAllChunksLoaded = false; // le drain de BandRemeshQueue vit dans le bloc submit
|
||
}
|
||
}
|
||
|
||
// F18 — TROU XY de l'anneau feuille (voir VoxelWorld.h) : boîte MC niveau-MaxClipLevel
|
||
// autour du joueur, rétrécie d'UNE tuile — le raccord feuille↔anneau MC garde une tuile
|
||
// MC pleine de recouvrement (même pas d'échantillonnage des deux côtés → discret), et en
|
||
// avançant, les tuiles MC de la zone nouvellement découpée étaient déjà desired au
|
||
// crossing précédent (chargées avant que le trou ne les découvre). Changement (crossing
|
||
// de tuile MaxClipLevel, ~tous les 2^L chunks) ⇒ re-queue des feuilles chevauchant
|
||
// l'ancien OU le nouveau trou.
|
||
{
|
||
int32 NewMinX = MAX_int32, NewMinY = MAX_int32;
|
||
int32 NewMaxX = MIN_int32, NewMaxY = MIN_int32;
|
||
const int32 TopMCLvl = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
if (Settings && Settings->bFarSheetRing && Settings->RenderDistanceChunks > 0)
|
||
{
|
||
const int32 RClip = FMath::Max(1, Settings->ClipRadius);
|
||
const int32 Shrink = FMath::Max(0, RClip - 1);
|
||
const int32 ExtM = CHUNK_SIZE << TopMCLvl; // tuile MaxLevel en voxels
|
||
const FIntVector CM = VF_FloorDiv(CenterChunk, 1 << TopMCLvl); // tuile MaxLevel du joueur
|
||
NewMinX = (CM.X - Shrink) * ExtM;
|
||
NewMinY = (CM.Y - Shrink) * ExtM;
|
||
NewMaxX = (CM.X + Shrink + 1) * ExtM; // EXCLUSIF
|
||
NewMaxY = (CM.Y + Shrink + 1) * ExtM;
|
||
}
|
||
if (NewMinX != SheetHoleMinXVox || NewMinY != SheetHoleMinYVox
|
||
|| NewMaxX != SheetHoleMaxXVox || NewMaxY != SheetHoleMaxYVox)
|
||
{
|
||
for (const FVoxelTileKey& T : LoadedTiles)
|
||
{
|
||
if (T.Level <= TopMCLvl) continue; // seules les feuilles portent le trou
|
||
const int32 Ext = CHUNK_SIZE << T.Level;
|
||
const int32 TMinX = T.Coord.X * Ext, TMinY = T.Coord.Y * Ext;
|
||
const bool bOldOv = TMinX < SheetHoleMaxXVox && TMinX + Ext > SheetHoleMinXVox
|
||
&& TMinY < SheetHoleMaxYVox && TMinY + Ext > SheetHoleMinYVox;
|
||
const bool bNewOv = TMinX < NewMaxX && TMinX + Ext > NewMinX
|
||
&& TMinY < NewMaxY && TMinY + Ext > NewMinY;
|
||
if ((bOldOv || bNewOv) && IsDesired(T)) BandRemeshQueue.Add(T);
|
||
}
|
||
SheetHoleMinXVox = NewMinX; SheetHoleMinYVox = NewMinY;
|
||
SheetHoleMaxXVox = NewMaxX; SheetHoleMaxYVox = NewMaxY;
|
||
bAllChunksLoaded = false;
|
||
}
|
||
}
|
||
|
||
// Cull rules — STRICT LOAD-BEFORE-UNLOAD so crossing a shell boundary NEVER leaves a hole:
|
||
// - out of clip range → left the view entirely, no replacement coming → cull now.
|
||
// - in range (mid-LOD-transition) → cull ONLY once EVERY desired tile that overlaps its
|
||
// footprint is loaded (a coarse tile is replaced by several finer tiles — the whole
|
||
// covering set must be in before it drops). Otherwise it goes to TransitionHold.
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_CullTiles);
|
||
|
||
auto FootprintsOverlap = [](const FVoxelTileKey& A, const FVoxelTileKey& B) -> bool
|
||
{
|
||
const int32 ea = A.ExtentVoxels(), eb = B.ExtentVoxels();
|
||
const FIntVector aMin = A.OriginVoxels(), bMin = B.OriginVoxels();
|
||
return aMin.X < bMin.X + eb && bMin.X < aMin.X + ea
|
||
&& aMin.Y < bMin.Y + eb && bMin.Y < aMin.Y + ea
|
||
&& aMin.Z < bMin.Z + eb && bMin.Z < aMin.Z + ea;
|
||
};
|
||
// "Every covering desired tile loaded" ⟺ "no unloaded desired tile overlaps T".
|
||
// Built LAZILY: only needed if an in-range transition candidate actually exists.
|
||
TArray<FVoxelTileKey> DesiredPending;
|
||
bool bPendingBuilt = false;
|
||
auto EnsurePending = [&]()
|
||
{
|
||
if (bPendingBuilt) return;
|
||
bPendingBuilt = true;
|
||
for (const FVoxelTileKey& D : DesiredSorted)
|
||
{
|
||
if (!LoadedTiles.Contains(D)) DesiredPending.Add(D);
|
||
}
|
||
};
|
||
auto ReplacementsReady = [&](const FVoxelTileKey& T) -> bool
|
||
{
|
||
for (const FVoxelTileKey& D : DesiredPending)
|
||
{
|
||
if (FootprintsOverlap(T, D)) return false; // a covering tile isn't ready → keep T
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// true = la tuile doit être RETENUE (transition en attente de ses remplaçants) ;
|
||
// false = rien à retenir (cullée, re-désirée, ou jamais chargée).
|
||
auto NeedsHold = [&](const FVoxelTileKey& T) -> bool
|
||
{
|
||
if (IsDesired(T)) { return false; } // re-désirée
|
||
if (!LoadedTiles.Contains(T) && !TileComponents.Contains(T))
|
||
{
|
||
return false; // jamais chargée / rien d'appliqué → rien à cull
|
||
}
|
||
if (!IsTileInClipRange(T, CenterChunk)) // left the view → cull now
|
||
{
|
||
PendingUnload.Add(T);
|
||
return false;
|
||
}
|
||
// In-range transition. Quand le backlog est gros (sprint), sauter le test de
|
||
// recouvrement et RETENIR est la direction hole-safe ; le settled cull ramassera.
|
||
EnsurePending();
|
||
if (DesiredPending.Num() <= 48 && ReplacementsReady(T))
|
||
{
|
||
PendingUnload.Add(T);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
for (const FVoxelTileKey& T : Leavers)
|
||
{
|
||
if (NeedsHold(T)) { AddToTransitionHold(T); }
|
||
}
|
||
|
||
// Hold : re-évaluation à BUDGET tournant. Re-scanner toute la hold par crossing
|
||
// redevient le vieux scan O(loaded) dès que le streaming ne settle jamais (mesuré
|
||
// 2.47 ms/crossing packagé) ; retenir plus longtemps est hole-safe, chaque tuile
|
||
// repasse sous le curseur en quelques crossings.
|
||
int32 HoldBudget = FMath::Min(TransitionHoldQueue.Num(), 256);
|
||
while (HoldBudget > 0 && TransitionHoldQueue.Num() > 0)
|
||
{
|
||
if (TransitionHoldCursor >= TransitionHoldQueue.Num()) { TransitionHoldCursor = 0; }
|
||
const FVoxelTileKey T = TransitionHoldQueue[TransitionHoldCursor];
|
||
if (!TransitionHold.Contains(T))
|
||
{
|
||
// Clé périmée (déchargée / settled-cullée) — retrait paresseux, ne consomme
|
||
// pas le budget (la queue rétrécit ⇒ la boucle termine).
|
||
TransitionHoldQueue.RemoveAtSwap(TransitionHoldCursor);
|
||
continue;
|
||
}
|
||
--HoldBudget;
|
||
if (!NeedsHold(T))
|
||
{
|
||
TransitionHold.Remove(T);
|
||
TransitionHoldQueue.RemoveAtSwap(TransitionHoldCursor);
|
||
}
|
||
else
|
||
{
|
||
++TransitionHoldCursor;
|
||
}
|
||
}
|
||
// Teardown différé — ProcessUnloadQueue étale les destructions sur plusieurs frames.
|
||
}
|
||
}
|
||
|
||
//=========================================================================
|
||
// Submit pending work (budgeted, nearest-first). Once everything desired is loaded,
|
||
// do the "settled" cull of the deferred LOD-transition tiles, then go idle.
|
||
//=========================================================================
|
||
if (!bAllChunksLoaded)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles);
|
||
int32 Submitted = 0;
|
||
|
||
// DIG RESPONSIVENESS — drain player-carve re-meshes FIRST (ahead of streaming + band) at
|
||
// BackgroundHigh, so a dig gets the task budget before any streaming gen. A tile that's still
|
||
// in flight is KEPT queued (retried next frame) so its stale pre-carve result is corrected.
|
||
for (auto It = DirtyRemeshQueue.CreateIterator(); It; ++It)
|
||
{
|
||
if (PendingTiles.Num() >= MaxTasks) break;
|
||
const FVoxelTileKey T = *It;
|
||
if (!LoadedTiles.Contains(T)) { It.RemoveCurrent(); continue; } // unloaded — drop
|
||
if (PendingTiles.Contains(T)) { continue; } // in flight — retry after it lands
|
||
It.RemoveCurrent();
|
||
LoadTile(T, /*bHighPriority*/ true);
|
||
++Submitted;
|
||
}
|
||
|
||
for (const FVoxelTileKey& T : DesiredSorted)
|
||
{
|
||
if (PendingTiles.Num() >= MaxTasks) break;
|
||
if (PendingTiles.Contains(T)) continue; // in flight
|
||
if (LoadedTiles.Contains(T)) continue; // already loaded (level is in the key — no LOD remesh)
|
||
LoadTile(T);
|
||
++Submitted;
|
||
}
|
||
|
||
// Bande de strate changée : re-gen budgétée des tuiles grossières concernées (le vieux
|
||
// mesh reste visible jusqu'au résultat — même schéma que RemeshDirtyChunks).
|
||
for (auto It = BandRemeshQueue.CreateIterator(); It; ++It)
|
||
{
|
||
if (PendingTiles.Num() >= MaxTasks) break;
|
||
const FVoxelTileKey T = *It;
|
||
It.RemoveCurrent();
|
||
if (PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue;
|
||
LoadTile(T);
|
||
++Submitted;
|
||
}
|
||
|
||
if (Submitted == 0 && PendingTiles.Num() == 0 && BandRemeshQueue.Num() == 0 && DirtyRemeshQueue.Num() == 0)
|
||
{
|
||
// Everything desired is loaded → load-before-unload is satisfied: drop the
|
||
// deferred (in-range, not-desired) transition tiles now. No holes. Full scan —
|
||
// rare (once per settle), and the safety net behind the delta cull above.
|
||
for (const auto& Pair : TileComponents)
|
||
if (!IsDesired(Pair.Key)) PendingUnload.Add(Pair.Key);
|
||
for (const FVoxelTileKey& T : LoadedTiles)
|
||
if (!IsDesired(T) && !TileComponents.Contains(T)) PendingUnload.Add(T);
|
||
// Teardown is drained by ProcessUnloadQueue (budgeted) — single spike-free path.
|
||
|
||
bAllChunksLoaded = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
|
||
{
|
||
if (PendingTiles.Contains(Tile)) return;
|
||
|
||
const int32 MaxTasks = GetMaxConcurrentTasks(); // T2.d — core-clamped
|
||
if (PendingTiles.Num() >= MaxTasks)
|
||
{
|
||
return; // Budget full — wait for a task to finish.
|
||
}
|
||
PendingTiles.Add(Tile);
|
||
|
||
const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords
|
||
|
||
// Coarse levels mesh with FEWER cells → much cheaper gen (blockier far field, which is far
|
||
// away + skirts hide the seams). Near levels (< FullResClipLevels) stay full CHUNK_SIZE cells.
|
||
// Extent stays CHUNK_SIZE<<Level (so the shell tiling is unchanged) — only the cell count and
|
||
// step change: Step = Extent / Cells.
|
||
const int32 FullRes = Settings ? FMath::Max(1, Settings->FullResClipLevels) : 2;
|
||
const int32 Extent = CHUNK_SIZE << Tile.Level;
|
||
|
||
// F18 — tuile FEUILLE : BuildDesiredTiles n'émet des clés au-delà de MaxClipLevel que pour
|
||
// l'anneau feuille (render distance) — elles se maillent en deux heightfields (GenerateSheetMesh),
|
||
// pas en marching cubes. Densité d'échantillonnage = celle de l'anneau MC niveau-MaxClipLevel
|
||
// (le nombre de cellules grandit avec la feuille, plafonné à 128/axe — au-delà le pas grossit).
|
||
const int32 TopMC = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
const bool bSheetTile = Tile.Level > TopMC;
|
||
|
||
int32 Cells = (Tile.Level < FullRes)
|
||
? CHUNK_SIZE
|
||
: (Settings ? FMath::Clamp(Settings->CoarseTileCells, 4, CHUNK_SIZE) : 16);
|
||
if (bSheetTile)
|
||
{
|
||
const int32 StepMC = FMath::Max(1, (CHUNK_SIZE << TopMC) / Cells);
|
||
Cells = FMath::Clamp(Extent / StepMC, 4, 128);
|
||
}
|
||
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<<Level, Cells == CHUNK_SIZE). When the density volume is active, ask the mesher to
|
||
// emit the captured R8 grid so the volume reuses it instead of re-sampling GetDensityAt. Gated to
|
||
// tiles the volume can actually consume (its shadow window is much smaller than the streaming
|
||
// ring) — the rest shouldn't pay the quantize + 32 KB queue payload for a grid it would refuse.
|
||
const bool bWantCapture = (Tile.Level == 0) && (Cells == CHUNK_SIZE)
|
||
&& DensityVolume != nullptr && Settings && Settings->bEnableDensityVolume
|
||
&& DensityVolume->IsTileCaptureUseful(Tile.Coord);
|
||
|
||
// STRATE CONTENT CUT — coarse tiles mesh only the player-strate band (see the band update in
|
||
// UpdateChunksAroundPosition + UVoxelSettings::StrateContentCutMinLevel). Chunk band → voxels
|
||
// (inclusive). Fine tiles / no band (gap, feature off) mesh full.
|
||
int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX;
|
||
int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32;
|
||
int32 SheetChunkZ = 0;
|
||
if (bSheetTile)
|
||
{
|
||
// F18 — la feuille a besoin de la STRATE de référence (les deux heightfields sont ceux de
|
||
// la strate du joueur) : pas de bande armée (gap inter-strates, ou bounds introuvables)
|
||
// ⇒ rien à mailler, tuile vide (re-queue automatique via BandRemeshQueue en atterrissant).
|
||
if (MeshBandChunkLo == MIN_int32)
|
||
{
|
||
FChunkResult Empty;
|
||
Empty.Tile = Tile;
|
||
Empty.Epoch = TaskEpoch;
|
||
ProcessQueue.Enqueue(MoveTemp(Empty));
|
||
return;
|
||
}
|
||
BandChunkLo = MeshBandChunkLo; // pour la résolution matériaux sol/cap dans ApplyMeshToTile
|
||
BandChunkHi = MeshBandChunkHi;
|
||
SheetChunkZ = MeshBandChunkLo + (MeshBandChunkHi - MeshBandChunkLo) / 2; // chunk au cœur de la strate
|
||
}
|
||
// F18 — trou XY courant (zone couverte par les coquilles MC, découpée des feuilles).
|
||
const int32 HoleMinX = SheetHoleMinXVox, HoleMinY = SheetHoleMinYVox;
|
||
const int32 HoleMaxX = SheetHoleMaxXVox, HoleMaxY = SheetHoleMaxYVox;
|
||
const int32 CutMin = Settings ? Settings->StrateContentCutMinLevel : 9;
|
||
if (!bSheetTile && Tile.Level >= CutMin && MeshBandChunkLo != MIN_int32)
|
||
{
|
||
BandChunkLo = MeshBandChunkLo;
|
||
BandChunkHi = MeshBandChunkHi;
|
||
BandVoxLo = MeshBandChunkLo * CHUNK_SIZE;
|
||
BandVoxHi = (MeshBandChunkHi + 1) * CHUNK_SIZE - 1;
|
||
|
||
// Résidu ultra-grossier (niveaux ≥7) : la coupe est à la granularité de la CELLULE. Si
|
||
// UNE cellule (Step voxels de haut) est plus haute que la bande entière, toute cellule
|
||
// qui chevauche la bande échantillonne quand même les airs des DEUX strates (mêmes trous
|
||
// et mélanges de matériaux qu'avant la coupe) — la tuile ne peut rendre que des artefacts
|
||
// ⇒ on n'émet RIEN. Résultat vide via ProcessQueue (bookkeeping normal : PendingTiles,
|
||
// LoadedTiles, epoch) ; jamais figé — le changement de bande re-queue via BandRemeshQueue.
|
||
if (Step > (BandChunkHi - BandChunkLo + 1) * CHUNK_SIZE)
|
||
{
|
||
FChunkResult Empty;
|
||
Empty.Tile = Tile;
|
||
Empty.Epoch = TaskEpoch;
|
||
Empty.BandChunkLo = BandChunkLo;
|
||
Empty.BandChunkHi = BandChunkHi;
|
||
ProcessQueue.Enqueue(MoveTemp(Empty));
|
||
return;
|
||
}
|
||
}
|
||
|
||
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.
|
||
// DIG RESPONSIVENESS: player carves launch at BackgroundHigh (bHighPriority) — still a background
|
||
// worker (yields to the frame, keeps the invariant) but jumps AHEAD of all pending streaming gen,
|
||
// so a dig is never queued behind a shell of streaming tasks.
|
||
const UE::Tasks::ETaskPriority TaskPriority = bHighPriority
|
||
? UE::Tasks::ETaskPriority::BackgroundHigh
|
||
: UE::Tasks::ETaskPriority::BackgroundNormal;
|
||
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
|
||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY]()
|
||
{
|
||
// RAII: decrement the counter on every exit path.
|
||
struct FTaskGuard
|
||
{
|
||
std::atomic<int32>& Counter;
|
||
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
|
||
} Guard{ActiveTaskCount};
|
||
|
||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
||
|
||
FChunkResult Result;
|
||
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
|
||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result);
|
||
|
||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||
{
|
||
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
||
}
|
||
}, TaskPriority);
|
||
}
|
||
|
||
// Worker-side gen for one tile (shared by the async ChunkGen task and the synchronous carve path).
|
||
// READS Generator/Mesher only — safe on a worker or the game thread. Fills Result; no enqueue.
|
||
void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
|
||
int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture,
|
||
int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi,
|
||
bool bSheetTile, int32 SheetChunkZ,
|
||
int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY,
|
||
FChunkResult& Result)
|
||
{
|
||
Result.Tile = Tile;
|
||
Result.Epoch = Epoch;
|
||
Result.BandChunkLo = BandChunkLo; // strate content cut (MIN/MAX = uncut)
|
||
Result.BandChunkHi = BandChunkHi;
|
||
|
||
// T1.d — TRIVIAL-TILE REJECT: ~84 % des tuiles générées sortaient vides (tout-roc /
|
||
// tout-air) en payant quand même le pré-échantillonnage complet. Le classifieur prouve
|
||
// (bornes exactes sur le treillis du mesher + gardes conservatives) qu'une tuile est
|
||
// uniforme → on saute GenerateMesh, Result reste bEmpty. Mixed = génération normale.
|
||
// Les tuiles à capture (density volume) génèrent toujours : le volume veut la grille
|
||
// même pour les cellules uniformes, et ces tuiles sont rares (fenêtre d'ombre).
|
||
// (Gate IsoLevel == 0 : les verdicts du classifieur supposent l'iso MC à zéro exactement.)
|
||
bool bTrivialEmpty = false;
|
||
if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
|
||
bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed);
|
||
}
|
||
|
||
// F18 — feuille : deux heightfields sol/cap échantillonnés par colonne (pas de marching
|
||
// cubes, pas de classifieur — la classe de surface est vraie par construction).
|
||
FVoxelMeshData MeshData;
|
||
if (!bTrivialEmpty)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||
MeshData = bSheetTile
|
||
? Mesher->GenerateSheetMesh(OriginVoxels, Step, Cells, SheetChunkZ,
|
||
HoleMinX, HoleMinY, HoleMaxX, HoleMaxY)
|
||
: Mesher->GenerateMesh(OriginVoxels, Step, Cells,
|
||
bWantCapture ? &Result.CaptureGrid : nullptr,
|
||
BandVoxLo, BandVoxHi);
|
||
}
|
||
|
||
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
||
// tiles carry no streams (Result.bEmpty stays true) → no component on apply.
|
||
if (!MeshData.IsEmpty())
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams);
|
||
Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>();
|
||
BuildTileStreamSet(*Result.Streams, MeshData);
|
||
Result.bEmpty = false;
|
||
|
||
// F17 — the mesher classified every triangle semantically (sky-cap vs ground, per
|
||
// vertex against the column's TerrainZ/CeilSurf) and packed them as two contiguous
|
||
// polygroup runs. Here we only record which sections exist for the apply path.
|
||
// (Replaces the whole-tile normal VOTE, which painted mixed coarse tiles — terrain
|
||
// AND cap in one tile — entirely with the winner's material.)
|
||
const int32 NumTris = MeshData.Triangles.Num() / 3;
|
||
Result.bHasCeilingTris = MeshData.NumCeilingTriangles > 0;
|
||
Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles;
|
||
}
|
||
}
|
||
|
||
// Section-group key shared by every tile component ("the" tile geometry group).
|
||
static FRealtimeMeshSectionGroupKey VoxelTileGroupKey()
|
||
{
|
||
return FRealtimeMeshSectionGroupKey::Create(FRealtimeMeshLODKey(0), FName("Tile"));
|
||
}
|
||
|
||
//=============================================================================
|
||
// TILE COMPONENT POOL (T2.c)
|
||
//=============================================================================
|
||
// Recycler les composants de tuile au lieu de les détruire/recréer.
|
||
|
||
URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent()
|
||
{
|
||
// Reuse a parked component when one is available — skips NewObject + RegisterComponent
|
||
// (and the full proxy teardown/GC of a destroy) during fast travel & regen bursts.
|
||
while (TileComponentPool.Num() > 0)
|
||
{
|
||
URealtimeMeshComponent* Pooled = TileComponentPool.Pop();
|
||
if (IsValid(Pooled))
|
||
{
|
||
Pooled->SetVisibility(true);
|
||
return Pooled;
|
||
}
|
||
}
|
||
|
||
URealtimeMeshComponent* MeshComp = NewObject<URealtimeMeshComponent>(this);
|
||
// Generated once, never moves → Static so RMC's cached static draw path + VSM shadow
|
||
// caching apply (see the root SetMobility note in BeginPlay). Must be set before register.
|
||
// Re-mesh on carve recreates the section-group proxy (RMC's Static path already does this),
|
||
// which is fine for an infrequent action.
|
||
MeshComp->SetMobility(EComponentMobility::Static);
|
||
MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps
|
||
MeshComp->SetCanEverAffectNavigation(false);
|
||
MeshComp->RegisterComponent();
|
||
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
|
||
return MeshComp;
|
||
}
|
||
|
||
void AVoxelWorld::ReleaseTileComponent(URealtimeMeshComponent* Comp)
|
||
{
|
||
if (!IsValid(Comp)) { return; }
|
||
|
||
if (TileComponentPool.Num() >= MaxPooledTileComponents)
|
||
{
|
||
Comp->DestroyComponent();
|
||
return;
|
||
}
|
||
|
||
// Strip the tile's geometry NOW, not at reuse: removing the section group drops its
|
||
// sections and their cooked collision, so a parked (hidden) component can't be collided
|
||
// with and its render memory is released while it waits. The mesh OBJECT is kept — reuse
|
||
// goes through the same RemoveSectionGroup/CreateSectionGroup path as a re-mesh.
|
||
if (URealtimeMeshSimple* RTMesh = Comp->GetRealtimeMeshAs<URealtimeMeshSimple>())
|
||
{
|
||
RTMesh->RemoveSectionGroup(VoxelTileGroupKey());
|
||
}
|
||
Comp->SetVisibility(false);
|
||
TileComponentPool.Add(Comp);
|
||
}
|
||
|
||
void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
|
||
{
|
||
// Water + decorations are no longer tile-bound (water is one player-following ocean plane via
|
||
// UpdateWater; decorations stream by distance via UpdateDecorations) — nothing to clear per tile.
|
||
if (URealtimeMeshComponent** Comp = TileComponents.Find(Tile))
|
||
{
|
||
if (*Comp) { ReleaseTileComponent(*Comp); } // T2.c — park, don't destroy
|
||
TileComponents.Remove(Tile);
|
||
}
|
||
LoadedTiles.Remove(Tile);
|
||
PendingTiles.Remove(Tile);
|
||
TransitionHold.Remove(Tile); // couvre aussi le settled cull (qui ne tient pas la hold à jour)
|
||
}
|
||
|
||
void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
||
|
||
// Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller
|
||
// (ProcessPendingChunks skips empty tiles). This path is game-thread-CHEAP: material lookup +
|
||
// component get/create + the upload + per-section config. No per-vertex work here.
|
||
const FVoxelTileKey& Tile = Result.Tile;
|
||
RealtimeMesh::FRealtimeMeshStreamSet& Streams = *Result.Streams;
|
||
const bool bHasGroundTris = Result.bHasGroundTris;
|
||
const bool bHasCeilingTris = Result.bHasCeilingTris;
|
||
const bool bLevel0 = (Tile.Level == 0);
|
||
|
||
// F17 — materials per POLYGROUP, not per tile. The mesher classified each triangle
|
||
// semantically (sky-cap = down-facing near the column's CeilSurf; terrain overhangs and
|
||
// future cave roofs stay ground) and packed two contiguous runs → RMC creates one section
|
||
// per non-empty group. Ground (group 0): strate override else global default. Sky-cap
|
||
// (group 1): the strate's CeilingMaterial when set (the rocky "night sky" overhead reads
|
||
// flat/bright otherwise, since it casts no shadow) else same as ground. A coarse tile
|
||
// spanning BOTH surfaces now renders both correctly (the old whole-tile vote painted the
|
||
// loser with the winner's material — Jahni's "terrain and ceiling become one" artifact).
|
||
UMaterialInterface* GroundMaterial = Settings ? Settings->VoxelMaterial : nullptr;
|
||
UMaterialInterface* CeilingMaterial = nullptr;
|
||
if (StrateManager)
|
||
{
|
||
// F17 — a coarse tile is 2^level CHUNKS TALL: its raw min/max corners can sit in a
|
||
// NEIGHBOUR strate (or the inter-strate gap → null) that the mesh doesn't even contain
|
||
// (strate content cut). Clamp the lookup Zs into the band the tile was MESHED with, then
|
||
// resolve: ground at the (clamped) BOTTOM chunk, sky-cap at the (clamped) TOP chunk —
|
||
// the cap is by definition the topmost surface in the tile (mid as a gap fallback).
|
||
// This was the "far cap renders with the ground material" residue: the min-corner lookup
|
||
// missed the surface strate entirely on tall far tiles.
|
||
const FIntVector MinChunk = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner
|
||
const int32 TileChunks = 1 << Tile.Level;
|
||
const int32 ZLoC = FMath::Max(MinChunk.Z, Result.BandChunkLo);
|
||
const int32 ZHiC = FMath::Min(MinChunk.Z + TileChunks - 1, Result.BandChunkHi);
|
||
if (UVoxelStrateDefinition* StrateDef =
|
||
StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, ZLoC)))
|
||
{
|
||
if (StrateDef->OverrideMaterial) { GroundMaterial = StrateDef->OverrideMaterial; }
|
||
if (StrateDef->CeilingMaterial) { CeilingMaterial = StrateDef->CeilingMaterial; }
|
||
}
|
||
UVoxelStrateDefinition* CapDef =
|
||
StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, ZHiC));
|
||
if (!CapDef || !CapDef->CeilingMaterial)
|
||
{
|
||
CapDef = StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, (ZLoC + ZHiC) / 2));
|
||
}
|
||
if (CapDef && CapDef->CeilingMaterial) { CeilingMaterial = CapDef->CeilingMaterial; }
|
||
}
|
||
if (!CeilingMaterial) { CeilingMaterial = GroundMaterial; }
|
||
|
||
// Mini-sun shadows: route the resolved base materials 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 tiles of a base still share one material.
|
||
if (DensityVolume && Settings && Settings->bEnableDensityVolume)
|
||
{
|
||
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(GroundMaterial)) { GroundMaterial = MID; }
|
||
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(CeilingMaterial)) { CeilingMaterial = 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.
|
||
|
||
// One component per tile — the clipmap keeps the total tile count low (~1-2k), so this is
|
||
// cheap on the game thread (no batching needed). Collision + content are level-0 only.
|
||
// T2.c: the component comes from the pool when one is parked (see AcquireTileComponent).
|
||
URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile);
|
||
if (!MeshComp)
|
||
{
|
||
MeshComp = AcquireTileComponent();
|
||
TileComponents.Add(Tile, MeshComp);
|
||
}
|
||
|
||
// §9.4 RENDER-SKIP — a tile only a CollisionOnly anchor wants (not the player clipmap) cooks its
|
||
// collision below but is hidden (no draw / VSM). Set every apply (overrides the pool's default-
|
||
// visible state); ReconcileAnchorTileVisibility handles later flips on already-loaded tiles.
|
||
MeshComp->SetVisibility(!CollisionOnlyTiles.Contains(Tile));
|
||
|
||
// Reuse the component's existing mesh object when it has one (pooled component or carve
|
||
// re-mesh) — InitializeRealtimeMesh allocates a brand-new URealtimeMesh EVERY call, so
|
||
// calling it unconditionally (as before) orphaned one mesh object per re-apply to the GC.
|
||
// The RemoveSectionGroup below does the actual geometry clearing on reuse.
|
||
URealtimeMeshSimple* RTMesh = MeshComp->GetRealtimeMeshAs<URealtimeMeshSimple>();
|
||
if (!RTMesh) { RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>(); }
|
||
if (!RTMesh) { return; }
|
||
// Shadow casting: far (level >= 2) tiles never cast; the sky-cap SECTION never casts either
|
||
// — otherwise the high rock ceiling shadows the entire terrain below it. F17: shadow is now
|
||
// PER SECTION, so a mixed tile keeps its ground shadow while its cap stays shadowless.
|
||
const bool bCastShadow = (Tile.Level <= 1);
|
||
MeshComp->SetCastShadow(bCastShadow);
|
||
|
||
const FRealtimeMeshSectionGroupKey GroupKey = VoxelTileGroupKey();
|
||
RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh
|
||
// (no-op on a fresh/pooled mesh)
|
||
RTMesh->SetupMaterialSlot(0, "Main", GroundMaterial);
|
||
RTMesh->SetupMaterialSlot(1, "SkyCap", CeilingMaterial);
|
||
RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams));
|
||
|
||
// RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the
|
||
// component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy, so the real
|
||
// shadow lever is the section flag. RMC auto-created one section per non-empty polygroup above
|
||
// (default config already maps material slot = polygroup index); only config sections that
|
||
// exist — the bHas* flags come from the worker. Collision at level 0 only (T1.c), both groups.
|
||
if (bHasGroundTris)
|
||
{
|
||
FRealtimeMeshSectionConfig GroundConfig(0);
|
||
GroundConfig.bCastsShadow = bCastShadow;
|
||
RTMesh->UpdateSectionConfig(
|
||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0),
|
||
GroundConfig, /*bShouldCreateCollision*/ bLevel0);
|
||
}
|
||
if (bHasCeilingTris)
|
||
{
|
||
FRealtimeMeshSectionConfig CapConfig(1);
|
||
CapConfig.bCastsShadow = false; // the cap never casts (see above)
|
||
RTMesh->UpdateSectionConfig(
|
||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 1),
|
||
CapConfig, /*bShouldCreateCollision*/ bLevel0);
|
||
}
|
||
|
||
// Water is no longer spawned per tile — it's a single player-following ocean plane (UpdateWater,
|
||
// driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps.
|
||
}
|
||
|
||
//=============================================================================
|
||
// STRATE QUERIES
|
||
//=============================================================================
|
||
|
||
int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
|
||
{
|
||
if (!StrateManager) return -1;
|
||
|
||
// GetStrateIndex expects Unreal world units — it converts internally.
|
||
return StrateManager->GetStrateIndex(WorldPosition.Z);
|
||
}
|
||
|
||
FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const
|
||
{
|
||
FVoxelBiomeQuery Out;
|
||
if (!Generator) return Out;
|
||
|
||
// Bring the world point into actor-LOCAL voxel space — the SAME transform the decoration scatter
|
||
// applies (UpdateDecorations), so the probe agrees with where props actually land.
|
||
const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation);
|
||
const float VX = Local.X / VOXEL_SIZE;
|
||
const float VY = Local.Y / VOXEL_SIZE;
|
||
const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||
|
||
Generator->QueryBiomeAt(VX, VY, ChunkZ, Out);
|
||
|
||
// Decoration streaming state of the region under this point — discriminates a render drop / empty
|
||
// march / stuck build / never-requested region for a visibly-bare patch (see FVoxelBiomeQuery).
|
||
if (ContentManager)
|
||
{
|
||
ContentManager->QueryDecoDebugAt(Local, Out.bDecoRegionApplied, Out.DecoAppliedInstances,
|
||
Out.bDecoRegionBuilding, Out.DecoCellsAccounted, Out.DecoCellsTotal,
|
||
Out.DecoLiveMarchSpawns, Out.DecoInstancesInCell);
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
bool AVoxelWorld::GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfaceWorldZ, float& OutCeilingWorldZ) const
|
||
{
|
||
OutSurfaceWorldZ = WorldLocation.Z; // sensible fallback: unchanged Z
|
||
OutCeilingWorldZ = WorldLocation.Z;
|
||
if (!Generator) return false;
|
||
|
||
// Undo the actor transform → voxel space (same convention as GetBiomeAtWorldLocation / the deco scatter).
|
||
const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation);
|
||
const float VX = Local.X / VOXEL_SIZE;
|
||
const float VY = Local.Y / VOXEL_SIZE;
|
||
const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||
|
||
float TerrainVZ, CeilVZ;
|
||
if (!Generator->GetSurfaceHeightAt(VX, VY, ChunkZ, TerrainVZ, CeilVZ)) return false; // not a heightfield
|
||
|
||
// Voxel Z → actor-local cm → world, keeping the query's XY so a tilted/scaled actor stays consistent.
|
||
OutSurfaceWorldZ = GetActorTransform().TransformPosition(FVector(Local.X, Local.Y, TerrainVZ * VOXEL_SIZE)).Z;
|
||
OutCeilingWorldZ = GetActorTransform().TransformPosition(FVector(Local.X, Local.Y, CeilVZ * VOXEL_SIZE)).Z;
|
||
return true;
|
||
}
|
||
|
||
//=============================================================================
|
||
// 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)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space
|
||
Mod.Radius = Radius;
|
||
Mod.Strength = -FMath::Abs(Strength); // force negative for carving
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Center = Position / VOXEL_SIZE;
|
||
Mod.Radius = Radius;
|
||
Mod.Strength = FMath::Abs(Strength); // force positive for filling
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::ApplyModification(const FVoxelModification& Modification)
|
||
{
|
||
if (!DiffLayer) return;
|
||
TArray<FIntVector> AffectedChunks = DiffLayer->ApplyModification(Modification);
|
||
|
||
// INSTANT DIG FEEL — synchronously re-mesh the level-0 tile the brush CENTRE sits in, so the hole
|
||
// appears THIS frame right where the player is looking. Neighbour tiles (brush edge) re-mesh async
|
||
// and prioritised (RemeshDirtyChunks → DirtyRemeshQueue @ BackgroundHigh), a frame or two behind —
|
||
// imperceptible. One full-res tile gen on the game thread; only for the common case.
|
||
// SKIP the sync when the centre tile is already mid-gen (an async task owns it): syncing would race
|
||
// the in-flight stale result (which lands with no hole and would clobber ours). Instead let it flow
|
||
// through the async queue, which now KEEPS in-flight tiles queued and re-gens them once the stale
|
||
// result lands. The centre tile must be loaded to remesh in place (else it streams in with the diff).
|
||
const FVoxelTileKey CenterTile(WorldToChunkCoord(Modification.Center * VOXEL_SIZE), 0);
|
||
bool bSyncedCenter = false;
|
||
if (AffectedChunks.Contains(CenterTile.Coord)
|
||
&& LoadedTiles.Contains(CenterTile)
|
||
&& !PendingTiles.Contains(CenterTile))
|
||
{
|
||
SyncRemeshTile(CenterTile);
|
||
bSyncedCenter = true;
|
||
}
|
||
|
||
RemeshDirtyChunks(AffectedChunks, bSyncedCenter ? &CenterTile : nullptr);
|
||
|
||
// Remove decorations inside the modified volume so grass doesn't float over a dug hole (or bury under a
|
||
// fill). Instant + flicker-free (only the affected instances go); the placer already skips carved columns
|
||
// on any future rebuild. Center/Radius are in voxels → world cm. Box/capsule use their bounding sphere.
|
||
if (ContentManager && AffectedChunks.Num() > 0)
|
||
{
|
||
const FVector WorldCenter = Modification.Center * VOXEL_SIZE; // Center was WorldPos/VOXEL_SIZE
|
||
ContentManager->RemoveDecorationsInSphere(WorldCenter, Modification.Radius * VOXEL_SIZE);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Box;
|
||
Mod.Center = Position / VOXEL_SIZE;
|
||
Mod.BoxExtent = ExtentVoxels;
|
||
Mod.Radius = ExtentVoxels.GetMax(); // budget proxy
|
||
Mod.Strength = -FMath::Abs(Strength); // carve
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Box;
|
||
Mod.Center = Position / VOXEL_SIZE;
|
||
Mod.BoxExtent = ExtentVoxels;
|
||
Mod.Radius = ExtentVoxels.GetMax();
|
||
Mod.Strength = FMath::Abs(Strength); // fill
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Capsule;
|
||
Mod.Center = WorldA / VOXEL_SIZE;
|
||
Mod.CapsuleEnd = WorldB / VOXEL_SIZE;
|
||
Mod.Radius = RadiusVoxels;
|
||
Mod.Strength = -FMath::Abs(Strength);
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Capsule;
|
||
Mod.Center = WorldA / VOXEL_SIZE;
|
||
Mod.CapsuleEnd = WorldB / VOXEL_SIZE;
|
||
Mod.Radius = RadiusVoxels;
|
||
Mod.Strength = FMath::Abs(Strength);
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::EditorCarveSphere()
|
||
{
|
||
if (!DiffLayer)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EditorCarveSphere: no DiffLayer (start PIE first)."));
|
||
return;
|
||
}
|
||
CarveAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength);
|
||
}
|
||
|
||
void AVoxelWorld::EditorFillSphere()
|
||
{
|
||
if (!DiffLayer)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EditorFillSphere: no DiffLayer (start PIE first)."));
|
||
return;
|
||
}
|
||
FillAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength);
|
||
}
|
||
|
||
//=============================================================================
|
||
// BIOME MAP PREVIEW — bake the XY biome field to Saved/BiomePreview.png
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::BakeBiomePreview()
|
||
{
|
||
if (!BiomePreviewStrate)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] BakeBiomePreview: assign BiomePreviewStrate first."));
|
||
return;
|
||
}
|
||
if (BiomePreviewChannel == EBiomePreviewChannel::Biome && BiomePreviewStrate->Biomes.Num() == 0)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] BakeBiomePreview: '%s' has no Biomes — bake Relief/Moisture instead, or add biomes."),
|
||
*BiomePreviewStrate->GetName());
|
||
return;
|
||
}
|
||
|
||
// Transient generator so this works in the editor without PIE.
|
||
UVoxelGenerator* Gen = NewObject<UVoxelGenerator>(this);
|
||
Gen->Seed = Settings ? Settings->Seed : 0;
|
||
|
||
// Flatten the strate's biomes into a context (mirrors StrateManager::GetBiomeContextForChunk).
|
||
FBiomeContext Ctx;
|
||
Ctx.Map = BiomePreviewStrate->BiomeMapParams;
|
||
for (int32 i = 0; i < BiomePreviewStrate->Biomes.Num(); ++i)
|
||
{
|
||
const UVoxelBiomeDefinition* B = BiomePreviewStrate->Biomes[i];
|
||
if (!B) continue;
|
||
FBiomeResolved R;
|
||
R.Index = i;
|
||
R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax;
|
||
R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax;
|
||
R.DebugColor = B->DebugColor.ToFColor(true);
|
||
Ctx.Biomes.Add(R);
|
||
}
|
||
|
||
const int32 Res = FMath::Clamp(BiomePreviewResolution, 64, 2048);
|
||
const float Size = FMath::Max(BiomePreviewWorldSize, 1.0f);
|
||
const float Step = Size / (float)Res;
|
||
const float OriginX = BiomePreviewCenter.X - Size * 0.5f;
|
||
const float OriginY = BiomePreviewCenter.Y - Size * 0.5f;
|
||
|
||
TArray<FColor> Pixels;
|
||
Pixels.SetNumUninitialized(Res * Res);
|
||
|
||
for (int32 py = 0; py < Res; ++py)
|
||
for (int32 px = 0; px < Res; ++px)
|
||
{
|
||
const float wx = OriginX + (px + 0.5f) * Step;
|
||
const float wy = OriginY + (py + 0.5f) * Step;
|
||
|
||
FColor C = FColor::Black;
|
||
switch (BiomePreviewChannel)
|
||
{
|
||
case EBiomePreviewChannel::Relief:
|
||
{
|
||
const float r = Gen->SampleRelief(wx, wy, Ctx.Map.ReliefFrequency, Ctx.Map.ReliefContrast);
|
||
const uint8 v = (uint8)FMath::Clamp(r * 255.0f, 0.0f, 255.0f);
|
||
C = FColor(v, v, v);
|
||
break;
|
||
}
|
||
case EBiomePreviewChannel::Moisture:
|
||
{
|
||
const float m = Gen->SampleMoisture(wx, wy, Ctx.Map.MoistureFrequency);
|
||
const uint8 v = (uint8)FMath::Clamp(m * 255.0f, 0.0f, 255.0f);
|
||
C = FColor(0, v, (uint8)(255 - v)); // dry=blue → wet=green
|
||
break;
|
||
}
|
||
default: // Biome
|
||
{
|
||
const FBiomeSample S = Gen->SampleBiomeAt(wx, wy, Ctx);
|
||
if (Ctx.Biomes.IsValidIndex(S.DominantIndex))
|
||
{
|
||
C = Ctx.Biomes[S.DominantIndex].DebugColor;
|
||
if (S.NeighborWeight > 0.0f && Ctx.Biomes.IsValidIndex(S.NeighborIndex))
|
||
{
|
||
const FLinearColor A(C);
|
||
const FLinearColor Bn(Ctx.Biomes[S.NeighborIndex].DebugColor);
|
||
C = FLinearColor::LerpUsingHSV(A, Bn, S.NeighborWeight).ToFColor(true);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
C.A = 255;
|
||
Pixels[py * Res + px] = C;
|
||
}
|
||
|
||
// Encode PNG and write to Saved/.
|
||
IImageWrapperModule& Module = FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
|
||
const TSharedPtr<IImageWrapper> Wrapper = Module.CreateImageWrapper(EImageFormat::PNG);
|
||
if (!Wrapper.IsValid() ||
|
||
!Wrapper->SetRaw(Pixels.GetData(), (int64)Pixels.Num() * sizeof(FColor), Res, Res, ERGBFormat::BGRA, 8))
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] BakeBiomePreview: failed to encode image."));
|
||
return;
|
||
}
|
||
|
||
const TArray64<uint8>& Png = Wrapper->GetCompressed(100);
|
||
const FString Path = FPaths::ProjectSavedDir() / TEXT("BiomePreview.png");
|
||
if (FFileHelper::SaveArrayToFile(Png, *Path))
|
||
{
|
||
UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] Biome preview (%dx%d, %s) saved to %s"),
|
||
Res, Res, *UEnum::GetValueAsString(BiomePreviewChannel), *FPaths::ConvertRelativePathToFull(Path));
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] BakeBiomePreview: failed to write %s"), *Path);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ClearAllModifications()
|
||
{
|
||
if (!DiffLayer) return;
|
||
|
||
DiffLayer->Clear();
|
||
|
||
// Regenerate all loaded chunks to restore procedural terrain
|
||
RegenerateAllChunks();
|
||
}
|
||
|
||
//=============================================================================
|
||
// SEED / SEASON MANAGEMENT
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::ChangeSeed(int32 NewSeed)
|
||
{
|
||
if (!Settings)
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned"));
|
||
return;
|
||
}
|
||
|
||
const int32 OldSeed = Settings->Seed;
|
||
const int32 OldSeason = Settings->CurrentSeason;
|
||
|
||
// 1. Update seed in Settings (the authoritative source)
|
||
Settings->Seed = NewSeed;
|
||
|
||
// 2. Increment season counter
|
||
Settings->CurrentSeason++;
|
||
|
||
// 3. Push new seed to Generator
|
||
if (Generator)
|
||
{
|
||
Generator->InitializeSettings(Settings);
|
||
}
|
||
|
||
// 4. Rebuild strate layout with the new seed.
|
||
// Strate assignments and passages all change.
|
||
if (StrateManager)
|
||
{
|
||
StrateManager->Initialize(Settings, NewSeed);
|
||
}
|
||
|
||
// 5. Clear all player modifications — carvings from the old world are meaningless
|
||
if (DiffLayer)
|
||
{
|
||
DiffLayer->Clear();
|
||
}
|
||
|
||
// 5b. Update content placement seed so the new world scatters differently.
|
||
if (ContentManager)
|
||
{
|
||
ContentManager->SetSeed(NewSeed);
|
||
ContentManager->ClearAll();
|
||
}
|
||
|
||
// 5c. Reset atmosphere — strate layout changed, re-apply on next Tick.
|
||
if (AtmosphereManager)
|
||
{
|
||
AtmosphereManager->Reset();
|
||
}
|
||
|
||
// 6. Unload all existing chunks and let Tick reload them with new generation
|
||
RegenerateAllChunks();
|
||
|
||
UE_LOG(LogTemp, Log,
|
||
TEXT("[VoxelWorld] Seed changed: %d -> %d (Season %d -> %d). All chunks regenerated, mods cleared."),
|
||
OldSeed, NewSeed, OldSeason, Settings->CurrentSeason);
|
||
}
|
||
|
||
int32 AVoxelWorld::GetCurrentSeed() const
|
||
{
|
||
return Settings ? Settings->Seed : 0;
|
||
}
|
||
|
||
int32 AVoxelWorld::GetCurrentSeason() const
|
||
{
|
||
return Settings ? Settings->CurrentSeason : 0;
|
||
}
|
||
|
||
//=============================================================================
|
||
// REMESH DIRTY CHUNKS — re-queue affected chunks after terrain modification
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords, const FVoxelTileKey* ExcludeTile)
|
||
{
|
||
// Edits only affect LEVEL-0 tiles (collision + visible detail are full-res near the player;
|
||
// coarse far tiles sample too sparsely to show small carves, and pick up the diff naturally
|
||
// when they next stream). Queue each loaded level-0 dirty tile onto DirtyRemeshQueue — drained
|
||
// FIRST in the submit loop and launched at BackgroundHigh (ahead of streaming), so a dig never
|
||
// waits behind a shell of streaming tasks. LoadTile re-runs gen (density includes the DiffLayer
|
||
// via GetDensityAt) and ProcessPendingChunks updates the existing component in place (old mesh
|
||
// stays visible until then, no pop).
|
||
//
|
||
// Tiles that are currently mid-gen are STILL queued (not skipped): their in-flight result was
|
||
// sampled BEFORE this carve, so it lands with no hole — keeping the tile queued re-gens it once
|
||
// that stale result drains. (The old inline path dropped both over-budget and in-flight tiles,
|
||
// which is why a dig could show up a beat late or not until the player moved.)
|
||
for (const FIntVector& Coord : DirtyCoords)
|
||
{
|
||
const FVoxelTileKey Tile(Coord, 0);
|
||
if (ExcludeTile && Tile == *ExcludeTile) continue; // handled synchronously this frame
|
||
if (!LoadedTiles.Contains(Tile)) continue; // only re-mesh loaded full-res tiles
|
||
DirtyRemeshQueue.Add(Tile);
|
||
}
|
||
if (DirtyRemeshQueue.Num() > 0)
|
||
{
|
||
// Wake the submit loop even if the streaming set had settled (idle player digging).
|
||
bAllChunksLoaded = false;
|
||
}
|
||
|
||
// 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<UMaterialInstanceDynamic>* Found = TerrainMIDs.Find(Base))
|
||
{
|
||
return Found->Get();
|
||
}
|
||
UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(Base, this);
|
||
if (MID)
|
||
{
|
||
TerrainMIDs.Add(Base, MID);
|
||
SetVolumeParamsOnMID(MID); // seed with the current frame's params
|
||
}
|
||
return MID;
|
||
}
|
||
|
||
void AVoxelWorld::SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const
|
||
{
|
||
if (!MID) return;
|
||
// Static FNames — this runs per MID on every param change; no per-call FName construction.
|
||
static const FName VolPNames[10] = {
|
||
FName("VolP0"), FName("VolP1"), FName("VolP2"), FName("VolP3"), FName("VolP4"),
|
||
FName("VolP5"), FName("VolP6"), FName("VolP7"), FName("VolP8"), FName("VolP9") };
|
||
static const FName VolTexNames[3] = { FName("VolTex0"), FName("VolTex1"), FName("VolTex2") };
|
||
|
||
const FLinearColor* TVPs[10] = { &TVP0, &TVP1, &TVP2, &TVP3, &TVP4, &TVP5, &TVP6, &TVP7, &TVP8, &TVP9 };
|
||
for (int32 i = 0; i < 10; ++i) { MID->SetVectorParameterValue(VolPNames[i], *TVPs[i]); }
|
||
if (DensityVolume)
|
||
{
|
||
for (int32 L = 0; L < 3; ++L)
|
||
{
|
||
if (UVolumeTexture* T = DensityVolume->GetLevelTexture(L)) { MID->SetTextureParameterValue(VolTexNames[L], T); }
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::UpdateTerrainMaterialParams()
|
||
{
|
||
if (!DensityVolume || !Settings || !Settings->bEnableDensityVolume) return;
|
||
|
||
// --- Per-level clipmap transforms (L0 = finest/near; L1-2 = coarser for shadow REACH) ---
|
||
// For each level the material maps WorldPos → RelPos = WorldPos - WindowOrigin → cellF = RelPos/Cell →
|
||
// toroidal UVW = frac((OriginMod + cellF + 0.5)/Res). OriginMod = OriginCells mod Res precomputed here
|
||
// so the shader never touches the large absolute cell coord (no float precision loss). The shader
|
||
// derives each level's cell size from L0's (cell_L = L0Cell * 2^L); Res is shared across levels.
|
||
const FTransform Xf = GetActorTransform();
|
||
int32 Res = 0;
|
||
bool bHave = false;
|
||
float CellWorldSize = VOXEL_SIZE; // L0 cm per cell
|
||
|
||
FLinearColor OriginC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
|
||
FLinearColor ModC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
|
||
for (int32 L = 0; L < 3; ++L)
|
||
{
|
||
FIntVector OriginCells(0, 0, 0);
|
||
float StepF = 1.0f;
|
||
int32 LRes = 0;
|
||
if (DensityVolume->GetLevelShaderParams(L, OriginCells, StepF, LRes) && LRes > 0)
|
||
{
|
||
const FVector OriginLocalCm = FVector(OriginCells.X, OriginCells.Y, OriginCells.Z) * (StepF * VOXEL_SIZE);
|
||
const FVector OW = Xf.TransformPosition(OriginLocalCm);
|
||
auto Mod = [LRes](int32 v) { const int32 m = v % LRes; return (float)((m < 0) ? m + LRes : m); };
|
||
OriginC[L] = FLinearColor(OW.X, OW.Y, OW.Z, 0.0f);
|
||
ModC[L] = FLinearColor(Mod(OriginCells.X), Mod(OriginCells.Y), Mod(OriginCells.Z), 0.0f);
|
||
if (L == 0) { bHave = true; Res = LRes; CellWorldSize = VOXEL_SIZE * StepF; }
|
||
}
|
||
}
|
||
// Track whether anything actually changed — the push below enqueues render-thread updates per MID,
|
||
// so on the (common) idle frames where the window didn't scroll and the orb didn't change, skip it.
|
||
bool bDirty = false;
|
||
auto SetTVP = [&bDirty](FLinearColor& Dst, const FLinearColor& V)
|
||
{
|
||
if (Dst != V) { Dst = V; bDirty = true; }
|
||
};
|
||
SetTVP(TVP0, OriginC[0]); SetTVP(TVP1, ModC[0]);
|
||
SetTVP(TVP6, OriginC[1]); SetTVP(TVP7, ModC[1]);
|
||
SetTVP(TVP8, OriginC[2]); SetTVP(TVP9, ModC[2]);
|
||
|
||
// --- Nearest active orb ---
|
||
FVoxelActiveOrb Best;
|
||
bool bHaveOrb = false;
|
||
if (ContentManager)
|
||
{
|
||
TArray<FVoxelActiveOrb> Orbs;
|
||
ContentManager->GetActiveOrbs(Orbs);
|
||
if (Orbs.Num() > 0)
|
||
{
|
||
const FVector P = GetPlayerPosition();
|
||
float BestD = FLT_MAX;
|
||
for (const FVoxelActiveOrb& O : Orbs)
|
||
{
|
||
const float D = FVector::DistSquared(O.WorldPos, P);
|
||
if (D < BestD) { BestD = D; Best = O; bHaveOrb = true; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// All data lives in .xyz (a Vector Parameter only delivers float3 into a Custom node). Intensity is
|
||
// premultiplied into the colour; Res / CellWorldSize / Enable go in TVP5.
|
||
const float Enable = (bHave && bHaveOrb && Res > 0) ? 1.0f : 0.0f;
|
||
const float Steps = (float)FMath::Clamp(Settings->DensityVolumeMarchSteps, 4, 256);
|
||
SetTVP(TVP2, FLinearColor(Best.WorldPos.X, Best.WorldPos.Y, Best.WorldPos.Z, 0.0f));
|
||
SetTVP(TVP3, FLinearColor(Best.Color.R * Best.Intensity, Best.Color.G * Best.Intensity, Best.Color.B * Best.Intensity, 0.0f));
|
||
SetTVP(TVP4, FLinearColor(Best.MaxShadowDistWorld, Best.FalloffWorld, Steps, 0.0f));
|
||
SetTVP(TVP5, FLinearColor((float)FMath::Max(Res, 0), CellWorldSize, Enable, 0.0f));
|
||
|
||
// Re-push if the L0 texture object itself was recreated (resolution change) even when the packed
|
||
// params happen to be identical — otherwise the MIDs would keep sampling the dropped texture.
|
||
UVolumeTexture* Tex0 = DensityVolume->GetLevelTexture(0);
|
||
if (LastBoundVolTex0.Get() != Tex0) { LastBoundVolTex0 = Tex0; bDirty = true; }
|
||
if (!bDirty) return;
|
||
|
||
// Push to every terrain MID (new MIDs are seeded on creation in GetOrCreateTerrainMID).
|
||
for (TPair<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>>& Pair : TerrainMIDs)
|
||
{
|
||
SetVolumeParamsOnMID(Pair.Value.Get());
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::UpdateOrbLightMPC()
|
||
{
|
||
if (!OrbLightMPC || !ContentManager) return;
|
||
|
||
TArray<FVoxelActiveOrb> Orbs;
|
||
ContentManager->GetActiveOrbs(Orbs);
|
||
|
||
// Nearest-first so Orb0..3 are the 4 closest orbs (the Light Function unions their pools; 4 is
|
||
// plenty since only nearby pools are visible and the player sits inside one or two at a time).
|
||
const FVector P = GetPlayerPosition();
|
||
Orbs.Sort([&P](const FVoxelActiveOrb& A, const FVoxelActiveOrb& B)
|
||
{
|
||
return FVector::DistSquared(A.WorldPos, P) < FVector::DistSquared(B.WorldPos, P);
|
||
});
|
||
|
||
static const FName OrbNames[4] = { FName("Orb0"), FName("Orb1"), FName("Orb2"), FName("Orb3") };
|
||
for (int32 i = 0; i < 4; ++i)
|
||
{
|
||
// (x,y,z) = orb WORLD position, .w = reach radius in cm (FalloffWorld = how far the pool
|
||
// extends). Unused slots = all-zero → radius 0 → the mask yields no pool for them.
|
||
FLinearColor V(0.f, 0.f, 0.f, 0.f);
|
||
if (i < Orbs.Num())
|
||
{
|
||
const FVoxelActiveOrb& O = Orbs[i];
|
||
V = FLinearColor((float)O.WorldPos.X, (float)O.WorldPos.Y, (float)O.WorldPos.Z, O.FalloffWorld);
|
||
}
|
||
// ALWAYS write, even when unchanged. A skip-if-identical cache was tried here and BROKE the
|
||
// lighting: the MPC's world INSTANCE can be reset/recreated behind our back (PIE init order,
|
||
// asset recompile), and a cached skip then leaves it holding defaults forever. The per-frame
|
||
// rewrite is what makes the collection self-healing — and 4 vector writes cost nothing.
|
||
UKismetMaterialLibrary::SetVectorParameterValue(this, OrbLightMPC, OrbNames[i], V);
|
||
}
|
||
}
|