New verify.py sanitize tier (Clang address,undefined,float-cast-overflow; GCC address,undefined; -fno-sanitize-recover) runs 19 cases per compiler at LOD0-8 across every generator, passages and the crossing: 0 runtime reports; a planted signed-overflow control is caught by both. A static scan fixed INT_MIN negation in floor division, unbounded signed LOD shifts and coordinate-times-extent overflow in VoxelClipmapDesiredTiles.h and VoxelTypes.h (plugin and standalone), and VoxelWorld.cpp, VoxelContentManager.cpp and VoxelDensityVolume.cpp (plugin only, uncompiled here). Supported-input output unchanged (version 211). fast/medium/full PASS. Research R31 (self-checked): far-LOD 4-8 cut plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
2733 lines
132 KiB
C++
2733 lines
132 KiB
C++
// VoxelContentManager.cpp
|
||
// Distance-based world-grid decoration scatter with ASYNC surface marching (no LOD pop, no frame cost)
|
||
// + aesthetic water surfaces.
|
||
|
||
#include "VoxelContentManager.h"
|
||
#include "VoxelStrateManager.h"
|
||
#include "VoxelStrateDefinition.h"
|
||
#include "VoxelBiomeDefinition.h"
|
||
#include "VoxelGenerator.h"
|
||
#include "VoxelSettings.h"
|
||
#include "VoxelWorld.h"
|
||
#include "VoxelCaveMorphology.h" // VoxelHash
|
||
#include "Components/StaticMeshComponent.h"
|
||
#include "Components/HierarchicalInstancedStaticMeshComponent.h"
|
||
#include "Engine/StaticMesh.h"
|
||
#include "Engine/World.h"
|
||
#include "GameFramework/Actor.h"
|
||
#include "Materials/MaterialInterface.h"
|
||
#include "Tasks/Task.h"
|
||
#include "HAL/PlatformProcess.h"
|
||
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes
|
||
#include <VoxelForgeCore/VoxelForgeCore.h>
|
||
#include <cmath>
|
||
#include <utility>
|
||
#include <string>
|
||
|
||
struct FCoreSurfaceSnapshot
|
||
{
|
||
double CoreVoxelSizeCm = 2.0 * VOXEL_SIZE;
|
||
VoxelForgeCore::WorldAssemblyRecipeV1 Recipe;
|
||
TMap<int32, VoxelForgeCore::WorldAssemblyStratumPlanV1> Plans;
|
||
|
||
const VoxelForgeCore::WorldAssemblyStratumPlanV1* FindPlan(int32 StrateIndex) const
|
||
{
|
||
return Plans.Find(StrateIndex);
|
||
}
|
||
|
||
bool GetHeights(int32 StrateIndex, float CoreVoxelX, float CoreVoxelY,
|
||
float& OutGroundCoreVoxels, float& OutCapCoreVoxels) const
|
||
{
|
||
const auto* Plan = FindPlan(StrateIndex);
|
||
if (!Plan || !VoxelMath::IsFinite(CoreVoxelX) || !VoxelMath::IsFinite(CoreVoxelY))
|
||
{
|
||
return false;
|
||
}
|
||
constexpr double SubvoxelsPerCoreVoxel = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||
const double MaxInput = static_cast<double>(MAX_int64) / SubvoxelsPerCoreVoxel;
|
||
if (FMath::Abs(static_cast<double>(CoreVoxelX)) > MaxInput
|
||
|| FMath::Abs(static_cast<double>(CoreVoxelY)) > MaxInput)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const VoxelForgeCore::Heightfield2D Heightfield(Recipe, *Plan);
|
||
if (!Heightfield.isValid())
|
||
{
|
||
return false;
|
||
}
|
||
const int64 XSubvoxels = static_cast<int64>(std::llround(
|
||
static_cast<double>(CoreVoxelX) * SubvoxelsPerCoreVoxel));
|
||
const int64 YSubvoxels = static_cast<int64>(std::llround(
|
||
static_cast<double>(CoreVoxelY) * SubvoxelsPerCoreVoxel));
|
||
const auto Sample = Heightfield.point(
|
||
XSubvoxels, YSubvoxels, 0);
|
||
int64 SurfaceHeightSubvoxels = Sample.groundHeightSubvoxels;
|
||
int64 SearchTopSubvoxels = Sample.groundHeightSubvoxels;
|
||
for (const auto& Feature : Plan->features)
|
||
{
|
||
if ((Feature.type != 2 && Feature.type != 4)
|
||
|| XSubvoxels < Feature.minimumSubvoxels[0] - 4 * 256
|
||
|| XSubvoxels > Feature.maximumSubvoxels[0] + 4 * 256
|
||
|| YSubvoxels < Feature.minimumSubvoxels[1] - 4 * 256
|
||
|| YSubvoxels > Feature.maximumSubvoxels[1] + 4 * 256)
|
||
{
|
||
continue;
|
||
}
|
||
SearchTopSubvoxels = FMath::Max(
|
||
SearchTopSubvoxels, Feature.maximumSubvoxels[2]);
|
||
}
|
||
SearchTopSubvoxels = FMath::Min(
|
||
Sample.capHeightSubvoxels, SearchTopSubvoxels + 256);
|
||
if (SearchTopSubvoxels > Sample.groundHeightSubvoxels)
|
||
{
|
||
// Quarter Core voxel, bounded to relevant features. / Quart de voxel Core, borne aux volumes pertinents.
|
||
constexpr int64 ScanStepSubvoxels = 64;
|
||
int64 PreviousZ = SearchTopSubvoxels;
|
||
int64 PreviousDensity = Heightfield.point(XSubvoxels, YSubvoxels, PreviousZ).densitySubvoxels;
|
||
for (int64 Z = SearchTopSubvoxels - ScanStepSubvoxels;
|
||
Z >= Sample.groundHeightSubvoxels; Z -= ScanStepSubvoxels)
|
||
{
|
||
const int64 Density = Heightfield.point(XSubvoxels, YSubvoxels, Z).densitySubvoxels;
|
||
if (PreviousDensity >= 0 && Density < 0)
|
||
{
|
||
const double Denominator = static_cast<double>(PreviousDensity - Density);
|
||
const double Alpha = Denominator > 0.0
|
||
? static_cast<double>(PreviousDensity) / Denominator : 0.5;
|
||
SurfaceHeightSubvoxels = static_cast<int64>(std::llround(
|
||
static_cast<double>(PreviousZ) + (static_cast<double>(Z - PreviousZ) * Alpha)));
|
||
break;
|
||
}
|
||
PreviousZ = Z;
|
||
PreviousDensity = Density;
|
||
}
|
||
}
|
||
OutGroundCoreVoxels = static_cast<float>(SurfaceHeightSubvoxels / SubvoxelsPerCoreVoxel);
|
||
OutCapCoreVoxels = static_cast<float>(Sample.capHeightSubvoxels / SubvoxelsPerCoreVoxel);
|
||
return VoxelMath::IsFinite(OutGroundCoreVoxels) && VoxelMath::IsFinite(OutCapCoreVoxels);
|
||
}
|
||
|
||
bool GetWater(int32 StrateIndex, bool& OutHasPlan, bool& OutEnabled, float& OutLocalCm) const
|
||
{
|
||
OutHasPlan = false;
|
||
OutEnabled = false;
|
||
OutLocalCm = -FLT_MAX;
|
||
const auto* Plan = FindPlan(StrateIndex);
|
||
if (!Plan)
|
||
{
|
||
return false;
|
||
}
|
||
OutHasPlan = true;
|
||
OutEnabled = Plan->waterEnabled;
|
||
if (OutEnabled)
|
||
{
|
||
OutLocalCm = static_cast<float>(Plan->waterSurfaceSubvoxels
|
||
* CoreVoxelSizeCm / VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel);
|
||
}
|
||
return true;
|
||
}
|
||
};
|
||
|
||
struct FCoreFloatingIslandFieldSnapshot
|
||
{
|
||
VoxelForgeCore::FloatingIslandRecipeV1 Recipe;
|
||
VoxelForgeCore::FloatingIslandPlanV1 Plan;
|
||
TUniquePtr<VoxelForgeCore::FloatingIslandFieldV1> Field;
|
||
std::vector<VoxelForgeCore::WorldAssemblyFeatureBoundsV1> SolidSupportBounds;
|
||
};
|
||
|
||
struct FCoreFloatingIslandSnapshot
|
||
{
|
||
double CoreVoxelSizeCm = 2.0 * VOXEL_SIZE;
|
||
TMap<int32, TSharedPtr<const FCoreFloatingIslandFieldSnapshot, ESPMode::ThreadSafe>> Fields;
|
||
|
||
const FCoreFloatingIslandFieldSnapshot* FindField(int32 StrateIndex) const
|
||
{
|
||
const auto* Found = Fields.Find(StrateIndex);
|
||
return Found != nullptr && Found->IsValid() ? Found->Get() : nullptr;
|
||
}
|
||
|
||
bool GetDensity(int32 StrateIndex, float CoreVoxelX, float CoreVoxelY,
|
||
float CoreVoxelZ, float& OutDensity) const
|
||
{
|
||
OutDensity = 1.0f;
|
||
const FCoreFloatingIslandFieldSnapshot* Entry = FindField(StrateIndex);
|
||
if (Entry == nullptr || !Entry->Field.IsValid()
|
||
|| !FMath::IsFinite(CoreVoxelX) || !FMath::IsFinite(CoreVoxelY)
|
||
|| !FMath::IsFinite(CoreVoxelZ))
|
||
{
|
||
return false;
|
||
}
|
||
constexpr double SubvoxelsPerCoreVoxel = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||
const double MaximumInput = static_cast<double>(MAX_int64) / SubvoxelsPerCoreVoxel;
|
||
if (FMath::Abs(static_cast<double>(CoreVoxelX)) > MaximumInput
|
||
|| FMath::Abs(static_cast<double>(CoreVoxelY)) > MaximumInput
|
||
|| FMath::Abs(static_cast<double>(CoreVoxelZ)) > MaximumInput)
|
||
{
|
||
return false;
|
||
}
|
||
const int64 Density = Entry->Field->densitySubvoxels(
|
||
static_cast<int64>(std::llround(static_cast<double>(CoreVoxelX) * SubvoxelsPerCoreVoxel)),
|
||
static_cast<int64>(std::llround(static_cast<double>(CoreVoxelY) * SubvoxelsPerCoreVoxel)),
|
||
static_cast<int64>(std::llround(static_cast<double>(CoreVoxelZ) * SubvoxelsPerCoreVoxel)));
|
||
OutDensity = static_cast<float>(static_cast<double>(Density) / SubvoxelsPerCoreVoxel);
|
||
return FMath::IsFinite(OutDensity);
|
||
}
|
||
|
||
bool GetTopAnchor(int32 StrateIndex, const FVector2D& LocalXYCm,
|
||
float& OutGroundLocalZCm) const
|
||
{
|
||
OutGroundLocalZCm = 0.0f;
|
||
const FCoreFloatingIslandFieldSnapshot* Entry = FindField(StrateIndex);
|
||
if (Entry == nullptr || !Entry->Field.IsValid()
|
||
|| Entry->Plan.islands.empty()
|
||
|| !FMath::IsFinite(LocalXYCm.X) || !FMath::IsFinite(LocalXYCm.Y))
|
||
{
|
||
return false;
|
||
}
|
||
constexpr double SubvoxelsPerCoreVoxel = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||
const double CentimetersPerCoreVoxel = CoreVoxelSizeCm;
|
||
const double XSubvoxels = static_cast<double>(LocalXYCm.X)
|
||
* SubvoxelsPerCoreVoxel / CentimetersPerCoreVoxel;
|
||
const double YSubvoxels = static_cast<double>(LocalXYCm.Y)
|
||
* SubvoxelsPerCoreVoxel / CentimetersPerCoreVoxel;
|
||
constexpr double MaximumInput = static_cast<double>(MAX_int64) - 1024.0;
|
||
if (FMath::Abs(XSubvoxels) > MaximumInput || FMath::Abs(YSubvoxels) > MaximumInput)
|
||
{
|
||
return false;
|
||
}
|
||
const int64 X = static_cast<int64>(std::llround(XSubvoxels));
|
||
const int64 Y = static_cast<int64>(std::llround(YSubvoxels));
|
||
int64 SearchTop = Entry->Plan.islands.front().topZSubvoxels;
|
||
int64 SearchBottom = Entry->Plan.islands.front().bottomZSubvoxels;
|
||
for (const auto& Island : Entry->Plan.islands)
|
||
{
|
||
SearchTop = FMath::Max(SearchTop, Island.topZSubvoxels);
|
||
SearchBottom = FMath::Min(SearchBottom, Island.bottomZSubvoxels);
|
||
}
|
||
SearchTop += 4 * 256;
|
||
SearchBottom -= 4 * 256;
|
||
constexpr int64 ScanStepSubvoxels = 128;
|
||
int64 PreviousZ = SearchTop;
|
||
int64 PreviousDensity = Entry->Field->densitySubvoxels(X, Y, PreviousZ);
|
||
if (PreviousDensity < 0) return false;
|
||
for (int64 Z = SearchTop - ScanStepSubvoxels; Z >= SearchBottom; Z -= ScanStepSubvoxels)
|
||
{
|
||
const int64 Density = Entry->Field->densitySubvoxels(X, Y, Z);
|
||
if (PreviousDensity >= 0 && Density < 0)
|
||
{
|
||
const double Denominator = static_cast<double>(PreviousDensity - Density);
|
||
const double Alpha = Denominator > 0.0
|
||
? static_cast<double>(PreviousDensity) / Denominator : 0.5;
|
||
const double SurfaceSubvoxels = static_cast<double>(PreviousZ)
|
||
+ static_cast<double>(Z - PreviousZ) * Alpha;
|
||
OutGroundLocalZCm = static_cast<float>(SurfaceSubvoxels
|
||
* CentimetersPerCoreVoxel / SubvoxelsPerCoreVoxel);
|
||
return FMath::IsFinite(OutGroundLocalZCm);
|
||
}
|
||
PreviousZ = Z;
|
||
PreviousDensity = Density;
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Shared consumer preparation; snapshots own all pointers for the entire worker lifetime.
|
||
// Preparation partagee : les snapshots gardent les pointeurs vivants durant toute la tache.
|
||
static std::vector<VoxelForgeCore::WorldAssemblyFeatureQueryV1> VF_ContentFeatureQueries(
|
||
const FCoreSurfaceSnapshot* Surface, const FCoreFloatingIslandSnapshot* Islands,
|
||
int32 StrateIndex, float MinX, float MinY, float MinZ, float MaxX, float MaxY, float MaxZ)
|
||
{
|
||
using namespace VoxelForgeCore;
|
||
constexpr double Q = WorldAssemblySubvoxelsPerVoxel;
|
||
const std::array<int64,3> Min{static_cast<int64>(std::floor(MinX*Q)),
|
||
static_cast<int64>(std::floor(MinY*Q)),static_cast<int64>(std::floor(MinZ*Q))};
|
||
const std::array<int64,3> Max{static_cast<int64>(std::ceil(MaxX*Q)),
|
||
static_cast<int64>(std::ceil(MaxY*Q)),static_cast<int64>(std::ceil(MaxZ*Q))};
|
||
std::vector<WorldAssemblyFeatureQueryV1> Queries;
|
||
if (Surface)
|
||
if (const auto* Plan = Surface->FindPlan(StrateIndex))
|
||
{
|
||
const Heightfield2D Field(Surface->Recipe,*Plan);
|
||
Queries.push_back({Field.recipe(),Field.plan(),Field.buildFeatureTileList(Min,Max)});
|
||
}
|
||
if (Islands)
|
||
if (const auto* Entry = Islands->FindField(StrateIndex))
|
||
Queries.push_back({&Entry->Recipe,&Entry->Plan,Entry->Field->buildFeatureTileList(Min,Max)});
|
||
return Queries;
|
||
}
|
||
|
||
// Preserve the original march lattice and crossing refinement, skipping only certified air.
|
||
// Garder la phase du pas et le raffinement d'origine; sauter uniquement l'air prouve.
|
||
static bool VF_ConstrainContentMarch(std::span<const VoxelForgeCore::WorldAssemblyFeatureBoundsV1> Bounds,
|
||
float X, float Y, float Step, float& Top, float& Bottom)
|
||
{
|
||
constexpr double Q = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||
const auto Range = VoxelForgeCore::BuildWorldAssemblyFeatureColumnRangeV1(Bounds,
|
||
static_cast<int64>(std::llround(X*Q)),static_cast<int64>(std::llround(Y*Q)));
|
||
if (!Range.known) return true;
|
||
if (Range.empty) return false;
|
||
const float SupportTop = static_cast<float>(Range.maximumZSubvoxels/Q);
|
||
const float SupportBottom = static_cast<float>(Range.minimumZSubvoxels/Q);
|
||
const float SkipSteps = FMath::Max(0.0f,FMath::FloorToFloat((Top-SupportTop)/Step)-1.0f);
|
||
Top -= SkipSteps*Step;
|
||
Bottom = FMath::Max(Bottom,SupportBottom-Step);
|
||
return Top >= Bottom;
|
||
}
|
||
|
||
// Global safety cap on spawned decoration ACTORS per cell (across all entries).
|
||
// HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk.
|
||
static constexpr int32 GMaxDecorationActorsPerCell = 400;
|
||
|
||
// Hard cap on total companion satellites (level 1 + level 2) spawned per placed PARENT — the safety net that
|
||
// makes 2-level nesting impossible to blow up regardless of authored counts.
|
||
static constexpr int32 GMaxCompanionsPerParent = 256;
|
||
|
||
// One cell = one chunk XY footprint (so DecorationRadiusChunks reads as a radius in chunks, and a
|
||
// decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
|
||
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE;
|
||
|
||
static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B)
|
||
{
|
||
return FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y));
|
||
}
|
||
|
||
static bool VF_DecoTransformLess(const FTransform& A, const FTransform& B)
|
||
{
|
||
// A transform is the complete deterministic identity of a placement. Comparing the matrix
|
||
// avoids the q/-q representation ambiguity of raw quaternions and gives the final region order
|
||
// the same result regardless of which worker cell completed first.
|
||
const FMatrix AM = A.ToMatrixWithScale();
|
||
const FMatrix BM = B.ToMatrixWithScale();
|
||
for (int32 Row = 0; Row < 4; ++Row)
|
||
{
|
||
for (int32 Col = 0; Col < 4; ++Col)
|
||
{
|
||
if (AM.M[Row][Col] != BM.M[Row][Col])
|
||
{
|
||
return AM.M[Row][Col] < BM.M[Row][Col];
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
|
||
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed)
|
||
{
|
||
Owner = InOwner;
|
||
StrateManager = InStrateManager;
|
||
Generator = InGenerator;
|
||
Settings = InSettings;
|
||
Seed = InSeed;
|
||
NativeSurfaceSnapshot.Reset();
|
||
NativeFloatingIslandSnapshot.Reset();
|
||
|
||
if (Settings)
|
||
{
|
||
FString ManifestJson, ManifestHash, ManifestError;
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(Owner.Get());
|
||
const bool bHasRuntimeAssembly = VoxelWorld != nullptr
|
||
&& VoxelWorld->GetVoxelForgeCoreWorldAssembly(ManifestJson, ManifestHash);
|
||
if (!bHasRuntimeAssembly)
|
||
{
|
||
Settings->GetWorldAssemblyManifestJson(ManifestJson, ManifestHash, ManifestError);
|
||
}
|
||
if (!ManifestJson.IsEmpty())
|
||
{
|
||
FTCHARToUTF8 Utf8Json(*ManifestJson);
|
||
std::string CoreError;
|
||
const std::optional<VoxelForgeCore::WorldAssemblyManifest> Manifest =
|
||
VoxelForgeCore::WorldAssemblyManifest::FromJson(
|
||
std::string_view(Utf8Json.Get(), static_cast<std::size_t>(Utf8Json.Length())), CoreError);
|
||
if (Manifest)
|
||
{
|
||
TSharedPtr<FCoreSurfaceSnapshot, ESPMode::ThreadSafe> Snapshot =
|
||
MakeShared<FCoreSurfaceSnapshot, ESPMode::ThreadSafe>();
|
||
Snapshot->CoreVoxelSizeCm = VoxelWorld != nullptr
|
||
? VoxelWorld->GetActiveVoxelSizeCm() : 2.0 * VOXEL_SIZE;
|
||
Snapshot->Recipe = Manifest->recipe;
|
||
for (const auto& Plan : Manifest->plan.strates)
|
||
{
|
||
Snapshot->Plans.Add(Plan.strateIndex, Plan);
|
||
}
|
||
NativeSurfaceSnapshot = Snapshot;
|
||
|
||
if (!Manifest->floatingIslands.empty())
|
||
{
|
||
TSharedPtr<FCoreFloatingIslandSnapshot, ESPMode::ThreadSafe> IslandSnapshot =
|
||
MakeShared<FCoreFloatingIslandSnapshot, ESPMode::ThreadSafe>();
|
||
IslandSnapshot->CoreVoxelSizeCm = VoxelWorld != nullptr
|
||
? VoxelWorld->GetActiveVoxelSizeCm() : 2.0 * VOXEL_SIZE;
|
||
for (const auto& IslandPlan : Manifest->floatingIslands)
|
||
{
|
||
TSharedPtr<FCoreFloatingIslandFieldSnapshot, ESPMode::ThreadSafe> Entry =
|
||
MakeShared<FCoreFloatingIslandFieldSnapshot, ESPMode::ThreadSafe>();
|
||
Entry->Recipe = IslandPlan.manifest.recipe;
|
||
Entry->Plan = IslandPlan.manifest.plan;
|
||
Entry->Field = MakeUnique<VoxelForgeCore::FloatingIslandFieldV1>(
|
||
Entry->Recipe, Entry->Plan);
|
||
if (!Entry->Field->isValid())
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelContentManager] Core floating-island field rejected strate %d."),
|
||
IslandPlan.strateIndex);
|
||
continue;
|
||
}
|
||
Entry->SolidSupportBounds = Entry->Field->solidSupportBounds();
|
||
IslandSnapshot->Fields.Add(IslandPlan.strateIndex, Entry);
|
||
}
|
||
if (!IslandSnapshot->Fields.IsEmpty())
|
||
{
|
||
NativeFloatingIslandSnapshot = IslandSnapshot;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelContentManager] native surface snapshot rejected: %s"),
|
||
UTF8_TO_TCHAR(CoreError.c_str()));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!PlaneMesh)
|
||
{
|
||
PlaneMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Plane.Plane"));
|
||
}
|
||
}
|
||
|
||
bool UVoxelContentManager::QueryNativeSurfaceAnchors(
|
||
int32 StrateIndex, const FVector2D& LocalXYCm, float& OutGroundLocalZCm,
|
||
bool& bOutHasWater, float& OutWaterLocalZCm) const
|
||
{
|
||
OutGroundLocalZCm = 0.0f;
|
||
bOutHasWater = false;
|
||
OutWaterLocalZCm = -FLT_MAX;
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(Owner.Get());
|
||
if (VoxelWorld == nullptr || !VoxelWorld->bUseVoxelForgeCore
|
||
|| !NativeSurfaceSnapshot.IsValid())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const float VoxelSizeCm = VoxelWorld->GetActiveVoxelSizeCm();
|
||
float GroundCoreVoxels = 0.0f;
|
||
float CapCoreVoxels = 0.0f;
|
||
if (!NativeSurfaceSnapshot->GetHeights(StrateIndex,
|
||
LocalXYCm.X / VoxelSizeCm, LocalXYCm.Y / VoxelSizeCm,
|
||
GroundCoreVoxels, CapCoreVoxels))
|
||
{
|
||
return false;
|
||
}
|
||
OutGroundLocalZCm = GroundCoreVoxels * VoxelSizeCm;
|
||
bool bHasPlan = false;
|
||
if (!NativeSurfaceSnapshot->GetWater(StrateIndex, bHasPlan,
|
||
bOutHasWater, OutWaterLocalZCm)
|
||
|| !bHasPlan)
|
||
{
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool UVoxelContentManager::QueryNativeFloatingIslandAnchor(
|
||
int32 StrateIndex, const FVector2D& LocalXYCm, float& OutGroundLocalZCm) const
|
||
{
|
||
OutGroundLocalZCm = 0.0f;
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(Owner.Get());
|
||
if (VoxelWorld == nullptr || !VoxelWorld->bUseVoxelForgeCore
|
||
|| !NativeFloatingIslandSnapshot.IsValid())
|
||
{
|
||
return false;
|
||
}
|
||
return NativeFloatingIslandSnapshot->GetTopAnchor(
|
||
StrateIndex, LocalXYCm, OutGroundLocalZCm);
|
||
}
|
||
|
||
bool UVoxelContentManager::QueryStrateWaterAnchor(
|
||
int32 StrateIndex, bool& bOutHasWater, float& OutWaterLocalZCm) const
|
||
{
|
||
bOutHasWater = false;
|
||
OutWaterLocalZCm = -FLT_MAX;
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(Owner.Get());
|
||
if (VoxelWorld == nullptr || StrateManager == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (VoxelWorld->bUseVoxelForgeCore && NativeSurfaceSnapshot.IsValid())
|
||
{
|
||
bool bHasPlan = false;
|
||
bool bEnabled = false;
|
||
float NativeWaterLocalZCm = -FLT_MAX;
|
||
if (NativeSurfaceSnapshot->GetWater(
|
||
StrateIndex, bHasPlan, bEnabled, NativeWaterLocalZCm) && bHasPlan)
|
||
{
|
||
bOutHasWater = bEnabled;
|
||
OutWaterLocalZCm = bEnabled ? NativeWaterLocalZCm : -FLT_MAX;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
for (const FStrateSlot& Slot : StrateManager->GetLayout())
|
||
{
|
||
if (Slot.StrateIndex != StrateIndex)
|
||
{
|
||
continue;
|
||
}
|
||
const int32 MidChunkZ = Slot.BottomChunkZ
|
||
+ (Slot.TopChunkZ - Slot.BottomChunkZ) / 2;
|
||
const float ManagerWaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(
|
||
FIntVector(0, 0, MidChunkZ));
|
||
if (ManagerWaterVoxelZ != -FLT_MAX)
|
||
{
|
||
bOutHasWater = true;
|
||
const float ManagerWaterLocalZCm = ManagerWaterVoxelZ * VOXEL_SIZE;
|
||
OutWaterLocalZCm = VoxelWorld->FromStrateManagerLocalZ(ManagerWaterLocalZCm);
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void UVoxelContentManager::BeginDestroy()
|
||
{
|
||
// Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained.
|
||
bShuttingDown.store(true, std::memory_order_release);
|
||
JoinDecorationTasks();
|
||
Super::BeginDestroy();
|
||
}
|
||
|
||
void UVoxelContentManager::NotifyShutdown()
|
||
{
|
||
bShuttingDown.store(true, std::memory_order_release);
|
||
|
||
// A timeout is safe for the editor's pre-mutation probe, but not for UObject destruction:
|
||
// join this manager's tasks fully before dropping its worker-captured `this`.
|
||
JoinDecorationTasks();
|
||
|
||
DrainDecoResults();
|
||
ResetGridBuildState(NearGrid);
|
||
ResetGridBuildState(FarGrid);
|
||
}
|
||
|
||
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
|
||
{
|
||
while (ActiveDecorationTasks.load(std::memory_order_relaxed) > 0)
|
||
{
|
||
if (FPlatformTime::Seconds() > Deadline) return false;
|
||
FPlatformProcess::Yield();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void UVoxelContentManager::JoinDecorationTasks()
|
||
{
|
||
while (ActiveDecorationTasks.load(std::memory_order_acquire) > 0)
|
||
{
|
||
FPlatformProcess::Yield();
|
||
}
|
||
}
|
||
|
||
void UVoxelContentManager::DrainDecoResults()
|
||
{
|
||
FDecoCellResult Discard;
|
||
while (DecoResults.Dequeue(Discard)) {}
|
||
}
|
||
|
||
void UVoxelContentManager::ResetGridBuildState(FDecoGrid& G)
|
||
{
|
||
G.Builds.Reset();
|
||
G.Completed.Reset();
|
||
G.PendingLaunch.Reset();
|
||
G.InFlightCells.Reset();
|
||
}
|
||
|
||
//=============================================================================
|
||
// Cell ↔ region helpers (region = RxR cells). Floor-division tiles across the origin (negative cells).
|
||
//=============================================================================
|
||
|
||
int32 UVoxelContentManager::RegionSize() const
|
||
{
|
||
return FMath::Max(1, Settings ? Settings->DecorationRegionSizeCells : 4);
|
||
}
|
||
|
||
// TRUE floor division (rounds toward -infinity). DO NOT use FMath::DivideAndRoundDown for cell↔region
|
||
// mapping: it is `A / B`, which TRUNCATES toward zero for negatives (a UE naming footgun). Cells are
|
||
// assigned to a region by the exact inverse `region*R + offset`, so the mapping back MUST floor — with
|
||
// truncation, a negative-coord cell is queued under one region but routed back to another → its march
|
||
// result is dropped → blank chunk. Truncation == floor for A>=0 and for R==1, which is exactly why only
|
||
// NEGATIVE coordinates at R>=2 were affected. R (region size) is always > 0 here.
|
||
static FORCEINLINE int32 FloorDivPos(int64 A, int32 R)
|
||
{
|
||
if (R <= 0) { return 0; }
|
||
const int64 Quotient = A / R;
|
||
const int64 Remainder = A % R;
|
||
return static_cast<int32>(FMath::Clamp<int64>(
|
||
Remainder < 0 ? Quotient - 1 : Quotient, MIN_int32, MAX_int32));
|
||
}
|
||
|
||
static FORCEINLINE FIntPoint CellToRegion(const FIntPoint& Cell, int32 R)
|
||
{
|
||
return FIntPoint(FloorDivPos(Cell.X, R), FloorDivPos(Cell.Y, R));
|
||
}
|
||
|
||
// Region is desired iff its cell footprint intersects the radius-FarR cell box around the player.
|
||
static FORCEINLINE bool IsRegionDesired(const FIntPoint& Region, const FIntPoint& PlayerCell,
|
||
int32 FarR, int32 R)
|
||
{
|
||
const int32 MinX = FloorDivPos(static_cast<int64>(PlayerCell.X) - FarR, R);
|
||
const int32 MaxX = FloorDivPos(static_cast<int64>(PlayerCell.X) + FarR, R);
|
||
const int32 MinY = FloorDivPos(static_cast<int64>(PlayerCell.Y) - FarR, R);
|
||
const int32 MaxY = FloorDivPos(static_cast<int64>(PlayerCell.Y) + FarR, R);
|
||
return Region.X >= MinX && Region.X <= MaxX && Region.Y >= MinY && Region.Y <= MaxY;
|
||
}
|
||
|
||
//=============================================================================
|
||
// DECORATIONS — distance-based world grid (async march)
|
||
//=============================================================================
|
||
|
||
// Deterministic placement hash. Pure function of cell, column, crossing, entry, seed.
|
||
static FORCEINLINE uint32 DecoHash(int32 CX, int32 CY, int32 GX, int32 GY,
|
||
int32 Crossing, int32 Entry, uint32 Seed, uint32 Salt)
|
||
{
|
||
uint32 H = VoxelHash::Cell(CX, CY, Seed ^ Salt);
|
||
H = VoxelHash::Mix(H ^ ((uint32)GX * 73856093u) ^ ((uint32)GY * 19349663u));
|
||
H = VoxelHash::Mix(H ^ ((uint32)Crossing * 83492791u + 0x9E3779B1u));
|
||
H = VoxelHash::Mix(H ^ ((uint32)Entry * 2654435761u + 40503u));
|
||
return VoxelHash::Mix(H);
|
||
}
|
||
|
||
void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
||
{
|
||
if (!StrateManager || !Generator || !Settings) return;
|
||
AActor* OwnerActor = Owner.Get();
|
||
if (!OwnerActor) return;
|
||
|
||
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
||
// All strate/water/density queries are in actor-LOCAL space; Core uses the actor's active voxel step.
|
||
// Les requetes de strate/eau/densite sont locales a l'acteur; Core utilise son pas de voxel actif.
|
||
const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos);
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(OwnerActor);
|
||
const float ManagerLocalZ = VoxelWorld
|
||
? VoxelWorld->ToStrateManagerLocalZ(LocalPlayer.Z) : LocalPlayer.Z;
|
||
|
||
// Player's strate band (actor-local cm). No strate (inter-strate gap / outside world) ⇒ no decos.
|
||
float TopZ, BotZ;
|
||
const bool bInStrate = StrateManager->GetStrateUnrealZRange(ManagerLocalZ, TopZ, BotZ);
|
||
const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(ManagerLocalZ) : INT32_MIN;
|
||
|
||
if (!bInStrate)
|
||
{
|
||
ClearAllDecorations();
|
||
LastStrateIndex = INT32_MIN;
|
||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||
return;
|
||
}
|
||
|
||
const float VoxelSizeCm = VoxelWorld ? VoxelWorld->GetActiveVoxelSizeCm() : VOXEL_SIZE;
|
||
const float CellWorld = static_cast<float>(DECO_CELL_VOXELS) * VoxelSizeCm;
|
||
const FIntPoint PlayerCell(
|
||
FMath::FloorToInt(LocalPlayer.X / CellWorld),
|
||
FMath::FloorToInt(LocalPlayer.Y / CellWorld));
|
||
|
||
// Shared per-update strate context (a strate is a horizontal slab → same for every cell).
|
||
CurrentCtx = FDecoContext();
|
||
CurrentCtx.StrateIndex = StrateIndex;
|
||
CurrentCtx.VoxelSizeCm = VoxelSizeCm;
|
||
CurrentCtx.ActiveToOwnerVoxelScale = VoxelSizeCm / VOXEL_SIZE;
|
||
CurrentCtx.TopVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(TopZ) : TopZ) / VoxelSizeCm;
|
||
CurrentCtx.BottomVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(BotZ) : BotZ) / VoxelSizeCm;
|
||
CurrentCtx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||
const FIntVector RepChunk(PlayerCell.X, PlayerCell.Y, CurrentCtx.RepChunkZ);
|
||
CurrentCtx.Def = StrateManager->GetStrateForChunk(RepChunk);
|
||
CurrentCtx.bSurfaceWorld =
|
||
(StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld);
|
||
CurrentCtx.NativeSurface = NativeSurfaceSnapshot;
|
||
CurrentCtx.bNativeSurface = VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore
|
||
&& CurrentCtx.bSurfaceWorld
|
||
&& CurrentCtx.NativeSurface.IsValid()
|
||
&& CurrentCtx.NativeSurface->FindPlan(StrateIndex) != nullptr;
|
||
CurrentCtx.NativeIslands = NativeFloatingIslandSnapshot;
|
||
CurrentCtx.bNativeFloatingIslands = VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore
|
||
&& StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::FloatingIslands
|
||
&& CurrentCtx.NativeIslands.IsValid()
|
||
&& CurrentCtx.NativeIslands->FindField(StrateIndex) != nullptr;
|
||
{
|
||
bool bHasNativePlan = false;
|
||
bool bNativeWaterEnabled = false;
|
||
float NativeWaterLocalZ = -FLT_MAX;
|
||
if (CurrentCtx.bNativeSurface
|
||
&& CurrentCtx.NativeSurface->GetWater(StrateIndex, bHasNativePlan,
|
||
bNativeWaterEnabled, NativeWaterLocalZ)
|
||
&& bHasNativePlan)
|
||
{
|
||
CurrentCtx.bHasWater = bNativeWaterEnabled;
|
||
CurrentCtx.WaterLocalZ = bNativeWaterEnabled ? NativeWaterLocalZ : -FLT_MAX;
|
||
}
|
||
else
|
||
{
|
||
const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk);
|
||
CurrentCtx.bHasWater = (Wv != -FLT_MAX);
|
||
CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater
|
||
? (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(Wv * VOXEL_SIZE) : Wv * VOXEL_SIZE)
|
||
: -FLT_MAX;
|
||
}
|
||
}
|
||
// Strate biome field (XY-global → resolved once; the worker picks the dominant biome per COLUMN).
|
||
CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
|
||
|
||
// Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read
|
||
// every frame so live edits to the data asset take effect; the grids themselves persist across updates.
|
||
// A stationary player still needs a rebuild when one of these values changes: otherwise already
|
||
// loaded regions remain outside the new radius (or retain the old sampling density) forever.
|
||
const int32 NewNearRadius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
|
||
const int32 NewFarRadius = FMath::Max(1, Settings->DecorationRadiusChunks);
|
||
const int32 NewNearSpacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
||
const int32 NewFarSpacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
|
||
const int32 NewRegionSize = RegionSize();
|
||
const bool bDecorationGridConfigChanged =
|
||
NearGrid.Radius != NewNearRadius || FarGrid.Radius != NewFarRadius
|
||
|| NearGrid.Spacing != NewNearSpacing || FarGrid.Spacing != NewFarSpacing
|
||
|| LastRegionSize != NewRegionSize;
|
||
|
||
NearGrid.Tier = EDecoStreamTier::Near;
|
||
NearGrid.Radius = NewNearRadius;
|
||
NearGrid.Spacing = NewNearSpacing;
|
||
FarGrid.Tier = EDecoStreamTier::Far;
|
||
FarGrid.Radius = NewFarRadius;
|
||
FarGrid.Spacing = NewFarSpacing;
|
||
|
||
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every
|
||
// biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome
|
||
// and rolls only the entries it owns → borders follow the warped-Voronoi field, not the 8 m cell grid
|
||
// (Task 1, §8.5). Without biomes, fall back to the strate's single list tagged -1 (always matches). Each
|
||
// entry routes to NearGrid/FarGrid by its StreamTier, so each grid marches only its own subset.
|
||
NearGrid.Entries.Reset(); NearGrid.EntryBiome.Reset();
|
||
FarGrid.Entries.Reset(); FarGrid.EntryBiome.Reset();
|
||
auto AddEntry = [&](const FStrateDecoration& D, int32 ci)
|
||
{
|
||
FDecoGrid& G = (D.StreamTier == EDecoStreamTier::Near) ? NearGrid : FarGrid;
|
||
G.Entries.Add(D);
|
||
G.EntryBiome.Add(ci);
|
||
};
|
||
if (CurrentCtx.Def)
|
||
{
|
||
if (CurrentCtx.BiomeCtx.IsValid())
|
||
{
|
||
for (int32 ci = 0; ci < CurrentCtx.BiomeCtx.Biomes.Num(); ++ci)
|
||
{
|
||
const int32 StrateBiomeIdx = CurrentCtx.BiomeCtx.Biomes[ci].Index;
|
||
const UVoxelBiomeDefinition* Bio =
|
||
CurrentCtx.Def->Biomes.IsValidIndex(StrateBiomeIdx) ? CurrentCtx.Def->Biomes[StrateBiomeIdx] : nullptr;
|
||
// A biome with no decorations inherits the strate's list (still tagged with THIS biome's
|
||
// index so it only fires inside that biome's columns — no cross-biome bleed).
|
||
const TArray<FStrateDecoration>& Src =
|
||
(Bio && Bio->Decorations.Num() > 0) ? Bio->Decorations : CurrentCtx.Def->Decorations;
|
||
for (const FStrateDecoration& D : Src) { AddEntry(D, ci); }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations) { AddEntry(D, -1); } // -1 → matches ColBiome -1
|
||
}
|
||
}
|
||
|
||
// Strate or grid configuration change → wipe + force a full rebuild. This also invalidates
|
||
// every old region build before the next stationary-player update can reuse it.
|
||
if (StrateIndex != LastStrateIndex || bDecorationGridConfigChanged)
|
||
{
|
||
ClearAllDecorations();
|
||
LastStrateIndex = StrateIndex;
|
||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||
}
|
||
LastNearRadius = NewNearRadius;
|
||
LastFarRadius = NewFarRadius;
|
||
LastNearSpacing = NewNearSpacing;
|
||
LastFarSpacing = NewFarSpacing;
|
||
LastRegionSize = NewRegionSize;
|
||
|
||
if (PlayerCell != LastDecoCell)
|
||
{
|
||
RebuildDesiredCells(NearGrid, PlayerCell);
|
||
RebuildDesiredCells(FarGrid, PlayerCell);
|
||
LastDecoCell = PlayerCell;
|
||
}
|
||
|
||
// Both grids share ONE concurrency budget; throttle each against the other's current in-flight count.
|
||
const int32 MaxConc = Settings->MaxConcurrentDecorationTasks;
|
||
LaunchDecoTasks(NearGrid, PlayerCell, FarGrid.InFlightCells.Num(), MaxConc);
|
||
LaunchDecoTasks(FarGrid, PlayerCell, NearGrid.InFlightCells.Num(), MaxConc);
|
||
ProcessDecoResults(PlayerCell);
|
||
}
|
||
|
||
bool UVoxelContentManager::MeasurePlacementCostForValidation(
|
||
const FVector& PlayerWorldPos, int32 SampleCellCount, int32 FrameSampleCount,
|
||
FDecorationPlacementCostAudit& OutAudit, FString& OutError)
|
||
{
|
||
OutAudit = FDecorationPlacementCostAudit();
|
||
OutError.Reset();
|
||
if (!Settings || !Generator || !StrateManager || !Owner.IsValid()
|
||
|| SampleCellCount < 1 || SampleCellCount > 256
|
||
|| FrameSampleCount < 1 || FrameSampleCount > 256)
|
||
{
|
||
OutError = TEXT("Decoration cost audit requires an initialized manager and bounded sample counts.");
|
||
return false;
|
||
}
|
||
if (!WaitForDecorationTasks(FPlatformTime::Seconds() + 10.0))
|
||
{
|
||
OutError = TEXT("Decoration cost audit could not drain prior worker tasks.");
|
||
return false;
|
||
}
|
||
|
||
struct FRestoreDecorationConcurrency
|
||
{
|
||
UVoxelSettings* Settings = nullptr;
|
||
int32 Original = 0;
|
||
~FRestoreDecorationConcurrency()
|
||
{
|
||
if (Settings != nullptr) Settings->MaxConcurrentDecorationTasks = Original;
|
||
}
|
||
} Restore{Settings, Settings->MaxConcurrentDecorationTasks};
|
||
|
||
// Resolve the current route and palette once, while preventing test work from reaching HISMs.
|
||
Settings->MaxConcurrentDecorationTasks = 0;
|
||
UpdateDecorations(PlayerWorldPos);
|
||
if (CurrentCtx.Def == nullptr)
|
||
{
|
||
OutError = TEXT("Decoration cost audit position is outside the resolved strate layout.");
|
||
return false;
|
||
}
|
||
OutAudit.bNativeSurface = CurrentCtx.bNativeSurface;
|
||
OutAudit.bNativeFloatingIslands = CurrentCtx.bNativeFloatingIslands;
|
||
OutAudit.NearEntries = NearGrid.Entries.Num();
|
||
OutAudit.FarEntries = FarGrid.Entries.Num();
|
||
|
||
auto MakeAuditEntries = [&](const FDecoGrid& Grid, TArray<FStrateDecoration>& Entries,
|
||
TArray<int32>& EntryBiome) -> bool
|
||
{
|
||
if (!Grid.Entries.IsEmpty())
|
||
{
|
||
Entries = Grid.Entries;
|
||
EntryBiome = Grid.EntryBiome;
|
||
return false;
|
||
}
|
||
|
||
OutAudit.bSyntheticWorkload = true;
|
||
const int32 BiomeCount = CurrentCtx.BiomeCtx.IsValid()
|
||
? CurrentCtx.BiomeCtx.Biomes.Num() : 0;
|
||
const int32 EntryCount = FMath::Max(1, BiomeCount);
|
||
Entries.Reserve(EntryCount);
|
||
EntryBiome.Reserve(EntryCount);
|
||
for (int32 Index = 0; Index < EntryCount; ++Index)
|
||
{
|
||
FStrateDecoration Entry;
|
||
Entry.Profile.ActorClass = AActor::StaticClass();
|
||
Entry.Profile.SurfacePlacement = ESurfaceType::Floor;
|
||
Entry.SpawnDensity = 1.0f;
|
||
Entry.MaxPerChunk = 4;
|
||
Entries.Add(MoveTemp(Entry));
|
||
EntryBiome.Add(BiomeCount > 0 ? Index : -1);
|
||
}
|
||
return true;
|
||
};
|
||
|
||
TArray<FStrateDecoration> NearEntriesAudit, FarEntriesAudit;
|
||
TArray<int32> NearEntryBiomeAudit, FarEntryBiomeAudit;
|
||
const bool bNearSynthetic = MakeAuditEntries(NearGrid, NearEntriesAudit, NearEntryBiomeAudit);
|
||
const bool bFarSynthetic = MakeAuditEntries(FarGrid, FarEntriesAudit, FarEntryBiomeAudit);
|
||
|
||
AActor* OwnerActor = Owner.Get();
|
||
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
||
const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos);
|
||
const float CellWorldCm = static_cast<float>(DECO_CELL_VOXELS) * CurrentCtx.VoxelSizeCm;
|
||
const FIntPoint PlayerCell(FMath::FloorToInt(LocalPlayer.X / CellWorldCm),
|
||
FMath::FloorToInt(LocalPlayer.Y / CellWorldCm));
|
||
const int32 GridSide = FMath::CeilToInt(FMath::Sqrt(static_cast<float>(SampleCellCount)));
|
||
const int32 Step = FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||
const int32 MaxCrossings = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
||
const float ColumnDepth = static_cast<float>(FMath::Max(8, Settings->DecorationColumnDepthVoxels));
|
||
TArray<double> CellMilliseconds, FullScanCellMilliseconds;
|
||
CellMilliseconds.Reserve(SampleCellCount);
|
||
int64 TotalColumns = 0;
|
||
int64 TotalDensityQueries = 0;
|
||
int64 TotalSurfaceQueries = 0;
|
||
|
||
for (int32 Sample = 0; Sample < SampleCellCount; ++Sample)
|
||
{
|
||
const int32 X = Sample % GridSide - GridSide / 2;
|
||
const int32 Y = Sample / GridSide - GridSide / 2;
|
||
const FIntPoint Cell(PlayerCell.X + X, PlayerCell.Y + Y);
|
||
double CellElapsed = 0.0, FullScanCellElapsed = 0.0;
|
||
for (const auto& GridAudit : {
|
||
std::make_pair(&NearGrid, &NearEntriesAudit),
|
||
std::make_pair(&FarGrid, &FarEntriesAudit)})
|
||
{
|
||
const FDecoGrid& Grid = *GridAudit.first;
|
||
const TArray<FStrateDecoration>& Entries = *GridAudit.second;
|
||
const TArray<int32>& EntryBiome = Grid.Tier == EDecoStreamTier::Near
|
||
? NearEntryBiomeAudit : FarEntryBiomeAudit;
|
||
if (Entries.IsEmpty()) continue;
|
||
TArray<FDecoSpawn> Spawns;
|
||
FPlacementBuildCounters Counters;
|
||
TArray<FDecoSpawn> FullScanSpawns;
|
||
const double FullStart = FPlatformTime::Seconds();
|
||
BuildCellSpawns(Generator, OwnerXf, Cell, CurrentCtx, Entries, EntryBiome,
|
||
static_cast<uint32>(Seed), Grid.Spacing, static_cast<float>(Step),
|
||
MaxCrossings, ColumnDepth, FullScanSpawns, nullptr, false);
|
||
FullScanCellElapsed += (FPlatformTime::Seconds()-FullStart)*1000.0;
|
||
const double CandidateStart = FPlatformTime::Seconds();
|
||
BuildCellSpawns(Generator, OwnerXf, Cell, CurrentCtx, Entries, EntryBiome,
|
||
static_cast<uint32>(Seed), Grid.Spacing, static_cast<float>(Step),
|
||
MaxCrossings, ColumnDepth, Spawns, &Counters);
|
||
CellElapsed += (FPlatformTime::Seconds()-CandidateStart)*1000.0;
|
||
if (Spawns.Num()!=FullScanSpawns.Num())
|
||
{ OutError=TEXT("Feature-query placement count changed from full scan."); return false; }
|
||
for (int32 Index=0;Index<Spawns.Num();++Index)
|
||
{
|
||
if (Spawns[Index].EntryIdx!=FullScanSpawns[Index].EntryIdx
|
||
|| !Spawns[Index].Xf.Equals(FullScanSpawns[Index].Xf,0.0f))
|
||
{ OutError=TEXT("Feature-query placement transform changed from full scan."); return false; }
|
||
++OutAudit.ExactPlacementComparisons;
|
||
}
|
||
TotalColumns += Counters.Columns;
|
||
TotalDensityQueries += Counters.DensityQueries;
|
||
TotalSurfaceQueries += Counters.SurfaceQueries;
|
||
OutAudit.Placements += Spawns.Num();
|
||
|
||
const bool bSyntheticGrid = Grid.Tier == EDecoStreamTier::Near
|
||
? bNearSynthetic : bFarSynthetic;
|
||
if (bSyntheticGrid)
|
||
{
|
||
for (const FDecoSpawn& Spawn : Spawns)
|
||
{
|
||
const FVector LocalSpawn = OwnerXf.InverseTransformPosition(Spawn.Xf.GetLocation());
|
||
if (CurrentCtx.bNativeSurface && CurrentCtx.NativeSurface.IsValid())
|
||
{
|
||
float GroundZ = 0.0f;
|
||
bool bHasWater = false;
|
||
float WaterZ = -FLT_MAX;
|
||
if (QueryNativeSurfaceAnchors(CurrentCtx.StrateIndex,
|
||
FVector2D(LocalSpawn.X, LocalSpawn.Y), GroundZ, bHasWater, WaterZ))
|
||
{
|
||
OutAudit.MaximumAnchorErrorCm = FMath::Max(
|
||
OutAudit.MaximumAnchorErrorCm,
|
||
FMath::Abs(static_cast<double>(LocalSpawn.Z - GroundZ)));
|
||
++OutAudit.AnchorChecks;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
float Density = 1.0f;
|
||
const bool bSampled = CurrentCtx.bNativeFloatingIslands
|
||
&& CurrentCtx.NativeIslands.IsValid()
|
||
? CurrentCtx.NativeIslands->GetDensity(CurrentCtx.StrateIndex,
|
||
LocalSpawn.X / CurrentCtx.VoxelSizeCm,
|
||
LocalSpawn.Y / CurrentCtx.VoxelSizeCm,
|
||
LocalSpawn.Z / CurrentCtx.VoxelSizeCm, Density)
|
||
: (Density = Generator->GetDensityAt(
|
||
LocalSpawn.X / CurrentCtx.VoxelSizeCm * CurrentCtx.ActiveToOwnerVoxelScale,
|
||
LocalSpawn.Y / CurrentCtx.VoxelSizeCm * CurrentCtx.ActiveToOwnerVoxelScale,
|
||
LocalSpawn.Z / CurrentCtx.VoxelSizeCm * CurrentCtx.ActiveToOwnerVoxelScale), true);
|
||
if (bSampled)
|
||
{
|
||
OutAudit.MaximumDensityResidual = FMath::Max(
|
||
OutAudit.MaximumDensityResidual,
|
||
FMath::Abs(static_cast<double>(Density)));
|
||
++OutAudit.AnchorChecks;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
CellMilliseconds.Add(CellElapsed);
|
||
FullScanCellMilliseconds.Add(FullScanCellElapsed);
|
||
OutAudit.ActiveBatchMilliseconds += CellElapsed;
|
||
OutAudit.FullScanActiveBatchMilliseconds += FullScanCellElapsed;
|
||
}
|
||
|
||
auto MedianAndP95 = [](TArray<double> Samples, double& OutMedian, double& OutP95)
|
||
{
|
||
Samples.Sort();
|
||
if (Samples.IsEmpty())
|
||
{
|
||
OutMedian = 0.0;
|
||
OutP95 = 0.0;
|
||
return;
|
||
}
|
||
OutMedian = Samples[Samples.Num() / 2];
|
||
const int32 P95Index = FMath::Clamp(FMath::CeilToInt(Samples.Num() * 0.95f) - 1,
|
||
0, Samples.Num() - 1);
|
||
OutP95 = Samples[P95Index];
|
||
};
|
||
OutAudit.SampledCells = SampleCellCount;
|
||
OutAudit.ColumnsPerCell = static_cast<int32>(TotalColumns / SampleCellCount);
|
||
OutAudit.DensityQueriesPerCell = static_cast<double>(TotalDensityQueries) / SampleCellCount;
|
||
OutAudit.SurfaceQueriesPerCell = static_cast<double>(TotalSurfaceQueries) / SampleCellCount;
|
||
MedianAndP95(MoveTemp(CellMilliseconds), OutAudit.CellMedianMilliseconds,
|
||
OutAudit.CellP95Milliseconds);
|
||
|
||
double UnusedMedian = 0.0;
|
||
MedianAndP95(MoveTemp(FullScanCellMilliseconds),UnusedMedian,OutAudit.FullScanCellP95Milliseconds);
|
||
|
||
// Deliberately populated fixture alongside the unmodified authored palette. No actors are spawned.
|
||
// Fixture volontairement peuplee, a cote de la palette auteur intacte. Aucun acteur cree.
|
||
UStaticMesh* FixtureMesh=nullptr;
|
||
for (const auto* Entries : {&NearEntriesAudit,&FarEntriesAudit})
|
||
for (const auto& Entry : *Entries)
|
||
if (!FixtureMesh && Entry.Profile.InstancedMesh) FixtureMesh=Entry.Profile.InstancedMesh;
|
||
if (!FixtureMesh)
|
||
FixtureMesh=LoadObject<UStaticMesh>(nullptr,TEXT("/Engine/BasicShapes/Cube.Cube"));
|
||
TMap<FIntPoint,FDecoRegionBuild> FixtureBuilds;
|
||
TArray<FStrateDecoration> FixtureEntries;
|
||
TArray<int32> FixtureBiomes;
|
||
const int32 BiomeCount = CurrentCtx.BiomeCtx.IsValid() ? CurrentCtx.BiomeCtx.Biomes.Num() : 0;
|
||
for (int32 Biome=0;Biome<FMath::Max(1,BiomeCount);++Biome)
|
||
for (int32 Wet=0;Wet<2;++Wet)
|
||
{
|
||
FStrateDecoration Entry;
|
||
Entry.Profile.ActorClass=AActor::StaticClass();
|
||
Entry.Profile.InstancedMesh=FixtureMesh;
|
||
Entry.Profile.SurfacePlacement=ESurfaceType::Floor;
|
||
Entry.Profile.bPlaceBelowWater=Wet!=0;
|
||
Entry.SpawnDensity=1.0f;
|
||
Entry.MaxPerChunk=64;
|
||
FixtureEntries.Add(Entry);
|
||
FixtureBiomes.Add(BiomeCount ? Biome : -1);
|
||
}
|
||
TArray<double> PopulatedTimes, FullPopulatedTimes;
|
||
for (int32 Sample=0;Sample<SampleCellCount;++Sample)
|
||
{
|
||
const FIntPoint Cell(PlayerCell.X+Sample%GridSide-GridSide/2,
|
||
PlayerCell.Y+Sample/GridSide-GridSide/2);
|
||
TArray<FDecoSpawn> FullSpawns, Spawns;
|
||
const double FullStart=FPlatformTime::Seconds();
|
||
BuildCellSpawns(Generator,OwnerXf,Cell,CurrentCtx,FixtureEntries,FixtureBiomes,
|
||
static_cast<uint32>(Seed),4,static_cast<float>(Step),MaxCrossings,ColumnDepth,
|
||
FullSpawns,nullptr,false);
|
||
const double FullElapsed=(FPlatformTime::Seconds()-FullStart)*1000.0;
|
||
const double CandidateStart=FPlatformTime::Seconds();
|
||
BuildCellSpawns(Generator,OwnerXf,Cell,CurrentCtx,FixtureEntries,FixtureBiomes,
|
||
static_cast<uint32>(Seed),4,static_cast<float>(Step),MaxCrossings,ColumnDepth,Spawns);
|
||
const double Elapsed=(FPlatformTime::Seconds()-CandidateStart)*1000.0;
|
||
OutAudit.PopulatedBatchMilliseconds+=Elapsed;
|
||
OutAudit.FullScanPopulatedBatchMilliseconds+=FullElapsed;
|
||
if (Spawns.Num()!=FullSpawns.Num())
|
||
{ OutError=TEXT("Populated fixture count differs from full scan."); return false; }
|
||
const double MergeStart=FPlatformTime::Seconds();
|
||
if (FixtureMesh && !Spawns.IsEmpty())
|
||
{
|
||
auto& Build=FixtureBuilds.FindOrAdd(CellToRegion(Cell,RegionSize()));
|
||
auto& Bucket=Build.MeshBuckets.FindOrAdd(FixtureMesh);
|
||
for (const auto& Spawn : Spawns)
|
||
{
|
||
Bucket.Profile=FixtureEntries[Spawn.EntryIdx].Profile;
|
||
Bucket.Xforms.Add(Spawn.Xf);
|
||
}
|
||
}
|
||
OutAudit.PopulatedMergeMilliseconds+=(FPlatformTime::Seconds()-MergeStart)*1000.0;
|
||
for (int32 Index=0;Index<Spawns.Num();++Index)
|
||
{
|
||
if (!Spawns[Index].Xf.Equals(FullSpawns[Index].Xf,0.0f))
|
||
{ OutError=TEXT("Populated fixture anchor differs from full scan."); return false; }
|
||
++OutAudit.ExactPlacementComparisons;
|
||
const FVector Local=OwnerXf.InverseTransformPosition(Spawns[Index].Xf.GetLocation());
|
||
float Density=0.0f;
|
||
if (CurrentCtx.bNativeFloatingIslands && CurrentCtx.NativeIslands.IsValid()
|
||
&& CurrentCtx.NativeIslands->GetDensity(CurrentCtx.StrateIndex,
|
||
Local.X/CurrentCtx.VoxelSizeCm,Local.Y/CurrentCtx.VoxelSizeCm,
|
||
Local.Z/CurrentCtx.VoxelSizeCm,Density))
|
||
{
|
||
OutAudit.MaximumDensityResidual=FMath::Max(OutAudit.MaximumDensityResidual,
|
||
FMath::Abs(static_cast<double>(Density)));
|
||
++OutAudit.AnchorChecks;
|
||
if (FMath::Abs(Density)>0.25f)
|
||
{ OutError=TEXT("Populated fixture is not anchored on the canonical field."); return false; }
|
||
}
|
||
}
|
||
if (!Spawns.IsEmpty())
|
||
{
|
||
++OutAudit.PopulatedCells;
|
||
OutAudit.PopulatedPlacements+=Spawns.Num();
|
||
PopulatedTimes.Add(Elapsed);
|
||
FullPopulatedTimes.Add(FullElapsed);
|
||
}
|
||
}
|
||
if ((CurrentCtx.bNativeSurface||CurrentCtx.bNativeFloatingIslands) && OutAudit.PopulatedPlacements==0)
|
||
{ OutError=TEXT("Populated content fixture produced no placements."); return false; }
|
||
if (FixtureMesh && OutAudit.PopulatedPlacements>0)
|
||
{
|
||
// Exercise actual region application, then close this isolated fixture immediately.
|
||
// Appliquer une vraie region puis detruire immediatement cette fixture isolee.
|
||
FDecoGrid FixtureGrid;
|
||
const double ApplyStart=FPlatformTime::Seconds();
|
||
// Fixture footprints are isolated from live landmark suppression. / Isoler les empreintes de fixture.
|
||
for (auto& Pair : FixtureBuilds) ApplyRegion(FixtureGrid,Pair.Key,Pair.Value,false);
|
||
OutAudit.PopulatedApplyMilliseconds=(FPlatformTime::Seconds()-ApplyStart)*1000.0;
|
||
for (const auto& Pair : FixtureGrid.Regions)
|
||
for (const auto& WeakHISM : Pair.Value.Instances)
|
||
if (const auto* HISM=WeakHISM.Get()) OutAudit.AppliedInstances+=HISM->GetInstanceCount();
|
||
for (const auto& Pair : FixtureBuilds) ClearDecorationRegion(FixtureGrid,Pair.Key);
|
||
if (OutAudit.AppliedInstances!=OutAudit.PopulatedPlacements)
|
||
{
|
||
OutError=FString::Printf(TEXT("Populated HISM fixture applied %d of %d placements (%d regions, mesh %s)."),
|
||
OutAudit.AppliedInstances,OutAudit.PopulatedPlacements,FixtureBuilds.Num(),*FixtureMesh->GetPathName());
|
||
return false;
|
||
}
|
||
}
|
||
MedianAndP95(MoveTemp(PopulatedTimes),UnusedMedian,OutAudit.PopulatedCellP95Milliseconds);
|
||
MedianAndP95(MoveTemp(FullPopulatedTimes),UnusedMedian,OutAudit.FullScanPopulatedCellP95Milliseconds);
|
||
|
||
// The game-thread update cost is sampled with task dispatch disabled; worker-cell cost is reported
|
||
// separately above, so the frame figure is not polluted by waiting for asynchronous work.
|
||
UpdateDecorations(PlayerWorldPos);
|
||
TArray<double> UpdateMilliseconds;
|
||
UpdateMilliseconds.Reserve(FrameSampleCount);
|
||
for (int32 Frame = 0; Frame < FrameSampleCount; ++Frame)
|
||
{
|
||
const double UpdateStart = FPlatformTime::Seconds();
|
||
UpdateDecorations(PlayerWorldPos);
|
||
UpdateMilliseconds.Add((FPlatformTime::Seconds() - UpdateStart) * 1000.0);
|
||
}
|
||
MedianAndP95(MoveTemp(UpdateMilliseconds), OutAudit.UpdateMedianMilliseconds,
|
||
OutAudit.UpdateP95Milliseconds);
|
||
return true;
|
||
}
|
||
|
||
void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& PlayerCell)
|
||
{
|
||
// REGION-granular streaming, per grid. Decoration cells are grouped into RxR regions; a region is the
|
||
// load/unload unit and shares ONE HISM per mesh, so the render thread walks ~R^2 fewer components. A
|
||
// region, once desired, marches ALL of its cells (so it is self-contained and NEVER re-streamed in
|
||
// place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region). The
|
||
// radius is G.Radius (this grid's tier), so Near and Far stream to different distances independently.
|
||
const int32 FarR = G.Radius;
|
||
const int32 R = RegionSize();
|
||
|
||
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
|
||
const FIntPoint RMin(FloorDivPos(static_cast<int64>(PlayerCell.X) - FarR, R),
|
||
FloorDivPos(static_cast<int64>(PlayerCell.Y) - FarR, R));
|
||
const FIntPoint RMax(FloorDivPos(static_cast<int64>(PlayerCell.X) + FarR, R),
|
||
FloorDivPos(static_cast<int64>(PlayerCell.Y) + FarR, R));
|
||
|
||
TSet<FIntPoint> DesiredRegions;
|
||
constexpr int64 MaxRegionReserve = 4096;
|
||
const int64 WidthX = static_cast<int64>(RMax.X) - RMin.X + 1;
|
||
const int64 WidthY = static_cast<int64>(RMax.Y) - RMin.Y + 1;
|
||
const int64 ReserveCount = WidthY > 0 && WidthX > MaxRegionReserve / WidthY
|
||
? MaxRegionReserve : FMath::Max<int64>(0, WidthX * WidthY);
|
||
DesiredRegions.Reserve(static_cast<int32>(ReserveCount));
|
||
for (int64 ry = RMin.Y; ry <= RMax.Y; ++ry)
|
||
for (int64 rx = RMin.X; rx <= RMax.X; ++rx)
|
||
{
|
||
DesiredRegions.Add(FIntPoint(static_cast<int32>(rx), static_cast<int32>(ry)));
|
||
}
|
||
|
||
// Unload loaded regions no longer desired (plain DestroyComponent — no per-instance removal).
|
||
{
|
||
TArray<FIntPoint> Loaded; G.Regions.GetKeys(Loaded);
|
||
for (const FIntPoint& K : Loaded)
|
||
{
|
||
if (!DesiredRegions.Contains(K)) ClearDecorationRegion(G, K);
|
||
}
|
||
}
|
||
|
||
TArray<FIntPoint> DesiredRegionList;
|
||
DesiredRegionList.Reserve(DesiredRegions.Num());
|
||
for (const FIntPoint& Region : DesiredRegions)
|
||
{
|
||
DesiredRegionList.Add(Region);
|
||
}
|
||
DesiredRegionList.Sort([](const FIntPoint& A, const FIntPoint& B)
|
||
{
|
||
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
|
||
});
|
||
|
||
// Start a build for each desired region that isn't already loaded or building. In-progress builds are
|
||
// LEFT to finish even if they fell out of range (their cell tasks are already off-thread); the apply
|
||
// step discards a completed build that is no longer desired (see ProcessDecoResults). Each new build
|
||
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
|
||
for (const FIntPoint& Region : DesiredRegionList)
|
||
{
|
||
if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
|
||
if (G.Builds.Contains(Region)) continue; // already marching its cells
|
||
|
||
FDecoRegionBuild& Build = G.Builds.Add(Region);
|
||
Build.BuildId = G.NextBuildId++;
|
||
Build.CellsRemaining = R * R;
|
||
|
||
const int32 BaseX = Region.X * R, BaseY = Region.Y * R;
|
||
for (int32 cy = 0; cy < R; ++cy)
|
||
for (int32 cx = 0; cx < R; ++cx)
|
||
{
|
||
G.PendingLaunch.Add(FIntPoint(BaseX + cx, BaseY + cy));
|
||
}
|
||
}
|
||
|
||
// Nearest-first so the region under the player fills in before the fringe. Stale entries (cells whose
|
||
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
|
||
G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
|
||
{
|
||
const int32 AD = CellChebyshev(A, PlayerCell);
|
||
const int32 BD = CellChebyshev(B, PlayerCell);
|
||
if (AD != BD) return AD < BD;
|
||
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
|
||
});
|
||
}
|
||
|
||
void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc)
|
||
{
|
||
if (!CurrentCtx.Def || !Generator) return;
|
||
if (MaxConc <= 0)
|
||
{
|
||
// Decorations disabled at runtime — drop THIS grid's queued/pending build state so nothing is stranded.
|
||
G.PendingLaunch.Reset();
|
||
G.Builds.Reset();
|
||
G.Completed.Reset();
|
||
return;
|
||
}
|
||
|
||
AActor* OwnerActor = Owner.Get();
|
||
if (!OwnerActor) return;
|
||
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
||
|
||
const int32 R = RegionSize();
|
||
const int32 Spacing = G.Spacing; // fine (Near) or coarse (Far) — the per-grid column grid
|
||
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
||
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||
const EDecoStreamTier GridTier = G.Tier; // stamped on each result so it routes back to this grid
|
||
|
||
// Throttle against the COMBINED in-flight count (this grid + the other) so both grids share MaxConc.
|
||
// Drain from the head by INDEX — RemoveAt(0) per pop shifted the whole array every time (O(N) each,
|
||
// quadratic on a long queue); now it's one compaction at the end. A cell still in flight from a
|
||
// PREVIOUS build (its build was dropped while the task was airborne — e.g. the MaxConc==0 reset path)
|
||
// is DEFERRED instead of dropped: dropping it would leave the NEW build waiting forever for a cell
|
||
// that never reports (a permanently blank, never-reapplied region).
|
||
int32 Head = 0;
|
||
TArray<FIntPoint> Deferred;
|
||
while (Head < G.PendingLaunch.Num() && (G.InFlightCells.Num() + OtherInFlight) < MaxConc)
|
||
{
|
||
const FIntPoint Cell = G.PendingLaunch[Head++];
|
||
|
||
if (G.InFlightCells.Contains(Cell)) { Deferred.Add(Cell); continue; }
|
||
|
||
// The cell's region build drives completion. If it's gone (region applied or discarded since this
|
||
// cell was queued), drop the cell — no range check here: a region intentionally marches all its
|
||
// cells (some sit just past FarR), and discarding the build is the only "no longer wanted" signal.
|
||
const FIntPoint Region = CellToRegion(Cell, R);
|
||
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||
if (!Build) continue;
|
||
const uint32 BuildId = Build->BuildId;
|
||
|
||
// This grid's palette (its tier's entries, flattened + biome-tagged) is built ONCE per update in
|
||
// UpdateDecorations; the per-COLUMN biome pick happens on the worker. Snapshot the flat list +
|
||
// tags for this cell's task (the biome context rides in Ctx).
|
||
if (G.Entries.Num() == 0)
|
||
{
|
||
MarkCellDone(G, Region, Cell, BuildId); // empty cell still counts toward the region's completion
|
||
continue;
|
||
}
|
||
|
||
TArray<FStrateDecoration> EntriesCopy = G.Entries; // snapshot for the worker + the spawner
|
||
TArray<int32> EntryBiomeCopy = G.EntryBiome; // parallel: ctx-biome owner per entry
|
||
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
|
||
const uint32 LocalSeed = (uint32)Seed;
|
||
UVoxelGenerator* Gen = Generator;
|
||
|
||
G.InFlightCells.Add(Cell);
|
||
ActiveDecorationTasks.fetch_add(1, std::memory_order_relaxed);
|
||
|
||
UE::Tasks::Launch(TEXT("DecoMarch"),
|
||
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
|
||
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
||
{
|
||
struct FGuard
|
||
{
|
||
UVoxelContentManager* Owner;
|
||
~FGuard() { Owner->ActiveDecorationTasks.fetch_sub(1, std::memory_order_release); }
|
||
} Guard{this};
|
||
|
||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
||
|
||
FDecoCellResult Result;
|
||
Result.Cell = Cell;
|
||
Result.BuildId = BuildId;
|
||
Result.Grid = GridTier;
|
||
Result.Entries = MoveTemp(Entries);
|
||
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed,
|
||
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
|
||
|
||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||
{
|
||
DecoResults.Enqueue(MoveTemp(Result));
|
||
}
|
||
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
||
}
|
||
|
||
if (Head > 0) { G.PendingLaunch.RemoveAt(0, Head); }
|
||
G.PendingLaunch.Append(Deferred); // retry next update, once the old task frees the cell
|
||
}
|
||
|
||
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
|
||
void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||
const FIntPoint& Cell, const FDecoContext& Ctx,
|
||
const TArray<FStrateDecoration>& Entries,
|
||
const TArray<int32>& EntryBiome, uint32 InSeed,
|
||
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
||
TArray<FDecoSpawn>& OutSpawns,
|
||
FPlacementBuildCounters* OutCounters, bool bUseFeatureQueries)
|
||
{
|
||
if (OutCounters != nullptr) *OutCounters = FPlacementBuildCounters();
|
||
if (!Gen || Entries.Num() == 0) return;
|
||
|
||
const int32 PerAxis = FMath::Max(1, CHUNK_SIZE / Spacing);
|
||
const int32 CellOriginVX = Cell.X * CHUNK_SIZE;
|
||
const int32 CellOriginVY = Cell.Y * CHUNK_SIZE;
|
||
|
||
const auto FeatureQueries = bUseFeatureQueries ? VF_ContentFeatureQueries(
|
||
Ctx.bNativeSurface ? Ctx.NativeSurface.Get() : nullptr,
|
||
Ctx.bNativeFloatingIslands ? Ctx.NativeIslands.Get() : nullptr,Ctx.StrateIndex,
|
||
CellOriginVX-1.0f,CellOriginVY-1.0f,Ctx.BottomVoxelZ-1.0f,
|
||
CellOriginVX+CHUNK_SIZE+1.0f,CellOriginVY+CHUNK_SIZE+1.0f,Ctx.TopVoxelZ+1.0f)
|
||
: std::vector<VoxelForgeCore::WorldAssemblyFeatureQueryV1>{};
|
||
const VoxelForgeCore::ScopedWorldAssemblyFeatureQueriesV1 FeatureScope(FeatureQueries);
|
||
|
||
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
|
||
int32 TotalActors = 0;
|
||
|
||
// Per-entry slope-gate cosines, hoisted out of PlaceAtCrossing (they were recomputed per crossing
|
||
// × entry). Same cos of the same angle → bit-identical gating. Sentinel < 0 = gate disabled
|
||
// (default angles), so the common case still costs no trig and never rejects.
|
||
TArray<float> CosMaxSlope, CosMinSlope;
|
||
CosMaxSlope.SetNumUninitialized(Entries.Num());
|
||
CosMinSlope.SetNumUninitialized(Entries.Num());
|
||
for (int32 e = 0; e < Entries.Num(); ++e)
|
||
{
|
||
CosMaxSlope[e] = (Entries[e].Profile.MaxSlopeAngle < 89.99f)
|
||
? VoxelMath::DetCos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f;
|
||
CosMinSlope[e] = (Entries[e].Profile.MinSlopeAngle > 0.01f)
|
||
? VoxelMath::DetCos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f;
|
||
}
|
||
|
||
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
|
||
// (one rebuild per chunk footprint), so resolving the dominant biome at every column in this cell is
|
||
// cheap. The cache is local to this worker task (determinism-safe — pure function of XY/seed/Ctx).
|
||
FChunkBiomeCache BiomeCache;
|
||
const bool bHasBiomes = Ctx.BiomeCtx.IsValid();
|
||
auto D = [&](float VX, float VY, float VZ)
|
||
{
|
||
if (OutCounters != nullptr) ++OutCounters->DensityQueries;
|
||
if (Ctx.bNativeFloatingIslands && Ctx.NativeIslands.IsValid())
|
||
{
|
||
float NativeDensity = 1.0f;
|
||
if (Ctx.NativeIslands->GetDensity(Ctx.StrateIndex, VX, VY, VZ, NativeDensity))
|
||
return NativeDensity;
|
||
return 1.0f;
|
||
}
|
||
return Gen->GetDensityAt(VX * Ctx.ActiveToOwnerVoxelScale,
|
||
VY * Ctx.ActiveToOwnerVoxelScale, VZ * Ctx.ActiveToOwnerVoxelScale);
|
||
};
|
||
auto GetSurface = [&](float VX, float VY, float& OutGround, float& OutCap)
|
||
{
|
||
if (OutCounters != nullptr) ++OutCounters->SurfaceQueries;
|
||
if (Ctx.bNativeSurface && Ctx.NativeSurface.IsValid())
|
||
{
|
||
return Ctx.NativeSurface->GetHeights(
|
||
Ctx.StrateIndex, VX, VY, OutGround, OutCap);
|
||
}
|
||
float OwnerGround = 0.0f;
|
||
float OwnerCap = 0.0f;
|
||
if (!Gen->GetSurfaceHeightAt(VX * Ctx.ActiveToOwnerVoxelScale,
|
||
VY * Ctx.ActiveToOwnerVoxelScale, Ctx.RepChunkZ, OwnerGround, OwnerCap))
|
||
{
|
||
return false;
|
||
}
|
||
OutGround = OwnerGround / Ctx.ActiveToOwnerVoxelScale;
|
||
OutCap = OwnerCap / Ctx.ActiveToOwnerVoxelScale;
|
||
return true;
|
||
};
|
||
|
||
// Build a companion satellite's transform from its profile + per-instance hash at a snapped surface point
|
||
// (voxel XY/Z + outward normal). Shared by level-1 and level-2 companions.
|
||
auto MakeCompanionXf = [&](const FPlacementProfile& P, uint32 IHash,
|
||
float sVX, float sVY, float sZ, const FVector& sN) -> FTransform
|
||
{
|
||
const FVector Local(sVX * Ctx.VoxelSizeCm, sVY * Ctx.VoxelSizeCm, sZ * Ctx.VoxelSizeCm);
|
||
const FVector Pos = OwnerXf.TransformPosition(Local) + sN * P.SurfaceOffset + P.LocationOffset;
|
||
FQuat Q = P.bAlignToSurface ? FRotationMatrix::MakeFromZ(sN).ToQuat() : FQuat::Identity;
|
||
Q = Q * P.RotationOffset.Quaternion();
|
||
if (!P.RandomRotation.IsNearlyZero())
|
||
{
|
||
const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x1111A1u)) - 0.5f) * P.RandomRotation.Pitch;
|
||
const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x2222B2u)) - 0.5f) * P.RandomRotation.Yaw;
|
||
const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x3333C3u)) - 0.5f) * P.RandomRotation.Roll;
|
||
Q = Q * FRotator(rp, ry, rr).Quaternion();
|
||
}
|
||
const float Sc = FMath::Lerp(P.MinScale, P.MaxScale, VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x5CA1E000u)));
|
||
return FTransform(Q, Pos, FVector(Sc));
|
||
};
|
||
|
||
// Shared: roll every decoration entry at one surface point (voxel XY, voxel Z, outward world normal)
|
||
// and append the passing ones to OutSpawns. CrossingIdx salts the hash so stacked surfaces differ.
|
||
// ColBiome = the column's dominant context-biome index (-1 when biomes are off); an entry is rolled
|
||
// only if it belongs to that biome (EntryBiome[EntryIdx] == ColBiome) → organic, per-column borders.
|
||
auto PlaceAtCrossing = [&](float VX, float VY, int32 gx, int32 gy, float ZC,
|
||
const FVector& NormalWorld, int32 CrossingIdx, int32 ColBiome)
|
||
{
|
||
const bool bFloor = NormalWorld.Z > 0.5f;
|
||
const bool bCeiling = NormalWorld.Z < -0.5f;
|
||
const bool bWall = !bFloor && !bCeiling;
|
||
|
||
const FVector LocalPos(VX * Ctx.VoxelSizeCm, VY * Ctx.VoxelSizeCm, ZC * Ctx.VoxelSizeCm);
|
||
const FVector PosWorld = OwnerXf.TransformPosition(LocalPos);
|
||
const bool bBelowWater = Ctx.bHasWater && (LocalPos.Z < Ctx.WaterLocalZ);
|
||
|
||
for (int32 EntryIdx = 0; EntryIdx < Entries.Num(); ++EntryIdx)
|
||
{
|
||
// Per-column biome gate: only this column's dominant biome owns its entries. -1-tagged
|
||
// entries (no biome field) match the -1 ColBiome, so the legacy single-list path is intact.
|
||
if (EntryBiome[EntryIdx] != ColBiome) continue;
|
||
|
||
const FStrateDecoration& Deco = Entries[EntryIdx];
|
||
const bool bInstanced = (Deco.Profile.InstancedMesh != nullptr);
|
||
if (!bInstanced && !Deco.Profile.ActorClass) continue;
|
||
if (Deco.SpawnDensity <= 0.0f) continue;
|
||
if (EntryCount[EntryIdx] >= Deco.MaxPerChunk) continue;
|
||
if (!bInstanced && TotalActors >= GMaxDecorationActorsPerCell) continue;
|
||
|
||
bool bMatches = true;
|
||
switch (Deco.Profile.SurfacePlacement)
|
||
{
|
||
case ESurfaceType::Floor: bMatches = bFloor; break;
|
||
case ESurfaceType::Wall: bMatches = bWall; break;
|
||
case ESurfaceType::Ceiling: bMatches = bCeiling; break;
|
||
default: bMatches = true; break;
|
||
}
|
||
if (!bMatches) continue;
|
||
|
||
// Strict-wall overhang gate: a "wall" point also covers surfaces that lean slightly downward
|
||
// (N.Z in [-0.5, 0)). For props flagged wall-only-upright, drop those so overhangs don't take
|
||
// wall decals. Applies whenever the point IS a wall (independent of Floor/Wall/Any setting).
|
||
if (bWall && Deco.Profile.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue;
|
||
|
||
// Surface-tilt gates: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). |N.Z| < cos(MaxSlope) ⇔
|
||
// tilt > MaxSlope (skip steeper); |N.Z| > cos(MinSlope) ⇔ tilt < MinSlope (skip flatter).
|
||
// Cosines are precomputed per entry above; < 0 = gate disabled (default angles).
|
||
if (CosMaxSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) < CosMaxSlope[EntryIdx]) continue;
|
||
if (CosMinSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) > CosMinSlope[EntryIdx]) continue;
|
||
|
||
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
|
||
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
|
||
|
||
if (Deco.Profile.bRequireWaterRelative && Ctx.bHasWater)
|
||
{
|
||
if (bBelowWater != Deco.Profile.bPlaceBelowWater) continue;
|
||
}
|
||
|
||
// F7 aware placement: relational conditions (relief/moisture/biome-border). Opt-in per entry —
|
||
// skipped entirely when the list is empty. Worker-safe pure query; uses the cell's biome context.
|
||
if (Deco.Profile.Conditions.Num() > 0 &&
|
||
!Gen->EvaluateTerrainConditions(Deco.Profile.Conditions,
|
||
VX * Ctx.ActiveToOwnerVoxelScale, VY * Ctx.ActiveToOwnerVoxelScale,
|
||
Ctx.BiomeCtx)) continue;
|
||
|
||
const FVector SpawnPos = PosWorld + NormalWorld * Deco.Profile.SurfaceOffset + Deco.Profile.LocationOffset;
|
||
// Rotation: optional surface-align → fixed offset → per-axis hash random (same model as landmarks).
|
||
// RandomRotation.Yaw defaults to 360 for decoration (see FStrateDecoration ctor) = full random
|
||
// heading, reproducing the legacy random-yaw look; the exact per-instance yaw values reshuffle
|
||
// once (different hash mix) but the distribution is identical.
|
||
FQuat BaseQ = Deco.Profile.bAlignToSurface
|
||
? FRotationMatrix::MakeFromZ(NormalWorld).ToQuat()
|
||
: FQuat::Identity;
|
||
BaseQ = BaseQ * Deco.Profile.RotationOffset.Quaternion();
|
||
if (!Deco.Profile.RandomRotation.IsNearlyZero())
|
||
{
|
||
const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * Deco.Profile.RandomRotation.Pitch;
|
||
const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * Deco.Profile.RandomRotation.Yaw;
|
||
const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * Deco.Profile.RandomRotation.Roll;
|
||
BaseQ = BaseQ * FRotator(rp, ry, rr).Quaternion();
|
||
}
|
||
const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E000u));
|
||
const float Scale = FMath::Lerp(Deco.Profile.MinScale, Deco.Profile.MaxScale, ScaleT);
|
||
|
||
FDecoSpawn& Out = OutSpawns.AddDefaulted_GetRef();
|
||
Out.EntryIdx = EntryIdx;
|
||
Out.bInstanced = bInstanced;
|
||
Out.Xf = FTransform(BaseQ, SpawnPos, FVector(Scale));
|
||
++EntryCount[EntryIdx];
|
||
if (!bInstanced) ++TotalActors;
|
||
|
||
// ---- Companions (F7 relational placement): deterministic cluster satellites around this parent.
|
||
// Level 1 re-snaps to the real surface at its own XY; level-2 SubCompanions inherit their L1
|
||
// satellite's point (no re-snap → cheap). A per-parent budget caps the total so nesting can't blow
|
||
// up. Pure function of the parent hash H → no "did a tree spawn here?" search.
|
||
int32 CompBudget = GMaxCompanionsPerParent;
|
||
for (int32 ci = 0; ci < Deco.Companions.Num() && CompBudget > 0; ++ci)
|
||
{
|
||
const FDecoCompanion& Comp = Deco.Companions[ci];
|
||
const bool bCompInst = (Comp.Profile.InstancedMesh != nullptr);
|
||
if (!bCompInst && !Comp.Profile.ActorClass) continue;
|
||
|
||
const uint32 CH = VoxelHash::Mix(H ^ (0x00C0FFEEu + (uint32)ci * 0x9E3779B1u));
|
||
if (VoxelHash::ToFloat01(CH) > Comp.Probability) continue;
|
||
|
||
const int64 Span = FMath::Max<int64>(0,
|
||
static_cast<int64>(Comp.CountMax) - Comp.CountMin);
|
||
const uint64 CountRange = static_cast<uint64>(Span) + 1u;
|
||
const float CountOffset = VoxelHash::ToFloat01(
|
||
VoxelHash::Mix(CH ^ 0x1234u)) * static_cast<float>(CountRange);
|
||
const int64 CountWide = static_cast<int64>(Comp.CountMin)
|
||
+ static_cast<int64>(CountOffset);
|
||
const int32 Count = static_cast<int32>(FMath::Clamp<int64>(
|
||
CountWide, MIN_int32, MAX_int32));
|
||
for (int32 ii = 0; ii < Count; ++ii)
|
||
{
|
||
if (CompBudget <= 0) break;
|
||
if (!bCompInst && TotalActors >= GMaxDecorationActorsPerCell) break; // actor budget
|
||
|
||
const uint32 IH = VoxelHash::Mix(CH ^ ((uint32)ii * 0x85EBCA77u + 0x2545F491u));
|
||
const float Ang = VoxelHash::ToFloat01(IH) * 2.0f * PI;
|
||
const float RadVox = FMath::Lerp(Comp.RadiusMinVox, Comp.RadiusMaxVox,
|
||
VoxelHash::ToFloat01(VoxelHash::Mix(IH ^ 0x77u)));
|
||
float SinAng = 0.0f, CosAng = 0.0f;
|
||
VoxelMath::DetSinCos(SinAng, CosAng, Ang);
|
||
const float SatVX = VX + CosAng * RadVox; // voxel, actor-local
|
||
const float SatVY = VY + SinAng * RadVox;
|
||
|
||
// Optional per-satellite gating (relief/moisture/biome-border at ITS own XY).
|
||
if (Comp.Profile.Conditions.Num() > 0 &&
|
||
!Gen->EvaluateTerrainConditions(Comp.Profile.Conditions,
|
||
SatVX * Ctx.ActiveToOwnerVoxelScale,
|
||
SatVY * Ctx.ActiveToOwnerVoxelScale, Ctx.BiomeCtx)) continue;
|
||
|
||
// Surface: SNAP to the real ground/ceiling at the satellite XY (kills floaters); or inherit.
|
||
float SatZ; FVector SatN;
|
||
if (Comp.bSnapToSurface)
|
||
{
|
||
if (!FindLandmarkColumn(Gen, OwnerXf, Ctx, SatVX, SatVY, Comp.Profile.SurfacePlacement,
|
||
Step, ColumnDepth, SatZ, SatN)) continue; // no surface → no floater
|
||
}
|
||
else { SatZ = ZC; SatN = NormalWorld; }
|
||
|
||
FDecoSpawn& CO = OutSpawns.AddDefaulted_GetRef();
|
||
CO.EntryIdx = EntryIdx;
|
||
CO.CompanionIdx = ci;
|
||
CO.bInstanced = bCompInst;
|
||
CO.Xf = MakeCompanionXf(Comp.Profile, IH, SatVX, SatVY, SatZ, SatN);
|
||
if (!bCompInst) ++TotalActors;
|
||
--CompBudget;
|
||
|
||
// ---- Level 2: SubCompanions ON this satellite (moss on a rock). Inherit the L1 satellite's
|
||
// snapped point (SatZ, SatN) — no re-snap, so nesting stays cheap. Small disk around it.
|
||
for (int32 si = 0; si < Comp.SubCompanions.Num() && CompBudget > 0; ++si)
|
||
{
|
||
const FDecoSubCompanion& Sub = Comp.SubCompanions[si];
|
||
const bool bSubInst = (Sub.Profile.InstancedMesh != nullptr);
|
||
if (!bSubInst && !Sub.Profile.ActorClass) continue;
|
||
|
||
const uint32 SH = VoxelHash::Mix(IH ^ (0x0000544Bu + (uint32)si * 0x27D4EB2Fu));
|
||
if (VoxelHash::ToFloat01(SH) > Sub.Probability) continue;
|
||
|
||
const int64 SubSpan = FMath::Max<int64>(0,
|
||
static_cast<int64>(Sub.CountMax) - Sub.CountMin);
|
||
const uint64 SubCountRange = static_cast<uint64>(SubSpan) + 1u;
|
||
const float SubCountOffset = VoxelHash::ToFloat01(
|
||
VoxelHash::Mix(SH ^ 0x1234u)) * static_cast<float>(SubCountRange);
|
||
const int64 SubCountWide = static_cast<int64>(Sub.CountMin)
|
||
+ static_cast<int64>(SubCountOffset);
|
||
const int32 SubCount = static_cast<int32>(FMath::Clamp<int64>(
|
||
SubCountWide, MIN_int32, MAX_int32));
|
||
for (int32 sj = 0; sj < SubCount; ++sj)
|
||
{
|
||
if (CompBudget <= 0) break;
|
||
if (!bSubInst && TotalActors >= GMaxDecorationActorsPerCell) break;
|
||
|
||
const uint32 JH = VoxelHash::Mix(SH ^ ((uint32)sj * 0x85EBCA77u + 0x165667B1u));
|
||
const float SAng = VoxelHash::ToFloat01(JH) * 2.0f * PI;
|
||
const float SRadVox = FMath::Lerp(Sub.RadiusMinVox, Sub.RadiusMaxVox,
|
||
VoxelHash::ToFloat01(VoxelHash::Mix(JH ^ 0x77u)));
|
||
float SinSAng = 0.0f, CosSAng = 0.0f;
|
||
VoxelMath::DetSinCos(SinSAng, CosSAng, SAng);
|
||
const float SubVX = SatVX + CosSAng * SRadVox;
|
||
const float SubVY = SatVY + SinSAng * SRadVox;
|
||
|
||
if (Sub.Profile.Conditions.Num() > 0 &&
|
||
!Gen->EvaluateTerrainConditions(Sub.Profile.Conditions,
|
||
SubVX * Ctx.ActiveToOwnerVoxelScale,
|
||
SubVY * Ctx.ActiveToOwnerVoxelScale, Ctx.BiomeCtx)) continue;
|
||
|
||
FDecoSpawn& SO = OutSpawns.AddDefaulted_GetRef();
|
||
SO.EntryIdx = EntryIdx;
|
||
SO.CompanionIdx = ci;
|
||
SO.SubIdx = si;
|
||
SO.bInstanced = bSubInst;
|
||
SO.Xf = MakeCompanionXf(Sub.Profile, JH, SubVX, SubVY, SatZ, SatN);
|
||
if (!bSubInst) ++TotalActors;
|
||
--CompBudget;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
for (int32 gy = 0; gy < PerAxis; ++gy)
|
||
for (int32 gx = 0; gx < PerAxis; ++gx)
|
||
{
|
||
if (OutCounters != nullptr) ++OutCounters->Columns;
|
||
// INTEGER active-voxel jitter: native Core and legacy routes both stay on their field's column grid.
|
||
// Gigue entière en voxels actifs : les routes Core et legacy restent sur la grille de colonnes du champ.
|
||
const uint32 HJ = DecoHash(Cell.X, Cell.Y, gx, gy, -1, 0, InSeed, 0x10C0u);
|
||
const int32 JX = FMath::Min(Spacing - 1, (int32)(VoxelHash::ToFloat01(HJ) * (float)Spacing));
|
||
const int32 JY = FMath::Min(Spacing - 1, (int32)(VoxelHash::ToFloat01(VoxelHash::Mix(HJ ^ 0x68BC21EBu)) * (float)Spacing));
|
||
const float VX = (float)(CellOriginVX + gx * Spacing + JX);
|
||
const float VY = (float)(CellOriginVY + gy * Spacing + JY);
|
||
|
||
// Resolve the column's biome ONCE (constant over the column's whole Z range). -1 when the strate
|
||
// has no biome field → matches the -1-tagged legacy entries.
|
||
int32 ColBiome = -1;
|
||
if (bHasBiomes)
|
||
{
|
||
const FBiomeSample BS = Gen->ResolveBiomeSampleAt(
|
||
VX * Ctx.ActiveToOwnerVoxelScale, VY * Ctx.ActiveToOwnerVoxelScale,
|
||
Ctx.RepChunkZ, Ctx.BiomeCtx, BiomeCache);
|
||
ColBiome = BS.DominantIndex; // index into Ctx.BiomeCtx.Biomes == the entry tag
|
||
|
||
// SOFTEN THE BORDER: a hard dominant pick still switches deco sets on a crisp line. In the
|
||
// blend band (NeighborWeight rises 0 → ~0.5 toward the shared border) flip a hash-decided
|
||
// fraction of columns to the NEIGHBOUR biome, so the two deco sets DITHER across the seam
|
||
// instead of snapping. Deterministic (pure hash of cell/column/seed) → no flicker/perf cost.
|
||
if (BS.NeighborWeight > 0.0f && BS.NeighborIndex >= 0)
|
||
{
|
||
const uint32 HBlend = DecoHash(Cell.X, Cell.Y, gx, gy, -7, 0, InSeed, 0xB1E2u);
|
||
if (VoxelHash::ToFloat01(HBlend) < BS.NeighborWeight) { ColBiome = BS.NeighborIndex; }
|
||
}
|
||
}
|
||
|
||
if (Ctx.bSurfaceWorld)
|
||
{
|
||
// HEIGHTFIELD ORACLE — O(1)/column instead of marching the whole band. Query the surface
|
||
// (+ 4 neighbours for the gradient normals), then verify with ONE density sample so we skip
|
||
// columns carved away by passages / the (0,0) spine / player diffs (the oracle is the raw
|
||
// heightfield and doesn't know about carving).
|
||
float hC, cC, hXp, cXp, hXm, cXm, hYp, cYp, hYm, cYm;
|
||
if (!GetSurface(VX, VY, hC, cC)) continue;
|
||
GetSurface(VX + 1.0f, VY, hXp, cXp);
|
||
GetSurface(VX - 1.0f, VY, hXm, cXm);
|
||
GetSurface(VX, VY + 1.0f, hYp, cYp);
|
||
GetSurface(VX, VY - 1.0f, hYm, cYm);
|
||
|
||
// Floor (terrain top): outward normal = (-dH/dx, -dH/dy, 1).
|
||
if (hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ
|
||
&& (Ctx.bNativeSurface || D(VX, VY, hC) <= 0.5f))
|
||
{
|
||
const float dHdx = (hXp - hXm) * 0.5f;
|
||
const float dHdy = (hYp - hYm) * 0.5f;
|
||
FVector N = OwnerXf.TransformVectorNoScale(FVector(-dHdx, -dHdy, 1.0f)).GetSafeNormal();
|
||
if (N.IsNearlyZero()) N = FVector::UpVector;
|
||
PlaceAtCrossing(VX, VY, gx, gy, hC, N, 0, ColBiome);
|
||
}
|
||
|
||
// Sky-cap ceiling underside: outward normal = (dC/dx, dC/dy, -1). Only if open space below.
|
||
if (cC > hC + 1.0f && cC <= Ctx.TopVoxelZ
|
||
&& (Ctx.bNativeSurface || D(VX, VY, cC) <= 0.5f))
|
||
{
|
||
const float dCdx = (cXp - cXm) * 0.5f;
|
||
const float dCdy = (cYp - cYm) * 0.5f;
|
||
FVector N = OwnerXf.TransformVectorNoScale(FVector(dCdx, dCdy, -1.0f)).GetSafeNormal();
|
||
if (N.IsNearlyZero()) N = FVector::DownVector;
|
||
PlaceAtCrossing(VX, VY, gx, gy, cC, N, 1, ColBiome);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// NON-SURFACE archetypes (caves/shafts/islands): ray-march the density column for crossings.
|
||
float MarchTop = Ctx.TopVoxelZ, MarchBottom = Ctx.BottomVoxelZ;
|
||
if (bUseFeatureQueries && Ctx.bNativeFloatingIslands && Ctx.NativeIslands.IsValid())
|
||
if (const auto* Entry = Ctx.NativeIslands->FindField(Ctx.StrateIndex))
|
||
if (!VF_ConstrainContentMarch(Entry->SolidSupportBounds,VX,VY,Step,MarchTop,MarchBottom))
|
||
continue;
|
||
float PrevD = D(VX, VY, MarchTop);
|
||
int32 Crossings = 0;
|
||
bool bSeenAir = (PrevD >= 0.0f); // GetDensityAt >= 0 == air
|
||
float SolidRun = 0.0f; // contiguous solid voxels since the last open air
|
||
|
||
for (float Z = MarchTop - Step; Z >= MarchBottom && Crossings < MaxCrossings; Z -= Step)
|
||
{
|
||
const float Dz = D(VX, VY, Z);
|
||
if ((PrevD >= 0.0f) != (Dz >= 0.0f)) // straddles IsoLevel 0 (air ↔ solid)
|
||
{
|
||
// Bisection-refine the crossing Z between Z (lo, Dz) and Z+Step (hi, PrevD).
|
||
float ZLo = Z, DLo = Dz, ZHi = Z + Step, DHi = PrevD;
|
||
for (int32 It = 0; It < 4; ++It)
|
||
{
|
||
const float ZM = 0.5f * (ZLo + ZHi);
|
||
const float DM = D(VX, VY, ZM);
|
||
if ((DM >= 0.0f) == (DHi >= 0.0f)) { ZHi = ZM; DHi = DM; }
|
||
else { ZLo = ZM; DLo = DM; }
|
||
}
|
||
const float Denom = (DLo - DHi);
|
||
const float T = (FMath::Abs(Denom) > KINDA_SMALL_NUMBER) ? (DLo / Denom) : 0.5f;
|
||
const float ZC = ZLo + (ZHi - ZLo) * T;
|
||
|
||
const FVector LocalGrad(
|
||
D(VX + 1.0f, VY, ZC) - D(VX - 1.0f, VY, ZC),
|
||
D(VX, VY + 1.0f, ZC) - D(VX, VY - 1.0f, ZC),
|
||
D(VX, VY, ZC + 1.0f) - D(VX, VY, ZC - 1.0f));
|
||
FVector NormalWorld = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
|
||
if (NormalWorld.IsNearlyZero()) NormalWorld = FVector::UpVector;
|
||
|
||
PlaceAtCrossing(VX, VY, gx, gy, ZC, NormalWorld, Crossings, ColBiome);
|
||
++Crossings;
|
||
}
|
||
|
||
// Once past into the open space, stop after a long bedrock run below it (skips solid rock to
|
||
// the strate floor; caves reset on each air gap so layered floors are still found).
|
||
if (Dz >= 0.0f) { bSeenAir = true; SolidRun = 0.0f; }
|
||
else { SolidRun += Step; }
|
||
if (bSeenAir && SolidRun > ColumnDepth) break;
|
||
|
||
PrevD = Dz;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- GAME THREAD: drain finished marches → merge into region builds, apply completed regions budgeted. ----
|
||
void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell)
|
||
{
|
||
// Drain every finished cell march and route it to its grid by Result.Grid, folding it into that grid's
|
||
// region build. Merging is cheap (transform appends) so it isn't budgeted; the expensive HISM build is
|
||
// budgeted below at region granularity.
|
||
FDecoCellResult R;
|
||
while (DecoResults.Dequeue(R))
|
||
{
|
||
FDecoGrid& G = (R.Grid == EDecoStreamTier::Near) ? NearGrid : FarGrid;
|
||
G.InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters
|
||
MergeCellResult(G, R);
|
||
}
|
||
|
||
// Apply completed regions across BOTH grids under ONE shared frame budget (one batched HISM-per-mesh
|
||
// build per region). A region whose build finished but is no longer desired (player moved on while it
|
||
// marched) is discarded instead of applied — keeps an out-of-range region from flashing in for a frame.
|
||
const int32 R_ = RegionSize();
|
||
const int32 Budget = FMath::Max(1, Settings->MaxDecorationCellsPerFrame);
|
||
int32 Applied = 0;
|
||
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||
{
|
||
FDecoGrid& G = *GP;
|
||
G.Completed.Sort([](const FIntPoint& A, const FIntPoint& B)
|
||
{
|
||
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
|
||
});
|
||
while (G.Completed.Num() > 0 && Applied < Budget)
|
||
{
|
||
const FIntPoint Region = G.Completed[0];
|
||
G.Completed.RemoveAt(0);
|
||
|
||
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||
if (!Build) continue; // already cleared
|
||
|
||
if (!IsRegionDesired(Region, PlayerCell, G.Radius, R_))
|
||
{
|
||
G.Builds.Remove(Region); // wandered out of range while building → drop it unbuilt
|
||
continue;
|
||
}
|
||
|
||
ApplyRegion(G, Region, *Build);
|
||
G.Builds.Remove(Region);
|
||
++Applied;
|
||
}
|
||
if (Applied >= Budget) break;
|
||
}
|
||
}
|
||
|
||
// Fold one finished cell's spawns into its region build, then mark the cell accounted for. A result whose
|
||
// region build is gone or whose BuildId no longer matches (region was cleared + re-marched) is discarded.
|
||
void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult& Result)
|
||
{
|
||
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
|
||
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||
if (!Build || Build->BuildId != Result.BuildId)
|
||
{
|
||
return;
|
||
}
|
||
// Already counted this cell (a duplicate task for the same cell+build landed first) → drop this
|
||
// result whole, or we'd append its spawns twice (double decorations at the same spots).
|
||
if (Build->AccountedCells.Contains(Result.Cell))
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (const FDecoSpawn& S : Result.Spawns)
|
||
{
|
||
if (!Result.Entries.IsValidIndex(S.EntryIdx)) continue;
|
||
const FStrateDecoration& Deco = Result.Entries[S.EntryIdx];
|
||
// Resolve which profile owns this spawn: the entry itself (CompanionIdx<0), a level-1 companion, or a
|
||
// level-2 sub-companion (F7). Each carries its own mesh/actor + render tuning.
|
||
const FPlacementProfile* ProfPtr = &Deco.Profile;
|
||
if (S.CompanionIdx >= 0 && Deco.Companions.IsValidIndex(S.CompanionIdx))
|
||
{
|
||
const FDecoCompanion& Comp = Deco.Companions[S.CompanionIdx];
|
||
ProfPtr = (S.SubIdx >= 0 && Comp.SubCompanions.IsValidIndex(S.SubIdx))
|
||
? &Comp.SubCompanions[S.SubIdx].Profile : &Comp.Profile;
|
||
}
|
||
const FPlacementProfile& Prof = *ProfPtr;
|
||
|
||
if (S.bInstanced)
|
||
{
|
||
if (!Prof.InstancedMesh) continue;
|
||
// Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first
|
||
// contributor in deterministic source order sets the render tuning (cull/shadow/scale) for the
|
||
// whole region's instances. Worker completion order must not choose that representative.
|
||
FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh);
|
||
const bool bEarlierSource =
|
||
S.EntryIdx < Bucket.SourceEntryIdx
|
||
|| (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx < Bucket.SourceCompanionIdx)
|
||
|| (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx == Bucket.SourceCompanionIdx
|
||
&& S.SubIdx < Bucket.SourceSubIdx);
|
||
if (Bucket.Xforms.Num() == 0 || bEarlierSource)
|
||
{
|
||
Bucket.Profile = Prof;
|
||
Bucket.SourceEntryIdx = S.EntryIdx;
|
||
Bucket.SourceCompanionIdx = S.CompanionIdx;
|
||
Bucket.SourceSubIdx = S.SubIdx;
|
||
}
|
||
Bucket.Xforms.Add(S.Xf);
|
||
}
|
||
else if (Prof.ActorClass)
|
||
{
|
||
FRegionActorSpawn& A = Build->ActorSpawns.AddDefaulted_GetRef();
|
||
A.ActorClass = Prof.ActorClass;
|
||
A.Xf = S.Xf;
|
||
}
|
||
}
|
||
|
||
MarkCellDone(G, Region, Result.Cell, Result.BuildId);
|
||
}
|
||
|
||
// Account one cell against its region — IDEMPOTENT per cell, so a duplicate task for the same cell can't
|
||
// double-decrement and apply the region early (which left a permanently-empty chunk until a regen). Queues
|
||
// the region for apply once every distinct cell has reported.
|
||
void UVoxelContentManager::MarkCellDone(FDecoGrid& G, const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId)
|
||
{
|
||
FDecoRegionBuild* Build = G.Builds.Find(Region);
|
||
if (!Build || Build->BuildId != BuildId) return;
|
||
|
||
bool bAlreadyAccounted = false;
|
||
Build->AccountedCells.Add(Cell, &bAlreadyAccounted);
|
||
if (bAlreadyAccounted) return; // this cell already counted → ignore the duplicate
|
||
|
||
if (--Build->CellsRemaining <= 0)
|
||
{
|
||
G.Completed.Add(Region); // ready for budgeted apply in ProcessDecoResults
|
||
}
|
||
}
|
||
|
||
// Build the region's components: one HISM per mesh (all cells merged → one batched AddInstances), actors
|
||
// spawned inline. Moves the region into G.Regions; the build is removed by the caller.
|
||
void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FDecoRegionBuild& Build,
|
||
bool bApplyLandmarkFootprints)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoApply); // total game-thread cost to apply one region
|
||
|
||
AActor* OwnerActor = Owner.Get();
|
||
if (!OwnerActor) return;
|
||
UWorld* World = OwnerActor->GetWorld();
|
||
if (!World) return;
|
||
|
||
FDecoRegionContent& Content = G.Regions.Add(Region);
|
||
|
||
Build.ActorSpawns.Sort([](const FRegionActorSpawn& A, const FRegionActorSpawn& B)
|
||
{
|
||
const FString AClass = A.ActorClass ? A.ActorClass->GetPathName() : FString();
|
||
const FString BClass = B.ActorClass ? B.ActorClass->GetPathName() : FString();
|
||
const int32 ClassCompare = FCString::Strcmp(*AClass, *BClass);
|
||
if (ClassCompare != 0) return ClassCompare < 0;
|
||
return VF_DecoTransformLess(A.Xf, B.Xf);
|
||
});
|
||
|
||
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
|
||
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
|
||
for (const FRegionActorSpawn& A : Build.ActorSpawns)
|
||
{
|
||
if (!A.ActorClass) continue;
|
||
FActorSpawnParameters SpawnParams;
|
||
SpawnParams.Owner = OwnerActor;
|
||
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||
if (AActor* NewActor = World->SpawnActor<AActor>(A.ActorClass, A.Xf, SpawnParams))
|
||
{
|
||
Content.Actors.Add(NewActor);
|
||
}
|
||
}
|
||
|
||
// One HISM per mesh for the ENTIRE region — the render-thread win: hundreds of per-cell components
|
||
// collapse to a handful per region, so InitViews walks far fewer primitives every frame.
|
||
TArray<TWeakObjectPtr<UStaticMesh>> MeshKeys;
|
||
Build.MeshBuckets.GetKeys(MeshKeys);
|
||
MeshKeys.Sort([](const TWeakObjectPtr<UStaticMesh>& A, const TWeakObjectPtr<UStaticMesh>& B)
|
||
{
|
||
const UStaticMesh* AMesh = A.Get();
|
||
const UStaticMesh* BMesh = B.Get();
|
||
const FString APath = AMesh ? AMesh->GetPathName() : FString();
|
||
const FString BPath = BMesh ? BMesh->GetPathName() : FString();
|
||
return FCString::Strcmp(*APath, *BPath) < 0;
|
||
});
|
||
for (const TWeakObjectPtr<UStaticMesh>& MeshKey : MeshKeys)
|
||
{
|
||
FRegionMeshBucket* BucketPtr = Build.MeshBuckets.Find(MeshKey);
|
||
if (!BucketPtr) continue;
|
||
FRegionMeshBucket& Bucket = *BucketPtr;
|
||
UStaticMesh* Mesh = MeshKey.Get();
|
||
if (!Mesh || Bucket.Xforms.Num() == 0) continue;
|
||
const FPlacementProfile& Prof = Bucket.Profile;
|
||
|
||
Bucket.Xforms.Sort(VF_DecoTransformLess);
|
||
|
||
UHierarchicalInstancedStaticMeshComponent* HISM =
|
||
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor);
|
||
HISM->SetStaticMesh(Mesh);
|
||
// STATIC, not Movable: decorations are placed once and never move, so Static lets the renderer
|
||
// CACHE their mesh draw commands (they drop out of the per-frame dynamic-primitive gather — the
|
||
// dominant render-thread cost once component count was solved) AND lets VSM cache their shadows.
|
||
// Mirrors the terrain tile + root mobility rationale (VoxelWorld.cpp). Must precede RegisterComponent.
|
||
HISM->SetMobility(EComponentMobility::Static);
|
||
HISM->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
|
||
// Per-entry render tuning — what makes dense groundcover affordable. Set BEFORE RegisterComponent
|
||
// so the render proxy is created once with the final state (no rebuild):
|
||
// • CullDistance bounds GPU cost — grass is drawn only near the player even when placed thickly.
|
||
// • bCastShadow off removes the dominant cost of dense instanced foliage.
|
||
HISM->SetCastShadow(Prof.bCastShadow);
|
||
if (Prof.CullDistance > 0.0f)
|
||
{
|
||
const int32 End = FMath::Max(1, (int32)Prof.CullDistance);
|
||
const int32 Start = FMath::Max(1, (int32)(Prof.CullDistance * 0.8f));
|
||
HISM->SetCullDistances(Start, End); // fade band 0.8x→1.0x, then gone
|
||
}
|
||
|
||
{
|
||
// Scene-proxy creation. Now amortised over a whole region rather than churned per cell.
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoHISMRegister);
|
||
HISM->RegisterComponent();
|
||
HISM->AttachToComponent(OwnerActor->GetRootComponent(),
|
||
FAttachmentTransformRules::KeepRelativeTransform);
|
||
}
|
||
{
|
||
// Cluster-tree build over the WHOLE region's instances in one shot (scales with instance
|
||
// count, but built once per region instead of once per cell).
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoAddInstances);
|
||
HISM->AddInstances(Bucket.Xforms, /*bShouldReturnIndices=*/false, /*bWorldSpace=*/true);
|
||
}
|
||
Content.Instances.Add(HISM);
|
||
}
|
||
|
||
// Landmark footprints (F7): a suppressing landmark cleared whatever decorations existed when it spawned,
|
||
// but this region just streamed in FRESH → re-clear under any loaded suppressing landmark overlapping it
|
||
// (the per-HISM spatial query early-outs for the non-overlapping majority). Keeps temple floors clear as
|
||
// you leave and return.
|
||
if (bApplyLandmarkFootprints && Content.Instances.Num() > 0)
|
||
{
|
||
for (const TPair<FIntVector, FLandmarkInstance>& LP : LandmarkInstances)
|
||
{
|
||
if (LP.Value.SuppressRadiusWorld > 0.0f)
|
||
{
|
||
RemoveInstancesInContent(Content, LP.Value.SuppressCenter, LP.Value.SuppressRadiusWorld);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void UVoxelContentManager::ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region)
|
||
{
|
||
FDecoRegionContent* Content = G.Regions.Find(Region);
|
||
if (!Content) return;
|
||
|
||
for (const TWeakObjectPtr<AActor>& A : Content->Actors)
|
||
{
|
||
if (AActor* Act = A.Get()) { Act->Destroy(); }
|
||
}
|
||
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
|
||
{
|
||
if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) { Comp->DestroyComponent(); }
|
||
}
|
||
G.Regions.Remove(Region);
|
||
}
|
||
|
||
void UVoxelContentManager::ClearAllDecorations()
|
||
{
|
||
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||
{
|
||
FDecoGrid& G = *GP;
|
||
TArray<FIntPoint> Keys; G.Regions.GetKeys(Keys);
|
||
for (const FIntPoint& K : Keys) ClearDecorationRegion(G, K);
|
||
ResetGridBuildState(G); // abandon any in-progress builds
|
||
}
|
||
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
|
||
// gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
|
||
DrainDecoResults();
|
||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||
}
|
||
|
||
void UVoxelContentManager::InvalidateDecorationBuilds()
|
||
{
|
||
// A terrain edit changes the worker's surface oracle. Clear both the applied regions and the
|
||
// in-progress build maps as one operation; any late result then fails the BuildId lookup and cannot
|
||
// resurrect pre-edit content. The next UpdateDecorations rebuilds the desired set even if the player
|
||
// has not moved.
|
||
ClearAllDecorations();
|
||
}
|
||
|
||
//=============================================================================
|
||
// LANDMARKS — rare large objects on a coarse hash lattice (the "mini-suns")
|
||
//=============================================================================
|
||
// Cost scales with the NUMBER of landmarks in range, not the area: cell = SpacingChunks chunks, so a huge
|
||
// StreamRadiusChunks is only ~(radius/spacing)² candidates. Placement is synchronous (so few candidates it
|
||
// never hitches) and deterministic (hash of cell+entry+seed → pop-free). Strate-bounded like decorations.
|
||
|
||
// Single-column surface find for a landmark. SurfaceWorld → height oracle (floor TerrainZ / ceiling CeilSurf
|
||
// by Surf); else ray-march the strate band top-down for the first crossing whose orientation matches Surf.
|
||
bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||
const FDecoContext& Ctx, float VX, float VY, ESurfaceType Surf,
|
||
float Step, float ColDepth, float& OutZ, FVector& OutNormal)
|
||
{
|
||
if (!Gen) return false;
|
||
|
||
const auto FeatureQueries = VF_ContentFeatureQueries(
|
||
Ctx.bNativeSurface ? Ctx.NativeSurface.Get() : nullptr,
|
||
Ctx.bNativeFloatingIslands ? Ctx.NativeIslands.Get() : nullptr,Ctx.StrateIndex,
|
||
VX-1.0f,VY-1.0f,Ctx.BottomVoxelZ-1.0f,VX+1.0f,VY+1.0f,Ctx.TopVoxelZ+1.0f);
|
||
const VoxelForgeCore::ScopedWorldAssemblyFeatureQueriesV1 FeatureScope(FeatureQueries);
|
||
const auto Density = [&](float SampleX, float SampleY, float SampleZ)
|
||
{
|
||
if (Ctx.bNativeFloatingIslands && Ctx.NativeIslands.IsValid())
|
||
{
|
||
float NativeDensity = 1.0f;
|
||
if (Ctx.NativeIslands->GetDensity(
|
||
Ctx.StrateIndex, SampleX, SampleY, SampleZ, NativeDensity))
|
||
{
|
||
return NativeDensity;
|
||
}
|
||
return 1.0f;
|
||
}
|
||
return Gen->GetDensityAt(SampleX * Ctx.ActiveToOwnerVoxelScale,
|
||
SampleY * Ctx.ActiveToOwnerVoxelScale, SampleZ * Ctx.ActiveToOwnerVoxelScale);
|
||
};
|
||
|
||
if (Ctx.bSurfaceWorld)
|
||
{
|
||
float hC, cC;
|
||
auto GetSurface = [&](float SampleX, float SampleY, float& OutGround, float& OutCap)
|
||
{
|
||
if (Ctx.bNativeSurface && Ctx.NativeSurface.IsValid())
|
||
{
|
||
return Ctx.NativeSurface->GetHeights(
|
||
Ctx.StrateIndex, SampleX, SampleY, OutGround, OutCap);
|
||
}
|
||
float OwnerGround = 0.0f;
|
||
float OwnerCap = 0.0f;
|
||
if (!Gen->GetSurfaceHeightAt(SampleX * Ctx.ActiveToOwnerVoxelScale,
|
||
SampleY * Ctx.ActiveToOwnerVoxelScale, Ctx.RepChunkZ,
|
||
OwnerGround, OwnerCap))
|
||
{
|
||
return false;
|
||
}
|
||
OutGround = OwnerGround / Ctx.ActiveToOwnerVoxelScale;
|
||
OutCap = OwnerCap / Ctx.ActiveToOwnerVoxelScale;
|
||
return true;
|
||
};
|
||
if (!GetSurface(VX, VY, hC, cC)) return false;
|
||
|
||
if (Surf == ESurfaceType::Ceiling)
|
||
{
|
||
if (!(cC > hC + 1.0f && cC <= Ctx.TopVoxelZ)) return false;
|
||
float d, cXp, cXm, cYp, cYm;
|
||
GetSurface(VX + 1.0f, VY, d, cXp);
|
||
GetSurface(VX - 1.0f, VY, d, cXm);
|
||
GetSurface(VX, VY + 1.0f, d, cYp);
|
||
GetSurface(VX, VY - 1.0f, d, cYm);
|
||
FVector N = OwnerXf.TransformVectorNoScale(
|
||
FVector((cXp - cXm) * 0.5f, (cYp - cYm) * 0.5f, -1.0f)).GetSafeNormal();
|
||
if (N.IsNearlyZero()) N = FVector::DownVector;
|
||
OutZ = cC; OutNormal = N; return true;
|
||
}
|
||
// Floor / Wall / Any → the terrain top.
|
||
if (!(hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ)) return false;
|
||
if (!Ctx.bNativeSurface && Density(VX, VY, hC) > 0.5f) return false;
|
||
float d, hXp, hXm, hYp, hYm;
|
||
GetSurface(VX + 1.0f, VY, hXp, d);
|
||
GetSurface(VX - 1.0f, VY, hXm, d);
|
||
GetSurface(VX, VY + 1.0f, hYp, d);
|
||
GetSurface(VX, VY - 1.0f, hYm, d);
|
||
FVector N = OwnerXf.TransformVectorNoScale(
|
||
FVector(-(hXp - hXm) * 0.5f, -(hYp - hYm) * 0.5f, 1.0f)).GetSafeNormal();
|
||
if (N.IsNearlyZero()) N = FVector::UpVector;
|
||
OutZ = hC; OutNormal = N; return true;
|
||
}
|
||
|
||
// Cave/shaft/island archetypes: march the column from the top for the first matching crossing.
|
||
// Bounded by ColDepth like the decoration march (this runs SYNCHRONOUSLY on the game thread):
|
||
// once past open air, a solid run longer than ColDepth means bedrock down to the strate floor —
|
||
// stop instead of paying GetDensityAt across the whole remaining band.
|
||
float MarchTop = Ctx.TopVoxelZ, MarchBottom = Ctx.BottomVoxelZ;
|
||
if (Ctx.bNativeFloatingIslands && Ctx.NativeIslands.IsValid())
|
||
if (const auto* Entry = Ctx.NativeIslands->FindField(Ctx.StrateIndex))
|
||
if (!VF_ConstrainContentMarch(Entry->SolidSupportBounds,VX,VY,Step,MarchTop,MarchBottom))
|
||
return false;
|
||
float PrevD = Density(VX, VY, MarchTop);
|
||
bool bSeenAir = (PrevD >= 0.0f);
|
||
float SolidRun = 0.0f;
|
||
for (float Z = MarchTop - Step; Z >= MarchBottom; Z -= Step)
|
||
{
|
||
const float Dz = Density(VX, VY, Z);
|
||
if ((PrevD >= 0.0f) != (Dz >= 0.0f)) // air ↔ solid crossing
|
||
{
|
||
float ZLo = Z, ZHi = Z + Step, DHi = PrevD, DLo = Dz;
|
||
for (int32 It = 0; It < 4; ++It)
|
||
{
|
||
const float ZM = 0.5f * (ZLo + ZHi);
|
||
const float DM = Density(VX, VY, ZM);
|
||
if ((DM >= 0.0f) == (DHi >= 0.0f)) { ZHi = ZM; DHi = DM; }
|
||
else { ZLo = ZM; DLo = DM; }
|
||
}
|
||
const float Denom = (DLo - DHi);
|
||
const float T = (FMath::Abs(Denom) > KINDA_SMALL_NUMBER) ? (DLo / Denom) : 0.5f;
|
||
const float ZC = ZLo + (ZHi - ZLo) * T;
|
||
|
||
const FVector LocalGrad(
|
||
Density(VX + 1.0f, VY, ZC) - Density(VX - 1.0f, VY, ZC),
|
||
Density(VX, VY + 1.0f, ZC) - Density(VX, VY - 1.0f, ZC),
|
||
Density(VX, VY, ZC + 1.0f) - Density(VX, VY, ZC - 1.0f));
|
||
FVector N = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
|
||
if (N.IsNearlyZero()) N = FVector::UpVector;
|
||
|
||
const bool bFloor = N.Z > 0.5f;
|
||
const bool bCeiling = N.Z < -0.5f;
|
||
const bool bWall = !bFloor && !bCeiling;
|
||
const bool bMatch =
|
||
(Surf == ESurfaceType::Floor && bFloor) ||
|
||
(Surf == ESurfaceType::Ceiling && bCeiling) ||
|
||
(Surf == ESurfaceType::Wall && bWall) ||
|
||
(Surf == ESurfaceType::Any);
|
||
if (bMatch) { OutZ = ZC; OutNormal = N; return true; }
|
||
}
|
||
|
||
if (Dz >= 0.0f) { bSeenAir = true; SolidRun = 0.0f; }
|
||
else { SolidRun += Step; }
|
||
if (bSeenAir && SolidRun > ColDepth) break; // long bedrock below open space → nothing deeper
|
||
|
||
PrevD = Dz;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool UVoxelContentManager::SpawnFromProfile(const FPlacementProfile& P, uint32 H, const FDecoContext& Ctx,
|
||
const FTransform& OwnerXf, AActor* OwnerActor,
|
||
float LocalX, float LocalY, float Step, float ColDepth,
|
||
FTransform& OutXf, FLandmarkInstance& Out)
|
||
{
|
||
if (!Generator) return false;
|
||
const float VX = LocalX / Ctx.VoxelSizeCm;
|
||
const float VY = LocalY / Ctx.VoxelSizeCm;
|
||
|
||
// Biome filter (resolved at the candidate XY, same field the density/deco paths use).
|
||
if (P.RequiredBiome)
|
||
{
|
||
const UVoxelBiomeDefinition* Bio = Generator->GetDominantBiomeAt(
|
||
VX * Ctx.ActiveToOwnerVoxelScale, VY * Ctx.ActiveToOwnerVoxelScale, Ctx.RepChunkZ);
|
||
if (Bio != P.RequiredBiome) return false; // leaves Out empty → evaluated, nothing placed
|
||
}
|
||
|
||
float ZC; FVector N;
|
||
if (!FindLandmarkColumn(Generator, OwnerXf, Ctx, VX, VY, P.SurfacePlacement, Step, ColDepth, ZC, N))
|
||
return false;
|
||
|
||
// Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig).
|
||
if (P.MaxSlopeAngle < 89.99f &&
|
||
FMath::Abs(N.Z) < VoxelMath::DetCos(FMath::DegreesToRadians(P.MaxSlopeAngle))) return false;
|
||
if (P.MinSlopeAngle > 0.01f &&
|
||
FMath::Abs(N.Z) > VoxelMath::DetCos(FMath::DegreesToRadians(P.MinSlopeAngle))) return false;
|
||
|
||
const FVector LocalPos(LocalX, LocalY, ZC * Ctx.VoxelSizeCm);
|
||
if (P.bRequireWaterRelative && Ctx.bHasWater)
|
||
{
|
||
const bool bBelowWater = (LocalPos.Z < Ctx.WaterLocalZ);
|
||
if (bBelowWater != P.bPlaceBelowWater) return false;
|
||
}
|
||
|
||
// F7 aware placement: relational conditions (relief/moisture/biome-border), evaluated at the candidate
|
||
// XY. Opt-in — empty list is free. "A monument only on high mesas / near a biome edge" lives here.
|
||
if (P.Conditions.Num() > 0 &&
|
||
!Generator->EvaluateTerrainConditions(P.Conditions,
|
||
VX * Ctx.ActiveToOwnerVoxelScale, VY * Ctx.ActiveToOwnerVoxelScale,
|
||
Ctx.BiomeCtx)) return false;
|
||
|
||
// Rotation: optional surface-align → fixed offset → per-axis hash random.
|
||
FQuat Q = P.bAlignToSurface ? FRotationMatrix::MakeFromZ(N).ToQuat() : FQuat::Identity;
|
||
Q = Q * P.RotationOffset.Quaternion();
|
||
if (!P.RandomRotation.IsNearlyZero())
|
||
{
|
||
const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * P.RandomRotation.Pitch;
|
||
const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * P.RandomRotation.Yaw;
|
||
const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * P.RandomRotation.Roll;
|
||
Q = Q * FRotator(rp, ry, rr).Quaternion();
|
||
}
|
||
|
||
const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E777u));
|
||
const float Scale = FMath::Lerp(P.MinScale, P.MaxScale, ScaleT);
|
||
|
||
// World-space position + XYZ offset (e.g. +Z lifts a sun off the sky-cap into the cavern).
|
||
const FVector WorldPos = OwnerXf.TransformPosition(LocalPos) + P.LocationOffset;
|
||
OutXf = FTransform(Q, WorldPos, FVector(Scale));
|
||
|
||
if (P.ActorClass)
|
||
{
|
||
UWorld* World = OwnerActor->GetWorld();
|
||
if (!World) return false;
|
||
FActorSpawnParameters SP;
|
||
SP.Owner = OwnerActor;
|
||
SP.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||
if (AActor* A = World->SpawnActor<AActor>(P.ActorClass, OutXf, SP)) { Out.Actor = A; }
|
||
return true;
|
||
}
|
||
if (P.InstancedMesh)
|
||
{
|
||
UStaticMeshComponent* C = NewObject<UStaticMeshComponent>(OwnerActor);
|
||
C->SetStaticMesh(P.InstancedMesh);
|
||
C->SetMobility(EComponentMobility::Static); // placed once, never moves → cached draw + VSM shadow
|
||
C->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
C->SetCastShadow(P.bCastShadow);
|
||
if (P.CullDistance > 0.0f) { C->SetCullDistance(P.CullDistance); } // 0 = never cull (far sun)
|
||
C->SetWorldTransform(OutXf);
|
||
C->RegisterComponent();
|
||
C->AttachToComponent(OwnerActor->GetRootComponent(), FAttachmentTransformRules::KeepWorldTransform);
|
||
Out.Component = C;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void UVoxelContentManager::SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
|
||
const FTransform& OwnerXf, AActor* OwnerActor,
|
||
float LocalX, float LocalY, float Step, float ColDepth,
|
||
FLandmarkInstance& Out)
|
||
{
|
||
FTransform Xf;
|
||
if (!SpawnFromProfile(L.Profile, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Xf, Out))
|
||
return; // Out stays empty → evaluated, nothing placed
|
||
|
||
// Mini-sun light orb (landmark-only): record world-space data for the terrain material's raymarched
|
||
// shadows. Distances convert active voxels→cm; the emitter radius scales with the instance too.
|
||
if (L.bIsLightOrb)
|
||
{
|
||
const float Scale = Xf.GetScale3D().X;
|
||
Out.bIsOrb = true;
|
||
Out.Orb.WorldPos = Xf.GetLocation();
|
||
Out.Orb.Color = L.OrbColor;
|
||
Out.Orb.Intensity = L.OrbIntensity;
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(Owner.Get());
|
||
const float VoxelSizeCm = VoxelWorld ? VoxelWorld->GetActiveVoxelSizeCm() : VOXEL_SIZE;
|
||
Out.Orb.RadiusWorld = L.OrbRadiusVoxels * VoxelSizeCm * Scale;
|
||
Out.Orb.FalloffWorld = L.OrbFalloffVoxels * VoxelSizeCm;
|
||
Out.Orb.MaxShadowDistWorld = L.OrbMaxShadowDistanceVoxels * VoxelSizeCm;
|
||
}
|
||
}
|
||
|
||
void UVoxelContentManager::GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const
|
||
{
|
||
OutOrbs.Reset();
|
||
for (const TPair<FIntVector, FLandmarkInstance>& Pair : LandmarkInstances)
|
||
{
|
||
if (Pair.Value.bIsOrb) { OutOrbs.Add(Pair.Value.Orb); }
|
||
}
|
||
}
|
||
|
||
void UVoxelContentManager::DestroyLandmarkInstance(FLandmarkInstance& Inst)
|
||
{
|
||
if (AActor* A = Inst.Actor.Get()) { A->Destroy(); }
|
||
if (UStaticMeshComponent* C = Inst.Component.Get()) { C->DestroyComponent(); }
|
||
Inst.Actor = nullptr;
|
||
Inst.Component = nullptr;
|
||
}
|
||
|
||
void UVoxelContentManager::ClearAllLandmarks()
|
||
{
|
||
for (TPair<FIntVector, FLandmarkInstance>& Pair : LandmarkInstances) { DestroyLandmarkInstance(Pair.Value); }
|
||
LandmarkInstances.Reset();
|
||
}
|
||
|
||
int32 UVoxelContentManager::RemoveInstancesInContent(FDecoRegionContent& Content, const FVector& Center, float Radius)
|
||
{
|
||
int32 Removed = 0;
|
||
for (TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& WP : Content.Instances)
|
||
{
|
||
UHierarchicalInstancedStaticMeshComponent* HISM = WP.Get();
|
||
if (!HISM || HISM->GetInstanceCount() == 0) continue;
|
||
// Fast bounds-tested spatial query → the vast majority of region HISMs return empty immediately.
|
||
TArray<int32> Hits = HISM->GetInstancesOverlappingSphere(Center, Radius, /*bSphereInWorldSpace*/ true);
|
||
if (Hits.Num() > 0)
|
||
{
|
||
HISM->RemoveInstances(Hits); // handles index shifting; marks render state dirty
|
||
Removed += Hits.Num();
|
||
}
|
||
}
|
||
return Removed;
|
||
}
|
||
|
||
int32 UVoxelContentManager::RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius)
|
||
{
|
||
if (WorldRadius <= 0.0f) return 0;
|
||
int32 Removed = 0;
|
||
for (FDecoGrid* G : { &NearGrid, &FarGrid })
|
||
{
|
||
for (TPair<FIntPoint, FDecoRegionContent>& RP : G->Regions)
|
||
{
|
||
Removed += RemoveInstancesInContent(RP.Value, WorldCenter, WorldRadius);
|
||
}
|
||
}
|
||
return Removed;
|
||
}
|
||
|
||
void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos)
|
||
{
|
||
if (!StrateManager || !Generator || !Settings) return;
|
||
AActor* OwnerActor = Owner.Get();
|
||
if (!OwnerActor) return;
|
||
|
||
const FTransform OwnerXf = OwnerActor->GetActorTransform();
|
||
const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos);
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(OwnerActor);
|
||
const float ManagerLocalZ = VoxelWorld
|
||
? VoxelWorld->ToStrateManagerLocalZ(LocalPlayer.Z) : LocalPlayer.Z;
|
||
|
||
float TopZ, BotZ;
|
||
const bool bInStrate = StrateManager->GetStrateUnrealZRange(ManagerLocalZ, TopZ, BotZ);
|
||
const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(ManagerLocalZ) : INT32_MIN;
|
||
|
||
if (!bInStrate)
|
||
{
|
||
if (LandmarkInstances.Num() > 0) { ClearAllLandmarks(); }
|
||
LastLandmarkStrate = INT32_MIN;
|
||
return;
|
||
}
|
||
if (StrateIndex != LastLandmarkStrate)
|
||
{
|
||
ClearAllLandmarks();
|
||
LastLandmarkStrate = StrateIndex;
|
||
}
|
||
|
||
const float VoxelSizeCm = VoxelWorld ? VoxelWorld->GetActiveVoxelSizeCm() : VOXEL_SIZE;
|
||
const float ChunkWorld = static_cast<float>(CHUNK_SIZE) * VoxelSizeCm;
|
||
|
||
// Shared strate context (a strate is a horizontal slab → same everywhere this update).
|
||
FDecoContext Ctx;
|
||
Ctx.StrateIndex = StrateIndex;
|
||
Ctx.VoxelSizeCm = VoxelSizeCm;
|
||
Ctx.ActiveToOwnerVoxelScale = VoxelSizeCm / VOXEL_SIZE;
|
||
Ctx.TopVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(TopZ) : TopZ) / VoxelSizeCm;
|
||
Ctx.BottomVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(BotZ) : BotZ) / VoxelSizeCm;
|
||
Ctx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||
const FIntVector RepChunk(FMath::FloorToInt(LocalPlayer.X / ChunkWorld),
|
||
FMath::FloorToInt(LocalPlayer.Y / ChunkWorld), Ctx.RepChunkZ);
|
||
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(RepChunk);
|
||
if (!Def || Def->Landmarks.Num() == 0)
|
||
{
|
||
if (LandmarkInstances.Num() > 0) { ClearAllLandmarks(); }
|
||
return;
|
||
}
|
||
Ctx.Def = Def;
|
||
Ctx.bSurfaceWorld = (StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld);
|
||
Ctx.NativeSurface = NativeSurfaceSnapshot;
|
||
Ctx.bNativeSurface = VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore
|
||
&& Ctx.bSurfaceWorld && Ctx.NativeSurface.IsValid()
|
||
&& Ctx.NativeSurface->FindPlan(StrateIndex) != nullptr;
|
||
{
|
||
bool bHasNativePlan = false;
|
||
bool bNativeWaterEnabled = false;
|
||
float NativeWaterLocalZ = -FLT_MAX;
|
||
if (Ctx.bNativeSurface
|
||
&& Ctx.NativeSurface->GetWater(StrateIndex, bHasNativePlan,
|
||
bNativeWaterEnabled, NativeWaterLocalZ)
|
||
&& bHasNativePlan)
|
||
{
|
||
Ctx.bHasWater = bNativeWaterEnabled;
|
||
Ctx.WaterLocalZ = bNativeWaterEnabled ? NativeWaterLocalZ : -FLT_MAX;
|
||
}
|
||
else
|
||
{
|
||
const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk);
|
||
Ctx.bHasWater = (Wv != -FLT_MAX);
|
||
Ctx.WaterLocalZ = Ctx.bHasWater
|
||
? (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(Wv * VOXEL_SIZE) : Wv * VOXEL_SIZE)
|
||
: -FLT_MAX;
|
||
}
|
||
}
|
||
// Landmarks use the same per-column biome gates as decorations. Keep the complete POD biome
|
||
// context in the worker/game-thread placement context; a null/default context would silently
|
||
// bypass the authored RequiredBiome/Conditions decisions for landmarks.
|
||
Ctx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
|
||
|
||
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||
const uint32 LocalSeed = (uint32)Seed;
|
||
|
||
// ---- Gather candidates (a small set) across every entry + anchor mode. Local XY (actor-space cm) is
|
||
// enough for the exclusion test — the surface-find only moves Z. ----
|
||
// bSpawnable = within the entry's real stream radius. POP-FREE EXCLUSION: we gather each entry in
|
||
// (radius + MaxExcl) so every conflictor of an in-range candidate is present regardless of player
|
||
// position; the extra "ring" candidates only SUPPRESS (never spawn), so a candidate's fate is a pure
|
||
// function of (seed, layout) → no edge-of-radius flicker.
|
||
struct FCand { FIntVector Key; int32 EntryIdx; uint32 H; float LocalX, LocalY; float ExclWorld; int32 Priority; bool bSpawnable; };
|
||
TArray<FCand> Cands;
|
||
bool bAnyExclusion = false; // pure scatter (all radii 0) skips the O(n²) resolve → same cost as before
|
||
|
||
float MaxExclChunks = 0.0f;
|
||
for (const FStrateLandmark& LE : Def->Landmarks) { MaxExclChunks = FMath::Max(MaxExclChunks, FMath::Max(0.0f, LE.ExclusionRadiusChunks)); }
|
||
const float MaxExclWorld = MaxExclChunks * ChunkWorld;
|
||
|
||
for (int32 EntryIdx = 0; EntryIdx < Def->Landmarks.Num(); ++EntryIdx)
|
||
{
|
||
const FStrateLandmark& L = Def->Landmarks[EntryIdx];
|
||
if (!L.Profile.ActorClass && !L.Profile.InstancedMesh) continue;
|
||
|
||
const int32 RadiusChunks = FMath::Max(1, L.StreamRadiusChunks);
|
||
const float RadiusWorld = (float)RadiusChunks * ChunkWorld;
|
||
const float GatherWorld = RadiusWorld + MaxExclWorld; // widened so all conflictors are gathered
|
||
const float ExclWorld = FMath::Max(0.0f, L.ExclusionRadiusChunks) * ChunkWorld;
|
||
if (ExclWorld > 0.0f) bAnyExclusion = true;
|
||
|
||
if (L.AnchorMode == ELandmarkAnchor::HashLattice)
|
||
{
|
||
const float SpacingChunks = FMath::Max(1.0f, L.SpacingChunks);
|
||
const float CellWorld = SpacingChunks * ChunkWorld; // lattice cell size in cm
|
||
const float JitterRange = FMath::Clamp(L.JitterFraction, 0.0f, 1.0f);
|
||
const FIntPoint PlayerLCell(FMath::FloorToInt(LocalPlayer.X / CellWorld),
|
||
FMath::FloorToInt(LocalPlayer.Y / CellWorld));
|
||
const int32 CellRange = FMath::CeilToInt(((float)RadiusChunks + MaxExclChunks) / SpacingChunks);
|
||
|
||
for (int32 dy = -CellRange; dy <= CellRange; ++dy)
|
||
for (int32 dx = -CellRange; dx <= CellRange; ++dx)
|
||
{
|
||
const FIntPoint LCell(PlayerLCell.X + dx, PlayerLCell.Y + dy);
|
||
|
||
// Existence roll (salt 0x1A2D5u kept from the original landmark path → positions unchanged).
|
||
const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u);
|
||
if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue;
|
||
|
||
const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange;
|
||
const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange;
|
||
const float LocalX = ((float)LCell.X + 0.5f + jx) * CellWorld;
|
||
const float LocalY = ((float)LCell.Y + 0.5f + jy) * CellWorld;
|
||
|
||
const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y;
|
||
const float DistSq = ddx * ddx + ddy * ddy;
|
||
if (DistSq > GatherWorld * GatherWorld) continue;
|
||
|
||
Cands.Add({ FIntVector(LCell.X, LCell.Y, EntryIdx), EntryIdx, H, LocalX, LocalY, ExclWorld, L.Priority,
|
||
DistSq <= RadiusWorld * RadiusWorld });
|
||
}
|
||
}
|
||
else // PassageMouth — enumerate the finite passage list, keep endpoints that land in THIS strate.
|
||
{
|
||
const TArray<FVoxelPassage>& Passages = StrateManager->GetPassages();
|
||
for (int32 pi = 0; pi < Passages.Num(); ++pi)
|
||
{
|
||
const FVoxelPassage& Pg = Passages[pi];
|
||
for (int32 side = 0; side < 2; ++side)
|
||
{
|
||
const bool bDescent = (side == 0); // 0 = this strate is the passage's UPPER (hole DOWN)
|
||
if (bDescent && !(L.bAtDescentMouths && Pg.UpperStrateIndex == StrateIndex)) continue;
|
||
if (!bDescent && !(L.bAtArrivalMouths && Pg.LowerStrateIndex == StrateIndex)) continue;
|
||
|
||
// Passage endpoints are GLOBAL VOXEL coords (same space as the SDF path) → local cm.
|
||
const FVector MouthVox = bDescent ? Pg.UpperPoint : Pg.LowerPoint;
|
||
const float LocalX = (MouthVox.X / Ctx.ActiveToOwnerVoxelScale) * Ctx.VoxelSizeCm;
|
||
const float LocalY = (MouthVox.Y / Ctx.ActiveToOwnerVoxelScale) * Ctx.VoxelSizeCm;
|
||
|
||
const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y;
|
||
const float DistSq = ddx * ddx + ddy * ddy;
|
||
if (DistSq > GatherWorld * GatherWorld) continue;
|
||
|
||
const uint32 H = DecoHash(pi, side, 0, 0, 0, EntryIdx, LocalSeed, 0x5E7C9u);
|
||
if (VoxelHash::ToFloat01(H) > L.MouthProbability) continue;
|
||
|
||
Cands.Add({ FIntVector(pi, side, EntryIdx), EntryIdx, H, LocalX, LocalY, ExclWorld, L.Priority,
|
||
DistSq <= RadiusWorld * RadiusWorld });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Resolve exclusion (only if any entry opts in): a candidate is suppressed when a HIGHER-RANKED one's
|
||
// disk covers it. Rank = (Priority, then hash) → deterministic. v1 resolves within the in-range set. ----
|
||
TSet<FIntVector> Desired;
|
||
Desired.Reserve(Cands.Num());
|
||
for (int32 i = 0; i < Cands.Num(); ++i)
|
||
{
|
||
const FCand& C = Cands[i];
|
||
if (!C.bSpawnable) continue; // ring-only conflictor (gathered for pop-free resolve, never spawned)
|
||
if (bAnyExclusion)
|
||
{
|
||
bool bSuppressed = false;
|
||
for (int32 j = 0; j < Cands.Num(); ++j)
|
||
{
|
||
if (j == i) continue;
|
||
const FCand& D = Cands[j];
|
||
if (D.ExclWorld <= 0.0f) continue;
|
||
const bool bDOutranks = (D.Priority != C.Priority) ? (D.Priority > C.Priority) : (D.H > C.H);
|
||
if (!bDOutranks) continue;
|
||
const float dxl = D.LocalX - C.LocalX, dyl = D.LocalY - C.LocalY;
|
||
if (dxl * dxl + dyl * dyl < D.ExclWorld * D.ExclWorld) { bSuppressed = true; break; }
|
||
}
|
||
if (bSuppressed) continue;
|
||
}
|
||
|
||
Desired.Add(C.Key);
|
||
if (LandmarkInstances.Contains(C.Key)) continue; // already evaluated (spawned OR empty)
|
||
|
||
FLandmarkInstance Inst;
|
||
SpawnLandmarkInstance(Def->Landmarks[C.EntryIdx], C.H, Ctx, OwnerXf, OwnerActor,
|
||
C.LocalX, C.LocalY, Step, ColDepth, Inst);
|
||
|
||
// Decoration footprint (F7): clear groundcover under a placed suppressing landmark now, and remember
|
||
// the footprint so a deco region streaming in near it re-clears too (see ApplyRegion).
|
||
const FStrateLandmark& LE = Def->Landmarks[C.EntryIdx];
|
||
if (LE.bSuppressDecorationsUnder && LE.SuppressRadiusChunks > 0.0f
|
||
&& (Inst.Actor.IsValid() || Inst.Component.IsValid()))
|
||
{
|
||
const FVector WP = Inst.Actor.IsValid() ? Inst.Actor.Get()->GetActorLocation()
|
||
: Inst.Component.Get()->GetComponentLocation();
|
||
Inst.SuppressCenter = WP;
|
||
Inst.SuppressRadiusWorld = LE.SuppressRadiusChunks * ChunkWorld;
|
||
RemoveDecorationsInSphere(WP, Inst.SuppressRadiusWorld);
|
||
}
|
||
|
||
LandmarkInstances.Add(C.Key, Inst); // stored even if empty → never re-evaluated while in range
|
||
}
|
||
|
||
// Drop instances no longer desired (player moved away, list shrank, lost an exclusion conflict).
|
||
for (auto It = LandmarkInstances.CreateIterator(); It; ++It)
|
||
{
|
||
if (Desired.Contains(It.Key())) continue;
|
||
DestroyLandmarkInstance(It.Value());
|
||
It.RemoveCurrent();
|
||
}
|
||
}
|
||
|
||
//=============================================================================
|
||
// WATER — tile-driven, level-0 only
|
||
//=============================================================================
|
||
|
||
void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos)
|
||
{
|
||
if (!StrateManager || !PlaneMesh) return;
|
||
AActor* OwnerActor = Owner.Get();
|
||
if (!OwnerActor) return;
|
||
|
||
const FTransform Xf = OwnerActor->GetActorTransform();
|
||
const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos);
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(OwnerActor);
|
||
const float LocalToManagerScale = VoxelWorld
|
||
? VOXEL_SIZE / VoxelWorld->GetActiveVoxelSizeCm() : 1.0f;
|
||
FVector ManagerLocalPos = LocalPos * LocalToManagerScale;
|
||
if (VoxelWorld)
|
||
{
|
||
ManagerLocalPos.Z = VoxelWorld->ToStrateManagerLocalZ(LocalPos.Z);
|
||
}
|
||
|
||
// Player chunk (voxel→chunk) for the strate / water-level lookup.
|
||
const float ManagerChunkWorld = static_cast<float>(CHUNK_SIZE) * VOXEL_SIZE;
|
||
const float VoxelSizeCm = VoxelWorld ? VoxelWorld->GetActiveVoxelSizeCm() : VOXEL_SIZE;
|
||
const float ChunkWorld = static_cast<float>(CHUNK_SIZE) * VoxelSizeCm;
|
||
const FIntVector PlayerChunk(
|
||
FMath::FloorToInt(ManagerLocalPos.X / ManagerChunkWorld),
|
||
FMath::FloorToInt(ManagerLocalPos.Y / ManagerChunkWorld),
|
||
FMath::FloorToInt(ManagerLocalPos.Z / ManagerChunkWorld));
|
||
|
||
float WaterLocalZ = -FLT_MAX;
|
||
const int32 StrateIndex = StrateManager->GetStrateIndex(ManagerLocalPos.Z);
|
||
bool bHasNativePlan = false;
|
||
bool bNativeWaterEnabled = false;
|
||
float NativeWaterLocalZ = -FLT_MAX;
|
||
if (VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore
|
||
&& NativeSurfaceSnapshot.IsValid()
|
||
&& NativeSurfaceSnapshot->GetWater(StrateIndex, bHasNativePlan,
|
||
bNativeWaterEnabled, NativeWaterLocalZ)
|
||
&& bHasNativePlan)
|
||
{
|
||
if (bNativeWaterEnabled)
|
||
{
|
||
WaterLocalZ = NativeWaterLocalZ;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
const float WaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(PlayerChunk);
|
||
if (WaterVoxelZ != -FLT_MAX)
|
||
{
|
||
const float ManagerWaterLocalZ = WaterVoxelZ * VOXEL_SIZE;
|
||
WaterLocalZ = VoxelWorld
|
||
? VoxelWorld->FromStrateManagerLocalZ(ManagerWaterLocalZ) : ManagerWaterLocalZ;
|
||
}
|
||
}
|
||
|
||
// No water in this strate means the plane stays hidden. / Sans eau dans cette strate, le plan reste masque.
|
||
if (WaterLocalZ == -FLT_MAX)
|
||
{
|
||
if (WaterPlane) { WaterPlane->SetVisibility(false); }
|
||
LastWaterZ = -FLT_MAX;
|
||
LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||
return;
|
||
}
|
||
|
||
// Snap the plane centre to a coarse cell so it only repositions when the player crosses it
|
||
// (avoids per-frame churn; a uniform plane sliding is invisible anyway as long as the water
|
||
// material pans on WORLD position rather than mesh UVs).
|
||
const FIntPoint Cell(
|
||
FMath::FloorToInt(LocalPos.X / ChunkWorld),
|
||
FMath::FloorToInt(LocalPos.Y / ChunkWorld));
|
||
|
||
if (WaterPlane && WaterPlane->IsVisible() && WaterLocalZ == LastWaterZ && Cell == LastWaterCell)
|
||
return; // nothing changed
|
||
|
||
if (!WaterPlane)
|
||
{
|
||
WaterPlane = NewObject<UStaticMeshComponent>(OwnerActor);
|
||
WaterPlane->SetStaticMesh(PlaneMesh);
|
||
WaterPlane->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
WaterPlane->SetCastShadow(false);
|
||
WaterPlane->SetMobility(EComponentMobility::Movable); // it follows the player
|
||
WaterPlane->RegisterComponent();
|
||
WaterPlane->AttachToComponent(OwnerActor->GetRootComponent(),
|
||
FAttachmentTransformRules::KeepRelativeTransform);
|
||
}
|
||
|
||
// Cover the whole view to the horizon (one draw, so be generous). Engine plane is 100 uu.
|
||
const float CoverWorld = Settings
|
||
? (float)FMath::Max(4, Settings->ViewDistanceXY + 4) * 2.0f * ChunkWorld
|
||
: 200000.0f;
|
||
WaterPlane->SetWorldScale3D(FVector(CoverWorld / 100.0f, CoverWorld / 100.0f, 1.0f));
|
||
|
||
const FVector LocalCenter(((float)Cell.X + 0.5f) * ChunkWorld,
|
||
((float)Cell.Y + 0.5f) * ChunkWorld,
|
||
WaterLocalZ);
|
||
WaterPlane->SetWorldLocation(Xf.TransformPosition(LocalCenter));
|
||
WaterPlane->SetVisibility(true);
|
||
|
||
if (const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(PlayerChunk))
|
||
{
|
||
if (Def->WaterMaterial) { WaterPlane->SetMaterial(0, Def->WaterMaterial); }
|
||
}
|
||
|
||
LastWaterZ = WaterLocalZ;
|
||
LastWaterCell = Cell;
|
||
}
|
||
|
||
//=============================================================================
|
||
// CLEAR ALL
|
||
//=============================================================================
|
||
|
||
void UVoxelContentManager::ClearAll()
|
||
{
|
||
ClearAllDecorations();
|
||
ClearAllLandmarks();
|
||
|
||
if (WaterPlane) { WaterPlane->DestroyComponent(); WaterPlane = nullptr; }
|
||
LastWaterZ = -FLT_MAX;
|
||
LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||
|
||
// Force a full decoration + landmark rebuild on the next update.
|
||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||
LastStrateIndex = INT32_MIN;
|
||
LastLandmarkStrate = INT32_MIN;
|
||
}
|
||
|
||
//=============================================================================
|
||
// DIAGNOSTIC — decoration streaming state under a point
|
||
//=============================================================================
|
||
|
||
void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bApplied, int32& InstanceCount,
|
||
bool& bBuilding, int32& CellsAccounted, int32& CellsTotal,
|
||
int32& LiveMarchSpawns, int32& InstancesInCell) const
|
||
{
|
||
bApplied = false; InstanceCount = 0; bBuilding = false; CellsAccounted = 0;
|
||
LiveMarchSpawns = -1; InstancesInCell = 0;
|
||
|
||
const int32 R = RegionSize();
|
||
CellsTotal = R * R;
|
||
|
||
// World-LOCAL XY → deco cell → region (same math the scatter uses).
|
||
const AVoxelWorld* VoxelWorld = Cast<AVoxelWorld>(Owner.Get());
|
||
const float VoxelSizeCm = VoxelWorld ? VoxelWorld->GetActiveVoxelSizeCm() : VOXEL_SIZE;
|
||
const float CellWorld = static_cast<float>(DECO_CELL_VOXELS) * VoxelSizeCm;
|
||
const FIntPoint Cell(FMath::FloorToInt(LocalPos.X / CellWorld),
|
||
FMath::FloorToInt(LocalPos.Y / CellWorld));
|
||
const FIntPoint Region = CellToRegion(Cell, R);
|
||
|
||
// Actor-LOCAL XY footprint of the probed cell (instances are stored component-local; the HISM sits at
|
||
// the actor transform with identity relative, so component-local == actor-local cell space).
|
||
const float CellMinX = (float)Cell.X * CellWorld, CellMaxX = CellMinX + CellWorld;
|
||
const float CellMinY = (float)Cell.Y * CellWorld, CellMaxY = CellMinY + CellWorld;
|
||
|
||
// Probe BOTH grids: a point is covered by a Far region always, plus a Near region when close. Aggregate
|
||
// applied instances + per-cell counts + building state across the two.
|
||
for (const FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||
{
|
||
const FDecoGrid& G = *GP;
|
||
if (const FDecoRegionContent* Content = G.Regions.Find(Region))
|
||
{
|
||
bApplied = true;
|
||
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
|
||
{
|
||
const UHierarchicalInstancedStaticMeshComponent* Comp = C.Get();
|
||
if (!Comp) continue;
|
||
const int32 N = Comp->GetInstanceCount();
|
||
InstanceCount += N;
|
||
// Count the ones actually inside the probed cell → tells a blank cell apart from a blank region.
|
||
for (int32 i = 0; i < N; ++i)
|
||
{
|
||
FTransform Xf;
|
||
if (!Comp->GetInstanceTransform(i, Xf, /*bWorldSpace=*/false)) continue;
|
||
const FVector P = Xf.GetLocation();
|
||
if (P.X >= CellMinX && P.X < CellMaxX && P.Y >= CellMinY && P.Y < CellMaxY)
|
||
{
|
||
++InstancesInCell;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (const FDecoRegionBuild* Build = G.Builds.Find(Region))
|
||
{
|
||
bBuilding = true;
|
||
CellsAccounted += Build->AccountedCells.Num();
|
||
}
|
||
}
|
||
|
||
// PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate context
|
||
// (set each UpdateDecorations), for BOTH grids' palettes summed. Same inputs the worker uses →
|
||
// byte-identical result, so it reports exactly what the scatter decides for this cell right now. Only
|
||
// valid when the probed point shares the player's current strate (CurrentCtx reflects that); else -1.
|
||
AActor* OwnerActor = Owner.Get();
|
||
if (Generator && Settings && OwnerActor && CurrentCtx.Def)
|
||
{
|
||
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
||
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||
|
||
int32 Total = 0; bool bAny = false;
|
||
for (const FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||
{
|
||
const FDecoGrid& G = *GP;
|
||
if (G.Entries.Num() == 0) continue;
|
||
bAny = true;
|
||
TArray<FDecoSpawn> Spawns;
|
||
BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx,
|
||
G.Entries, G.EntryBiome, (uint32)Seed,
|
||
G.Spacing, Step, MaxCross, ColDepth, Spawns);
|
||
Total += Spawns.Num();
|
||
}
|
||
if (bAny) { LiveMarchSpawns = Total; }
|
||
}
|
||
}
|