1. Collision cook breakdown (request -> ready), critical support tile: spawn (0,0,-1): generate 107 ms, apply < 1 ms, RMC submit->body 479 ms, total 587 ms; deep floor (0,0,-7): generate 82 ms, RMC 1,441 ms, total 1,548 ms. Almost all of the wait is inside RMC (end-of-frame update + async pool + Chaos cook + body install, not separable from our side). Critical priority does not shorten it. Collision is async, complex-as-simple, fast-cook off; skirts are ~16-18% of collision triangles. No cook change made. Startup outlier: first support tile waited 2.09 s for its game-thread apply. Underfoot dig: a carving brush that intersects the pawn's capsule and support tile now disables the old body and withdraws readiness on the carve frame: 0 frames standing on removed ground (replacement 91.8 ms later). 8 neighbouring digs: 0 gate frames. Gap audit 0. 2. Stack sampler, 49.4 s game path: top resolved leaf was the profiler's own VoxelDensityProfile::BucketFromName (4.86%). Linear label scan -> CRC-keyed lookup with exact compare: 59.26 -> 57.87 ms/tile (-2.35%), 980/980 tile hashes identical. 3. Measured only: F (classifier mode 1, no exact check) +48% worker CPU, 0/980 tiles differ -> not worth it. H (handoff component scans) p95 0.07 ms/frame -> not worth it. E (FullResClipLevels=1): worker CPU 56.7 -> 35.7 s, settle 9.6 -> 7.0 s, crossing samples -30%; changes the look, owner's decision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
10945 lines
504 KiB
C++
10945 lines
504 KiB
C++
// VoxelWorld.cpp
|
||
// Implementation of the voxel world manager
|
||
|
||
#include "VoxelWorld.h"
|
||
#include "VoxelDiffLayer.h"
|
||
#include "RealtimeMeshComponent.h"
|
||
#include "RealtimeMeshSimple.h"
|
||
#include "Core/RealtimeMeshCollision.h"
|
||
#include "VoxelMarchingCubesMesher.h"
|
||
#include "VoxelStrateDefinition.h"
|
||
#include "VoxelBiomeDefinition.h"
|
||
#include "VoxelTerrainOpDefinition.h"
|
||
#include "VoxelContentManager.h"
|
||
#include "VoxelDensityVolume.h"
|
||
#include "VoxelDensityOpStack.h"
|
||
#include "VoxelDensityProfile.h"
|
||
#include "VoxelGeometryHash.h"
|
||
#include "VoxelStartupTrace.h"
|
||
#include "VoxelStackSampler.h"
|
||
#include "VoxelStats.h"
|
||
#include "VoxelCaveMorphology.h"
|
||
#include "VoxelPassageGeometry.h"
|
||
#include "VoxelTilePostReach.h"
|
||
#include "VoxelClipmapDesiredTiles.h"
|
||
#include "VoxelCollisionDesiredTiles.h"
|
||
#include "VoxelPreEditDensityCache.h"
|
||
// IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc
|
||
// APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController n'est
|
||
// complet que par transitivité : on l'inclut explicitement.
|
||
// GetPlayerPosition dereferences the pawn, so APawn must be COMPLETE — Casts.h only forward-declares
|
||
// it. APlayerController is complete only transitively; include it explicitly.
|
||
#include "GameFramework/Pawn.h"
|
||
#include "GameFramework/PlayerController.h"
|
||
#include "GameFramework/PawnMovementComponent.h"
|
||
#include "GameFramework/Character.h"
|
||
#include "GameFramework/CharacterMovementComponent.h"
|
||
#include "Components/CapsuleComponent.h"
|
||
#include "Components/PrimitiveComponent.h"
|
||
#include "Camera/PlayerCameraManager.h"
|
||
#include "Async/Async.h"
|
||
#include "Materials/MaterialInstanceDynamic.h"
|
||
#include "Materials/MaterialParameterCollection.h"
|
||
#include "Kismet/KismetMaterialLibrary.h"
|
||
#include "Engine/VolumeTexture.h"
|
||
#include "Engine/Engine.h"
|
||
#include "Engine/GameViewportClient.h"
|
||
#include "Slate/SceneViewport.h"
|
||
#include "Camera/CameraActor.h"
|
||
#include "VoxelAtmosphereManager.h"
|
||
#include "DrawDebugHelpers.h"
|
||
#include "IImageWrapper.h"
|
||
#include "IImageWrapperModule.h"
|
||
#include "Modules/ModuleManager.h"
|
||
#include "Misc/FileHelper.h"
|
||
#include "Misc/CommandLine.h"
|
||
#include "Misc/Paths.h"
|
||
#include "Misc/ScopeLock.h"
|
||
#include "HAL/IConsoleManager.h"
|
||
#include "HAL/PlatformMisc.h"
|
||
#include "Policies/CondensedJsonPrintPolicy.h"
|
||
#include "Serialization/JsonWriter.h"
|
||
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0)
|
||
|
||
static bool VF_TileOverlapsVoxelBounds(const FVoxelTileKey& Tile,
|
||
const FVector& BoundsMin, const FVector& BoundsMax);
|
||
static bool VF_TileFootprintsOverlap(const FVoxelTileKey& A, const FVoxelTileKey& B);
|
||
|
||
#if WITH_EDITOR
|
||
#include "VoxelStrateComposer.h"
|
||
#include "VoxelStrateMeasure.h"
|
||
#endif
|
||
|
||
namespace
|
||
{
|
||
int32 GVoxelForgeCollisionGate = 1;
|
||
FAutoConsoleVariableRef CVarVoxelForgeCollisionGate(
|
||
TEXT("voxel.CollisionGate"),
|
||
GVoxelForgeCollisionGate,
|
||
TEXT("Protect the pawn from falling through unresolved ground: 0 off, 1 on."));
|
||
|
||
int32 GVoxelForgeProfileTileGeneration = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeProfileTileGeneration(
|
||
TEXT("voxel.ProfileTileGeneration"),
|
||
GVoxelForgeProfileTileGeneration,
|
||
TEXT("Log worker tile generation time with level/step/cell count."));
|
||
|
||
int32 GVoxelForgeProfileCollisionCookStages = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeProfileCollisionCookStages(
|
||
TEXT("voxel.ProfileCollisionCookStages"),
|
||
GVoxelForgeProfileCollisionCookStages,
|
||
TEXT("Log per-tile collision queue/apply/RMC/future timing and cooked geometry counts."));
|
||
|
||
int32 GVoxelForgeTestFullResClipLevels = -1;
|
||
FAutoConsoleVariableRef CVarVoxelForgeTestFullResClipLevels(
|
||
TEXT("voxel.TestFullResClipLevels"),
|
||
GVoxelForgeTestFullResClipLevels,
|
||
TEXT("Measurement-only override for the streaming full-resolution clip level count; -1 uses the asset."));
|
||
|
||
int32 GVoxelForgeProfileDensity = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeProfileDensity(
|
||
TEXT("voxel.ProfileDensity"),
|
||
GVoxelForgeProfileDensity,
|
||
TEXT("Collect sampled per-density profiling for the game path."));
|
||
int32 GVoxelForgeProfileDensityFull = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeProfileDensityFull(
|
||
TEXT("voxel.ProfileDensityFull"),
|
||
GVoxelForgeProfileDensityFull,
|
||
TEXT("Collect full per-operator profiling for the game path."));
|
||
int32 GVoxelForgePerfAttribution = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgePerfAttribution(
|
||
TEXT("voxel.PerfAttribution"),
|
||
GVoxelForgePerfAttribution,
|
||
TEXT("Collect low-overhead component attribution for the game path."));
|
||
int32 GVoxelForgeSampleStacks = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeSampleStacks(
|
||
TEXT("voxel.SampleStacks"),
|
||
GVoxelForgeSampleStacks,
|
||
TEXT("Sample active generation worker stacks at the requested interval in microseconds; 0 is off."));
|
||
|
||
// The nested 8^3 proof is useful for focused/offline measurements, but it is opt-in for
|
||
// streaming because every qualifying mixed tile would pay the recursive proof again.
|
||
int32 GVoxelForgeUseBlockEarlyOut = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeUseBlockEarlyOut(
|
||
TEXT("voxel.UseBlockEarlyOut"),
|
||
GVoxelForgeUseBlockEarlyOut,
|
||
TEXT("Use the optional nested 8x8x8 mesher classifier in streaming."));
|
||
|
||
// Outer classifier benchmark modes: 0 = disabled, 1 = current discarded exact validation.
|
||
int32 GVoxelForgeOuterClassifierMode = 0;
|
||
FAutoConsoleVariableRef CVarVoxelForgeOuterClassifierMode(
|
||
TEXT("voxel.OuterClassifierMode"),
|
||
GVoxelForgeOuterClassifierMode,
|
||
TEXT("Outer tile classifier mode: 0 off, 1 discarded exact validation."));
|
||
|
||
// Cheap structural proofs for LOD tiles whose exact core lattice lies entirely in a gap/seal
|
||
// (AllSolid) or constant out-of-layout air (AllAir). They are independent of the discarded
|
||
// full classifier and default on; 0 is retained for per-tile identity/A-B traces.
|
||
int32 GVoxelForgeSealedSolidProof = 1;
|
||
FAutoConsoleVariableRef CVarVoxelForgeSealedSolidProof(
|
||
TEXT("voxel.SealedSolidProof"),
|
||
GVoxelForgeSealedSolidProof,
|
||
TEXT("Skip meshing only when an exact gap/seal or constant-air proof holds: 0 off, 1 on."));
|
||
|
||
// Startup command-line cvars can arrive before the game world has spawned AVoxelWorld (or
|
||
// before its first tiles have become visible). Keep the diagnostic edit pending until Tick has
|
||
// a loaded tile; otherwise a startup measurement silently becomes a no-op or edits the wrong
|
||
// position.
|
||
int32 GVoxelForgePendingTestModification = 0;
|
||
float GVoxelForgePendingModificationRadius = 3.0f;
|
||
float GVoxelForgePendingModificationStrength = 10.0f;
|
||
bool GVoxelForgeTestModificationLoadedProofTarget = false;
|
||
bool GVoxelForgeTestModificationLoadedGeometryTarget = false;
|
||
bool GVoxelForgeTestModificationCommandLineConsumed = false;
|
||
bool GVoxelForgeStreamingBudgetReported = false;
|
||
bool GVoxelForgeGenerationCapHitReported = false;
|
||
int32 GVoxelForgeMaxPendingTilesObserved = 0;
|
||
|
||
struct FVisualGapWitnessKey
|
||
{
|
||
FVoxelTileKey Tile;
|
||
int32 Index = 0;
|
||
bool operator==(const FVisualGapWitnessKey& Other) const
|
||
{
|
||
return Tile == Other.Tile && Index == Other.Index;
|
||
}
|
||
};
|
||
|
||
FORCEINLINE uint32 GetTypeHash(const FVisualGapWitnessKey& Key)
|
||
{
|
||
return HashCombine(::GetTypeHash(Key.Tile), ::GetTypeHash(Key.Index));
|
||
}
|
||
|
||
struct FVisualGapWitness
|
||
{
|
||
FVisualGapWitnessKey Key;
|
||
FVector WorldPosition = FVector::ZeroVector;
|
||
};
|
||
|
||
struct FVisualGapMissingSample
|
||
{
|
||
double BeginSeconds = 0.0;
|
||
uint64 BeginFrame = 0;
|
||
FVector WorldPosition = FVector::ZeroVector;
|
||
};
|
||
|
||
struct FVisualGapAuditState
|
||
{
|
||
bool bEnabled = false;
|
||
bool bInjectEarlyHide = false;
|
||
bool bLegacyVisualHandoff = false;
|
||
bool bInjectionDone = false;
|
||
bool bInjectionCaught = false;
|
||
bool bCollectionStarted = false;
|
||
bool bCollecting = false;
|
||
uint64 Frame = 0;
|
||
uint64 CollectionFrames = 0;
|
||
uint64 LongestGapFrames = 0;
|
||
uint64 InjectionRestoreFrame = 0;
|
||
double BeginSeconds = 0.0;
|
||
double LastSampleSeconds = 0.0;
|
||
int32 Samples = 0;
|
||
int32 GapEvents = 0;
|
||
int32 GapPointFrames = 0;
|
||
int32 GapFrames = 0;
|
||
int32 MaxGapPoints = 0;
|
||
int32 CameraViewFrames = 0;
|
||
int32 MissingSamples = 0;
|
||
int32 LoggedGapEvents = 0;
|
||
double LongestGapSeconds = 0.0;
|
||
bool bReportLogged = false;
|
||
TMap<FVoxelTileKey, TArray<FVector>> WitnessesByTile;
|
||
TArray<FVisualGapWitness> PreviousVisibleWitnesses;
|
||
TMap<FVisualGapWitnessKey, FVisualGapMissingSample> MissingSinceSeconds;
|
||
TArray<TPair<FVector, FVector>> EditedBounds;
|
||
FVoxelTileKey InjectedTile;
|
||
FVisualGapWitnessKey InjectedWitnessKey;
|
||
TWeakObjectPtr<URealtimeMeshComponent> InjectedComponent;
|
||
};
|
||
|
||
struct FDigMotionTestState
|
||
{
|
||
bool bEnabled = false;
|
||
bool bStarted = false;
|
||
bool bUnderDigIssued = false;
|
||
bool bSawFalling = false;
|
||
bool bResultLogged = false;
|
||
int32 NearDigCount = 8;
|
||
int32 NearDigsIssued = 0;
|
||
int32 Frame = 0;
|
||
int32 NearFrames = 0;
|
||
int32 NearGateFrames = 0;
|
||
int32 NearFallingFrames = 0;
|
||
int32 NearVerticalMotionFrames = 0;
|
||
int32 UnderFrames = 0;
|
||
int32 UnderGateFrames = 0;
|
||
float NearMaxDownwardVelocity = 0.0f;
|
||
float UnderMaxDownwardVelocity = 0.0f;
|
||
double BeginSeconds = 0.0;
|
||
double NextDigSeconds = 0.0;
|
||
double UnderDigSeconds = 0.0;
|
||
double LastFrameSeconds = 0.0;
|
||
double DigIntervalSeconds = 0.35;
|
||
FVector StartPosition = FVector::ZeroVector;
|
||
FVector UnderDigStartPosition = FVector::ZeroVector;
|
||
};
|
||
|
||
TMap<AVoxelWorld*, FVisualGapAuditState> GVoxelForgeVisualGapAudits;
|
||
TMap<AVoxelWorld*, FDigMotionTestState> GVoxelForgeDigMotionTests;
|
||
|
||
void VF_StartVisualGapAuditWindow(FVisualGapAuditState& Audit, double Now)
|
||
{
|
||
Audit.bCollecting = true;
|
||
Audit.bCollectionStarted = true;
|
||
Audit.CollectionFrames = 0;
|
||
Audit.BeginSeconds = Now;
|
||
Audit.LastSampleSeconds = Now;
|
||
Audit.Samples = 0;
|
||
Audit.GapEvents = 0;
|
||
Audit.GapPointFrames = 0;
|
||
Audit.GapFrames = 0;
|
||
Audit.MaxGapPoints = 0;
|
||
Audit.CameraViewFrames = 0;
|
||
Audit.MissingSamples = 0;
|
||
Audit.LoggedGapEvents = 0;
|
||
Audit.LongestGapSeconds = 0.0;
|
||
Audit.LongestGapFrames = 0;
|
||
Audit.MissingSinceSeconds.Reset();
|
||
}
|
||
}
|
||
|
||
static bool VF_ProjectHeadlessPassageFloor(
|
||
const FVoxelPassage& Passage, const FVector& PositionVoxel, float& OutFloorZ)
|
||
{
|
||
const auto NearLanding = [&PositionVoxel](const FVoxelPassageLanding& Landing) -> bool
|
||
{
|
||
if (!(Landing.HalfWidth > 0.0f))
|
||
{
|
||
return false;
|
||
}
|
||
const FVector2D Delta(
|
||
PositionVoxel.X - Landing.StandingPoint.X,
|
||
PositionVoxel.Y - Landing.StandingPoint.Y);
|
||
return Delta.SizeSquared() <= FMath::Square(Landing.HalfWidth + 1.0f);
|
||
};
|
||
|
||
if (NearLanding(Passage.UpperLanding))
|
||
{
|
||
OutFloorZ = Passage.UpperLanding.FloorZ;
|
||
return VoxelMath::IsFinite(OutFloorZ);
|
||
}
|
||
if (NearLanding(Passage.LowerLanding))
|
||
{
|
||
OutFloorZ = Passage.LowerLanding.FloorZ;
|
||
return VoxelMath::IsFinite(OutFloorZ);
|
||
}
|
||
|
||
float SupportRadius = 0.0f;
|
||
return VoxelPassageGeometry::ProjectWalkableTunnelFloor(
|
||
Passage.ControlPoints, Passage.ControlRadii, PositionVoxel,
|
||
OutFloorZ, SupportRadius);
|
||
}
|
||
|
||
AVoxelWorld::AVoxelWorld()
|
||
{
|
||
PrimaryActorTick.bCanEverTick = true;
|
||
// Run the collision-readiness check before physics. The pawn receives this actor as a tick
|
||
// prerequisite once it is possessed, so a movement step cannot race the gate on the same frame.
|
||
PrimaryActorTick.TickGroup = TG_PrePhysics;
|
||
}
|
||
|
||
//=============================================================================
|
||
// T1.f — build the RMC geometry buffers OFF the game thread.
|
||
//=============================================================================
|
||
// FRealtimeMeshStreamSet is plain CPU data; on the game thread (in ApplyMeshToTile) the
|
||
// per-vertex/per-triangle builder loop is the dominant streaming cost (game thread >6 ms while
|
||
// moving, GPU/draw idle). It touches ONLY the POD MeshData arrays — no UObject, no generator — so
|
||
// it's safe on the gen worker. The game thread then just uploads the finished streams
|
||
// (CreateSectionGroup). Byte-identical geometry; only WHERE it runs differs.
|
||
static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, const FVoxelMeshData& MeshData)
|
||
{
|
||
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
||
Builder.EnableTangents();
|
||
Builder.EnableTexCoords();
|
||
Builder.EnableColors(); // masques matériau F6 (palette biome / pente / fondu) — voir le mesher
|
||
Builder.EnablePolyGroups();
|
||
|
||
const int32 NumVertices = MeshData.Vertices.Num();
|
||
Builder.ReserveAdditionalVertices(NumVertices);
|
||
for (int32 i = 0; i < NumVertices; i++)
|
||
{
|
||
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
|
||
if (MeshData.Normals.IsValidIndex(i))
|
||
{
|
||
Vertex.SetNormalAndTangent((FVector3f)MeshData.Normals[i], FVector3f(1, 0, 0));
|
||
}
|
||
if (MeshData.UVs.IsValidIndex(i))
|
||
{
|
||
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
||
}
|
||
if (MeshData.Colors.IsValidIndex(i))
|
||
{
|
||
Vertex.SetColor(MeshData.Colors[i]);
|
||
}
|
||
}
|
||
|
||
// F17 — the mesher packs the index buffer as [ground run | sky-cap run] (see
|
||
// FVoxelMeshData::NumCeilingTriangles): polygroup 0 = ground, 1 = sky-cap ceiling.
|
||
// RMC derives one section per contiguous group run (material slot = group index).
|
||
const int32 NumIndices = MeshData.Triangles.Num();
|
||
const int32 FirstCapIndex = NumIndices - MeshData.NumCeilingTriangles * 3;
|
||
Builder.ReserveAdditionalTriangles(NumIndices / 3);
|
||
for (int32 i = 0; i < NumIndices; i += 3)
|
||
{
|
||
Builder.AddTriangle((uint32)MeshData.Triangles[i],
|
||
(uint32)MeshData.Triangles[i + 1],
|
||
(uint32)MeshData.Triangles[i + 2],
|
||
(i >= FirstCapIndex) ? 1 : 0 /*poly group*/);
|
||
}
|
||
}
|
||
|
||
class FScopedGenerationPause
|
||
{
|
||
public:
|
||
explicit FScopedGenerationPause(AVoxelWorld* InWorld)
|
||
: World(InWorld)
|
||
{
|
||
if (!World) return;
|
||
|
||
World->bGenerationPaused.store(true, std::memory_order_release);
|
||
|
||
// The game thread owns this gate; workers only read Generator/Mesher and enqueue results.
|
||
// La barrière est prise sur le thread de jeu ; les workers ne font qu'énumérer et Enqueue.
|
||
const double Deadline = FPlatformTime::Seconds() + 5.0;
|
||
while (World->ActiveTaskCount.load(std::memory_order_relaxed) > 0)
|
||
{
|
||
if (FPlatformTime::Seconds() > Deadline) return;
|
||
FPlatformProcess::Yield();
|
||
}
|
||
|
||
if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return;
|
||
|
||
// DensityVolume owns a separate FRunnableThread; the world task counter and decoration
|
||
// counter cannot observe it. Stop/join it before mutating the generator or manager.
|
||
if (World->DensityVolume)
|
||
{
|
||
World->DensityVolume->PauseForGenerationChange();
|
||
bDensityVolumePaused = true;
|
||
}
|
||
|
||
bAcquired = true;
|
||
}
|
||
|
||
~FScopedGenerationPause()
|
||
{
|
||
if (World)
|
||
{
|
||
if (bDensityVolumePaused && World->DensityVolume)
|
||
{
|
||
World->DensityVolume->ResumeAfterGenerationChange();
|
||
}
|
||
World->bGenerationPaused.store(false, std::memory_order_release);
|
||
}
|
||
}
|
||
|
||
bool Acquired() const { return bAcquired; }
|
||
|
||
private:
|
||
AVoxelWorld* World = nullptr;
|
||
bool bAcquired = false;
|
||
bool bDensityVolumePaused = false;
|
||
};
|
||
|
||
#if WITH_EDITOR
|
||
namespace
|
||
{
|
||
void VF_SetComposerRuntimeBounds(FVoxelStrateArchetypeParams& Params,
|
||
float TopWorldZ, float BottomWorldZ)
|
||
{
|
||
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
|
||
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
|
||
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
|
||
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
|
||
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
|
||
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
|
||
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
|
||
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
|
||
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
|
||
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
|
||
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
|
||
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
|
||
}
|
||
|
||
float VF_ComposerBoundarySeal(const FVoxelStrateArchetypeParams& Params,
|
||
ECaveGeneratorType Archetype)
|
||
{
|
||
switch (Archetype)
|
||
{
|
||
case ECaveGeneratorType::FlatPlain:
|
||
case ECaveGeneratorType::CrystalChamber:
|
||
return Params.SlabParams.BoundarySealThickness;
|
||
case ECaveGeneratorType::Maze:
|
||
return Params.MazeParams.BoundarySealThickness;
|
||
case ECaveGeneratorType::SurfaceWorld:
|
||
return Params.SurfaceParams.BoundarySealThickness;
|
||
case ECaveGeneratorType::VerticalShafts:
|
||
return Params.VerticalShaftParams.BoundarySealThickness;
|
||
case ECaveGeneratorType::FloatingIslands:
|
||
return Params.FloatingIslandParams.BoundarySealThickness;
|
||
case ECaveGeneratorType::Underwater:
|
||
case ECaveGeneratorType::TunnelNetwork:
|
||
default:
|
||
return Params.TunnelNetworkParams.BoundarySealThickness;
|
||
}
|
||
}
|
||
|
||
class FComposerStackDensitySampler final : public IVoxelStrateDensitySampler
|
||
{
|
||
public:
|
||
explicit FComposerStackDensitySampler(const FVoxelOpStack& InStack)
|
||
: Stack(InStack) {}
|
||
|
||
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
|
||
{
|
||
return Stack.EvalMC(WorldX, WorldY, WorldZ);
|
||
}
|
||
|
||
private:
|
||
const FVoxelOpStack& Stack;
|
||
};
|
||
|
||
bool VF_BuildComposerReferenceStack(
|
||
const FVoxelStrateComposerCandidate& Candidate,
|
||
const UVoxelGenerator& Generator,
|
||
const UVoxelStrateManager& StrateManager,
|
||
int32 TargetStrateIndex,
|
||
int32 TargetTopChunkZ,
|
||
int32 TargetBottomChunkZ,
|
||
FVoxelOpStack& OutStack,
|
||
FVoxelOpContext& OutContext,
|
||
FString& OutError)
|
||
{
|
||
const float TopWorldZ = (float)(TargetTopChunkZ + 1) * CHUNK_SIZE;
|
||
const float BottomWorldZ = (float)TargetBottomChunkZ * CHUNK_SIZE;
|
||
FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams;
|
||
VF_SetComposerRuntimeBounds(Params, TopWorldZ, BottomWorldZ);
|
||
|
||
bool bBuilt = false;
|
||
if (Candidate.Regions.RegionCount > 1)
|
||
{
|
||
FVoxelStrateRegionManifest RegionManifest = Candidate.Regions;
|
||
VF_RekeyStrateRegionManifest(RegionManifest, Candidate.Seed,
|
||
TargetStrateIndex);
|
||
RegionManifest.StrateTopWorldZ = TopWorldZ;
|
||
RegionManifest.StrateBottomWorldZ = BottomWorldZ;
|
||
RegionManifest.bHasGlobalStructuralParams = true;
|
||
for (FVoxelStrateRegion& Region : RegionManifest.Regions)
|
||
{
|
||
VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams,
|
||
TopWorldZ, BottomWorldZ);
|
||
}
|
||
bBuilt = VF_BuildStrateRegionStack(
|
||
RegionManifest, Generator.OriginSpineRadius, &StrateManager,
|
||
OutStack, OutContext, &OutError);
|
||
}
|
||
else if (Candidate.bStructureRoll)
|
||
{
|
||
bBuilt = VF_BuildStackFromRecipe(
|
||
Candidate.Recipe, Params, Generator.Seed, Generator.OriginSpineRadius,
|
||
&StrateManager, OutStack, OutContext, &OutError);
|
||
}
|
||
else
|
||
{
|
||
bBuilt = VF_BuildNativeStrateStackForCandidate(
|
||
Candidate.Archetype, Params, Generator.Seed, Generator.OriginSpineRadius,
|
||
Generator.WorldRadiusVoxels, Generator.EdgeSealThickness, &StrateManager,
|
||
OutStack, OutContext);
|
||
if (!bBuilt && OutError.IsEmpty())
|
||
{
|
||
OutError = TEXT("The production native candidate stack could not be materialised.");
|
||
}
|
||
}
|
||
if (!bBuilt)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// The recipe builder deliberately defaults the world-edge context to 0 for offline
|
||
// measurements. A live candidate must use the actual world's edge law, while its
|
||
// vertical bounds and structural seal remain candidate-owned.
|
||
const int32 MidChunkZ = TargetBottomChunkZ
|
||
+ (TargetTopChunkZ - TargetBottomChunkZ) / 2;
|
||
OutContext.ChunkCoord = FIntVector(0, 0, MidChunkZ);
|
||
OutContext.Step = 1;
|
||
OutContext.LayoutVersion = StrateManager.GetLayoutVersion();
|
||
OutContext.WorldRadiusVoxels = Generator.WorldRadiusVoxels;
|
||
OutContext.EdgeSealThickness = Generator.EdgeSealThickness;
|
||
OutStack.PrepareChunk(OutContext);
|
||
return true;
|
||
}
|
||
|
||
bool VF_ComposerReferenceIsComparable(
|
||
const AVoxelWorld& World,
|
||
const FStrateSlot& TargetSlot,
|
||
ECaveGeneratorType Archetype,
|
||
const FIntVector& RepresentativeChunk,
|
||
FString& OutReason)
|
||
{
|
||
OutReason.Reset();
|
||
if (World.DiffLayer && World.DiffLayer->HasAnyMods())
|
||
{
|
||
OutReason = TEXT("diff-layer-modifications-present");
|
||
return false;
|
||
}
|
||
|
||
const FStrateDisturbanceParams Disturbances =
|
||
World.StrateManager->GetDisturbanceParamsForChunk(RepresentativeChunk);
|
||
if (Disturbances.ChasmDensity > 0.0f
|
||
|| Disturbances.BridgeDensity > 0.0f
|
||
|| Disturbances.RidgeDensity > 0.0f)
|
||
{
|
||
OutReason = TEXT("live-disturbance-post-present");
|
||
return false;
|
||
}
|
||
|
||
// The production SurfaceWorld stack may include a generator-owned biome field and
|
||
// per-biome params. The standalone candidate stack intentionally does not invent that
|
||
// context; fall back to the live metric and mark the density check as unavailable.
|
||
if (Archetype == ECaveGeneratorType::SurfaceWorld
|
||
&& TargetSlot.Definition != nullptr
|
||
&& TargetSlot.Definition->Biomes.Num() > 0)
|
||
{
|
||
OutReason = TEXT("surface-biome-context-present");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
FVoxelStrateMeasureSettings VF_ComposerMeasureSettings()
|
||
{
|
||
FVoxelStrateMeasureSettings Settings;
|
||
Settings.SampleStep = 4;
|
||
Settings.RadiusInVoxels = 256;
|
||
Settings.CenterXY = FVector2D::ZeroVector;
|
||
Settings.MaxCells = 8000000;
|
||
Settings.MaxRouteRetries = 16;
|
||
Settings.HeadroomCells = 2;
|
||
Settings.InteriorMarginVoxels = -1;
|
||
return Settings;
|
||
}
|
||
|
||
bool VF_ComposerDensitySanityCheck(
|
||
const UVoxelGenerator& Generator,
|
||
const FVoxelOpStack& ReferenceStack,
|
||
int32 TargetTopChunkZ,
|
||
int32 TargetBottomChunkZ,
|
||
float& OutMaxDelta,
|
||
int32& OutMismatches,
|
||
int32& OutSamples)
|
||
{
|
||
// Keep all probes inside one representative chunk column where possible. This exercises
|
||
// both the live thread-local refetch and the prepared reference stack without making the
|
||
// check depend on a particular room being present at a hand-picked coordinate.
|
||
static const FIntVector Offsets[] =
|
||
{
|
||
FIntVector(1, 1, -15),
|
||
FIntVector(7, 13, -7),
|
||
FIntVector(17, 3, 1),
|
||
FIntVector(29, 27, 9),
|
||
FIntVector(3, 23, 17),
|
||
FIntVector(19, 19, 25),
|
||
FIntVector(11, 29, 31),
|
||
FIntVector(27, 5, -23),
|
||
};
|
||
|
||
const int32 MidChunkZ = TargetBottomChunkZ
|
||
+ (TargetTopChunkZ - TargetBottomChunkZ) / 2;
|
||
const int32 BottomWorldZ = TargetBottomChunkZ * CHUNK_SIZE;
|
||
const int32 TopWorldZExclusive = (TargetTopChunkZ + 1) * CHUNK_SIZE;
|
||
const int32 MidWorldZ = MidChunkZ * CHUNK_SIZE + CHUNK_SIZE / 2;
|
||
|
||
OutMaxDelta = 0.0f;
|
||
OutMismatches = 0;
|
||
OutSamples = 0;
|
||
for (const FIntVector& Offset : Offsets)
|
||
{
|
||
const float X = (float)Offset.X;
|
||
const float Y = (float)Offset.Y;
|
||
const float Z = (float)FMath::Clamp(
|
||
MidWorldZ + Offset.Z, BottomWorldZ + 1, TopWorldZExclusive - 1);
|
||
const float Live = Generator.GetDensityAt(X, Y, Z);
|
||
const float Reference = ReferenceStack.EvalMC(X, Y, Z);
|
||
|
||
uint32 LiveBits = 0;
|
||
uint32 ReferenceBits = 0;
|
||
FMemory::Memcpy(&LiveBits, &Live, sizeof(LiveBits));
|
||
FMemory::Memcpy(&ReferenceBits, &Reference, sizeof(ReferenceBits));
|
||
const bool bBitEqual = LiveBits == ReferenceBits;
|
||
if (!bBitEqual)
|
||
{
|
||
++OutMismatches;
|
||
}
|
||
|
||
const float Delta = (VoxelMath::IsFinite(Live) && VoxelMath::IsFinite(Reference))
|
||
? FMath::Abs(Live - Reference) : FLT_MAX;
|
||
OutMaxDelta = FMath::Max(OutMaxDelta, Delta);
|
||
++OutSamples;
|
||
}
|
||
return OutMismatches == 0;
|
||
}
|
||
}
|
||
#endif
|
||
|
||
//=============================================================================
|
||
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::RegenerateAllChunks()
|
||
{
|
||
// Bump the generation epoch so in-flight async tasks become stale.
|
||
// ProcessPendingChunks will discard any result with an old epoch.
|
||
GenerationEpoch++;
|
||
PreEditDensityCache.Reset();
|
||
|
||
// Tear down every tile component, then clear all tile state. Components are GC-safe via
|
||
// actor ownership. T2.c: PARK them instead of destroying — the reload right after this
|
||
// is exactly the burst the pool exists for (overflow past the cap is destroyed).
|
||
const int32 Count = LoadedTiles.Num();
|
||
for (auto& Pair : TileComponents) { if (Pair.Value) ReleaseTileComponent(Pair.Value); }
|
||
TileComponents.Empty();
|
||
LoadedTiles.Empty();
|
||
LoadedTileGeometry.Reset();
|
||
ModificationEpoch = 0;
|
||
LastModificationMinVoxel = FVector::ZeroVector;
|
||
LastModificationMaxVoxel = FVector::ZeroVector;
|
||
ModificationRevisions.Reset();
|
||
AppliedModificationEpoch.Reset();
|
||
CollisionReadyTiles.Empty();
|
||
CollisionSuppressedUntilCook.Empty();
|
||
CollisionSuppressedAtCycles.Empty();
|
||
CollisionNotRequiredTiles.Empty();
|
||
CollisionSolidTiles.Empty();
|
||
PendingCollisionCooks.Empty();
|
||
|
||
// Decorations/water are keyed per level-0 chunk — clear them all.
|
||
if (ContentManager) { ContentManager->ClearAll(); }
|
||
|
||
// Density volume: bump epoch (drop in-flight fills) + drop data → full refill next Tick.
|
||
if (DensityVolume) { DensityVolume->Reset(); }
|
||
|
||
// Mark any still-running request obsolete. Keep its PendingTiles slot and token map until the
|
||
// worker/result drains; otherwise a late old result could remove the slot belonging to a new
|
||
// request for the same key. Live-edit callers normally arrive with generation paused (and no
|
||
// active tasks), in which case the stale queue can be discarded immediately.
|
||
for (TPair<FVoxelTileKey, TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>>& Pair :
|
||
PendingTileCancellation)
|
||
{
|
||
if (Pair.Value) { Pair.Value->bObsolete.store(true, std::memory_order_release); }
|
||
}
|
||
if (ActiveTaskCount.load(std::memory_order_relaxed) == 0)
|
||
{
|
||
FChunkResult StaleResult;
|
||
while (CriticalProcessQueue.Dequeue(StaleResult)) {}
|
||
while (EditedProcessQueue.Dequeue(StaleResult)) {}
|
||
while (NearProcessQueue.Dequeue(StaleResult)) {}
|
||
while (ProcessQueue.Dequeue(StaleResult)) {}
|
||
PendingTiles.Empty();
|
||
PendingTileCancellation.Empty();
|
||
}
|
||
// Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
|
||
// try to UnloadTile coords that no longer exist.
|
||
PendingUnload.Empty();
|
||
// Re-mesh queues reference now-unloaded tiles — drop them (they'd be skipped anyway).
|
||
DirtyRemeshQueue.Empty();
|
||
BandRemeshQueue.Empty();
|
||
// §9.4 collision-only tracking references destroyed tiles — clear (rebuilt on the next crossing).
|
||
CollisionOnlyTiles.Empty();
|
||
PrevCollisionOnlyTiles.Empty();
|
||
CollisionDesiredTiles.Empty();
|
||
CollisionGateDesiredTiles.Empty();
|
||
|
||
// Reset streaming state so the next Tick rebuilds the desired set and reloads.
|
||
LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||
bAllChunksLoaded = false;
|
||
DesiredSorted.Reset();
|
||
CriticalDesiredTiles.Reset();
|
||
DesiredTransitionDescendants.Reset();
|
||
DesiredTransitionMaxLevel = 0;
|
||
DesiredStamped.Reset();
|
||
++DesiredEpoch;
|
||
TransitionHold.Reset();
|
||
TransitionHoldQueue.Reset();
|
||
TransitionHoldCursor = 0;
|
||
|
||
// Tick will reload all tiles on the next frame with fresh params.
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): cleared %d tiles"), GenerationEpoch, Count);
|
||
}
|
||
|
||
void AVoxelWorld::RebuildStrates()
|
||
{
|
||
bool bLayoutRebuilt = true;
|
||
bool bCreatedStrateManager = false;
|
||
{
|
||
FScopedGenerationPause Guard(this);
|
||
if (!Guard.Acquired())
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] RebuildStrates: generation pause timed out; no mutation applied."));
|
||
return;
|
||
}
|
||
|
||
// A live Settings reference can change from no strata to a populated pool/season. Create
|
||
// the manager lazily in that case; Initialize itself is transactional, so an existing
|
||
// valid manager remains untouched when the new asset is invalid.
|
||
if (!StrateManager && Settings
|
||
&& (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0
|
||
|| Settings->FixedStrates.Num() > 0))
|
||
{
|
||
StrateManager = NewObject<UVoxelStrateManager>(this);
|
||
bCreatedStrateManager = true;
|
||
}
|
||
|
||
if (StrateManager && Settings)
|
||
{
|
||
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
|
||
bLayoutRebuilt = StrateManager->Initialize(
|
||
Settings, Settings->GetEffectiveWorldSeed());
|
||
}
|
||
|
||
if (!bLayoutRebuilt)
|
||
{
|
||
// A newly-created manager has no valid previous state to preserve. Do not leave the
|
||
// generator wired to a failed temporary object when this was the first live setup.
|
||
if (bCreatedStrateManager) { StrateManager = nullptr; }
|
||
}
|
||
else
|
||
{
|
||
// Rebind every consumer of the settings snapshot while generation is paused. The
|
||
// manager layout alone is not sufficient: world radius, edge sealing, seed, mesher
|
||
// policy, edit budget, decoration state, and the optional density volume all cache
|
||
// settings-derived values independently.
|
||
if (Generator)
|
||
{
|
||
Generator->InitializeSettings(Settings);
|
||
Generator->SetStrateManager(StrateManager);
|
||
Generator->SetDiffLayer(DiffLayer);
|
||
}
|
||
if (Mesher && Settings)
|
||
{
|
||
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
|
||
Mesher->SkirtCells = Settings->SkirtCells;
|
||
Mesher->LODOctaveDrop = Settings->LODOctaveDrop;
|
||
Mesher->bUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0;
|
||
}
|
||
if (DiffLayer && Settings)
|
||
{
|
||
DiffLayer->SetBudget(Settings->MaxModifications,
|
||
Settings->MaxBrushRadius,
|
||
Settings->MaxTotalVolume);
|
||
}
|
||
if (ContentManager)
|
||
{
|
||
ContentManager->Initialize(
|
||
this, StrateManager, Generator, Settings,
|
||
Settings ? Settings->GetEffectiveWorldSeed() : 0);
|
||
ContentManager->ClearAll();
|
||
}
|
||
if (Settings && Settings->bEnableDensityVolume)
|
||
{
|
||
if (!DensityVolume) { DensityVolume = NewObject<UVoxelDensityVolume>(this); }
|
||
if (DensityVolume)
|
||
{
|
||
DensityVolume->Initialize(this, Generator, Settings);
|
||
DensityVolume->Reset();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (DensityVolume)
|
||
{
|
||
DensityVolume->NotifyShutdown();
|
||
DensityVolume = nullptr;
|
||
}
|
||
SetDensityVolumeMaterialParamsDisabled();
|
||
}
|
||
if (AtmosphereManager) AtmosphereManager->Reset();
|
||
}
|
||
}
|
||
|
||
if (!bLayoutRebuilt)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] RebuildStrates refused an invalid season; loaded tiles were not regenerated."));
|
||
return;
|
||
}
|
||
|
||
// Reload all chunks against the rebuilt strate data.
|
||
RegenerateAllChunks();
|
||
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RebuildStrates: strate layout + passages rebuilt from settings."));
|
||
}
|
||
|
||
void AVoxelWorld::ValidateDeterminism()
|
||
{
|
||
// F2 — window-invariance regression test (§8.4). The density function must return the SAME
|
||
// value for a coordinate no matter which chunk's thread_local caches (SDF rooms, strate
|
||
// memo, biome grid, surface columns, lattice bakes) happen to be warm. Historically THE
|
||
// source of chunk seams — and the invariant every "bit-identical" hot-path refactor claims
|
||
// to preserve. This runs on the game thread, whose caches are isolated from the workers.
|
||
if (!Generator)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelForge] ValidateDeterminism: no Generator — run during PIE."));
|
||
return;
|
||
}
|
||
|
||
FVector PlayerPosition = FVector::ZeroVector;
|
||
if (!TryGetPlayerPosition(PlayerPosition))
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelForge] ValidateDeterminism: no player pawn — run during PIE after possession."));
|
||
return;
|
||
}
|
||
const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(PlayerPosition));
|
||
|
||
float MaxRepeatDelta = 0.0f; // same alignment sampled twice — must be 0 (statelessness)
|
||
float MaxWindowDelta = 0.0f; // left-warmed vs right-warmed — must be 0 (window invariance)
|
||
FVector WorstP = FVector::ZeroVector;
|
||
int32 Mismatches = 0, Points = 0;
|
||
|
||
// Points hugging the X boundary between chunk (CX,CY) and (CX+1,CY): they sit inside BOTH
|
||
// chunks' cache search boxes (box = chunk extent + margin), so either alignment may legally
|
||
// serve them — exactly the cross-window case that seams when an invariant breaks.
|
||
const float BoundaryX = (float)((CenterChunk.X + 1) * CHUNK_SIZE);
|
||
for (int32 iy = 0; iy < 16; ++iy)
|
||
{
|
||
for (int32 iz = 0; iz < 8; ++iz)
|
||
{
|
||
const float Y = (float)(CenterChunk.Y * CHUNK_SIZE) + (float)iy * 2.0f + 0.5f;
|
||
const float Z = (float)(CenterChunk.Z * CHUNK_SIZE) + (float)iz * 4.0f + 0.5f;
|
||
for (const float Side : { -0.5f, 0.5f }) // just left / just right of the boundary
|
||
{
|
||
const float X = BoundaryX + Side;
|
||
++Points;
|
||
|
||
// Warm every cache from the LEFT chunk's middle, sample the point twice.
|
||
Generator->GetDensityAt(BoundaryX - (float)CHUNK_SIZE * 0.5f, Y, Z);
|
||
const float DLeft = Generator->GetDensityAt(X, Y, Z);
|
||
const float DLeft2 = Generator->GetDensityAt(X, Y, Z);
|
||
|
||
// Re-warm from the RIGHT chunk (rebuilds the boxes centred there), resample.
|
||
Generator->GetDensityAt(BoundaryX + (float)CHUNK_SIZE * 0.5f, Y, Z);
|
||
const float DRight = Generator->GetDensityAt(X, Y, Z);
|
||
|
||
MaxRepeatDelta = FMath::Max(MaxRepeatDelta, FMath::Abs(DLeft - DLeft2));
|
||
const float WDelta = FMath::Abs(DLeft - DRight);
|
||
if (WDelta > MaxWindowDelta)
|
||
{
|
||
MaxWindowDelta = WDelta;
|
||
WorstP = FVector(X, Y, Z);
|
||
}
|
||
if (WDelta > 0.0f) { ++Mismatches; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (MaxWindowDelta == 0.0f && MaxRepeatDelta == 0.0f)
|
||
{
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelForge] ValidateDeterminism: OK — %d boundary points at chunk (%d,%d,%d), window delta 0, repeat delta 0."),
|
||
Points, CenterChunk.X, CenterChunk.Y, CenterChunk.Z);
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelForge] ValidateDeterminism: FAIL — %d/%d points mismatch, max window delta %.6f (repeat %.6f) at voxel (%.1f, %.1f, %.1f). Window-invariance regression — see ARCHITECTURE §8.4."),
|
||
Mismatches, Points, MaxWindowDelta, MaxRepeatDelta, WorstP.X, WorstP.Y, WorstP.Z);
|
||
}
|
||
}
|
||
|
||
#if WITH_EDITOR
|
||
void AVoxelWorld::ApplyComposerCandidate()
|
||
{
|
||
UWorld* World = GetWorld();
|
||
if (World == nullptr || !World->IsPlayInEditor())
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: run this button from a PIE world."));
|
||
return;
|
||
}
|
||
if (bShuttingDown.load(std::memory_order_acquire)
|
||
|| bGenerationPaused.load(std::memory_order_acquire))
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: generation is already stopping or paused."));
|
||
return;
|
||
}
|
||
if (!Settings || !Generator || !StrateManager)
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: BeginPlay has not initialized Settings, Generator, and StrateManager."));
|
||
return;
|
||
}
|
||
|
||
const TArray<FStrateSlot>& Layout = StrateManager->GetLayout();
|
||
const FStrateSlot* TargetSlot = nullptr;
|
||
for (const FStrateSlot& Slot : Layout)
|
||
{
|
||
if (Slot.StrateIndex == ComposerTargetStrateIndex)
|
||
{
|
||
TargetSlot = &Slot;
|
||
break;
|
||
}
|
||
}
|
||
if (TargetSlot == nullptr || TargetSlot->Definition == nullptr)
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: ComposerTargetStrateIndex=%d is not a live layout slot (layout has %d slots)."),
|
||
ComposerTargetStrateIndex, Layout.Num());
|
||
return;
|
||
}
|
||
|
||
FVoxelStrateCorpus Corpus;
|
||
FString CorpusReport;
|
||
if (!Corpus.LoadFromAssetRegistry(CorpusReport))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: composer corpus is unavailable: %s"),
|
||
*CorpusReport);
|
||
return;
|
||
}
|
||
|
||
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
|
||
Corpus, ComposerSeed, ComposerCandidateIndex, bComposerRollStructure);
|
||
if (!Candidate.bValid)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d roll failed: %s"),
|
||
ComposerSeed, ComposerCandidateIndex, *Candidate.FailureReason);
|
||
return;
|
||
}
|
||
|
||
const int32 TargetTopChunkZ = TargetSlot->TopChunkZ;
|
||
const int32 TargetBottomChunkZ = TargetSlot->BottomChunkZ;
|
||
const float TargetTopWorldZ = (float)(TargetTopChunkZ + 1) * CHUNK_SIZE;
|
||
const float TargetBottomWorldZ = (float)TargetBottomChunkZ * CHUNK_SIZE;
|
||
const FIntVector RepresentativeChunk(
|
||
0, 0, TargetBottomChunkZ + (TargetTopChunkZ - TargetBottomChunkZ) / 2);
|
||
|
||
// Materialise before mutating the live manager. A bad recipe or unsupported native family
|
||
// therefore leaves the running world untouched.
|
||
FVoxelStrateArchetypeParams PreflightParams = Candidate.ArchetypeParams;
|
||
VF_SetComposerRuntimeBounds(PreflightParams, TargetTopWorldZ, TargetBottomWorldZ);
|
||
FVoxelOpStack PreflightStack;
|
||
FVoxelOpContext PreflightContext;
|
||
FString PreflightError;
|
||
bool bPreflightBuilt = false;
|
||
FVoxelStrateRegionManifest PreflightRegions;
|
||
if (Candidate.Regions.RegionCount > 1)
|
||
{
|
||
PreflightRegions = Candidate.Regions;
|
||
VF_RekeyStrateRegionManifest(PreflightRegions, Candidate.Seed,
|
||
ComposerTargetStrateIndex);
|
||
PreflightRegions.StrateTopWorldZ = TargetTopWorldZ;
|
||
PreflightRegions.StrateBottomWorldZ = TargetBottomWorldZ;
|
||
PreflightRegions.bHasGlobalStructuralParams = true;
|
||
for (FVoxelStrateRegion& Region : PreflightRegions.Regions)
|
||
{
|
||
VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams,
|
||
TargetTopWorldZ, TargetBottomWorldZ);
|
||
}
|
||
bPreflightBuilt = VF_BuildStrateRegionStack(
|
||
PreflightRegions, Generator->OriginSpineRadius, StrateManager,
|
||
PreflightStack, PreflightContext, &PreflightError);
|
||
}
|
||
else if (Candidate.bStructureRoll)
|
||
{
|
||
bPreflightBuilt = VF_BuildStackFromRecipe(
|
||
Candidate.Recipe, PreflightParams, Generator->Seed, Generator->OriginSpineRadius,
|
||
StrateManager, PreflightStack, PreflightContext, &PreflightError);
|
||
}
|
||
else
|
||
{
|
||
bPreflightBuilt = VF_BuildNativeStrateStackForCandidate(
|
||
Candidate.Archetype, PreflightParams, Generator->Seed, Generator->OriginSpineRadius,
|
||
Generator->WorldRadiusVoxels, Generator->EdgeSealThickness, StrateManager,
|
||
PreflightStack, PreflightContext);
|
||
if (!bPreflightBuilt)
|
||
{
|
||
PreflightError = TEXT("The production native candidate stack could not be materialised.");
|
||
}
|
||
}
|
||
if (!bPreflightBuilt)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"),
|
||
ComposerSeed, ComposerCandidateIndex, *PreflightError);
|
||
return;
|
||
}
|
||
|
||
FString ApplyError;
|
||
{
|
||
// The manager override is a density-only replacement. It does not call Initialize or
|
||
// GeneratePassages while the layout is half-built: the existing slot Z span, content
|
||
// definition, and passage geometry remain stable. The pause drains current workers before
|
||
// the map is changed, and the subsequent RegenerateAllChunks supplies the epoch bump.
|
||
FScopedGenerationPause Guard(this);
|
||
if (!Guard.Acquired())
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: generation pause timed out; no mutation applied."));
|
||
return;
|
||
}
|
||
|
||
if (!StrateManager->SetComposerOverrideForStrate(
|
||
ComposerTargetStrateIndex, Candidate.Seed, Candidate.Archetype,
|
||
Candidate.ArchetypeParams, Candidate.bStructureRoll,
|
||
Candidate.bStructureRoll ? &Candidate.Recipe : nullptr, ApplyError,
|
||
Candidate.Regions.RegionCount > 1 ? &Candidate.Regions : nullptr))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"),
|
||
ComposerSeed, ComposerCandidateIndex, *ApplyError);
|
||
return;
|
||
}
|
||
|
||
// These are the same game-thread-owned side systems reset by RebuildStrates. The full
|
||
// chunk reset below also resets the density volume and preserves DiffLayer edits.
|
||
if (AtmosphereManager) { AtmosphereManager->Reset(); }
|
||
if (ContentManager) { ContentManager->ClearAll(); }
|
||
}
|
||
|
||
// Reuse the established seed/live-edit invalidation path: GenerationEpoch increments here,
|
||
// stale ProcessQueue results are dropped, and the normal Tick submits a fresh stream around
|
||
// the player. No async worker can observe the half-applied manager state.
|
||
RegenerateAllChunks();
|
||
|
||
FVoxelOpStack ReferenceStack;
|
||
FVoxelOpContext ReferenceContext;
|
||
FString ReferenceError;
|
||
const bool bReferenceBuilt = VF_BuildComposerReferenceStack(
|
||
Candidate, *Generator, *StrateManager, ComposerTargetStrateIndex,
|
||
TargetTopChunkZ, TargetBottomChunkZ,
|
||
ReferenceStack, ReferenceContext, ReferenceError);
|
||
|
||
FString ComparableReason;
|
||
const bool bReferenceComparable = bReferenceBuilt
|
||
&& VF_ComposerReferenceIsComparable(
|
||
*this, *TargetSlot, Candidate.Archetype, RepresentativeChunk, ComparableReason);
|
||
|
||
const FVoxelStrateMeasureSettings MeasureSettings = VF_ComposerMeasureSettings();
|
||
FVoxelStrateMetrics Metrics;
|
||
FString MetricSource;
|
||
if (bReferenceComparable)
|
||
{
|
||
FComposerStackDensitySampler Sampler(ReferenceStack);
|
||
Metrics = VF_MeasureStrateWithSampler(
|
||
Sampler, (int32)TargetBottomWorldZ, (int32)TargetTopWorldZ,
|
||
VF_ComposerBoundarySeal(PreflightParams, Candidate.Archetype), MeasureSettings);
|
||
MetricSource = TEXT("candidate-stack");
|
||
}
|
||
else
|
||
{
|
||
// A live diff, disturbance post, or surface biome context makes a standalone stack an
|
||
// unlike oracle. Keep the owner's metric useful by measuring the actual applied generator;
|
||
// the log records why the bit-level oracle was skipped.
|
||
Metrics = VF_MeasureStrate(
|
||
*Generator, *StrateManager, ComposerTargetStrateIndex, MeasureSettings);
|
||
MetricSource = TEXT("live-generator");
|
||
}
|
||
|
||
float MaxDensityDelta = 0.0f;
|
||
int32 DensityMismatches = 0;
|
||
int32 DensitySamples = 0;
|
||
bool bDensityMatch = false;
|
||
if (bReferenceComparable)
|
||
{
|
||
bDensityMatch = VF_ComposerDensitySanityCheck(
|
||
*Generator, ReferenceStack, TargetTopChunkZ, TargetBottomChunkZ,
|
||
MaxDensityDelta, DensityMismatches, DensitySamples);
|
||
}
|
||
|
||
const FString RecipeText = Candidate.bStructureRoll
|
||
? VF_FormatStrateStructureRecipe(Candidate.Recipe)
|
||
: TEXT("parameter-roll");
|
||
const FString DeterminismText = !bReferenceBuilt
|
||
? FString::Printf(TEXT("UNAVAILABLE(%s)"), *ReferenceError)
|
||
: !bReferenceComparable
|
||
? FString::Printf(TEXT("SKIPPED(%s)"), *ComparableReason)
|
||
: FString::Printf(TEXT("%s/%d"), bDensityMatch ? TEXT("PASS") : TEXT("FAIL"),
|
||
DensitySamples);
|
||
const float AirFraction = Metrics.bValid ? Metrics.AirFraction : -1.0f;
|
||
const float LargestShare = Metrics.bValid ? Metrics.LargestComponentShare : -1.0f;
|
||
const float WalkableFraction = Metrics.bValid ? Metrics.WalkableFraction : -1.0f;
|
||
const float FeatureScale = Metrics.bValid ? Metrics.MedianFeatureScale : -1.0f;
|
||
|
||
UE_LOG(LogTemp, Log,
|
||
TEXT("[VoxelWorld] ComposerCandidate applied seed=%d index=%d target=%d recipe=%s archetype=%s metrics=%s air=%.6f largest=%.6f walkable=%.6f feature=%.3f components=%d determinism=%s max_delta=%.9g mismatches=%d epoch=%u"),
|
||
Candidate.Seed, Candidate.Index, ComposerTargetStrateIndex, *RecipeText,
|
||
VF_GetStrateArchetypeName(Candidate.Archetype), *MetricSource,
|
||
AirFraction, LargestShare, WalkableFraction, FeatureScale,
|
||
Metrics.bValid ? Metrics.NumAirComponents : -1,
|
||
*DeterminismText, MaxDensityDelta, DensityMismatches, GenerationEpoch);
|
||
}
|
||
|
||
void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
|
||
{
|
||
Super::PostEditChangeProperty(PropertyChangedEvent);
|
||
|
||
// During PIE with live edit on, a Settings reference change needs the full rebind path: the
|
||
// manager, generator, mesher, diff budget, decorations and density volume each own a snapshot.
|
||
// Data asset edits (and the settings asset itself) are handled separately by
|
||
// OnObjectModifiedInEditor.
|
||
if (bLiveEditStrates && GetWorld() && GetWorld()->IsPlayInEditor())
|
||
{
|
||
const FName ChangedProperty = PropertyChangedEvent.GetPropertyName();
|
||
if (ChangedProperty == GET_MEMBER_NAME_CHECKED(AVoxelWorld, Settings))
|
||
{
|
||
RebuildStrates();
|
||
}
|
||
else
|
||
{
|
||
RegenerateAllChunks();
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
||
{
|
||
// Only react during PIE with live edit enabled
|
||
if (!bLiveEditStrates || !GetWorld() || !GetWorld()->IsPlayInEditor()) return;
|
||
|
||
// Only care about generation-owned assets. This delegate fires for every UObject modification
|
||
// in the editor.
|
||
bool bIsRelevant = false;
|
||
FString AssetName;
|
||
|
||
// Case 1: the settings asset itself was modified
|
||
if (UVoxelSettings* ModifiedSettings = Cast<UVoxelSettings>(ModifiedObject))
|
||
{
|
||
if (ModifiedSettings != Settings) return;
|
||
bIsRelevant = true;
|
||
AssetName = ModifiedSettings->GetName();
|
||
}
|
||
// Case 2: A strate definition was modified
|
||
else if (UVoxelStrateDefinition* ModifiedStrate = Cast<UVoxelStrateDefinition>(ModifiedObject))
|
||
{
|
||
if (!Settings) return;
|
||
AssetName = ModifiedStrate->GetName();
|
||
|
||
// Check the strate pool
|
||
for (const TSoftObjectPtr<UVoxelStrateDefinition>& PoolEntry : Settings->StratePool)
|
||
{
|
||
if (PoolEntry.Get() == ModifiedStrate) { bIsRelevant = true; break; }
|
||
}
|
||
// Check fixed strates
|
||
if (!bIsRelevant)
|
||
{
|
||
for (const auto& FixedEntry : Settings->FixedStrates)
|
||
{
|
||
if (FixedEntry.Value.Get() == ModifiedStrate) { bIsRelevant = true; break; }
|
||
}
|
||
}
|
||
}
|
||
// Case 3: A terrain op definition was modified — check if any strate references it
|
||
else if (UVoxelTerrainOpDefinition* ModifiedOp = Cast<UVoxelTerrainOpDefinition>(ModifiedObject))
|
||
{
|
||
if (!Settings || !StrateManager) return;
|
||
AssetName = ModifiedOp->GetName();
|
||
|
||
// Check every strate definition's terrain op list
|
||
for (const TSoftObjectPtr<UVoxelStrateDefinition>& PoolEntry : Settings->StratePool)
|
||
{
|
||
UVoxelStrateDefinition* Def = PoolEntry.Get();
|
||
if (!Def) continue;
|
||
for (const FStrateTerrainOpEntry& Entry : Def->TerrainOperations)
|
||
{
|
||
if (Entry.Operation.Get() == ModifiedOp) { bIsRelevant = true; break; }
|
||
}
|
||
if (bIsRelevant) break;
|
||
}
|
||
if (!bIsRelevant)
|
||
{
|
||
for (const auto& FixedEntry : Settings->FixedStrates)
|
||
{
|
||
UVoxelStrateDefinition* Def = FixedEntry.Value.Get();
|
||
if (!Def) continue;
|
||
for (const FStrateTerrainOpEntry& Entry : Def->TerrainOperations)
|
||
{
|
||
if (Entry.Operation.Get() == ModifiedOp) { bIsRelevant = true; break; }
|
||
}
|
||
if (bIsRelevant) break;
|
||
}
|
||
}
|
||
}
|
||
// Case 4: a biome definition can change both terrain parameters and decoration/material
|
||
// resolution. Treat it like the owning strate definition, but only when this world references it.
|
||
else if (UVoxelBiomeDefinition* ModifiedBiome = Cast<UVoxelBiomeDefinition>(ModifiedObject))
|
||
{
|
||
if (!Settings || !StrateManager) return;
|
||
AssetName = ModifiedBiome->GetName();
|
||
|
||
for (const FStrateSlot& Slot : StrateManager->GetLayout())
|
||
{
|
||
if (Slot.Definition && Slot.Definition->Biomes.Contains(ModifiedBiome))
|
||
{
|
||
bIsRelevant = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!bIsRelevant) return;
|
||
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Live edit: '%s' modified, regenerating..."),
|
||
*AssetName);
|
||
|
||
// Use the same transactional full rebind as a Settings-reference change. In particular, do not
|
||
// ignore Initialize's return value: an invalid live asset must leave the previous field intact.
|
||
RebuildStrates();
|
||
}
|
||
#endif
|
||
|
||
void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||
{
|
||
// Signal all async tasks to bail out ASAP
|
||
const double EndPlayStartSeconds = FPlatformTime::Seconds();
|
||
bShuttingDown.store(true, std::memory_order_release);
|
||
ReleasePawnCollisionGate();
|
||
if (APawn* Pawn = PawnTickPrerequisite.Get())
|
||
{
|
||
Pawn->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
if (UPawnMovementComponent* PawnMovement = PawnMovementTickPrerequisite.Get())
|
||
{
|
||
PawnMovement->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
PawnTickPrerequisite.Reset();
|
||
PawnMovementTickPrerequisite.Reset();
|
||
UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: shutdown signalled; waiting for %d tasks"),
|
||
ActiveTaskCount.load(std::memory_order_relaxed));
|
||
|
||
// Wait for every running task before destroying UObjects. A timeout here is unsafe: a task
|
||
// already inside GenerateTileResult may still be evaluating a cached op stack that points at
|
||
// this world's manager. Log a slow drain, but never tear the world down underneath a reader.
|
||
const double Deadline = FPlatformTime::Seconds() + 3.0;
|
||
bool bReportedSlowDrain = false;
|
||
while (ActiveTaskCount.load(std::memory_order_relaxed) > 0)
|
||
{
|
||
if (!bReportedSlowDrain && FPlatformTime::Seconds() > Deadline)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EndPlay: waiting for %d tasks after 3s; UObject teardown is deferred"),
|
||
ActiveTaskCount.load(std::memory_order_relaxed));
|
||
bReportedSlowDrain = true;
|
||
}
|
||
FPlatformProcess::Yield(); // Give CPU to other threads
|
||
}
|
||
// Drain any queued results
|
||
FChunkResult Discard;
|
||
while (CriticalProcessQueue.Dequeue(Discard))
|
||
{
|
||
if (Discard.bObsolete) { ++ObsoleteTileAbortCount; }
|
||
}
|
||
while (EditedProcessQueue.Dequeue(Discard))
|
||
{
|
||
if (Discard.bObsolete) { ++ObsoleteTileAbortCount; }
|
||
}
|
||
while (NearProcessQueue.Dequeue(Discard))
|
||
{
|
||
if (Discard.bObsolete) { ++ObsoleteTileAbortCount; }
|
||
}
|
||
while (ProcessQueue.Dequeue(Discard))
|
||
{
|
||
if (Discard.bObsolete) { ++ObsoleteTileAbortCount; }
|
||
}
|
||
UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: workers drained in %.6fs; obsolete_tile_aborts=%d"),
|
||
FPlatformTime::Seconds() - EndPlayStartSeconds, ObsoleteTileAbortCount);
|
||
if (bSampleStacksStarted)
|
||
{
|
||
FVoxelStackSampler::Get().StopAndWrite();
|
||
bSampleStacksStarted = false;
|
||
}
|
||
VoxelCaveMorphology::LogPlayerFitMemoStats();
|
||
LogStreamingLatencySummary();
|
||
WriteTileHashDump();
|
||
const uint64 TilePostReachDifferences =
|
||
VoxelGenLOD::GSkippedPostDifferences.load(std::memory_order_relaxed);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTilePostReach] tiles=%llu origin_reachable=%llu origin_skipped=%llu "
|
||
"passage_landing_reachable=%llu passage_landing_skipped=%llu "
|
||
"passage_structural_reachable=%llu passage_structural_skipped=%llu "
|
||
"debug=%d comparisons=%llu differences=%llu"),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GOriginLandingReachableTiles.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GOriginLandingSkippedTiles.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GPassageLandingReachableTiles.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GPassageLandingSkippedTiles.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GPassageStructuralReachableTiles.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GPassageStructuralSkippedTiles.load(std::memory_order_relaxed)),
|
||
VoxelGenLOD::GTilePostReachDebugEnabled.load(std::memory_order_relaxed) ? 1 : 0,
|
||
static_cast<unsigned long long>(
|
||
VoxelGenLOD::GSkippedPostComparisons.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(TilePostReachDifferences));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTilePostReachKinds] comparisons="
|
||
"origin_air=%llu/origin_floor=%llu/passage_sdf=%llu/landing_air=%llu/"
|
||
"tunnel_air=%llu/landing_floor=%llu/structural=%llu/native_floor=%llu/room_floor=%llu "
|
||
"final_field=%llu differences=%llu/%llu/%llu/%llu/%llu/%llu/%llu/%llu/%llu/%llu"),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::OriginAir)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::OriginFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingSDF)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingAir)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageTunnelAir)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageStructuralPosts)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageNativeFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingRoomFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::FinalField)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::OriginAir)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::OriginFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingSDF)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingAir)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageTunnelAir)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageStructuralPosts)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageNativeFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::PassageLandingRoomFloor)].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::FinalField)].load(
|
||
std::memory_order_relaxed)));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileReachDecisions] core_reachable=%llu core_skipped=%llu "
|
||
"passage_carving_reachable=%llu passage_carving_skipped=%llu"),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTunnelCoreReachableTiles.load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTunnelCoreSkippedTiles.load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GPassageCarvingReachableTiles.load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GPassageCarvingSkippedTiles.load(
|
||
std::memory_order_relaxed)));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileReachCost] core_world_far_calls=%llu far_cycles=%llu "
|
||
"near_calls=%llu near_cycles=%llu"),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][1].load(
|
||
std::memory_order_relaxed)));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileReachCost] core_tail_far_calls=%llu far_cycles=%llu "
|
||
"near_calls=%llu near_cycles=%llu"),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][1].load(
|
||
std::memory_order_relaxed)));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileReachCost] passage_carving_far_calls=%llu far_cycles=%llu "
|
||
"near_calls=%llu near_cycles=%llu"),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][1].load(
|
||
std::memory_order_relaxed)));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileBlockReachCost] core_world_far_calls=%llu far_cycles=%llu "
|
||
"near_calls=%llu near_cycles=%llu; core_tail_far_calls=%llu far_cycles=%llu "
|
||
"near_calls=%llu near_cycles=%llu; passage_far_calls=%llu far_cycles=%llu "
|
||
"near_calls=%llu near_cycles=%llu"),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::TunnelCoreTail)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][0].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCalls[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][1].load(
|
||
std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(VoxelGenLOD::GTileReachBlockCostCycles[
|
||
static_cast<uint8>(VoxelGenLOD::ETileReachCostKind::PassageCarving)][1].load(
|
||
std::memory_order_relaxed)));
|
||
if (VoxelGenLOD::GTilePostReachDebugEnabled.load(std::memory_order_relaxed)
|
||
&& TilePostReachDifferences != 0)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeTilePostReach] soundness failure: skipped post changed the field."));
|
||
FPlatformMisc::RequestExitWithStatus(
|
||
false, 1, TEXT("VoxelForge tile post reach soundness failure"));
|
||
}
|
||
PendingTiles.Empty();
|
||
PendingTileCancellation.Empty();
|
||
PendingUnload.Empty();
|
||
DirtyRemeshQueue.Empty();
|
||
BandRemeshQueue.Empty();
|
||
PendingCollisionCooks.Empty();
|
||
CollisionReadyTiles.Empty();
|
||
CollisionSuppressedUntilCook.Empty();
|
||
CollisionSuppressedAtCycles.Empty();
|
||
CollisionDesiredTiles.Empty();
|
||
CollisionNotRequiredTiles.Empty();
|
||
CollisionSolidTiles.Empty();
|
||
for (const TPair<URealtimeMesh*, FDelegateHandle>& Pair : CollisionBodyUpdatedHandles)
|
||
{
|
||
if (Pair.Key != nullptr)
|
||
{
|
||
Pair.Key->OnCollisionBodyUpdated().Remove(Pair.Value);
|
||
}
|
||
}
|
||
CollisionBodyUpdatedHandles.Empty();
|
||
|
||
// Stop + drain the decoration march tasks (they read the Generator) before UObject teardown.
|
||
if (ContentManager)
|
||
{
|
||
ContentManager->NotifyShutdown();
|
||
}
|
||
|
||
// Stop + drain the density-volume fill tasks (they read the Generator) before UObject teardown.
|
||
if (DensityVolume)
|
||
{
|
||
DensityVolume->NotifyShutdown();
|
||
}
|
||
|
||
// Destroy any spawned atmosphere layer actors.
|
||
if (AtmosphereManager)
|
||
{
|
||
AtmosphereManager->Reset();
|
||
}
|
||
|
||
// Unbind the data asset monitoring delegate
|
||
#if WITH_EDITOR
|
||
if (OnObjectModifiedHandle.IsValid())
|
||
{
|
||
FCoreUObjectDelegates::OnObjectModified.Remove(OnObjectModifiedHandle);
|
||
OnObjectModifiedHandle.Reset();
|
||
}
|
||
#endif
|
||
|
||
if (VoxelForgeStartupTrace::IsActive())
|
||
{
|
||
VoxelForgeStartupTrace::Finish(TEXT("end_play_before_steady_state"));
|
||
}
|
||
|
||
LogVisualStalenessAudit();
|
||
GVoxelForgeDigMotionTests.Remove(this);
|
||
GVoxelForgeVisualGapAudits.Remove(this);
|
||
LogReadyTransitionVisibilityMeasure();
|
||
Super::EndPlay(EndPlayReason);
|
||
}
|
||
|
||
void AVoxelWorld::BeginPlay()
|
||
{
|
||
VoxelForgeStartupTrace::BeginFromCommandLine();
|
||
VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("BeginPlay"));
|
||
Super::BeginPlay();
|
||
int32 MeasureReadyTransitionVisibility = 0;
|
||
FParse::Value(FCommandLine::Get(),
|
||
TEXT("voxel.MeasureReadyTransitionVisibility="), MeasureReadyTransitionVisibility);
|
||
bMeasureReadyTransitionVisibility = MeasureReadyTransitionVisibility != 0;
|
||
ReadyTransitionVisibilitySamples.Reset();
|
||
FarFieldSubmitSamples.Reset();
|
||
if (bMeasureReadyTransitionVisibility)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeReadyVisibilityMeasure] enabled=1 instrumentation=command_line"));
|
||
}
|
||
VoxelCaveMorphology::ConfigurePlayerFitMemoFromCommandLine();
|
||
VoxelCaveMorphology::ResetPlayerFitMemoStats();
|
||
bShuttingDown.store(false, std::memory_order_relaxed);
|
||
LOD0ReadySamples.Reset();
|
||
ObsoleteTileAbortCount = 0;
|
||
PeakObservedPawnSpeedCmPerSecond = 0.0f;
|
||
TotalWorkerGenerationCycles.store(0, std::memory_order_relaxed);
|
||
TotalWorkerGenerationTasks.store(0, std::memory_order_relaxed);
|
||
TotalObsoleteWorkerCycles.store(0, std::memory_order_relaxed);
|
||
TotalObsoleteWorkerTasks.store(0, std::memory_order_relaxed);
|
||
TotalValidationDensityCalls.store(0, std::memory_order_relaxed);
|
||
TotalMesherDensityCalls.store(0, std::memory_order_relaxed);
|
||
TotalSealedSolidProofCandidates.store(0, std::memory_order_relaxed);
|
||
TotalSealedSolidProofSkips.store(0, std::memory_order_relaxed);
|
||
TotalOutOfLayoutAirProofSkips.store(0, std::memory_order_relaxed);
|
||
AppliedTileCount = 0;
|
||
AppliedVisibleTileCount = 0;
|
||
AppliedTriangleCount = 0;
|
||
LoadedTileGeometry.Reset();
|
||
for (int32 LOD = 0; LOD < TrackedClassifierLODCount; ++LOD)
|
||
{
|
||
AppliedVisibleTileCountByLevel[LOD] = 0;
|
||
AppliedTriangleCountByLevel[LOD] = 0;
|
||
AppliedBandTileCountByLevel[LOD] = 0;
|
||
}
|
||
for (int32 LOD = 0; LOD < TrackedClassifierLODCount; ++LOD)
|
||
{
|
||
OuterClassifierCallsByLOD[LOD].store(0, std::memory_order_relaxed);
|
||
for (int32 Verdict = 0; Verdict < 3; ++Verdict)
|
||
{
|
||
OuterClassifierVerdictsByLOD[LOD][Verdict].store(0, std::memory_order_relaxed);
|
||
}
|
||
}
|
||
ConfigureHeadlessStreamingTest();
|
||
|
||
// Some headless harnesses load module CVars after the engine's startup-CVar pass. Read the
|
||
// profiling switches explicitly as well, so a reported game profile cannot silently run with
|
||
// the default mode because of startup ordering.
|
||
int32 CommandLineProfile = GVoxelForgeProfileDensity;
|
||
int32 CommandLineProfileFull = GVoxelForgeProfileDensityFull;
|
||
int32 CommandLinePerfAttribution = GVoxelForgePerfAttribution;
|
||
int32 CommandLineProfileTileGeneration = GVoxelForgeProfileTileGeneration;
|
||
int32 CommandLineProfileCollisionCookStages = GVoxelForgeProfileCollisionCookStages;
|
||
int32 CommandLineTestFullResClipLevels = GVoxelForgeTestFullResClipLevels;
|
||
int32 CommandLineUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut;
|
||
int32 CommandLineOuterClassifierMode = GVoxelForgeOuterClassifierMode;
|
||
int32 CommandLineSealedSolidProof = GVoxelForgeSealedSolidProof;
|
||
int32 CommandLineSampleStacks = GVoxelForgeSampleStacks;
|
||
int32 CommandLineCollisionGate = GVoxelForgeCollisionGate;
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.ProfileDensity="), CommandLineProfile))
|
||
{
|
||
GVoxelForgeProfileDensity = CommandLineProfile;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.ProfileDensityFull="), CommandLineProfileFull))
|
||
{
|
||
GVoxelForgeProfileDensityFull = CommandLineProfileFull;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.PerfAttribution="), CommandLinePerfAttribution))
|
||
{
|
||
GVoxelForgePerfAttribution = CommandLinePerfAttribution;
|
||
}
|
||
if (FParse::Value(
|
||
FCommandLine::Get(),
|
||
TEXT("voxel.ProfileTileGeneration="),
|
||
CommandLineProfileTileGeneration))
|
||
{
|
||
GVoxelForgeProfileTileGeneration = CommandLineProfileTileGeneration;
|
||
}
|
||
if (FParse::Value(
|
||
FCommandLine::Get(),
|
||
TEXT("voxel.ProfileCollisionCookStages="),
|
||
CommandLineProfileCollisionCookStages))
|
||
{
|
||
GVoxelForgeProfileCollisionCookStages = CommandLineProfileCollisionCookStages;
|
||
}
|
||
if (FParse::Value(
|
||
FCommandLine::Get(),
|
||
TEXT("voxel.TestFullResClipLevels="),
|
||
CommandLineTestFullResClipLevels))
|
||
{
|
||
GVoxelForgeTestFullResClipLevels = CommandLineTestFullResClipLevels;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseBlockEarlyOut="), CommandLineUseBlockEarlyOut))
|
||
{
|
||
GVoxelForgeUseBlockEarlyOut = CommandLineUseBlockEarlyOut;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.OuterClassifierMode="), CommandLineOuterClassifierMode))
|
||
{
|
||
GVoxelForgeOuterClassifierMode = CommandLineOuterClassifierMode;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.SealedSolidProof="), CommandLineSealedSolidProof))
|
||
{
|
||
GVoxelForgeSealedSolidProof = CommandLineSealedSolidProof;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.SampleStacks="), CommandLineSampleStacks))
|
||
{
|
||
GVoxelForgeSampleStacks = CommandLineSampleStacks;
|
||
}
|
||
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.CollisionGate="), CommandLineCollisionGate))
|
||
{
|
||
GVoxelForgeCollisionGate = CommandLineCollisionGate;
|
||
}
|
||
GVoxelForgeProfileTileGeneration = GVoxelForgeProfileTileGeneration != 0 ? 1 : 0;
|
||
GVoxelForgeUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0 ? 1 : 0;
|
||
GVoxelForgeOuterClassifierMode = FMath::Clamp(GVoxelForgeOuterClassifierMode, 0, 1);
|
||
GVoxelForgeSealedSolidProof = GVoxelForgeSealedSolidProof != 0 ? 1 : 0;
|
||
GVoxelForgeSampleStacks = FMath::Clamp(
|
||
GVoxelForgeSampleStacks,
|
||
0,
|
||
static_cast<int32>(FVoxelStackSampler::MaxIntervalUs));
|
||
GVoxelForgeCollisionGate = GVoxelForgeCollisionGate != 0 ? 1 : 0;
|
||
bCollisionGateEnabled = GVoxelForgeCollisionGate != 0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] startup enabled=%d"),
|
||
bCollisionGateEnabled ? 1 : 0);
|
||
|
||
if (GVoxelForgePerfAttribution != 0)
|
||
{
|
||
VoxelDensityProfile::SetMode(
|
||
VoxelDensityProfile::EMode::Attribution,
|
||
VoxelDensityProfile::DefaultSampleInterval);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDensityProfile] game_mode=attribution sample_interval=%u"),
|
||
VoxelDensityProfile::DefaultSampleInterval);
|
||
}
|
||
else if (GVoxelForgeProfileDensityFull != 0)
|
||
{
|
||
VoxelDensityProfile::SetMode(
|
||
VoxelDensityProfile::EMode::Full,
|
||
VoxelDensityProfile::DefaultSampleInterval);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDensityProfile] game_mode=full sample_interval=%u"),
|
||
VoxelDensityProfile::DefaultSampleInterval);
|
||
}
|
||
else if (GVoxelForgeProfileDensity != 0)
|
||
{
|
||
VoxelDensityProfile::SetMode(
|
||
VoxelDensityProfile::EMode::Sampled,
|
||
VoxelDensityProfile::DefaultSampleInterval);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDensityProfile] game_mode=sampled sample_interval=%u"),
|
||
VoxelDensityProfile::DefaultSampleInterval);
|
||
}
|
||
|
||
if (!Settings)
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] No Settings assigned — world won't generate."));
|
||
return;
|
||
}
|
||
|
||
int32 TestRenderDistanceChunks = 0;
|
||
const bool bTestRenderDistance =
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestRenderDistanceChunks="),
|
||
TestRenderDistanceChunks)
|
||
&& TestRenderDistanceChunks > 0;
|
||
int32 TestMaxClipLevel = -1;
|
||
const bool bTestMaxClipLevel =
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestMaxClipLevel="), TestMaxClipLevel);
|
||
if (bTestRenderDistance || bTestMaxClipLevel)
|
||
{
|
||
if (UVoxelSettings* TestSettings = DuplicateObject<UVoxelSettings>(Settings, this))
|
||
{
|
||
const int32 AuthoredRenderDistance = Settings->RenderDistanceChunks;
|
||
const int32 AuthoredMaxClipLevel = Settings->MaxClipLevel;
|
||
if (bTestRenderDistance)
|
||
{
|
||
TestSettings->RenderDistanceChunks = TestRenderDistanceChunks;
|
||
}
|
||
if (bTestMaxClipLevel)
|
||
{
|
||
TestSettings->MaxClipLevel = FMath::Clamp(TestMaxClipLevel, 0, 8);
|
||
}
|
||
Settings = TestSettings;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeHorizonMeasure] transient_settings=1 "
|
||
"render_distance_chunks=%d authored=%d max_clip_level=%d authored=%d"),
|
||
Settings->RenderDistanceChunks, AuthoredRenderDistance,
|
||
Settings->MaxClipLevel, AuthoredMaxClipLevel);
|
||
}
|
||
}
|
||
|
||
int32 TestCeilingView = 0;
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingView="), TestCeilingView);
|
||
bTestCeilingViewEnabled = TestCeilingView != 0;
|
||
if (bTestCeilingViewEnabled)
|
||
{
|
||
TestCeilingCameraWorldPosition = FVector(10583.003176, 20477.525456, -38442.958976);
|
||
TestCeilingCameraRotation = FRotator(78.399901, 255.200004, 0.0);
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingCameraX="),
|
||
TestCeilingCameraWorldPosition.X);
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingCameraY="),
|
||
TestCeilingCameraWorldPosition.Y);
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingCameraZ="),
|
||
TestCeilingCameraWorldPosition.Z);
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingCameraPitch="),
|
||
TestCeilingCameraRotation.Pitch);
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingCameraYaw="),
|
||
TestCeilingCameraRotation.Yaw);
|
||
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestCeilingCaptureDelaySeconds="),
|
||
TestCeilingCaptureDelaySeconds);
|
||
TestCeilingCaptureDelaySeconds = FMath::Max(TestCeilingCaptureDelaySeconds, 1.0f);
|
||
TestCeilingViewBeginSeconds = FPlatformTime::Seconds();
|
||
bHeadlessStreamingTestCenterOverride = true;
|
||
HeadlessStreamingTestCenterVoxel = WorldToLocalVoxel(TestCeilingCameraWorldPosition);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestCeiling] camera_world_cm=(%.3f,%.3f,%.3f) "
|
||
"camera_rotation=(%.3f,%.3f,%.3f) center_vox=(%.3f,%.3f,%.3f) capture_delay_s=%.1f"),
|
||
TestCeilingCameraWorldPosition.X, TestCeilingCameraWorldPosition.Y,
|
||
TestCeilingCameraWorldPosition.Z, TestCeilingCameraRotation.Pitch,
|
||
TestCeilingCameraRotation.Yaw, TestCeilingCameraRotation.Roll,
|
||
HeadlessStreamingTestCenterVoxel.X, HeadlessStreamingTestCenterVoxel.Y,
|
||
HeadlessStreamingTestCenterVoxel.Z, TestCeilingCaptureDelaySeconds);
|
||
}
|
||
|
||
// The owner's current DA_Settings is intentionally a one-strate surface layout, so it cannot
|
||
// exercise the separate inter-strate traversal regression. The explicit headless crossing
|
||
// switch uses a transient copy of the authored four-definition corpus; no package is modified
|
||
// or saved, and ordinary gameplay keeps the assigned settings untouched.
|
||
if (bHeadlessStrateCrossingTest)
|
||
{
|
||
UVoxelSettings* CrossingSettings = DuplicateObject<UVoxelSettings>(Settings, this);
|
||
TArray<UVoxelStrateDefinition*> CrossingDefinitions;
|
||
for (int32 DefinitionIndex = 1; DefinitionIndex <= 4; ++DefinitionIndex)
|
||
{
|
||
const FString AssetPath = FString::Printf(
|
||
TEXT("/Game/VoxelForge/DA_Strate%d.DA_Strate%d"),
|
||
DefinitionIndex, DefinitionIndex);
|
||
if (UVoxelStrateDefinition* Definition =
|
||
LoadObject<UVoxelStrateDefinition>(nullptr, *AssetPath))
|
||
{
|
||
CrossingDefinitions.Add(Definition);
|
||
}
|
||
}
|
||
if (CrossingSettings != nullptr && CrossingDefinitions.Num() == 4)
|
||
{
|
||
CrossingSettings->Season.Reset();
|
||
CrossingSettings->StratePool.Reset();
|
||
CrossingSettings->FixedStrates.Reset();
|
||
CrossingSettings->TotalStrates = CrossingDefinitions.Num();
|
||
for (int32 DefinitionIndex = 0; DefinitionIndex < CrossingDefinitions.Num();
|
||
++DefinitionIndex)
|
||
{
|
||
CrossingSettings->FixedStrates.Add(
|
||
DefinitionIndex, CrossingDefinitions[DefinitionIndex]);
|
||
}
|
||
Settings = CrossingSettings;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] fixture=transient_authored_corpus "
|
||
"strates=%d assets_not_saved=1"),
|
||
CrossingDefinitions.Num());
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelForgeStrateCrossingTest] fixture=transient_authored_corpus "
|
||
"unavailable definitions=%d; using assigned settings"),
|
||
CrossingDefinitions.Num());
|
||
}
|
||
}
|
||
|
||
if (VoxelForgeStartupTrace::IsActive())
|
||
{
|
||
FString SettingsPath = Settings->GetPathName();
|
||
SettingsPath.ReplaceInline(TEXT("\\"), TEXT("\\\\"));
|
||
SettingsPath.ReplaceInline(TEXT("\""), TEXT("\\\""));
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("settings_resolved"), FString::Printf(
|
||
TEXT("\"asset\":\"%s\",\"seed\":%d,\"total_strates\":%d,\"inter_strate_gap_chunks\":%d,"
|
||
"\"strate_pool\":%d,\"strate_content_cut_min_level\":%d,"
|
||
"\"effective_strate_content_cut_min_level\":%d,\"max_clip_level\":%d,\"clip_radius\":%d,"
|
||
"\"render_distance_chunks\":%d,"
|
||
"\"enable_density_volume\":%d,\"density_volume_resolution\":%d,\"density_volume_levels\":%d,"
|
||
"\"density_volume_gpu_upload\":%d"),
|
||
*SettingsPath,
|
||
Settings->GetEffectiveWorldSeed(), Settings->TotalStrates,
|
||
Settings->InterStrateGapChunks, Settings->StratePool.Num(),
|
||
Settings->StrateContentCutMinLevel,
|
||
Settings->GetEffectiveStrateContentCutMinLevel(), Settings->MaxClipLevel,
|
||
Settings->ClipRadius, Settings->RenderDistanceChunks,
|
||
Settings->bEnableDensityVolume ? 1 : 0, Settings->DensityVolumeResolution,
|
||
Settings->DensityVolumeLevels,
|
||
Settings->bDensityVolumeGPUUpload ? 1 : 0));
|
||
}
|
||
|
||
// Tiles never move once generated, so make the actor root STATIC. RMC already requests the
|
||
// cached static DRAW path (section group DrawType defaults to Static), but a Movable parent
|
||
// forces every child back to Movable — which also defeats Virtual Shadow Map caching (Movable
|
||
// geometry re-renders its shadow every frame; that's the cost that made us turn VSM off). With
|
||
// a Static root + Static tile components, draws cache AND VSM can cache the terrain's shadows,
|
||
// so VSM can be turned back on cheaply. Content decoration HISMs are likewise Static (placed once,
|
||
// never move). Movable children (atmosphere fog/sky, water planes — these follow the player) under a
|
||
// Static root are allowed. NOTE: a Static actor can't be moved in-editor —
|
||
// VoxelWorld is expected to sit at the origin.
|
||
if (USceneComponent* Root = GetRootComponent())
|
||
{
|
||
Root->SetMobility(EComponentMobility::Static);
|
||
}
|
||
|
||
// Générateur + mesher (UObjects légers)
|
||
// TRANSLATION SEULEMENT / TRANSLATION ONLY.
|
||
// Tout le systeme raisonne en boites ALIGNEES SUR LES AXES DU MONDE : bornes de tuile, verdicts
|
||
// ClassifyBox, fenetre du clipmap, culling, distance de streaming. Une rotation ou une echelle
|
||
// non unitaire fait qu une tuile cesse d etre alignee sur les axes en espace monde, et TOUTE
|
||
// cette arithmetique devient fausse -- pas degradee : structurellement fausse.
|
||
// The whole system reasons in WORLD-AXIS-ALIGNED boxes: tile bounds, ClassifyBox verdicts, the
|
||
// clipmap window, culling, streaming distance. A rotation or non-unit scale means a tile is no
|
||
// longer axis-aligned in world space and none of that arithmetic survives - it does not degrade,
|
||
// it breaks structurally. Translating the actor IS supported (that is the point of the
|
||
// WorldToLocal* helpers); rotating or scaling it is not.
|
||
{
|
||
const FTransform ActorXf = GetActorTransform();
|
||
if (!ActorXf.GetRotation().Rotator().IsNearlyZero(0.01f) ||
|
||
!ActorXf.GetScale3D().Equals(FVector::OneVector, 0.001f))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] Actor has rotation %s / scale %s. VoxelForge supports TRANSLATION ONLY - ")
|
||
TEXT("tile bounds, ClassifyBox verdicts, the clipmap window and culling all assume ")
|
||
TEXT("world-axis-aligned boxes. Reset rotation to 0 and scale to 1."),
|
||
*ActorXf.GetRotation().Rotator().ToString(), *ActorXf.GetScale3D().ToString());
|
||
}
|
||
}
|
||
|
||
Generator = NewObject<UVoxelGenerator>(this);
|
||
Mesher = NewObject<UVoxelMarchingCubesMesher>(this);
|
||
|
||
Generator->InitializeSettings(Settings);
|
||
Mesher->SetGenerator(Generator);
|
||
Mesher->SetShutdownFlag(&bShuttingDown);
|
||
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
|
||
Mesher->SkirtCells = Settings->SkirtCells;
|
||
Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off
|
||
Mesher->bUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0;
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeClassifierConfig] outer_mode=%d sealed_solid_proof=%d nested_block_early_out=%d"),
|
||
GVoxelForgeOuterClassifierMode, GVoxelForgeSealedSolidProof,
|
||
Mesher->bUseBlockEarlyOut ? 1 : 0);
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeFullResClipConfig] authored=%d effective=%d override=%d"),
|
||
Settings->FullResClipLevels,
|
||
GVoxelForgeTestFullResClipLevels >= 0
|
||
? FMath::Clamp(GVoxelForgeTestFullResClipLevels, 1, 9)
|
||
: FMath::Max(1, Settings->FullResClipLevels),
|
||
GVoxelForgeTestFullResClipLevels);
|
||
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("generator_mesher_constructed"), FString::Printf(
|
||
TEXT("\"generator\":1,\"mesher\":1,\"generate_skirts\":%d,\"full_res_clip_levels\":%d,\"coarse_tile_cells\":%d"),
|
||
Settings->bGenerateSkirts ? 1 : 0,
|
||
Settings->FullResClipLevels, Settings->CoarseTileCells));
|
||
|
||
// Système de strates — a cooked season takes precedence; otherwise the authored pool path is
|
||
// exactly the legacy one.
|
||
if (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0
|
||
|| Settings->FixedStrates.Num() > 0)
|
||
{
|
||
StrateManager = NewObject<UVoxelStrateManager>(this);
|
||
if (!StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed()))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] World generation disabled because its assigned season is invalid or incomplete."));
|
||
bShuttingDown.store(true, std::memory_order_release);
|
||
SetActorTickEnabled(false);
|
||
return;
|
||
}
|
||
Generator->SetStrateManager(StrateManager);
|
||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"),
|
||
StrateManager->GetNumStrates());
|
||
}
|
||
|
||
InitializeHeadlessStrateCrossingTest();
|
||
|
||
// Diff layer — stocke les modifications du joueur par dessus la densité
|
||
// procédurale. Créé systématiquement (coût nul tant qu'il n'y a pas d'édit).
|
||
DiffLayer = NewObject<UVoxelDiffLayer>(this);
|
||
DiffLayer->SetBudget(Settings->MaxModifications, Settings->MaxBrushRadius, Settings->MaxTotalVolume);
|
||
Generator->SetDiffLayer(DiffLayer);
|
||
|
||
// Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes.
|
||
ContentManager = NewObject<UVoxelContentManager>(this);
|
||
ContentManager->Initialize(
|
||
this, StrateManager, Generator, Settings, Settings->GetEffectiveWorldSeed());
|
||
|
||
// Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers.
|
||
if (bManageAtmosphere && StrateManager)
|
||
{
|
||
AtmosphereManager = NewObject<UVoxelAtmosphereManager>(this);
|
||
AtmosphereManager->Initialize(this, StrateManager, Generator);
|
||
}
|
||
|
||
// Density volume — player-centred clipmap streamed to the GPU for mini-sun raymarched shadows.
|
||
if (Settings->bEnableDensityVolume)
|
||
{
|
||
DensityVolume = NewObject<UVoxelDensityVolume>(this);
|
||
DensityVolume->Initialize(this, Generator, Settings);
|
||
}
|
||
else
|
||
{
|
||
SetDensityVolumeMaterialParamsDisabled();
|
||
}
|
||
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("density_volume_resolved"), FString::Printf(
|
||
TEXT("\"enabled\":%d,\"created\":%d,\"gpu_upload\":%d,\"resolution\":%d,\"levels\":%d"),
|
||
Settings->bEnableDensityVolume ? 1 : 0, DensityVolume != nullptr ? 1 : 0,
|
||
Settings->bDensityVolumeGPUUpload ? 1 : 0,
|
||
Settings->DensityVolumeResolution, Settings->DensityVolumeLevels));
|
||
|
||
#if WITH_EDITOR
|
||
// Listen for data asset edits during PIE so live edit can detect
|
||
// strate definition changes (PostEditChangeProperty only fires for
|
||
// properties on this actor itself, not on referenced data assets).
|
||
OnObjectModifiedHandle = FCoreUObjectDelegates::OnObjectModified.AddUObject(
|
||
this, &AVoxelWorld::OnObjectModifiedInEditor);
|
||
#endif
|
||
|
||
// A possessed pawn may already exist when BeginPlay runs. Engage the same pre-physics gate
|
||
// immediately; Tick repeats the check for late possession and for pawn replacement.
|
||
FVector InitialPlayerPosition = FVector::ZeroVector;
|
||
APawn* InitialPawn = nullptr;
|
||
if (TryGetPlayerPosition(InitialPlayerPosition, &InitialPawn))
|
||
{
|
||
UpdatePawnCollisionGate(InitialPawn, InitialPlayerPosition, 0.0f);
|
||
}
|
||
|
||
if (GVoxelForgeSampleStacks > 0)
|
||
{
|
||
const FString SamplerOutputDirectory = FPaths::Combine(
|
||
FPaths::ProjectDir(), TEXT("Plugins/VoxelForge/Saved"));
|
||
bSampleStacksStarted = FVoxelStackSampler::Get().Start(
|
||
static_cast<uint32>(GVoxelForgeSampleStacks),
|
||
SamplerOutputDirectory,
|
||
TEXT("game"));
|
||
if (!bSampleStacksStarted)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] stack sampler requested but could not be started."));
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ConfigureHeadlessStreamingTest()
|
||
{
|
||
GVoxelForgeVisualGapAudits.Remove(this);
|
||
GVoxelForgeDigMotionTests.Remove(this);
|
||
bHeadlessStreamingTestMovement = false;
|
||
bHeadlessStreamingTestExitOnSteady = false;
|
||
bHeadlessStreamingTestExitRequested = false;
|
||
bStartupTraceThroughCrossing = false;
|
||
bHeadlessStreamingTestCenterOverride = false;
|
||
HeadlessStreamingTestCenterVoxel = FVector::ZeroVector;
|
||
HeadlessStreamingTestMoveAttempts = 0;
|
||
HeadlessStreamingTestDistanceCm = 0.0;
|
||
HeadlessStreamingTestSpeedCmPerSecond = 0.0f;
|
||
HeadlessStreamingTestStartSeconds = 0.0f;
|
||
HeadlessStreamingTestExitSeconds = 0.0f;
|
||
HeadlessStreamingTestDirection = FVector::XAxisVector;
|
||
HeadlessStreamingTestLastActualPosition = FVector::ZeroVector;
|
||
HeadlessStreamingTestBeginSeconds = 0.0;
|
||
HeadlessStreamingTestLastElapsedSeconds = 0.0;
|
||
bHeadlessCollisionGateStressTest = false;
|
||
bHeadlessCollisionGateStressTestStartPlaced = false;
|
||
bHeadlessCollisionGateStressTestStarted = false;
|
||
bHeadlessCollisionGateStressTestPassed = false;
|
||
bHeadlessCollisionGateStressTestFailed = false;
|
||
bHeadlessCollisionGateStressTestExitRequested = false;
|
||
bHeadlessCollisionGateStressTestGateWasEngaged = false;
|
||
bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = false;
|
||
bHeadlessCollisionGateStressTestFullStop = false;
|
||
bHeadlessCollisionGateStressTestStartHeightCrossed = false;
|
||
bHeadlessCollisionGateStressTestTerrainViolation = false;
|
||
bHeadlessCollisionGateStressTestTerrainMeasurementMissing = false;
|
||
bHeadlessCollisionGateStressTestLanded = false;
|
||
bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured = false;
|
||
bHeadlessCollisionGateStressTestSettingsReported = false;
|
||
bHeadlessCollisionGateStressDeepDrop = false;
|
||
bHeadlessCollisionGateStressDeepDropPrepared = false;
|
||
bHeadlessCollisionGateStressDeepDropPrewarmReported = false;
|
||
bHeadlessCollisionGateStressDeepDropSurfaceCached = false;
|
||
HeadlessCollisionGateStressDeepDropSurfaceXYVox = FVector2D::ZeroVector;
|
||
HeadlessCollisionGateStressDeepDropSurfaceZVox = 0.0f;
|
||
HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f;
|
||
HeadlessCollisionGateStressTestDurationSeconds = 2.0f;
|
||
HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f;
|
||
HeadlessCollisionGateStressTestBeginSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestStartSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestLandedSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestGateStartSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestDistanceCm = 0.0;
|
||
HeadlessCollisionGateStressTestGateDistanceCm = 0.0;
|
||
HeadlessCollisionGateStressTestTotalGateDurationSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestMaxGateDurationSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestFullStopDurationSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestLaunchSupportCheckSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = 0.0;
|
||
HeadlessCollisionGateStressTestLaunchSupportReadySeconds = 0.0;
|
||
HeadlessCollisionGateStressTestMinClearanceCm = 1.0e30;
|
||
HeadlessCollisionGateStressTestMinFeetDensity = 0.0f;
|
||
HeadlessCollisionGateStressTestMinSurfaceLocalVoxelZ = 0.0f;
|
||
HeadlessCollisionGateStressTestTerrainSampleCount = 0;
|
||
HeadlessCollisionGateStressTestTraceFrame = 0;
|
||
HeadlessCollisionGateStressTestGateHoldCount = 0;
|
||
HeadlessCollisionGateStressTestFullStopCount = 0;
|
||
HeadlessCollisionGateStressTestDirection = FVector::ZeroVector;
|
||
HeadlessCollisionGateStressTestLastActualPosition = FVector::ZeroVector;
|
||
HeadlessCollisionGateStressTestStartPosition = FVector::ZeroVector;
|
||
HeadlessCollisionGateStressTestMinClearancePosition = FVector::ZeroVector;
|
||
HeadlessCollisionGateStressTestLaunchSupportTile = FVoxelTileKey();
|
||
HeadlessCollisionGateStressTestFailureReason.Reset();
|
||
bHeadlessSurfaceFallTest = false;
|
||
bHeadlessSurfaceFallTestStartPlaced = false;
|
||
bHeadlessSurfaceFallTestPassed = false;
|
||
bHeadlessSurfaceFallTestFailed = false;
|
||
bHeadlessSurfaceFallTestExitRequested = false;
|
||
bHeadlessSurfaceFallTestPredictionProbeInjected = false;
|
||
bHeadlessSurfaceFallTestPredictionProbeCleared = false;
|
||
HeadlessSurfaceFallTestSpawnHeightVoxels = 20.0f;
|
||
HeadlessSurfaceFallTestGroundToleranceVoxels = 4.0f;
|
||
HeadlessSurfaceFallTestTimeoutSeconds = 8.0f;
|
||
HeadlessSurfaceFallTestPlayerHalfHeightVoxels =
|
||
VoxelPassageGeometry::PlayerHalfHeightVoxels;
|
||
HeadlessSurfaceFallTestTerrainZ = 0.0f;
|
||
HeadlessSurfaceFallTestBeginSeconds = 0.0;
|
||
HeadlessSurfaceFallTestStartSeconds = 0.0;
|
||
HeadlessSurfaceFallTestDistanceCm = 0.0;
|
||
HeadlessSurfaceFallTestStartPosition = FVector::ZeroVector;
|
||
HeadlessSurfaceFallTestLastActualPosition = FVector::ZeroVector;
|
||
HeadlessSurfaceFallTestPredictionProbePosition = FVector::ZeroVector;
|
||
HeadlessSurfaceFallTestFailureReason.Reset();
|
||
bTileHashDumpEnabled = false;
|
||
TileHashDumpPath.Reset();
|
||
TileHashDumpRecords.Reset();
|
||
|
||
bHeadlessStrateCrossingTest = false;
|
||
bHeadlessStrateCrossingTestStartPlaced = false;
|
||
bHeadlessStrateCrossingTestStarted = false;
|
||
bHeadlessStrateCrossingTestPassed = false;
|
||
bHeadlessStrateCrossingTestFailed = false;
|
||
bHeadlessStrateCrossingTestExitRequested = false;
|
||
bHeadlessStrateCrossingTestGateWasEngaged = false;
|
||
bHeadlessStrateCrossingTestInitialSupportReported = false;
|
||
bHeadlessStrateCrossingTestPendingFloorOverlap = false;
|
||
HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE;
|
||
HeadlessStrateCrossingTestPassageIndex = INDEX_NONE;
|
||
HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE;
|
||
HeadlessStrateCrossingTestLowerStrateIndex = INDEX_NONE;
|
||
HeadlessStrateCrossingTestRouteTargetIndex = 1;
|
||
HeadlessStrateCrossingTestLastReportedStrate = INDEX_NONE;
|
||
HeadlessStrateCrossingTestPlayerHalfHeightVoxels =
|
||
VoxelPassageGeometry::PlayerHalfHeightVoxels;
|
||
HeadlessStrateCrossingTestMinFloorClearanceVoxels = 1000000.0f;
|
||
HeadlessStrateCrossingTestBeginSeconds = 0.0;
|
||
HeadlessStrateCrossingTestStartSeconds = 0.0;
|
||
HeadlessStrateCrossingTestGateStartSeconds = 0.0;
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds = 0.0;
|
||
HeadlessStrateCrossingTestTotalGateDurationSeconds = 0.0;
|
||
HeadlessStrateCrossingTestLastActualPosition = FVector::ZeroVector;
|
||
HeadlessStrateCrossingTestFailureReason.Reset();
|
||
HeadlessStrateCrossingTestRoute.Reset();
|
||
|
||
bVisualStalenessAudit = false;
|
||
VisualStalenessAuditMode = 0;
|
||
bVisualStalenessAuditExitLogged = false;
|
||
VisualAuditEditEpoch = 0;
|
||
VisualAuditEditMinVoxel = FVector::ZeroVector;
|
||
VisualAuditEditMaxVoxel = FVector::ZeroVector;
|
||
VisualAuditEditBeginSeconds = 0.0;
|
||
VisualAuditEditStaleDurationSeconds = -1.0;
|
||
VisualAuditEditMaxStaleTiles = 0;
|
||
bVisualAuditEditSawStale = false;
|
||
bVisualAuditEditResolved = false;
|
||
VisualAuditLODOverlapStartSeconds = 0.0;
|
||
VisualAuditLODOverlapMaxSeconds = 0.0;
|
||
VisualAuditLODOverlapEvents = 0;
|
||
VisualAuditLODOverlapMaxPairs = 0;
|
||
|
||
const TCHAR* CommandLine = FCommandLine::Get();
|
||
int32 VisualAuditValue = 0;
|
||
if (FParse::Value(CommandLine, TEXT("voxel.TestVisualStaleness="), VisualAuditValue))
|
||
{
|
||
VisualStalenessAuditMode = FMath::Clamp(VisualAuditValue, 0, 2);
|
||
bVisualStalenessAudit = VisualStalenessAuditMode != 0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualStaleness] configured=1 mode=%d"),
|
||
VisualStalenessAuditMode);
|
||
}
|
||
|
||
int32 VisualGapAuditValue = 0;
|
||
int32 VisualGapInjectionValue = 0;
|
||
int32 LegacyVisualHandoffValue = 0;
|
||
FParse::Value(CommandLine, TEXT("voxel.TestVisualGapAudit="), VisualGapAuditValue);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestVisualGapInjectEarlyHide="), VisualGapInjectionValue);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestGapAuditLegacyHandoff="), LegacyVisualHandoffValue);
|
||
if (VisualGapAuditValue != 0 || VisualGapInjectionValue != 0)
|
||
{
|
||
FVisualGapAuditState& Audit = GVoxelForgeVisualGapAudits.Add(this);
|
||
Audit.bEnabled = true;
|
||
Audit.bInjectEarlyHide = VisualGapInjectionValue != 0;
|
||
Audit.bLegacyVisualHandoff = LegacyVisualHandoffValue != 0;
|
||
Audit.bCollecting = Audit.bInjectEarlyHide;
|
||
Audit.bCollectionStarted = Audit.bInjectEarlyHide;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualGapAudit] configured=1 witness_samples_per_tile=24 inject_early_hide=%d legacy_handoff=%d"),
|
||
Audit.bInjectEarlyHide ? 1 : 0, Audit.bLegacyVisualHandoff ? 1 : 0);
|
||
}
|
||
|
||
int32 DigMotionValue = 0;
|
||
FParse::Value(CommandLine, TEXT("voxel.TestDigMotion="), DigMotionValue);
|
||
if (DigMotionValue != 0)
|
||
{
|
||
FDigMotionTestState& Test = GVoxelForgeDigMotionTests.Add(this);
|
||
Test.bEnabled = true;
|
||
FParse::Value(CommandLine, TEXT("voxel.TestDigMotionNearDigCount="), Test.NearDigCount);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestDigMotionIntervalSeconds="), Test.DigIntervalSeconds);
|
||
Test.NearDigCount = FMath::Clamp(Test.NearDigCount, 1, 32);
|
||
Test.DigIntervalSeconds = FMath::Clamp(Test.DigIntervalSeconds, 0.1, 3.0);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDigMotion] configured=1 near_dig_count=%d interval_s=%.3f"),
|
||
Test.NearDigCount, Test.DigIntervalSeconds);
|
||
}
|
||
FString RequestedTileHashDumpPath;
|
||
if (FParse::Value(CommandLine, TEXT("voxel.TileHashDump="), RequestedTileHashDumpPath))
|
||
{
|
||
RequestedTileHashDumpPath.TrimQuotesInline();
|
||
if (FPaths::IsRelative(RequestedTileHashDumpPath))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeParity] voxel.TileHashDump must be an absolute path: '%s'."),
|
||
*RequestedTileHashDumpPath);
|
||
}
|
||
else
|
||
{
|
||
TileHashDumpPath = FPaths::ConvertRelativePathToFull(RequestedTileHashDumpPath);
|
||
FPaths::NormalizeFilename(TileHashDumpPath);
|
||
bTileHashDumpEnabled = true;
|
||
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeParity] tile_hash_dump_enabled=1 path=%s"),
|
||
*TileHashDumpPath);
|
||
}
|
||
}
|
||
int32 MoveValue = 0;
|
||
const bool bMoveRequested =
|
||
(FParse::Value(CommandLine, TEXT("voxel.TestMove="), MoveValue) && MoveValue != 0)
|
||
|| FParse::Param(CommandLine, TEXT("voxel.TestMove"));
|
||
float Speed = 800.0f;
|
||
float StartSeconds = 20.0f;
|
||
float ExitSeconds = 0.0f;
|
||
int32 ExitOnSteadyValue = 0;
|
||
FParse::Value(CommandLine, TEXT("voxel.TestMoveSpeedCmPerSecond="), Speed);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestMoveStartSeconds="), StartSeconds);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestExitSeconds="), ExitSeconds);
|
||
if (FParse::Value(CommandLine, TEXT("voxel.TestExitOnSteady="), ExitOnSteadyValue))
|
||
{
|
||
bHeadlessStreamingTestExitOnSteady = ExitOnSteadyValue != 0;
|
||
}
|
||
|
||
int32 CrossingValue = 0;
|
||
bHeadlessStrateCrossingTest =
|
||
(FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossing="), CrossingValue)
|
||
&& CrossingValue != 0)
|
||
|| FParse::Param(CommandLine, TEXT("voxel.TestStrateCrossing"));
|
||
if (bHeadlessStrateCrossingTest)
|
||
{
|
||
bHeadlessStreamingTestMovement = false;
|
||
HeadlessStreamingTestExitSeconds = 0.0f;
|
||
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond = 800.0f;
|
||
HeadlessStrateCrossingTestStartDelaySeconds = 1.0f;
|
||
HeadlessStrateCrossingTestTimeoutSeconds = 120.0f;
|
||
HeadlessStrateCrossingTestMaxGateSeconds = 10.0f;
|
||
HeadlessStrateCrossingTestRouteTargetRadiusVoxels = 2.0f;
|
||
FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossingSpeedCmPerSecond="),
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossingStartDelaySeconds="),
|
||
HeadlessStrateCrossingTestStartDelaySeconds);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossingTimeoutSeconds="),
|
||
HeadlessStrateCrossingTestTimeoutSeconds);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossingMaxGateSeconds="),
|
||
HeadlessStrateCrossingTestMaxGateSeconds);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossingTargetRadiusVoxels="),
|
||
HeadlessStrateCrossingTestRouteTargetRadiusVoxels);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestStrateCrossingPassageIndex="),
|
||
HeadlessStrateCrossingTestRequestedPassageIndex);
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond = FMath::Max(
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond, 1.0f);
|
||
HeadlessStrateCrossingTestStartDelaySeconds = FMath::Max(
|
||
HeadlessStrateCrossingTestStartDelaySeconds, 0.0f);
|
||
HeadlessStrateCrossingTestTimeoutSeconds = FMath::Max(
|
||
HeadlessStrateCrossingTestTimeoutSeconds, 1.0f);
|
||
HeadlessStrateCrossingTestMaxGateSeconds = FMath::Max(
|
||
HeadlessStrateCrossingTestMaxGateSeconds, 0.1f);
|
||
HeadlessStrateCrossingTestRouteTargetRadiusVoxels = FMath::Max(
|
||
HeadlessStrateCrossingTestRouteTargetRadiusVoxels, 0.25f);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] configured=1 speed_cm_s=%.1f start_delay_s=%.3f "
|
||
"timeout_s=%.3f max_gate_s=%.3f target_radius_vox=%.3f requested_passage=%d"),
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond,
|
||
HeadlessStrateCrossingTestStartDelaySeconds,
|
||
HeadlessStrateCrossingTestTimeoutSeconds,
|
||
HeadlessStrateCrossingTestMaxGateSeconds,
|
||
HeadlessStrateCrossingTestRouteTargetRadiusVoxels,
|
||
HeadlessStrateCrossingTestRequestedPassageIndex);
|
||
}
|
||
|
||
int32 SurfaceFallValue = 0;
|
||
bHeadlessSurfaceFallTest =
|
||
(FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFall="), SurfaceFallValue)
|
||
&& SurfaceFallValue != 0)
|
||
|| FParse::Param(CommandLine, TEXT("voxel.TestSurfaceFall"));
|
||
if (bHeadlessSurfaceFallTest)
|
||
{
|
||
if (bHeadlessStrateCrossingTest)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=conflicting_headless_tests"));
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("conflicting_headless_tests");
|
||
}
|
||
FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallSpawnHeightVoxels="),
|
||
HeadlessSurfaceFallTestSpawnHeightVoxels);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallGroundToleranceVoxels="),
|
||
HeadlessSurfaceFallTestGroundToleranceVoxels);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallTimeoutSeconds="),
|
||
HeadlessSurfaceFallTestTimeoutSeconds);
|
||
HeadlessSurfaceFallTestSpawnHeightVoxels = FMath::Max(
|
||
HeadlessSurfaceFallTestSpawnHeightVoxels, 1.0f);
|
||
HeadlessSurfaceFallTestGroundToleranceVoxels = FMath::Max(
|
||
HeadlessSurfaceFallTestGroundToleranceVoxels, 0.5f);
|
||
HeadlessSurfaceFallTestTimeoutSeconds = FMath::Max(
|
||
HeadlessSurfaceFallTestTimeoutSeconds, 1.0f);
|
||
HeadlessSurfaceFallTestBeginSeconds = FPlatformTime::Seconds();
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSurfaceFallTest] configured=1 spawn_height_vox=%.2f "
|
||
"ground_tolerance_vox=%.2f timeout_s=%.3f"),
|
||
HeadlessSurfaceFallTestSpawnHeightVoxels,
|
||
HeadlessSurfaceFallTestGroundToleranceVoxels,
|
||
HeadlessSurfaceFallTestTimeoutSeconds);
|
||
}
|
||
|
||
int32 TraceThroughCrossingValue = 0;
|
||
if (FParse::Value(
|
||
CommandLine,
|
||
TEXT("voxel.StartupTraceThroughCrossing="),
|
||
TraceThroughCrossingValue))
|
||
{
|
||
bStartupTraceThroughCrossing = TraceThroughCrossingValue != 0;
|
||
}
|
||
|
||
float CenterX = 0.0f;
|
||
float CenterY = 0.0f;
|
||
float CenterZ = 0.0f;
|
||
const bool bCenterX = FParse::Value(CommandLine, TEXT("voxel.StreamingTestCenterX="), CenterX);
|
||
const bool bCenterY = FParse::Value(CommandLine, TEXT("voxel.StreamingTestCenterY="), CenterY);
|
||
const bool bCenterZ = FParse::Value(CommandLine, TEXT("voxel.StreamingTestCenterZ="), CenterZ);
|
||
if (bCenterX && bCenterY && bCenterZ
|
||
&& VoxelMath::IsFinite(CenterX)
|
||
&& VoxelMath::IsFinite(CenterY)
|
||
&& VoxelMath::IsFinite(CenterZ))
|
||
{
|
||
bHeadlessStreamingTestCenterOverride = true;
|
||
HeadlessStreamingTestCenterVoxel = FVector(CenterX, CenterY, CenterZ);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingTest] center_override=1 local_vox=(%.2f,%.2f,%.2f)"),
|
||
CenterX, CenterY, CenterZ);
|
||
}
|
||
|
||
if (bMoveRequested && !bHeadlessStrateCrossingTest)
|
||
{
|
||
FString DirectionText;
|
||
if (FParse::Value(CommandLine, TEXT("voxel.TestMoveDirection="), DirectionText))
|
||
{
|
||
TArray<FString> Components;
|
||
DirectionText.ParseIntoArray(Components, TEXT(","), true);
|
||
if (Components.Num() == 3)
|
||
{
|
||
HeadlessStreamingTestDirection = FVector(
|
||
FCString::Atof(*Components[0]),
|
||
FCString::Atof(*Components[1]),
|
||
FCString::Atof(*Components[2]));
|
||
}
|
||
}
|
||
HeadlessStreamingTestDirection = HeadlessStreamingTestDirection.GetSafeNormal();
|
||
if (HeadlessStreamingTestDirection.IsNearlyZero())
|
||
{
|
||
HeadlessStreamingTestDirection = FVector::XAxisVector;
|
||
}
|
||
HeadlessStreamingTestSpeedCmPerSecond = FMath::Max(Speed, 0.0f);
|
||
HeadlessStreamingTestStartSeconds = FMath::Max(StartSeconds, 0.0f);
|
||
bHeadlessStreamingTestMovement = HeadlessStreamingTestSpeedCmPerSecond > 0.0f;
|
||
}
|
||
|
||
HeadlessStreamingTestExitSeconds = FMath::Max(ExitSeconds, 0.0f);
|
||
if (!bHeadlessStrateCrossingTest
|
||
&& (bHeadlessStreamingTestMovement || HeadlessStreamingTestExitSeconds > 0.0f))
|
||
{
|
||
HeadlessStreamingTestBeginSeconds = FPlatformTime::Seconds();
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingTest] clean=1 movement=%d speed_cm_s=%.3f start_s=%.3f "
|
||
"exit_s=%.3f direction=(%.3f,%.3f,%.3f)"),
|
||
bHeadlessStreamingTestMovement ? 1 : 0,
|
||
HeadlessStreamingTestSpeedCmPerSecond,
|
||
HeadlessStreamingTestStartSeconds,
|
||
HeadlessStreamingTestExitSeconds,
|
||
HeadlessStreamingTestDirection.X,
|
||
HeadlessStreamingTestDirection.Y,
|
||
HeadlessStreamingTestDirection.Z);
|
||
}
|
||
|
||
int32 CollisionGateStressValue = 0;
|
||
bHeadlessCollisionGateStressTest =
|
||
(FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStress="), CollisionGateStressValue)
|
||
&& CollisionGateStressValue != 0)
|
||
|| FParse::Param(CommandLine, TEXT("voxel.TestCollisionGateStress"));
|
||
if (bHeadlessCollisionGateStressTest)
|
||
{
|
||
if (bHeadlessStrateCrossingTest || bHeadlessSurfaceFallTest)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("conflicting_headless_tests");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] result=FAIL reason=%s"),
|
||
*HeadlessCollisionGateStressTestFailureReason);
|
||
}
|
||
float StressSpeed = HeadlessCollisionGateStressTestSpeedCmPerSecond;
|
||
float StressSpawnHeight = HeadlessCollisionGateStressTestSpawnHeightVoxels;
|
||
float StressDuration = HeadlessCollisionGateStressTestDurationSeconds;
|
||
float StressTimeout = HeadlessCollisionGateStressTestTimeoutSeconds;
|
||
int32 StressDeepDrop = 0;
|
||
FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressSpeedCmPerSecond="),
|
||
StressSpeed);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressSpawnHeightVoxels="),
|
||
StressSpawnHeight);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressDeepDrop="),
|
||
StressDeepDrop);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressDurationSeconds="),
|
||
StressDuration);
|
||
FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressTimeoutSeconds="),
|
||
StressTimeout);
|
||
HeadlessCollisionGateStressTestSpeedCmPerSecond = FMath::Max(StressSpeed, 1.0f);
|
||
HeadlessCollisionGateStressTestSpawnHeightVoxels = FMath::Max(StressSpawnHeight, 1.0f);
|
||
bHeadlessCollisionGateStressDeepDrop = StressDeepDrop != 0;
|
||
HeadlessCollisionGateStressTestDurationSeconds = FMath::Max(StressDuration, 0.25f);
|
||
HeadlessCollisionGateStressTestTimeoutSeconds = FMath::Max(StressTimeout, 1.0f);
|
||
// The stress path owns movement and keeps the streamer centred at the launch tile. Do
|
||
// not let the older synthetic TestMove path run in parallel with it.
|
||
bHeadlessStreamingTestMovement = false;
|
||
HeadlessStreamingTestExitSeconds = 0.0f;
|
||
HeadlessCollisionGateStressTestBeginSeconds = 0.0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] configured=1 speed_cm_s=%.1f "
|
||
"spawn_height_vox=%.2f deep_drop=%d duration_after_landing_s=%.3f timeout_s=%.3f"),
|
||
HeadlessCollisionGateStressTestSpeedCmPerSecond,
|
||
HeadlessCollisionGateStressTestSpawnHeightVoxels,
|
||
bHeadlessCollisionGateStressDeepDrop ? 1 : 0,
|
||
HeadlessCollisionGateStressTestDurationSeconds,
|
||
HeadlessCollisionGateStressTestTimeoutSeconds);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
|
||
int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi,
|
||
const FVoxelMeshData& MeshData)
|
||
{
|
||
if (!bTileHashDumpEnabled)
|
||
{
|
||
return;
|
||
}
|
||
|
||
FVoxelTileHashDumpRecord Record;
|
||
Record.Tile = Tile;
|
||
Record.OriginVoxels = OriginVoxels;
|
||
Record.Step = Step;
|
||
Record.Cells = Cells;
|
||
Record.BandChunkLo = BandChunkLo;
|
||
Record.BandChunkHi = BandChunkHi;
|
||
Record.bEmpty = MeshData.IsEmpty();
|
||
Record.NumTriangles = MeshData.Triangles.Num() / 3;
|
||
Record.GeometryHash = VoxelForgeGeometryHash::Compute(MeshData);
|
||
|
||
FScopeLock Lock(&TileHashDumpMutex);
|
||
TileHashDumpRecords.Add(MoveTemp(Record));
|
||
}
|
||
|
||
void AVoxelWorld::WriteTileHashDump()
|
||
{
|
||
if (!bTileHashDumpEnabled || TileHashDumpPath.IsEmpty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
TArray<FVoxelTileHashDumpRecord> Records;
|
||
{
|
||
FScopeLock Lock(&TileHashDumpMutex);
|
||
Records = TileHashDumpRecords;
|
||
}
|
||
Records.Sort([](const FVoxelTileHashDumpRecord& A, const FVoxelTileHashDumpRecord& B)
|
||
{
|
||
if (A.Tile.Level != B.Tile.Level) return A.Tile.Level < B.Tile.Level;
|
||
if (A.Tile.Coord.Z != B.Tile.Coord.Z) return A.Tile.Coord.Z < B.Tile.Coord.Z;
|
||
if (A.Tile.Coord.Y != B.Tile.Coord.Y) return A.Tile.Coord.Y < B.Tile.Coord.Y;
|
||
if (A.Tile.Coord.X != B.Tile.Coord.X) return A.Tile.Coord.X < B.Tile.Coord.X;
|
||
return A.GeometryHash < B.GeometryHash;
|
||
});
|
||
|
||
IFileManager::Get().MakeDirectory(*FPaths::GetPath(TileHashDumpPath), true);
|
||
FString Json;
|
||
TSharedRef<TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>> Writer =
|
||
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Json);
|
||
Writer->WriteObjectStart();
|
||
Writer->WriteValue(TEXT("schema_version"), 1);
|
||
Writer->WriteValue(TEXT("source"), TEXT("game_streaming_path"));
|
||
Writer->WriteValue(TEXT("settings_asset"), Settings ? Settings->GetPathName() : FString());
|
||
if (Settings != nullptr)
|
||
{
|
||
Writer->WriteObjectStart(TEXT("runtime_config"));
|
||
Writer->WriteValue(TEXT("generate_skirts"), Settings->bGenerateSkirts);
|
||
Writer->WriteValue(TEXT("skirt_cells"), static_cast<double>(Settings->SkirtCells));
|
||
Writer->WriteValue(TEXT("lod_octave_drop"), Settings->LODOctaveDrop);
|
||
Writer->WriteValue(TEXT("full_res_clip_levels"), Settings->FullResClipLevels);
|
||
Writer->WriteValue(TEXT("coarse_tile_cells"), Settings->CoarseTileCells);
|
||
Writer->WriteValue(TEXT("max_clip_level"), Settings->MaxClipLevel);
|
||
Writer->WriteValue(TEXT("render_distance_chunks"), Settings->RenderDistanceChunks);
|
||
Writer->WriteValue(TEXT("strate_content_cut_min_level"),
|
||
Settings->GetEffectiveStrateContentCutMinLevel());
|
||
Writer->WriteObjectEnd();
|
||
}
|
||
Writer->WriteValue(TEXT("record_count"), Records.Num());
|
||
Writer->WriteArrayStart(TEXT("records"));
|
||
for (const FVoxelTileHashDumpRecord& Record : Records)
|
||
{
|
||
Writer->WriteObjectStart();
|
||
Writer->WriteValue(TEXT("tile_x"), Record.Tile.Coord.X);
|
||
Writer->WriteValue(TEXT("tile_y"), Record.Tile.Coord.Y);
|
||
Writer->WriteValue(TEXT("tile_z"), Record.Tile.Coord.Z);
|
||
Writer->WriteValue(TEXT("level"), Record.Tile.Level);
|
||
Writer->WriteValue(TEXT("origin_x_voxels"), Record.OriginVoxels.X);
|
||
Writer->WriteValue(TEXT("origin_y_voxels"), Record.OriginVoxels.Y);
|
||
Writer->WriteValue(TEXT("origin_z_voxels"), Record.OriginVoxels.Z);
|
||
Writer->WriteValue(TEXT("step"), Record.Step);
|
||
Writer->WriteValue(TEXT("cells"), Record.Cells);
|
||
Writer->WriteValue(TEXT("band_chunk_lo"), Record.BandChunkLo);
|
||
Writer->WriteValue(TEXT("band_chunk_hi"), Record.BandChunkHi);
|
||
// The parity harness still compares this field; the runtime has no sheet tiles.
|
||
Writer->WriteValue(TEXT("sheet_tile"), false);
|
||
Writer->WriteValue(TEXT("empty"), Record.bEmpty);
|
||
Writer->WriteValue(TEXT("triangle_count"), Record.NumTriangles);
|
||
Writer->WriteValue(TEXT("geometry_hash"), Record.GeometryHash);
|
||
Writer->WriteObjectEnd();
|
||
}
|
||
Writer->WriteArrayEnd();
|
||
Writer->WriteObjectEnd();
|
||
if (!Writer->Close() || !FFileHelper::SaveStringToFile(Json, *TileHashDumpPath))
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeParity] Could not write tile hash dump '%s'."),
|
||
*TileHashDumpPath);
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeParity] tile_hash_dump=%s records=%d"),
|
||
*TileHashDumpPath, Records.Num());
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::InitializeHeadlessStrateCrossingTest()
|
||
{
|
||
if (!bHeadlessStrateCrossingTest)
|
||
{
|
||
return;
|
||
}
|
||
|
||
HeadlessStrateCrossingTestBeginSeconds = FPlatformTime::Seconds();
|
||
if (StrateManager == nullptr)
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("strate_manager_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeStrateCrossingTest] result=FAIL reason=%s"),
|
||
*HeadlessStrateCrossingTestFailureReason);
|
||
return;
|
||
}
|
||
|
||
const TArray<FVoxelPassage>& Passages = StrateManager->GetPassages();
|
||
auto IsUsablePassage = [](const FVoxelPassage& Passage) -> bool
|
||
{
|
||
return Passage.bWalkableTunnelContract
|
||
&& Passage.UpperStrateIndex >= 0
|
||
&& Passage.LowerStrateIndex == Passage.UpperStrateIndex + 1
|
||
&& Passage.UpperLanding.HalfWidth > 0.0f
|
||
&& Passage.LowerLanding.HalfWidth > 0.0f
|
||
&& Passage.ControlPoints.Num() >= 2
|
||
&& Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
||
};
|
||
|
||
int32 SelectedPassageIndex = INDEX_NONE;
|
||
if (HeadlessStrateCrossingTestRequestedPassageIndex != INDEX_NONE)
|
||
{
|
||
if (Passages.IsValidIndex(HeadlessStrateCrossingTestRequestedPassageIndex)
|
||
&& IsUsablePassage(Passages[HeadlessStrateCrossingTestRequestedPassageIndex]))
|
||
{
|
||
SelectedPassageIndex = HeadlessStrateCrossingTestRequestedPassageIndex;
|
||
}
|
||
else
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("requested_passage_unusable");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Prefer the first real A→B connection so the default test has a fixed, local route. The
|
||
// fallback still follows the manager's generated chain when the authored layout has more
|
||
// than two slots or a future layout changes the first passage's walkability metadata.
|
||
for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex)
|
||
{
|
||
if (IsUsablePassage(Passages[PassageIndex])
|
||
&& Passages[PassageIndex].UpperStrateIndex == 0
|
||
&& Passages[PassageIndex].LowerStrateIndex == 1)
|
||
{
|
||
SelectedPassageIndex = PassageIndex;
|
||
break;
|
||
}
|
||
}
|
||
if (SelectedPassageIndex == INDEX_NONE)
|
||
{
|
||
for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex)
|
||
{
|
||
if (IsUsablePassage(Passages[PassageIndex]))
|
||
{
|
||
SelectedPassageIndex = PassageIndex;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (SelectedPassageIndex == INDEX_NONE)
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("no_usable_inter_strate_passage");
|
||
}
|
||
}
|
||
|
||
if (bHeadlessStrateCrossingTestFailed)
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeStrateCrossingTest] result=FAIL reason=%s passages=%d"),
|
||
*HeadlessStrateCrossingTestFailureReason, Passages.Num());
|
||
return;
|
||
}
|
||
|
||
const FVoxelPassage& Passage = Passages[SelectedPassageIndex];
|
||
HeadlessStrateCrossingTestPassageIndex = SelectedPassageIndex;
|
||
HeadlessStrateCrossingTestUpperStrateIndex = Passage.UpperStrateIndex;
|
||
HeadlessStrateCrossingTestLowerStrateIndex = Passage.LowerStrateIndex;
|
||
HeadlessStrateCrossingTestRoute.Reset();
|
||
|
||
const auto AddRoutePoint = [this](const FVector& Point)
|
||
{
|
||
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
||
|| !VoxelMath::IsFinite(Point.Z))
|
||
{
|
||
return;
|
||
}
|
||
if (HeadlessStrateCrossingTestRoute.Num() == 0
|
||
|| FVector::DistSquared(HeadlessStrateCrossingTestRoute.Last(), Point)
|
||
> FMath::Square(0.01f))
|
||
{
|
||
HeadlessStrateCrossingTestRoute.Add(Point);
|
||
}
|
||
};
|
||
|
||
// ControlPoints starts/ends at the two DoorPoints. Keep both landing anchors around the chain
|
||
// so the test covers the actual walk from a standing pose into A, through the gap, and out to
|
||
// a standing pose in B; duplicate door points are collapsed by AddRoutePoint.
|
||
AddRoutePoint(Passage.UpperLanding.StandingPoint);
|
||
AddRoutePoint(Passage.UpperLanding.DoorPoint);
|
||
for (const FVector& Point : Passage.ControlPoints)
|
||
{
|
||
AddRoutePoint(Point);
|
||
}
|
||
AddRoutePoint(Passage.LowerLanding.DoorPoint);
|
||
AddRoutePoint(Passage.LowerLanding.StandingPoint);
|
||
|
||
if (HeadlessStrateCrossingTestRoute.Num() < 4)
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("passage_route_degenerate");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeStrateCrossingTest] result=FAIL reason=%s passage=%d route_points=%d"),
|
||
*HeadlessStrateCrossingTestFailureReason,
|
||
SelectedPassageIndex, HeadlessStrateCrossingTestRoute.Num());
|
||
return;
|
||
}
|
||
|
||
HeadlessStrateCrossingTestRouteTargetIndex = 1;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] route_ready passage=%d upper_strate=%d lower_strate=%d "
|
||
"route_points=%d upper=(%.2f,%.2f,%.2f) lower=(%.2f,%.2f,%.2f) "
|
||
"control_points=%d vertical_drop_vox=%.2f horizontal_run_vox=%.2f"),
|
||
SelectedPassageIndex,
|
||
Passage.UpperStrateIndex,
|
||
Passage.LowerStrateIndex,
|
||
HeadlessStrateCrossingTestRoute.Num(),
|
||
Passage.UpperLanding.StandingPoint.X,
|
||
Passage.UpperLanding.StandingPoint.Y,
|
||
Passage.UpperLanding.FloorZ,
|
||
Passage.LowerLanding.StandingPoint.X,
|
||
Passage.LowerLanding.StandingPoint.Y,
|
||
Passage.LowerLanding.FloorZ,
|
||
Passage.ControlPoints.Num(),
|
||
Passage.TunnelVerticalDropVoxels,
|
||
Passage.TunnelHorizontalPathLengthVoxels);
|
||
}
|
||
|
||
void AVoxelWorld::AdvanceHeadlessStrateCrossingTest(
|
||
FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn)
|
||
{
|
||
if (!bHeadlessStrateCrossingTest || bHeadlessStrateCrossingTestFailed
|
||
|| bHeadlessStrateCrossingTestPassed || !IsValid(PlayerPawn)
|
||
|| HeadlessStrateCrossingTestBeginSeconds <= 0.0
|
||
|| !StrateManager
|
||
|| !StrateManager->GetPassages().IsValidIndex(HeadlessStrateCrossingTestPassageIndex))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
const double ElapsedSeconds = Now - HeadlessStrateCrossingTestBeginSeconds;
|
||
if (ElapsedSeconds < static_cast<double>(HeadlessStrateCrossingTestStartDelaySeconds))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const FVoxelPassage& Passage =
|
||
StrateManager->GetPassages()[HeadlessStrateCrossingTestPassageIndex];
|
||
if (!bHeadlessStrateCrossingTestStartPlaced)
|
||
{
|
||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
const float HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
if (VoxelMath::IsFinite(HalfHeightCm) && HalfHeightCm > 0.0f)
|
||
{
|
||
HeadlessStrateCrossingTestPlayerHalfHeightVoxels =
|
||
HalfHeightCm / VOXEL_SIZE;
|
||
}
|
||
}
|
||
}
|
||
|
||
FVector StartVoxel = Passage.UpperLanding.StandingPoint;
|
||
StartVoxel.Z = Passage.UpperLanding.FloorZ
|
||
+ HeadlessStrateCrossingTestPlayerHalfHeightVoxels;
|
||
const FVector StartWorld = LocalVoxelToWorld(StartVoxel);
|
||
|
||
// The test pawn starts in the map's default spawn area, but the authored passage can be
|
||
// many tiles away. Do not teleport the capsule into an unresolved landing floor: ask for
|
||
// that exact support tile first, then let the pending-floor assertion cover only movement
|
||
// after the crossing has genuinely begun.
|
||
// Keep the streamer's centre at the prospective landing while that request is serviced;
|
||
// the pawn itself remains at the map spawn until the collision is ready.
|
||
bHeadlessStreamingTestCenterOverride = true;
|
||
HeadlessStreamingTestCenterVoxel = StartVoxel;
|
||
FVoxelTileKey StartSupportTile;
|
||
const TCHAR* StartSupportUnavailableReason = nullptr;
|
||
if (!IsPlayerSupportCollisionReady(
|
||
PlayerPawn, StartWorld, &StartSupportTile, &StartSupportUnavailableReason))
|
||
{
|
||
RequestCollisionGateSupportTile(StartSupportTile);
|
||
if (!bHeadlessStrateCrossingTestInitialSupportReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] waiting_for_initial_support "
|
||
"tile=(%d,%d,%d) reason=%s"),
|
||
StartSupportTile.Coord.X, StartSupportTile.Coord.Y,
|
||
StartSupportTile.Coord.Z,
|
||
StartSupportUnavailableReason != nullptr
|
||
? StartSupportUnavailableReason : TEXT("collision_pending"));
|
||
bHeadlessStrateCrossingTestInitialSupportReported = true;
|
||
}
|
||
return;
|
||
}
|
||
bHeadlessStrateCrossingTestInitialSupportReported = false;
|
||
|
||
const bool bPlaced = PlayerPawn->SetActorLocation(
|
||
StartWorld, false, nullptr, ETeleportType::TeleportPhysics);
|
||
const FVector ActualPosition = PlayerPawn->GetActorLocation();
|
||
InOutPlayerPosition = ActualPosition;
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
HeadlessStrateCrossingTestLastActualPosition = ActualPosition;
|
||
bHeadlessStrateCrossingTestStartPlaced = true;
|
||
bHeadlessStrateCrossingTestGateWasEngaged = false;
|
||
HeadlessStrateCrossingTestGateStartSeconds = 0.0;
|
||
HeadlessStrateCrossingTestStartSeconds = Now;
|
||
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||
{
|
||
// Keep the route test natural (AddMovementInput + the normal swept character
|
||
// movement), but make its requested speed explicit and identical across binaries.
|
||
CharacterMovement->MaxWalkSpeed = HeadlessStrateCrossingTestSpeedCmPerSecond;
|
||
CharacterMovement->MaxAcceleration = FMath::Max(
|
||
CharacterMovement->MaxAcceleration,
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond * 4.0f);
|
||
CharacterMovement->BrakingDecelerationWalking = FMath::Max(
|
||
CharacterMovement->BrakingDecelerationWalking,
|
||
HeadlessStrateCrossingTestSpeedCmPerSecond * 4.0f);
|
||
CharacterMovement->StopMovementImmediately();
|
||
}
|
||
}
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] start_placed moved=%d world=(%.1f,%.1f,%.1f) "
|
||
"local_vox=(%.2f,%.2f,%.2f) floor_vox=%.2f half_height_vox=%.3f"),
|
||
bPlaced ? 1 : 0,
|
||
ActualPosition.X, ActualPosition.Y, ActualPosition.Z,
|
||
StartVoxel.X, StartVoxel.Y, StartVoxel.Z,
|
||
Passage.UpperLanding.FloorZ,
|
||
HeadlessStrateCrossingTestPlayerHalfHeightVoxels);
|
||
return;
|
||
}
|
||
|
||
// The landing was kept warm for the teleport. From this tick onward the stream follows the
|
||
// pawn's actual swept movement again.
|
||
bHeadlessStreamingTestCenterOverride = false;
|
||
|
||
if (!bHeadlessStrateCrossingTestStarted)
|
||
{
|
||
if (!IsPlayerSupportCollisionReady(PlayerPawn, InOutPlayerPosition))
|
||
{
|
||
return;
|
||
}
|
||
bHeadlessStrateCrossingTestStarted = true;
|
||
HeadlessStrateCrossingTestStartSeconds = Now;
|
||
HeadlessStrateCrossingTestRouteTargetIndex = 1;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] natural_walk_started elapsed_s=%.3f "
|
||
"route_target=%d/%d"),
|
||
ElapsedSeconds,
|
||
HeadlessStrateCrossingTestRouteTargetIndex,
|
||
HeadlessStrateCrossingTestRoute.Num());
|
||
return;
|
||
}
|
||
|
||
const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition);
|
||
while (HeadlessStrateCrossingTestRouteTargetIndex
|
||
< HeadlessStrateCrossingTestRoute.Num())
|
||
{
|
||
const FVector& Target = HeadlessStrateCrossingTestRoute[
|
||
HeadlessStrateCrossingTestRouteTargetIndex];
|
||
const FVector2D Delta(
|
||
Target.X - LocalPosition.X, Target.Y - LocalPosition.Y);
|
||
if (Delta.SizeSquared() > FMath::Square(
|
||
HeadlessStrateCrossingTestRouteTargetRadiusVoxels))
|
||
{
|
||
break;
|
||
}
|
||
++HeadlessStrateCrossingTestRouteTargetIndex;
|
||
UE_LOG(LogTemp, Verbose,
|
||
TEXT("[VoxelForgeStrateCrossingTest] route_target_reached index=%d/%d"),
|
||
HeadlessStrateCrossingTestRouteTargetIndex,
|
||
HeadlessStrateCrossingTestRoute.Num());
|
||
}
|
||
|
||
const int32 CurrentStrate = GetStrateAtPosition(InOutPlayerPosition);
|
||
if (HeadlessStrateCrossingTestRouteTargetIndex
|
||
>= HeadlessStrateCrossingTestRoute.Num())
|
||
{
|
||
if (CurrentStrate == HeadlessStrateCrossingTestLowerStrateIndex)
|
||
{
|
||
bHeadlessStrateCrossingTestPassed = true;
|
||
}
|
||
else
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("route_finished_without_lower_strate");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (bPawnCollisionGateEngaged)
|
||
{
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
return;
|
||
}
|
||
|
||
const FVector& Target = HeadlessStrateCrossingTestRoute[
|
||
HeadlessStrateCrossingTestRouteTargetIndex];
|
||
const FVector LocalDirection(
|
||
Target.X - LocalPosition.X,
|
||
Target.Y - LocalPosition.Y,
|
||
0.0f);
|
||
const FVector WorldDirection = GetActorTransform().TransformVectorNoScale(
|
||
LocalDirection.GetSafeNormal());
|
||
if (!WorldDirection.IsNearlyZero())
|
||
{
|
||
const FVector NormalizedWorldDirection = WorldDirection.GetSafeNormal();
|
||
PlayerPawn->AddMovementInput(NormalizedWorldDirection, 1.0f, false);
|
||
InOutPlayerHeading = NormalizedWorldDirection
|
||
* HeadlessStrateCrossingTestSpeedCmPerSecond;
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ObserveHeadlessStrateCrossingTest(
|
||
const FVector& PlayerPosition, APawn* PlayerPawn)
|
||
{
|
||
if (!bHeadlessStrateCrossingTest || !bHeadlessStrateCrossingTestStartPlaced
|
||
|| !IsValid(PlayerPawn) || bHeadlessStrateCrossingTestExitRequested)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (bHeadlessStrateCrossingTestStarted)
|
||
{
|
||
HeadlessStreamingTestDistanceCm += FVector::Dist(
|
||
HeadlessStrateCrossingTestLastActualPosition, PlayerPosition);
|
||
}
|
||
HeadlessStrateCrossingTestLastActualPosition = PlayerPosition;
|
||
|
||
const bool bGateEngaged = bPawnCollisionGateEngaged;
|
||
if (bGateEngaged && !bHeadlessStrateCrossingTestGateWasEngaged)
|
||
{
|
||
HeadlessStrateCrossingTestGateStartSeconds = Now;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] gate=engaged elapsed_s=%.3f"),
|
||
Now - HeadlessStrateCrossingTestBeginSeconds);
|
||
}
|
||
else if (!bGateEngaged && bHeadlessStrateCrossingTestGateWasEngaged)
|
||
{
|
||
const double GateDuration = FMath::Max(
|
||
0.0, Now - HeadlessStrateCrossingTestGateStartSeconds);
|
||
HeadlessStrateCrossingTestTotalGateDurationSeconds += GateDuration;
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds = FMath::Max(
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds, GateDuration);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] gate=released duration_s=%.6f total_s=%.6f"),
|
||
GateDuration,
|
||
HeadlessStrateCrossingTestTotalGateDurationSeconds);
|
||
}
|
||
bHeadlessStrateCrossingTestGateWasEngaged = bGateEngaged;
|
||
|
||
if (bGateEngaged && HeadlessStrateCrossingTestGateStartSeconds > 0.0)
|
||
{
|
||
const double GateDuration = FMath::Max(
|
||
0.0, Now - HeadlessStrateCrossingTestGateStartSeconds);
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds = FMath::Max(
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds, GateDuration);
|
||
if (GateDuration > static_cast<double>(HeadlessStrateCrossingTestMaxGateSeconds))
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("collision_gate_timeout");
|
||
}
|
||
}
|
||
|
||
if (!StrateManager->GetPassages().IsValidIndex(HeadlessStrateCrossingTestPassageIndex))
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("passage_invalidated");
|
||
return;
|
||
}
|
||
|
||
const FVoxelPassage& Passage =
|
||
StrateManager->GetPassages()[HeadlessStrateCrossingTestPassageIndex];
|
||
const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition);
|
||
FVoxelTileKey PendingFloorTile;
|
||
if (IsPawnCapsuleOverlappingPendingFloor(
|
||
PlayerPawn, PlayerPosition, PlayerPosition, &PendingFloorTile))
|
||
{
|
||
bHeadlessStrateCrossingTestPendingFloorOverlap = true;
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("capsule_overlaps_pending_floor");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeStrateCrossingTest] assertion_failed=%s tile=(%d,%d,%d) "
|
||
"position_vox=(%.2f,%.2f,%.2f) gate=%d"),
|
||
*HeadlessStrateCrossingTestFailureReason,
|
||
PendingFloorTile.Coord.X, PendingFloorTile.Coord.Y, PendingFloorTile.Coord.Z,
|
||
LocalPosition.X, LocalPosition.Y, LocalPosition.Z,
|
||
bPawnCollisionGateEngaged ? 1 : 0);
|
||
return;
|
||
}
|
||
float PassageFloorZ = 0.0f;
|
||
if (VF_ProjectHeadlessPassageFloor(Passage, LocalPosition, PassageFloorZ))
|
||
{
|
||
const float FeetZ = LocalPosition.Z
|
||
- HeadlessStrateCrossingTestPlayerHalfHeightVoxels;
|
||
const float Clearance = FeetZ - PassageFloorZ;
|
||
HeadlessStrateCrossingTestMinFloorClearanceVoxels = FMath::Min(
|
||
HeadlessStrateCrossingTestMinFloorClearanceVoxels, Clearance);
|
||
constexpr float FloorViolationToleranceVoxels = 2.0f;
|
||
if (Clearance < -FloorViolationToleranceVoxels)
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("pawn_below_passage_floor");
|
||
}
|
||
}
|
||
|
||
const int32 CurrentStrate = GetStrateAtPosition(PlayerPosition);
|
||
if (CurrentStrate != HeadlessStrateCrossingTestLastReportedStrate)
|
||
{
|
||
FVoxelTileKey SupportTile;
|
||
const bool bSupportReady = IsPlayerSupportCollisionReady(
|
||
PlayerPawn, PlayerPosition, &SupportTile);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] progress current_strate=%d target_strate=%d "
|
||
"route=%d/%d position_vox=(%.2f,%.2f,%.2f) mesh_band=[%d..%d] "
|
||
"band_remesh=%d pending_tiles=%d support=(%d,%d,%d) support_ready=%d gate=%d"),
|
||
CurrentStrate,
|
||
HeadlessStrateCrossingTestLowerStrateIndex,
|
||
HeadlessStrateCrossingTestRouteTargetIndex,
|
||
HeadlessStrateCrossingTestRoute.Num(),
|
||
LocalPosition.X, LocalPosition.Y, LocalPosition.Z,
|
||
MeshBandChunkLo, MeshBandChunkHi,
|
||
BandRemeshQueue.Num(), PendingTiles.Num(),
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z,
|
||
bSupportReady ? 1 : 0,
|
||
bGateEngaged ? 1 : 0);
|
||
HeadlessStrateCrossingTestLastReportedStrate = CurrentStrate;
|
||
}
|
||
|
||
// Reaching the lower strate is the crossing contract (not the final room anchor). Requiring route index 2 means the pawn
|
||
// has consumed the upper landing door point and is following the generated passage chain; it
|
||
// cannot pass while merely standing at the test's initial upper landing. The lower landing
|
||
// anchor is intentionally not required: after the boundary is crossed, that extra room walk is
|
||
// outside the inter-strate streaming/collision transition being tested and can be affected by
|
||
// authored chamber furniture or a non-monotonic final XY turn.
|
||
if (!bHeadlessStrateCrossingTestFailed
|
||
&& !bGateEngaged
|
||
&& bHeadlessStrateCrossingTestStarted
|
||
&& CurrentStrate == HeadlessStrateCrossingTestLowerStrateIndex
|
||
&& HeadlessStrateCrossingTestRouteTargetIndex >= 2)
|
||
{
|
||
bHeadlessStrateCrossingTestPassed = true;
|
||
}
|
||
|
||
if (!bHeadlessStrateCrossingTestPassed
|
||
&& !bHeadlessStrateCrossingTestFailed
|
||
&& Now - HeadlessStrateCrossingTestBeginSeconds
|
||
> static_cast<double>(HeadlessStrateCrossingTestTimeoutSeconds))
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("crossing_timeout");
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::MaybeFinishHeadlessStrateCrossingTest()
|
||
{
|
||
if (!bHeadlessStrateCrossingTest || bHeadlessStrateCrossingTestExitRequested
|
||
|| HeadlessStrateCrossingTestBeginSeconds <= 0.0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (!bHeadlessStrateCrossingTestPassed && !bHeadlessStrateCrossingTestFailed
|
||
&& Now - HeadlessStrateCrossingTestBeginSeconds
|
||
>= static_cast<double>(HeadlessStrateCrossingTestTimeoutSeconds))
|
||
{
|
||
bHeadlessStrateCrossingTestFailed = true;
|
||
HeadlessStrateCrossingTestFailureReason = TEXT("crossing_timeout");
|
||
}
|
||
if (!bHeadlessStrateCrossingTestPassed && !bHeadlessStrateCrossingTestFailed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (bHeadlessStrateCrossingTestGateWasEngaged
|
||
&& HeadlessStrateCrossingTestGateStartSeconds > 0.0)
|
||
{
|
||
const double GateDuration = FMath::Max(
|
||
0.0, Now - HeadlessStrateCrossingTestGateStartSeconds);
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds = FMath::Max(
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds, GateDuration);
|
||
}
|
||
|
||
bHeadlessStrateCrossingTestExitRequested = true;
|
||
const bool bPassed = bHeadlessStrateCrossingTestPassed
|
||
&& !bHeadlessStrateCrossingTestFailed;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStrateCrossingTest] result=%s reason=%s passage=%d upper_strate=%d "
|
||
"lower_strate=%d elapsed_s=%.6f distance_m=%.6f route=%d/%d "
|
||
"max_gate_s=%.6f gate_limit_s=%.3f gate_total_s=%.6f "
|
||
"min_floor_clearance_vox=%.6f pending_floor_overlap=%d "
|
||
"final_position=(%.1f,%.1f,%.1f)"),
|
||
bPassed ? TEXT("PASS") : TEXT("FAIL"),
|
||
bPassed ? TEXT("reached_lower_strate") : *HeadlessStrateCrossingTestFailureReason,
|
||
HeadlessStrateCrossingTestPassageIndex,
|
||
HeadlessStrateCrossingTestUpperStrateIndex,
|
||
HeadlessStrateCrossingTestLowerStrateIndex,
|
||
Now - HeadlessStrateCrossingTestBeginSeconds,
|
||
HeadlessStreamingTestDistanceCm / 100.0,
|
||
HeadlessStrateCrossingTestRouteTargetIndex,
|
||
HeadlessStrateCrossingTestRoute.Num(),
|
||
HeadlessStrateCrossingTestMaxGateDurationSeconds,
|
||
HeadlessStrateCrossingTestMaxGateSeconds,
|
||
HeadlessStrateCrossingTestTotalGateDurationSeconds,
|
||
HeadlessStrateCrossingTestMinFloorClearanceVoxels,
|
||
bHeadlessStrateCrossingTestPendingFloorOverlap ? 1 : 0,
|
||
HeadlessStrateCrossingTestLastActualPosition.X,
|
||
HeadlessStrateCrossingTestLastActualPosition.Y,
|
||
HeadlessStrateCrossingTestLastActualPosition.Z);
|
||
if (bStartupTraceThroughCrossing && VoxelForgeStartupTrace::IsActive())
|
||
{
|
||
VoxelForgeStartupTrace::Finish(
|
||
bPassed ? TEXT("strate_crossing_passed") : TEXT("strate_crossing_failed"));
|
||
}
|
||
FPlatformMisc::RequestExitWithStatus(
|
||
false, bPassed ? 0 : 1, TEXT("VoxelForge strate crossing test complete"));
|
||
}
|
||
|
||
void AVoxelWorld::AdvanceHeadlessDigMotionTest(FVector& InOutPlayerPosition, APawn* Pawn)
|
||
{
|
||
FDigMotionTestState* Test = GVoxelForgeDigMotionTests.Find(this);
|
||
if (!Test || !Test->bEnabled || Test->bResultLogged || !IsValid(Pawn))
|
||
{
|
||
return;
|
||
}
|
||
|
||
ACharacter* Character = Cast<ACharacter>(Pawn);
|
||
UCharacterMovementComponent* Movement = Character ? Character->GetCharacterMovement() : nullptr;
|
||
if (!Test->bStarted)
|
||
{
|
||
if (!Movement || Movement->MovementMode != MOVE_Walking)
|
||
{
|
||
return;
|
||
}
|
||
FVoxelTileKey SupportTile;
|
||
if (!IsPlayerSupportCollisionReady(Pawn, InOutPlayerPosition, &SupportTile))
|
||
{
|
||
return;
|
||
}
|
||
Test->bStarted = true;
|
||
Test->BeginSeconds = FPlatformTime::Seconds();
|
||
Test->LastFrameSeconds = Test->BeginSeconds;
|
||
Test->NextDigSeconds = Test->BeginSeconds + 0.25;
|
||
Test->StartPosition = InOutPlayerPosition;
|
||
if (FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this))
|
||
{
|
||
if (!GapAudit->bCollectionStarted)
|
||
{
|
||
VF_StartVisualGapAuditWindow(*GapAudit, Test->BeginSeconds);
|
||
}
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDigMotion] begin=1 position=(%.2f,%.2f,%.2f) support=(%d,%d,%d) ready=1 "
|
||
"movement_mode=%d capsule_half_height_cm=%.2f"),
|
||
InOutPlayerPosition.X, InOutPlayerPosition.Y, InOutPlayerPosition.Z,
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z,
|
||
static_cast<int32>(Movement->MovementMode),
|
||
Character->GetCapsuleComponent()->GetScaledCapsuleHalfHeight());
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (Now - Test->BeginSeconds > 240.0)
|
||
{
|
||
Test->bResultLogged = true;
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeDigMotion] result=FAIL reason=waiting_for_repro_completion near_digs=%d"),
|
||
Test->NearDigsIssued);
|
||
FPlatformMisc::RequestExitWithStatus(
|
||
false, 1, TEXT("VoxelForge dig-motion repro timed out"));
|
||
return;
|
||
}
|
||
|
||
if (Test->NearDigsIssued < Test->NearDigCount && Now >= Test->NextDigSeconds)
|
||
{
|
||
FVoxelTileKey SupportTile;
|
||
if (!IsPlayerSupportCollisionReady(Pawn, InOutPlayerPosition, &SupportTile)
|
||
|| SupportTile.Level != 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition);
|
||
const float TileMinX = static_cast<float>(SupportTile.Coord.X * CHUNK_SIZE);
|
||
const float TileMaxX = TileMinX + CHUNK_SIZE;
|
||
const float TileMinY = static_cast<float>(SupportTile.Coord.Y * CHUNK_SIZE);
|
||
const float TileMaxY = TileMinY + CHUNK_SIZE;
|
||
const float Clearances[4] = {
|
||
TileMaxX - LocalPosition.X, LocalPosition.X - TileMinX,
|
||
TileMaxY - LocalPosition.Y, LocalPosition.Y - TileMinY
|
||
};
|
||
const FVector Directions[4] = {
|
||
FVector(1, 0, 0), FVector(-1, 0, 0),
|
||
FVector(0, 1, 0), FVector(0, -1, 0)
|
||
};
|
||
int32 BestDirection = 0;
|
||
for (int32 Index = 1; Index < UE_ARRAY_COUNT(Clearances); ++Index)
|
||
{
|
||
if (Clearances[Index] > Clearances[BestDirection])
|
||
{
|
||
BestDirection = Index;
|
||
}
|
||
}
|
||
constexpr float DigOffsetVoxels = 8.0f;
|
||
if (Clearances[BestDirection] < DigOffsetVoxels + 3.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float HalfHeightVoxels = Character->GetCapsuleComponent()
|
||
->GetScaledCapsuleHalfHeight() / VOXEL_SIZE;
|
||
FVector DigCenterVoxel = LocalPosition
|
||
+ Directions[BestDirection] * DigOffsetVoxels;
|
||
DigCenterVoxel.Z = LocalPosition.Z - HalfHeightVoxels + 1.0f;
|
||
const FVector DigCenterWorld = LocalVoxelToWorld(DigCenterVoxel);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDigMotion] near_dig=%d center=(%.2f,%.2f,%.2f) pawn=(%.2f,%.2f,%.2f) "
|
||
"horizontal_offset_cm=%.2f support=(%d,%d,%d)"),
|
||
Test->NearDigsIssued + 1,
|
||
DigCenterWorld.X, DigCenterWorld.Y, DigCenterWorld.Z,
|
||
InOutPlayerPosition.X, InOutPlayerPosition.Y, InOutPlayerPosition.Z,
|
||
DigOffsetVoxels * VOXEL_SIZE,
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z);
|
||
CarveAtPosition(DigCenterWorld, 2.0f, 50.0f);
|
||
++Test->NearDigsIssued;
|
||
Test->NextDigSeconds = Now + Test->DigIntervalSeconds;
|
||
return;
|
||
}
|
||
|
||
if (Test->NearDigsIssued >= Test->NearDigCount && !Test->bUnderDigIssued
|
||
&& Now >= Test->NextDigSeconds + 0.5)
|
||
{
|
||
const float HalfHeightVoxels = Character->GetCapsuleComponent()
|
||
->GetScaledCapsuleHalfHeight() / VOXEL_SIZE;
|
||
FVector DigCenterVoxel = WorldToLocalVoxel(InOutPlayerPosition);
|
||
DigCenterVoxel.Z -= HalfHeightVoxels + 1.0f;
|
||
Test->UnderDigStartPosition = InOutPlayerPosition;
|
||
Test->UnderDigSeconds = Now;
|
||
Test->bUnderDigIssued = true;
|
||
Test->NextDigSeconds = Now;
|
||
const FVector DigCenterWorld = LocalVoxelToWorld(DigCenterVoxel);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDigMotion] under_dig=1 center=(%.2f,%.2f,%.2f) pawn=(%.2f,%.2f,%.2f) "
|
||
"radius_vox=5"),
|
||
DigCenterWorld.X, DigCenterWorld.Y, DigCenterWorld.Z,
|
||
InOutPlayerPosition.X, InOutPlayerPosition.Y, InOutPlayerPosition.Z);
|
||
CarveAtPosition(DigCenterWorld, 5.0f, 100.0f);
|
||
return;
|
||
}
|
||
|
||
if (Test->bUnderDigIssued && Now - Test->UnderDigSeconds >= 2.5)
|
||
{
|
||
Test->bResultLogged = true;
|
||
const double FallCm = Test->UnderDigStartPosition.Z - InOutPlayerPosition.Z;
|
||
const bool bPassed = Test->NearDigsIssued == Test->NearDigCount
|
||
&& Test->NearGateFrames == 0
|
||
&& Test->NearFallingFrames == 0
|
||
&& Test->NearVerticalMotionFrames == 0
|
||
&& Test->bSawFalling
|
||
&& FallCm >= 10.0
|
||
&& Test->UnderMaxDownwardVelocity < 6000.0f;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDigMotion] result=%s near_digs=%d near_frames=%d near_gate_frames=%d "
|
||
"near_falling_frames=%d near_vertical_motion_frames=%d "
|
||
"under_saw_falling=%d under_gate_frames=%d under_fall_cm=%.2f "
|
||
"under_max_downward_velocity_cm_s=%.2f"),
|
||
bPassed ? TEXT("PASS") : TEXT("FAIL"), Test->NearDigsIssued, Test->NearFrames,
|
||
Test->NearGateFrames, Test->NearFallingFrames, Test->NearVerticalMotionFrames,
|
||
Test->bSawFalling ? 1 : 0, Test->UnderGateFrames, FallCm,
|
||
Test->UnderMaxDownwardVelocity);
|
||
FPlatformMisc::RequestExitWithStatus(
|
||
false, bPassed ? 0 : 1, TEXT("VoxelForge dig-motion repro complete"));
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::RecordHeadlessDigMotionFrame(
|
||
APawn* Pawn, const FVector& PlayerPosition, float DeltaTime)
|
||
{
|
||
FDigMotionTestState* Test = GVoxelForgeDigMotionTests.Find(this);
|
||
if (!Test || !Test->bEnabled || !Test->bStarted || Test->bResultLogged || !IsValid(Pawn))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
const double SampleDelta = FMath::Max(DeltaTime, 0.0f);
|
||
const ACharacter* Character = Cast<ACharacter>(Pawn);
|
||
const UCharacterMovementComponent* Movement = Character
|
||
? Character->GetCharacterMovement() : nullptr;
|
||
const float VerticalVelocity = Movement ? Movement->Velocity.Z : Pawn->GetVelocity().Z;
|
||
const float GravityZ = Movement ? Movement->GetGravityZ() : 0.0f;
|
||
const int32 MovementMode = Movement ? static_cast<int32>(Movement->MovementMode) : -1;
|
||
FVoxelTileKey SupportTile;
|
||
const bool bSupportReady = IsPlayerSupportCollisionReady(Pawn, PlayerPosition, &SupportTile);
|
||
const bool bNearPhase = !Test->bUnderDigIssued;
|
||
++Test->Frame;
|
||
if (bNearPhase)
|
||
{
|
||
++Test->NearFrames;
|
||
if (bPawnCollisionGateEngaged) { ++Test->NearGateFrames; }
|
||
if (Movement && Movement->MovementMode == MOVE_Falling) { ++Test->NearFallingFrames; }
|
||
if (FMath::Abs(VerticalVelocity) > 10.0f) { ++Test->NearVerticalMotionFrames; }
|
||
Test->NearMaxDownwardVelocity = FMath::Max(
|
||
Test->NearMaxDownwardVelocity, FMath::Max(0.0f, -VerticalVelocity));
|
||
}
|
||
else
|
||
{
|
||
++Test->UnderFrames;
|
||
if (bPawnCollisionGateEngaged) { ++Test->UnderGateFrames; }
|
||
if (Movement && Movement->MovementMode == MOVE_Falling && VerticalVelocity < -10.0f)
|
||
{
|
||
Test->bSawFalling = true;
|
||
}
|
||
Test->UnderMaxDownwardVelocity = FMath::Max(
|
||
Test->UnderMaxDownwardVelocity, FMath::Max(0.0f, -VerticalVelocity));
|
||
}
|
||
Test->LastFrameSeconds = Now;
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDigMotionFrame] frame=%d elapsed_s=%.4f phase=%s dt_s=%.5f "
|
||
"velocity_z_cm_s=%.3f gravity_z_cm_s2=%.3f gate=%d support_ready=%d "
|
||
"support_tile=(%d,%d,%d) movement_mode=%d position_z_cm=%.3f"),
|
||
Test->Frame, Now - Test->BeginSeconds, bNearPhase ? TEXT("near") : TEXT("under"),
|
||
SampleDelta, VerticalVelocity, GravityZ, bPawnCollisionGateEngaged ? 1 : 0,
|
||
bSupportReady ? 1 : 0, SupportTile.Coord.X, SupportTile.Coord.Y,
|
||
SupportTile.Coord.Z, MovementMode, PlayerPosition.Z);
|
||
}
|
||
|
||
void AVoxelWorld::AdvanceHeadlessStreamingTest(
|
||
FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn)
|
||
{
|
||
if (!bHeadlessStreamingTestMovement || !PlayerPawn || HeadlessStreamingTestBeginSeconds <= 0.0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double ElapsedSeconds = FPlatformTime::Seconds() - HeadlessStreamingTestBeginSeconds;
|
||
if (ElapsedSeconds < static_cast<double>(HeadlessStreamingTestStartSeconds))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// The headless game runs uncapped, so simulation DeltaTime is not a stable movement clock. Use
|
||
// wall time for this explicit harness path; both A/B runs then cover exactly the same distance
|
||
// regardless of how much work the scheduler is doing per frame.
|
||
const double PreviousElapsedSeconds = FMath::Max(
|
||
HeadlessStreamingTestLastElapsedSeconds,
|
||
static_cast<double>(HeadlessStreamingTestStartSeconds));
|
||
const double MoveSeconds = FMath::Max(0.0, ElapsedSeconds - PreviousElapsedSeconds);
|
||
HeadlessStreamingTestLastElapsedSeconds = ElapsedSeconds;
|
||
if (MoveSeconds <= 0.0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const FVector NewPosition = InOutPlayerPosition
|
||
+ HeadlessStreamingTestDirection
|
||
* HeadlessStreamingTestSpeedCmPerSecond
|
||
* static_cast<float>(MoveSeconds);
|
||
const bool bMoved = PlayerPawn->SetActorLocation(
|
||
NewPosition, false, nullptr, ETeleportType::TeleportPhysics);
|
||
++HeadlessStreamingTestMoveAttempts;
|
||
if (bMoved)
|
||
{
|
||
const FVector ActualPosition = PlayerPawn->GetActorLocation();
|
||
HeadlessStreamingTestDistanceCm += FVector::Dist(InOutPlayerPosition, ActualPosition);
|
||
HeadlessStreamingTestLastActualPosition = ActualPosition;
|
||
InOutPlayerPosition = ActualPosition;
|
||
InOutPlayerHeading = HeadlessStreamingTestDirection * HeadlessStreamingTestSpeedCmPerSecond;
|
||
|
||
// The explicit -nullrhi harness moves the pawn from this world's pre-physics tick. Keep
|
||
// the template character movement component from applying its own zero-input/gravity step
|
||
// afterward and undoing the synthetic teleport before the next scheduler observation.
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||
{
|
||
CharacterMovement->StopMovementImmediately();
|
||
CharacterMovement->SetMovementMode(MOVE_None);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
bool AVoxelWorld::MeasureHeadlessCollisionGateStressTerrain(
|
||
const FVector& WorldPosition, float CapsuleHalfHeightCm,
|
||
float& OutFeetDensity, float& OutCenterDensity,
|
||
float& OutSurfaceLocalVoxelZ, float& OutClearanceCm)
|
||
{
|
||
OutFeetDensity = 0.0f;
|
||
OutCenterDensity = 0.0f;
|
||
OutSurfaceLocalVoxelZ = 0.0f;
|
||
OutClearanceCm = 0.0f;
|
||
if (Generator == nullptr || !VoxelMath::IsFinite(CapsuleHalfHeightCm)
|
||
|| CapsuleHalfHeightCm < 0.0f)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const FVector LocalPosition = WorldToLocalVoxel(WorldPosition);
|
||
const float FeetZ = LocalPosition.Z - CapsuleHalfHeightCm / VOXEL_SIZE;
|
||
OutCenterDensity = Generator->GetDensityAt(
|
||
LocalPosition.X, LocalPosition.Y, LocalPosition.Z);
|
||
OutFeetDensity = Generator->GetDensityAt(
|
||
LocalPosition.X, LocalPosition.Y, FeetZ);
|
||
if (!VoxelMath::IsFinite(OutCenterDensity) || !VoxelMath::IsFinite(OutFeetDensity))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const bool bUseCachedDeepDropSurface = bHeadlessCollisionGateStressDeepDrop
|
||
&& bHeadlessCollisionGateStressDeepDropSurfaceCached
|
||
&& FMath::Abs(LocalPosition.X - HeadlessCollisionGateStressDeepDropSurfaceXYVox.X) < 0.01f
|
||
&& FMath::Abs(LocalPosition.Y - HeadlessCollisionGateStressDeepDropSurfaceXYVox.Y) < 0.01f;
|
||
if (bUseCachedDeepDropSurface)
|
||
{
|
||
OutSurfaceLocalVoxelZ = HeadlessCollisionGateStressDeepDropSurfaceZVox;
|
||
OutClearanceCm = (FeetZ - OutSurfaceLocalVoxelZ) * VOXEL_SIZE;
|
||
return true;
|
||
}
|
||
|
||
// The density field is the authoritative terrain oracle (negative = solid). Find the
|
||
// nearest air/solid zero crossing adjacent to the capsule's lowest point, then linearly
|
||
// interpolate it. Searching up when the feet sample is solid reports negative clearance
|
||
// instead of silently choosing a lower floor after a fall-through.
|
||
constexpr float SearchStepVoxels = 0.5f;
|
||
const int32 MaxSearchSteps = bHeadlessCollisionGateStressDeepDrop
|
||
&& !bHeadlessCollisionGateStressDeepDropSurfaceCached
|
||
? 512 : 256;
|
||
float LastZ = FeetZ;
|
||
float LastDensity = OutFeetDensity;
|
||
const bool bSearchDown = OutFeetDensity >= 0.0f;
|
||
for (int32 Step = 1; Step <= MaxSearchSteps; ++Step)
|
||
{
|
||
const float SampleZ = FeetZ + (bSearchDown ? -1.0f : 1.0f)
|
||
* SearchStepVoxels * static_cast<float>(Step);
|
||
const float SampleDensity = Generator->GetDensityAt(
|
||
LocalPosition.X, LocalPosition.Y, SampleZ);
|
||
if (!VoxelMath::IsFinite(SampleDensity))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const bool bCrossedSurface = bSearchDown
|
||
? (LastDensity >= 0.0f && SampleDensity < 0.0f)
|
||
: (LastDensity <= 0.0f && SampleDensity > 0.0f);
|
||
if (bCrossedSurface)
|
||
{
|
||
const float Denominator = LastDensity - SampleDensity;
|
||
const float Alpha = FMath::IsNearlyZero(Denominator)
|
||
? 0.5f
|
||
: FMath::Clamp(LastDensity / Denominator, 0.0f, 1.0f);
|
||
OutSurfaceLocalVoxelZ = FMath::Lerp(LastZ, SampleZ, Alpha);
|
||
OutClearanceCm = (FeetZ - OutSurfaceLocalVoxelZ) * VOXEL_SIZE;
|
||
if (bHeadlessCollisionGateStressDeepDrop)
|
||
{
|
||
HeadlessCollisionGateStressDeepDropSurfaceXYVox = FVector2D(
|
||
LocalPosition.X, LocalPosition.Y);
|
||
HeadlessCollisionGateStressDeepDropSurfaceZVox = OutSurfaceLocalVoxelZ;
|
||
bHeadlessCollisionGateStressDeepDropSurfaceCached = true;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
LastZ = SampleZ;
|
||
LastDensity = SampleDensity;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest(
|
||
FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn)
|
||
{
|
||
if (!bHeadlessCollisionGateStressTest || bHeadlessCollisionGateStressTestFailed
|
||
|| bHeadlessCollisionGateStressTestPassed || !IsValid(PlayerPawn))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
int32 DeepDropTopChunkZ = 0;
|
||
int32 DeepDropBottomChunkZ = 0;
|
||
if (bHeadlessCollisionGateStressDeepDrop)
|
||
{
|
||
if (HeadlessCollisionGateStressTestBeginSeconds <= 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestBeginSeconds = Now;
|
||
}
|
||
if (StrateManager == nullptr
|
||
|| !StrateManager->GetStrateChunkZBounds(
|
||
/*ChunkZ*/ 0, DeepDropTopChunkZ, DeepDropBottomChunkZ))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_fixture_strate_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_fixture_strate_unavailable probe_chunk_z=0"));
|
||
return;
|
||
}
|
||
}
|
||
if (!bHeadlessCollisionGateStressTestStartPlaced)
|
||
{
|
||
if (bHeadlessCollisionGateStressDeepDrop && !bHeadlessCollisionGateStressDeepDropPrepared)
|
||
{
|
||
// Carve a tile-wide air column through the top strate. The test later checks the exact
|
||
// 35^3 density samples for each pre-proven empty tile before allowing the support walk
|
||
// to skip it. The box ends at the first sample below the floor tile's upper boundary,
|
||
// avoiding the round end-cap that a capsule would extend into the floor. It extends
|
||
// along the test's post-landing route so that route does not hit the shaft's side wall.
|
||
// Its long extent needs a larger brush-radius allowance in this transient commandlet
|
||
// world only.
|
||
constexpr float DeepDropCenterX = CHUNK_SIZE * 0.5f;
|
||
constexpr float DeepDropCenterY = CHUNK_SIZE * 0.5f;
|
||
constexpr float DeepDropShaftStartX = -32.0f;
|
||
const float DeepDropShaftEndX = CHUNK_SIZE * 0.5f
|
||
+ (HeadlessCollisionGateStressTestSpeedCmPerSecond
|
||
* HeadlessCollisionGateStressTestDurationSeconds / VOXEL_SIZE)
|
||
+ 64.0f;
|
||
const float StrateTopExclusiveVoxels =
|
||
static_cast<float>(DeepDropTopChunkZ + 1) * CHUNK_SIZE;
|
||
const float ShaftTopVoxels = StrateTopExclusiveVoxels + 32.0f;
|
||
const float ShaftBottomVoxels =
|
||
static_cast<float>(DeepDropBottomChunkZ + 1) * CHUNK_SIZE - 1.0f;
|
||
if (ShaftBottomVoxels >= ShaftTopVoxels || DiffLayer == nullptr)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_fixture_strate_or_diff_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_fixture_strate_or_diff_unavailable top_chunk_z=%d "
|
||
"bottom_chunk_z=%d diff_available=%d"),
|
||
DeepDropTopChunkZ, DeepDropBottomChunkZ, DiffLayer != nullptr ? 1 : 0);
|
||
return;
|
||
}
|
||
const float ShaftHalfExtentZ = (ShaftTopVoxels - ShaftBottomVoxels) * 0.5f;
|
||
const float ShaftCenterX =
|
||
(DeepDropShaftStartX + DeepDropShaftEndX) * 0.5f;
|
||
const float ShaftHalfExtentX =
|
||
(DeepDropShaftEndX - DeepDropShaftStartX) * 0.5f;
|
||
const FVector ShaftCenterWorld = LocalVoxelToWorld(FVector(
|
||
ShaftCenterX, DeepDropCenterY,
|
||
(ShaftTopVoxels + ShaftBottomVoxels) * 0.5f));
|
||
const FVector ShaftHalfExtent(ShaftHalfExtentX, 18.0f, ShaftHalfExtentZ);
|
||
const float FixtureBrushRadius = FMath::Max(
|
||
FMath::Max3(ShaftHalfExtent.X, ShaftHalfExtent.Y, ShaftHalfExtent.Z),
|
||
Settings != nullptr ? Settings->MaxBrushRadius : 0.0f);
|
||
DiffLayer->SetBudget(
|
||
Settings != nullptr ? Settings->MaxModifications : 0,
|
||
FixtureBrushRadius,
|
||
Settings != nullptr ? Settings->MaxTotalVolume : 0.0f);
|
||
CarveBox(ShaftCenterWorld, ShaftHalfExtent, 512.0f);
|
||
|
||
int32 SuppressedPlayerAnchorCount = 0;
|
||
for (const FVoxelStreamingAnchor& Anchor : StreamingAnchors)
|
||
{
|
||
if (Anchor.Actor.Get() == PlayerPawn)
|
||
{
|
||
++SuppressedPlayerAnchorCount;
|
||
}
|
||
}
|
||
if (SuppressedPlayerAnchorCount > 0)
|
||
{
|
||
// The owner map registers the local pawn as an additional anchor. Suppress only
|
||
// that redundant test-world anchor so this fixture exercises the gate's fallback
|
||
// path; all other AI/remote-player anchors remain active. The commandlet exits
|
||
// with the transient world immediately after this test.
|
||
UnregisterStreamingAnchor(PlayerPawn);
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_player_anchor_suppressed=%d "
|
||
"remaining_anchors=%d"),
|
||
SuppressedPlayerAnchorCount, StreamingAnchors.Num());
|
||
|
||
FIntVector DeepDropRingMin = FIntVector::ZeroValue;
|
||
FIntVector DeepDropRingMax = FIntVector::ZeroValue;
|
||
VoxelClipmapDesiredTiles::AlignedRingBounds(
|
||
FIntVector(0, 0, DeepDropTopChunkZ),
|
||
Settings != nullptr ? FMath::Max(1, Settings->ClipRadius) : 3,
|
||
2, DeepDropRingMin, DeepDropRingMax);
|
||
for (int32 TileZ = DeepDropRingMin.Z - 1;
|
||
TileZ > DeepDropBottomChunkZ; --TileZ)
|
||
{
|
||
const FVoxelTileKey EmptyTile(FIntVector(0, 0, TileZ), 0);
|
||
if (LoadedTiles.Contains(EmptyTile) || PendingTiles.Contains(EmptyTile))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_empty_tile_already_streamed");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_empty_tile_already_streamed tile=(0,0,%d)"),
|
||
TileZ);
|
||
return;
|
||
}
|
||
|
||
const int32 TileMinX = EmptyTile.Coord.X * CHUNK_SIZE - 1;
|
||
const int32 TileMaxX = (EmptyTile.Coord.X + 1) * CHUNK_SIZE + 1;
|
||
const int32 TileMinY = EmptyTile.Coord.Y * CHUNK_SIZE - 1;
|
||
const int32 TileMaxY = (EmptyTile.Coord.Y + 1) * CHUNK_SIZE + 1;
|
||
const int32 TileMinZ = EmptyTile.Coord.Z * CHUNK_SIZE - 1;
|
||
const int32 TileMaxZ = (EmptyTile.Coord.Z + 1) * CHUNK_SIZE + 1;
|
||
int32 CheckedSamples = 0;
|
||
float MinimumDensity = TNumericLimits<float>::Max();
|
||
float FirstNonAirDensity = 0.0f;
|
||
FIntVector FirstNonAirSample = FIntVector::ZeroValue;
|
||
bool bEverySampleAir = true;
|
||
for (int32 Z = TileMinZ; Z <= TileMaxZ && bEverySampleAir; ++Z)
|
||
for (int32 Y = TileMinY; Y <= TileMaxY && bEverySampleAir; ++Y)
|
||
for (int32 X = TileMinX; X <= TileMaxX; ++X)
|
||
{
|
||
const float Density = Generator != nullptr
|
||
? Generator->GetDensityAt(
|
||
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z))
|
||
: -1.0f;
|
||
++CheckedSamples;
|
||
if (!VoxelMath::IsFinite(Density) || Density <= 0.0f)
|
||
{
|
||
bEverySampleAir = false;
|
||
FirstNonAirDensity = Density;
|
||
FirstNonAirSample = FIntVector(X, Y, Z);
|
||
break;
|
||
}
|
||
MinimumDensity = FMath::Min(MinimumDensity, Density);
|
||
}
|
||
if (!bEverySampleAir)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_empty_tile_density_proof_failed");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_empty_tile_density_proof_failed "
|
||
"tile=(0,0,%d) samples=%d first_non_air=(%d,%d,%d) "
|
||
"density=%.6f"),
|
||
TileZ, CheckedSamples,
|
||
FirstNonAirSample.X, FirstNonAirSample.Y, FirstNonAirSample.Z,
|
||
FirstNonAirDensity);
|
||
return;
|
||
}
|
||
CollisionNotRequiredTiles.Add(EmptyTile);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_empty_tile "
|
||
"tile=(0,0,%d) samples=%d min_density=%.6f seeded_known_empty=1"),
|
||
TileZ, CheckedSamples, MinimumDensity);
|
||
}
|
||
|
||
bHeadlessCollisionGateStressDeepDropPrepared = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_fixture=carved "
|
||
"top_chunk_z=%d bottom_chunk_z=%d "
|
||
"local_vox_x=(%.2f,%.2f) y=%.2f z=(%.2f,%.2f) "
|
||
"half_extent_vox=(%.2f,%.2f,%.2f) fixture_brush_radius=%.2f"),
|
||
DeepDropTopChunkZ, DeepDropBottomChunkZ,
|
||
DeepDropShaftStartX, DeepDropShaftEndX, DeepDropCenterY,
|
||
ShaftTopVoxels, ShaftBottomVoxels,
|
||
ShaftHalfExtent.X, ShaftHalfExtent.Y, ShaftHalfExtent.Z,
|
||
FixtureBrushRadius);
|
||
}
|
||
|
||
if (!bHeadlessCollisionGateStressTestSettingsReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] owner_settings clip_radius=%d "
|
||
"max_clip_level=%d render_distance_chunks=%d"),
|
||
Settings != nullptr ? Settings->ClipRadius : -1,
|
||
Settings != nullptr ? Settings->MaxClipLevel : -1,
|
||
Settings != nullptr ? Settings->RenderDistanceChunks : -1);
|
||
bHeadlessCollisionGateStressTestSettingsReported = true;
|
||
}
|
||
float CapsuleHalfHeightCm = 0.0f;
|
||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
CapsuleHalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
}
|
||
}
|
||
|
||
// The earlier horizontal route ran through an open origin drop and ended more than a
|
||
// metre above its floor. Start above the measured density surface at this XY so the pawn
|
||
// must fall onto a freshly requested level-0 tile before the horizontal diagnostic.
|
||
const float LaunchLocalVoxelX = bHeadlessCollisionGateStressDeepDrop
|
||
? CHUNK_SIZE * 0.5f : 18.2f;
|
||
const float LaunchLocalVoxelY = bHeadlessCollisionGateStressDeepDrop
|
||
? CHUNK_SIZE * 0.5f : 0.0f;
|
||
float LaunchHeightAboveGroundVoxels =
|
||
HeadlessCollisionGateStressTestSpawnHeightVoxels;
|
||
const FVector SurfaceProbeWorld = LocalVoxelToWorld(
|
||
FVector(LaunchLocalVoxelX, LaunchLocalVoxelY, 0.0f));
|
||
float ProbeFeetDensity = 0.0f;
|
||
float ProbeCenterDensity = 0.0f;
|
||
float SurfaceLocalVoxelZ = 0.0f;
|
||
float ProbeClearanceCm = 0.0f;
|
||
const bool bSurfaceFound = MeasureHeadlessCollisionGateStressTerrain(
|
||
SurfaceProbeWorld, CapsuleHalfHeightCm, ProbeFeetDensity,
|
||
ProbeCenterDensity, SurfaceLocalVoxelZ, ProbeClearanceCm);
|
||
if (!bSurfaceFound)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("stress_launch_surface_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=stress_launch_surface_unavailable probe=(%.2f,%.2f,%.2f) "
|
||
"feet_density=%.6f"),
|
||
SurfaceProbeWorld.X, SurfaceProbeWorld.Y, SurfaceProbeWorld.Z,
|
||
ProbeFeetDensity);
|
||
return;
|
||
}
|
||
|
||
float LaunchLocalVoxelZ = SurfaceLocalVoxelZ
|
||
+ CapsuleHalfHeightCm / VOXEL_SIZE + LaunchHeightAboveGroundVoxels;
|
||
int32 FloorChunkZ = FMath::FloorToInt(SurfaceLocalVoxelZ / CHUNK_SIZE);
|
||
int32 SurfaceStrateTopChunkZ = 0;
|
||
int32 SurfaceStrateBottomChunkZ = 0;
|
||
FIntVector LevelZeroRingMin = FIntVector::ZeroValue;
|
||
FIntVector LevelZeroRingMax = FIntVector::ZeroValue;
|
||
if (bHeadlessCollisionGateStressDeepDrop)
|
||
{
|
||
if (!StrateManager->GetStrateChunkZBounds(
|
||
FloorChunkZ, SurfaceStrateTopChunkZ, SurfaceStrateBottomChunkZ)
|
||
|| SurfaceStrateTopChunkZ != DeepDropTopChunkZ
|
||
|| FloorChunkZ != DeepDropBottomChunkZ)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_floor_outside_fixture_strate");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_floor_outside_fixture_strate surface_vox_z=%.3f "
|
||
"floor_chunk_z=%d fixture_strate=%d..%d surface_strate=%d..%d"),
|
||
SurfaceLocalVoxelZ, FloorChunkZ, DeepDropTopChunkZ,
|
||
DeepDropBottomChunkZ, SurfaceStrateTopChunkZ,
|
||
SurfaceStrateBottomChunkZ);
|
||
return;
|
||
}
|
||
|
||
const float CapsuleHalfHeightVoxels = CapsuleHalfHeightCm / VOXEL_SIZE;
|
||
const float StrateTopExclusiveVoxels =
|
||
static_cast<float>(SurfaceStrateTopChunkZ + 1) * CHUNK_SIZE;
|
||
LaunchLocalVoxelZ = StrateTopExclusiveVoxels
|
||
- CapsuleHalfHeightVoxels - 0.5f;
|
||
LaunchHeightAboveGroundVoxels = LaunchLocalVoxelZ
|
||
- SurfaceLocalVoxelZ - CapsuleHalfHeightVoxels;
|
||
const FIntVector LaunchCenterTile(
|
||
FMath::FloorToInt(LaunchLocalVoxelX / CHUNK_SIZE),
|
||
FMath::FloorToInt(LaunchLocalVoxelY / CHUNK_SIZE),
|
||
FMath::FloorToInt(LaunchLocalVoxelZ / CHUNK_SIZE));
|
||
const int32 ClipRadius = Settings != nullptr
|
||
? FMath::Max(1, Settings->ClipRadius) : 3;
|
||
VoxelClipmapDesiredTiles::AlignedRingBounds(
|
||
LaunchCenterTile, ClipRadius, 2, LevelZeroRingMin, LevelZeroRingMax);
|
||
if (LaunchHeightAboveGroundVoxels <= 0.0f
|
||
|| FloorChunkZ >= LevelZeroRingMin.Z)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_floor_not_below_level0_ring");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_floor_not_below_level0_ring "
|
||
"start_center_z=%.3f surface_z=%.3f floor_tile_z=%d "
|
||
"ring_min_z=%d spawn_height_vox=%.3f"),
|
||
LaunchLocalVoxelZ, SurfaceLocalVoxelZ, FloorChunkZ,
|
||
LevelZeroRingMin.Z, LaunchHeightAboveGroundVoxels);
|
||
return;
|
||
}
|
||
}
|
||
|
||
HeadlessCollisionGateStressTestSpawnHeightVoxels =
|
||
LaunchHeightAboveGroundVoxels;
|
||
const FVector LaunchVoxel(
|
||
LaunchLocalVoxelX, LaunchLocalVoxelY, LaunchLocalVoxelZ);
|
||
const FVector RequestedLaunchWorld = LocalVoxelToWorld(LaunchVoxel);
|
||
FVoxelTileKey ExpectedLaunchSupportTile;
|
||
if (!GetPlayerSupportTile(
|
||
PlayerPawn, RequestedLaunchWorld, ExpectedLaunchSupportTile))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("stress_launch_support_missing");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=stress_launch_support_missing"));
|
||
return;
|
||
}
|
||
if (bHeadlessCollisionGateStressDeepDrop)
|
||
{
|
||
int32 UnusedTopChunkZ = 0;
|
||
int32 UnusedBottomChunkZ = 0;
|
||
if (!StrateManager->GetStrateChunkZBounds(
|
||
ExpectedLaunchSupportTile.Coord.Z,
|
||
UnusedTopChunkZ, UnusedBottomChunkZ))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_start_outside_layout");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_start_outside_layout start_tile_z=%d"),
|
||
ExpectedLaunchSupportTile.Coord.Z);
|
||
return;
|
||
}
|
||
|
||
bHeadlessStreamingTestCenterOverride = true;
|
||
HeadlessStreamingTestCenterVoxel = LaunchVoxel;
|
||
int32 ResolvedColumnTiles = 0;
|
||
const int32 RequiredColumnTiles =
|
||
ExpectedLaunchSupportTile.Coord.Z - LevelZeroRingMin.Z + 1;
|
||
bool bPrewarmReady = true;
|
||
for (int32 TileZ = ExpectedLaunchSupportTile.Coord.Z;
|
||
TileZ >= LevelZeroRingMin.Z; --TileZ)
|
||
{
|
||
const FVoxelTileKey ColumnTile(
|
||
FIntVector(ExpectedLaunchSupportTile.Coord.X,
|
||
ExpectedLaunchSupportTile.Coord.Y, TileZ), 0);
|
||
const bool bAirProven = CollisionNotRequiredTiles.Contains(ColumnTile);
|
||
const bool bResolved = IsDesired(ColumnTile) && bAirProven;
|
||
if (IsDesired(ColumnTile) && IsTileCollisionReady(ColumnTile) && !bAirProven)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_ring_column_not_empty");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_ring_column_not_empty tile=(%d,%d,%d)"),
|
||
ColumnTile.Coord.X, ColumnTile.Coord.Y, ColumnTile.Coord.Z);
|
||
return;
|
||
}
|
||
if (bResolved)
|
||
{
|
||
++ResolvedColumnTiles;
|
||
}
|
||
else
|
||
{
|
||
bPrewarmReady = false;
|
||
}
|
||
}
|
||
if (!bPrewarmReady)
|
||
{
|
||
if (!bHeadlessCollisionGateStressDeepDropPrewarmReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_prewarm "
|
||
"resolved=%d required=%d start_tile=(%d,%d,%d) "
|
||
"floor_tile_z=%d ring_min_z=%d"),
|
||
ResolvedColumnTiles, RequiredColumnTiles,
|
||
ExpectedLaunchSupportTile.Coord.X,
|
||
ExpectedLaunchSupportTile.Coord.Y,
|
||
ExpectedLaunchSupportTile.Coord.Z,
|
||
FloorChunkZ, LevelZeroRingMin.Z);
|
||
bHeadlessCollisionGateStressDeepDropPrewarmReported = true;
|
||
}
|
||
return;
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_prewarm_ready "
|
||
"empty_tiles=%d required=%d start_tile_z=%d floor_tile_z=%d "
|
||
"ring_min_z=%d drop_depth_vox=%.3f"),
|
||
ResolvedColumnTiles, RequiredColumnTiles,
|
||
ExpectedLaunchSupportTile.Coord.Z, FloorChunkZ,
|
||
LevelZeroRingMin.Z, LaunchLocalVoxelZ - SurfaceLocalVoxelZ);
|
||
|
||
FVoxelTileKey WalkTarget;
|
||
const TCHAR* WalkUnavailableReason = nullptr;
|
||
const bool bWalkResolved = IsCollisionReadyFromSupportTile(
|
||
ExpectedLaunchSupportTile, &WalkTarget, &WalkUnavailableReason);
|
||
const bool bWalkTargetInsideRenderRing =
|
||
WalkTarget.Coord.X >= LevelZeroRingMin.X
|
||
&& WalkTarget.Coord.X <= LevelZeroRingMax.X
|
||
&& WalkTarget.Coord.Y >= LevelZeroRingMin.Y
|
||
&& WalkTarget.Coord.Y <= LevelZeroRingMax.Y
|
||
&& WalkTarget.Coord.Z >= LevelZeroRingMin.Z
|
||
&& WalkTarget.Coord.Z <= LevelZeroRingMax.Z;
|
||
if (bWalkResolved || WalkUnavailableReason != nullptr
|
||
|| WalkTarget.Coord.Z != FloorChunkZ || bWalkTargetInsideRenderRing)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("deep_drop_support_walk_target_invalid");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=deep_drop_support_walk_target_invalid resolved=%d "
|
||
"unavailable=%s walk_target=(%d,%d,%d) floor_tile_z=%d "
|
||
"render_ring_desired=%d gate_retained=%d ring_min_z=%d"),
|
||
bWalkResolved ? 1 : 0,
|
||
WalkUnavailableReason != nullptr ? WalkUnavailableReason : TEXT("none"),
|
||
WalkTarget.Coord.X, WalkTarget.Coord.Y, WalkTarget.Coord.Z,
|
||
FloorChunkZ, bWalkTargetInsideRenderRing ? 1 : 0,
|
||
CollisionGateDesiredTiles.Contains(WalkTarget) ? 1 : 0,
|
||
LevelZeroRingMin.Z);
|
||
return;
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_support_walk "
|
||
"start=(%d,%d,%d) target=(%d,%d,%d) render_ring_desired=0 "
|
||
"gate_retained=%d desired_now=%d ring_min_z=%d floor_tile_z=%d"),
|
||
ExpectedLaunchSupportTile.Coord.X,
|
||
ExpectedLaunchSupportTile.Coord.Y,
|
||
ExpectedLaunchSupportTile.Coord.Z,
|
||
WalkTarget.Coord.X, WalkTarget.Coord.Y, WalkTarget.Coord.Z,
|
||
CollisionGateDesiredTiles.Contains(WalkTarget) ? 1 : 0,
|
||
IsDesired(WalkTarget) ? 1 : 0, LevelZeroRingMin.Z, FloorChunkZ);
|
||
}
|
||
const bool bPlaced = PlayerPawn->SetActorLocation(
|
||
RequestedLaunchWorld, false, nullptr, ETeleportType::TeleportPhysics);
|
||
if (!bPlaced)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("stress_launch_placement_failed");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=stress_launch_placement_failed requested=(%.2f,%.2f,%.2f)"),
|
||
RequestedLaunchWorld.X, RequestedLaunchWorld.Y, RequestedLaunchWorld.Z);
|
||
return;
|
||
}
|
||
|
||
const FVector ActualPosition = PlayerPawn->GetActorLocation();
|
||
const FVector ActualLaunchVoxel = WorldToLocalVoxel(ActualPosition);
|
||
// Keep streaming centred on the launch footprint while the character drops and later
|
||
// moves horizontally. The support tile below the capsule is still not cooked at launch.
|
||
bHeadlessStreamingTestCenterOverride = true;
|
||
HeadlessStreamingTestCenterVoxel = ActualLaunchVoxel;
|
||
InOutPlayerPosition = ActualPosition;
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
HeadlessCollisionGateStressTestLastActualPosition = ActualPosition;
|
||
HeadlessCollisionGateStressTestStartPosition = ActualPosition;
|
||
HeadlessCollisionGateStressTestDirection = FVector::ZeroVector;
|
||
bHeadlessCollisionGateStressTestLanded = false;
|
||
|
||
FVoxelTileKey FootTile;
|
||
const bool bHaveFootTile = GetPlayerSupportTile(PlayerPawn, ActualPosition, FootTile);
|
||
FVoxelTileKey GateSupportTile;
|
||
const TCHAR* SupportUnavailableReason = nullptr;
|
||
const bool bSupportReady = IsPlayerSupportCollisionReady(
|
||
PlayerPawn, ActualPosition, &GateSupportTile, &SupportUnavailableReason);
|
||
const bool bFootTileReady = bHaveFootTile && IsTileCollisionReady(FootTile);
|
||
if (!bHaveFootTile)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("stress_launch_support_missing");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed "
|
||
"reason=stress_launch_support_missing"));
|
||
return;
|
||
}
|
||
HeadlessCollisionGateStressTestLaunchSupportTile = FootTile;
|
||
bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured = true;
|
||
HeadlessCollisionGateStressTestLaunchSupportCheckSeconds = Now;
|
||
if (bFootTileReady)
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = Now;
|
||
HeadlessCollisionGateStressTestLaunchSupportReadySeconds = Now;
|
||
}
|
||
else if (PendingTiles.Contains(FootTile) || LoadedTiles.Contains(FootTile))
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = Now;
|
||
}
|
||
|
||
bHeadlessCollisionGateStressTestStartPlaced = true;
|
||
HeadlessCollisionGateStressTestStartSeconds = Now;
|
||
HeadlessCollisionGateStressTestBeginSeconds = Now;
|
||
bHeadlessCollisionGateStressTestStarted = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] drop_started position=(%.1f,%.1f,%.1f) "
|
||
"support=(%d,%d,%d)"),
|
||
ActualPosition.X, ActualPosition.Y, ActualPosition.Z,
|
||
FootTile.Coord.X, FootTile.Coord.Y, FootTile.Coord.Z);
|
||
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement())
|
||
{
|
||
CharacterMovement->MaxWalkSpeed = HeadlessCollisionGateStressTestSpeedCmPerSecond;
|
||
CharacterMovement->MaxAcceleration = FMath::Max(
|
||
CharacterMovement->MaxAcceleration,
|
||
HeadlessCollisionGateStressTestSpeedCmPerSecond * 8.0f);
|
||
CharacterMovement->BrakingDecelerationWalking = FMath::Max(
|
||
CharacterMovement->BrakingDecelerationWalking,
|
||
HeadlessCollisionGateStressTestSpeedCmPerSecond * 8.0f);
|
||
CharacterMovement->StopMovementImmediately();
|
||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||
// Exercise the gate's actual hazard: a pawn descending onto a just-streamed
|
||
// floor fast enough to cross the surface in one movement frame if uncooked.
|
||
CharacterMovement->Velocity = FVector(
|
||
0.0f, 0.0f, -HeadlessCollisionGateStressTestSpeedCmPerSecond);
|
||
CharacterMovement->UpdateComponentVelocity();
|
||
}
|
||
}
|
||
|
||
float FeetDensity = 0.0f;
|
||
float CenterDensity = 0.0f;
|
||
float ActualSurfaceLocalVoxelZ = 0.0f;
|
||
float ClearanceCm = 0.0f;
|
||
const bool bLaunchSurfaceFound = MeasureHeadlessCollisionGateStressTerrain(
|
||
ActualPosition, CapsuleHalfHeightCm, FeetDensity, CenterDensity,
|
||
ActualSurfaceLocalVoxelZ, ClearanceCm);
|
||
if (bLaunchSurfaceFound)
|
||
{
|
||
HeadlessCollisionGateStressTestMinClearanceCm = ClearanceCm;
|
||
HeadlessCollisionGateStressTestMinClearancePosition = ActualPosition;
|
||
HeadlessCollisionGateStressTestMinFeetDensity = FeetDensity;
|
||
HeadlessCollisionGateStressTestMinSurfaceLocalVoxelZ = ActualSurfaceLocalVoxelZ;
|
||
++HeadlessCollisionGateStressTestTerrainSampleCount;
|
||
}
|
||
else
|
||
{
|
||
bHeadlessCollisionGateStressTestTerrainMeasurementMissing = true;
|
||
}
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] start_placed moved=%d "
|
||
"support=(%d,%d,%d) tile_collision_ready=%d gate_support=(%d,%d,%d) "
|
||
"gate_support_ready=%d unavailable=%s surface_vox_z=%.4f "
|
||
"spawn_height_vox=%.2f position=(%.1f,%.1f,%.1f) "
|
||
"launch_vox=(%.3f,%.3f,%.3f)"),
|
||
bPlaced ? 1 : 0,
|
||
FootTile.Coord.X, FootTile.Coord.Y, FootTile.Coord.Z,
|
||
bFootTileReady ? 1 : 0,
|
||
GateSupportTile.Coord.X, GateSupportTile.Coord.Y, GateSupportTile.Coord.Z,
|
||
bSupportReady ? 1 : 0,
|
||
SupportUnavailableReason != nullptr ? SupportUnavailableReason : TEXT("none"),
|
||
SurfaceLocalVoxelZ, LaunchHeightAboveGroundVoxels,
|
||
ActualPosition.X, ActualPosition.Y, ActualPosition.Z,
|
||
ActualLaunchVoxel.X, ActualLaunchVoxel.Y, ActualLaunchVoxel.Z);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] start_density feet=%.6f center=%.6f "
|
||
"surface_found=%d surface_vox_z=%.4f clearance_cm=%.3f "
|
||
"half_height_cm=%.2f probe_feet_density=%.6f"),
|
||
FeetDensity, CenterDensity, bLaunchSurfaceFound ? 1 : 0,
|
||
ActualSurfaceLocalVoxelZ, ClearanceCm, CapsuleHalfHeightCm, ProbeFeetDensity);
|
||
return;
|
||
}
|
||
|
||
// The measured interval starts with the vertical drop. Horizontal movement begins only after
|
||
// CharacterMovement reports a landing, so the test observes the entire fall and then checks
|
||
// that support gating leaves horizontal movement available.
|
||
if (!bHeadlessCollisionGateStressTestLanded)
|
||
{
|
||
if (bHeadlessCollisionGateStressDeepDrop
|
||
&& bPawnCollisionGateEngaged
|
||
&& HeadlessCollisionGateStressTestGateDistanceCm < 50.0)
|
||
{
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement())
|
||
{
|
||
FVector Velocity = CharacterMovement->Velocity;
|
||
Velocity.X = 100.0f;
|
||
CharacterMovement->Velocity = Velocity;
|
||
CharacterMovement->UpdateComponentVelocity();
|
||
if (!bHeadlessCollisionGateStressTestHorizontalGateProbeApplied)
|
||
{
|
||
bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] horizontal_gate_probe "
|
||
"applied=1 velocity_x_cm_s=%.1f gate=1"), Velocity.X);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
return;
|
||
}
|
||
|
||
const FVector WorldDirection = GetActorTransform().TransformVectorNoScale(
|
||
HeadlessCollisionGateStressTestDirection).GetSafeNormal();
|
||
if (WorldDirection.IsNearlyZero())
|
||
{
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
return;
|
||
}
|
||
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||
{
|
||
// The baseline gate changes MOVE_Walking to MOVE_None. Do not undo that here; the
|
||
// test must observe the resulting full stop. The fall-only gate keeps this input live.
|
||
if (!bPawnCollisionGateEngaged
|
||
&& CharacterMovement->MovementMode == MOVE_None)
|
||
{
|
||
CharacterMovement->SetMovementMode(MOVE_Walking);
|
||
}
|
||
PlayerPawn->AddMovementInput(WorldDirection, 1.0f, false);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
PlayerPawn->AddMovementInput(WorldDirection, 1.0f, false);
|
||
}
|
||
InOutPlayerHeading = WorldDirection * HeadlessCollisionGateStressTestSpeedCmPerSecond;
|
||
}
|
||
|
||
void AVoxelWorld::ObserveHeadlessCollisionGateStressTest(
|
||
const FVector& PlayerPosition, APawn* PlayerPawn)
|
||
{
|
||
if (!bHeadlessCollisionGateStressTest || !bHeadlessCollisionGateStressTestStarted
|
||
|| !IsValid(PlayerPawn) || bHeadlessCollisionGateStressTestExitRequested
|
||
|| bHeadlessCollisionGateStressTestFailed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
const FVector PreviousPosition = HeadlessCollisionGateStressTestLastActualPosition;
|
||
const float HorizontalDelta = FVector::Dist2D(
|
||
PreviousPosition, PlayerPosition);
|
||
HeadlessCollisionGateStressTestDistanceCm += HorizontalDelta;
|
||
HeadlessCollisionGateStressTestLastActualPosition = PlayerPosition;
|
||
|
||
const bool bGateEngaged = bPawnCollisionGateEngaged;
|
||
if (bGateEngaged && bHeadlessCollisionGateStressTestLanded
|
||
&& HorizontalDelta > 0.1f
|
||
&& !bHeadlessCollisionGateStressTestHorizontalGateProbeApplied)
|
||
{
|
||
bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] horizontal_gate_probe "
|
||
"observed=1 delta_cm=%.3f gate=1"), HorizontalDelta);
|
||
}
|
||
if (bGateEngaged || bHeadlessCollisionGateStressTestGateWasEngaged)
|
||
{
|
||
HeadlessCollisionGateStressTestGateDistanceCm += HorizontalDelta;
|
||
}
|
||
if (bGateEngaged && !bHeadlessCollisionGateStressTestGateWasEngaged)
|
||
{
|
||
++HeadlessCollisionGateStressTestGateHoldCount;
|
||
HeadlessCollisionGateStressTestGateStartSeconds = Now;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] gate=engaged elapsed_s=%.6f"),
|
||
Now - HeadlessCollisionGateStressTestStartSeconds);
|
||
}
|
||
else if (!bGateEngaged && bHeadlessCollisionGateStressTestGateWasEngaged)
|
||
{
|
||
const double GateDuration = FMath::Max(
|
||
0.0, Now - HeadlessCollisionGateStressTestGateStartSeconds);
|
||
HeadlessCollisionGateStressTestTotalGateDurationSeconds += GateDuration;
|
||
HeadlessCollisionGateStressTestMaxGateDurationSeconds = FMath::Max(
|
||
HeadlessCollisionGateStressTestMaxGateDurationSeconds, GateDuration);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] gate=released duration_s=%.6f "
|
||
"total_s=%.6f"),
|
||
GateDuration,
|
||
HeadlessCollisionGateStressTestTotalGateDurationSeconds);
|
||
}
|
||
bHeadlessCollisionGateStressTestGateWasEngaged = bGateEngaged;
|
||
|
||
// The first sample after engaging any gate can have zero horizontal delta simply because the
|
||
// world prerequisite ran before CharacterMovementComponent. Require a sustained no-progress
|
||
// interval before calling it a full stop; a permanent stop still fails through the distance
|
||
// shortfall check below, while repeated gate holds remain visible in gate_holds/total_s.
|
||
constexpr double FullStopConfirmationSeconds = 0.05;
|
||
const bool bNoHorizontalProgress = bGateEngaged
|
||
&& bHeadlessCollisionGateStressTestLanded && HorizontalDelta <= 0.01f;
|
||
if (bNoHorizontalProgress)
|
||
{
|
||
if (HeadlessCollisionGateStressTestFullStopStartSeconds <= 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestFullStopStartSeconds = Now;
|
||
}
|
||
if (!bHeadlessCollisionGateStressTestFullStop
|
||
&& Now - HeadlessCollisionGateStressTestFullStopStartSeconds
|
||
>= FullStopConfirmationSeconds)
|
||
{
|
||
++HeadlessCollisionGateStressTestFullStopCount;
|
||
bHeadlessCollisionGateStressTestFullStop = true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (bHeadlessCollisionGateStressTestFullStop
|
||
&& HeadlessCollisionGateStressTestFullStopStartSeconds > 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestFullStopDurationSeconds += FMath::Max(
|
||
0.0, Now - HeadlessCollisionGateStressTestFullStopStartSeconds);
|
||
}
|
||
bHeadlessCollisionGateStressTestFullStop = false;
|
||
HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0;
|
||
}
|
||
|
||
float CapsuleHalfHeightCm = 0.0f;
|
||
FVector PawnVelocity = PlayerPawn->GetVelocity();
|
||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
CapsuleHalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
}
|
||
if (const UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement())
|
||
{
|
||
PawnVelocity = CharacterMovement->Velocity;
|
||
}
|
||
}
|
||
|
||
const FVoxelTileKey& LaunchSupportTile =
|
||
HeadlessCollisionGateStressTestLaunchSupportTile;
|
||
const bool bLaunchTileMatches =
|
||
bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured
|
||
&& LaunchSupportTile.Level == 0;
|
||
if (bLaunchTileMatches)
|
||
{
|
||
const bool bPending = PendingTiles.Contains(LaunchSupportTile);
|
||
const bool bLoaded = LoadedTiles.Contains(LaunchSupportTile);
|
||
if (HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds <= 0.0
|
||
&& (bPending || bLoaded))
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = Now;
|
||
}
|
||
if (HeadlessCollisionGateStressTestLaunchSupportReadySeconds <= 0.0
|
||
&& IsTileCollisionReady(LaunchSupportTile))
|
||
{
|
||
if (HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds <= 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = Now;
|
||
}
|
||
HeadlessCollisionGateStressTestLaunchSupportReadySeconds = Now;
|
||
}
|
||
}
|
||
|
||
if (!bHeadlessCollisionGateStressTestLanded)
|
||
{
|
||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (const UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement();
|
||
CharacterMovement != nullptr && CharacterMovement->IsMovingOnGround())
|
||
{
|
||
bHeadlessCollisionGateStressTestLanded = true;
|
||
HeadlessCollisionGateStressTestLandedSeconds = Now;
|
||
HeadlessCollisionGateStressTestDirection = FVector::XAxisVector;
|
||
// Follow the character after landing so the horizontal diagnostic exercises the
|
||
// normal desired-set path while the support gate remains active.
|
||
bHeadlessStreamingTestCenterOverride = false;
|
||
UPrimitiveComponent* MovementBase = CharacterMovement->GetMovementBase();
|
||
const FVector MovementBaseLocation = IsValid(MovementBase)
|
||
? MovementBase->GetComponentLocation() : FVector::ZeroVector;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] landed elapsed_s=%.6f "
|
||
"position=(%.2f,%.2f,%.2f) velocity=(%.2f,%.2f,%.2f) "
|
||
"movement_mode=%d base=%s base_location=(%.2f,%.2f,%.2f) "
|
||
"launch_support_ready=%d"),
|
||
Now - HeadlessCollisionGateStressTestBeginSeconds,
|
||
PlayerPosition.X, PlayerPosition.Y, PlayerPosition.Z,
|
||
PawnVelocity.X, PawnVelocity.Y, PawnVelocity.Z,
|
||
static_cast<int32>(CharacterMovement->MovementMode),
|
||
*GetNameSafe(MovementBase),
|
||
MovementBaseLocation.X, MovementBaseLocation.Y,
|
||
MovementBaseLocation.Z,
|
||
bLaunchTileMatches && IsTileCollisionReady(LaunchSupportTile) ? 1 : 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
float FeetDensity = 0.0f;
|
||
float CenterDensity = 0.0f;
|
||
float SurfaceLocalVoxelZ = 0.0f;
|
||
float ClearanceCm = 0.0f;
|
||
const bool bSurfaceFound = MeasureHeadlessCollisionGateStressTerrain(
|
||
PlayerPosition, CapsuleHalfHeightCm, FeetDensity, CenterDensity,
|
||
SurfaceLocalVoxelZ, ClearanceCm);
|
||
const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition);
|
||
FVoxelTileKey FootTile;
|
||
const bool bHaveFootTile = GetPlayerSupportTile(PlayerPawn, PlayerPosition, FootTile);
|
||
const bool bFootTileCollisionReady = bHaveFootTile && IsTileCollisionReady(FootTile);
|
||
FVoxelTileKey GateSupportTile;
|
||
const TCHAR* GateSupportUnavailableReason = nullptr;
|
||
const bool bGateSupportReady = IsPlayerSupportCollisionReady(
|
||
PlayerPawn, PlayerPosition, &GateSupportTile, &GateSupportUnavailableReason);
|
||
|
||
if (bSurfaceFound)
|
||
{
|
||
++HeadlessCollisionGateStressTestTerrainSampleCount;
|
||
if (ClearanceCm < HeadlessCollisionGateStressTestMinClearanceCm)
|
||
{
|
||
HeadlessCollisionGateStressTestMinClearanceCm = ClearanceCm;
|
||
HeadlessCollisionGateStressTestMinClearancePosition = PlayerPosition;
|
||
HeadlessCollisionGateStressTestMinFeetDensity = FeetDensity;
|
||
HeadlessCollisionGateStressTestMinSurfaceLocalVoxelZ = SurfaceLocalVoxelZ;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
bHeadlessCollisionGateStressTestTerrainMeasurementMissing = true;
|
||
}
|
||
|
||
if (!bHeadlessCollisionGateStressTestStartHeightCrossed
|
||
&& PlayerPosition.Z < HeadlessCollisionGateStressTestStartPosition.Z - 0.5f)
|
||
{
|
||
bHeadlessCollisionGateStressTestStartHeightCrossed = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] start_height_crossed elapsed_s=%.6f "
|
||
"position=(%.2f,%.2f,%.2f) velocity=(%.2f,%.2f,%.2f) gate=%d "
|
||
"foot_tile=(%d,%d,%d) foot_tile_ready=%d gate_support=(%d,%d,%d) "
|
||
"gate_support_ready=%d unavailable=%s feet_density=%.6f "
|
||
"surface_found=%d surface_vox_z=%.4f clearance_cm=%.3f"),
|
||
Now - HeadlessCollisionGateStressTestStartSeconds,
|
||
PlayerPosition.X, PlayerPosition.Y, PlayerPosition.Z,
|
||
PawnVelocity.X, PawnVelocity.Y, PawnVelocity.Z,
|
||
bPawnCollisionGateEngaged ? 1 : 0,
|
||
FootTile.Coord.X, FootTile.Coord.Y, FootTile.Coord.Z,
|
||
bFootTileCollisionReady ? 1 : 0,
|
||
GateSupportTile.Coord.X, GateSupportTile.Coord.Y, GateSupportTile.Coord.Z,
|
||
bGateSupportReady ? 1 : 0,
|
||
GateSupportUnavailableReason != nullptr
|
||
? GateSupportUnavailableReason : TEXT("none"),
|
||
FeetDensity, bSurfaceFound ? 1 : 0, SurfaceLocalVoxelZ, ClearanceCm);
|
||
}
|
||
|
||
constexpr double AllowedSurfacePenetrationCm = 5.0;
|
||
bool bSweptIntoSolid = false;
|
||
FVector FirstSolidSample = PlayerPosition;
|
||
float FirstSolidDensity = FeetDensity;
|
||
const float SegmentLengthCm = FVector::Distance(PreviousPosition, PlayerPosition);
|
||
const int32 SegmentSamples = FMath::Clamp(
|
||
FMath::CeilToInt(SegmentLengthCm / (VOXEL_SIZE * 0.5f)), 1, 256);
|
||
for (int32 SampleIndex = 1; SampleIndex < SegmentSamples; ++SampleIndex)
|
||
{
|
||
const float Alpha = static_cast<float>(SampleIndex)
|
||
/ static_cast<float>(SegmentSamples);
|
||
const FVector SampleWorldPosition = FMath::Lerp(PreviousPosition, PlayerPosition, Alpha);
|
||
float SampleFeetDensity = 0.0f;
|
||
float SampleCenterDensity = 0.0f;
|
||
float SampleSurfaceLocalVoxelZ = 0.0f;
|
||
float SampleClearanceCm = 0.0f;
|
||
const bool bSampleSurfaceFound = MeasureHeadlessCollisionGateStressTerrain(
|
||
SampleWorldPosition, CapsuleHalfHeightCm, SampleFeetDensity,
|
||
SampleCenterDensity, SampleSurfaceLocalVoxelZ, SampleClearanceCm);
|
||
if (bSampleSurfaceFound)
|
||
{
|
||
++HeadlessCollisionGateStressTestTerrainSampleCount;
|
||
if (SampleClearanceCm < HeadlessCollisionGateStressTestMinClearanceCm)
|
||
{
|
||
HeadlessCollisionGateStressTestMinClearanceCm = SampleClearanceCm;
|
||
HeadlessCollisionGateStressTestMinClearancePosition = SampleWorldPosition;
|
||
HeadlessCollisionGateStressTestMinFeetDensity = SampleFeetDensity;
|
||
HeadlessCollisionGateStressTestMinSurfaceLocalVoxelZ =
|
||
SampleSurfaceLocalVoxelZ;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
bHeadlessCollisionGateStressTestTerrainMeasurementMissing = true;
|
||
}
|
||
if ((bSampleSurfaceFound
|
||
&& SampleClearanceCm < -AllowedSurfacePenetrationCm)
|
||
|| (!bSampleSurfaceFound && SampleFeetDensity < -0.25f))
|
||
{
|
||
bSweptIntoSolid = true;
|
||
FirstSolidSample = SampleWorldPosition;
|
||
FirstSolidDensity = SampleFeetDensity;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const bool bBelowSurface = bSurfaceFound
|
||
? ClearanceCm < -AllowedSurfacePenetrationCm
|
||
: FeetDensity < -0.25f;
|
||
FVector ViolationPosition = bSweptIntoSolid ? FirstSolidSample : PlayerPosition;
|
||
float ViolationFeetDensity = bSweptIntoSolid ? FirstSolidDensity : FeetDensity;
|
||
float ViolationCenterDensity = CenterDensity;
|
||
float ViolationSurfaceLocalVoxelZ = SurfaceLocalVoxelZ;
|
||
float ViolationClearanceCm = ClearanceCm;
|
||
const bool bViolationSurfaceFound = bSweptIntoSolid
|
||
? MeasureHeadlessCollisionGateStressTerrain(
|
||
ViolationPosition, CapsuleHalfHeightCm, ViolationFeetDensity,
|
||
ViolationCenterDensity, ViolationSurfaceLocalVoxelZ,
|
||
ViolationClearanceCm)
|
||
: bSurfaceFound;
|
||
const bool bSweptPenetrationExceedsTolerance = bSweptIntoSolid
|
||
&& (!bViolationSurfaceFound
|
||
|| ViolationClearanceCm < -AllowedSurfacePenetrationCm);
|
||
if (bBelowSurface || bSweptPenetrationExceedsTolerance)
|
||
{
|
||
if (bViolationSurfaceFound
|
||
&& ViolationClearanceCm < HeadlessCollisionGateStressTestMinClearanceCm)
|
||
{
|
||
HeadlessCollisionGateStressTestMinClearanceCm = ViolationClearanceCm;
|
||
HeadlessCollisionGateStressTestMinClearancePosition = ViolationPosition;
|
||
HeadlessCollisionGateStressTestMinFeetDensity = ViolationFeetDensity;
|
||
HeadlessCollisionGateStressTestMinSurfaceLocalVoxelZ =
|
||
ViolationSurfaceLocalVoxelZ;
|
||
}
|
||
bHeadlessCollisionGateStressTestTerrainViolation = true;
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("terrain_penetration");
|
||
FVoxelTileKey ViolationFootTile;
|
||
const bool bHaveViolationFootTile = GetPlayerSupportTile(
|
||
PlayerPawn, ViolationPosition, ViolationFootTile);
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] first_terrain_penetration "
|
||
"elapsed_s=%.6f position=(%.2f,%.2f,%.2f) local_vox=(%.3f,%.3f,%.3f) "
|
||
"velocity=(%.2f,%.2f,%.2f) gate=%d foot_tile=(%d,%d,%d) "
|
||
"foot_tile_ready=%d feet_density=%.6f surface_found=%d "
|
||
"surface_vox_z=%.4f clearance_cm=%.3f swept=%d"),
|
||
Now - HeadlessCollisionGateStressTestStartSeconds,
|
||
ViolationPosition.X, ViolationPosition.Y, ViolationPosition.Z,
|
||
WorldToLocalVoxel(ViolationPosition).X,
|
||
WorldToLocalVoxel(ViolationPosition).Y,
|
||
WorldToLocalVoxel(ViolationPosition).Z,
|
||
PawnVelocity.X, PawnVelocity.Y, PawnVelocity.Z,
|
||
bPawnCollisionGateEngaged ? 1 : 0,
|
||
ViolationFootTile.Coord.X, ViolationFootTile.Coord.Y,
|
||
ViolationFootTile.Coord.Z,
|
||
bHaveViolationFootTile && IsTileCollisionReady(ViolationFootTile) ? 1 : 0,
|
||
ViolationFeetDensity, bViolationSurfaceFound ? 1 : 0,
|
||
ViolationSurfaceLocalVoxelZ, ViolationClearanceCm,
|
||
bSweptIntoSolid ? 1 : 0);
|
||
}
|
||
|
||
++HeadlessCollisionGateStressTestTraceFrame;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] trace frame=%d elapsed_s=%.6f "
|
||
"position=(%.2f,%.2f,%.2f) local_vox=(%.3f,%.3f,%.3f) "
|
||
"velocity=(%.2f,%.2f,%.2f) gate=%d foot_tile=(%d,%d,%d) "
|
||
"foot_tile_ready=%d gate_support=(%d,%d,%d) gate_support_ready=%d "
|
||
"feet_density=%.6f center_density=%.6f surface_found=%d "
|
||
"surface_vox_z=%.4f clearance_cm=%.3f landed=%d segment_cm=%.2f"),
|
||
HeadlessCollisionGateStressTestTraceFrame,
|
||
Now - HeadlessCollisionGateStressTestStartSeconds,
|
||
PlayerPosition.X, PlayerPosition.Y, PlayerPosition.Z,
|
||
LocalPosition.X, LocalPosition.Y, LocalPosition.Z,
|
||
PawnVelocity.X, PawnVelocity.Y, PawnVelocity.Z,
|
||
bPawnCollisionGateEngaged ? 1 : 0,
|
||
FootTile.Coord.X, FootTile.Coord.Y, FootTile.Coord.Z,
|
||
bFootTileCollisionReady ? 1 : 0,
|
||
GateSupportTile.Coord.X, GateSupportTile.Coord.Y, GateSupportTile.Coord.Z,
|
||
bGateSupportReady ? 1 : 0,
|
||
FeetDensity, CenterDensity, bSurfaceFound ? 1 : 0,
|
||
SurfaceLocalVoxelZ, ClearanceCm,
|
||
bHeadlessCollisionGateStressTestLanded ? 1 : 0, SegmentLengthCm);
|
||
|
||
if (bHeadlessCollisionGateStressTestLanded
|
||
&& Now - HeadlessCollisionGateStressTestLandedSeconds
|
||
>= static_cast<double>(HeadlessCollisionGateStressTestDurationSeconds))
|
||
{
|
||
return;
|
||
}
|
||
if (Now - HeadlessCollisionGateStressTestBeginSeconds
|
||
>= static_cast<double>(HeadlessCollisionGateStressTestTimeoutSeconds))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("stress_timeout");
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::AdvanceHeadlessSurfaceFallTest(
|
||
FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn)
|
||
{
|
||
if (!bHeadlessSurfaceFallTest || bHeadlessSurfaceFallTestFailed
|
||
|| bHeadlessSurfaceFallTestPassed || !IsValid(PlayerPawn)
|
||
|| HeadlessSurfaceFallTestBeginSeconds <= 0.0 || !Generator || !StrateManager)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (!bHeadlessSurfaceFallTestStartPlaced)
|
||
{
|
||
if (StrateManager->GetNumStrates() != 1
|
||
|| StrateManager->GetGeneratorTypeForChunk(FIntVector(0, 0, 0))
|
||
!= ECaveGeneratorType::SurfaceWorld)
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("layout_is_not_one_surface_strate");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s strates=%d"),
|
||
*HeadlessSurfaceFallTestFailureReason, StrateManager->GetNumStrates());
|
||
return;
|
||
}
|
||
|
||
float HeightfieldTerrainZ = 0.0f;
|
||
float CeilingZ = 0.0f;
|
||
if (!Generator->GetSurfaceHeightAt(
|
||
0.0f, 0.0f, 0, HeightfieldTerrainZ, CeilingZ))
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_height_oracle_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||
*HeadlessSurfaceFallTestFailureReason);
|
||
return;
|
||
}
|
||
|
||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
const float HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
if (VoxelMath::IsFinite(HalfHeightCm) && HalfHeightCm > 0.0f)
|
||
{
|
||
HeadlessSurfaceFallTestPlayerHalfHeightVoxels =
|
||
HalfHeightCm / VOXEL_SIZE;
|
||
}
|
||
}
|
||
}
|
||
|
||
int32 TopChunkZ = 0;
|
||
int32 BottomChunkZ = 0;
|
||
if (!StrateManager->GetStrateChunkZBounds(0, TopChunkZ, BottomChunkZ))
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_strate_bounds_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||
*HeadlessSurfaceFallTestFailureReason);
|
||
return;
|
||
}
|
||
|
||
// The authored surface entry is at the top of the one-strate band. The procedural
|
||
// heightfield can be far below that opening, so use the higher of the two as the spawn
|
||
// floor; this reproduces a character falling through the surface-only world instead of
|
||
// accidentally starting next to the eventual terrain.
|
||
const float SurfaceEntryTopZ = static_cast<float>(TopChunkZ + 1) * CHUNK_SIZE;
|
||
if (!StrateManager->GetLayout().IsValidIndex(0)
|
||
|| StrateManager->GetLayout()[0].Definition == nullptr
|
||
|| Settings == nullptr)
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_entry_geometry_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||
*HeadlessSurfaceFallTestFailureReason);
|
||
return;
|
||
}
|
||
|
||
const UVoxelStrateDefinition* TopDefinition =
|
||
StrateManager->GetLayout()[0].Definition;
|
||
const float TopSeal = TopDefinition->SurfaceParams.BoundarySealThickness;
|
||
const float OriginRadius = Settings->GetEffectiveOriginSpineRadius();
|
||
const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding =
|
||
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
||
SurfaceEntryTopZ,
|
||
static_cast<float>(BottomChunkZ) * CHUNK_SIZE,
|
||
TopSeal,
|
||
OriginRadius);
|
||
if (!OriginLanding.bValid)
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_entry_landing_unavailable");
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||
*HeadlessSurfaceFallTestFailureReason);
|
||
return;
|
||
}
|
||
HeadlessSurfaceFallTestTerrainZ = OriginLanding.FloorZ;
|
||
const float GroundHeightfieldZ = HeightfieldTerrainZ;
|
||
const float SpawnBaseZ = FMath::Max(HeightfieldTerrainZ, SurfaceEntryTopZ);
|
||
const FVector StartVoxel(
|
||
0.0f,
|
||
0.0f,
|
||
SpawnBaseZ
|
||
+ HeadlessSurfaceFallTestPlayerHalfHeightVoxels
|
||
+ HeadlessSurfaceFallTestSpawnHeightVoxels);
|
||
HeadlessSurfaceFallTestStartPosition = LocalVoxelToWorld(StartVoxel);
|
||
const bool bPlaced = PlayerPawn->SetActorLocation(
|
||
HeadlessSurfaceFallTestStartPosition,
|
||
false,
|
||
nullptr,
|
||
ETeleportType::TeleportPhysics);
|
||
const FVector ActualPosition = PlayerPawn->GetActorLocation();
|
||
InOutPlayerPosition = ActualPosition;
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
HeadlessSurfaceFallTestStartPosition = ActualPosition;
|
||
HeadlessSurfaceFallTestLastActualPosition = ActualPosition;
|
||
HeadlessSurfaceFallTestDistanceCm = 0.0;
|
||
bHeadlessSurfaceFallTestStartPlaced = true;
|
||
HeadlessSurfaceFallTestStartSeconds = Now;
|
||
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||
{
|
||
CharacterMovement->StopMovementImmediately();
|
||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||
}
|
||
}
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSurfaceFallTest] start_placed moved=%d ground_vox=%.3f "
|
||
"heightfield_vox=%.3f entry_top_vox=%.3f "
|
||
"spawn_vox=(%.2f,%.2f,%.2f) world=(%.1f,%.1f,%.1f)"),
|
||
bPlaced ? 1 : 0,
|
||
HeadlessSurfaceFallTestTerrainZ,
|
||
GroundHeightfieldZ,
|
||
SurfaceEntryTopZ,
|
||
StartVoxel.X, StartVoxel.Y, StartVoxel.Z,
|
||
ActualPosition.X, ActualPosition.Y, ActualPosition.Z);
|
||
return;
|
||
}
|
||
|
||
// Reproduce the gate's original stale-prediction path while the pawn is still above the
|
||
// landing: the current support is valid, but a deliberately fast one-frame horizontal probe
|
||
// points at a tile outside the current desired set. The old gate remembers that probe after
|
||
// stopping the pawn and never re-derives it. The probe is cleared on the next tick only when
|
||
// the gate has correctly released, after which ordinary gravity continues the fall.
|
||
if (!bHeadlessSurfaceFallTestPredictionProbeInjected
|
||
&& Now - HeadlessSurfaceFallTestStartSeconds >= 0.25)
|
||
{
|
||
FVoxelTileKey ProbeSupportTile;
|
||
const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition);
|
||
const bool bProbeSupportReady =
|
||
IsPlayerSupportCollisionReady(PlayerPawn, InOutPlayerPosition, &ProbeSupportTile);
|
||
int32 ProbeTopChunkZ = 0;
|
||
int32 ProbeBottomChunkZ = 0;
|
||
const bool bProbeSupportInLayout = bProbeSupportReady
|
||
&& StrateManager->GetStrateChunkZBounds(
|
||
ProbeSupportTile.Coord.Z, ProbeTopChunkZ, ProbeBottomChunkZ);
|
||
if (bProbeSupportReady
|
||
&& bProbeSupportInLayout
|
||
&& LocalPosition.Z - HeadlessSurfaceFallTestPlayerHalfHeightVoxels
|
||
> HeadlessSurfaceFallTestTerrainZ + 2.0f)
|
||
{
|
||
bHeadlessSurfaceFallTestPredictionProbeInjected = true;
|
||
HeadlessSurfaceFallTestPredictionProbePosition = InOutPlayerPosition;
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement())
|
||
{
|
||
CharacterMovement->Velocity = FVector(1000000.0f, 0.0f, 0.0f);
|
||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||
CharacterMovement->UpdateComponentVelocity();
|
||
}
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSurfaceFallTest] prediction_probe injected=1 "
|
||
"current_support=(%d,%d,%d) local=(%.2f,%.2f,%.2f) "
|
||
"velocity_cm_s=1000000 pawn_velocity=(%.1f,%.1f,%.1f)"),
|
||
ProbeSupportTile.Coord.X, ProbeSupportTile.Coord.Y, ProbeSupportTile.Coord.Z,
|
||
LocalPosition.X, LocalPosition.Y, LocalPosition.Z,
|
||
PlayerPawn->GetVelocity().X, PlayerPawn->GetVelocity().Y,
|
||
PlayerPawn->GetVelocity().Z);
|
||
// Use a stable test-frame delta so this command-line probe exercises the same
|
||
// prediction branch even when an uncapped -nullrhi tick reports a near-zero delta.
|
||
UpdatePawnCollisionGate(PlayerPawn, InOutPlayerPosition, 1.0f / 60.0f);
|
||
}
|
||
}
|
||
else if (bHeadlessSurfaceFallTestPredictionProbeInjected
|
||
&& !bHeadlessSurfaceFallTestPredictionProbeCleared
|
||
&& !bPawnCollisionGateEngaged)
|
||
{
|
||
const bool bRestored = PlayerPawn->SetActorLocation(
|
||
HeadlessSurfaceFallTestPredictionProbePosition,
|
||
false,
|
||
nullptr,
|
||
ETeleportType::TeleportPhysics);
|
||
InOutPlayerPosition = PlayerPawn->GetActorLocation();
|
||
InOutPlayerHeading = FVector::ZeroVector;
|
||
bHeadlessSurfaceFallTestPredictionProbeCleared = true;
|
||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement())
|
||
{
|
||
CharacterMovement->StopMovementImmediately();
|
||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||
}
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSurfaceFallTest] prediction_probe cleared=1 restored=%d"),
|
||
bRestored ? 1 : 0);
|
||
}
|
||
|
||
HeadlessSurfaceFallTestDistanceCm += FVector::Dist(
|
||
HeadlessSurfaceFallTestLastActualPosition, InOutPlayerPosition);
|
||
HeadlessSurfaceFallTestLastActualPosition = InOutPlayerPosition;
|
||
|
||
const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition);
|
||
const float FeetZ = LocalPosition.Z - HeadlessSurfaceFallTestPlayerHalfHeightVoxels;
|
||
bool bOnGround = false;
|
||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||
{
|
||
if (const UCharacterMovementComponent* CharacterMovement =
|
||
Character->GetCharacterMovement())
|
||
{
|
||
bOnGround = CharacterMovement->IsMovingOnGround();
|
||
}
|
||
}
|
||
if (bOnGround
|
||
&& bHeadlessSurfaceFallTestPredictionProbeCleared
|
||
&& FeetZ <= HeadlessSurfaceFallTestTerrainZ
|
||
+ HeadlessSurfaceFallTestGroundToleranceVoxels
|
||
&& FeetZ >= HeadlessSurfaceFallTestTerrainZ
|
||
- HeadlessSurfaceFallTestGroundToleranceVoxels)
|
||
{
|
||
bHeadlessSurfaceFallTestPassed = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSurfaceFallTest] reached_ground elapsed_s=%.6f "
|
||
"distance_m=%.6f feet_vox=%.3f terrain_vox=%.3f gate=%d probe=1"),
|
||
Now - HeadlessSurfaceFallTestStartSeconds,
|
||
HeadlessSurfaceFallTestDistanceCm / 100.0,
|
||
FeetZ,
|
||
HeadlessSurfaceFallTestTerrainZ,
|
||
bPawnCollisionGateEngaged ? 1 : 0);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::MaybeFinishHeadlessSurfaceFallTest()
|
||
{
|
||
if (!bHeadlessSurfaceFallTest || bHeadlessSurfaceFallTestExitRequested
|
||
|| HeadlessSurfaceFallTestBeginSeconds <= 0.0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (!bHeadlessSurfaceFallTestStartPlaced
|
||
&& Now - HeadlessSurfaceFallTestBeginSeconds
|
||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("pawn_start_not_observed");
|
||
}
|
||
else if (!bHeadlessSurfaceFallTestPredictionProbeInjected
|
||
&& !bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed
|
||
&& Now - HeadlessSurfaceFallTestStartSeconds
|
||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_prediction_probe_not_exercised");
|
||
}
|
||
else if (bHeadlessSurfaceFallTestPredictionProbeInjected
|
||
&& !bHeadlessSurfaceFallTestPredictionProbeCleared
|
||
&& !bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed
|
||
&& Now - HeadlessSurfaceFallTestStartSeconds
|
||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("collision_gate_prediction_deadlock");
|
||
}
|
||
else if (!bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed
|
||
&& Now - HeadlessSurfaceFallTestStartSeconds
|
||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||
{
|
||
bHeadlessSurfaceFallTestFailed = true;
|
||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_ground_timeout");
|
||
}
|
||
if (!bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
bHeadlessSurfaceFallTestExitRequested = true;
|
||
const FVector FinalPosition = HeadlessSurfaceFallTestLastActualPosition;
|
||
const bool bPassed = bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSurfaceFallTest] result=%s reason=%s elapsed_s=%.6f "
|
||
"distance_m=%.6f final_position=(%.1f,%.1f,%.1f) terrain_vox=%.3f"),
|
||
bPassed ? TEXT("PASS") : TEXT("FAIL"),
|
||
bPassed ? TEXT("reached_surface_ground") : *HeadlessSurfaceFallTestFailureReason,
|
||
Now - HeadlessSurfaceFallTestBeginSeconds,
|
||
HeadlessSurfaceFallTestDistanceCm / 100.0,
|
||
FinalPosition.X, FinalPosition.Y, FinalPosition.Z,
|
||
HeadlessSurfaceFallTestTerrainZ);
|
||
FPlatformMisc::RequestExitWithStatus(
|
||
false, bPassed ? 0 : 1, TEXT("VoxelForge surface fall test complete"));
|
||
}
|
||
|
||
void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest()
|
||
{
|
||
if (!bHeadlessCollisionGateStressTest || bHeadlessCollisionGateStressTestExitRequested
|
||
|| HeadlessCollisionGateStressTestBeginSeconds <= 0.0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
const double SinceBegin = Now - HeadlessCollisionGateStressTestBeginSeconds;
|
||
if (!bHeadlessCollisionGateStressTestStartPlaced
|
||
&& !bHeadlessCollisionGateStressTestPassed
|
||
&& !bHeadlessCollisionGateStressTestFailed
|
||
&& SinceBegin >= static_cast<double>(HeadlessCollisionGateStressTestTimeoutSeconds))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("stress_start_not_observed");
|
||
}
|
||
else if (bHeadlessCollisionGateStressTestStarted
|
||
&& bHeadlessCollisionGateStressTestLanded
|
||
&& !bHeadlessCollisionGateStressTestPassed
|
||
&& !bHeadlessCollisionGateStressTestFailed
|
||
&& Now - HeadlessCollisionGateStressTestLandedSeconds
|
||
>= static_cast<double>(HeadlessCollisionGateStressTestDurationSeconds))
|
||
{
|
||
// The gate's job is to protect the density surface. Travel, holds, and support timing
|
||
// stay in the report, but only measured capsule clearance decides PASS/FAIL.
|
||
if (bHeadlessCollisionGateStressTestTerrainViolation)
|
||
{
|
||
// The first penetration was recorded at the sample that observed it.
|
||
}
|
||
else if (HeadlessCollisionGateStressTestTerrainSampleCount == 0
|
||
|| bHeadlessCollisionGateStressTestTerrainMeasurementMissing)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("terrain_surface_unavailable");
|
||
}
|
||
else if (HeadlessCollisionGateStressTestMinClearanceCm < -5.0)
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("terrain_clearance_below_tolerance");
|
||
}
|
||
else
|
||
{
|
||
bHeadlessCollisionGateStressTestPassed = true;
|
||
}
|
||
}
|
||
else if (!bHeadlessCollisionGateStressTestPassed
|
||
&& !bHeadlessCollisionGateStressTestFailed
|
||
&& SinceBegin >= static_cast<double>(HeadlessCollisionGateStressTestTimeoutSeconds))
|
||
{
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason = TEXT("stress_timeout");
|
||
}
|
||
|
||
if (bHeadlessCollisionGateStressTestPassed
|
||
&& !bHeadlessCollisionGateStressTestFailed
|
||
&& bHeadlessCollisionGateStressDeepDrop && bCollisionGateEnabled
|
||
&& (!bHeadlessCollisionGateStressTestHorizontalGateProbeApplied
|
||
|| HeadlessCollisionGateStressTestGateDistanceCm < 1.0))
|
||
{
|
||
bHeadlessCollisionGateStressTestPassed = false;
|
||
bHeadlessCollisionGateStressTestFailed = true;
|
||
HeadlessCollisionGateStressTestFailureReason =
|
||
TEXT("gate_horizontal_movement_blocked");
|
||
}
|
||
|
||
if (!bHeadlessCollisionGateStressTestPassed && !bHeadlessCollisionGateStressTestFailed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (bHeadlessCollisionGateStressTestGateWasEngaged
|
||
&& HeadlessCollisionGateStressTestGateStartSeconds > 0.0)
|
||
{
|
||
const double GateDuration = FMath::Max(
|
||
0.0, Now - HeadlessCollisionGateStressTestGateStartSeconds);
|
||
HeadlessCollisionGateStressTestTotalGateDurationSeconds += GateDuration;
|
||
HeadlessCollisionGateStressTestMaxGateDurationSeconds = FMath::Max(
|
||
HeadlessCollisionGateStressTestMaxGateDurationSeconds, GateDuration);
|
||
}
|
||
if (bHeadlessCollisionGateStressTestFullStop
|
||
&& HeadlessCollisionGateStressTestFullStopStartSeconds > 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestFullStopDurationSeconds += FMath::Max(
|
||
0.0, Now - HeadlessCollisionGateStressTestFullStopStartSeconds);
|
||
}
|
||
|
||
bHeadlessCollisionGateStressTestExitRequested = true;
|
||
const bool bPassed = bHeadlessCollisionGateStressTestPassed
|
||
&& !bHeadlessCollisionGateStressTestFailed;
|
||
const double ExpectedDistanceCm =
|
||
static_cast<double>(HeadlessCollisionGateStressTestSpeedCmPerSecond)
|
||
* static_cast<double>(HeadlessCollisionGateStressTestDurationSeconds);
|
||
const double SupportSubmitLatencySeconds = bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateSubmittedSeconds > 0.0
|
||
? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds
|
||
- StartupCollisionGateFirstCheckSeconds)
|
||
: -1.0;
|
||
const double SupportReadyLatencySeconds = bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateReadySeconds > 0.0
|
||
? FMath::Max(0.0, StartupCollisionGateReadySeconds
|
||
- StartupCollisionGateFirstCheckSeconds)
|
||
: -1.0;
|
||
const double StressSupportSubmitLatencySeconds =
|
||
bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured
|
||
&& HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds > 0.0
|
||
? FMath::Max(0.0,
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds
|
||
- HeadlessCollisionGateStressTestLaunchSupportCheckSeconds)
|
||
: -1.0;
|
||
const double StressSupportReadyLatencySeconds =
|
||
bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured
|
||
&& HeadlessCollisionGateStressTestLaunchSupportReadySeconds > 0.0
|
||
? FMath::Max(0.0,
|
||
HeadlessCollisionGateStressTestLaunchSupportReadySeconds
|
||
- HeadlessCollisionGateStressTestLaunchSupportCheckSeconds)
|
||
: -1.0;
|
||
|
||
float FinalFeetDensity = 0.0f;
|
||
float FinalCenterDensity = 0.0f;
|
||
float FinalSurfaceLocalVoxelZ = 0.0f;
|
||
float FinalClearanceCm = 0.0f;
|
||
float FinalHalfHeightCm = 0.0f;
|
||
FVector IgnoredCurrentPosition;
|
||
APawn* StressPawn = nullptr;
|
||
TryGetPlayerPosition(IgnoredCurrentPosition, &StressPawn);
|
||
if (const ACharacter* Character = Cast<ACharacter>(StressPawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
FinalHalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
}
|
||
}
|
||
const bool bFinalSurfaceFound = MeasureHeadlessCollisionGateStressTerrain(
|
||
HeadlessCollisionGateStressTestLastActualPosition, FinalHalfHeightCm,
|
||
FinalFeetDensity, FinalCenterDensity, FinalSurfaceLocalVoxelZ, FinalClearanceCm);
|
||
const FVector FinalLocalPosition = WorldToLocalVoxel(
|
||
HeadlessCollisionGateStressTestLastActualPosition);
|
||
const float ColumnCenterZ = bFinalSurfaceFound
|
||
? FinalSurfaceLocalVoxelZ : FinalLocalPosition.Z - FinalHalfHeightCm / VOXEL_SIZE;
|
||
FString DensityColumnSamples;
|
||
for (int32 Offset = 12; Offset >= -12; --Offset)
|
||
{
|
||
const float SampleZ = ColumnCenterZ + static_cast<float>(Offset);
|
||
const float Density = Generator != nullptr
|
||
? Generator->GetDensityAt(FinalLocalPosition.X, FinalLocalPosition.Y, SampleZ)
|
||
: 0.0f;
|
||
if (!DensityColumnSamples.IsEmpty())
|
||
{
|
||
DensityColumnSamples += TEXT(",");
|
||
}
|
||
DensityColumnSamples += FString::Printf(TEXT("%.2f:%.5f"), SampleZ, Density);
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] density_column world_xy=(%.2f,%.2f) "
|
||
"local_xy_vox=(%.4f,%.4f) surface_found=%d surface_vox_z=%.4f "
|
||
"feet_density=%.6f clearance_cm=%.3f samples_zvox_density=%s"),
|
||
HeadlessCollisionGateStressTestLastActualPosition.X,
|
||
HeadlessCollisionGateStressTestLastActualPosition.Y,
|
||
FinalLocalPosition.X, FinalLocalPosition.Y,
|
||
bFinalSurfaceFound ? 1 : 0, FinalSurfaceLocalVoxelZ,
|
||
FinalFeetDensity, FinalClearanceCm, *DensityColumnSamples);
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] result=%s reason=%s elapsed_s=%.6f "
|
||
"distance_m=%.6f expected_distance_m=%.6f speed_cm_s=%.1f spawn_height_vox=%.2f deep_drop=%d "
|
||
"gate_holds=%d gate_total_s=%.6f max_gate_s=%.6f gate_progress_m=%.6f "
|
||
"horizontal_gate_probe=%d "
|
||
"full_stops=%d full_stop_total_s=%.6f collision_gate=%d "
|
||
"support_tile=(%d,%d,%d) support_first_check_to_submit_s=%.6f "
|
||
"support_first_check_to_collision_ready_s=%.6f "
|
||
"test_support_tile=(%d,%d,%d) "
|
||
"test_support_first_check_to_submit_s=%.6f "
|
||
"test_support_first_check_to_collision_ready_s=%.6f landed=%d "
|
||
"terrain_samples=%d terrain_measurement_missing=%d "
|
||
"start_height_crossed=%d min_clearance_cm=%.3f "
|
||
"min_clearance_position=(%.2f,%.2f,%.2f) min_feet_density=%.6f "
|
||
"min_surface_vox_z=%.4f final_position=(%.1f,%.1f,%.1f)"),
|
||
bPassed ? TEXT("PASS") : TEXT("FAIL"),
|
||
bPassed ? TEXT("capsule_never_below_density_surface")
|
||
: *HeadlessCollisionGateStressTestFailureReason,
|
||
Now - HeadlessCollisionGateStressTestBeginSeconds,
|
||
HeadlessCollisionGateStressTestDistanceCm / 100.0,
|
||
ExpectedDistanceCm / 100.0,
|
||
HeadlessCollisionGateStressTestSpeedCmPerSecond,
|
||
HeadlessCollisionGateStressTestSpawnHeightVoxels,
|
||
bHeadlessCollisionGateStressDeepDrop ? 1 : 0,
|
||
HeadlessCollisionGateStressTestGateHoldCount,
|
||
HeadlessCollisionGateStressTestTotalGateDurationSeconds,
|
||
HeadlessCollisionGateStressTestMaxGateDurationSeconds,
|
||
HeadlessCollisionGateStressTestGateDistanceCm / 100.0,
|
||
bHeadlessCollisionGateStressTestHorizontalGateProbeApplied ? 1 : 0,
|
||
HeadlessCollisionGateStressTestFullStopCount,
|
||
HeadlessCollisionGateStressTestFullStopDurationSeconds,
|
||
bCollisionGateEnabled ? 1 : 0,
|
||
StartupCollisionGateSupportTile.Coord.X,
|
||
StartupCollisionGateSupportTile.Coord.Y,
|
||
StartupCollisionGateSupportTile.Coord.Z,
|
||
SupportSubmitLatencySeconds,
|
||
SupportReadyLatencySeconds,
|
||
HeadlessCollisionGateStressTestLaunchSupportTile.Coord.X,
|
||
HeadlessCollisionGateStressTestLaunchSupportTile.Coord.Y,
|
||
HeadlessCollisionGateStressTestLaunchSupportTile.Coord.Z,
|
||
StressSupportSubmitLatencySeconds,
|
||
StressSupportReadyLatencySeconds,
|
||
bHeadlessCollisionGateStressTestLanded ? 1 : 0,
|
||
HeadlessCollisionGateStressTestTerrainSampleCount,
|
||
bHeadlessCollisionGateStressTestTerrainMeasurementMissing ? 1 : 0,
|
||
bHeadlessCollisionGateStressTestStartHeightCrossed ? 1 : 0,
|
||
HeadlessCollisionGateStressTestMinClearanceCm,
|
||
HeadlessCollisionGateStressTestMinClearancePosition.X,
|
||
HeadlessCollisionGateStressTestMinClearancePosition.Y,
|
||
HeadlessCollisionGateStressTestMinClearancePosition.Z,
|
||
HeadlessCollisionGateStressTestMinFeetDensity,
|
||
HeadlessCollisionGateStressTestMinSurfaceLocalVoxelZ,
|
||
HeadlessCollisionGateStressTestLastActualPosition.X,
|
||
HeadlessCollisionGateStressTestLastActualPosition.Y,
|
||
HeadlessCollisionGateStressTestLastActualPosition.Z);
|
||
FPlatformMisc::RequestExitWithStatus(
|
||
false, bPassed ? 0 : 1, TEXT("VoxelForge collision-gate stress test complete"));
|
||
}
|
||
|
||
void AVoxelWorld::MaybeFinishHeadlessStreamingTest()
|
||
{
|
||
if (bHeadlessStrateCrossingTest)
|
||
{
|
||
MaybeFinishHeadlessStrateCrossingTest();
|
||
return;
|
||
}
|
||
|
||
if (bHeadlessSurfaceFallTest)
|
||
{
|
||
MaybeFinishHeadlessSurfaceFallTest();
|
||
return;
|
||
}
|
||
|
||
if (bHeadlessCollisionGateStressTest)
|
||
{
|
||
MaybeFinishHeadlessCollisionGateStressTest();
|
||
return;
|
||
}
|
||
|
||
if (bHeadlessStreamingTestExitRequested || HeadlessStreamingTestExitSeconds <= 0.0f
|
||
|| HeadlessStreamingTestBeginSeconds <= 0.0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (FPlatformTime::Seconds() - HeadlessStreamingTestBeginSeconds
|
||
< static_cast<double>(HeadlessStreamingTestExitSeconds))
|
||
{
|
||
return;
|
||
}
|
||
|
||
bHeadlessStreamingTestExitRequested = true;
|
||
LogVisualStalenessAudit();
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingTest] complete; moves=%d distance_m=%.6f last_position=(%.1f,%.1f,%.1f); requesting graceful exit after clean session"),
|
||
HeadlessStreamingTestMoveAttempts,
|
||
HeadlessStreamingTestDistanceCm / 100.0,
|
||
HeadlessStreamingTestLastActualPosition.X,
|
||
HeadlessStreamingTestLastActualPosition.Y,
|
||
HeadlessStreamingTestLastActualPosition.Z);
|
||
FPlatformMisc::RequestExit(false, TEXT("VoxelForge headless streaming test complete"));
|
||
}
|
||
|
||
static bool VF_TileOverlapsVoxelBounds(const FVoxelTileKey& Tile,
|
||
const FVector& BoundsMin, const FVector& BoundsMax)
|
||
{
|
||
const FIntVector Origin = Tile.OriginVoxels();
|
||
const FVector TileMin(
|
||
static_cast<float>(Origin.X), static_cast<float>(Origin.Y), static_cast<float>(Origin.Z));
|
||
const FVector TileMax = TileMin + FVector(static_cast<float>(Tile.ExtentVoxels()));
|
||
return TileMin.X < BoundsMax.X && TileMax.X > BoundsMin.X
|
||
&& TileMin.Y < BoundsMax.Y && TileMax.Y > BoundsMin.Y
|
||
&& TileMin.Z < BoundsMax.Z && TileMax.Z > BoundsMin.Z;
|
||
}
|
||
|
||
static bool VF_TileFootprintsOverlap(const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||
{
|
||
const FIntVector AOrigin = A.OriginVoxels();
|
||
const FIntVector BOrigin = B.OriginVoxels();
|
||
const FVector AMin(
|
||
static_cast<float>(AOrigin.X), static_cast<float>(AOrigin.Y), static_cast<float>(AOrigin.Z));
|
||
const FVector BMin(
|
||
static_cast<float>(BOrigin.X), static_cast<float>(BOrigin.Y), static_cast<float>(BOrigin.Z));
|
||
const float AExtent = static_cast<float>(A.ExtentVoxels());
|
||
const float BExtent = static_cast<float>(B.ExtentVoxels());
|
||
return AMin.X < BMin.X + BExtent && BMin.X < AMin.X + AExtent
|
||
&& AMin.Y < BMin.Y + BExtent && BMin.Y < AMin.Y + AExtent
|
||
&& AMin.Z < BMin.Z + BExtent && BMin.Z < AMin.Z + AExtent;
|
||
}
|
||
|
||
template <typename CandidateArray>
|
||
static void VF_AppendTouchingAncestors(
|
||
const FVoxelTileKey& Tile, int32 AncestorLevel, CandidateArray& OutCandidates)
|
||
{
|
||
if (AncestorLevel <= Tile.Level) { return; }
|
||
const int32 Divisor = 1 << (AncestorLevel - Tile.Level);
|
||
const auto FloorDiv = [Divisor](int32 Value)
|
||
{
|
||
return Value >= 0 ? Value / Divisor : -(((-Value) + Divisor - 1) / Divisor);
|
||
};
|
||
const auto AxisCandidates = [&FloorDiv, Divisor](int32 Value)
|
||
{
|
||
TArray<int32, TInlineAllocator<2>> Candidates;
|
||
int32 First = FloorDiv(Value);
|
||
if ((Value % Divisor) == 0) { --First; }
|
||
const int32 Last = FloorDiv(Value + 1);
|
||
for (int32 Coord = First; Coord <= Last; ++Coord) { Candidates.Add(Coord); }
|
||
return Candidates;
|
||
};
|
||
const TArray<int32, TInlineAllocator<2>> X = AxisCandidates(Tile.Coord.X);
|
||
const TArray<int32, TInlineAllocator<2>> Y = AxisCandidates(Tile.Coord.Y);
|
||
const TArray<int32, TInlineAllocator<2>> Z = AxisCandidates(Tile.Coord.Z);
|
||
for (const int32 XCoord : X)
|
||
{
|
||
for (const int32 YCoord : Y)
|
||
{
|
||
for (const int32 ZCoord : Z)
|
||
{
|
||
OutCandidates.AddUnique(FVoxelTileKey(FIntVector(XCoord, YCoord, ZCoord), AncestorLevel));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::UpdateVisualStalenessAudit()
|
||
{
|
||
FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this);
|
||
if (!bVisualStalenessAudit && (!GapAudit || !GapAudit->bEnabled))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (GapAudit && GapAudit->bEnabled)
|
||
{
|
||
FVector GapViewLocation = FVector::ZeroVector;
|
||
FRotator GapViewRotation = FRotator::ZeroRotator;
|
||
float GapHorizontalTanHalfFov = 0.0f;
|
||
float GapVerticalTanHalfFov = 0.0f;
|
||
bool bGapAuditHasView = false;
|
||
if (UWorld* World = GetWorld())
|
||
{
|
||
if (APlayerController* PC = World->GetFirstPlayerController())
|
||
{
|
||
PC->GetPlayerViewPoint(GapViewLocation, GapViewRotation);
|
||
float HorizontalFovDegrees = 90.0f;
|
||
if (PC->PlayerCameraManager)
|
||
{
|
||
HorizontalFovDegrees = PC->PlayerCameraManager->GetFOVAngle();
|
||
}
|
||
FVector2D ViewportSize(16.0f, 9.0f);
|
||
if (GEngine && GEngine->GameViewport)
|
||
{
|
||
GEngine->GameViewport->GetViewportSize(ViewportSize);
|
||
}
|
||
const float AspectRatio = ViewportSize.Y > 0.0f
|
||
? ViewportSize.X / ViewportSize.Y : (16.0f / 9.0f);
|
||
GapHorizontalTanHalfFov = FMath::Tan(
|
||
FMath::DegreesToRadians(HorizontalFovDegrees * 0.5f));
|
||
GapVerticalTanHalfFov = GapHorizontalTanHalfFov / FMath::Max(AspectRatio, 0.1f);
|
||
bGapAuditHasView = true;
|
||
}
|
||
}
|
||
++GapAudit->Frame;
|
||
if (!GapAudit->bCollectionStarted
|
||
&& bHeadlessStreamingTestMovement
|
||
&& HeadlessStreamingTestBeginSeconds > 0.0
|
||
&& Now - HeadlessStreamingTestBeginSeconds
|
||
>= static_cast<double>(HeadlessStreamingTestStartSeconds))
|
||
{
|
||
VF_StartVisualGapAuditWindow(*GapAudit, Now);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualGapAudit] collection_begin=1 reason=fast_walk start_frame=%llu"),
|
||
static_cast<unsigned long long>(GapAudit->Frame));
|
||
}
|
||
if (URealtimeMeshComponent* Injected = GapAudit->InjectedComponent.Get())
|
||
{
|
||
if (GapAudit->Frame >= GapAudit->InjectionRestoreFrame)
|
||
{
|
||
Injected->SetVisibility(true);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualGapAudit] injected_early_hide_restored=1 frame=%llu tile=(%d,%d,%d,%d)"),
|
||
static_cast<unsigned long long>(GapAudit->Frame),
|
||
GapAudit->InjectedTile.Level, GapAudit->InjectedTile.Coord.X,
|
||
GapAudit->InjectedTile.Coord.Y, GapAudit->InjectedTile.Coord.Z);
|
||
GapAudit->InjectedComponent.Reset();
|
||
}
|
||
}
|
||
|
||
const auto CandidateTilesAtLevel = [this](
|
||
const FVector& WorldPosition, int32 Level, auto& OutCandidates)
|
||
{
|
||
const FVector LocalVoxel = WorldToLocalVoxel(WorldPosition);
|
||
const float TileExtentVoxels = static_cast<float>(CHUNK_SIZE * (1 << Level));
|
||
TArray<int32, TInlineAllocator<2>> XCandidates;
|
||
TArray<int32, TInlineAllocator<2>> YCandidates;
|
||
TArray<int32, TInlineAllocator<2>> ZCandidates;
|
||
const auto AddAxisCandidates = [TileExtentVoxels](float Value, auto& Candidates)
|
||
{
|
||
const float ExactTileCoord = Value / TileExtentVoxels;
|
||
const int32 BaseTileCoord = FMath::FloorToInt(ExactTileCoord);
|
||
Candidates.Add(BaseTileCoord);
|
||
const float NearestBoundary = FMath::RoundToFloat(ExactTileCoord);
|
||
if (FMath::Abs(ExactTileCoord - NearestBoundary) <= 1.0e-4f)
|
||
{
|
||
Candidates.AddUnique(static_cast<int32>(NearestBoundary) - 1);
|
||
}
|
||
};
|
||
AddAxisCandidates(LocalVoxel.X, XCandidates);
|
||
AddAxisCandidates(LocalVoxel.Y, YCandidates);
|
||
AddAxisCandidates(LocalVoxel.Z, ZCandidates);
|
||
for (const int32 X : XCandidates)
|
||
{
|
||
for (const int32 Y : YCandidates)
|
||
{
|
||
for (const int32 Z : ZCandidates)
|
||
{
|
||
OutCandidates.AddUnique(FVoxelTileKey(FIntVector(X, Y, Z), Level));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
const auto IsPointInCameraView = [bGapAuditHasView, &GapViewLocation, &GapViewRotation,
|
||
GapHorizontalTanHalfFov, GapVerticalTanHalfFov](
|
||
const FVector& WorldPosition)
|
||
{
|
||
if (!bGapAuditHasView)
|
||
{
|
||
return true;
|
||
}
|
||
const FVector ViewLocal = GapViewRotation.UnrotateVector(WorldPosition - GapViewLocation);
|
||
return ViewLocal.X > 0.0f
|
||
&& FMath::Abs(ViewLocal.Y) <= ViewLocal.X * GapHorizontalTanHalfFov
|
||
&& FMath::Abs(ViewLocal.Z) <= ViewLocal.X * GapVerticalTanHalfFov;
|
||
};
|
||
const auto IsEditedPoint = [this, GapAudit](const FVector& WorldPosition)
|
||
{
|
||
const FVector LocalVoxel = WorldToLocalVoxel(WorldPosition);
|
||
for (const TPair<FVector, FVector>& Bounds : GapAudit->EditedBounds)
|
||
{
|
||
if (LocalVoxel.X >= Bounds.Key.X && LocalVoxel.X <= Bounds.Value.X
|
||
&& LocalVoxel.Y >= Bounds.Key.Y && LocalVoxel.Y <= Bounds.Value.Y
|
||
&& LocalVoxel.Z >= Bounds.Key.Z && LocalVoxel.Z <= Bounds.Value.Z)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
const auto IsDesiredPoint = [this, &CandidateTilesAtLevel](const FVector& WorldPosition)
|
||
{
|
||
for (int32 Level = 0; Level <= DesiredTransitionMaxLevel; ++Level)
|
||
{
|
||
TArray<FVoxelTileKey, TInlineAllocator<8>> Candidates;
|
||
CandidateTilesAtLevel(WorldPosition, Level, Candidates);
|
||
for (const FVoxelTileKey& Candidate : Candidates)
|
||
{
|
||
if (IsDesired(Candidate)) { return true; }
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
const auto IsCoveredByVisibleTile = [this, GapAudit, &CandidateTilesAtLevel](
|
||
const FVector& WorldPosition, const FVoxelTileKey* ExcludedTile)
|
||
{
|
||
for (int32 Level = 0; Level <= DesiredTransitionMaxLevel; ++Level)
|
||
{
|
||
TArray<FVoxelTileKey, TInlineAllocator<8>> Candidates;
|
||
CandidateTilesAtLevel(WorldPosition, Level, Candidates);
|
||
for (const FVoxelTileKey& Candidate : Candidates)
|
||
{
|
||
if (ExcludedTile && Candidate == *ExcludedTile) { continue; }
|
||
const URealtimeMeshComponent* const* Component = TileComponents.Find(Candidate);
|
||
const TArray<FVector>* Witnesses = GapAudit->WitnessesByTile.Find(Candidate);
|
||
if (Component && *Component && (*Component)->IsVisible()
|
||
&& Witnesses && !Witnesses->IsEmpty())
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
if (GapAudit->bCollecting && GapAudit->BeginSeconds <= 0.0)
|
||
{
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
const TArray<FVector>* Witnesses = GapAudit->WitnessesByTile.Find(Pair.Key);
|
||
if (Pair.Value && Pair.Value->IsVisible() && Witnesses && !Witnesses->IsEmpty())
|
||
{
|
||
GapAudit->BeginSeconds = Now;
|
||
GapAudit->LastSampleSeconds = Now;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (GapAudit->bInjectEarlyHide && !GapAudit->bInjectionDone
|
||
&& GapAudit->BeginSeconds > 0.0 && Now - GapAudit->BeginSeconds >= 2.0)
|
||
{
|
||
TArray<FVoxelTileKey> CandidateTiles;
|
||
TileComponents.GetKeys(CandidateTiles);
|
||
CandidateTiles.Sort([](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||
{
|
||
if (A.Level != B.Level) { return A.Level < B.Level; }
|
||
if (A.Coord.Z != B.Coord.Z) { return A.Coord.Z < B.Coord.Z; }
|
||
if (A.Coord.Y != B.Coord.Y) { return A.Coord.Y < B.Coord.Y; }
|
||
return A.Coord.X < B.Coord.X;
|
||
});
|
||
for (const FVoxelTileKey& CandidateTile : CandidateTiles)
|
||
{
|
||
URealtimeMeshComponent* Component = TileComponents.FindRef(CandidateTile);
|
||
const TArray<FVector>* Witnesses = GapAudit->WitnessesByTile.Find(CandidateTile);
|
||
if (!Component || !Component->IsVisible() || !Witnesses || Witnesses->IsEmpty())
|
||
{
|
||
continue;
|
||
}
|
||
for (int32 WitnessIndex = 0; WitnessIndex < Witnesses->Num(); ++WitnessIndex)
|
||
{
|
||
const FVector& Point = (*Witnesses)[WitnessIndex];
|
||
if (!IsPointInCameraView(Point) || !IsDesiredPoint(Point) || IsEditedPoint(Point)
|
||
|| IsCoveredByVisibleTile(Point, &CandidateTile))
|
||
{
|
||
continue;
|
||
}
|
||
Component->SetVisibility(false);
|
||
GapAudit->bInjectionDone = true;
|
||
GapAudit->InjectedTile = CandidateTile;
|
||
GapAudit->InjectedWitnessKey.Tile = CandidateTile;
|
||
GapAudit->InjectedWitnessKey.Index = WitnessIndex;
|
||
GapAudit->InjectedComponent = Component;
|
||
GapAudit->InjectionRestoreFrame = GapAudit->Frame + 3;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualGapAudit] injected_early_hide=1 frame=%llu tile=(%d,%d,%d,%d) witness=(%.2f,%.2f,%.2f) restore_frame=%llu"),
|
||
static_cast<unsigned long long>(GapAudit->Frame), CandidateTile.Level,
|
||
CandidateTile.Coord.X, CandidateTile.Coord.Y, CandidateTile.Coord.Z,
|
||
Point.X, Point.Y, Point.Z,
|
||
static_cast<unsigned long long>(GapAudit->InjectionRestoreFrame));
|
||
break;
|
||
}
|
||
if (GapAudit->bInjectionDone) { break; }
|
||
}
|
||
}
|
||
|
||
TArray<FVisualGapWitness> CurrentVisibleWitnesses;
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
if (!Pair.Value || !Pair.Value->IsVisible())
|
||
{
|
||
continue;
|
||
}
|
||
const TArray<FVector>* Witnesses = GapAudit->WitnessesByTile.Find(Pair.Key);
|
||
if (!Witnesses)
|
||
{
|
||
continue;
|
||
}
|
||
for (int32 Index = 0; Index < Witnesses->Num(); ++Index)
|
||
{
|
||
if (!IsPointInCameraView((*Witnesses)[Index]))
|
||
{
|
||
continue;
|
||
}
|
||
FVisualGapWitness& Sample = CurrentVisibleWitnesses.AddDefaulted_GetRef();
|
||
Sample.Key.Tile = Pair.Key;
|
||
Sample.Key.Index = Index;
|
||
Sample.WorldPosition = (*Witnesses)[Index];
|
||
}
|
||
}
|
||
|
||
if (GapAudit->bCollecting)
|
||
{
|
||
++GapAudit->CollectionFrames;
|
||
if (bGapAuditHasView) { ++GapAudit->CameraViewFrames; }
|
||
TSet<FVisualGapWitnessKey> MissingThisFrame;
|
||
for (const FVisualGapWitness& Previous : GapAudit->PreviousVisibleWitnesses)
|
||
{
|
||
const bool bExpected = IsPointInCameraView(Previous.WorldPosition)
|
||
&& IsDesiredPoint(Previous.WorldPosition)
|
||
&& !IsEditedPoint(Previous.WorldPosition);
|
||
if (!bExpected || IsCoveredByVisibleTile(Previous.WorldPosition, nullptr))
|
||
{
|
||
GapAudit->MissingSinceSeconds.Remove(Previous.Key);
|
||
continue;
|
||
}
|
||
FVisualGapMissingSample* MissingSince = GapAudit->MissingSinceSeconds.Find(Previous.Key);
|
||
if (!MissingSince)
|
||
{
|
||
FVisualGapMissingSample& Added = GapAudit->MissingSinceSeconds.Add(Previous.Key);
|
||
Added.BeginSeconds = Now;
|
||
Added.BeginFrame = GapAudit->CollectionFrames;
|
||
Added.WorldPosition = Previous.WorldPosition;
|
||
MissingSince = &Added;
|
||
++GapAudit->GapEvents;
|
||
if (GapAudit->LoggedGapEvents < 24)
|
||
{
|
||
++GapAudit->LoggedGapEvents;
|
||
FVoxelTileKey ExpectedTile;
|
||
bool bFoundExpectedTile = false;
|
||
for (int32 Level = 0; Level <= DesiredTransitionMaxLevel; ++Level)
|
||
{
|
||
TArray<FVoxelTileKey, TInlineAllocator<8>> Candidates;
|
||
CandidateTilesAtLevel(Previous.WorldPosition, Level, Candidates);
|
||
const FVoxelTileKey* Candidate = Candidates.FindByPredicate(
|
||
[this](const FVoxelTileKey& Tile) { return IsDesired(Tile); });
|
||
if (Candidate)
|
||
{
|
||
ExpectedTile = *Candidate;
|
||
bFoundExpectedTile = true;
|
||
break;
|
||
}
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualGapAudit] point_gap_begin=1 source=(%d,%d,%d,%d) "
|
||
"expected_found=%d expected=(%d,%d,%d,%d) point=(%.2f,%.2f,%.2f) frame=%llu"),
|
||
Previous.Key.Tile.Level, Previous.Key.Tile.Coord.X,
|
||
Previous.Key.Tile.Coord.Y, Previous.Key.Tile.Coord.Z,
|
||
bFoundExpectedTile ? 1 : 0, ExpectedTile.Level,
|
||
ExpectedTile.Coord.X, ExpectedTile.Coord.Y, ExpectedTile.Coord.Z,
|
||
Previous.WorldPosition.X, Previous.WorldPosition.Y,
|
||
Previous.WorldPosition.Z,
|
||
static_cast<unsigned long long>(GapAudit->Frame));
|
||
}
|
||
if (GapAudit->bInjectionDone
|
||
&& GapAudit->Frame < GapAudit->InjectionRestoreFrame
|
||
&& Previous.Key == GapAudit->InjectedWitnessKey)
|
||
{
|
||
GapAudit->bInjectionCaught = true;
|
||
}
|
||
}
|
||
MissingThisFrame.Add(Previous.Key);
|
||
}
|
||
|
||
// A hidden source component stops contributing fresh witnesses, but its previously
|
||
// visible points remain expected for as long as a desired tile covers that location.
|
||
for (auto It = GapAudit->MissingSinceSeconds.CreateIterator(); It; ++It)
|
||
{
|
||
if (MissingThisFrame.Contains(It.Key()))
|
||
{
|
||
continue;
|
||
}
|
||
const FVector& Point = It.Value().WorldPosition;
|
||
if (!IsDesiredPoint(Point) || IsEditedPoint(Point)
|
||
|| IsCoveredByVisibleTile(Point, nullptr))
|
||
{
|
||
It.RemoveCurrent();
|
||
continue;
|
||
}
|
||
MissingThisFrame.Add(It.Key());
|
||
}
|
||
|
||
const int32 MissingPointCount = MissingThisFrame.Num();
|
||
GapAudit->GapPointFrames += MissingPointCount;
|
||
for (const TPair<FVisualGapWitnessKey, FVisualGapMissingSample>& Missing
|
||
: GapAudit->MissingSinceSeconds)
|
||
{
|
||
const uint64 GapFrames = GapAudit->CollectionFrames - Missing.Value.BeginFrame + 1;
|
||
GapAudit->LongestGapFrames = FMath::Max(GapAudit->LongestGapFrames, GapFrames);
|
||
GapAudit->LongestGapSeconds = FMath::Max(
|
||
GapAudit->LongestGapSeconds,
|
||
FMath::Max(0.0, Now - Missing.Value.BeginSeconds));
|
||
}
|
||
if (MissingPointCount > 0)
|
||
{
|
||
++GapAudit->GapFrames;
|
||
GapAudit->MaxGapPoints = FMath::Max(GapAudit->MaxGapPoints, MissingPointCount);
|
||
}
|
||
GapAudit->MissingSamples = GapAudit->MissingSinceSeconds.Num();
|
||
GapAudit->Samples += GapAudit->PreviousVisibleWitnesses.Num();
|
||
GapAudit->LastSampleSeconds = Now;
|
||
}
|
||
GapAudit->PreviousVisibleWitnesses = MoveTemp(CurrentVisibleWitnesses);
|
||
|
||
if (GapAudit->bCollecting)
|
||
{
|
||
if (const FDigMotionTestState* DigTest = GVoxelForgeDigMotionTests.Find(this))
|
||
{
|
||
if (DigTest->bResultLogged) { GapAudit->bCollecting = false; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (VisualStalenessAuditMode == 1 && VisualAuditEditEpoch != 0
|
||
&& VisualAuditEditBeginSeconds > 0.0)
|
||
{
|
||
int32 StaleVisibleTiles = 0;
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
if (!Pair.Value || !Pair.Value->IsVisible()
|
||
|| !VF_TileOverlapsVoxelBounds(
|
||
Pair.Key, VisualAuditEditMinVoxel, VisualAuditEditMaxVoxel))
|
||
{
|
||
continue;
|
||
}
|
||
if (AppliedModificationEpoch.FindRef(Pair.Key) != VisualAuditEditEpoch)
|
||
{
|
||
++StaleVisibleTiles;
|
||
}
|
||
}
|
||
|
||
if (StaleVisibleTiles > 0)
|
||
{
|
||
bVisualAuditEditSawStale = true;
|
||
VisualAuditEditMaxStaleTiles = FMath::Max(
|
||
VisualAuditEditMaxStaleTiles, StaleVisibleTiles);
|
||
}
|
||
else if (!bVisualAuditEditResolved)
|
||
{
|
||
bVisualAuditEditResolved = true;
|
||
VisualAuditEditStaleDurationSeconds = FMath::Max(
|
||
0.0, Now - VisualAuditEditBeginSeconds);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualStaleness] edit_resolved=1 duration_s=%.6f stale_tiles_max=%d"),
|
||
VisualAuditEditStaleDurationSeconds, VisualAuditEditMaxStaleTiles);
|
||
}
|
||
}
|
||
|
||
if (VisualStalenessAuditMode == 2 && bHeadlessStreamingTestMovement
|
||
&& HeadlessStreamingTestBeginSeconds > 0.0
|
||
&& Now - HeadlessStreamingTestBeginSeconds
|
||
>= static_cast<double>(HeadlessStreamingTestStartSeconds))
|
||
{
|
||
TArray<FVoxelTileKey> VisibleTiles;
|
||
VisibleTiles.Reserve(TileComponents.Num());
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
if (Pair.Value && Pair.Value->IsVisible())
|
||
{
|
||
VisibleTiles.Add(Pair.Key);
|
||
}
|
||
}
|
||
|
||
int32 OverlapPairs = 0;
|
||
for (int32 OldIndex = 0; OldIndex < VisibleTiles.Num(); ++OldIndex)
|
||
{
|
||
const FVoxelTileKey& OldTile = VisibleTiles[OldIndex];
|
||
if (IsDesired(OldTile))
|
||
{
|
||
continue;
|
||
}
|
||
for (int32 NewIndex = 0; NewIndex < VisibleTiles.Num(); ++NewIndex)
|
||
{
|
||
const FVoxelTileKey& NewTile = VisibleTiles[NewIndex];
|
||
if (OldTile.Level == NewTile.Level || !IsDesired(NewTile)
|
||
|| !VF_TileFootprintsOverlap(OldTile, NewTile))
|
||
{
|
||
continue;
|
||
}
|
||
++OverlapPairs;
|
||
}
|
||
}
|
||
|
||
if (OverlapPairs > 0)
|
||
{
|
||
if (VisualAuditLODOverlapStartSeconds <= 0.0)
|
||
{
|
||
VisualAuditLODOverlapStartSeconds = Now;
|
||
++VisualAuditLODOverlapEvents;
|
||
}
|
||
VisualAuditLODOverlapMaxPairs = FMath::Max(
|
||
VisualAuditLODOverlapMaxPairs, OverlapPairs);
|
||
}
|
||
else if (VisualAuditLODOverlapStartSeconds > 0.0)
|
||
{
|
||
VisualAuditLODOverlapMaxSeconds = FMath::Max(
|
||
VisualAuditLODOverlapMaxSeconds,
|
||
FMath::Max(0.0, Now - VisualAuditLODOverlapStartSeconds));
|
||
VisualAuditLODOverlapStartSeconds = 0.0;
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::LogVisualStalenessAudit()
|
||
{
|
||
FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this);
|
||
const bool bNeedStalenessReport = bVisualStalenessAudit && !bVisualStalenessAuditExitLogged;
|
||
const bool bNeedGapReport = GapAudit && GapAudit->bEnabled && !GapAudit->bReportLogged;
|
||
if (!bNeedStalenessReport && !bNeedGapReport)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double Now = FPlatformTime::Seconds();
|
||
if (bNeedGapReport)
|
||
{
|
||
if (URealtimeMeshComponent* Injected = GapAudit->InjectedComponent.Get())
|
||
{
|
||
Injected->SetVisibility(true);
|
||
GapAudit->InjectedComponent.Reset();
|
||
}
|
||
const bool bCameraViewComplete = GapAudit->CollectionFrames > 0
|
||
&& GapAudit->CameraViewFrames == static_cast<int32>(GapAudit->CollectionFrames);
|
||
const bool bPassed = bCameraViewComplete && (GapAudit->bInjectionCaught
|
||
|| (!GapAudit->bInjectEarlyHide && GapAudit->Samples > 0
|
||
&& GapAudit->GapPointFrames == 0));
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualGapAudit] result=%s window_s=%.3f window_frames=%llu camera_view_frames=%d surface_point_frames=%d "
|
||
"gap_count=%d gap_frames=%d gap_point_frames=%d max_missing_points=%d "
|
||
"longest_gap_frames=%llu longest_gap_ms=%.3f injected_early_hide=%d injection_caught=%d"),
|
||
bPassed ? TEXT("PASS") : TEXT("FAIL"),
|
||
GapAudit->BeginSeconds > 0.0
|
||
? FMath::Max(0.0, GapAudit->LastSampleSeconds - GapAudit->BeginSeconds) : 0.0,
|
||
static_cast<unsigned long long>(GapAudit->CollectionFrames),
|
||
GapAudit->CameraViewFrames, GapAudit->Samples,
|
||
GapAudit->GapEvents, GapAudit->GapFrames,
|
||
GapAudit->GapPointFrames, GapAudit->MaxGapPoints,
|
||
static_cast<unsigned long long>(GapAudit->LongestGapFrames),
|
||
GapAudit->LongestGapSeconds * 1000.0,
|
||
GapAudit->bInjectionDone ? 1 : 0, GapAudit->bInjectionCaught ? 1 : 0);
|
||
GapAudit->bReportLogged = true;
|
||
}
|
||
if (!bNeedStalenessReport)
|
||
{
|
||
return;
|
||
}
|
||
bVisualStalenessAuditExitLogged = true;
|
||
|
||
if (VisualAuditLODOverlapStartSeconds > 0.0)
|
||
{
|
||
VisualAuditLODOverlapMaxSeconds = FMath::Max(
|
||
VisualAuditLODOverlapMaxSeconds,
|
||
FMath::Max(0.0, Now - VisualAuditLODOverlapStartSeconds));
|
||
VisualAuditLODOverlapStartSeconds = 0.0;
|
||
}
|
||
if (VisualAuditEditEpoch != 0 && VisualAuditEditBeginSeconds > 0.0
|
||
&& !bVisualAuditEditResolved)
|
||
{
|
||
VisualAuditEditStaleDurationSeconds = FMath::Max(
|
||
0.0, Now - VisualAuditEditBeginSeconds);
|
||
}
|
||
|
||
const bool bPassed = VisualStalenessAuditMode == 1
|
||
? (VisualAuditEditEpoch != 0 && bVisualAuditEditResolved)
|
||
: (VisualStalenessAuditMode == 2 && VisualAuditLODOverlapMaxSeconds <= 0.050);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualStaleness] result=%s mode=%d "
|
||
"edit_applied=%d edit_resolved=%d edit_stale_duration_s=%.6f "
|
||
"edit_stale_tiles_max=%d lod_overlap_max_s=%.6f "
|
||
"lod_overlap_events=%d lod_overlap_pairs_max=%d"),
|
||
bPassed ? TEXT("PASS") : TEXT("FAIL"), VisualStalenessAuditMode,
|
||
VisualAuditEditEpoch != 0 ? 1 : 0,
|
||
bVisualAuditEditResolved ? 1 : 0,
|
||
VisualAuditEditStaleDurationSeconds,
|
||
VisualAuditEditMaxStaleTiles,
|
||
VisualAuditLODOverlapMaxSeconds,
|
||
VisualAuditLODOverlapEvents,
|
||
VisualAuditLODOverlapMaxPairs);
|
||
}
|
||
|
||
void AVoxelWorld::LogReadyTransitionVisibilityMeasure()
|
||
{
|
||
if (!bMeasureReadyTransitionVisibility)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!ReadyTransitionVisibilitySamples.IsEmpty())
|
||
{
|
||
TArray<FReadyTransitionVisibilitySample> Sorted = ReadyTransitionVisibilitySamples;
|
||
Sorted.Sort([](const FReadyTransitionVisibilitySample& A,
|
||
const FReadyTransitionVisibilitySample& B)
|
||
{
|
||
return A.Cycles < B.Cycles;
|
||
});
|
||
const int32 P50Index = Sorted.Num() / 2;
|
||
const int32 P95Index = FMath::Clamp(
|
||
FMath::CeilToInt(static_cast<float>(Sorted.Num()) * 0.95f) - 1,
|
||
0, Sorted.Num() - 1);
|
||
const FReadyTransitionVisibilitySample& P95 = Sorted[P95Index];
|
||
const FReadyTransitionVisibilitySample& Max = Sorted.Last();
|
||
|
||
int32 MinComponents = MAX_int32, MaxComponents = 0;
|
||
int32 MinDesired = MAX_int32, MaxDesired = 0;
|
||
int64 TotalComponents = 0, TotalDesired = 0;
|
||
for (const FReadyTransitionVisibilitySample& Sample : Sorted)
|
||
{
|
||
MinComponents = FMath::Min(MinComponents, Sample.TileComponentCount);
|
||
MaxComponents = FMath::Max(MaxComponents, Sample.TileComponentCount);
|
||
MinDesired = FMath::Min(MinDesired, Sample.DesiredTileCount);
|
||
MaxDesired = FMath::Max(MaxDesired, Sample.DesiredTileCount);
|
||
TotalComponents += Sample.TileComponentCount;
|
||
TotalDesired += Sample.DesiredTileCount;
|
||
}
|
||
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeReadyVisibilityMeasure] samples=%d p50_ms=%.6f p95_ms=%.6f max_ms=%.6f "
|
||
"p95_components=%d p95_desired=%d components_range=%d..%d desired_range=%d..%d "
|
||
"components_mean=%.1f desired_mean=%.1f"),
|
||
Sorted.Num(),
|
||
FPlatformTime::ToSeconds64(Sorted[P50Index].Cycles) * 1000.0,
|
||
FPlatformTime::ToSeconds64(P95.Cycles) * 1000.0,
|
||
FPlatformTime::ToSeconds64(Max.Cycles) * 1000.0,
|
||
P95.TileComponentCount, P95.DesiredTileCount,
|
||
MinComponents, MaxComponents, MinDesired, MaxDesired,
|
||
static_cast<double>(TotalComponents) / Sorted.Num(),
|
||
static_cast<double>(TotalDesired) / Sorted.Num());
|
||
|
||
constexpr int32 ComponentBucketWidth = 128;
|
||
TMap<int32, TArray<double>> MillisecondsByComponentBucket;
|
||
for (const FReadyTransitionVisibilitySample& Sample : Sorted)
|
||
{
|
||
const int32 BucketMin = (Sample.TileComponentCount / ComponentBucketWidth)
|
||
* ComponentBucketWidth;
|
||
MillisecondsByComponentBucket.FindOrAdd(BucketMin).Add(
|
||
FPlatformTime::ToSeconds64(Sample.Cycles) * 1000.0);
|
||
}
|
||
TArray<int32> BucketMins;
|
||
MillisecondsByComponentBucket.GetKeys(BucketMins);
|
||
BucketMins.Sort();
|
||
for (const int32 BucketMin : BucketMins)
|
||
{
|
||
TArray<double>& Times = MillisecondsByComponentBucket.FindChecked(BucketMin);
|
||
Times.Sort();
|
||
double TotalMs = 0.0;
|
||
for (const double TimeMs : Times)
|
||
{
|
||
TotalMs += TimeMs;
|
||
}
|
||
const int32 P50BucketIndex = Times.Num() / 2;
|
||
const int32 P95BucketIndex = FMath::Clamp(
|
||
FMath::CeilToInt(static_cast<float>(Times.Num()) * 0.95f) - 1,
|
||
0, Times.Num() - 1);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeReadyVisibilityBucket] components=%d..%d samples=%d "
|
||
"p50_ms=%.6f p95_ms=%.6f mean_ms=%.6f"),
|
||
BucketMin, BucketMin + ComponentBucketWidth - 1, Times.Num(),
|
||
Times[P50BucketIndex], Times[P95BucketIndex], TotalMs / Times.Num());
|
||
}
|
||
}
|
||
|
||
if (!FarFieldSubmitSamples.IsEmpty())
|
||
{
|
||
TArray<FFarFieldSubmitSample> Sorted = FarFieldSubmitSamples;
|
||
Sorted.Sort([](const FFarFieldSubmitSample& A, const FFarFieldSubmitSample& B)
|
||
{
|
||
return A.Cycles < B.Cycles;
|
||
});
|
||
const int32 P50Index = Sorted.Num() / 2;
|
||
const int32 P95Index = FMath::Clamp(
|
||
FMath::CeilToInt(static_cast<float>(Sorted.Num()) * 0.95f) - 1,
|
||
0, Sorted.Num() - 1);
|
||
const FFarFieldSubmitSample& P95 = Sorted[P95Index];
|
||
const FFarFieldSubmitSample& Max = Sorted.Last();
|
||
|
||
int32 MinDesired = MAX_int32, MaxDesired = 0;
|
||
int32 MinLevel = MAX_int32, MaxLevel = 0;
|
||
for (const FFarFieldSubmitSample& Sample : Sorted)
|
||
{
|
||
MinDesired = FMath::Min(MinDesired, Sample.DesiredTileCount);
|
||
MaxDesired = FMath::Max(MaxDesired, Sample.DesiredTileCount);
|
||
MinLevel = FMath::Min(MinLevel, Sample.MaxDesiredLevel);
|
||
MaxLevel = FMath::Max(MaxLevel, Sample.MaxDesiredLevel);
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeFarFieldSubmitMeasure] samples=%d p50_ms=%.6f p95_ms=%.6f max_ms=%.6f "
|
||
"p95_desired=%d p95_max_level=%d desired_range=%d..%d levels=%d..%d"),
|
||
Sorted.Num(),
|
||
FPlatformTime::ToSeconds64(Sorted[P50Index].Cycles) * 1000.0,
|
||
FPlatformTime::ToSeconds64(P95.Cycles) * 1000.0,
|
||
FPlatformTime::ToSeconds64(Max.Cycles) * 1000.0,
|
||
P95.DesiredTileCount, P95.MaxDesiredLevel,
|
||
MinDesired, MaxDesired, MinLevel, MaxLevel);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::Tick(float DeltaTime)
|
||
{
|
||
Super::Tick(DeltaTime);
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_Tick); // game-thread streaming orchestration breakdown
|
||
if (DensityVolume && (!Settings || !Settings->bEnableDensityVolume))
|
||
{
|
||
DensityVolume->NotifyShutdown();
|
||
DensityVolume = nullptr;
|
||
SetDensityVolumeMaterialParamsDisabled();
|
||
}
|
||
FVector PlayerLastPos = FVector::ZeroVector;
|
||
APawn* PlayerPawn = nullptr;
|
||
const bool bHasPlayer = TryGetPlayerPosition(PlayerLastPos, &PlayerPawn);
|
||
|
||
if (bHasPlayer) {
|
||
// Capture velocity before the collision gate can deliberately zero it while the support
|
||
// tile is cooking. The scheduler uses this as the movement heading for its next-tile
|
||
// critical prefix; a stationary pawn falls back to actor forward in BuildDesiredTiles.
|
||
FVector PlayerHeading = PlayerPawn ? PlayerPawn->GetVelocity() : FVector::ZeroVector;
|
||
AdvanceHeadlessStreamingTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||
AdvanceHeadlessStrateCrossingTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||
AdvanceHeadlessSurfaceFallTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||
AdvanceHeadlessCollisionGateStressTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||
AdvanceHeadlessDigMotionTest(PlayerLastPos, PlayerPawn);
|
||
PeakObservedPawnSpeedCmPerSecond = FMath::Max(
|
||
PeakObservedPawnSpeedCmPerSecond, PlayerHeading.Size2D());
|
||
UpdatePawnCollisionGate(PlayerPawn, PlayerLastPos, DeltaTime);
|
||
RecordHeadlessDigMotionFrame(PlayerPawn, PlayerLastPos, DeltaTime);
|
||
ObserveHeadlessStrateCrossingTest(PlayerLastPos, PlayerPawn);
|
||
ObserveHeadlessCollisionGateStressTest(PlayerLastPos, PlayerPawn);
|
||
const FVector StreamingCenterPosition = bHeadlessStreamingTestCenterOverride
|
||
? LocalVoxelToWorld(HeadlessStreamingTestCenterVoxel)
|
||
: PlayerLastPos;
|
||
UpdateChunksAroundPosition(StreamingCenterPosition, PlayerPawn, PlayerHeading);
|
||
if (AtmosphereManager)
|
||
{
|
||
AtmosphereManager->UpdateForPlayer(PlayerLastPos);
|
||
}
|
||
if (ContentManager)
|
||
{
|
||
// Distance-based decoration streaming (no LOD pop). Cheap no-op unless the player crosses
|
||
// a decoration cell boundary or changes strate; otherwise just drains the spawn budget.
|
||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); }
|
||
// Rare hash-lattice landmarks (the "mini-suns") — cheap at any radius (scales with count, not area).
|
||
// Landmarks now cover F7 set-pieces too (AnchorMode HashLattice/PassageMouth + exclusion).
|
||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateLandmarks); ContentManager->UpdateLandmarks(PlayerLastPos); }
|
||
// One strate-global ocean plane following the player (water at every LOD, to the horizon).
|
||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); }
|
||
}
|
||
if (DensityVolume)
|
||
{
|
||
// Density clipmap for mini-sun shadows: recentre + queue/launch/drain worker fills.
|
||
// Cheap unless the player crossed a level-0 cell boundary or a carve dirtied cells.
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDensityVolume);
|
||
DensityVolume->Update(PlayerLastPos);
|
||
UpdateTerrainMaterialParams();
|
||
}
|
||
// Bounded-directional mini-sun lighting: stream the nearest orbs into the Light Function MPC.
|
||
// Independent of the density volume (self-guards on OrbLightMPC); this is the replacement path.
|
||
UpdateOrbLightMPC();
|
||
}
|
||
else
|
||
{
|
||
ReleasePawnCollisionGate();
|
||
if (APawn* PreviousPawn = PawnTickPrerequisite.Get())
|
||
{
|
||
PreviousPawn->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
if (UPawnMovementComponent* PreviousMovement = PawnMovementTickPrerequisite.Get())
|
||
{
|
||
PreviousMovement->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
PawnTickPrerequisite.Reset();
|
||
PawnMovementTickPrerequisite.Reset();
|
||
}
|
||
// The diagnostic harness uses an explicit command-line token rather than a console command:
|
||
// Unreal processes startup ExecCmds before this runtime module's console registrations are
|
||
// guaranteed to exist. Reading the command line here is order-independent, and the actual edit
|
||
// still waits for a loaded tile and goes through the ordinary modification path below.
|
||
if (!GVoxelForgeTestModificationCommandLineConsumed)
|
||
{
|
||
GVoxelForgeTestModificationCommandLineConsumed = true;
|
||
const FString CommandLine(FCommandLine::Get());
|
||
if (CommandLine.Contains(TEXT("-voxel.TestModification=1"), ESearchCase::IgnoreCase))
|
||
{
|
||
GVoxelForgePendingModificationRadius = 3.0f;
|
||
GVoxelForgePendingModificationStrength = 10.0f;
|
||
FString ModificationTarget;
|
||
if (FParse::Value(
|
||
*CommandLine,
|
||
TEXT("voxel.TestModificationTarget="),
|
||
ModificationTarget)
|
||
&& ModificationTarget.Equals(TEXT("loaded-proof"), ESearchCase::IgnoreCase))
|
||
{
|
||
GVoxelForgeTestModificationLoadedProofTarget = true;
|
||
}
|
||
else if (ModificationTarget.Equals(TEXT("loaded-geometry"), ESearchCase::IgnoreCase))
|
||
{
|
||
GVoxelForgeTestModificationLoadedGeometryTarget = true;
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestModification] queued radius=%.2f strength=%.2f target=%s"),
|
||
GVoxelForgePendingModificationRadius,
|
||
GVoxelForgePendingModificationStrength,
|
||
GVoxelForgeTestModificationLoadedProofTarget ? TEXT("loaded-proof")
|
||
: (GVoxelForgeTestModificationLoadedGeometryTarget
|
||
? TEXT("loaded-geometry") : TEXT("player")));
|
||
GVoxelForgePendingTestModification = 1;
|
||
}
|
||
}
|
||
ProcessPendingChunks();
|
||
ReconcileReadyTransitionVisibility();
|
||
// Near-field evidence is independent of the full horizon settle point. Record one completion
|
||
// for each desired-set epoch after this frame's result drain, including moving crossings.
|
||
RecordLOD0RingReadyIfComplete();
|
||
|
||
// The startup trace stops at the same point the streaming policy declares itself settled:
|
||
// every desired key is loaded, no generation task remains, and the game-thread result drain
|
||
// has run for the frame. This is intentionally after ProcessPendingChunks so request-to-apply
|
||
// includes the final submission; the separate collision_ready events carry cook latency.
|
||
if (VoxelForgeStartupTrace::IsActive()
|
||
&& bAllChunksLoaded
|
||
&& PendingTiles.Num() == 0
|
||
&& bStartupTraceDesiredRecorded
|
||
&& PendingCollisionCooks.Num() == 0
|
||
&& (!bStartupTraceThroughCrossing
|
||
|| !bHeadlessStrateCrossingTest
|
||
|| bHeadlessStrateCrossingTestPassed
|
||
|| bHeadlessStrateCrossingTestFailed)
|
||
// The desired set being applied is not the player-ready point: the feet tile must have
|
||
// received its completed LOD0 RMC body first. If no pawn exists, there is no gate to wait
|
||
// for and the headless trace keeps its old completion behavior.
|
||
&& (!bHasPlayer
|
||
|| bHeadlessStreamingTestCenterOverride
|
||
|| IsPlayerSupportCollisionReady(PlayerPawn, PlayerLastPos)))
|
||
{
|
||
TMap<int32, int32> SteadyLevelCounts;
|
||
TMap<int32, FIntVector> SteadyLevelMins;
|
||
TMap<int32, FIntVector> SteadyLevelMaxs;
|
||
TMap<int32, int32> LoadedLevelCounts;
|
||
TMap<int32, uint64> LoadedLevelVertices;
|
||
TMap<int32, uint64> LoadedLevelTriangles;
|
||
TMap<int32, uint64> LoadedLevelGeometryBytes;
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
++SteadyLevelCounts.FindOrAdd(Key.Level);
|
||
FIntVector& Min = SteadyLevelMins.FindOrAdd(Key.Level, FIntVector(MAX_int32, MAX_int32, MAX_int32));
|
||
FIntVector& Max = SteadyLevelMaxs.FindOrAdd(Key.Level, FIntVector(MIN_int32, MIN_int32, MIN_int32));
|
||
Min.X = FMath::Min(Min.X, Key.Coord.X);
|
||
Min.Y = FMath::Min(Min.Y, Key.Coord.Y);
|
||
Min.Z = FMath::Min(Min.Z, Key.Coord.Z);
|
||
Max.X = FMath::Max(Max.X, Key.Coord.X);
|
||
Max.Y = FMath::Max(Max.Y, Key.Coord.Y);
|
||
Max.Z = FMath::Max(Max.Z, Key.Coord.Z);
|
||
}
|
||
for (const FVoxelTileKey& Key : LoadedTiles)
|
||
{
|
||
++LoadedLevelCounts.FindOrAdd(Key.Level);
|
||
if (const FLoadedTileGeometryStats* Geometry = LoadedTileGeometry.Find(Key))
|
||
{
|
||
LoadedLevelVertices.FindOrAdd(Key.Level) += Geometry->Vertices;
|
||
LoadedLevelTriangles.FindOrAdd(Key.Level) += Geometry->Triangles;
|
||
LoadedLevelGeometryBytes.FindOrAdd(Key.Level) += Geometry->GeometryBytes;
|
||
}
|
||
}
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("steady_state"), FString::Printf(
|
||
TEXT("\"desired_tiles\":%d,\"loaded_tiles\":%d,\"collision_ready_tiles\":%d,"
|
||
"\"collision_not_required_tiles\":%d,\"pending_collision_cooks\":%d,"
|
||
"\"pawn_collision_gate_engaged\":%d,"
|
||
"\"obsolete_tile_aborts\":%d,\"desired_epoch\":%u,"
|
||
"\"pending_tiles\":%d,\"pending_unload\":%d,\"generation_epoch\":%u"),
|
||
DesiredSorted.Num(), LoadedTiles.Num(), CollisionReadyTiles.Num(),
|
||
CollisionNotRequiredTiles.Num(), PendingCollisionCooks.Num(),
|
||
bPawnCollisionGateEngaged ? 1 : 0,
|
||
ObsoleteTileAbortCount, DesiredEpoch,
|
||
PendingTiles.Num(), PendingUnload.Num(), GenerationEpoch));
|
||
TArray<int32> SteadyLevels;
|
||
SteadyLevelCounts.GetKeys(SteadyLevels);
|
||
SteadyLevels.Sort();
|
||
for (const int32 Level : SteadyLevels)
|
||
{
|
||
const FIntVector Min = SteadyLevelMins.FindChecked(Level);
|
||
const FIntVector Max = SteadyLevelMaxs.FindChecked(Level);
|
||
const int32 ExtentVoxels = CHUNK_SIZE << Level;
|
||
const double TileExtentMetres = static_cast<double>(ExtentVoxels) * VOXEL_SIZE / 100.0;
|
||
const double SpanX = static_cast<double>(Max.X - Min.X + 1) * TileExtentMetres;
|
||
const double SpanY = static_cast<double>(Max.Y - Min.Y + 1) * TileExtentMetres;
|
||
const double SpanZ = static_cast<double>(Max.Z - Min.Z + 1) * TileExtentMetres;
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("steady_desired_level"), FString::Printf(
|
||
TEXT("\"level\":%d,\"tiles\":%d,\"min_coord\":[%d,%d,%d],\"max_coord\":[%d,%d,%d],"
|
||
"\"tile_extent_m\":%s,\"coverage_extent_m\":[%s,%s,%s]"),
|
||
Level, SteadyLevelCounts.FindChecked(Level),
|
||
Min.X, Min.Y, Min.Z, Max.X, Max.Y, Max.Z,
|
||
*FString::SanitizeFloat(TileExtentMetres),
|
||
*FString::SanitizeFloat(SpanX), *FString::SanitizeFloat(SpanY), *FString::SanitizeFloat(SpanZ)));
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("steady_loaded_level"), FString::Printf(
|
||
TEXT("\"level\":%d,\"tiles\":%d,\"vertices\":%llu,\"triangles\":%llu,\"geometry_bytes\":%llu"),
|
||
Level, LoadedLevelCounts.FindRef(Level),
|
||
static_cast<unsigned long long>(LoadedLevelVertices.FindRef(Level)),
|
||
static_cast<unsigned long long>(LoadedLevelTriangles.FindRef(Level)),
|
||
static_cast<unsigned long long>(LoadedLevelGeometryBytes.FindRef(Level))));
|
||
}
|
||
const bool bExitOnSteady = bHeadlessStreamingTestExitOnSteady;
|
||
VoxelForgeStartupTrace::Finish(TEXT("desired_set_satisfied_and_queue_drained"));
|
||
if (bExitOnSteady && !bHeadlessStreamingTestExitRequested)
|
||
{
|
||
bHeadlessStreamingTestExitRequested = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingTest] steady_exit_requested=1"));
|
||
FPlatformMisc::RequestExit(false, TEXT("VoxelForge steady streaming measurement complete"));
|
||
}
|
||
}
|
||
|
||
// Complete the one-shot diagnostic request only after the player's level-0 centre tile is
|
||
// loaded and idle. The edit fixture selects an already-visible level-0 geometry tile, so it
|
||
// measures the normal synchronous centre remesh without depending on a coarse overlay being
|
||
// visible. No pawn is distinct from a pawn at (0,0,0); retain the actor-location fallback for
|
||
// headless/editor use without making the world-origin pawn skip streaming.
|
||
FVector ModificationPosition = bHasPlayer ? PlayerLastPos : GetActorLocation();
|
||
FVoxelTileKey ModificationCenterTile(
|
||
WorldToChunkCoord(WorldToLocalCm(ModificationPosition)), 0);
|
||
bool bModificationTargetSelected = false;
|
||
if (GVoxelForgePendingTestModification != 0
|
||
&& (GVoxelForgeTestModificationLoadedProofTarget
|
||
|| GVoxelForgeTestModificationLoadedGeometryTarget)
|
||
&& Generator)
|
||
{
|
||
// The diagnostic target is deliberately selected from the applied set. That makes the
|
||
// edit exercise the normal loaded-tile synchronous remesh, while the proof query here is
|
||
// the same public predicate used by GenerateTileResult before the diff exists.
|
||
for (const FVoxelTileKey& CandidateTile : LoadedTiles)
|
||
{
|
||
if (CandidateTile.Level != 0
|
||
|| PendingTiles.Contains(CandidateTile))
|
||
{
|
||
continue;
|
||
}
|
||
if (GVoxelForgeTestModificationLoadedGeometryTarget
|
||
&& !LoadedTileGeometry.Contains(CandidateTile))
|
||
{
|
||
continue;
|
||
}
|
||
if (GVoxelForgeTestModificationLoadedGeometryTarget)
|
||
{
|
||
URealtimeMeshComponent* CandidateComponent = TileComponents.FindRef(CandidateTile);
|
||
if (!CandidateComponent || !CandidateComponent->IsVisible())
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
const FIntVector CandidateOrigin(
|
||
CandidateTile.Coord.X * CHUNK_SIZE,
|
||
CandidateTile.Coord.Y * CHUNK_SIZE,
|
||
CandidateTile.Coord.Z * CHUNK_SIZE);
|
||
if (GVoxelForgeTestModificationLoadedProofTarget
|
||
&& !Generator->TryProveSealedSolidTile(CandidateOrigin, 1, CHUNK_SIZE))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const FIntVector CandidateCenter(
|
||
CandidateOrigin.X + CHUNK_SIZE / 2,
|
||
CandidateOrigin.Y + CHUNK_SIZE / 2,
|
||
CandidateOrigin.Z + CHUNK_SIZE / 2);
|
||
ModificationPosition = LocalVoxelToWorld(FVector(CandidateCenter));
|
||
ModificationCenterTile = CandidateTile;
|
||
bModificationTargetSelected = true;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestModification] player_mine_target tile=(%d,%d,%d) "
|
||
"local_center=(%d,%d,%d) before_proof=%d"),
|
||
CandidateTile.Coord.X, CandidateTile.Coord.Y, CandidateTile.Coord.Z,
|
||
CandidateCenter.X, CandidateCenter.Y, CandidateCenter.Z,
|
||
GVoxelForgeTestModificationLoadedProofTarget ? 1 : 0);
|
||
break;
|
||
}
|
||
}
|
||
if (GVoxelForgePendingTestModification != 0
|
||
&& DiffLayer
|
||
&& ((!GVoxelForgeTestModificationLoadedProofTarget
|
||
&& !GVoxelForgeTestModificationLoadedGeometryTarget)
|
||
|| bModificationTargetSelected)
|
||
&& LoadedTiles.Contains(ModificationCenterTile)
|
||
&& !PendingTiles.Contains(ModificationCenterTile))
|
||
{
|
||
GVoxelForgePendingTestModification = 0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestModification] applying player_mine=1 tile=(%d,%d,%d) "
|
||
"position=(%.2f,%.2f,%.2f) radius=%.2f strength=%.2f"),
|
||
ModificationCenterTile.Coord.X, ModificationCenterTile.Coord.Y, ModificationCenterTile.Coord.Z,
|
||
ModificationPosition.X, ModificationPosition.Y, ModificationPosition.Z,
|
||
GVoxelForgePendingModificationRadius, GVoxelForgePendingModificationStrength);
|
||
CarveAtPosition(
|
||
ModificationPosition,
|
||
GVoxelForgePendingModificationRadius,
|
||
GVoxelForgePendingModificationStrength);
|
||
const FIntVector ModifiedOrigin(
|
||
ModificationCenterTile.Coord.X * CHUNK_SIZE,
|
||
ModificationCenterTile.Coord.Y * CHUNK_SIZE,
|
||
ModificationCenterTile.Coord.Z * CHUNK_SIZE);
|
||
const bool bProofSurvivedCarve = Generator
|
||
&& Generator->TryProveSealedSolidTile(ModifiedOrigin, 1, CHUNK_SIZE);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestModification] player_mine_complete tile=(%d,%d,%d) "
|
||
"after_proof=%d"),
|
||
ModificationCenterTile.Coord.X, ModificationCenterTile.Coord.Y,
|
||
ModificationCenterTile.Coord.Z, bProofSurvivedCarve ? 1 : 0);
|
||
}
|
||
ProcessUnloadQueue();
|
||
UpdateVisualStalenessAudit();
|
||
MaybeFinishHeadlessStreamingTest();
|
||
|
||
if (bTestCeilingViewEnabled)
|
||
{
|
||
UWorld* World = GetWorld();
|
||
APlayerController* PC = World ? World->GetFirstPlayerController() : nullptr;
|
||
if (PC && !TestCeilingCamera.IsValid())
|
||
{
|
||
FActorSpawnParameters SpawnParams;
|
||
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||
ACameraActor* Camera = World->SpawnActor<ACameraActor>(
|
||
ACameraActor::StaticClass(), TestCeilingCameraWorldPosition,
|
||
TestCeilingCameraRotation, SpawnParams);
|
||
if (IsValid(Camera))
|
||
{
|
||
TestCeilingCamera = Camera;
|
||
PC->SetViewTarget(Camera);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestCeiling] camera_installed=1 applied_visible_tiles=%llu"),
|
||
static_cast<unsigned long long>(AppliedVisibleTileCount));
|
||
}
|
||
}
|
||
|
||
if (TestCeilingCamera.IsValid() && !bTestCeilingCaptureRequested
|
||
&& AppliedVisibleTileCount >= 16
|
||
&& FPlatformTime::Seconds() - TestCeilingViewBeginSeconds
|
||
>= static_cast<double>(TestCeilingCaptureDelaySeconds))
|
||
{
|
||
bTestCeilingCaptureRequested = true;
|
||
// UEngine::Exec is not routed through the game viewport in the command-line game
|
||
// target, so HighResShot returns false there even with a live render viewport. Call
|
||
// the viewport API directly; it schedules the same one-frame high-res capture.
|
||
const bool bCaptureAccepted = GEngine
|
||
&& GEngine->GameViewport
|
||
&& GEngine->GameViewport->GetGameViewport()
|
||
&& GEngine->GameViewport->GetGameViewport()->TakeHighResScreenShot();
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTestCeiling] capture_requested=%d elapsed_s=%.3f "
|
||
"visible_tiles=%llu triangles=%llu"),
|
||
bCaptureAccepted ? 1 : 0,
|
||
FPlatformTime::Seconds() - TestCeilingViewBeginSeconds,
|
||
static_cast<unsigned long long>(AppliedVisibleTileCount),
|
||
static_cast<unsigned long long>(AppliedTriangleCount));
|
||
}
|
||
}
|
||
|
||
#if ENABLE_DRAW_DEBUG
|
||
// Density-volume overlay (step 1a): cyan boxes for solid level-0 cells near the player.
|
||
if (DensityVolume) { DensityVolume->DebugDraw(); }
|
||
|
||
// Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints).
|
||
// Points are in voxel coords → world units (×VOXEL_SIZE) → actor space.
|
||
if (bDebugDrawPassages && StrateManager)
|
||
{
|
||
const FTransform Xf = GetActorTransform();
|
||
auto ToWorld = [&](const FVector& VoxelPt) { return Xf.TransformPosition(VoxelPt * VOXEL_SIZE); };
|
||
auto DrawSeg = [&](const FVector& A, const FVector& B)
|
||
{
|
||
DrawDebugLine(GetWorld(), ToWorld(A), ToWorld(B), FColor::Cyan, false, -1.0f, 0, 30.0f);
|
||
};
|
||
|
||
for (const FVoxelPassage& P : StrateManager->GetPassages())
|
||
{
|
||
if (P.ControlPoints.Num() >= 2)
|
||
{
|
||
for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j)
|
||
DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]);
|
||
}
|
||
else
|
||
{
|
||
DrawSeg(P.UpperPoint, P.LowerPoint);
|
||
}
|
||
|
||
DrawDebugSphere(GetWorld(), ToWorld(P.UpperPoint), P.Radius * VOXEL_SIZE, 12, FColor::Green, false, -1.0f, 0, 4.0f);
|
||
DrawDebugSphere(GetWorld(), ToWorld(P.LowerPoint), P.Radius * VOXEL_SIZE, 12, FColor::Red, false, -1.0f, 0, 4.0f);
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
//=============================================================================
|
||
// ESPACE MONDE <-> ESPACE ACTEUR / WORLD <-> ACTOR SPACE
|
||
//=============================================================================
|
||
// Le champ de densite est en espace ACTEUR. Ces trois fonctions sont la SEULE frontiere
|
||
// autorisee entre les coordonnees Unreal et les coordonnees voxel (cf. VoxelWorld.h).
|
||
// The density field is in ACTOR space. These three are the ONLY sanctioned boundary between
|
||
// Unreal coordinates and voxel coordinates (see VoxelWorld.h).
|
||
|
||
FVector AVoxelWorld::WorldToLocalCm(FVector WorldPos) const
|
||
{
|
||
return GetActorTransform().InverseTransformPosition(WorldPos);
|
||
}
|
||
|
||
FVector AVoxelWorld::WorldToLocalVoxel(FVector WorldPos) const
|
||
{
|
||
return GetActorTransform().InverseTransformPosition(WorldPos) / VOXEL_SIZE;
|
||
}
|
||
|
||
FVector AVoxelWorld::LocalVoxelToWorld(FVector VoxelPos) const
|
||
{
|
||
return GetActorTransform().TransformPosition(VoxelPos * VOXEL_SIZE);
|
||
}
|
||
|
||
bool AVoxelWorld::TryGetPlayerPosition(FVector& OutPosition, APawn** OutPawn) const
|
||
{
|
||
OutPosition = FVector::ZeroVector;
|
||
if (OutPawn != nullptr)
|
||
{
|
||
*OutPawn = nullptr;
|
||
}
|
||
|
||
UWorld* World = GetWorld();
|
||
APlayerController* PC = World ? World->GetFirstPlayerController() : nullptr;
|
||
APawn* Pawn = PC ? PC->GetPawn() : nullptr;
|
||
if (!IsValid(Pawn))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const FVector Position = Pawn->GetActorLocation();
|
||
if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y)
|
||
|| !VoxelMath::IsFinite(Position.Z))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
OutPosition = Position;
|
||
if (OutPawn != nullptr)
|
||
{
|
||
*OutPawn = Pawn;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
FVector AVoxelWorld::GetPlayerPosition() const
|
||
{
|
||
FVector Position = FVector::ZeroVector;
|
||
TryGetPlayerPosition(Position);
|
||
return Position;
|
||
}
|
||
|
||
bool AVoxelWorld::GetPlayerSupportTile(APawn* Pawn, const FVector& PlayerPosition,
|
||
FVoxelTileKey& OutSupportTile) const
|
||
{
|
||
if (!IsValid(Pawn))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
float HalfHeightCm = 0.0f;
|
||
if (const ACharacter* Character = Cast<ACharacter>(Pawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// APawn is not required to be a character. Use its colliding component bounds as a
|
||
// conservative feet estimate so custom pawn movement still gets the same safety gate.
|
||
FVector BoundsOrigin = FVector::ZeroVector;
|
||
FVector BoundsExtent = FVector::ZeroVector;
|
||
Pawn->GetActorBounds(/*bOnlyCollidingComponents*/ true, BoundsOrigin, BoundsExtent);
|
||
HalfHeightCm = FMath::Max(0.0f, BoundsExtent.Z);
|
||
}
|
||
if (!VoxelMath::IsFinite(HalfHeightCm))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Probe one centimetre below the feet. At an exact chunk boundary this selects the tile
|
||
// containing the solid surface below, rather than the empty tile immediately above it.
|
||
FVector LocalFeet = WorldToLocalCm(PlayerPosition);
|
||
LocalFeet.Z -= HalfHeightCm + 1.0f;
|
||
OutSupportTile = FVoxelTileKey(WorldToChunkCoord(LocalFeet), 0);
|
||
return true;
|
||
}
|
||
|
||
bool AVoxelWorld::IsTileCollisionReady(const FVoxelTileKey& Tile) const
|
||
{
|
||
if (Tile.Level != 0 || !IsTileCollisionDesired(Tile)
|
||
|| !CollisionReadyTiles.Contains(Tile) || !LoadedTiles.Contains(Tile))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile);
|
||
if (!IsValid(MeshComp))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
URealtimeMeshSimple* Mesh = MeshComp->GetRealtimeMeshAs<URealtimeMeshSimple>();
|
||
return Mesh != nullptr && Mesh->GetBodySetup() != nullptr;
|
||
}
|
||
|
||
bool AVoxelWorld::IsTileCollisionDesired(const FVoxelTileKey& Tile) const
|
||
{
|
||
return Tile.Level == 0
|
||
&& (CollisionDesiredTiles.Contains(Tile) || CollisionGateDesiredTiles.Contains(Tile));
|
||
}
|
||
|
||
bool AVoxelWorld::IsCollisionTileUnavailable(const FVoxelTileKey& Tile,
|
||
const TCHAR*& OutReason) const
|
||
{
|
||
OutReason = nullptr;
|
||
|
||
// A tile outside every authored strate is open air. The streamer deliberately does not
|
||
// request it, so treating its absent collision as pending would make a one-strate world an
|
||
// impossible gate target. Inter-strate gap chunks are different: they are authored solid
|
||
// bedrock and must remain blocking until their collision is ready.
|
||
if (StrateManager != nullptr)
|
||
{
|
||
int32 TopChunkZ = 0;
|
||
int32 BottomChunkZ = 0;
|
||
const bool bInStrate = StrateManager->GetStrateChunkZBounds(
|
||
Tile.Coord.Z, TopChunkZ, BottomChunkZ);
|
||
if (!bInStrate && !StrateManager->IsGapChunk(Tile.Coord))
|
||
{
|
||
OutReason = TEXT("out_of_layout");
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
bool AVoxelWorld::IsCollisionReadyFromSupportTile(
|
||
const FVoxelTileKey& InitialTile, FVoxelTileKey* OutSupportTile,
|
||
const TCHAR** OutUnavailableReason) const
|
||
{
|
||
if (OutUnavailableReason != nullptr)
|
||
{
|
||
*OutUnavailableReason = nullptr;
|
||
}
|
||
|
||
FVoxelTileKey CandidateTile = InitialTile;
|
||
const int32 Direction = CollisionSolidTiles.Contains(InitialTile) ? 1 : -1;
|
||
|
||
// A pawn above an all-air/empty band searches down. If its probe lands inside an all-solid
|
||
// tile (including an exact chunk-boundary case), search up instead: the MC boundary can be
|
||
// emitted by the first non-solid tile above it. Never cross an unresolved tile.
|
||
constexpr int32 MaxSupportSearchTiles = 64;
|
||
for (int32 Depth = 0; Depth < MaxSupportSearchTiles; ++Depth)
|
||
{
|
||
if (IsTileCollisionReady(CandidateTile))
|
||
{
|
||
if (OutSupportTile != nullptr)
|
||
{
|
||
*OutSupportTile = CandidateTile;
|
||
}
|
||
return true;
|
||
}
|
||
const TCHAR* UnavailableReason = nullptr;
|
||
if (IsCollisionTileUnavailable(CandidateTile, UnavailableReason))
|
||
{
|
||
if (OutSupportTile != nullptr)
|
||
{
|
||
*OutSupportTile = CandidateTile;
|
||
}
|
||
if (OutUnavailableReason != nullptr)
|
||
{
|
||
*OutUnavailableReason = UnavailableReason;
|
||
}
|
||
return true;
|
||
}
|
||
if (!CollisionNotRequiredTiles.Contains(CandidateTile))
|
||
{
|
||
if (OutSupportTile != nullptr)
|
||
{
|
||
*OutSupportTile = CandidateTile;
|
||
}
|
||
return false;
|
||
}
|
||
CandidateTile.Coord.Z += Direction;
|
||
}
|
||
if (OutSupportTile != nullptr)
|
||
{
|
||
*OutSupportTile = CandidateTile;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool AVoxelWorld::IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition,
|
||
FVoxelTileKey* OutSupportTile,
|
||
const TCHAR** OutUnavailableReason) const
|
||
{
|
||
if (OutUnavailableReason != nullptr)
|
||
{
|
||
*OutUnavailableReason = nullptr;
|
||
}
|
||
|
||
FVoxelTileKey CandidateTile;
|
||
if (!GetPlayerSupportTile(Pawn, PlayerPosition, CandidateTile))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return IsCollisionReadyFromSupportTile(
|
||
CandidateTile, OutSupportTile, OutUnavailableReason);
|
||
}
|
||
|
||
bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor(
|
||
APawn* Pawn, const FVector& PlayerPosition, const FVector& NextPlayerPosition,
|
||
FVoxelTileKey* OutPendingTile) const
|
||
{
|
||
if (OutPendingTile != nullptr)
|
||
{
|
||
*OutPendingTile = FVoxelTileKey();
|
||
}
|
||
if (!IsValid(Pawn))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
FVoxelTileKey SupportTile;
|
||
const TCHAR* SupportUnavailableReason = nullptr;
|
||
const bool bCurrentSupportReady = IsPlayerSupportCollisionReady(
|
||
Pawn, PlayerPosition, &SupportTile, &SupportUnavailableReason);
|
||
if (!bCurrentSupportReady
|
||
&& (SupportUnavailableReason != nullptr
|
||
|| SupportTile.Level != 0
|
||
|| CollisionNotRequiredTiles.Contains(SupportTile)))
|
||
{
|
||
return false;
|
||
}
|
||
if (bCurrentSupportReady)
|
||
{
|
||
const TCHAR* NextSupportUnavailableReason = nullptr;
|
||
if (IsPlayerSupportCollisionReady(
|
||
Pawn, NextPlayerPosition, &SupportTile, &NextSupportUnavailableReason)
|
||
|| NextSupportUnavailableReason != nullptr
|
||
|| SupportTile.Level != 0
|
||
|| CollisionNotRequiredTiles.Contains(SupportTile))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
float HalfHeightCm = 0.0f;
|
||
float RadiusCm = 0.0f;
|
||
if (const ACharacter* Character = Cast<ACharacter>(Pawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||
RadiusCm = Capsule->GetScaledCapsuleRadius();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
FVector BoundsOrigin = FVector::ZeroVector;
|
||
FVector BoundsExtent = FVector::ZeroVector;
|
||
Pawn->GetActorBounds(/*bOnlyCollidingComponents*/ true, BoundsOrigin, BoundsExtent);
|
||
HalfHeightCm = FMath::Max(0.0f, BoundsExtent.Z);
|
||
RadiusCm = FMath::Max(BoundsExtent.X, BoundsExtent.Y);
|
||
}
|
||
if (!VoxelMath::IsFinite(HalfHeightCm) || !VoxelMath::IsFinite(RadiusCm)
|
||
|| HalfHeightCm <= 0.0f || RadiusCm < 0.0f)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition);
|
||
const FVector LocalNextPosition = WorldToLocalVoxel(NextPlayerPosition);
|
||
const float HalfHeightVoxels = HalfHeightCm / VOXEL_SIZE;
|
||
const float RadiusVoxels = RadiusCm / VOXEL_SIZE;
|
||
const float TileMinX = static_cast<float>(SupportTile.Coord.X * CHUNK_SIZE);
|
||
const float TileMaxX = static_cast<float>((SupportTile.Coord.X + 1) * CHUNK_SIZE);
|
||
const float TileMinY = static_cast<float>(SupportTile.Coord.Y * CHUNK_SIZE);
|
||
const float TileMaxY = static_cast<float>((SupportTile.Coord.Y + 1) * CHUNK_SIZE);
|
||
const float TileMinZ = static_cast<float>(SupportTile.Coord.Z * CHUNK_SIZE);
|
||
const float TileMaxZ = static_cast<float>((SupportTile.Coord.Z + 1) * CHUNK_SIZE);
|
||
const float CapsuleMinZ = FMath::Min(LocalPosition.Z, LocalNextPosition.Z) - HalfHeightVoxels;
|
||
const float CapsuleMaxZ = FMath::Max(LocalPosition.Z, LocalNextPosition.Z) + HalfHeightVoxels;
|
||
const float CapsuleMinX = FMath::Min(LocalPosition.X, LocalNextPosition.X) - RadiusVoxels;
|
||
const float CapsuleMaxX = FMath::Max(LocalPosition.X, LocalNextPosition.X) + RadiusVoxels;
|
||
const float CapsuleMinY = FMath::Min(LocalPosition.Y, LocalNextPosition.Y) - RadiusVoxels;
|
||
const float CapsuleMaxY = FMath::Max(LocalPosition.Y, LocalNextPosition.Y) + RadiusVoxels;
|
||
const bool bHorizontalOverlap = CapsuleMaxX > TileMinX && CapsuleMinX < TileMaxX
|
||
&& CapsuleMaxY > TileMinY && CapsuleMinY < TileMaxY;
|
||
const bool bVerticalOverlap = CapsuleMaxZ > TileMinZ && CapsuleMinZ < TileMaxZ;
|
||
if (!bHorizontalOverlap || !bVerticalOverlap)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (OutPendingTile != nullptr)
|
||
{
|
||
*OutPendingTile = SupportTile;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTile)
|
||
{
|
||
const double RequestCheckSeconds = FPlatformTime::Seconds();
|
||
const bool bIsStressLaunchSupport =
|
||
bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured
|
||
&& HeadlessCollisionGateStressTestLaunchSupportTile.Coord == SupportTile.Coord
|
||
&& HeadlessCollisionGateStressTestLaunchSupportTile.Level == SupportTile.Level;
|
||
if (bIsStressLaunchSupport)
|
||
{
|
||
const bool bPending = PendingTiles.Contains(SupportTile);
|
||
const bool bLoaded = LoadedTiles.Contains(SupportTile);
|
||
if (HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds <= 0.0
|
||
&& (bPending || bLoaded || IsTileCollisionReady(SupportTile)))
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = RequestCheckSeconds;
|
||
}
|
||
if (HeadlessCollisionGateStressTestLaunchSupportReadySeconds <= 0.0
|
||
&& IsTileCollisionReady(SupportTile))
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportReadySeconds = RequestCheckSeconds;
|
||
}
|
||
}
|
||
if (SupportTile.Level != 0
|
||
|| IsTileCollisionReady(SupportTile)
|
||
|| CollisionNotRequiredTiles.Contains(SupportTile))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!bStartupCollisionGateTimingCaptured)
|
||
{
|
||
StartupCollisionGateSupportTile = SupportTile;
|
||
bStartupCollisionGateTimingCaptured = true;
|
||
StartupCollisionGateFirstCheckSeconds = RequestCheckSeconds;
|
||
}
|
||
|
||
if (!bPawnGateSupportRequestReported
|
||
|| PawnGateRequestedSupportTile.Coord != SupportTile.Coord
|
||
|| PawnGateRequestedSupportTile.Level != SupportTile.Level)
|
||
{
|
||
PawnGateRequestedSupportTile = SupportTile;
|
||
bPawnGateSupportRequestReported = false;
|
||
}
|
||
|
||
// A gate check can run in BeginPlay, before the first desired set, and before the rebuild on a
|
||
// teleport/crossing tick. Submit the level-0 collision tile immediately; the desired rebuild
|
||
// below adopts the same key through PendingTiles and IsDesired, so it is neither duplicated nor
|
||
// cancelled when the full clipmap arrives.
|
||
const bool bDesiredAtRequest = DesiredStamp != 0 && IsDesired(SupportTile);
|
||
if (bHeadlessCollisionGateStressDeepDrop
|
||
&& SupportTile.Coord.Z < 0 && !bPawnGateSupportRequestReported)
|
||
{
|
||
FIntVector RingMin = FIntVector::ZeroValue;
|
||
FIntVector RingMax = FIntVector::ZeroValue;
|
||
VoxelClipmapDesiredTiles::AlignedRingBounds(
|
||
CurrentCenterChunk, Settings ? FMath::Max(1, Settings->ClipRadius) : 3,
|
||
2, RingMin, RingMax);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGateStressTest] gate_context target=(%d,%d,%d) "
|
||
"desired=%d center=(%d,%d,%d) center_override=%d override_vox=(%.1f,%.1f,%.1f) "
|
||
"ring_min=(%d,%d,%d) ring_max=(%d,%d,%d) anchors=%d"),
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z,
|
||
bDesiredAtRequest ? 1 : 0,
|
||
CurrentCenterChunk.X, CurrentCenterChunk.Y, CurrentCenterChunk.Z,
|
||
bHeadlessStreamingTestCenterOverride ? 1 : 0,
|
||
HeadlessStreamingTestCenterVoxel.X,
|
||
HeadlessStreamingTestCenterVoxel.Y,
|
||
HeadlessStreamingTestCenterVoxel.Z,
|
||
RingMin.X, RingMin.Y, RingMin.Z,
|
||
RingMax.X, RingMax.Y, RingMax.Z,
|
||
StreamingAnchors.Num());
|
||
}
|
||
bool bNeedsDesiredRebuild = !bDesiredAtRequest;
|
||
constexpr int32 CollisionGatePrefetchDownTiles = 4;
|
||
for (auto It = CollisionGateDesiredTiles.CreateIterator(); It; ++It)
|
||
{
|
||
const FVoxelTileKey& ExistingTile = *It;
|
||
const int32 RelativeZ = SupportTile.Coord.Z - ExistingTile.Coord.Z;
|
||
if (ExistingTile.Coord.X != SupportTile.Coord.X
|
||
|| ExistingTile.Coord.Y != SupportTile.Coord.Y
|
||
|| RelativeZ < 0 || RelativeZ >= CollisionGatePrefetchDownTiles)
|
||
{
|
||
bNeedsDesiredRebuild |= IsDesired(ExistingTile);
|
||
It.RemoveCurrent();
|
||
}
|
||
}
|
||
for (int32 Offset = 0; Offset < CollisionGatePrefetchDownTiles; ++Offset)
|
||
{
|
||
FVoxelTileKey ColumnTile = SupportTile;
|
||
ColumnTile.Coord.Z -= Offset;
|
||
const TCHAR* UnavailableReason = nullptr;
|
||
if (IsTileCollisionReady(ColumnTile)
|
||
|| CollisionNotRequiredTiles.Contains(ColumnTile)
|
||
|| IsCollisionTileUnavailable(ColumnTile, UnavailableReason))
|
||
{
|
||
continue;
|
||
}
|
||
if (!CollisionGateDesiredTiles.Contains(ColumnTile))
|
||
{
|
||
CollisionGateDesiredTiles.Add(ColumnTile);
|
||
bNeedsDesiredRebuild |= !IsDesired(ColumnTile);
|
||
}
|
||
if (LoadedTiles.Contains(ColumnTile) && !PendingCollisionCooks.Contains(ColumnTile))
|
||
{
|
||
// A render tile may already be loaded without a BodySetup. Upgrade it through the
|
||
// normal generation/apply path so the gate never mistakes LoadedTiles for collision.
|
||
DirtyRemeshQueue.Add(ColumnTile);
|
||
bAllChunksLoaded = false;
|
||
}
|
||
}
|
||
bForceDesiredRebuild |= bNeedsDesiredRebuild;
|
||
|
||
// Preserve the established critical floor ordering when it already belongs to this desired
|
||
// epoch. Before the first build there is no array to promote into; LoadTile below still uses
|
||
// the same CollisionCritical work class and priority queue.
|
||
if (bDesiredAtRequest)
|
||
{
|
||
CriticalDesiredTiles.Remove(SupportTile);
|
||
CriticalDesiredTiles.Insert(SupportTile, 0);
|
||
}
|
||
if (PendingTiles.Contains(SupportTile) || LoadedTiles.Contains(SupportTile))
|
||
{
|
||
if (bIsStressLaunchSupport
|
||
&& HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds <= 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds = RequestCheckSeconds;
|
||
}
|
||
if (bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateSupportTile.Coord == SupportTile.Coord
|
||
&& StartupCollisionGateSupportTile.Level == SupportTile.Level
|
||
&& StartupCollisionGateSubmittedSeconds <= 0.0)
|
||
{
|
||
// A prior normal streaming submit is already an accepted request. The exact earlier
|
||
// submission is outside the gate's sample, so report zero gate-to-submit latency.
|
||
StartupCollisionGateSubmittedSeconds = RequestCheckSeconds;
|
||
}
|
||
if (!bPawnGateSupportRequestReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] support_request_present tile=(%d,%d,%d) "
|
||
"pending=%d loaded=%d desired=%d first_check_to_submit_s=%.6f"),
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z,
|
||
PendingTiles.Contains(SupportTile) ? 1 : 0,
|
||
LoadedTiles.Contains(SupportTile) ? 1 : 0,
|
||
bDesiredAtRequest ? 1 : 0,
|
||
bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateSupportTile.Coord == SupportTile.Coord
|
||
&& StartupCollisionGateSupportTile.Level == SupportTile.Level
|
||
&& StartupCollisionGateSubmittedSeconds > 0.0
|
||
? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds
|
||
- StartupCollisionGateFirstCheckSeconds)
|
||
: -1.0);
|
||
bPawnGateSupportRequestReported = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const int32 PendingBefore = PendingTiles.Num();
|
||
LoadTile(SupportTile, EVoxelTileWorkPriority::CollisionCritical);
|
||
const bool bSubmitted = PendingTiles.Num() > PendingBefore
|
||
|| PendingTiles.Contains(SupportTile);
|
||
if (bSubmitted)
|
||
{
|
||
bAllChunksLoaded = false;
|
||
if (bIsStressLaunchSupport
|
||
&& HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds <= 0.0)
|
||
{
|
||
HeadlessCollisionGateStressTestLaunchSupportSubmittedSeconds =
|
||
FPlatformTime::Seconds();
|
||
}
|
||
}
|
||
if (!bPawnGateSupportRequestReported || bSubmitted)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] support_request tile=(%d,%d,%d) "
|
||
"submitted=%d desired=%d priority=collision_critical "
|
||
"first_check_to_submit_s=%.6f"),
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z,
|
||
bSubmitted ? 1 : 0,
|
||
bDesiredAtRequest ? 1 : 0,
|
||
bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateSupportTile.Coord == SupportTile.Coord
|
||
&& StartupCollisionGateSupportTile.Level == SupportTile.Level
|
||
&& StartupCollisionGateSubmittedSeconds > 0.0
|
||
? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds
|
||
- StartupCollisionGateFirstCheckSeconds)
|
||
: -1.0);
|
||
bPawnGateSupportRequestReported = true;
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition,
|
||
float DeltaTime)
|
||
{
|
||
if (!IsValid(Pawn) || bShuttingDown.load(std::memory_order_relaxed))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// This is a startup snapshot of voxel.CollisionGate. The hot path never reads the CVar.
|
||
if (!bCollisionGateEnabled)
|
||
{
|
||
ReleasePawnCollisionGate();
|
||
return;
|
||
}
|
||
|
||
// Tick prerequisites are kept even while the gate is open. That makes the next movement
|
||
// decision happen before pawn physics, including the first frame after a tile-boundary crossing.
|
||
if (PawnTickPrerequisite.Get() != Pawn)
|
||
{
|
||
if (APawn* PreviousPawn = PawnTickPrerequisite.Get())
|
||
{
|
||
PreviousPawn->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
if (UPawnMovementComponent* PreviousMovement = PawnMovementTickPrerequisite.Get())
|
||
{
|
||
PreviousMovement->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
PawnTickPrerequisite = Pawn;
|
||
Pawn->AddTickPrerequisiteActor(this);
|
||
bPawnGateUnsupportedReported = false;
|
||
bPawnGateUnavailableReported = false;
|
||
}
|
||
|
||
// The actor prerequisite covers the pawn tick; attach the same prerequisite directly to its
|
||
// movement component because CharacterMovementComponent has its own component tick function.
|
||
// Re-check this even for the same pawn: possession/setup code can replace the component late.
|
||
UPawnMovementComponent* CurrentMovement = Pawn->GetMovementComponent();
|
||
if (PawnMovementTickPrerequisite.Get() != CurrentMovement)
|
||
{
|
||
if (UPawnMovementComponent* PreviousMovement = PawnMovementTickPrerequisite.Get())
|
||
{
|
||
PreviousMovement->RemoveTickPrerequisiteActor(this);
|
||
}
|
||
if (CurrentMovement != nullptr)
|
||
{
|
||
CurrentMovement->AddTickPrerequisiteActor(this);
|
||
}
|
||
PawnMovementTickPrerequisite = CurrentMovement;
|
||
}
|
||
|
||
if (bPawnCollisionGateEngaged && CollisionGatedPawn.Get() == Pawn
|
||
&& CollisionGatedCharacterMovement.Get() != CurrentMovement
|
||
&& CollisionGatedPawnMovement.Get() != CurrentMovement)
|
||
{
|
||
// The component was replaced while the pawn was held. Restore the old component (if it
|
||
// still exists) and let the normal path engage the replacement against the same readiness
|
||
// decision below.
|
||
ReleasePawnCollisionGate();
|
||
}
|
||
|
||
if (bPawnCollisionGateEngaged && CollisionGatedPawn.Get() != Pawn)
|
||
{
|
||
ReleasePawnCollisionGate();
|
||
}
|
||
|
||
FVoxelTileKey CurrentSupportTile;
|
||
const TCHAR* CurrentSupportUnavailableReason = nullptr;
|
||
const bool bCurrentSupportReady = IsPlayerSupportCollisionReady(
|
||
Pawn, PlayerPosition, &CurrentSupportTile, &CurrentSupportUnavailableReason);
|
||
if (CurrentSupportUnavailableReason != nullptr)
|
||
{
|
||
if (!bPawnGateUnavailableReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] nonblocking pawn=%s reason=%s "
|
||
"support=(%d,%d,%d); tile will not be produced, no collision to wait for"),
|
||
*Pawn->GetName(),
|
||
CurrentSupportUnavailableReason,
|
||
CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y,
|
||
CurrentSupportTile.Coord.Z);
|
||
}
|
||
bPawnGateUnavailableReported = true;
|
||
ReleasePawnCollisionGate();
|
||
return;
|
||
}
|
||
else
|
||
{
|
||
bPawnGateUnavailableReported = false;
|
||
}
|
||
|
||
if (!bCurrentSupportReady && !bStartupCollisionGateTimingCaptured)
|
||
{
|
||
StartupCollisionGateSupportTile = CurrentSupportTile;
|
||
bStartupCollisionGateTimingCaptured = true;
|
||
StartupCollisionGateFirstCheckSeconds = FPlatformTime::Seconds();
|
||
}
|
||
|
||
// Predict one movement step before releasing a ready current tile. This requests a neighboring
|
||
// support tile before horizontal movement can carry the capsule across its boundary.
|
||
FVector NextPlayerPosition = PlayerPosition;
|
||
FVector Velocity = Pawn->GetVelocity();
|
||
float GravityZ = 0.0f;
|
||
if (const ACharacter* Character = Cast<ACharacter>(Pawn))
|
||
{
|
||
if (const UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||
{
|
||
Velocity = CharacterMovement->Velocity;
|
||
GravityZ = CharacterMovement->GetGravityZ();
|
||
}
|
||
}
|
||
if (DeltaTime > 0.0f)
|
||
{
|
||
NextPlayerPosition += Velocity * DeltaTime;
|
||
if (Velocity.Z <= KINDA_SMALL_NUMBER && GravityZ < 0.0f)
|
||
{
|
||
NextPlayerPosition.Z += 0.5f * GravityZ * DeltaTime * DeltaTime;
|
||
}
|
||
}
|
||
|
||
FVoxelTileKey PredictedSupportTile;
|
||
const TCHAR* PredictedSupportUnavailableReason = nullptr;
|
||
const bool bPredictedSupportReady = IsPlayerSupportCollisionReady(
|
||
Pawn, NextPlayerPosition, &PredictedSupportTile, &PredictedSupportUnavailableReason);
|
||
if (!bCurrentSupportReady)
|
||
{
|
||
// Request collision even when the pawn is moving up and the gate will not hold it. A spawn
|
||
// or teleport should not have to wait for downward velocity before its floor is queued.
|
||
RequestCollisionGateSupportTile(CurrentSupportTile);
|
||
|
||
}
|
||
else if (!bPredictedSupportReady && PredictedSupportUnavailableReason == nullptr
|
||
&& PredictedSupportTile.Level == 0
|
||
&& !CollisionNotRequiredTiles.Contains(PredictedSupportTile))
|
||
{
|
||
RequestCollisionGateSupportTile(PredictedSupportTile);
|
||
}
|
||
|
||
// An unresolved tile above a rising pawn is not a landing hazard. Keep its request warm, but
|
||
// let upward movement continue without entering the temporary collision-hold movement mode.
|
||
if (Velocity.Z > KINDA_SMALL_NUMBER)
|
||
{
|
||
ReleasePawnCollisionGate();
|
||
return;
|
||
}
|
||
|
||
FVoxelTileKey PendingFloorTile;
|
||
if (!IsPawnCapsuleOverlappingPendingFloor(
|
||
Pawn, PlayerPosition, NextPlayerPosition, &PendingFloorTile))
|
||
{
|
||
ReleasePawnCollisionGate();
|
||
return;
|
||
}
|
||
|
||
if (!bPawnCollisionGateEngaged)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] waiting pawn=%s current_support=(%d,%d,%d) ready=%d "
|
||
"hold_fall=1"),
|
||
*Pawn->GetName(),
|
||
CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y, CurrentSupportTile.Coord.Z,
|
||
bCurrentSupportReady ? 1 : 0);
|
||
}
|
||
EngagePawnCollisionGate(Pawn);
|
||
}
|
||
|
||
void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn)
|
||
{
|
||
if (!IsValid(Pawn))
|
||
{
|
||
return;
|
||
}
|
||
if (bPawnCollisionGateEngaged)
|
||
{
|
||
// Re-apply the vertical hold every game-thread sample. Movement input and horizontal
|
||
// velocity are intentionally untouched.
|
||
if (UCharacterMovementComponent* CharacterMovement =
|
||
CollisionGatedCharacterMovement.Get())
|
||
{
|
||
if (CharacterMovement->MovementMode != MOVE_Flying)
|
||
{
|
||
CharacterMovement->SetMovementMode(MOVE_Flying);
|
||
}
|
||
CharacterMovement->GravityScale = 0.0f;
|
||
FVector Velocity = CharacterMovement->Velocity;
|
||
if (Velocity.Z < 0.0f)
|
||
{
|
||
SavedGateVerticalVelocity = Velocity.Z;
|
||
bSavedGateVerticalVelocity = true;
|
||
Velocity.Z = 0.0f;
|
||
CharacterMovement->Velocity = Velocity;
|
||
CharacterMovement->UpdateComponentVelocity();
|
||
}
|
||
}
|
||
else if (UPawnMovementComponent* PawnMovement = CollisionGatedPawnMovement.Get())
|
||
{
|
||
FVector Velocity = PawnMovement->Velocity;
|
||
if (Velocity.Z < 0.0f)
|
||
{
|
||
SavedGateVerticalVelocity = Velocity.Z;
|
||
bSavedGateVerticalVelocity = true;
|
||
Velocity.Z = 0.0f;
|
||
PawnMovement->Velocity = Velocity;
|
||
PawnMovement->UpdateComponentVelocity();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
CollisionGatedPawn = Pawn;
|
||
|
||
if (ACharacter* Character = Cast<ACharacter>(Pawn))
|
||
{
|
||
if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||
{
|
||
CollisionGatedCharacterMovement = CharacterMovement;
|
||
SavedCharacterGravityScale = CharacterMovement->GravityScale;
|
||
bSavedCharacterGravityScale = true;
|
||
SavedCharacterMovementMode = static_cast<uint8>(CharacterMovement->MovementMode);
|
||
SavedCharacterCustomMovementMode = CharacterMovement->CustomMovementMode;
|
||
bSavedCharacterMovementMode = true;
|
||
|
||
FVector Velocity = CharacterMovement->Velocity;
|
||
if (Velocity.Z < 0.0f)
|
||
{
|
||
SavedGateVerticalVelocity = Velocity.Z;
|
||
bSavedGateVerticalVelocity = true;
|
||
Velocity.Z = 0.0f;
|
||
CharacterMovement->Velocity = Velocity;
|
||
}
|
||
CharacterMovement->SetMovementMode(MOVE_Flying);
|
||
CharacterMovement->GravityScale = 0.0f;
|
||
CharacterMovement->UpdateComponentVelocity();
|
||
bPawnCollisionGateEngaged = true;
|
||
PawnGateEngagedAtSeconds = FPlatformTime::Seconds();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (UPawnMovementComponent* PawnMovement = Pawn->GetMovementComponent())
|
||
{
|
||
CollisionGatedPawnMovement = PawnMovement;
|
||
FVector Velocity = PawnMovement->Velocity;
|
||
if (Velocity.Z < 0.0f)
|
||
{
|
||
SavedGateVerticalVelocity = Velocity.Z;
|
||
bSavedGateVerticalVelocity = true;
|
||
Velocity.Z = 0.0f;
|
||
PawnMovement->Velocity = Velocity;
|
||
PawnMovement->UpdateComponentVelocity();
|
||
}
|
||
bPawnCollisionGateEngaged = true;
|
||
PawnGateEngagedAtSeconds = FPlatformTime::Seconds();
|
||
return;
|
||
}
|
||
|
||
if (!bPawnGateUnsupportedReported)
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelForgeCollisionGate] pawn=%s has no movement component; cannot gate it "
|
||
"while support collision cooks"),
|
||
*Pawn->GetName());
|
||
bPawnGateUnsupportedReported = true;
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ReleasePawnCollisionGate()
|
||
{
|
||
if (!bPawnCollisionGateEngaged)
|
||
{
|
||
CollisionGatedPawn.Reset();
|
||
CollisionGatedPawnMovement.Reset();
|
||
CollisionGatedCharacterMovement.Reset();
|
||
bSavedCharacterGravityScale = false;
|
||
bSavedCharacterMovementMode = false;
|
||
bSavedGateVerticalVelocity = false;
|
||
SavedGateVerticalVelocity = 0.0f;
|
||
bPawnGateSupportRequestReported = false;
|
||
PawnGateEngagedAtSeconds = 0.0;
|
||
return;
|
||
}
|
||
|
||
if (UCharacterMovementComponent* CharacterMovement = CollisionGatedCharacterMovement.Get())
|
||
{
|
||
if (bSavedCharacterMovementMode)
|
||
{
|
||
CharacterMovement->SetMovementMode(
|
||
static_cast<EMovementMode>(SavedCharacterMovementMode),
|
||
SavedCharacterCustomMovementMode);
|
||
}
|
||
if (bSavedCharacterGravityScale)
|
||
{
|
||
CharacterMovement->GravityScale = SavedCharacterGravityScale;
|
||
}
|
||
if (bSavedGateVerticalVelocity && CharacterMovement->Velocity.Z <= KINDA_SMALL_NUMBER)
|
||
{
|
||
CharacterMovement->Velocity.Z = SavedGateVerticalVelocity;
|
||
}
|
||
CharacterMovement->UpdateComponentVelocity();
|
||
}
|
||
else if (UPawnMovementComponent* PawnMovement = CollisionGatedPawnMovement.Get())
|
||
{
|
||
if (bSavedGateVerticalVelocity && PawnMovement->Velocity.Z <= KINDA_SMALL_NUMBER)
|
||
{
|
||
PawnMovement->Velocity.Z = SavedGateVerticalVelocity;
|
||
}
|
||
PawnMovement->UpdateComponentVelocity();
|
||
}
|
||
|
||
if (APawn* Pawn = CollisionGatedPawn.Get())
|
||
{
|
||
const double HoldSeconds = PawnGateEngagedAtSeconds > 0.0
|
||
? FMath::Max(0.0, FPlatformTime::Seconds() - PawnGateEngagedAtSeconds)
|
||
: 0.0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] released pawn=%s hold_s=%.6f"),
|
||
*Pawn->GetName(), HoldSeconds);
|
||
}
|
||
CollisionGatedPawn.Reset();
|
||
CollisionGatedPawnMovement.Reset();
|
||
CollisionGatedCharacterMovement.Reset();
|
||
bSavedCharacterGravityScale = false;
|
||
bSavedCharacterMovementMode = false;
|
||
bSavedGateVerticalVelocity = false;
|
||
SavedGateVerticalVelocity = 0.0f;
|
||
bPawnCollisionGateEngaged = false;
|
||
PawnGateEngagedAtSeconds = 0.0;
|
||
bPawnGateSupportRequestReported = false;
|
||
}
|
||
|
||
void AVoxelWorld::BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh)
|
||
{
|
||
if (Mesh == nullptr || CollisionBodyUpdatedHandles.Contains(Mesh))
|
||
{
|
||
return;
|
||
}
|
||
CollisionBodyUpdatedHandles.Add(
|
||
Mesh,
|
||
Mesh->OnCollisionBodyUpdated().AddUObject(
|
||
this, &AVoxelWorld::HandleRealtimeMeshCollisionBodyUpdated));
|
||
}
|
||
|
||
void AVoxelWorld::UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh)
|
||
{
|
||
if (Mesh == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
if (FDelegateHandle* Handle = CollisionBodyUpdatedHandles.Find(Mesh))
|
||
{
|
||
Mesh->OnCollisionBodyUpdated().Remove(*Handle);
|
||
CollisionBodyUpdatedHandles.Remove(Mesh);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UBodySetup* BodySetup)
|
||
{
|
||
// RMC dispatches this event on the game thread after installing BodySetup. Keep the future as
|
||
// the per-request authority, but use the native delegate as an independent completion witness
|
||
// and diagnostic boundary (it has no tile/request serial of its own).
|
||
if (!IsInGameThread())
|
||
{
|
||
return;
|
||
}
|
||
for (TPair<FVoxelTileKey, FPendingCollisionCook>& Pair : PendingCollisionCooks)
|
||
{
|
||
FPendingCollisionCook& Pending = Pair.Value;
|
||
if (Pending.Mesh.Get() == Mesh && BodySetup != nullptr)
|
||
{
|
||
Pending.bBodyUpdatedEventSeen = true;
|
||
Pending.BodyUpdatedEventCycles = FPlatformTime::Cycles64();
|
||
Pending.BodyUpdatedEventFrame = GFrameCounter;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::RecordLOD0ReadySample(
|
||
uint64 RequestStartCycles, uint64 GenerationStartCycles, uint64 GenerationEndCycles,
|
||
uint64 ApplyStartCycles, uint64 ReadyCycles, uint64 CollisionSubmittedCycles)
|
||
{
|
||
if (RequestStartCycles == 0 || ReadyCycles < RequestStartCycles)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const auto SecondsBetween = [](uint64 Later, uint64 Earlier) -> double
|
||
{
|
||
return Later >= Earlier ? FPlatformTime::ToSeconds64(Later - Earlier) : 0.0;
|
||
};
|
||
|
||
FStreamingLatencySample Sample;
|
||
Sample.RequestToReadySeconds = SecondsBetween(ReadyCycles, RequestStartCycles);
|
||
Sample.GenerationSeconds = (GenerationStartCycles != 0 && GenerationEndCycles >= GenerationStartCycles)
|
||
? SecondsBetween(GenerationEndCycles, GenerationStartCycles) : 0.0;
|
||
|
||
// Queue wait is deliberately only the time before generation plus the result wait before the
|
||
// game-thread apply. Mesh upload and the RMC collision cook stay in request-to-ready but are not
|
||
// mislabeled as scheduler time.
|
||
const double WorkerQueueSeconds = GenerationStartCycles != 0
|
||
? SecondsBetween(GenerationStartCycles, RequestStartCycles) : 0.0;
|
||
const double ResultQueueSeconds = GenerationEndCycles != 0 && ApplyStartCycles != 0
|
||
? SecondsBetween(ApplyStartCycles, GenerationEndCycles)
|
||
: (GenerationStartCycles == 0 ? SecondsBetween(ApplyStartCycles, RequestStartCycles) : 0.0);
|
||
Sample.QueueWaitSeconds = WorkerQueueSeconds + ResultQueueSeconds;
|
||
Sample.CollisionCookSeconds = CollisionSubmittedCycles != 0
|
||
? SecondsBetween(ReadyCycles, CollisionSubmittedCycles) : 0.0;
|
||
LOD0ReadySamples.Add(Sample);
|
||
}
|
||
|
||
void AVoxelWorld::RecordLOD0RingReadyIfComplete()
|
||
{
|
||
if (!VoxelForgeStartupTrace::IsActive()
|
||
|| DesiredEpoch == 0
|
||
|| LastLOD0RingReadyEpoch == DesiredEpoch)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int32 DesiredLOD0Count = 0;
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
if (Key.Level != 0) continue;
|
||
++DesiredLOD0Count;
|
||
if (!LoadedTiles.Contains(Key))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
if (DesiredLOD0Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// LoadedTiles remains populated during an async remesh, so also require that no current
|
||
// level-0 request is still in flight. Obsolete requests from an older desired epoch do not
|
||
// hold the current ring back; their cancellation/epoch fence is handled by the normal drain.
|
||
for (const FVoxelTileKey& Key : PendingTiles)
|
||
{
|
||
if (Key.Level == 0 && IsDesired(Key))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
LastLOD0RingReadyEpoch = DesiredEpoch;
|
||
const uint64 ReadyCycles = FPlatformTime::Cycles64();
|
||
const double DurationSeconds = DesiredEpochStartCycles != 0
|
||
&& ReadyCycles >= DesiredEpochStartCycles
|
||
? FPlatformTime::ToSeconds64(ReadyCycles - DesiredEpochStartCycles)
|
||
: 0.0;
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("lod0_ring_ready"), FString::Printf(
|
||
TEXT("\"desired_epoch\":%u,\"center_chunk\":[%d,%d,%d],\"lod0_tiles\":%d,"
|
||
"\"duration_s\":%s"),
|
||
DesiredEpoch,
|
||
DesiredEpochCenterChunk.X, DesiredEpochCenterChunk.Y, DesiredEpochCenterChunk.Z,
|
||
DesiredLOD0Count,
|
||
*FString::SanitizeFloat(DurationSeconds)));
|
||
}
|
||
|
||
void AVoxelWorld::LogStreamingLatencySummary() const
|
||
{
|
||
TArray<double> RequestToReady;
|
||
TArray<double> QueueWait;
|
||
TArray<double> Generation;
|
||
TArray<double> CollisionCook;
|
||
RequestToReady.Reserve(LOD0ReadySamples.Num());
|
||
QueueWait.Reserve(LOD0ReadySamples.Num());
|
||
Generation.Reserve(LOD0ReadySamples.Num());
|
||
CollisionCook.Reserve(LOD0ReadySamples.Num());
|
||
for (const FStreamingLatencySample& Sample : LOD0ReadySamples)
|
||
{
|
||
RequestToReady.Add(Sample.RequestToReadySeconds);
|
||
QueueWait.Add(Sample.QueueWaitSeconds);
|
||
Generation.Add(Sample.GenerationSeconds);
|
||
CollisionCook.Add(Sample.CollisionCookSeconds);
|
||
}
|
||
|
||
const auto Percentile = [](TArray<double> Values, double Fraction) -> double
|
||
{
|
||
if (Values.Num() == 0) return 0.0;
|
||
Values.Sort();
|
||
const int32 Index = FMath::Clamp(
|
||
FMath::CeilToInt(Fraction * static_cast<double>(Values.Num() - 1)),
|
||
0, Values.Num() - 1);
|
||
return Values[Index];
|
||
};
|
||
const auto Maximum = [](const TArray<double>& Values) -> double
|
||
{
|
||
double Result = 0.0;
|
||
for (const double Value : Values) { Result = FMath::Max(Result, Value); }
|
||
return Result;
|
||
};
|
||
|
||
const double RequestP95 = Percentile(RequestToReady, 0.95);
|
||
const double TravelP95Metres = RequestP95
|
||
* static_cast<double>(PeakObservedPawnSpeedCmPerSecond) / 100.0;
|
||
FString ClassifierVerdictSummary;
|
||
for (int32 LOD = 0; LOD < TrackedClassifierLODCount; ++LOD)
|
||
{
|
||
if (LOD > 0)
|
||
{
|
||
ClassifierVerdictSummary += TEXT(";");
|
||
}
|
||
ClassifierVerdictSummary += FString::Printf(
|
||
TEXT("l%d[c=%llu,m=%llu,s=%llu,a=%llu]"),
|
||
LOD,
|
||
static_cast<unsigned long long>(OuterClassifierCallsByLOD[LOD].load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(OuterClassifierVerdictsByLOD[LOD][0].load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(OuterClassifierVerdictsByLOD[LOD][1].load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(OuterClassifierVerdictsByLOD[LOD][2].load(std::memory_order_relaxed)));
|
||
}
|
||
FString AppliedLevelSummary;
|
||
FString BandLevelSummary;
|
||
for (int32 LOD = 0; LOD < TrackedClassifierLODCount; ++LOD)
|
||
{
|
||
if (LOD > 0)
|
||
{
|
||
AppliedLevelSummary += TEXT(";");
|
||
BandLevelSummary += TEXT(";");
|
||
}
|
||
AppliedLevelSummary += FString::Printf(
|
||
TEXT("l%d[t=%llu,tri=%llu]"), LOD,
|
||
static_cast<unsigned long long>(AppliedVisibleTileCountByLevel[LOD]),
|
||
static_cast<unsigned long long>(AppliedTriangleCountByLevel[LOD]));
|
||
BandLevelSummary += FString::Printf(
|
||
TEXT("l%d[t=%llu]"), LOD,
|
||
static_cast<unsigned long long>(AppliedBandTileCountByLevel[LOD]));
|
||
}
|
||
const uint64 WorkerGenerationCycles =
|
||
TotalWorkerGenerationCycles.load(std::memory_order_relaxed);
|
||
const uint64 ObsoleteWorkerCycles =
|
||
TotalObsoleteWorkerCycles.load(std::memory_order_relaxed);
|
||
const uint64 SealedSolidProofCandidates =
|
||
TotalSealedSolidProofCandidates.load(std::memory_order_relaxed);
|
||
const uint64 SealedSolidProofSkips =
|
||
TotalSealedSolidProofSkips.load(std::memory_order_relaxed);
|
||
const uint64 OutOfLayoutAirProofSkips =
|
||
TotalOutOfLayoutAirProofSkips.load(std::memory_order_relaxed);
|
||
const bool bDiagnosticsEnabled = GVoxelForgeProfileTileGeneration != 0
|
||
|| VoxelForgeStartupTrace::IsActive()
|
||
|| VoxelDensityProfile::GetMode() != VoxelDensityProfile::EMode::Disabled;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingLatency] mode=%s diagnostics=%s lod0_ready_samples=%d "
|
||
"request_to_ready_s[p50=%.6f p95=%.6f max=%.6f] "
|
||
"queue_wait_s[p50=%.6f p95=%.6f max=%.6f] "
|
||
"generation_s[p50=%.6f p95=%.6f max=%.6f] "
|
||
"collision_cook_s[p50=%.6f p95=%.6f max=%.6f] "
|
||
"peak_speed_cm_s=%.3f p95_travel_m=%.6f obsolete_tile_aborts=%d "
|
||
"outer_classifier_mode=%d nested_block_early_out=%d "
|
||
"applied_tiles=%llu applied_visible_tiles=%llu applied_triangles=%llu "
|
||
"worker_generation_tasks=%llu worker_generation_sum_s=%.6f "
|
||
"validation_density_calls=%llu mesher_density_calls=%llu "
|
||
"sealed_solid_proof_candidates=%llu sealed_solid_proof_skips=%llu "
|
||
"out_of_layout_air_proof_skips=%llu "
|
||
"obsolete_worker_tasks=%llu obsolete_worker_sum_s=%.6f "
|
||
"outer_classifier_verdicts=%s applied_level_summary=%s band_level_summary=%s"),
|
||
bDiagnosticsEnabled ? TEXT("profiled") : TEXT("clean"),
|
||
bDiagnosticsEnabled ? TEXT("on") : TEXT("off"),
|
||
LOD0ReadySamples.Num(),
|
||
Percentile(RequestToReady, 0.50), RequestP95, Maximum(RequestToReady),
|
||
Percentile(QueueWait, 0.50), Percentile(QueueWait, 0.95), Maximum(QueueWait),
|
||
Percentile(Generation, 0.50), Percentile(Generation, 0.95), Maximum(Generation),
|
||
Percentile(CollisionCook, 0.50), Percentile(CollisionCook, 0.95), Maximum(CollisionCook),
|
||
PeakObservedPawnSpeedCmPerSecond, TravelP95Metres, ObsoleteTileAbortCount,
|
||
GVoxelForgeOuterClassifierMode, GVoxelForgeUseBlockEarlyOut,
|
||
static_cast<unsigned long long>(AppliedTileCount),
|
||
static_cast<unsigned long long>(AppliedVisibleTileCount),
|
||
static_cast<unsigned long long>(AppliedTriangleCount),
|
||
static_cast<unsigned long long>(TotalWorkerGenerationTasks.load(std::memory_order_relaxed)),
|
||
FPlatformTime::ToSeconds64(WorkerGenerationCycles),
|
||
static_cast<unsigned long long>(TotalValidationDensityCalls.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(TotalMesherDensityCalls.load(std::memory_order_relaxed)),
|
||
static_cast<unsigned long long>(SealedSolidProofCandidates),
|
||
static_cast<unsigned long long>(SealedSolidProofSkips),
|
||
static_cast<unsigned long long>(OutOfLayoutAirProofSkips),
|
||
static_cast<unsigned long long>(TotalObsoleteWorkerTasks.load(std::memory_order_relaxed)),
|
||
FPlatformTime::ToSeconds64(ObsoleteWorkerCycles),
|
||
*ClassifierVerdictSummary,
|
||
*AppliedLevelSummary,
|
||
*BandLevelSummary);
|
||
}
|
||
|
||
void AVoxelWorld::HandleTileCollisionCookComplete(
|
||
const FVoxelTileKey& Tile, uint64 SubmissionId, URealtimeMesh* Mesh, uint8 Result,
|
||
uint64 FutureCallbackCycles)
|
||
{
|
||
const uint64 CompletionGameThreadCycles = FPlatformTime::Cycles64();
|
||
if (!IsInGameThread())
|
||
{
|
||
TWeakObjectPtr<AVoxelWorld> WeakWorld(this);
|
||
AsyncTask(ENamedThreads::GameThread,
|
||
[WeakWorld, Tile, SubmissionId, Mesh, Result, FutureCallbackCycles]()
|
||
{
|
||
if (AVoxelWorld* World = WeakWorld.Get())
|
||
{
|
||
World->HandleTileCollisionCookComplete(
|
||
Tile, SubmissionId, Mesh, Result, FutureCallbackCycles);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (bShuttingDown.load(std::memory_order_relaxed))
|
||
{
|
||
return;
|
||
}
|
||
|
||
FPendingCollisionCook* Pending = PendingCollisionCooks.Find(Tile);
|
||
URealtimeMesh* CompletedMesh = Pending ? Pending->Mesh.Get() : nullptr;
|
||
if (Pending == nullptr || Pending->SubmissionId != SubmissionId
|
||
|| CompletedMesh == nullptr || CompletedMesh != Mesh)
|
||
{
|
||
// A remesh, unload, or pool reuse superseded this future. It must not change the newer
|
||
// tile state, even if RMC finishes the old cook after that transition.
|
||
return;
|
||
}
|
||
|
||
Pending->FutureCallbackCycles = FutureCallbackCycles;
|
||
Pending->CompletionGameThreadCycles = CompletionGameThreadCycles;
|
||
|
||
const uint64 MeasuredMeshSubmittedCycles = Pending->MeshSubmittedCycles;
|
||
const uint64 MeasuredCollisionSubmittedCycles = Pending->CollisionSubmittedCycles;
|
||
const bool bBodyUpdatedEventSeen = Pending->bBodyUpdatedEventSeen;
|
||
const uint64 BodyUpdatedEventCycles = Pending->BodyUpdatedEventCycles;
|
||
const uint64 BodyUpdatedEventFrame = Pending->BodyUpdatedEventFrame;
|
||
const uint64 MeasuredCollisionSubmittedFrame = Pending->CollisionSubmittedFrame;
|
||
const uint64 MeasuredRequestStartCycles = Pending->RequestStartCycles;
|
||
const uint64 MeasuredGenerationStartCycles = Pending->GenerationStartCycles;
|
||
const uint64 MeasuredGenerationEndCycles = Pending->GenerationEndCycles;
|
||
const uint64 MeasuredApplyStartCycles = Pending->ApplyStartCycles;
|
||
const EVoxelTileWorkPriority WorkPriority = Pending->WorkPriority;
|
||
const int32 NumVertices = Pending->NumVertices;
|
||
const int32 NumTriangles = Pending->NumTriangles;
|
||
const int32 NumSkirtTriangles = Pending->NumSkirtTriangles;
|
||
const int32 PendingCooksAtSubmit = Pending->PendingCooksAtSubmit;
|
||
const bool bUseAsyncCook = Pending->bUseAsyncCook;
|
||
const bool bUseComplexAsSimpleCollision = Pending->bUseComplexAsSimpleCollision;
|
||
const bool bShouldFastCookMeshes = Pending->bShouldFastCookMeshes;
|
||
const bool bMergeAllMeshes = Pending->bMergeAllMeshes;
|
||
const uint64 SuppressedAtCycles = Pending->SuppressedAtCycles;
|
||
const bool bRecordStreamingLatency = Pending->bRecordStreamingLatency;
|
||
PendingCollisionCooks.Remove(Tile);
|
||
|
||
const ERealtimeMeshCollisionUpdateResult CollisionResult =
|
||
static_cast<ERealtimeMeshCollisionUpdateResult>(Result);
|
||
const bool bBodyInstalled = CollisionResult == ERealtimeMeshCollisionUpdateResult::Updated
|
||
&& CompletedMesh->GetBodySetup() != nullptr;
|
||
const bool bCollisionStillDesired = IsTileCollisionDesired(Tile);
|
||
if (bBodyInstalled && bCollisionStillDesired)
|
||
{
|
||
CollisionSuppressedUntilCook.Remove(Tile);
|
||
CollisionSuppressedAtCycles.Remove(Tile);
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile))
|
||
{
|
||
Comp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
|
||
}
|
||
CollisionReadyTiles.Add(Tile);
|
||
if (bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateSupportTile.Coord == Tile.Coord
|
||
&& StartupCollisionGateSupportTile.Level == Tile.Level
|
||
&& StartupCollisionGateReadySeconds <= 0.0)
|
||
{
|
||
StartupCollisionGateReadySeconds = FPlatformTime::Seconds();
|
||
const double FirstCheckToSubmitSeconds = StartupCollisionGateSubmittedSeconds > 0.0
|
||
? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds
|
||
- StartupCollisionGateFirstCheckSeconds)
|
||
: -1.0;
|
||
const double FirstCheckToReadySeconds = FMath::Max(
|
||
0.0, StartupCollisionGateReadySeconds
|
||
- StartupCollisionGateFirstCheckSeconds);
|
||
const double SubmitToReadySeconds = StartupCollisionGateSubmittedSeconds > 0.0
|
||
? FMath::Max(0.0, StartupCollisionGateReadySeconds
|
||
- StartupCollisionGateSubmittedSeconds)
|
||
: -1.0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] support_collision_ready tile=(%d,%d,%d) "
|
||
"first_check_to_submit_s=%.6f first_check_to_collision_ready_s=%.6f "
|
||
"submit_to_ready_s=%.6f"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z,
|
||
FirstCheckToSubmitSeconds, FirstCheckToReadySeconds, SubmitToReadySeconds);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
CollisionReadyTiles.Remove(Tile);
|
||
if (!bCollisionStillDesired)
|
||
{
|
||
CollisionSuppressedUntilCook.Remove(Tile);
|
||
CollisionSuppressedAtCycles.Remove(Tile);
|
||
}
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile))
|
||
{
|
||
Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
}
|
||
if (bCollisionStillDesired)
|
||
{
|
||
// A failed/ignored current cook is not a loaded guarantee. Leaving the tile unloaded
|
||
// makes the normal submit loop retry it while the pawn remains gated.
|
||
LoadedTiles.Remove(Tile);
|
||
LoadedTileGeometry.Remove(Tile);
|
||
bAllChunksLoaded = false;
|
||
}
|
||
}
|
||
|
||
const uint64 ReadyCycles = FPlatformTime::Cycles64();
|
||
if (bBodyInstalled && Tile.Level == 0 && bRecordStreamingLatency)
|
||
{
|
||
RecordLOD0ReadySample(MeasuredRequestStartCycles, MeasuredGenerationStartCycles,
|
||
MeasuredGenerationEndCycles, MeasuredApplyStartCycles,
|
||
ReadyCycles, MeasuredCollisionSubmittedCycles);
|
||
}
|
||
const double MeshToCookSeconds = FPlatformTime::ToSeconds64(
|
||
ReadyCycles - MeasuredMeshSubmittedCycles);
|
||
const double CollisionRequestToCookSeconds = FPlatformTime::ToSeconds64(
|
||
ReadyCycles - MeasuredCollisionSubmittedCycles);
|
||
const double BodyEventToFutureSeconds = bBodyUpdatedEventSeen
|
||
&& ReadyCycles >= BodyUpdatedEventCycles
|
||
? FPlatformTime::ToSeconds64(ReadyCycles - BodyUpdatedEventCycles) : -1.0;
|
||
const auto SecondsBetween = [](uint64 EndCycles, uint64 StartCycles) -> double
|
||
{
|
||
return StartCycles != 0 && EndCycles >= StartCycles
|
||
? FPlatformTime::ToSeconds64(EndCycles - StartCycles)
|
||
: -1.0;
|
||
};
|
||
const double RequestToWorkerStartSeconds = SecondsBetween(
|
||
MeasuredGenerationStartCycles, MeasuredRequestStartCycles);
|
||
const double WorkerGenerationSeconds = SecondsBetween(
|
||
MeasuredGenerationEndCycles, MeasuredGenerationStartCycles);
|
||
const double WorkerResultToApplySeconds = SecondsBetween(
|
||
MeasuredApplyStartCycles, MeasuredGenerationEndCycles);
|
||
const double ApplyToMeshSubmitSeconds = SecondsBetween(
|
||
MeasuredMeshSubmittedCycles, MeasuredApplyStartCycles);
|
||
const double MeshSubmitToCollisionSubmitSeconds = SecondsBetween(
|
||
MeasuredCollisionSubmittedCycles, MeasuredMeshSubmittedCycles);
|
||
const double RMCSubmitToBodyUpdateSeconds = bBodyUpdatedEventSeen
|
||
? SecondsBetween(BodyUpdatedEventCycles, MeasuredCollisionSubmittedCycles) : -1.0;
|
||
const double BodyUpdateToFutureCallbackSeconds = bBodyUpdatedEventSeen
|
||
? SecondsBetween(FutureCallbackCycles, BodyUpdatedEventCycles) : -1.0;
|
||
const double FutureCallbackToGameThreadSeconds = SecondsBetween(
|
||
CompletionGameThreadCycles, FutureCallbackCycles);
|
||
const double SuppressedToReadySeconds = SecondsBetween(ReadyCycles, SuppressedAtCycles);
|
||
const int64 FramesToBodyUpdate = bBodyUpdatedEventSeen
|
||
&& BodyUpdatedEventFrame >= MeasuredCollisionSubmittedFrame
|
||
? static_cast<int64>(BodyUpdatedEventFrame - MeasuredCollisionSubmittedFrame)
|
||
: -1;
|
||
const TCHAR* ResultName = TEXT("Unknown");
|
||
switch (CollisionResult)
|
||
{
|
||
case ERealtimeMeshCollisionUpdateResult::Updated: ResultName = TEXT("Updated"); break;
|
||
case ERealtimeMeshCollisionUpdateResult::Ignored: ResultName = TEXT("Ignored"); break;
|
||
case ERealtimeMeshCollisionUpdateResult::Error: ResultName = TEXT("Error"); break;
|
||
default: break;
|
||
}
|
||
if (GVoxelForgeProfileTileGeneration != 0 || GVoxelForgeProfileCollisionCookStages != 0
|
||
|| VoxelForgeStartupTrace::IsActive()
|
||
|| !bBodyInstalled)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionReady] tile=(%d,%d,%d) level=%d result=%s body_setup=%d "
|
||
"ready=%d body_event=%d priority=%d critical=%d vertices=%d triangles=%d "
|
||
"skirt_triangles=%d pending_cooks=%d async=%d complex_as_simple=%d fast_cook=%d "
|
||
"merge=%d request_to_worker_s=%.6f worker_gen_s=%.6f worker_to_apply_s=%.6f "
|
||
"apply_to_mesh_submit_s=%.6f mesh_to_collision_submit_s=%.6f "
|
||
"rmc_submit_to_body_update_s=%.6f submit_to_body_frames=%lld "
|
||
"body_update_to_future_callback_s=%.6f future_callback_to_gt_s=%.6f "
|
||
"suppressed_to_ready_s=%.6f mesh_submit_to_ready=%.6f "
|
||
"collision_request_to_ready=%.6f body_event_to_future=%.6f"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, ResultName,
|
||
CompletedMesh->GetBodySetup() != nullptr ? 1 : 0,
|
||
bBodyInstalled ? 1 : 0,
|
||
bBodyUpdatedEventSeen ? 1 : 0,
|
||
static_cast<int32>(WorkPriority),
|
||
WorkPriority == EVoxelTileWorkPriority::CollisionCritical ? 1 : 0,
|
||
NumVertices, NumTriangles, NumSkirtTriangles, PendingCooksAtSubmit,
|
||
bUseAsyncCook ? 1 : 0, bUseComplexAsSimpleCollision ? 1 : 0,
|
||
bShouldFastCookMeshes ? 1 : 0, bMergeAllMeshes ? 1 : 0,
|
||
RequestToWorkerStartSeconds, WorkerGenerationSeconds, WorkerResultToApplySeconds,
|
||
ApplyToMeshSubmitSeconds, MeshSubmitToCollisionSubmitSeconds,
|
||
RMCSubmitToBodyUpdateSeconds, FramesToBodyUpdate,
|
||
BodyUpdateToFutureCallbackSeconds, FutureCallbackToGameThreadSeconds,
|
||
SuppressedToReadySeconds, MeshToCookSeconds,
|
||
CollisionRequestToCookSeconds, BodyEventToFutureSeconds);
|
||
}
|
||
if (VoxelForgeStartupTrace::IsActive())
|
||
{
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("collision_ready"), FString::Printf(
|
||
TEXT("\"tile\":[%d,%d,%d],\"level\":%d,\"result\":\"%s\",\"body_setup\":%d,"
|
||
"\"ready\":%d,\"body_update_event\":%d,"
|
||
"\"priority\":%d,\"critical\":%d,\"vertices\":%d,\"triangles\":%d,"
|
||
"\"skirt_triangles\":%d,\"pending_cooks_at_submit\":%d,"
|
||
"\"request_to_worker_s\":%s,\"worker_generation_s\":%s,"
|
||
"\"worker_result_to_apply_s\":%s,\"apply_to_mesh_submit_s\":%s,"
|
||
"\"mesh_to_collision_submit_s\":%s,\"rmc_submit_to_body_update_s\":%s,"
|
||
"\"submit_to_body_frames\":%lld,\"body_update_to_future_callback_s\":%s,"
|
||
"\"future_callback_to_gt_s\":%s,\"suppressed_to_ready_s\":%s,"
|
||
"\"mesh_submit_to_ready_s\":%s,\"collision_request_to_ready_s\":%s"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, ResultName,
|
||
CompletedMesh->GetBodySetup() != nullptr ? 1 : 0, bBodyInstalled ? 1 : 0,
|
||
bBodyUpdatedEventSeen ? 1 : 0,
|
||
static_cast<int32>(WorkPriority),
|
||
WorkPriority == EVoxelTileWorkPriority::CollisionCritical ? 1 : 0,
|
||
NumVertices, NumTriangles, NumSkirtTriangles, PendingCooksAtSubmit,
|
||
*FString::SanitizeFloat(RequestToWorkerStartSeconds),
|
||
*FString::SanitizeFloat(WorkerGenerationSeconds),
|
||
*FString::SanitizeFloat(WorkerResultToApplySeconds),
|
||
*FString::SanitizeFloat(ApplyToMeshSubmitSeconds),
|
||
*FString::SanitizeFloat(MeshSubmitToCollisionSubmitSeconds),
|
||
*FString::SanitizeFloat(RMCSubmitToBodyUpdateSeconds), FramesToBodyUpdate,
|
||
*FString::SanitizeFloat(BodyUpdateToFutureCallbackSeconds),
|
||
*FString::SanitizeFloat(FutureCallbackToGameThreadSeconds),
|
||
*FString::SanitizeFloat(SuppressedToReadySeconds),
|
||
*FString::SanitizeFloat(MeshToCookSeconds),
|
||
*FString::SanitizeFloat(CollisionRequestToCookSeconds)));
|
||
}
|
||
|
||
// A completion can occur between world ticks. Refresh immediately so the pawn is released on
|
||
// the same game-thread turn once its feet tile is truly collidable.
|
||
FVector PlayerPosition = FVector::ZeroVector;
|
||
APawn* PlayerPawn = nullptr;
|
||
if (TryGetPlayerPosition(PlayerPosition, &PlayerPawn))
|
||
{
|
||
UpdatePawnCollisionGate(PlayerPawn, PlayerPosition, 0.0f);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ProcessPendingChunks()
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
||
// This runs on the game thread, called from Tick.
|
||
//
|
||
// STEPS:
|
||
// 1. Try to dequeue a result from ProcessQueue
|
||
// TQueue has a Dequeue(OutItem) method that returns true if it got something
|
||
//
|
||
// 2. If we got a result:
|
||
// a. Store the chunk data in our Chunks map
|
||
// b. Apply the mesh so it becomes visible
|
||
// c. Remove the coord from PendingChunkCoord (it's no longer "in progress")
|
||
//
|
||
// 3. You can process multiple results per frame with a while loop,
|
||
// or limit to a few per frame to avoid stutters (e.g., max 4 per tick)
|
||
//
|
||
// TOOLS:
|
||
// - ProcessQueue.Dequeue(Result) — returns bool, fills Result if true
|
||
// - Chunks.Add(Key, Value)
|
||
// - ApplyMeshToChunk(ChunkCoord, MeshData)
|
||
// - PendingChunkCoord.Remove(ChunkCoord)
|
||
// Drain the process queue up to the per-frame budget.
|
||
// This prevents stutters from applying too many meshes in one frame.
|
||
// Budget limits how many VISIBLE mesh applies we do per frame (GPU upload cost).
|
||
// Empty meshes and stale results are free to drain — don't count them.
|
||
const bool bProfileApply = GVoxelForgeProfileTileGeneration != 0;
|
||
const uint64 DrainStartCycles = FPlatformTime::Cycles64();
|
||
const int32 MaxApplies = FMath::Max(
|
||
Settings ? Settings->MaxMeshAppliesPerFrame : 4, 1);
|
||
const float ApplyBudgetMilliseconds = Settings
|
||
? FMath::Max(Settings->MaxMeshApplyMilliseconds, 0.0f)
|
||
: 2.0f;
|
||
const double ApplyBudgetSeconds =
|
||
static_cast<double>(ApplyBudgetMilliseconds) * 0.001;
|
||
int32 MeshesApplied = 0;
|
||
int32 ResultsDrained = 0;
|
||
bool bTimeBudgetHit = false;
|
||
FChunkResult DequeuedChunk;
|
||
const auto DequeueNextResult = [&]() -> bool
|
||
{
|
||
// Mirror the admission order. A horizon result that completed earlier must not consume
|
||
// the game-thread apply budget ahead of collision support, an edit, or ordinary LOD0.
|
||
return CriticalProcessQueue.Dequeue(DequeuedChunk)
|
||
|| EditedProcessQueue.Dequeue(DequeuedChunk)
|
||
|| NearProcessQueue.Dequeue(DequeuedChunk)
|
||
|| ProcessQueue.Dequeue(DequeuedChunk);
|
||
};
|
||
while (DequeueNextResult())
|
||
{
|
||
++ResultsDrained;
|
||
const TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe> Cancellation =
|
||
PendingTileCancellation.FindRef(DequeuedChunk.Tile);
|
||
const bool bRequestObsolete = DequeuedChunk.bObsolete
|
||
|| (Cancellation.IsValid()
|
||
&& Cancellation->bObsolete.load(std::memory_order_acquire));
|
||
PendingTileCancellation.Remove(DequeuedChunk.Tile);
|
||
PendingTiles.Remove(DequeuedChunk.Tile);
|
||
|
||
// A worker can observe shutdown after entering the mesher. Do not turn that partial
|
||
// result into an all-air tile: it must remain eligible for a future generation epoch.
|
||
// Read the token here as well as on the worker: cancellation can race the final worker
|
||
// check after it has already enqueued a complete result. This also drops a stale result
|
||
// if the tile leaves and re-enters the desired set before the queue is drained.
|
||
if (DequeuedChunk.bAborted || bRequestObsolete)
|
||
{
|
||
if (bRequestObsolete)
|
||
{
|
||
++ObsoleteTileAbortCount;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// ApplyTileResult does epoch check, capture ingest, empty-release / mesh submission, and
|
||
// only then records the tile as loaded. Only a real (visible) upload counts against the
|
||
// per-frame budget — stale/empty drains are free.
|
||
if (ApplyTileResult(DequeuedChunk))
|
||
{
|
||
++MeshesApplied;
|
||
}
|
||
|
||
// Time-box the entire game-thread drain, not only visible uploads. Empty releases and
|
||
// stale-result cleanup normally cost nothing, but a pooled component teardown or a
|
||
// backend callback can still be the one result that produces the player's hitch.
|
||
if (ApplyBudgetSeconds > 0.0
|
||
&& FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DrainStartCycles)
|
||
>= ApplyBudgetSeconds)
|
||
{
|
||
bTimeBudgetHit = true;
|
||
break;
|
||
}
|
||
if (MeshesApplied >= MaxApplies)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (bProfileApply && ResultsDrained > 0)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeApplyFrame] drained=%d meshes=%d elapsed=%.6f "
|
||
"max_meshes=%d budget_ms=%.3f budget_hit=%d"),
|
||
ResultsDrained,
|
||
MeshesApplied,
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DrainStartCycles),
|
||
MaxApplies,
|
||
ApplyBudgetMilliseconds,
|
||
bTimeBudgetHit ? 1 : 0);
|
||
}
|
||
}
|
||
|
||
// Game-thread apply for one gen result. Shared by ProcessPendingChunks (async drain) and
|
||
// SyncRemeshTile (synchronous carve). Returns true iff a visible mesh was uploaded (budget).
|
||
bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
|
||
{
|
||
// Discard results from a previous generation epoch (stale), or for a tile that has since left
|
||
// the desired set. ProcessPendingChunks also reads the per-request token before removing it,
|
||
// closing the race where the worker enqueues just after CancelObsoleteTileWork flips the flag.
|
||
if (Result.bAborted || Result.Epoch != GenerationEpoch
|
||
|| !IsDesired(Result.Tile))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Store only lattices still within the local pawn's fixed 3×3×3 neighborhood. The cache is
|
||
// independent of DiffLayer revisions because it contains only the procedural pre-edit field.
|
||
if (Result.PreEditDensityGrid.Samples.Num() > 0
|
||
&& VoxelForgePreEditDensityCache::IsPawnRadiusTile(Result.Tile, CurrentCenterChunk))
|
||
{
|
||
if (PreEditDensityCache.Num() < VoxelForgePreEditDensityCache::MaxCachedTiles
|
||
|| PreEditDensityCache.Contains(Result.Tile))
|
||
{
|
||
PreEditDensityCache.Add(Result.Tile, MoveTemp(Result.PreEditDensityGrid));
|
||
}
|
||
}
|
||
|
||
// A worker may have captured the density field before one or more edits. Do not let that
|
||
// result overwrite the current visual/collision state when any later revision touches this
|
||
// tile's footprint. Keep the old state in place and admit one post-edit request through the
|
||
// normal budgeted edited queue; this is the edit equivalent of load-before-unload.
|
||
if (Result.ModificationEpoch < ModificationEpoch)
|
||
{
|
||
bool bAffectedByLaterEdit = false;
|
||
for (const FModificationRevision& Revision : ModificationRevisions)
|
||
{
|
||
if (Revision.Epoch > Result.ModificationEpoch
|
||
&& VF_TileOverlapsVoxelBounds(
|
||
Result.Tile, Revision.MinVoxel, Revision.MaxVoxel))
|
||
{
|
||
bAffectedByLaterEdit = true;
|
||
break;
|
||
}
|
||
}
|
||
if (bAffectedByLaterEdit)
|
||
{
|
||
DirtyRemeshQueue.Add(Result.Tile);
|
||
bAllChunksLoaded = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Applied is emitted after the game-thread submission, not when the worker result is dequeued.
|
||
// It deliberately does NOT mean collision-ready: RMC's cook future records that later boundary
|
||
// in HandleTileCollisionCookComplete.
|
||
const bool bResultEmpty = Result.bEmpty || !Result.Streams;
|
||
const bool bTraceTile = VoxelForgeStartupTrace::IsActive();
|
||
const uint64 ApplyStartCycles = FPlatformTime::Cycles64();
|
||
Result.ApplyStartCycles = ApplyStartCycles;
|
||
const auto LogTileApplied = [&]()
|
||
{
|
||
++AppliedTileCount;
|
||
if (Result.Tile.Level >= 0 && Result.Tile.Level < TrackedClassifierLODCount
|
||
&& Result.BandChunkLo != MIN_int32)
|
||
{
|
||
++AppliedBandTileCountByLevel[Result.Tile.Level];
|
||
}
|
||
if (!bResultEmpty)
|
||
{
|
||
++AppliedVisibleTileCount;
|
||
const uint64 Triangles = static_cast<uint64>(FMath::Max(0, Result.NumTriangles));
|
||
AppliedTriangleCount += Triangles;
|
||
if (Result.Tile.Level >= 0 && Result.Tile.Level < TrackedClassifierLODCount)
|
||
{
|
||
++AppliedVisibleTileCountByLevel[Result.Tile.Level];
|
||
AppliedTriangleCountByLevel[Result.Tile.Level] += Triangles;
|
||
}
|
||
}
|
||
if ((GVoxelForgeProfileTileGeneration != 0 || bTraceTile)
|
||
&& Result.RequestStartCycles != 0)
|
||
{
|
||
const uint64 AppliedCycles = FPlatformTime::Cycles64();
|
||
const bool bHaveWorkerTiming = Result.GenerationStartCycles != 0
|
||
&& Result.GenerationEndCycles >= Result.GenerationStartCycles;
|
||
const double RequestToApply = FPlatformTime::ToSeconds64(
|
||
AppliedCycles - Result.RequestStartCycles);
|
||
const double ApplySeconds = ApplyStartCycles != 0
|
||
? FPlatformTime::ToSeconds64(AppliedCycles - ApplyStartCycles) : 0.0;
|
||
const double GenerationSeconds = bHaveWorkerTiming
|
||
? FPlatformTime::ToSeconds64(
|
||
Result.GenerationEndCycles - Result.GenerationStartCycles) : 0.0;
|
||
const double WorkerQueueSeconds = bHaveWorkerTiming
|
||
? FPlatformTime::ToSeconds64(
|
||
Result.GenerationStartCycles - Result.RequestStartCycles) : 0.0;
|
||
const double ResultQueueSeconds = bHaveWorkerTiming
|
||
? FMath::Max(0.0, RequestToApply - WorkerQueueSeconds
|
||
- GenerationSeconds - ApplySeconds)
|
||
: FMath::Max(0.0, RequestToApply - ApplySeconds);
|
||
if (GVoxelForgeProfileTileGeneration != 0)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileApplied] tile=(%d,%d,%d) level=%d empty=%d triangles=%d "
|
||
"band=(%d,%d) "
|
||
"request_to_apply=%.6f worker_queue=%.6f generation=%.6f "
|
||
"result_queue=%.6f apply=%.6f"),
|
||
Result.Tile.Coord.X, Result.Tile.Coord.Y, Result.Tile.Coord.Z,
|
||
Result.Tile.Level, bResultEmpty ? 1 : 0, Result.NumTriangles,
|
||
Result.BandChunkLo, Result.BandChunkHi,
|
||
RequestToApply, WorkerQueueSeconds, GenerationSeconds,
|
||
ResultQueueSeconds, ApplySeconds);
|
||
}
|
||
if (bTraceTile)
|
||
{
|
||
VoxelForgeStartupTrace::FTileSample Sample;
|
||
Sample.TileX = Result.Tile.Coord.X;
|
||
Sample.TileY = Result.Tile.Coord.Y;
|
||
Sample.TileZ = Result.Tile.Coord.Z;
|
||
Sample.Level = Result.Tile.Level;
|
||
Sample.Verdict = Result.ClassifyVerdict;
|
||
Sample.bSealedSolidProof = Result.bSealedSolidProof;
|
||
Sample.bEmpty = bResultEmpty;
|
||
Sample.Vertices = Result.NumVertices;
|
||
Sample.Triangles = Result.NumTriangles;
|
||
Sample.GeometryBytes = Result.GeometryBytes;
|
||
Sample.RequestToApplySeconds = RequestToApply;
|
||
Sample.WorkerQueueSeconds = WorkerQueueSeconds;
|
||
Sample.ResultQueueSeconds = ResultQueueSeconds;
|
||
Sample.QueueWaitSeconds = WorkerQueueSeconds + ResultQueueSeconds;
|
||
Sample.GenerationSeconds = GenerationSeconds;
|
||
Sample.ClassifySeconds = Result.ClassifySeconds;
|
||
Sample.MeshSeconds = Result.MeshSeconds;
|
||
Sample.StreamSeconds = Result.StreamSeconds;
|
||
Sample.ApplySeconds = ApplySeconds;
|
||
VoxelForgeStartupTrace::RecordTile(Sample);
|
||
}
|
||
}
|
||
};
|
||
|
||
// CAPTURE-DURING-MESHING: hand the mesher's captured density grid to the clipmap BEFORE the
|
||
// empty-tile early-out — all-air / all-solid tiles are exactly the uniform cells the volume
|
||
// needs, and they carry a valid CaptureGrid even though they render nothing.
|
||
if (DensityVolume && Settings && Settings->bEnableDensityVolume
|
||
&& Result.CaptureGrid.Num() > 0)
|
||
{
|
||
DensityVolume->IngestTileCapture(Result.Tile.Coord, MoveTemp(Result.CaptureGrid));
|
||
}
|
||
|
||
// Empty mesh = no-surface tile — nothing to render, but still "loaded".
|
||
if (Result.bEmpty || !Result.Streams)
|
||
{
|
||
// An empty re-gen invalidates any previous body and any completion future for the old
|
||
// geometry. Empty tiles are loaded bookkeeping, never collision-ready.
|
||
CollisionReadyTiles.Remove(Result.Tile);
|
||
CollisionSuppressedUntilCook.Remove(Result.Tile);
|
||
CollisionSuppressedAtCycles.Remove(Result.Tile);
|
||
CollisionSolidTiles.Remove(Result.Tile);
|
||
if (Result.Tile.Level == 0)
|
||
{
|
||
CollisionNotRequiredTiles.Add(Result.Tile);
|
||
if (Result.ClassifyVerdict == static_cast<int32>(EVoxelTileClass::AllSolid))
|
||
{
|
||
CollisionSolidTiles.Add(Result.Tile);
|
||
}
|
||
}
|
||
PendingCollisionCooks.Remove(Result.Tile);
|
||
// Une RE-GEN (BandRemeshQueue / RemeshDirtyChunks) peut passer de "contenu" à "vide" :
|
||
// bande déplacée hors de la tuile, ou skip cellule-plus-haute-que-la-bande après un
|
||
// changement de strate (LoadTile). L'ancien composant doit tomber, sinon sa vieille
|
||
// géométrie (l'autre strate !) reste affichée. Première gen vide : Find rate, no-op.
|
||
const uint64 EmptyReleaseStartCycles = GVoxelForgeProfileTileGeneration != 0
|
||
? FPlatformTime::Cycles64() : 0;
|
||
bool bReleasedComponent = false;
|
||
if (URealtimeMeshComponent** OldComp = TileComponents.Find(Result.Tile))
|
||
{
|
||
if (*OldComp)
|
||
{
|
||
ReleaseTileComponent(*OldComp);
|
||
bReleasedComponent = true;
|
||
}
|
||
TileComponents.Remove(Result.Tile);
|
||
}
|
||
if (GVoxelForgeProfileTileGeneration != 0)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeApplyProfile] level=%d empty=1 component_release=%d total=%.6f"),
|
||
Result.Tile.Level,
|
||
bReleasedComponent ? 1 : 0,
|
||
FPlatformTime::ToSeconds64(
|
||
FPlatformTime::Cycles64() - EmptyReleaseStartCycles));
|
||
}
|
||
LoadedTiles.Add(Result.Tile);
|
||
AppliedModificationEpoch.Add(Result.Tile, Result.ModificationEpoch);
|
||
LoadedTileGeometry.Remove(Result.Tile);
|
||
if (FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this))
|
||
{
|
||
GapAudit->WitnessesByTile.Remove(Result.Tile);
|
||
}
|
||
LogTileApplied();
|
||
if (Result.Tile.Level == 0 && Result.bRecordStreamingLatency)
|
||
{
|
||
RecordLOD0ReadySample(Result.RequestStartCycles, Result.GenerationStartCycles,
|
||
Result.GenerationEndCycles, Result.ApplyStartCycles,
|
||
FPlatformTime::Cycles64(), 0);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Apply mesh (GPU upload). The vertex/index buffers were already built (T1.f, on the worker for
|
||
// the async path or inline for the sync carve path); the game thread only uploads them here.
|
||
const bool bApplied = ApplyMeshToTile(Result);
|
||
if (!bApplied)
|
||
{
|
||
// A component/mesh initialisation failure is recoverable. Do not poison LoadedTiles: the
|
||
// ordinary submit loop will retry this desired tile on a later frame.
|
||
CollisionReadyTiles.Remove(Result.Tile);
|
||
CollisionNotRequiredTiles.Remove(Result.Tile);
|
||
CollisionSolidTiles.Remove(Result.Tile);
|
||
PendingCollisionCooks.Remove(Result.Tile);
|
||
LoadedTiles.Remove(Result.Tile);
|
||
LoadedTileGeometry.Remove(Result.Tile);
|
||
if (FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this))
|
||
{
|
||
GapAudit->WitnessesByTile.Remove(Result.Tile);
|
||
}
|
||
bAllChunksLoaded = false;
|
||
LogTileApplied();
|
||
return false;
|
||
}
|
||
LoadedTiles.Add(Result.Tile);
|
||
AppliedModificationEpoch.Add(Result.Tile, Result.ModificationEpoch);
|
||
LoadedTileGeometry.Add(Result.Tile, FLoadedTileGeometryStats{
|
||
static_cast<uint64>(FMath::Max(0, Result.NumVertices)),
|
||
static_cast<uint64>(FMath::Max(0, Result.NumTriangles)),
|
||
Result.GeometryBytes});
|
||
if (FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this))
|
||
{
|
||
if (Result.SurfaceWitnesses.IsEmpty())
|
||
{
|
||
GapAudit->WitnessesByTile.Remove(Result.Tile);
|
||
}
|
||
else
|
||
{
|
||
GapAudit->WitnessesByTile.Add(Result.Tile, MoveTemp(Result.SurfaceWitnesses));
|
||
}
|
||
}
|
||
LogTileApplied();
|
||
return true;
|
||
}
|
||
|
||
// Same-frame level-0 re-mesh on the game thread (see header). Mirrors LoadTile's level-0 parameters
|
||
// (Cells = CHUNK_SIZE, Step = 1) + the strate content band; skips density-volume capture (the volume
|
||
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
|
||
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
|
||
{
|
||
if (!Generator || !Mesher || ShouldAbortWork()) return;
|
||
|
||
const bool bProfileModification = GVoxelForgeProfileTileGeneration != 0;
|
||
const uint64 SyncStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
|
||
const FIntVector OriginVoxels = Tile.OriginVoxels();
|
||
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
|
||
const int32 Step = 1; // Extent(=CHUNK_SIZE) / Cells
|
||
|
||
// STRATE CONTENT CUT — identical to LoadTile. The effective minimum is at least 1, so a
|
||
// level-0 re-mesh always remains a complete collision tile; only LOD1+ can receive the band.
|
||
int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX;
|
||
int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32;
|
||
const int32 CutMin = Settings ? Settings->GetEffectiveStrateContentCutMinLevel() : 9;
|
||
if (Tile.Level >= CutMin && MeshBandChunkLo != MIN_int32)
|
||
{
|
||
BandChunkLo = MeshBandChunkLo;
|
||
BandChunkHi = MeshBandChunkHi;
|
||
BandVoxLo = MeshBandChunkLo * CHUNK_SIZE;
|
||
BandVoxHi = (MeshBandChunkHi + 1) * CHUNK_SIZE - 1;
|
||
}
|
||
|
||
FChunkResult Result;
|
||
Result.DesiredEpoch = DesiredEpoch;
|
||
Result.ModificationEpoch = ModificationEpoch;
|
||
Result.bRecordStreamingLatency = false;
|
||
Result.bCaptureSurfaceWitnesses = GVoxelForgeVisualGapAudits.Contains(this);
|
||
Result.RequestStartCycles = bProfileModification
|
||
? FPlatformTime::Cycles64() : 0;
|
||
const uint64 GenerateStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
Result.GenerationStartCycles = GenerateStartCycles;
|
||
FVoxelSharedDensityGrid EditedPreEditGrid;
|
||
const FVoxelSharedDensityGrid* SharedDensityOverride = nullptr;
|
||
const FVoxelSharedDensityGrid* CachedPreEditGrid = PreEditDensityCache.Find(Tile);
|
||
const bool bCacheHit = CachedPreEditGrid != nullptr
|
||
&& VoxelForgePreEditDensityCache::BuildEditedGrid(
|
||
*CachedPreEditGrid, DiffLayer, EditedPreEditGrid);
|
||
if (bCacheHit)
|
||
{
|
||
SharedDensityOverride = &EditedPreEditGrid;
|
||
}
|
||
const bool bCapturePreEditDensityGrid = !bCacheHit
|
||
&& VoxelForgePreEditDensityCache::IsPawnRadiusTile(Tile, CurrentCenterChunk);
|
||
GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false,
|
||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||
Result, nullptr, bCapturePreEditDensityGrid, SharedDensityOverride);
|
||
Result.GenerationEndCycles = GenerateStartCycles != 0
|
||
? FPlatformTime::Cycles64() : 0;
|
||
|
||
const double GenerateSeconds = bProfileModification
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - GenerateStartCycles)
|
||
: 0.0;
|
||
const uint64 ApplyStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
const bool bResultEmpty = Result.bEmpty || !Result.Streams;
|
||
ApplyTileResult(Result);
|
||
if (bProfileModification)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeSyncRemeshProfile] tile=(%d,%d,%d) empty=%d generation=%.6f "
|
||
"apply=%.6f total=%.6f cache_hit=%d cache_tiles=%d cache_bytes=%llu"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z,
|
||
bResultEmpty ? 1 : 0,
|
||
GenerateSeconds,
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles),
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - SyncStartCycles),
|
||
bCacheHit ? 1 : 0,
|
||
PreEditDensityCache.Num(),
|
||
static_cast<unsigned long long>(PreEditDensityCache.Num()
|
||
* (CHUNK_SIZE + 3) * (CHUNK_SIZE + 3) * (CHUNK_SIZE + 3) * sizeof(float)));
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ProcessUnloadQueue()
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessUnload);
|
||
// Budgeted teardown: destroy at most a few tiles' components + content actors per frame, so a
|
||
// fast traversal's whole-shell cull (dozens of UnloadTile in one frame) doesn't spike the game
|
||
// thread. The budget auto-scales with the backlog (PendingUnload/4) so it never falls far behind.
|
||
if (PendingUnload.Num() == 0) return;
|
||
|
||
// Drain the floor budget, scaling up with the backlog so we never fall far behind, but capped
|
||
// at 4× the floor so a huge backlog (extreme speed) can't itself become a one-frame spike — the
|
||
// excess just lingers a few more frames (it's all behind the player, out of view).
|
||
const int32 Floor = Settings ? FMath::Max(1, Settings->MaxUnloadsPerFrame) : 6;
|
||
int32 DestroyBudget = FMath::Clamp(PendingUnload.Num() / 4, Floor, Floor * 4);
|
||
|
||
TArray<FVoxelTileKey> Dequeued; // removed from the queue this frame (destroyed OR cancelled)
|
||
for (const FVoxelTileKey& T : PendingUnload)
|
||
{
|
||
if (IsDesired(T))
|
||
{
|
||
// Re-desired before its turn (player reversed) — keep it; it's still loaded, just drop
|
||
// it from the queue. Doesn't count against the destroy budget.
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(T))
|
||
{
|
||
Comp->SetVisibility(!CollisionOnlyTiles.Contains(T));
|
||
}
|
||
Dequeued.Add(T);
|
||
continue;
|
||
}
|
||
UnloadTile(T);
|
||
Dequeued.Add(T);
|
||
if (--DestroyBudget <= 0) break;
|
||
}
|
||
for (const FVoxelTileKey& T : Dequeued) PendingUnload.Remove(T);
|
||
}
|
||
|
||
void AVoxelWorld::QueuePendingUnload(const FVoxelTileKey& Tile)
|
||
{
|
||
if (IsDesired(Tile))
|
||
{
|
||
return;
|
||
}
|
||
|
||
HideOverlappingDesiredVisuals(Tile);
|
||
PendingUnload.Add(Tile);
|
||
TransitionHold.Remove(Tile);
|
||
// Teardown remains budgeted in ProcessUnloadQueue. Hiding here is the cheap, same-frame visual
|
||
// handoff; collision is deliberately left untouched until UnloadTile drains the queue.
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile))
|
||
{
|
||
Comp->SetVisibility(false);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile)
|
||
{
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
if (Pair.Key == RetiredTile || !Pair.Value || !IsDesired(Pair.Key)
|
||
|| CollisionOnlyTiles.Contains(Pair.Key))
|
||
{
|
||
continue;
|
||
}
|
||
if (VF_TileFootprintsOverlap(RetiredTile, Pair.Key))
|
||
{
|
||
Pair.Value->SetVisibility(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ReconcileReadyTransitionVisibility()
|
||
{
|
||
const FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this);
|
||
const bool bUseLegacyVisualHandoff = GapAudit && GapAudit->bLegacyVisualHandoff;
|
||
const bool bMeasure = bMeasureReadyTransitionVisibility;
|
||
const uint64 MeasureStartCycles = bMeasure ? FPlatformTime::Cycles64() : 0;
|
||
const int32 ComponentCountAtStart = bMeasure ? TileComponents.Num() : 0;
|
||
const int32 DesiredCountAtStart = bMeasure ? DesiredSorted.Num() : 0;
|
||
const auto RecordMeasure = [this, bMeasure, MeasureStartCycles,
|
||
ComponentCountAtStart, DesiredCountAtStart]()
|
||
{
|
||
if (!bMeasure)
|
||
{
|
||
return;
|
||
}
|
||
FReadyTransitionVisibilitySample& Sample =
|
||
ReadyTransitionVisibilitySamples.AddDefaulted_GetRef();
|
||
Sample.Cycles = FPlatformTime::Cycles64() - MeasureStartCycles;
|
||
Sample.TileComponentCount = ComponentCountAtStart;
|
||
Sample.DesiredTileCount = DesiredCountAtStart;
|
||
};
|
||
|
||
if (bAllChunksLoaded && TransitionHold.Num() == 0 && PendingUnload.Num() == 0)
|
||
{
|
||
RecordMeasure();
|
||
return;
|
||
}
|
||
if (DesiredSorted.Num() == 0 || TileComponents.Num() == 0)
|
||
{
|
||
RecordMeasure();
|
||
return;
|
||
}
|
||
|
||
// This is a visibility-only pass. It runs while streaming is unsettled and after result
|
||
// application, so an old component is hidden in the same game-thread turn in which the full
|
||
// desired covering set becomes loaded. Destruction and collision removal stay in the budgeted
|
||
// queue below.
|
||
TArray<FVoxelTileKey> ReadyToHide;
|
||
ReadyToHide.Reserve(TileComponents.Num());
|
||
auto GetAncestorAtLevel = [](const FVoxelTileKey& Tile, int32 AncestorLevel)
|
||
{
|
||
const int32 Divisor = 1 << (AncestorLevel - Tile.Level);
|
||
return FVoxelTileKey(VoxelClipmapDesiredTiles::FloorDiv(Tile.Coord, Divisor), AncestorLevel);
|
||
};
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
const FVoxelTileKey& OldTile = Pair.Key;
|
||
if (!Pair.Value || IsDesired(OldTile) || PendingUnload.Contains(OldTile))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const bool bOldTileInClipRange = IsTileInClipRange(OldTile, CurrentCenterChunk);
|
||
if (bUseLegacyVisualHandoff && !bOldTileInClipRange)
|
||
{
|
||
ReadyToHide.Add(OldTile);
|
||
continue;
|
||
}
|
||
|
||
bool bHasReplacement = false;
|
||
bool bAllReplacementsLoaded = true;
|
||
// A coarser desired tile can only overlap this tile by containing it. Test those exact
|
||
// ancestors directly, then test desired finer tiles from the prebuilt ancestor index.
|
||
for (int32 Level = OldTile.Level + 1; Level <= DesiredTransitionMaxLevel; ++Level)
|
||
{
|
||
TArray<FVoxelTileKey, TInlineAllocator<8>> CandidateAncestors;
|
||
VF_AppendTouchingAncestors(OldTile, Level, CandidateAncestors);
|
||
for (const FVoxelTileKey& NewTile : CandidateAncestors)
|
||
{
|
||
if (!IsDesired(NewTile) || CollisionOnlyTiles.Contains(NewTile)) { continue; }
|
||
bHasReplacement = true;
|
||
if (!LoadedTiles.Contains(NewTile))
|
||
{
|
||
bAllReplacementsLoaded = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!bAllReplacementsLoaded) { break; }
|
||
}
|
||
if (bAllReplacementsLoaded)
|
||
{
|
||
if (const TArray<FVoxelTileKey>* NewDescendants = DesiredTransitionDescendants.Find(OldTile))
|
||
{
|
||
for (const FVoxelTileKey& NewTile : *NewDescendants)
|
||
{
|
||
if (CollisionOnlyTiles.Contains(NewTile)) { continue; }
|
||
bHasReplacement = true;
|
||
if (!LoadedTiles.Contains(NewTile))
|
||
{
|
||
bAllReplacementsLoaded = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if ((bHasReplacement && bAllReplacementsLoaded)
|
||
|| (!bUseLegacyVisualHandoff && !bOldTileInClipRange && !bHasReplacement))
|
||
{
|
||
// Even a tile outside the current shell may still be the only rendered cover for a
|
||
// surface that remains desired. Keep it visible until every overlapping replacement
|
||
// is loaded; cull immediately only when no desired tile overlaps it.
|
||
ReadyToHide.Add(OldTile);
|
||
}
|
||
}
|
||
|
||
for (const FVoxelTileKey& Tile : ReadyToHide)
|
||
{
|
||
QueuePendingUnload(Tile);
|
||
}
|
||
|
||
// Replacement components may have been uploaded hidden while a retired tile still covered
|
||
// only part of the transition. Reveal them only after every overlapping retired component has
|
||
// entered PendingUnload (and was therefore hidden above). Collision visibility is independent:
|
||
// the component remains queryable until the budgeted UnloadTile call.
|
||
TSet<FVoxelTileKey> VisibleRetiredTiles;
|
||
TSet<FVoxelTileKey> VisibleRetiredAncestors;
|
||
int32 MaxVisibleRetiredLevel = DesiredTransitionMaxLevel;
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& OldPair : TileComponents)
|
||
{
|
||
const FVoxelTileKey& OldTile = OldPair.Key;
|
||
URealtimeMeshComponent* OldComp = OldPair.Value;
|
||
if (IsDesired(OldTile) || PendingUnload.Contains(OldTile)
|
||
|| !OldComp || !OldComp->IsVisible())
|
||
{
|
||
continue;
|
||
}
|
||
VisibleRetiredTiles.Add(OldTile);
|
||
MaxVisibleRetiredLevel = FMath::Max(MaxVisibleRetiredLevel, OldTile.Level);
|
||
for (int32 Level = OldTile.Level + 1; Level <= DesiredTransitionMaxLevel; ++Level)
|
||
{
|
||
VisibleRetiredAncestors.Add(GetAncestorAtLevel(OldTile, Level));
|
||
}
|
||
}
|
||
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& NewPair : TileComponents)
|
||
{
|
||
const FVoxelTileKey& NewTile = NewPair.Key;
|
||
URealtimeMeshComponent* NewComp = NewPair.Value;
|
||
if (!NewComp || !IsDesired(NewTile) || CollisionOnlyTiles.Contains(NewTile))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bool bBlockedByVisibleRetiredTile = VisibleRetiredAncestors.Contains(NewTile);
|
||
for (int32 Level = NewTile.Level + 1;
|
||
!bBlockedByVisibleRetiredTile && Level <= MaxVisibleRetiredLevel; ++Level)
|
||
{
|
||
if (VisibleRetiredTiles.Contains(GetAncestorAtLevel(NewTile, Level)))
|
||
{
|
||
bBlockedByVisibleRetiredTile = true;
|
||
}
|
||
}
|
||
if (!bBlockedByVisibleRetiredTile)
|
||
{
|
||
NewComp->SetVisibility(true);
|
||
}
|
||
}
|
||
|
||
RecordMeasure();
|
||
}
|
||
|
||
// Integer floor-division (correct for negatives), scalar + vector.
|
||
static FORCEINLINE int32 VF_FloorDiv(int32 V, int32 D)
|
||
{
|
||
return V >= 0 ? (V / D) : -(((-V) + D - 1) / D);
|
||
}
|
||
static FORCEINLINE FIntVector VF_FloorDiv(const FIntVector& V, int32 D)
|
||
{
|
||
return FIntVector(VF_FloorDiv(V.X, D), VF_FloorDiv(V.Y, D), VF_FloorDiv(V.Z, D));
|
||
}
|
||
|
||
// The outer shell is the MaxClipLevel ring stretched to RenderDistanceChunks. Shared by desired
|
||
// selection and range culling so both use the same outer horizon.
|
||
static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, int32 MaxLevel,
|
||
int32& OutLevel, int32& OutRadius)
|
||
{
|
||
VoxelClipmapDesiredTiles::FParameters Parameters;
|
||
Parameters.RenderDistanceChunks = Settings ? Settings->RenderDistanceChunks : 0;
|
||
VoxelClipmapDesiredTiles::OuterShell(Parameters, R, MaxLevel, OutLevel, OutRadius);
|
||
}
|
||
|
||
void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition,
|
||
APawn* PlayerPawn, const FVector& PlayerHeading,
|
||
TArray<FVoxelTileKey>& OutLeavers)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles);
|
||
DesiredSorted.Reset();
|
||
CriticalDesiredTiles.Reset();
|
||
OutLeavers.Reset();
|
||
CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below
|
||
CollisionDesiredTiles.Reset();
|
||
++DesiredStamp; // les upserts ci-dessous marquent le crossing courant
|
||
++DesiredEpoch;
|
||
DesiredEpochStartCycles = FPlatformTime::Cycles64();
|
||
DesiredEpochCenterChunk = Center;
|
||
|
||
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
|
||
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
|
||
// Render-distance tiles expand horizontally.
|
||
// IsTileInClipRange shares the outer-shell calculation so culling sees this same horizon.
|
||
int32 StrateTopZ = 0, StrateBottomZ = 0;
|
||
bool bHasStrate = false;
|
||
if (StrateManager)
|
||
{
|
||
bHasStrate = StrateManager->GetStrateChunkZBounds(Center.Z, StrateTopZ, StrateBottomZ);
|
||
}
|
||
|
||
// Keep the clamp enabled, but cover the player's complete current strate plus its margin.
|
||
// Only when no strate bounds exist (the inter-strate gap) does the small player window apply.
|
||
int32 ZLo = MIN_int32, ZHi = MAX_int32;
|
||
VoxelClipmapDesiredTiles::ResolveVerticalBounds(
|
||
Center.Z,
|
||
Settings ? Settings->ViewDistanceUp : 5,
|
||
Settings ? Settings->ViewDistanceDown : 5,
|
||
Settings ? Settings->StrateViewMarginChunks : 3,
|
||
Settings && Settings->bClampViewToStrate,
|
||
bHasStrate, StrateTopZ, StrateBottomZ, ZLo, ZHi);
|
||
|
||
VoxelClipmapDesiredTiles::FParameters TileParameters;
|
||
TileParameters.ClipRadius = R;
|
||
TileParameters.MaxClipLevel = MaxLevel;
|
||
TileParameters.RenderDistanceChunks = Settings ? Settings->RenderDistanceChunks : 0;
|
||
VoxelClipmapDesiredTiles::Build(Center, ZLo, ZHi, TileParameters, DesiredSorted);
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
DesiredStamped.FindOrAdd(Key) = DesiredStamp;
|
||
}
|
||
|
||
const int32 PawnCollisionRadius = FMath::Clamp(
|
||
Settings ? Settings->PawnCollisionRadiusChunks : 1, 1, 4);
|
||
VoxelCollisionDesiredTiles::AddBox(
|
||
CollisionDesiredTiles, Center, PawnCollisionRadius,
|
||
PawnCollisionRadius, PawnCollisionRadius);
|
||
|
||
// Streaming anchors (AI / remote players, §9.3): fold each one's small level-0 box into the SAME
|
||
// desired set BEFORE the leaver sweep, so the delta cull releases an anchor's tiles automatically
|
||
// once it moves away or is unregistered. No-op (zero cost) when there are no anchors.
|
||
AddAnchorDesiredTiles();
|
||
|
||
// A pending support target and a few tiles below it are collision-only desired tiles. This
|
||
// keeps an out-of-ring request alive across this rebuild and lets the worker/apply queues fill
|
||
// the landing column while the pawn is still above it.
|
||
for (auto It = CollisionGateDesiredTiles.CreateIterator(); It; ++It)
|
||
{
|
||
const FVoxelTileKey& Key = *It;
|
||
const TCHAR* UnavailableReason = nullptr;
|
||
if (IsTileCollisionReady(Key) || CollisionNotRequiredTiles.Contains(Key)
|
||
|| IsCollisionTileUnavailable(Key, UnavailableReason))
|
||
{
|
||
It.RemoveCurrent();
|
||
continue;
|
||
}
|
||
|
||
uint32& Stamp = DesiredStamped.FindOrAdd(Key);
|
||
const bool bNew = Stamp != DesiredStamp;
|
||
if (bNew)
|
||
{
|
||
Stamp = DesiredStamp;
|
||
DesiredSorted.Add(Key);
|
||
CollisionOnlyTiles.Add(Key);
|
||
}
|
||
CollisionDesiredTiles.Add(Key);
|
||
}
|
||
|
||
if (GVoxelForgeProfileTileGeneration != 0)
|
||
{
|
||
int32 RenderLOD0Count = 0;
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
if (Key.Level == 0) { ++RenderLOD0Count; }
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionRadius] pawn_radius_chunks=%d collision_l0=%d "
|
||
"render_l0=%d anchors=%d gate_column=%d"),
|
||
PawnCollisionRadius, CollisionDesiredTiles.Num(), RenderLOD0Count,
|
||
StreamingAnchors.Num(), CollisionGateDesiredTiles.Num());
|
||
}
|
||
|
||
ReconcileCollisionDesiredTiles();
|
||
|
||
// Balayage UNIQUE de la map : les entrées à stamp périmé viennent de quitter le desired set —
|
||
// ce sont les seuls candidats au cull de ce crossing (avec la TransitionHold). On les retire
|
||
// ici même (RemoveCurrent est sûr en itérant), la map reste donc == desired set courant.
|
||
for (auto It = DesiredStamped.CreateIterator(); It; ++It)
|
||
{
|
||
if (It.Value() != DesiredStamp)
|
||
{
|
||
OutLeavers.Add(It.Key());
|
||
It.RemoveCurrent();
|
||
}
|
||
}
|
||
|
||
if (bStartupCollisionGateTimingCaptured
|
||
&& !bStartupCollisionGateAdoptionReported
|
||
&& PendingTiles.Contains(StartupCollisionGateSupportTile))
|
||
{
|
||
const bool bSupportAdopted = IsDesired(StartupCollisionGateSupportTile);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionGate] support_request_desired_reconcile tile=(%d,%d,%d) "
|
||
"adopted=%d desired_epoch=%u pending=%d"),
|
||
StartupCollisionGateSupportTile.Coord.X,
|
||
StartupCollisionGateSupportTile.Coord.Y,
|
||
StartupCollisionGateSupportTile.Coord.Z,
|
||
bSupportAdopted ? 1 : 0, DesiredEpoch,
|
||
PendingTiles.Contains(StartupCollisionGateSupportTile) ? 1 : 0);
|
||
bStartupCollisionGateAdoptionReported = true;
|
||
}
|
||
|
||
// Cancel work that no longer belongs to this desired set before scheduling replacements. A
|
||
// worker owns no mutable world state; it only observes its request token in GenerateTileResult.
|
||
CancelObsoleteTileWork();
|
||
|
||
// Nearest-first (by tile-centre distance to the TRUE pawn position), so a large tile's
|
||
// artificial chunk-centre approximation cannot put work behind the player. The explicit key
|
||
// tie-break keeps equal-distance ordering deterministic across runs.
|
||
const FVector PlayerVoxel = WorldToLocalCm(PlayerPosition) / VOXEL_SIZE;
|
||
DesiredSorted.Sort([&PlayerVoxel](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||
{
|
||
const FVector CA = A.CenterCm() / VOXEL_SIZE;
|
||
const FVector CB = B.CenterCm() / VOXEL_SIZE;
|
||
const float DA = FVector::DistSquared(CA, PlayerVoxel);
|
||
const float DB = FVector::DistSquared(CB, PlayerVoxel);
|
||
if (DA != DB) { return DA < DB; }
|
||
if (A.Level != B.Level) { return A.Level < B.Level; }
|
||
if (A.Coord.X != B.Coord.X) { return A.Coord.X < B.Coord.X; }
|
||
if (A.Coord.Y != B.Coord.Y) { return A.Coord.Y < B.Coord.Y; }
|
||
return A.Coord.Z < B.Coord.Z;
|
||
});
|
||
|
||
// Absolute floor priority. The support tile is the level-0 tile under the pawn's feet — the
|
||
// one that can catch a falling pawn — followed by the adjacent tile in the dominant movement
|
||
// direction. Keep the actor-centre tile as a fallback for capsule/offset edge cases. Only keys
|
||
// in the current desired set are promoted, so this never expands the streaming footprint.
|
||
const FVoxelTileKey PlayerTile(
|
||
WorldToChunkCoord(WorldToLocalCm(PlayerPosition)), 0);
|
||
FVoxelTileKey OccupiedTile = PlayerTile;
|
||
FVoxelTileKey SupportTile;
|
||
if (GetPlayerSupportTile(PlayerPawn, PlayerPosition, SupportTile))
|
||
{
|
||
OccupiedTile = SupportTile;
|
||
}
|
||
|
||
auto AddCriticalTile = [this](const FVoxelTileKey& Key)
|
||
{
|
||
if (IsDesired(Key) && !CriticalDesiredTiles.Contains(Key))
|
||
{
|
||
CriticalDesiredTiles.Add(Key);
|
||
}
|
||
};
|
||
if (CollisionGateDesiredTiles.Contains(PawnGateRequestedSupportTile))
|
||
{
|
||
AddCriticalTile(PawnGateRequestedSupportTile);
|
||
}
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
if (CollisionGateDesiredTiles.Contains(Key))
|
||
{
|
||
AddCriticalTile(Key);
|
||
}
|
||
}
|
||
AddCriticalTile(OccupiedTile);
|
||
|
||
FVector Heading = PlayerHeading;
|
||
if (Heading.IsNearlyZero() && IsValid(PlayerPawn))
|
||
{
|
||
Heading = PlayerPawn->GetActorForwardVector();
|
||
}
|
||
Heading = GetActorTransform().InverseTransformVectorNoScale(Heading);
|
||
const float AbsX = FMath::Abs(Heading.X);
|
||
const float AbsY = FMath::Abs(Heading.Y);
|
||
const float AbsZ = FMath::Abs(Heading.Z);
|
||
FIntVector HeadingStep = FIntVector::ZeroValue;
|
||
if (AbsX > KINDA_SMALL_NUMBER || AbsY > KINDA_SMALL_NUMBER || AbsZ > KINDA_SMALL_NUMBER)
|
||
{
|
||
if (AbsX >= AbsY && AbsX >= AbsZ)
|
||
{
|
||
HeadingStep.X = Heading.X >= 0.0f ? 1 : -1;
|
||
}
|
||
else if (AbsY >= AbsZ)
|
||
{
|
||
HeadingStep.Y = Heading.Y >= 0.0f ? 1 : -1;
|
||
}
|
||
else
|
||
{
|
||
HeadingStep.Z = Heading.Z >= 0.0f ? 1 : -1;
|
||
}
|
||
}
|
||
if (HeadingStep != FIntVector::ZeroValue)
|
||
{
|
||
FVoxelTileKey NextTile = OccupiedTile;
|
||
NextTile.Coord += HeadingStep;
|
||
AddCriticalTile(NextTile);
|
||
}
|
||
AddCriticalTile(PlayerTile);
|
||
|
||
// Insert in reverse so CriticalDesiredTiles[0] remains the occupied floor, [1] the heading
|
||
// tile, and all ordinary distance-sorted work follows both.
|
||
for (int32 Index = CriticalDesiredTiles.Num() - 1; Index >= 0; --Index)
|
||
{
|
||
const int32 ExistingIndex = DesiredSorted.Find(CriticalDesiredTiles[Index]);
|
||
if (ExistingIndex > 0)
|
||
{
|
||
const FVoxelTileKey Key = DesiredSorted[ExistingIndex];
|
||
DesiredSorted.RemoveAt(ExistingIndex, 1, EAllowShrinking::No);
|
||
DesiredSorted.Insert(Key, 0);
|
||
}
|
||
}
|
||
|
||
// Build a dyadic ancestor index once per desired-set epoch. Different-level tile overlap is
|
||
// containment because every tile footprint is power-of-two aligned. This lets the per-frame
|
||
// transition pass inspect only actual overlapping candidates.
|
||
DesiredTransitionDescendants.Reset();
|
||
DesiredTransitionMaxLevel = 0;
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
DesiredTransitionMaxLevel = FMath::Max(DesiredTransitionMaxLevel, Key.Level);
|
||
}
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
DesiredTransitionMaxLevel = FMath::Max(DesiredTransitionMaxLevel, Pair.Key.Level);
|
||
}
|
||
// A worker from the previous desired epoch can finish late with a component at a level no
|
||
// longer present in TileComponents when this index is built. Index through the highest
|
||
// possible tile level (MaxClipLevel is clamped to 8) so that such a retired tile still
|
||
// finds all of its desired descendants when the result is reconciled.
|
||
constexpr int32 MaxSupportedTileLevel = 8;
|
||
const int32 DescendantIndexMaxLevel = FMath::Max(
|
||
DesiredTransitionMaxLevel, MaxSupportedTileLevel);
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
for (int32 Level = Key.Level + 1; Level <= DescendantIndexMaxLevel; ++Level)
|
||
{
|
||
TArray<FVoxelTileKey, TInlineAllocator<8>> TouchingAncestors;
|
||
VF_AppendTouchingAncestors(Key, Level, TouchingAncestors);
|
||
for (const FVoxelTileKey& Ancestor : TouchingAncestors)
|
||
{
|
||
DesiredTransitionDescendants.FindOrAdd(Ancestor).Add(Key);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (VoxelForgeStartupTrace::IsActive())
|
||
{
|
||
int32 LOD0Count = 0;
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
if (Key.Level == 0) ++LOD0Count;
|
||
}
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("lod0_ring_begin"), FString::Printf(
|
||
TEXT("\"desired_epoch\":%u,\"center_chunk\":[%d,%d,%d],\"lod0_tiles\":%d"),
|
||
DesiredEpoch, Center.X, Center.Y, Center.Z, LOD0Count));
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::CancelObsoleteTileWork()
|
||
{
|
||
for (const FVoxelTileKey& Tile : PendingTiles)
|
||
{
|
||
if (IsDesired(Tile))
|
||
{
|
||
continue;
|
||
}
|
||
if (TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>* Cancellation =
|
||
PendingTileCancellation.Find(Tile))
|
||
{
|
||
(*Cancellation)->bObsolete.store(true, std::memory_order_release);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fold every registered anchor's small level-0 box into the current desired set (§9.3). Runs inside
|
||
// BuildDesiredTiles after the player clipmap, keyed on the same DesiredStamp so the leaver
|
||
// sweep + delta cull handle anchor tiles leaving. Level-0 only (collision lives on level-0 tiles); empty
|
||
// tiles in the box are ~free (the trivial-tile reject skips gen). Dedup vs the player clipmap by stamp.
|
||
// §9.4: a tile the player clipmap did NOT stamp (bNew) that only a CollisionOnly anchor wants goes into
|
||
// CollisionOnlyTiles → hidden at apply. A FullVisual anchor (or the clipmap) forces it rendered.
|
||
void AVoxelWorld::AddAnchorDesiredTiles()
|
||
{
|
||
for (const FVoxelStreamingAnchor& Anchor : StreamingAnchors)
|
||
{
|
||
if (!Anchor.Actor.IsValid()) continue; // dead ptr — pruned in UpdateChunksAroundPosition
|
||
const FIntVector AC = Anchor.LastChunk; // set this Tick by the move-detection pass
|
||
const int32 RXY = FMath::Clamp(Anchor.XYRadiusChunks, 0, 4); // guard the box small
|
||
const int32 RZLo = FMath::Clamp(Anchor.ZBelowChunks, 0, 4);
|
||
const int32 RZHi = FMath::Clamp(Anchor.ZAboveChunks, 0, 4);
|
||
const bool bColl = (Anchor.Policy == EVoxelAnchorPolicy::CollisionOnly);
|
||
VoxelCollisionDesiredTiles::AddBox(CollisionDesiredTiles, AC, RXY, RZLo, RZHi);
|
||
for (int32 dz = -RZLo; dz <= RZHi; ++dz)
|
||
for (int32 dy = -RXY; dy <= RXY; ++dy)
|
||
for (int32 dx = -RXY; dx <= RXY; ++dx)
|
||
{
|
||
const FVoxelTileKey Key(AC + FIntVector(dx, dy, dz), 0);
|
||
uint32& S = DesiredStamped.FindOrAdd(Key);
|
||
const bool bNew = (S != DesiredStamp); // false ⇒ already desired (clipmap / earlier anchor)
|
||
if (bNew)
|
||
{
|
||
S = DesiredStamp;
|
||
DesiredSorted.Add(Key);
|
||
}
|
||
if (bColl)
|
||
{
|
||
// Collision-only only if NOTHING full-visual claimed this exact level-0 tile this
|
||
// crossing (bNew). If the clipmap or a FullVisual anchor stamped it first, leave it rendered.
|
||
if (bNew) { CollisionOnlyTiles.Add(Key); }
|
||
}
|
||
else
|
||
{
|
||
CollisionOnlyTiles.Remove(Key); // FullVisual anchor → force rendered (undo a prior coll mark)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ReconcileCollisionDesiredTiles()
|
||
{
|
||
// A radius/anchor change takes effect on the same desired-set rebuild. Disable old far bodies
|
||
// immediately; a late cook callback performs the same check before it can restore readiness.
|
||
for (auto It = CollisionReadyTiles.CreateIterator(); It; ++It)
|
||
{
|
||
const FVoxelTileKey& Key = *It;
|
||
if (IsTileCollisionDesired(Key))
|
||
{
|
||
continue;
|
||
}
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key))
|
||
{
|
||
Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
}
|
||
It.RemoveCurrent();
|
||
}
|
||
|
||
// Tiles loaded for rendering before entering the collision radius need a collision remesh.
|
||
// Proven uniform tiles have no surface/body and stay on the cheap resolved path.
|
||
for (const FVoxelTileKey& Key : CollisionDesiredTiles)
|
||
{
|
||
if (!LoadedTiles.Contains(Key) || CollisionReadyTiles.Contains(Key)
|
||
|| CollisionNotRequiredTiles.Contains(Key) || PendingTiles.Contains(Key)
|
||
|| PendingCollisionCooks.Contains(Key))
|
||
{
|
||
continue;
|
||
}
|
||
DirtyRemeshQueue.Add(Key);
|
||
bAllChunksLoaded = false;
|
||
}
|
||
}
|
||
|
||
// §9.4 — apply visibility flips to ALREADY-LOADED tiles when a tile changed render↔collision-only this
|
||
// crossing (player walked toward/away from a CollisionOnly cluster). Bounded by the collision-only set
|
||
// (small); a no-op when there are no CollisionOnly anchors. Newly-loaded tiles get their state at apply
|
||
// (ApplyMeshToTile reads CollisionOnlyTiles). Called after BuildDesiredTiles rebuilt CollisionOnlyTiles.
|
||
void AVoxelWorld::ReconcileAnchorTileVisibility()
|
||
{
|
||
if (CollisionOnlyTiles.Num() == 0 && PrevCollisionOnlyTiles.Num() == 0) return; // fast path
|
||
|
||
// Became collision-only → hide (if loaded).
|
||
for (const FVoxelTileKey& Key : CollisionOnlyTiles)
|
||
{
|
||
if (!PrevCollisionOnlyTiles.Contains(Key))
|
||
{
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) { Comp->SetVisibility(false); }
|
||
}
|
||
}
|
||
// Stopped being collision-only → show, but only if still desired (else it's a leaver being culled —
|
||
// don't flash it visible on its way out).
|
||
for (const FVoxelTileKey& Key : PrevCollisionOnlyTiles)
|
||
{
|
||
if (!CollisionOnlyTiles.Contains(Key) && IsDesired(Key))
|
||
{
|
||
if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) { Comp->SetVisibility(true); }
|
||
}
|
||
}
|
||
PrevCollisionOnlyTiles = CollisionOnlyTiles;
|
||
}
|
||
|
||
void AVoxelWorld::RegisterStreamingAnchor(AActor* Actor, EVoxelAnchorPolicy Policy,
|
||
int32 XYRadiusChunks, int32 ZBelowChunks, int32 ZAboveChunks)
|
||
{
|
||
if (!Actor) return;
|
||
for (FVoxelStreamingAnchor& Existing : StreamingAnchors)
|
||
{
|
||
if (Existing.Actor.Get() == Actor) // already registered → update policy/box in place
|
||
{
|
||
Existing.Policy = Policy;
|
||
Existing.XYRadiusChunks = XYRadiusChunks;
|
||
Existing.ZBelowChunks = ZBelowChunks;
|
||
Existing.ZAboveChunks = ZAboveChunks;
|
||
bForceDesiredRebuild = true;
|
||
return;
|
||
}
|
||
}
|
||
FVoxelStreamingAnchor A;
|
||
A.Actor = Actor;
|
||
A.Policy = Policy;
|
||
A.XYRadiusChunks = XYRadiusChunks;
|
||
A.ZBelowChunks = ZBelowChunks;
|
||
A.ZAboveChunks = ZAboveChunks;
|
||
// LastChunk stays at its sentinel → next Tick's move detection sets it + triggers the rebuild.
|
||
StreamingAnchors.Add(A);
|
||
bForceDesiredRebuild = true;
|
||
}
|
||
|
||
void AVoxelWorld::UnregisterStreamingAnchor(AActor* Actor)
|
||
{
|
||
if (!Actor) return;
|
||
for (int32 i = StreamingAnchors.Num() - 1; i >= 0; --i)
|
||
{
|
||
if (StreamingAnchors[i].Actor.Get() == Actor)
|
||
{
|
||
StreamingAnchors.RemoveAtSwap(i);
|
||
bForceDesiredRebuild = true; // rebuild next Tick so its now-unwanted tiles become leavers
|
||
}
|
||
}
|
||
}
|
||
|
||
bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& Center) const
|
||
{
|
||
// In range = the tile's centre falls within the OUTERMOST shell (VF_OuterShell — the
|
||
// render-distance level+radius, same as BuildDesiredTiles). A loaded-but-not-desired tile can
|
||
// still cover a desired surface outside this shell, so the caller checks replacement readiness
|
||
// before hiding it in either case.
|
||
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
|
||
const int32 MaxLevelBase = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||
int32 MaxLevel = MaxLevelBase, ROuter = R;
|
||
VF_OuterShell(Settings, R, MaxLevelBase, MaxLevel, ROuter);
|
||
const int32 PowMax = 1 << MaxLevel;
|
||
const FIntVector CMax = VF_FloorDiv(Center, PowMax);
|
||
|
||
const FVector CV = Tile.CenterCm() / VOXEL_SIZE; // tile centre in voxels
|
||
const int32 SizeMax = CHUNK_SIZE * PowMax;
|
||
const FIntVector TMax(
|
||
VF_FloorDiv(FMath::FloorToInt(CV.X), SizeMax),
|
||
VF_FloorDiv(FMath::FloorToInt(CV.Y), SizeMax),
|
||
VF_FloorDiv(FMath::FloorToInt(CV.Z), SizeMax));
|
||
|
||
return FMath::Abs(TMax.X - CMax.X) <= ROuter
|
||
&& FMath::Abs(TMax.Y - CMax.Y) <= ROuter
|
||
&& FMath::Abs(TMax.Z - CMax.Z) <= ROuter;
|
||
}
|
||
|
||
int32 AVoxelWorld::GetMaxConcurrentTasks() const
|
||
{
|
||
// T2.d — both sides of the cap are authored in Voxel|Streaming. BackgroundNormal priority
|
||
// (see LoadTile) already stops gen from starving the frame; this cap stops a flat task count
|
||
// from thrashing context switches on small CPUs where it exceeds spare parallelism.
|
||
const int32 Asset = Settings ? Settings->MaxConcurrentTasks : 16;
|
||
const int32 LogicalCores = FMath::Max(
|
||
1, FPlatformMisc::NumberOfCoresIncludingHyperthreads());
|
||
const int32 AutomaticCap = FMath::Max(2, LogicalCores - 2);
|
||
const int32 AuthoredCoreCap = Settings ? Settings->MaxGenerationWorkerCores : 0;
|
||
const int32 CoreCap = AuthoredCoreCap > 0
|
||
? FMath::Clamp(AuthoredCoreCap, 1, LogicalCores)
|
||
: AutomaticCap;
|
||
return FMath::Clamp(Asset, 1, CoreCap);
|
||
}
|
||
|
||
int32 AVoxelWorld::GetNearTaskReserve(int32 MaxTasks) const
|
||
{
|
||
const int32 AuthoredReserve = Settings ? Settings->NearTaskReserve : 4;
|
||
// Keep at least one slot usable by coarse work even when an authored reserve is larger than
|
||
// the effective task budget. A one-slot budget has no meaningful reservation to make.
|
||
return FMath::Clamp(AuthoredReserve, 0, FMath::Max(0, MaxTasks - 1));
|
||
}
|
||
|
||
void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APawn* PlayerPawn,
|
||
const FVector& PlayerHeading)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
|
||
const int32 MaxTasks = GetMaxConcurrentTasks();
|
||
const int32 NearTaskReserve = GetNearTaskReserve(MaxTasks);
|
||
const int32 CoarseTaskCap = FMath::Max(1, MaxTasks - NearTaskReserve);
|
||
if (GVoxelForgeProfileTileGeneration != 0
|
||
&& !GVoxelForgeStreamingBudgetReported)
|
||
{
|
||
const int32 LogicalCores = FMath::Max(
|
||
1, FPlatformMisc::NumberOfCoresIncludingHyperthreads());
|
||
const int32 AutomaticCap = FMath::Max(2, LogicalCores - 2);
|
||
const int32 AssetCap = Settings ? Settings->MaxConcurrentTasks : 16;
|
||
const int32 AuthoredCoreCap = Settings ? Settings->MaxGenerationWorkerCores : 0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingBudget] logical_cores=%d automatic_core_cap=%d "
|
||
"authored_core_cap=%d asset_task_cap=%d effective_generation_cap=%d "
|
||
"near_task_reserve=%d coarse_task_cap=%d "
|
||
"max_mesh_applies=%d max_apply_ms=%.3f max_unloads=%d"),
|
||
LogicalCores,
|
||
AutomaticCap,
|
||
AuthoredCoreCap,
|
||
AssetCap,
|
||
MaxTasks,
|
||
NearTaskReserve,
|
||
CoarseTaskCap,
|
||
Settings ? Settings->MaxMeshAppliesPerFrame : 4,
|
||
Settings ? Settings->MaxMeshApplyMilliseconds : 2.0f,
|
||
Settings ? Settings->MaxUnloadsPerFrame : 6);
|
||
GVoxelForgeStreamingBudgetReported = true;
|
||
}
|
||
|
||
const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile
|
||
CurrentCenterChunk = CenterChunk;
|
||
for (auto It = PreEditDensityCache.CreateIterator(); It; ++It)
|
||
{
|
||
if (!VoxelForgePreEditDensityCache::IsPawnRadiusTile(It.Key(), CenterChunk))
|
||
{
|
||
It.RemoveCurrent();
|
||
}
|
||
}
|
||
|
||
// Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the
|
||
// desired set rebuilds when an anchor moves (its box of collision tiles follows it). Cheap: a few
|
||
// WorldToChunkCoord per anchor, and empty (no anchors) is a zero-iteration loop.
|
||
bool bAnchorsMoved = false;
|
||
for (int32 i = StreamingAnchors.Num() - 1; i >= 0; --i)
|
||
{
|
||
AActor* A = StreamingAnchors[i].Actor.Get();
|
||
if (!A)
|
||
{
|
||
StreamingAnchors.RemoveAtSwap(i); // destroyed → drop it; its tiles must be culled
|
||
bAnchorsMoved = true;
|
||
continue;
|
||
}
|
||
const FIntVector AC = WorldToChunkCoord(WorldToLocalCm(A->GetActorLocation()));
|
||
if (AC != StreamingAnchors[i].LastChunk)
|
||
{
|
||
StreamingAnchors[i].LastChunk = AC;
|
||
bAnchorsMoved = true;
|
||
}
|
||
}
|
||
|
||
//=========================================================================
|
||
// Rebuild the desired tile set when the player crosses a level-0 tile boundary — or when a
|
||
// streaming anchor moved / (un)registered (bAnchorsMoved / bForceDesiredRebuild).
|
||
//=========================================================================
|
||
if (CenterChunk != LastUpdateCenter || bAnchorsMoved || bForceDesiredRebuild)
|
||
{
|
||
LastUpdateCenter = CenterChunk;
|
||
bAllChunksLoaded = false;
|
||
bForceDesiredRebuild = false; // consumed
|
||
|
||
// DELTA CULL: BuildDesiredTiles returns the LEAVERS (desired on the previous crossing, but
|
||
// not now). Only those leavers + TransitionHold (retained across prior crossings) enter
|
||
// the cull. Pending work that leaves desired is marked obsolete before replacement submit;
|
||
// its aborted result is drained and dropped, while the settled cull remains the full-scan
|
||
// safety net.
|
||
TArray<FVoxelTileKey> Leavers;
|
||
const bool bTraceFirstDesired = VoxelForgeStartupTrace::IsActive()
|
||
&& !bStartupTraceDesiredRecorded;
|
||
if (bTraceFirstDesired)
|
||
{
|
||
VoxelForgeStartupTrace::StageBegin(TEXT("BuildDesiredTiles.first"));
|
||
}
|
||
BuildDesiredTiles(CenterChunk, CenterPosition, PlayerPawn, PlayerHeading, Leavers);
|
||
if (bTraceFirstDesired)
|
||
{
|
||
VoxelForgeStartupTrace::StageEnd(TEXT("BuildDesiredTiles.first"));
|
||
|
||
struct FDesiredLevelSummary
|
||
{
|
||
int32 Level = -1;
|
||
int32 Count = 0;
|
||
FIntVector Min = FIntVector(MAX_int32, MAX_int32, MAX_int32);
|
||
FIntVector Max = FIntVector(MIN_int32, MIN_int32, MIN_int32);
|
||
};
|
||
TArray<FDesiredLevelSummary> LevelSummaries;
|
||
for (const FVoxelTileKey& Key : DesiredSorted)
|
||
{
|
||
int32 SummaryIndex = INDEX_NONE;
|
||
for (int32 Index = 0; Index < LevelSummaries.Num(); ++Index)
|
||
{
|
||
if (LevelSummaries[Index].Level == Key.Level)
|
||
{
|
||
SummaryIndex = Index;
|
||
break;
|
||
}
|
||
}
|
||
if (SummaryIndex == INDEX_NONE)
|
||
{
|
||
SummaryIndex = LevelSummaries.AddDefaulted();
|
||
LevelSummaries[SummaryIndex].Level = Key.Level;
|
||
}
|
||
FDesiredLevelSummary& Summary = LevelSummaries[SummaryIndex];
|
||
++Summary.Count;
|
||
Summary.Min.X = FMath::Min(Summary.Min.X, Key.Coord.X);
|
||
Summary.Min.Y = FMath::Min(Summary.Min.Y, Key.Coord.Y);
|
||
Summary.Min.Z = FMath::Min(Summary.Min.Z, Key.Coord.Z);
|
||
Summary.Max.X = FMath::Max(Summary.Max.X, Key.Coord.X);
|
||
Summary.Max.Y = FMath::Max(Summary.Max.Y, Key.Coord.Y);
|
||
Summary.Max.Z = FMath::Max(Summary.Max.Z, Key.Coord.Z);
|
||
}
|
||
LevelSummaries.Sort([](const FDesiredLevelSummary& A, const FDesiredLevelSummary& B)
|
||
{
|
||
return A.Level < B.Level;
|
||
});
|
||
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("desired_set"), FString::Printf(
|
||
TEXT("\"center_chunk\":[%d,%d,%d],\"total\":%d,\"levels\":%d,\"leavers\":%d,"
|
||
"\"collision_only\":%d"),
|
||
CenterChunk.X, CenterChunk.Y, CenterChunk.Z, DesiredSorted.Num(),
|
||
LevelSummaries.Num(), Leavers.Num(), CollisionOnlyTiles.Num()));
|
||
for (const FDesiredLevelSummary& Summary : LevelSummaries)
|
||
{
|
||
const int32 ExtentVoxels = CHUNK_SIZE << Summary.Level;
|
||
const double TileExtentMetres = static_cast<double>(ExtentVoxels) * VOXEL_SIZE / 100.0;
|
||
const double SpanX = static_cast<double>(Summary.Max.X - Summary.Min.X + 1) * TileExtentMetres;
|
||
const double SpanY = static_cast<double>(Summary.Max.Y - Summary.Min.Y + 1) * TileExtentMetres;
|
||
const double SpanZ = static_cast<double>(Summary.Max.Z - Summary.Min.Z + 1) * TileExtentMetres;
|
||
VoxelForgeStartupTrace::RecordEvent(TEXT("desired_level"), FString::Printf(
|
||
TEXT("\"level\":%d,\"tiles\":%d,\"min_coord\":[%d,%d,%d],\"max_coord\":[%d,%d,%d],"
|
||
"\"tile_extent_m\":%s,\"coverage_extent_m\":[%s,%s,%s]"),
|
||
Summary.Level, Summary.Count,
|
||
Summary.Min.X, Summary.Min.Y, Summary.Min.Z,
|
||
Summary.Max.X, Summary.Max.Y, Summary.Max.Z,
|
||
*FString::SanitizeFloat(TileExtentMetres),
|
||
*FString::SanitizeFloat(SpanX), *FString::SanitizeFloat(SpanY),
|
||
*FString::SanitizeFloat(SpanZ)));
|
||
}
|
||
bStartupTraceDesiredRecorded = true;
|
||
}
|
||
|
||
// §9.4 — toggle visibility on already-loaded tiles that flipped render↔collision-only this
|
||
// crossing (a CollisionOnly cluster the player just walked toward/away from). No-op w/o anchors.
|
||
ReconcileAnchorTileVisibility();
|
||
|
||
// STRATE CONTENT CUT — the band coarse tiles are meshed to = the player strate's EXACT
|
||
// chunk-Z bounds (no margin: that's the view clamp's job — selection vs content). In the
|
||
// inter-strate gap: no band (full tiles, you can see both sides through the descent).
|
||
// On band change (strate transition), re-queue the loaded coarse tiles whose mesh depends
|
||
// on it — fully inside BOTH bands ⇒ identical either way; fully outside both ⇒ empty
|
||
// either way; everything else re-gens in place via BandRemeshQueue (no visual pop).
|
||
{
|
||
const int32 CutMin = Settings ? Settings->GetEffectiveStrateContentCutMinLevel() : 9;
|
||
int32 NewLo = MIN_int32, NewHi = MAX_int32;
|
||
if (CutMin <= 8 && StrateManager)
|
||
{
|
||
int32 StrTopZ = 0, StrBotZ = 0;
|
||
if (StrateManager->GetStrateChunkZBounds(CenterChunk.Z, StrTopZ, StrBotZ))
|
||
{
|
||
NewLo = StrBotZ;
|
||
NewHi = StrTopZ;
|
||
}
|
||
}
|
||
if (NewLo != MeshBandChunkLo || NewHi != MeshBandChunkHi)
|
||
{
|
||
// Diagnostic volontairement VISIBLE : si cette ligne n'apparaît JAMAIS dans
|
||
// l'Output Log, la coupe de contenu ne s'est jamais armée (bounds de strate
|
||
// introuvables pour la position du PION → tout se maille plein, comme avant).
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelWorld] Strate content band -> chunks [%d..%d] (was [%d..%d]), CutMinLevel=%d, pawn chunk Z=%d"),
|
||
NewLo, NewHi, MeshBandChunkLo, MeshBandChunkHi, CutMin, CenterChunk.Z);
|
||
for (const FVoxelTileKey& T : LoadedTiles)
|
||
{
|
||
if (T.Level < CutMin) continue;
|
||
const int32 CLo = T.Coord.Z << T.Level;
|
||
const int32 CHi = ((T.Coord.Z + 1) << T.Level) - 1;
|
||
const bool bInOld = CLo >= MeshBandChunkLo && CHi <= MeshBandChunkHi;
|
||
const bool bInNew = CLo >= NewLo && CHi <= NewHi;
|
||
const bool bOutOld = CHi < MeshBandChunkLo || CLo > MeshBandChunkHi;
|
||
const bool bOutNew = CHi < NewLo || CLo > NewHi;
|
||
if ((bInOld && bInNew) || (bOutOld && bOutNew)) continue; // même contenu
|
||
if (!IsDesired(T)) continue; // sera cull, pas re-gen
|
||
BandRemeshQueue.Add(T);
|
||
}
|
||
MeshBandChunkLo = NewLo;
|
||
MeshBandChunkHi = NewHi;
|
||
bAllChunksLoaded = false; // le drain de BandRemeshQueue vit dans le bloc submit
|
||
}
|
||
}
|
||
|
||
// Cull rules — STRICT LOAD-BEFORE-UNLOAD so crossing a shell boundary NEVER leaves a hole:
|
||
// cull only after EVERY desired tile that overlaps the retiring footprint is loaded, whether
|
||
// the old tile is still inside the shell or has crossed the outer clip range. Otherwise it
|
||
// stays in TransitionHold until the covering set is ready.
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_CullTiles);
|
||
|
||
auto FootprintsOverlap = [](const FVoxelTileKey& A, const FVoxelTileKey& B) -> bool
|
||
{
|
||
const int32 ea = A.ExtentVoxels(), eb = B.ExtentVoxels();
|
||
const FIntVector aMin = A.OriginVoxels(), bMin = B.OriginVoxels();
|
||
return aMin.X <= bMin.X + eb && bMin.X <= aMin.X + ea
|
||
&& aMin.Y <= bMin.Y + eb && bMin.Y <= aMin.Y + ea
|
||
&& aMin.Z <= bMin.Z + eb && bMin.Z <= aMin.Z + ea;
|
||
};
|
||
// "Every covering desired tile loaded" ⟺ "no unloaded desired tile overlaps T".
|
||
// Built LAZILY: only needed if an in-range transition candidate actually exists.
|
||
TArray<FVoxelTileKey> DesiredPending;
|
||
bool bPendingBuilt = false;
|
||
auto EnsurePending = [&]()
|
||
{
|
||
if (bPendingBuilt) return;
|
||
bPendingBuilt = true;
|
||
for (const FVoxelTileKey& D : DesiredSorted)
|
||
{
|
||
if (!LoadedTiles.Contains(D)) DesiredPending.Add(D);
|
||
}
|
||
};
|
||
auto ReplacementsReady = [&](const FVoxelTileKey& T) -> bool
|
||
{
|
||
for (const FVoxelTileKey& D : DesiredPending)
|
||
{
|
||
if (CollisionOnlyTiles.Contains(D)) { continue; }
|
||
if (FootprintsOverlap(T, D)) return false; // a covering tile isn't ready → keep T
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// true = la tuile doit être RETENUE (transition en attente de ses remplaçants) ;
|
||
// false = rien à retenir (cullée, re-désirée, ou jamais chargée).
|
||
auto NeedsHold = [&](const FVoxelTileKey& T) -> bool
|
||
{
|
||
if (IsDesired(T)) { return false; } // re-désirée
|
||
if (!LoadedTiles.Contains(T) && !TileComponents.Contains(T))
|
||
{
|
||
return false; // jamais chargée / rien d'appliqué → rien à cull
|
||
}
|
||
const FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this);
|
||
if (GapAudit && GapAudit->bLegacyVisualHandoff
|
||
&& !IsTileInClipRange(T, CurrentCenterChunk))
|
||
{
|
||
QueuePendingUnload(T);
|
||
return false;
|
||
}
|
||
// Quand le backlog est gros (sprint), sauter le test de recouvrement et RETENIR
|
||
// est la direction hole-safe, y compris juste au-delà du shell extérieur.
|
||
EnsurePending();
|
||
if (DesiredPending.Num() <= 48 && ReplacementsReady(T))
|
||
{
|
||
QueuePendingUnload(T);
|
||
return false;
|
||
}
|
||
HideOverlappingDesiredVisuals(T);
|
||
return true;
|
||
};
|
||
|
||
for (const FVoxelTileKey& T : Leavers)
|
||
{
|
||
if (NeedsHold(T)) { AddToTransitionHold(T); }
|
||
}
|
||
|
||
// Hold : re-évaluation à BUDGET tournant. Re-scanner toute la hold par crossing
|
||
// redevient le vieux scan O(loaded) dès que le streaming ne settle jamais (mesuré
|
||
// 2.47 ms/crossing packagé) ; retenir plus longtemps est hole-safe, chaque tuile
|
||
// repasse sous le curseur en quelques crossings.
|
||
int32 HoldBudget = FMath::Min(TransitionHoldQueue.Num(), 256);
|
||
while (HoldBudget > 0 && TransitionHoldQueue.Num() > 0)
|
||
{
|
||
if (TransitionHoldCursor >= TransitionHoldQueue.Num()) { TransitionHoldCursor = 0; }
|
||
const FVoxelTileKey T = TransitionHoldQueue[TransitionHoldCursor];
|
||
if (!TransitionHold.Contains(T))
|
||
{
|
||
// Clé périmée (déchargée / settled-cullée) — retrait paresseux, ne consomme
|
||
// pas le budget (la queue rétrécit ⇒ la boucle termine).
|
||
TransitionHoldQueue.RemoveAtSwap(TransitionHoldCursor);
|
||
continue;
|
||
}
|
||
--HoldBudget;
|
||
if (!NeedsHold(T))
|
||
{
|
||
TransitionHold.Remove(T);
|
||
TransitionHoldQueue.RemoveAtSwap(TransitionHoldCursor);
|
||
}
|
||
else
|
||
{
|
||
++TransitionHoldCursor;
|
||
}
|
||
}
|
||
// Teardown différé — ProcessUnloadQueue étale les destructions sur plusieurs frames.
|
||
}
|
||
}
|
||
|
||
//=========================================================================
|
||
// Submit pending work (budgeted, collision-critical -> edits -> LOD0 -> ascending coarse
|
||
// levels). Coarse work is capped below the full task budget so a later near request always
|
||
// has reserved admission; far work still runs continuously in the remaining slots.
|
||
//=========================================================================
|
||
if (!bAllChunksLoaded)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles);
|
||
int32 Submitted = 0;
|
||
|
||
// FLOOR RESPONSIVENESS — the occupied support tile and the next tile along the pawn heading
|
||
// are an absolute prefix, ahead of dirty remeshes, band work, and ordinary clipmap work.
|
||
// They run at BackgroundHigh so the pawn's floor gets the first available worker.
|
||
for (const FVoxelTileKey& T : CriticalDesiredTiles)
|
||
{
|
||
if (PendingTiles.Num() >= MaxTasks) break;
|
||
if (PendingTiles.Contains(T) || LoadedTiles.Contains(T)) continue;
|
||
LoadTile(T, EVoxelTileWorkPriority::CollisionCritical);
|
||
++Submitted;
|
||
}
|
||
|
||
// DIG RESPONSIVENESS — drain player-carve re-meshes next at BackgroundHigh. A tile that's
|
||
// still in flight is KEPT queued (retried next frame) so its stale pre-carve result is corrected.
|
||
for (auto It = DirtyRemeshQueue.CreateIterator(); It; ++It)
|
||
{
|
||
if (PendingTiles.Num() >= MaxTasks) break;
|
||
const FVoxelTileKey T = *It;
|
||
if (!IsDesired(T)) { It.RemoveCurrent(); continue; } // obsolete — drop
|
||
if (PendingTiles.Contains(T)) { continue; } // in flight — retry after it lands
|
||
It.RemoveCurrent();
|
||
LoadTile(T, EVoxelTileWorkPriority::Edited);
|
||
++Submitted;
|
||
}
|
||
|
||
// NEAR TERRAIN — all ordinary level-0 work is promoted after the absolute support/edit
|
||
// prefixes. It uses the near queue and BackgroundHigh so both worker execution and result
|
||
// application stay ahead of a completed horizon backlog.
|
||
for (const FVoxelTileKey& T : DesiredSorted)
|
||
{
|
||
if (T.Level != 0) continue;
|
||
if (PendingTiles.Num() >= MaxTasks) break;
|
||
if (PendingTiles.Contains(T)) continue; // in flight
|
||
if (LoadedTiles.Contains(T)) continue; // already loaded (level is in the key — no LOD remesh)
|
||
LoadTile(T, EVoxelTileWorkPriority::LOD0);
|
||
++Submitted;
|
||
}
|
||
|
||
// FAR FIELD — preserve the clipmap's level ordering explicitly. DesiredSorted is
|
||
// distance-sorted for the critical/near work, but a level pass prevents a level-5 horizon
|
||
// tile from being admitted ahead of an unfinished level-1/2 bridge tile.
|
||
const uint64 FarFieldMeasureStartCycles = bMeasureReadyTransitionVisibility
|
||
? FPlatformTime::Cycles64() : 0;
|
||
int32 MaxDesiredLevel = 0;
|
||
for (const FVoxelTileKey& T : DesiredSorted)
|
||
{
|
||
MaxDesiredLevel = FMath::Max(MaxDesiredLevel, T.Level);
|
||
}
|
||
for (int32 Level = 1; Level <= MaxDesiredLevel; ++Level)
|
||
{
|
||
for (const FVoxelTileKey& T : DesiredSorted)
|
||
{
|
||
if (T.Level != Level) continue;
|
||
if (PendingTiles.Num() >= CoarseTaskCap) break;
|
||
if (PendingTiles.Contains(T)) continue; // in flight
|
||
if (LoadedTiles.Contains(T)) continue; // already loaded
|
||
LoadTile(T);
|
||
++Submitted;
|
||
}
|
||
if (PendingTiles.Num() >= CoarseTaskCap) break;
|
||
}
|
||
if (bMeasureReadyTransitionVisibility)
|
||
{
|
||
FFarFieldSubmitSample& Sample = FarFieldSubmitSamples.AddDefaulted_GetRef();
|
||
Sample.Cycles = FPlatformTime::Cycles64() - FarFieldMeasureStartCycles;
|
||
Sample.DesiredTileCount = DesiredSorted.Num();
|
||
Sample.MaxDesiredLevel = MaxDesiredLevel;
|
||
}
|
||
|
||
// Bande de strate changée : re-gen budgétée des tuiles grossières concernées (le vieux
|
||
// mesh reste visible jusqu'au résultat — même schéma que RemeshDirtyChunks).
|
||
for (auto It = BandRemeshQueue.CreateIterator(); It; ++It)
|
||
{
|
||
if (PendingTiles.Num() >= CoarseTaskCap) break;
|
||
const FVoxelTileKey T = *It;
|
||
It.RemoveCurrent();
|
||
if (!IsDesired(T) || PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue;
|
||
LoadTile(T);
|
||
++Submitted;
|
||
}
|
||
|
||
if (GVoxelForgeProfileTileGeneration != 0
|
||
&& PendingTiles.Num() > GVoxelForgeMaxPendingTilesObserved)
|
||
{
|
||
GVoxelForgeMaxPendingTilesObserved = PendingTiles.Num();
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingBudget] pending_max=%d effective=%d submitted=%d"),
|
||
GVoxelForgeMaxPendingTilesObserved, MaxTasks, Submitted);
|
||
}
|
||
if (GVoxelForgeProfileTileGeneration != 0
|
||
&& PendingTiles.Num() >= CoarseTaskCap
|
||
&& !GVoxelForgeGenerationCapHitReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingBudget] coarse_generation_cap_hit=1 pending=%d "
|
||
"coarse_cap=%d effective=%d near_reserve=%d"),
|
||
PendingTiles.Num(), CoarseTaskCap, MaxTasks, NearTaskReserve);
|
||
GVoxelForgeGenerationCapHitReported = true;
|
||
}
|
||
|
||
if (Submitted == 0 && PendingTiles.Num() == 0 && BandRemeshQueue.Num() == 0 && DirtyRemeshQueue.Num() == 0)
|
||
{
|
||
// Everything desired is loaded → load-before-unload is satisfied: drop the
|
||
// deferred (in-range, not-desired) transition tiles now. No holes. Full scan —
|
||
// rare (once per settle), and the safety net behind the delta cull above.
|
||
for (const auto& Pair : TileComponents)
|
||
if (!IsDesired(Pair.Key)) QueuePendingUnload(Pair.Key);
|
||
for (const FVoxelTileKey& T : LoadedTiles)
|
||
if (!IsDesired(T) && !TileComponents.Contains(T)) QueuePendingUnload(T);
|
||
// Teardown is drained by ProcessUnloadQueue (budgeted) — single spike-free path.
|
||
|
||
bAllChunksLoaded = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority WorkPriority)
|
||
{
|
||
if (PendingTiles.Contains(Tile)) return;
|
||
|
||
const int32 MaxTasks = GetMaxConcurrentTasks(); // T2.d — core-clamped
|
||
const bool bNearPriority = WorkPriority != EVoxelTileWorkPriority::Horizon;
|
||
const int32 CoarseTaskCap = FMath::Max(1, MaxTasks - GetNearTaskReserve(MaxTasks));
|
||
const int32 AdmissionCap = WorkPriority == EVoxelTileWorkPriority::Horizon
|
||
? CoarseTaskCap : MaxTasks;
|
||
if (PendingTiles.Num() >= AdmissionCap)
|
||
{
|
||
if (GVoxelForgeProfileTileGeneration != 0
|
||
&& !GVoxelForgeGenerationCapHitReported)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeStreamingBudget] generation_cap_hit=1 pending=%d admission=%d "
|
||
"effective=%d near_reserve=%d tile_level=%d"),
|
||
PendingTiles.Num(), AdmissionCap, MaxTasks, GetNearTaskReserve(MaxTasks), Tile.Level);
|
||
GVoxelForgeGenerationCapHitReported = true;
|
||
}
|
||
return; // Budget full — wait for a task to finish.
|
||
}
|
||
PendingTiles.Add(Tile);
|
||
if (bStartupCollisionGateTimingCaptured
|
||
&& StartupCollisionGateSupportTile.Coord == Tile.Coord
|
||
&& StartupCollisionGateSupportTile.Level == Tile.Level
|
||
&& StartupCollisionGateSubmittedSeconds <= 0.0)
|
||
{
|
||
StartupCollisionGateSubmittedSeconds = FPlatformTime::Seconds();
|
||
}
|
||
const uint64 RequestStartCycles = FPlatformTime::Cycles64();
|
||
|
||
const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords
|
||
|
||
// Coarse levels mesh with FEWER cells → much cheaper gen (blockier far field, which is far
|
||
// away + skirts hide the seams). Near levels (< FullResClipLevels) stay full CHUNK_SIZE cells.
|
||
// Extent stays CHUNK_SIZE<<Level (so the shell tiling is unchanged) — only the cell count and
|
||
// step change: Step = Extent / Cells.
|
||
const int32 FullRes = GVoxelForgeTestFullResClipLevels >= 0
|
||
? FMath::Clamp(GVoxelForgeTestFullResClipLevels, 1, 9)
|
||
: (Settings ? FMath::Max(1, Settings->FullResClipLevels) : 2);
|
||
const int32 Extent = CHUNK_SIZE << Tile.Level;
|
||
|
||
const int32 Cells = (Tile.Level < FullRes)
|
||
? CHUNK_SIZE
|
||
: (Settings ? FMath::Clamp(Settings->CoarseTileCells, 4, CHUNK_SIZE) : 16);
|
||
const int32 Step = FMath::Max(1, Extent / Cells);
|
||
const uint32 TaskEpoch = GenerationEpoch;
|
||
const uint32 TaskDesiredEpoch = DesiredEpoch;
|
||
const uint32 TaskModificationEpoch = ModificationEpoch;
|
||
|
||
// CAPTURE-DURING-MESHING: only level-0 full-res tiles map 1:1 onto a density-clipmap level
|
||
// (Step == 1<<Level, Cells == CHUNK_SIZE). When the density volume is active, ask the mesher to
|
||
// emit the captured R8 grid so the volume reuses it instead of re-sampling GetDensityAt. Gated to
|
||
// tiles the volume can actually consume (its shadow window is much smaller than the streaming
|
||
// ring) — the rest shouldn't pay the quantize + 32 KB queue payload for a grid it would refuse.
|
||
const bool bWantCapture = (Tile.Level == 0) && (Cells == CHUNK_SIZE)
|
||
&& DensityVolume != nullptr && Settings && Settings->bEnableDensityVolume
|
||
&& DensityVolume->IsTileCaptureUseful(Tile.Coord);
|
||
const bool bWantSurfaceWitnesses = GVoxelForgeVisualGapAudits.Contains(this);
|
||
const bool bWantPreEditDensityGrid = !PreEditDensityCache.Contains(Tile)
|
||
&& VoxelForgePreEditDensityCache::IsPawnRadiusTile(Tile, CurrentCenterChunk);
|
||
|
||
// STRATE CONTENT CUT — LODs at/above the effective minimum mesh only the player-strate band
|
||
// (see the band update in UpdateChunksAroundPosition + UVoxelSettings::StrateContentCutMinLevel).
|
||
// Chunk band → voxels (inclusive). LOD0 is always full gameplay resolution; the independent
|
||
// collision radius decides which level-0 results also create a Chaos body. Finer LODs below
|
||
// the configured minimum and all LODs in the gap (no band) mesh full.
|
||
int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX;
|
||
int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32;
|
||
const int32 CutMin = Settings ? Settings->GetEffectiveStrateContentCutMinLevel() : 9;
|
||
if (Tile.Level >= CutMin && MeshBandChunkLo != MIN_int32)
|
||
{
|
||
BandChunkLo = MeshBandChunkLo;
|
||
BandChunkHi = MeshBandChunkHi;
|
||
BandVoxLo = MeshBandChunkLo * CHUNK_SIZE;
|
||
BandVoxHi = (MeshBandChunkHi + 1) * CHUNK_SIZE - 1;
|
||
|
||
// Résidu ultra-grossier (niveaux ≥7) : la coupe est à la granularité de la CELLULE. Si
|
||
// UNE cellule (Step voxels de haut) est plus haute que la bande entière, toute cellule
|
||
// qui chevauche la bande échantillonne quand même les airs des DEUX strates (mêmes trous
|
||
// et mélanges de matériaux qu'avant la coupe) — la tuile ne peut rendre que des artefacts
|
||
// ⇒ on n'émet RIEN. Résultat vide via ProcessQueue (bookkeeping normal : PendingTiles,
|
||
// LoadedTiles, epoch) ; jamais figé — le changement de bande re-queue via BandRemeshQueue.
|
||
if (Step > (BandChunkHi - BandChunkLo + 1) * CHUNK_SIZE)
|
||
{
|
||
FChunkResult Empty;
|
||
Empty.Tile = Tile;
|
||
Empty.Epoch = TaskEpoch;
|
||
Empty.DesiredEpoch = TaskDesiredEpoch;
|
||
Empty.ModificationEpoch = TaskModificationEpoch;
|
||
Empty.RequestStartCycles = RequestStartCycles;
|
||
Empty.BandChunkLo = BandChunkLo;
|
||
Empty.BandChunkHi = BandChunkHi;
|
||
Empty.WorkPriority = WorkPriority;
|
||
if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical)
|
||
CriticalProcessQueue.Enqueue(MoveTemp(Empty));
|
||
else if (WorkPriority == EVoxelTileWorkPriority::Edited)
|
||
EditedProcessQueue.Enqueue(MoveTemp(Empty));
|
||
else if (WorkPriority == EVoxelTileWorkPriority::LOD0)
|
||
NearProcessQueue.Enqueue(MoveTemp(Empty));
|
||
else
|
||
ProcessQueue.Enqueue(MoveTemp(Empty));
|
||
return;
|
||
}
|
||
}
|
||
|
||
const TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe> Cancellation =
|
||
MakeShared<FVoxelTileCancellationState, ESPMode::ThreadSafe>();
|
||
PendingTileCancellation.Add(Tile, Cancellation);
|
||
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
|
||
|
||
// BackgroundNormal priority: coarse gen runs on background workers that YIELD to foreground
|
||
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare
|
||
// core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom).
|
||
// At background priority the frame keeps its cores; gen just fills in around it.
|
||
// NEAR RESPONSIVENESS: collision support, edits, and every level-0 tile launch at
|
||
// BackgroundHigh — still a background worker (yields to the frame, keeps the invariant) but
|
||
// jumps AHEAD of ordinary pending streaming gen. Admission reserves slots for this class and
|
||
// the result queues apply it in collision -> edit -> LOD0 order.
|
||
const UE::Tasks::ETaskPriority TaskPriority = bNearPriority
|
||
? UE::Tasks::ETaskPriority::BackgroundHigh
|
||
: UE::Tasks::ETaskPriority::BackgroundNormal;
|
||
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch,
|
||
bWantCapture, bWantPreEditDensityGrid, bWantSurfaceWitnesses,
|
||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||
RequestStartCycles, TaskDesiredEpoch, TaskModificationEpoch, Cancellation,
|
||
WorkPriority]()
|
||
{
|
||
// RAII: decrement the counter on every exit path.
|
||
struct FTaskGuard
|
||
{
|
||
std::atomic<int32>& Counter;
|
||
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
|
||
} Guard{ActiveTaskCount};
|
||
|
||
auto EnqueueObsoleteResult = [&]()
|
||
{
|
||
if (bShuttingDown.load(std::memory_order_relaxed)
|
||
|| bGenerationPaused.load(std::memory_order_relaxed))
|
||
{
|
||
return;
|
||
}
|
||
FChunkResult Canceled;
|
||
Canceled.Tile = Tile;
|
||
Canceled.Epoch = TaskEpoch;
|
||
Canceled.DesiredEpoch = TaskDesiredEpoch;
|
||
Canceled.ModificationEpoch = TaskModificationEpoch;
|
||
Canceled.RequestStartCycles = RequestStartCycles;
|
||
Canceled.WorkPriority = WorkPriority;
|
||
Canceled.bAborted = true;
|
||
Canceled.bObsolete = true;
|
||
if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical)
|
||
CriticalProcessQueue.Enqueue(MoveTemp(Canceled));
|
||
else if (WorkPriority == EVoxelTileWorkPriority::Edited)
|
||
EditedProcessQueue.Enqueue(MoveTemp(Canceled));
|
||
else if (WorkPriority == EVoxelTileWorkPriority::LOD0)
|
||
NearProcessQueue.Enqueue(MoveTemp(Canceled));
|
||
else
|
||
ProcessQueue.Enqueue(MoveTemp(Canceled));
|
||
};
|
||
|
||
if (ShouldAbortWork()) return;
|
||
if (Cancellation->bObsolete.load(std::memory_order_relaxed))
|
||
{
|
||
EnqueueObsoleteResult();
|
||
return;
|
||
}
|
||
|
||
FScopedVoxelStackRegistration StackRegistration(Tile.Level);
|
||
FChunkResult Result;
|
||
Result.DesiredEpoch = TaskDesiredEpoch;
|
||
Result.ModificationEpoch = TaskModificationEpoch;
|
||
Result.WorkPriority = WorkPriority;
|
||
Result.bCaptureSurfaceWitnesses = bWantSurfaceWitnesses;
|
||
const uint64 GenerationStartCycles = FPlatformTime::Cycles64();
|
||
Result.GenerationStartCycles = GenerationStartCycles;
|
||
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
|
||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||
Result, &Cancellation->bObsolete, bWantPreEditDensityGrid);
|
||
Result.GenerationEndCycles = GenerationStartCycles != 0
|
||
? FPlatformTime::Cycles64() : 0;
|
||
Result.RequestStartCycles = RequestStartCycles;
|
||
|
||
const uint64 WorkerGenerationCycles = Result.GenerationEndCycles >= GenerationStartCycles
|
||
? Result.GenerationEndCycles - GenerationStartCycles : 0;
|
||
TotalWorkerGenerationTasks.fetch_add(1, std::memory_order_relaxed);
|
||
TotalWorkerGenerationCycles.fetch_add(WorkerGenerationCycles, std::memory_order_relaxed);
|
||
const bool bObsoleteWork = Cancellation->bObsolete.load(std::memory_order_relaxed)
|
||
|| Result.bObsolete;
|
||
if (bObsoleteWork)
|
||
{
|
||
TotalObsoleteWorkerTasks.fetch_add(1, std::memory_order_relaxed);
|
||
TotalObsoleteWorkerCycles.fetch_add(WorkerGenerationCycles, std::memory_order_relaxed);
|
||
}
|
||
|
||
if (bObsoleteWork || Result.bAborted)
|
||
{
|
||
if (!ShouldAbortWork())
|
||
{
|
||
EnqueueObsoleteResult();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!ShouldAbortWork())
|
||
{
|
||
if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical)
|
||
CriticalProcessQueue.Enqueue(MoveTemp(Result));
|
||
else if (WorkPriority == EVoxelTileWorkPriority::Edited)
|
||
EditedProcessQueue.Enqueue(MoveTemp(Result));
|
||
else if (WorkPriority == EVoxelTileWorkPriority::LOD0)
|
||
NearProcessQueue.Enqueue(MoveTemp(Result));
|
||
else
|
||
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
||
}
|
||
}, TaskPriority);
|
||
}
|
||
|
||
// Worker-side gen for one tile (shared by the async ChunkGen task and the synchronous carve path).
|
||
// READS Generator/Mesher only — safe on a worker or the game thread. Fills Result; no enqueue.
|
||
void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
|
||
int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture,
|
||
int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi,
|
||
FChunkResult& Result,
|
||
const std::atomic<bool>* ObsoleteFlag,
|
||
bool bWantPreEditDensityGrid,
|
||
const FVoxelSharedDensityGrid* SharedDensityOverride)
|
||
{
|
||
const bool bLogTileProfile = GVoxelForgeProfileTileGeneration != 0;
|
||
const bool bMeasureTile = bLogTileProfile
|
||
|| VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Attribution
|
||
|| VoxelForgeStartupTrace::IsActive();
|
||
const double TileStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0;
|
||
double ClassifySeconds = 0.0;
|
||
double MeshSeconds = 0.0;
|
||
double StreamSeconds = 0.0;
|
||
int32 ClassifyVerdict = -1; // Mixed = 0, AllSolid = 1, AllAir = 2
|
||
FVoxelTileClassificationStats ClassifierStats;
|
||
const bool bProfileOps = bLogTileProfile
|
||
&& VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||
const VoxelDensityProfile::FSnapshot TileProfileStart = bMeasureTile
|
||
? VoxelDensityProfile::SnapshotCurrentThread()
|
||
: VoxelDensityProfile::FSnapshot();
|
||
auto EmitTileProfile = [&]()
|
||
{
|
||
if (bLogTileProfile)
|
||
{
|
||
const VoxelDensityProfile::FSnapshot TileProfileEnd = bMeasureTile
|
||
? VoxelDensityProfile::SnapshotCurrentThread()
|
||
: VoxelDensityProfile::FSnapshot();
|
||
const auto CounterDelta = [&](VoxelDensityProfile::ECounter Counter) -> uint64
|
||
{
|
||
const int32 Index = static_cast<int32>(Counter);
|
||
return bMeasureTile && TileProfileEnd.Counters[Index] >= TileProfileStart.Counters[Index]
|
||
? TileProfileEnd.Counters[Index] - TileProfileStart.Counters[Index] : 0;
|
||
};
|
||
const auto CycleDelta = [&](VoxelDensityProfile::EBucket Bucket) -> uint64
|
||
{
|
||
const int32 Index = static_cast<int32>(Bucket);
|
||
return bMeasureTile && TileProfileEnd.Cycles[Index] >= TileProfileStart.Cycles[Index]
|
||
? TileProfileEnd.Cycles[Index] - TileProfileStart.Cycles[Index] : 0;
|
||
};
|
||
const double TileSeconds = FPlatformTime::Seconds() - TileStartSeconds;
|
||
const double CacheBuildSeconds = FPlatformTime::ToSeconds64(
|
||
CycleDelta(VoxelDensityProfile::EBucket::RoomGraphBuild));
|
||
const double EvaluationSeconds = FMath::Max(
|
||
0.0, TileSeconds - CacheBuildSeconds);
|
||
const uint64 WormEligibleSamples = CounterDelta(
|
||
VoxelDensityProfile::ECounter::WormEligibleSamples);
|
||
const uint64 WormBlockProofs = CounterDelta(
|
||
VoxelDensityProfile::ECounter::WormBlockProofs);
|
||
const uint64 WormBlockSkippedSamples = CounterDelta(
|
||
VoxelDensityProfile::ECounter::WormBlockSkippedSamples);
|
||
const double WormBlockSkipRate = WormEligibleSamples > 0
|
||
? static_cast<double>(WormBlockSkippedSamples)
|
||
/ static_cast<double>(WormEligibleSamples)
|
||
: 0.0;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeTileProfile] tile=(%d,%d,%d) level=%d step=%d cells=%d aborted=%d empty=%d triangles=%d ceiling_triangles=%d "
|
||
"band=(%d,%d) "
|
||
"verdict=%d proof=%d air_proof=%d classify=%.6f mesh=%.6f streams=%.6f seconds=%.6f "
|
||
"cache_build=%.6f evaluation=%.6f "
|
||
"refine=%u stack=%u core_samples=%u final_samples=%u core_hits=%u final_hits=%u "
|
||
"core_leaves=%u final_leaves=%u tail_queries=%u tail_eval=%u "
|
||
"stack_work=%.6f core_work=%.6f final_work=%.6f tail_work=%.6f "
|
||
"room_work=%.6f room_exact=%.6f cache_work=%.6f "
|
||
"rooms=%d tunnels=%d joins=%d pits=%d chimneys=%d "
|
||
"whole_mixed=%u whole_solid=%u whole_air=%u final_nodes=%u "
|
||
"split_nodes=%u max_depth=%u block_builds=%llu block_samples=%llu "
|
||
"block_ops=%llu block_active=%llu block_pruned=%llu cache_builds=%llu "
|
||
"cache_generator=%llu cache_tunnel_core=%llu cache_op_shared=%llu "
|
||
"cache_op_local=%llu cache_classifier_shared=%llu cache_classifier_local=%llu "
|
||
"cache_unknown=%llu room_candidates=%llu room_evaluated=%llu "
|
||
"tunnel_candidates=%llu tunnel_evaluated=%llu tunnel_core_candidates=%llu "
|
||
"tunnel_core_evaluated=%llu support_column_candidates=%llu "
|
||
"worm_eligible=%llu worm_block_proofs=%llu worm_block_skipped=%llu "
|
||
"worm_skip_rate=%.6f"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, Step, Cells,
|
||
Result.bAborted ? 1 : 0,
|
||
Result.bEmpty ? 1 : 0, Result.NumTriangles, Result.NumCeilingTriangles,
|
||
BandChunkLo, BandChunkHi,
|
||
ClassifyVerdict, Result.bSealedSolidProof ? 1 : 0,
|
||
Result.bOutOfLayoutAirProof ? 1 : 0,
|
||
ClassifySeconds, MeshSeconds, StreamSeconds,
|
||
TileSeconds, CacheBuildSeconds, EvaluationSeconds,
|
||
ClassifierStats.RefineNodes, ClassifierStats.StackBoxCalls,
|
||
ClassifierStats.ExactCoreSamples, ClassifierStats.ExactFinalSamples,
|
||
ClassifierStats.ExactCoreCacheHits, ClassifierStats.ExactFinalCacheHits,
|
||
ClassifierStats.ExactCoreLeaves, ClassifierStats.ExactFinalLeaves,
|
||
ClassifierStats.RoomTailQueries, ClassifierStats.RoomTailEvaluated,
|
||
FPlatformTime::ToSeconds64(ClassifierStats.StackBoxCycles),
|
||
FPlatformTime::ToSeconds64(ClassifierStats.ExactCoreCycles),
|
||
FPlatformTime::ToSeconds64(ClassifierStats.ExactFinalCycles),
|
||
FPlatformTime::ToSeconds64(ClassifierStats.RoomTailCycles),
|
||
FPlatformTime::ToSeconds64(ClassifierStats.RoomPropagateCycles),
|
||
FPlatformTime::ToSeconds64(ClassifierStats.RoomExactPrimitiveCycles),
|
||
FPlatformTime::ToSeconds64(ClassifierStats.RoomCacheWindowCycles),
|
||
ClassifierStats.RoomNumRooms, ClassifierStats.RoomNumTunnels,
|
||
ClassifierStats.RoomNumRoomFloorJoins, ClassifierStats.RoomNumPits,
|
||
ClassifierStats.RoomNumChimneys,
|
||
ClassifierStats.WholeMixedNodes, ClassifierStats.WholeSolidNodes,
|
||
ClassifierStats.WholeAirNodes, ClassifierStats.NeedsFinalFieldNodes,
|
||
ClassifierStats.SplitNodes, ClassifierStats.MaxRefinementDepth,
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::OpBlockBuilds)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::OpBlockSamples)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::OpBlockOperators)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::OpBlockActiveOperators)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::OpBlockPrunedOperators)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::SdfCacheBuild)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTile)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTunnelCore)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildOpShared)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildOpLocal)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildClassifierShared)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildClassifierLocal)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildUnknown)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::CaveRoomCandidates)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::CaveRoomEvaluated)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::CaveTunnelCandidates)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::CaveTunnelEvaluated)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::TunnelCoreCandidates)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::TunnelCoreEvaluated)),
|
||
static_cast<unsigned long long>(CounterDelta(VoxelDensityProfile::ECounter::TunnelSupportColumnCandidates)),
|
||
static_cast<unsigned long long>(WormEligibleSamples),
|
||
static_cast<unsigned long long>(WormBlockProofs),
|
||
static_cast<unsigned long long>(WormBlockSkippedSamples),
|
||
WormBlockSkipRate);
|
||
|
||
if (bProfileOps)
|
||
{
|
||
static const VoxelDensityProfile::EBucket GameOpBuckets[] = {
|
||
VoxelDensityProfile::EBucket::RoomGraphSource,
|
||
VoxelDensityProfile::EBucket::SdfCarve,
|
||
VoxelDensityProfile::EBucket::CaveRoughnessMod,
|
||
VoxelDensityProfile::EBucket::CaveTerraceMod,
|
||
VoxelDensityProfile::EBucket::LayerLineMod,
|
||
VoxelDensityProfile::EBucket::RibbingMod,
|
||
VoxelDensityProfile::EBucket::CaveOverhangMod,
|
||
VoxelDensityProfile::EBucket::CaveCliffMod,
|
||
VoxelDensityProfile::EBucket::ScallopMod,
|
||
VoxelDensityProfile::EBucket::CaveArchMod,
|
||
VoxelDensityProfile::EBucket::RoomColumnMod,
|
||
VoxelDensityProfile::EBucket::DomeMod,
|
||
VoxelDensityProfile::EBucket::PinchMod,
|
||
VoxelDensityProfile::EBucket::FloorBiasMod,
|
||
VoxelDensityProfile::EBucket::WormFieldSource,
|
||
VoxelDensityProfile::EBucket::OriginSpineOp,
|
||
VoxelDensityProfile::EBucket::BoundarySealOp,
|
||
VoxelDensityProfile::EBucket::PassageCarveOp,
|
||
VoxelDensityProfile::EBucket::XYEdgeSealOp,
|
||
};
|
||
for (const VoxelDensityProfile::EBucket Bucket : GameOpBuckets)
|
||
{
|
||
const int32 Index = static_cast<int32>(Bucket);
|
||
const uint64 Calls = TileProfileEnd.Calls[Index] >= TileProfileStart.Calls[Index]
|
||
? TileProfileEnd.Calls[Index] - TileProfileStart.Calls[Index] : 0;
|
||
const uint64 Cycles = TileProfileEnd.WallCycles[Index] >= TileProfileStart.WallCycles[Index]
|
||
? TileProfileEnd.WallCycles[Index] - TileProfileStart.WallCycles[Index] : 0;
|
||
if (Calls == 0) { continue; }
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeGameOpProfile] tile=(%d,%d,%d) level=%d op=%s calls=%llu total_us=%.3f us_per_call=%.6f"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level,
|
||
VoxelDensityProfile::BucketName(Bucket),
|
||
static_cast<unsigned long long>(Calls),
|
||
FPlatformTime::ToSeconds64(Cycles) * 1.0e6,
|
||
FPlatformTime::ToSeconds64(Cycles) * 1.0e6 / static_cast<double>(Calls));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
Result.Tile = Tile;
|
||
Result.Epoch = Epoch;
|
||
Result.bAborted = false;
|
||
Result.bObsolete = false;
|
||
Result.bEmpty = true;
|
||
Result.ClassifyVerdict = -1;
|
||
Result.bSealedSolidProof = false;
|
||
Result.bOutOfLayoutAirProof = false;
|
||
Result.ClassifySeconds = 0.0;
|
||
Result.MeshSeconds = 0.0;
|
||
Result.StreamSeconds = 0.0;
|
||
Result.NumTriangles = 0;
|
||
Result.ValidationDensityCalls = 0;
|
||
Result.MesherDensityCalls = 0;
|
||
Result.Streams.Reset();
|
||
Result.CaptureGrid.Reset();
|
||
Result.PreEditDensityGrid.Reset();
|
||
Result.BandChunkLo = BandChunkLo; // strate content cut (MIN/MAX = uncut)
|
||
Result.BandChunkHi = BandChunkHi;
|
||
|
||
// Shared by async workers and the synchronous carve path. An interrupted result is empty but
|
||
// explicitly discarded, never an all-air tile that gets marked loaded.
|
||
auto AbortResult = [&]()
|
||
{
|
||
Result.bAborted = true;
|
||
Result.bObsolete = ObsoleteFlag != nullptr
|
||
&& ObsoleteFlag->load(std::memory_order_relaxed);
|
||
Result.bEmpty = true;
|
||
Result.Streams.Reset();
|
||
Result.CaptureGrid.Reset();
|
||
Result.PreEditDensityGrid.Reset();
|
||
};
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
AbortResult();
|
||
EmitTileProfile();
|
||
return;
|
||
}
|
||
|
||
// T1.d — TRIVIAL-TILE REJECT: ~84 % des tuiles générées sortaient vides (tout-roc /
|
||
// tout-air) en payant quand même le pré-échantillonnage complet. Le classifieur prouve
|
||
// (bornes exactes sur le treillis du mesher + gardes conservatives) qu'une tuile est
|
||
// uniforme → on saute GenerateMesh, Result reste bEmpty. Mixed = génération normale.
|
||
// Les tuiles à capture (density volume) génèrent toujours : le volume veut la grille
|
||
// même pour les cellules uniformes, et ces tuiles sont rares (fenêtre d'ombre).
|
||
// (Gate IsoLevel == 0 : les verdicts du classifieur supposent l'iso MC à zéro exactement.)
|
||
bool bTrivialEmpty = false;
|
||
if (GVoxelForgeSealedSolidProof != 0
|
||
&& !bWantCapture && SharedDensityOverride == nullptr
|
||
&& Generator && Mesher
|
||
&& Mesher->IsoLevel == 0.0f)
|
||
{
|
||
TotalSealedSolidProofCandidates.fetch_add(1, std::memory_order_relaxed);
|
||
const double ProofStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0;
|
||
if (Generator->TryProveSealedSolidTile(OriginVoxels, Step, Cells))
|
||
{
|
||
ClassifySeconds = bMeasureTile
|
||
? FPlatformTime::Seconds() - ProofStartSeconds : 0.0;
|
||
ClassifyVerdict = static_cast<int32>(EVoxelTileClass::AllSolid);
|
||
Result.ClassifyVerdict = ClassifyVerdict;
|
||
Result.bSealedSolidProof = true;
|
||
TotalSealedSolidProofSkips.fetch_add(1, std::memory_order_relaxed);
|
||
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
|
||
bTrivialEmpty = true;
|
||
}
|
||
else if (Generator->TryProveOutOfLayoutAirTile(OriginVoxels, Step, Cells))
|
||
{
|
||
ClassifySeconds = bMeasureTile
|
||
? FPlatformTime::Seconds() - ProofStartSeconds : 0.0;
|
||
ClassifyVerdict = static_cast<int32>(EVoxelTileClass::AllAir);
|
||
Result.ClassifyVerdict = ClassifyVerdict;
|
||
Result.bOutOfLayoutAirProof = true;
|
||
TotalOutOfLayoutAirProofSkips.fetch_add(1, std::memory_order_relaxed);
|
||
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllAir);
|
||
bTrivialEmpty = true;
|
||
}
|
||
else if (bMeasureTile)
|
||
{
|
||
// Keep the measured proof cost visible on a tile that continues through the normal
|
||
// path. The clean summary remains counter-only and pays no timer overhead.
|
||
ClassifySeconds = FPlatformTime::Seconds() - ProofStartSeconds;
|
||
}
|
||
}
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
AbortResult();
|
||
EmitTileProfile();
|
||
return;
|
||
}
|
||
const int32 OuterClassifierMode = FMath::Clamp(GVoxelForgeOuterClassifierMode, 0, 1);
|
||
if (!bTrivialEmpty && OuterClassifierMode != 0
|
||
&& !bWantCapture && SharedDensityOverride == nullptr
|
||
&& Generator && Mesher && Mesher->IsoLevel == 0.0f)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
|
||
INC_DWORD_STAT(STAT_VoxelForgeTilesClassified);
|
||
const double ClassifyStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0;
|
||
const EVoxelTileClass Verdict = Generator->ClassifyTile(
|
||
OriginVoxels, Step, Cells, bMeasureTile ? &ClassifierStats : nullptr);
|
||
|
||
// ITEM 6 — the outer classifier is a proof candidate, not the final skip decision. The
|
||
// mesher's block path validates candidates against the exact MC vertex lattice before it
|
||
// skips anything; whole-tile verdicts need the same fence. Sample the core vertices
|
||
// g=0..Cells (the halo is normal-only) with the mesher's LOD TLS, so a coarse tile is
|
||
// checked against the field the mesher would actually consume.
|
||
EVoxelTileClass ValidatedVerdict = Verdict;
|
||
bool bValidationAborted = false;
|
||
if (Verdict != EVoxelTileClass::Mixed)
|
||
{
|
||
const int32 ValidationStep = FMath::Max(1, Step);
|
||
const int32 ValidationCells = FMath::Clamp(Cells, 2, CHUNK_SIZE);
|
||
const int32 ValidationOctaveBias = (Mesher->LODOctaveDrop > 0 && ValidationStep > 1)
|
||
? Mesher->LODOctaveDrop
|
||
* static_cast<int32>(FMath::FloorLog2(static_cast<uint32>(ValidationStep)))
|
||
: 0;
|
||
TGuardValue<int32> ValidationOctaveBiasGuard(
|
||
VoxelGenLOD::OctaveBias, ValidationOctaveBias);
|
||
TGuardValue<int32> ValidationSampleStepGuard(
|
||
VoxelGenLOD::SampleStep, ValidationStep);
|
||
|
||
bool bExactUniform = true;
|
||
for (int32 Z = 0; Z <= ValidationCells
|
||
&& bExactUniform && !bValidationAborted; ++Z)
|
||
{
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
bValidationAborted = true;
|
||
break;
|
||
}
|
||
for (int32 Y = 0; Y <= ValidationCells
|
||
&& bExactUniform && !bValidationAborted; ++Y)
|
||
{
|
||
for (int32 X = 0; X <= ValidationCells; ++X)
|
||
{
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
bValidationAborted = true;
|
||
break;
|
||
}
|
||
const float Density = Generator->GetDensityAt(
|
||
OriginVoxels.X + X * ValidationStep,
|
||
OriginVoxels.Y + Y * ValidationStep,
|
||
OriginVoxels.Z + Z * ValidationStep);
|
||
++Result.ValidationDensityCalls;
|
||
const bool bSampleAgrees = VoxelMath::IsFinite(Density) && Density != 0.0f
|
||
&& (Verdict != EVoxelTileClass::AllSolid || Density < 0.0f)
|
||
&& (Verdict != EVoxelTileClass::AllAir || Density > 0.0f);
|
||
if (!bSampleAgrees)
|
||
{
|
||
bExactUniform = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
TotalValidationDensityCalls.fetch_add(
|
||
static_cast<uint64>(Result.ValidationDensityCalls), std::memory_order_relaxed);
|
||
if (!bExactUniform)
|
||
{
|
||
// A false Mixed costs meshing; a false uniform verdict removes both render and
|
||
// collision geometry. Fall back to the safe path whenever one exact vertex disagrees.
|
||
ValidatedVerdict = EVoxelTileClass::Mixed;
|
||
}
|
||
}
|
||
if (bValidationAborted)
|
||
{
|
||
AbortResult();
|
||
EmitTileProfile();
|
||
return;
|
||
}
|
||
ClassifySeconds += bMeasureTile ? FPlatformTime::Seconds() - ClassifyStartSeconds : 0.0;
|
||
ClassifyVerdict = static_cast<int32>(ValidatedVerdict);
|
||
Result.ClassifyVerdict = ClassifyVerdict;
|
||
const int32 TrackedLOD = FMath::Clamp(Tile.Level, 0, TrackedClassifierLODCount - 1);
|
||
OuterClassifierCallsByLOD[TrackedLOD].fetch_add(1, std::memory_order_relaxed);
|
||
if (ClassifyVerdict >= 0 && ClassifyVerdict < 3)
|
||
{
|
||
OuterClassifierVerdictsByLOD[TrackedLOD][ClassifyVerdict].fetch_add(
|
||
1, std::memory_order_relaxed);
|
||
}
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
AbortResult();
|
||
EmitTileProfile();
|
||
return;
|
||
}
|
||
if (ValidatedVerdict == EVoxelTileClass::AllSolid)
|
||
{
|
||
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
|
||
}
|
||
else if (ValidatedVerdict == EVoxelTileClass::AllAir)
|
||
{
|
||
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllAir);
|
||
}
|
||
bTrivialEmpty = (ValidatedVerdict != EVoxelTileClass::Mixed);
|
||
}
|
||
|
||
// `TilesMeshed` peut dépasser `TilesClassified` : les tuiles qui ratent cette porte sont
|
||
// maillées sans classification. / `TilesMeshed` may exceed `TilesClassified`: tiles that
|
||
// fail this gate are meshed without classification.
|
||
FVoxelMeshData MeshData;
|
||
if (!bTrivialEmpty)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||
const double MeshStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0;
|
||
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells,
|
||
bWantCapture ? &Result.CaptureGrid : nullptr,
|
||
BandVoxLo, BandVoxHi,
|
||
&Result.MesherDensityCalls,
|
||
bWantPreEditDensityGrid ? &Result.PreEditDensityGrid : nullptr,
|
||
SharedDensityOverride);
|
||
TotalMesherDensityCalls.fetch_add(
|
||
static_cast<uint64>(Result.MesherDensityCalls), std::memory_order_relaxed);
|
||
MeshSeconds = bMeasureTile ? FPlatformTime::Seconds() - MeshStartSeconds : 0.0;
|
||
INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed);
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
AbortResult();
|
||
EmitTileProfile();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
||
// tiles carry no streams (Result.bEmpty stays true) → no component on apply.
|
||
if (!MeshData.IsEmpty())
|
||
{
|
||
if (Result.bCaptureSurfaceWitnesses)
|
||
{
|
||
constexpr int32 MaxSurfaceWitnesses = 24;
|
||
const int32 SurfaceVertexCount = FMath::Clamp(
|
||
MeshData.NumSurfaceVertices, 0, MeshData.Vertices.Num());
|
||
const int32 WitnessCount = FMath::Min(MaxSurfaceWitnesses, SurfaceVertexCount);
|
||
Result.SurfaceWitnesses.Reserve(WitnessCount);
|
||
for (int32 WitnessIndex = 0; WitnessIndex < WitnessCount; ++WitnessIndex)
|
||
{
|
||
const int32 VertexIndex = static_cast<int32>(
|
||
(static_cast<int64>(WitnessIndex) * SurfaceVertexCount) / WitnessCount);
|
||
Result.SurfaceWitnesses.Add(MeshData.Vertices[VertexIndex]);
|
||
}
|
||
}
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams);
|
||
const double StreamStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0;
|
||
Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>();
|
||
VoxelDensityProfile::FScopedTimer RuntimeStreamTimer(
|
||
VoxelDensityProfile::EBucket::RuntimeStreamBuilding);
|
||
BuildTileStreamSet(*Result.Streams, MeshData);
|
||
Result.NumVertices = MeshData.Vertices.Num();
|
||
Result.GeometryBytes = 0;
|
||
Result.Streams->ForEach([&](const RealtimeMesh::FRealtimeMeshStream& Stream)
|
||
{
|
||
Result.GeometryBytes += static_cast<uint64>(Stream.GetAllocatedSize());
|
||
});
|
||
StreamSeconds = bMeasureTile ? FPlatformTime::Seconds() - StreamStartSeconds : 0.0;
|
||
if (ShouldAbortWork(ObsoleteFlag))
|
||
{
|
||
AbortResult();
|
||
EmitTileProfile();
|
||
return;
|
||
}
|
||
Result.bEmpty = false;
|
||
|
||
// F17 — the mesher classified every triangle semantically (sky-cap vs ground, per
|
||
// vertex against the column's TerrainZ/CeilSurf) and packed them as two contiguous
|
||
// polygroup runs. Here we only record which sections exist for the apply path.
|
||
// (Replaces the whole-tile normal VOTE, which painted mixed coarse tiles — terrain
|
||
// AND cap in one tile — entirely with the winner's material.)
|
||
const int32 NumTris = MeshData.Triangles.Num() / 3;
|
||
Result.NumTriangles = NumTris;
|
||
Result.NumCeilingTriangles = MeshData.NumCeilingTriangles;
|
||
Result.NumSkirtTriangles = MeshData.NumSkirtTriangles;
|
||
Result.bHasCeilingTris = MeshData.NumCeilingTriangles > 0;
|
||
Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles;
|
||
}
|
||
|
||
// Parity instrumentation observes the same FVoxelMeshData that the runtime stream builder
|
||
// consumes. It is intentionally after the abort fence, so a canceled worker cannot publish a
|
||
// partial identity, and it records empty/proof tiles as well as visible meshes.
|
||
RecordTileHash(Tile, OriginVoxels, Step, Cells, BandChunkLo, BandChunkHi, MeshData);
|
||
|
||
Result.ClassifySeconds = ClassifySeconds;
|
||
Result.MeshSeconds = MeshSeconds;
|
||
Result.StreamSeconds = StreamSeconds;
|
||
EmitTileProfile();
|
||
}
|
||
|
||
// Section-group key shared by every tile component ("the" tile geometry group).
|
||
static FRealtimeMeshSectionGroupKey VoxelTileGroupKey()
|
||
{
|
||
return FRealtimeMeshSectionGroupKey::Create(FRealtimeMeshLODKey(0), FName("Tile"));
|
||
}
|
||
|
||
//=============================================================================
|
||
// TILE COMPONENT POOL (T2.c)
|
||
//=============================================================================
|
||
// Recycler les composants de tuile au lieu de les détruire/recréer.
|
||
|
||
URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent(
|
||
double* OutCreationSeconds,
|
||
double* OutRegistrationSeconds,
|
||
bool* bOutCreated)
|
||
{
|
||
if (OutCreationSeconds != nullptr) { *OutCreationSeconds = 0.0; }
|
||
if (OutRegistrationSeconds != nullptr) { *OutRegistrationSeconds = 0.0; }
|
||
if (bOutCreated != nullptr) { *bOutCreated = false; }
|
||
|
||
// Reuse a parked component when one is available — skips NewObject + RegisterComponent
|
||
// (and the full proxy teardown/GC of a destroy) during fast travel & regen bursts.
|
||
while (TileComponentPool.Num() > 0)
|
||
{
|
||
URealtimeMeshComponent* Pooled = TileComponentPool.Pop();
|
||
if (IsValid(Pooled))
|
||
{
|
||
Pooled->SetVisibility(true);
|
||
return Pooled;
|
||
}
|
||
}
|
||
|
||
const bool bMeasureComponentPhases = OutCreationSeconds != nullptr
|
||
|| OutRegistrationSeconds != nullptr;
|
||
const uint64 CreationStartCycles = bMeasureComponentPhases
|
||
? FPlatformTime::Cycles64() : 0;
|
||
URealtimeMeshComponent* MeshComp = NewObject<URealtimeMeshComponent>(this);
|
||
// Generated once, never moves → Static so RMC's cached static draw path + VSM shadow
|
||
// caching apply (see the root SetMobility note in BeginPlay). Must be set before register.
|
||
// Re-mesh on carve recreates the section-group proxy (RMC's Static path already does this),
|
||
// which is fine for an infrequent action.
|
||
MeshComp->SetMobility(EComponentMobility::Static);
|
||
MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps
|
||
MeshComp->SetCanEverAffectNavigation(false);
|
||
if (OutCreationSeconds != nullptr)
|
||
{
|
||
*OutCreationSeconds = FPlatformTime::ToSeconds64(
|
||
FPlatformTime::Cycles64() - CreationStartCycles);
|
||
}
|
||
const uint64 RegistrationStartCycles = bMeasureComponentPhases
|
||
? FPlatformTime::Cycles64() : 0;
|
||
MeshComp->RegisterComponent();
|
||
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
|
||
if (OutRegistrationSeconds != nullptr)
|
||
{
|
||
*OutRegistrationSeconds = FPlatformTime::ToSeconds64(
|
||
FPlatformTime::Cycles64() - RegistrationStartCycles);
|
||
}
|
||
if (bOutCreated != nullptr) { *bOutCreated = true; }
|
||
return MeshComp;
|
||
}
|
||
|
||
void AVoxelWorld::ReleaseTileComponent(URealtimeMeshComponent* Comp)
|
||
{
|
||
if (!IsValid(Comp)) { return; }
|
||
|
||
// A pooled component may still have its previous BodySetup while RMC processes the asynchronous
|
||
// RemoveSectionGroup collision update. Disable the component first so that stale geometry can
|
||
// never act as a collision barrier for another tile while it is parked.
|
||
Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
|
||
if (TileComponentPool.Num() >= MaxPooledTileComponents)
|
||
{
|
||
if (URealtimeMeshSimple* RTMesh = Comp->GetRealtimeMeshAs<URealtimeMeshSimple>())
|
||
{
|
||
UnbindRealtimeMeshCollisionEvent(RTMesh);
|
||
}
|
||
Comp->DestroyComponent();
|
||
return;
|
||
}
|
||
|
||
// Strip the tile's geometry NOW, not at reuse: removing the section group drops its
|
||
// sections and their cooked collision, so a parked (hidden) component can't be collided
|
||
// with and its render memory is released while it waits. The mesh OBJECT is kept — reuse
|
||
// goes through the same RemoveSectionGroup/CreateSectionGroup path as a re-mesh.
|
||
if (URealtimeMeshSimple* RTMesh = Comp->GetRealtimeMeshAs<URealtimeMeshSimple>())
|
||
{
|
||
RTMesh->RemoveSectionGroup(VoxelTileGroupKey());
|
||
}
|
||
Comp->SetVisibility(false);
|
||
TileComponentPool.Add(Comp);
|
||
}
|
||
|
||
void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
|
||
{
|
||
// RMC futures are not cancellable from the world side. Remove this tile's state first so a
|
||
// late completion from the old component cannot resurrect readiness after pool reuse.
|
||
// Generation work is different: mark it obsolete and let its normal aborted result drain so
|
||
// PendingTiles cannot be removed on behalf of an older request after a replacement is queued.
|
||
if (TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>* Cancellation =
|
||
PendingTileCancellation.Find(Tile))
|
||
{
|
||
(*Cancellation)->bObsolete.store(true, std::memory_order_release);
|
||
}
|
||
CollisionReadyTiles.Remove(Tile);
|
||
CollisionSuppressedUntilCook.Remove(Tile);
|
||
CollisionSuppressedAtCycles.Remove(Tile);
|
||
CollisionNotRequiredTiles.Remove(Tile);
|
||
CollisionSolidTiles.Remove(Tile);
|
||
PendingCollisionCooks.Remove(Tile);
|
||
// Water + decorations are not tile-bound (water is one player-following ocean plane via
|
||
// UpdateWater; decorations stream by distance via UpdateDecorations) — nothing to clear per tile.
|
||
if (URealtimeMeshComponent** Comp = TileComponents.Find(Tile))
|
||
{
|
||
if (*Comp) { ReleaseTileComponent(*Comp); } // T2.c — park, don't destroy
|
||
TileComponents.Remove(Tile);
|
||
}
|
||
LoadedTiles.Remove(Tile);
|
||
AppliedModificationEpoch.Remove(Tile);
|
||
LoadedTileGeometry.Remove(Tile);
|
||
if (FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this))
|
||
{
|
||
GapAudit->WitnessesByTile.Remove(Tile);
|
||
}
|
||
TransitionHold.Remove(Tile); // couvre aussi le settled cull (qui ne tient pas la hold à jour)
|
||
}
|
||
|
||
bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||
{
|
||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
||
|
||
const bool bProfileApply = GVoxelForgeProfileTileGeneration != 0;
|
||
const uint64 ApplyStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
|
||
// Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller
|
||
// (ProcessPendingChunks skips empty tiles). This path is game-thread-CHEAP: material lookup +
|
||
// component get/create + the upload + per-section config. No per-vertex work here.
|
||
const FVoxelTileKey& Tile = Result.Tile;
|
||
RealtimeMesh::FRealtimeMeshStreamSet& Streams = *Result.Streams;
|
||
const bool bHasGroundTris = Result.bHasGroundTris;
|
||
const bool bHasCeilingTris = Result.bHasCeilingTris;
|
||
const bool bLevel0 = (Tile.Level == 0);
|
||
const bool bWantsCollision = bLevel0 && IsTileCollisionDesired(Tile);
|
||
const bool bCollisionSuppressed = CollisionSuppressedUntilCook.Contains(Tile);
|
||
|
||
// RMC keeps its installed BodySetup active while the replacement collision cooks. Preserve
|
||
// support readiness across that interval when this tile already has a live cooked body; the
|
||
// cook-complete callback below replaces the body and decides readiness for the new geometry.
|
||
// A first cook, an unavailable body, or a tile leaving collision range still withdraws ready.
|
||
URealtimeMeshComponent* ExistingCollisionComponent = TileComponents.FindRef(Tile);
|
||
const bool bRetainInstalledCollisionReady = bWantsCollision && !bCollisionSuppressed
|
||
&& CollisionReadyTiles.Contains(Tile)
|
||
&& IsValid(ExistingCollisionComponent)
|
||
&& ExistingCollisionComponent->GetCollisionEnabled() != ECollisionEnabled::NoCollision
|
||
&& ExistingCollisionComponent->GetBodySetup() != nullptr;
|
||
if (!bRetainInstalledCollisionReady)
|
||
{
|
||
CollisionReadyTiles.Remove(Tile);
|
||
}
|
||
else if (GVoxelForgeDigMotionTests.Contains(this))
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionRetain] tile=(%d,%d,%d) old_body=1 ready_during_recook=1"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z);
|
||
}
|
||
CollisionNotRequiredTiles.Remove(Tile);
|
||
CollisionSolidTiles.Remove(Tile);
|
||
PendingCollisionCooks.Remove(Tile);
|
||
|
||
// F17 — materials per POLYGROUP, not per tile. The mesher classified each triangle
|
||
// semantically (sky-cap = down-facing near the column's CeilSurf; terrain overhangs and
|
||
// future cave roofs stay ground) and packed two contiguous runs → RMC creates one section
|
||
// per non-empty group. Ground (group 0): strate override else global default. Sky-cap
|
||
// (group 1): the strate's CeilingMaterial when set (the rocky "night sky" overhead reads
|
||
// flat/bright otherwise, since it casts no shadow) else same as ground. A coarse tile
|
||
// spanning BOTH surfaces now renders both correctly (the old whole-tile vote painted the
|
||
// loser with the winner's material — Jahni's "terrain and ceiling become one" artifact).
|
||
UMaterialInterface* GroundMaterial = Settings ? Settings->VoxelMaterial : nullptr;
|
||
UMaterialInterface* CeilingMaterial = nullptr;
|
||
if (StrateManager)
|
||
{
|
||
// F17 — a coarse tile is 2^level CHUNKS TALL: its raw min/max corners can sit in a
|
||
// NEIGHBOUR strate (or the inter-strate gap → null) that the mesh doesn't even contain
|
||
// (strate content cut). Clamp the lookup Zs into the band the tile was MESHED with, then
|
||
// resolve: ground at the (clamped) BOTTOM chunk, sky-cap at the (clamped) TOP chunk —
|
||
// the cap is by definition the topmost surface in the tile (mid as a gap fallback).
|
||
// This was the "far cap renders with the ground material" residue: the min-corner lookup
|
||
// missed the surface strate entirely on tall far tiles.
|
||
const FIntVector MinChunk = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner
|
||
const int32 TileChunks = 1 << Tile.Level;
|
||
const int32 ZLoC = FMath::Max(MinChunk.Z, Result.BandChunkLo);
|
||
const int32 ZHiC = FMath::Min(MinChunk.Z + TileChunks - 1, Result.BandChunkHi);
|
||
if (UVoxelStrateDefinition* StrateDef =
|
||
StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, ZLoC)))
|
||
{
|
||
if (StrateDef->OverrideMaterial) { GroundMaterial = StrateDef->OverrideMaterial; }
|
||
if (StrateDef->CeilingMaterial) { CeilingMaterial = StrateDef->CeilingMaterial; }
|
||
}
|
||
UVoxelStrateDefinition* CapDef =
|
||
StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, ZHiC));
|
||
if (!CapDef || !CapDef->CeilingMaterial)
|
||
{
|
||
CapDef = StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, (ZLoC + ZHiC) / 2));
|
||
}
|
||
if (CapDef && CapDef->CeilingMaterial) { CeilingMaterial = CapDef->CeilingMaterial; }
|
||
}
|
||
if (!CeilingMaterial) { CeilingMaterial = GroundMaterial; }
|
||
|
||
// Route the resolved base material through one shared MID per base. With the volume disabled,
|
||
// the MID carries neutral VolP* values and null VolTex* bindings so Custom nodes exit at P5.z=0.
|
||
if (Settings)
|
||
{
|
||
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(GroundMaterial)) { GroundMaterial = MID; }
|
||
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(CeilingMaterial)) { CeilingMaterial = MID; }
|
||
}
|
||
const double MaterialSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles)
|
||
: 0.0;
|
||
|
||
// The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it.
|
||
// Vertices are world-space; the component sits at the actor origin.
|
||
|
||
// One component per tile — the clipmap keeps the total tile count low (~1-2k), so this is
|
||
// cheap on the game thread (no batching needed). Collision + content are level-0 only.
|
||
// T2.c: the component comes from the pool when one is parked (see AcquireTileComponent).
|
||
URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile);
|
||
const bool bComponentAlreadyPresent = MeshComp != nullptr;
|
||
const uint64 ComponentStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
double ComponentCreationSeconds = 0.0;
|
||
double ComponentRegistrationSeconds = 0.0;
|
||
bool bComponentCreated = false;
|
||
if (!MeshComp)
|
||
{
|
||
MeshComp = AcquireTileComponent(
|
||
bProfileApply ? &ComponentCreationSeconds : nullptr,
|
||
bProfileApply ? &ComponentRegistrationSeconds : nullptr,
|
||
bProfileApply ? &bComponentCreated : nullptr);
|
||
TileComponents.Add(Tile, MeshComp);
|
||
}
|
||
if (!IsValid(MeshComp))
|
||
{
|
||
TileComponents.Remove(Tile);
|
||
if (bProfileApply)
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelForgeApplyProfile] level=%d empty=0 failed_component_acquire=1 total=%.6f"),
|
||
Tile.Level,
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles));
|
||
}
|
||
return false;
|
||
}
|
||
const double ComponentSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ComponentStartCycles)
|
||
: 0.0;
|
||
|
||
// §9.4 RENDER-SKIP — a tile only a CollisionOnly anchor wants (not the player clipmap) cooks its
|
||
// collision below but is hidden (no draw / VSM). Set every apply (overrides the pool's default-
|
||
// visible state); ReconcileAnchorTileVisibility handles later flips on already-loaded tiles.
|
||
// During an LOD handoff, keep a newly loaded replacement hidden until the complete covering set
|
||
// is ready. ReconcileReadyTransitionVisibility then hides the retired component and reveals the
|
||
// replacement set in the same game-thread turn, so old and new levels are never drawn together.
|
||
bool bDeferTransitionVisibility = false;
|
||
if (!CollisionOnlyTiles.Contains(Tile) && IsDesired(Tile))
|
||
{
|
||
for (const TPair<FVoxelTileKey, URealtimeMeshComponent*>& Pair : TileComponents)
|
||
{
|
||
if (Pair.Key == Tile || !Pair.Value || IsDesired(Pair.Key)
|
||
|| !Pair.Value->IsVisible())
|
||
{
|
||
continue;
|
||
}
|
||
if (VF_TileFootprintsOverlap(Tile, Pair.Key))
|
||
{
|
||
bDeferTransitionVisibility = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
MeshComp->SetVisibility(
|
||
!CollisionOnlyTiles.Contains(Tile) && !bDeferTransitionVisibility);
|
||
MeshComp->SetCollisionEnabled(
|
||
bWantsCollision && !bCollisionSuppressed
|
||
? ECollisionEnabled::QueryAndPhysics
|
||
: ECollisionEnabled::NoCollision);
|
||
|
||
// Reuse the component's existing mesh object when it has one (pooled component or carve
|
||
// re-mesh) — InitializeRealtimeMesh allocates a brand-new URealtimeMesh EVERY call, so
|
||
// calling it unconditionally (as before) orphaned one mesh object per re-apply to the GC.
|
||
// The RemoveSectionGroup below does the actual geometry clearing on reuse.
|
||
URealtimeMeshSimple* RTMesh = MeshComp->GetRealtimeMeshAs<URealtimeMeshSimple>();
|
||
const uint64 MeshInitStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
if (!RTMesh) { RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>(); }
|
||
const double MeshInitSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - MeshInitStartCycles)
|
||
: 0.0;
|
||
if (!RTMesh)
|
||
{
|
||
if (bProfileApply)
|
||
{
|
||
UE_LOG(LogTemp, Warning,
|
||
TEXT("[VoxelForgeApplyProfile] level=%d empty=0 failed_mesh_init=1 total=%.6f"),
|
||
Tile.Level,
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles));
|
||
}
|
||
TileComponents.Remove(Tile);
|
||
ReleaseTileComponent(MeshComp);
|
||
return false;
|
||
}
|
||
BindRealtimeMeshCollisionEvent(RTMesh);
|
||
// Shadow casting: far (level >= 2) tiles never cast; the sky-cap SECTION never casts either
|
||
// — otherwise the high rock ceiling shadows the entire terrain below it. F17: shadow is
|
||
// PER SECTION, so a mixed tile keeps its ground shadow while its cap stays shadowless.
|
||
const bool bCastShadow = (Tile.Level <= 1);
|
||
MeshComp->SetCastShadow(bCastShadow);
|
||
|
||
const FRealtimeMeshSectionGroupKey GroupKey = VoxelTileGroupKey();
|
||
const uint64 RemoveGroupStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh
|
||
// (no-op on a fresh/pooled mesh)
|
||
const double RemoveGroupSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - RemoveGroupStartCycles)
|
||
: 0.0;
|
||
const uint64 MaterialSlotStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
RTMesh->SetupMaterialSlot(0, "Main", GroundMaterial);
|
||
RTMesh->SetupMaterialSlot(1, "SkyCap", CeilingMaterial);
|
||
const double MaterialSlotSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - MaterialSlotStartCycles)
|
||
: 0.0;
|
||
// RMC owns both the CPU stream-to-buffer conversion and the render-resource upload behind
|
||
// CreateSectionGroup. Keep this as one phase: it is the exact plugin/RMC boundary we can
|
||
// budget without modifying RealtimeMeshComponent.
|
||
const uint64 StreamUploadStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams));
|
||
const uint64 MeshSubmittedCycles = FPlatformTime::Cycles64();
|
||
const double StreamUploadSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - StreamUploadStartCycles)
|
||
: 0.0;
|
||
|
||
// RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the
|
||
// component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy, so the real
|
||
// shadow lever is the section flag. RMC auto-created one section per non-empty polygroup above
|
||
// (default config already maps material slot = polygroup index); only config sections that
|
||
// exist — the bHas* flags come from the worker. Collision at level 0 only (T1.c), both groups.
|
||
const uint64 CollisionConfigStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||
if (bHasGroundTris)
|
||
{
|
||
FRealtimeMeshSectionConfig GroundConfig(0);
|
||
GroundConfig.bCastsShadow = bCastShadow;
|
||
RTMesh->UpdateSectionConfig(
|
||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0),
|
||
GroundConfig, /*bShouldCreateCollision*/ bWantsCollision);
|
||
}
|
||
if (bHasCeilingTris)
|
||
{
|
||
FRealtimeMeshSectionConfig CapConfig(1);
|
||
CapConfig.bCastsShadow = false; // the cap never casts (see above)
|
||
RTMesh->UpdateSectionConfig(
|
||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 1),
|
||
CapConfig, /*bShouldCreateCollision*/ bWantsCollision);
|
||
}
|
||
const double CollisionConfigSeconds = bProfileApply
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - CollisionConfigStartCycles)
|
||
: 0.0;
|
||
|
||
if (bWantsCollision)
|
||
{
|
||
if (GVoxelForgeProfileTileGeneration != 0)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeCollisionCookSubmitted] tile=(%d,%d,%d) level=0"),
|
||
Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z);
|
||
}
|
||
// RMC's collision future is fulfilled only after its async cook has been applied to the
|
||
// mesh BodySetup. This is the per-apply completion boundary; no submission-time guess is
|
||
// promoted to CollisionReadyTiles.
|
||
const uint64 SubmissionId = ++NextCollisionSubmissionId;
|
||
const FRealtimeMeshCollisionConfiguration CollisionConfig = RTMesh->GetCollisionConfig();
|
||
const uint64 CollisionSubmittedCycles = FPlatformTime::Cycles64();
|
||
|
||
FPendingCollisionCook Pending;
|
||
Pending.Mesh = RTMesh;
|
||
Pending.SubmissionId = SubmissionId;
|
||
Pending.MeshSubmittedCycles = MeshSubmittedCycles;
|
||
Pending.CollisionSubmittedCycles = CollisionSubmittedCycles;
|
||
Pending.CollisionSubmittedFrame = GFrameCounter;
|
||
Pending.RequestStartCycles = Result.RequestStartCycles;
|
||
Pending.GenerationStartCycles = Result.GenerationStartCycles;
|
||
Pending.GenerationEndCycles = Result.GenerationEndCycles;
|
||
Pending.ApplyStartCycles = Result.ApplyStartCycles;
|
||
Pending.WorkPriority = Result.WorkPriority;
|
||
Pending.NumVertices = Result.NumVertices;
|
||
Pending.NumTriangles = Result.NumTriangles;
|
||
Pending.NumSkirtTriangles = Result.NumSkirtTriangles;
|
||
Pending.PendingCooksAtSubmit = PendingCollisionCooks.Num();
|
||
Pending.bUseAsyncCook = CollisionConfig.bUseAsyncCook;
|
||
Pending.bUseComplexAsSimpleCollision = CollisionConfig.bUseComplexAsSimpleCollision;
|
||
Pending.bShouldFastCookMeshes = CollisionConfig.bShouldFastCookMeshes;
|
||
Pending.bMergeAllMeshes = CollisionConfig.bMergeAllMeshes;
|
||
Pending.SuppressedAtCycles = CollisionSuppressedAtCycles.FindRef(Tile);
|
||
Pending.bRecordStreamingLatency = Result.bRecordStreamingLatency;
|
||
PendingCollisionCooks.Add(Tile, Pending);
|
||
|
||
// Register the serial before asking RMC to dirty collision. The current implementation
|
||
// defers the future to end-of-frame, but this ordering also keeps an already-fulfilled
|
||
// future or an inline body-update event from racing the per-tile record.
|
||
TFuture<ERealtimeMeshCollisionUpdateResult> CollisionFuture =
|
||
RTMesh->SetCollisionConfig(CollisionConfig);
|
||
|
||
TWeakObjectPtr<AVoxelWorld> WeakWorld(this);
|
||
TWeakObjectPtr<URealtimeMesh> WeakMesh(RTMesh);
|
||
CollisionFuture.Next(
|
||
[WeakWorld, WeakMesh, Tile, SubmissionId](ERealtimeMeshCollisionUpdateResult CollisionResult)
|
||
{
|
||
if (AVoxelWorld* World = WeakWorld.Get())
|
||
{
|
||
World->HandleTileCollisionCookComplete(
|
||
Tile, SubmissionId, WeakMesh.Get(), static_cast<uint8>(CollisionResult),
|
||
FPlatformTime::Cycles64());
|
||
}
|
||
});
|
||
}
|
||
|
||
if (bProfileApply)
|
||
{
|
||
const double TotalSeconds = FPlatformTime::ToSeconds64(
|
||
FPlatformTime::Cycles64() - ApplyStartCycles);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeApplyProfile] level=%d empty=0 created=%d pooled=%d "
|
||
"material=%.6f component=%.6f component_create=%.6f "
|
||
"component_register=%.6f mesh_init=%.6f remove_group=%.6f "
|
||
"material_slots=%.6f stream_upload=%.6f create_section_group=%.6f "
|
||
"collision_config=%.6f total=%.6f"),
|
||
Tile.Level,
|
||
bComponentCreated ? 1 : 0,
|
||
(!bComponentAlreadyPresent && !bComponentCreated) ? 1 : 0,
|
||
MaterialSeconds,
|
||
ComponentSeconds,
|
||
ComponentCreationSeconds,
|
||
ComponentRegistrationSeconds,
|
||
MeshInitSeconds,
|
||
RemoveGroupSeconds,
|
||
MaterialSlotSeconds,
|
||
StreamUploadSeconds,
|
||
StreamUploadSeconds,
|
||
CollisionConfigSeconds,
|
||
TotalSeconds);
|
||
}
|
||
|
||
// Water is not spawned per tile — it's a single player-following ocean plane (UpdateWater,
|
||
// driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps.
|
||
return true;
|
||
}
|
||
|
||
//=============================================================================
|
||
// STRATE QUERIES
|
||
//=============================================================================
|
||
|
||
int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
|
||
{
|
||
if (!StrateManager) return -1;
|
||
|
||
// GetStrateIndex veut des cm ACTEUR-LOCAUX (il convertit cm -> voxels lui-meme).
|
||
// GetStrateIndex wants actor-LOCAL cm (it does the cm -> voxel conversion itself).
|
||
return StrateManager->GetStrateIndex(WorldToLocalCm(WorldPosition).Z);
|
||
}
|
||
|
||
FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const
|
||
{
|
||
FVoxelBiomeQuery Out;
|
||
if (!Generator) return Out;
|
||
|
||
// Bring the world point into actor-LOCAL voxel space — the SAME transform the decoration scatter
|
||
// applies (UpdateDecorations), so the probe agrees with where props actually land.
|
||
const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation);
|
||
const float VX = Local.X / VOXEL_SIZE;
|
||
const float VY = Local.Y / VOXEL_SIZE;
|
||
const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||
|
||
Generator->QueryBiomeAt(VX, VY, ChunkZ, Out);
|
||
|
||
// Decoration streaming state of the region under this point — discriminates a render drop / empty
|
||
// march / stuck build / never-requested region for a visibly-bare patch (see FVoxelBiomeQuery).
|
||
if (ContentManager)
|
||
{
|
||
ContentManager->QueryDecoDebugAt(Local, Out.bDecoRegionApplied, Out.DecoAppliedInstances,
|
||
Out.bDecoRegionBuilding, Out.DecoCellsAccounted, Out.DecoCellsTotal,
|
||
Out.DecoLiveMarchSpawns, Out.DecoInstancesInCell);
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
bool AVoxelWorld::GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfaceWorldZ, float& OutCeilingWorldZ) const
|
||
{
|
||
OutSurfaceWorldZ = WorldLocation.Z; // sensible fallback: unchanged Z
|
||
OutCeilingWorldZ = WorldLocation.Z;
|
||
if (!Generator) return false;
|
||
|
||
// Undo the actor transform → voxel space (same convention as GetBiomeAtWorldLocation / the deco scatter).
|
||
const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation);
|
||
const float VX = Local.X / VOXEL_SIZE;
|
||
const float VY = Local.Y / VOXEL_SIZE;
|
||
const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||
|
||
float TerrainVZ, CeilVZ;
|
||
if (!Generator->GetSurfaceHeightAt(VX, VY, ChunkZ, TerrainVZ, CeilVZ)) return false; // not a heightfield
|
||
|
||
// Voxel Z → actor-local cm → world, keeping the query's XY so a tilted/scaled actor stays consistent.
|
||
OutSurfaceWorldZ = GetActorTransform().TransformPosition(FVector(Local.X, Local.Y, TerrainVZ * VOXEL_SIZE)).Z;
|
||
OutCeilingWorldZ = GetActorTransform().TransformPosition(FVector(Local.X, Local.Y, CeilVZ * VOXEL_SIZE)).Z;
|
||
return true;
|
||
}
|
||
|
||
//=============================================================================
|
||
// TERRAIN MODIFICATION — player carving & filling
|
||
//=============================================================================
|
||
|
||
// All brush entry points below build an FVoxelModification and funnel through ApplyModification
|
||
// (diff layer + re-mesh). Strength sign convention: NEGATIVE = carve (air), POSITIVE = fill (solid).
|
||
|
||
void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Center = WorldToLocalVoxel(Position); // world cm → voxel space
|
||
Mod.Radius = Radius;
|
||
Mod.Strength = -FMath::Abs(Strength); // force negative for carving
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Center = WorldToLocalVoxel(Position);
|
||
Mod.Radius = Radius;
|
||
Mod.Strength = FMath::Abs(Strength); // force positive for filling
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::ApplyModification(const FVoxelModification& Modification)
|
||
{
|
||
if (!DiffLayer) return;
|
||
const bool bProfileModification = GVoxelForgeProfileTileGeneration != 0;
|
||
const uint64 ModificationStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
const uint64 DiffStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
TArray<FIntVector> AffectedChunks = DiffLayer->ApplyModification(Modification);
|
||
const double DiffSeconds = bProfileModification
|
||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DiffStartCycles)
|
||
: 0.0;
|
||
|
||
// If a carve removes the surface directly beneath this pawn, an installed old BodySetup would
|
||
// otherwise keep it standing on geometry that the diff has already removed. Suppress only the
|
||
// current support tile, and only when the brush bounds intersect the pawn bounds; nearby digs
|
||
// keep their existing same-tile recook handoff.
|
||
if (Modification.Strength < 0.0f && AffectedChunks.Num() > 0)
|
||
{
|
||
FVector PlayerPosition = FVector::ZeroVector;
|
||
APawn* Pawn = nullptr;
|
||
FVoxelTileKey SupportTile;
|
||
if (TryGetPlayerPosition(PlayerPosition, &Pawn)
|
||
&& GetPlayerSupportTile(Pawn, PlayerPosition, SupportTile)
|
||
&& AffectedChunks.Contains(SupportTile.Coord)
|
||
&& IsTileCollisionReady(SupportTile))
|
||
{
|
||
FVector EditMinVoxel, EditMaxVoxel;
|
||
Modification.GetWorldBounds(EditMinVoxel, EditMaxVoxel);
|
||
FBox EditWorldBounds(ForceInit);
|
||
for (int32 Corner = 0; Corner < 8; ++Corner)
|
||
{
|
||
const FVector LocalVoxel(
|
||
(Corner & 1) != 0 ? EditMaxVoxel.X : EditMinVoxel.X,
|
||
(Corner & 2) != 0 ? EditMaxVoxel.Y : EditMinVoxel.Y,
|
||
(Corner & 4) != 0 ? EditMaxVoxel.Z : EditMinVoxel.Z);
|
||
EditWorldBounds += LocalVoxelToWorld(LocalVoxel);
|
||
}
|
||
|
||
FBox PawnWorldBounds(ForceInit);
|
||
if (const ACharacter* Character = Cast<ACharacter>(Pawn))
|
||
{
|
||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||
{
|
||
PawnWorldBounds = Capsule->Bounds.GetBox();
|
||
}
|
||
}
|
||
if (!PawnWorldBounds.IsValid)
|
||
{
|
||
FVector PawnBoundsOrigin = FVector::ZeroVector;
|
||
FVector PawnBoundsExtent = FVector::ZeroVector;
|
||
Pawn->GetActorBounds(true, PawnBoundsOrigin, PawnBoundsExtent);
|
||
PawnWorldBounds = FBox(
|
||
PawnBoundsOrigin - PawnBoundsExtent,
|
||
PawnBoundsOrigin + PawnBoundsExtent);
|
||
}
|
||
|
||
if (EditWorldBounds.Intersect(PawnWorldBounds))
|
||
{
|
||
if (URealtimeMeshComponent* SupportComponent = TileComponents.FindRef(SupportTile))
|
||
{
|
||
CollisionSuppressedUntilCook.Add(SupportTile);
|
||
CollisionSuppressedAtCycles.Add(SupportTile, FPlatformTime::Cycles64());
|
||
CollisionReadyTiles.Remove(SupportTile);
|
||
// A prior cook can still complete after this edit. Remove its serial so that
|
||
// its completion cannot restore readiness over the replacement geometry.
|
||
PendingCollisionCooks.Remove(SupportTile);
|
||
SupportComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeUnderfootCollisionSuppressed] tile=(%d,%d,%d) "
|
||
"frame=%llu brush_capsule_bounds_intersect=1"),
|
||
SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z,
|
||
static_cast<unsigned long long>(GFrameCounter));
|
||
UpdatePawnCollisionGate(Pawn, PlayerPosition, 0.0f);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (AffectedChunks.Num() > 0)
|
||
{
|
||
++ModificationEpoch;
|
||
Modification.GetWorldBounds(LastModificationMinVoxel, LastModificationMaxVoxel);
|
||
if (FVisualGapAuditState* GapAudit = GVoxelForgeVisualGapAudits.Find(this))
|
||
{
|
||
GapAudit->EditedBounds.Emplace(LastModificationMinVoxel, LastModificationMaxVoxel);
|
||
if (GapAudit->EditedBounds.Num() > 256)
|
||
{
|
||
GapAudit->EditedBounds.RemoveAt(0, GapAudit->EditedBounds.Num() - 256, EAllowShrinking::No);
|
||
}
|
||
}
|
||
FModificationRevision Revision;
|
||
Revision.Epoch = ModificationEpoch;
|
||
Revision.MinVoxel = LastModificationMinVoxel;
|
||
Revision.MaxVoxel = LastModificationMaxVoxel;
|
||
ModificationRevisions.Add(Revision);
|
||
if (bVisualStalenessAudit && VisualStalenessAuditMode == 1)
|
||
{
|
||
VisualAuditEditEpoch = ModificationEpoch;
|
||
VisualAuditEditMinVoxel = LastModificationMinVoxel;
|
||
VisualAuditEditMaxVoxel = LastModificationMaxVoxel;
|
||
VisualAuditEditBeginSeconds = FPlatformTime::Seconds();
|
||
VisualAuditEditStaleDurationSeconds = -1.0;
|
||
VisualAuditEditMaxStaleTiles = 0;
|
||
bVisualAuditEditSawStale = false;
|
||
bVisualAuditEditResolved = false;
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeVisualStaleness] edit_begin=1 epoch=%u bounds_min=(%.2f,%.2f,%.2f) "
|
||
"bounds_max=(%.2f,%.2f,%.2f)"),
|
||
VisualAuditEditEpoch,
|
||
VisualAuditEditMinVoxel.X, VisualAuditEditMinVoxel.Y, VisualAuditEditMinVoxel.Z,
|
||
VisualAuditEditMaxVoxel.X, VisualAuditEditMaxVoxel.Y, VisualAuditEditMaxVoxel.Z);
|
||
}
|
||
}
|
||
|
||
// INSTANT DIG FEEL — synchronously re-mesh the level-0 tile the brush CENTRE sits in, so the hole
|
||
// appears THIS frame right where the player is looking. Neighbour tiles (brush edge) re-mesh async
|
||
// and prioritised (RemeshDirtyChunks → DirtyRemeshQueue @ BackgroundHigh), a frame or two behind —
|
||
// imperceptible. One full-res tile gen on the game thread; only for the common case.
|
||
// SKIP the sync when the centre tile is already mid-gen (an async task owns it): syncing would race
|
||
// the in-flight stale result (which lands with no hole and would clobber ours). Instead let it flow
|
||
// through the async queue, which now KEEPS in-flight tiles queued and re-gens them once the stale
|
||
// result lands. The centre tile must be loaded to remesh in place (else it streams in with the diff).
|
||
const FVoxelTileKey CenterTile(WorldToChunkCoord(Modification.Center * VOXEL_SIZE), 0);
|
||
bool bSyncedCenter = false;
|
||
if (AffectedChunks.Contains(CenterTile.Coord)
|
||
&& LoadedTiles.Contains(CenterTile)
|
||
&& !PendingTiles.Contains(CenterTile))
|
||
{
|
||
const uint64 SyncStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
SyncRemeshTile(CenterTile);
|
||
if (bProfileModification)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeModificationProfile] center_sync=%.6f"),
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - SyncStartCycles));
|
||
}
|
||
bSyncedCenter = true;
|
||
}
|
||
|
||
const uint64 QueueStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0;
|
||
RemeshDirtyChunks(AffectedChunks, bSyncedCenter ? &CenterTile : nullptr);
|
||
if (bProfileModification)
|
||
{
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeModificationProfile] affected=%d diff=%.6f queue=%.6f total=%.6f "
|
||
"center_synced=%d"),
|
||
AffectedChunks.Num(),
|
||
DiffSeconds,
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - QueueStartCycles),
|
||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ModificationStartCycles),
|
||
bSyncedCenter ? 1 : 0);
|
||
}
|
||
|
||
// Invalidate the complete decoration build state after a terrain edit. This removes live instances,
|
||
// abandons in-flight pre-edit marches, and forces the next stationary update to resample the edited
|
||
// field. Clearing all regions is deliberately conservative: FVoxelModification is actor-local voxel
|
||
// geometry and may be a box/capsule, while decoration transforms are world-space; a partial sphere
|
||
// conversion here would risk leaving floaters or allowing an old result to resurrect one.
|
||
if (ContentManager && AffectedChunks.Num() > 0)
|
||
{
|
||
ContentManager->InvalidateDecorationBuilds();
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Box;
|
||
Mod.Center = WorldToLocalVoxel(Position);
|
||
Mod.BoxExtent = ExtentVoxels;
|
||
Mod.Radius = ExtentVoxels.GetMax(); // budget proxy
|
||
Mod.Strength = -FMath::Abs(Strength); // carve
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Box;
|
||
Mod.Center = WorldToLocalVoxel(Position);
|
||
Mod.BoxExtent = ExtentVoxels;
|
||
Mod.Radius = ExtentVoxels.GetMax();
|
||
Mod.Strength = FMath::Abs(Strength); // fill
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Capsule;
|
||
Mod.Center = WorldToLocalVoxel(WorldA);
|
||
Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
|
||
Mod.Radius = RadiusVoxels;
|
||
Mod.Strength = -FMath::Abs(Strength);
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels, float Strength)
|
||
{
|
||
FVoxelModification Mod;
|
||
Mod.Shape = EVoxelBrushShape::Capsule;
|
||
Mod.Center = WorldToLocalVoxel(WorldA);
|
||
Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
|
||
Mod.Radius = RadiusVoxels;
|
||
Mod.Strength = FMath::Abs(Strength);
|
||
ApplyModification(Mod);
|
||
}
|
||
|
||
void AVoxelWorld::EditorCarveSphere()
|
||
{
|
||
if (!DiffLayer)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EditorCarveSphere: no DiffLayer (start PIE first)."));
|
||
return;
|
||
}
|
||
CarveAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength);
|
||
}
|
||
|
||
void AVoxelWorld::EditorFillSphere()
|
||
{
|
||
if (!DiffLayer)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EditorFillSphere: no DiffLayer (start PIE first)."));
|
||
return;
|
||
}
|
||
FillAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength);
|
||
}
|
||
|
||
//=============================================================================
|
||
// BIOME MAP PREVIEW — bake the XY biome field to Saved/BiomePreview.png
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::BakeBiomePreview()
|
||
{
|
||
if (!BiomePreviewStrate)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] BakeBiomePreview: assign BiomePreviewStrate first."));
|
||
return;
|
||
}
|
||
if (BiomePreviewChannel == EBiomePreviewChannel::Biome && BiomePreviewStrate->Biomes.Num() == 0)
|
||
{
|
||
UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] BakeBiomePreview: '%s' has no Biomes — bake Relief/Moisture instead, or add biomes."),
|
||
*BiomePreviewStrate->GetName());
|
||
return;
|
||
}
|
||
|
||
// Transient generator so this works in the editor without PIE.
|
||
UVoxelGenerator* Gen = NewObject<UVoxelGenerator>(this);
|
||
Gen->Seed = Settings ? Settings->Seed : 0;
|
||
|
||
// Flatten the strate's biomes into a context (mirrors StrateManager::GetBiomeContextForChunk).
|
||
FBiomeContext Ctx;
|
||
Ctx.Map = BiomePreviewStrate->BiomeMapParams;
|
||
for (int32 i = 0; i < BiomePreviewStrate->Biomes.Num(); ++i)
|
||
{
|
||
const UVoxelBiomeDefinition* B = BiomePreviewStrate->Biomes[i];
|
||
if (!B) continue;
|
||
FBiomeResolved R;
|
||
R.Index = i;
|
||
R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax;
|
||
R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax;
|
||
R.DebugColor = B->DebugColor.ToFColor(true);
|
||
Ctx.Biomes.Add(R);
|
||
}
|
||
|
||
const int32 Res = FMath::Clamp(BiomePreviewResolution, 64, 2048);
|
||
const float Size = FMath::Max(BiomePreviewWorldSize, 1.0f);
|
||
const float Step = Size / (float)Res;
|
||
const float OriginX = BiomePreviewCenter.X - Size * 0.5f;
|
||
const float OriginY = BiomePreviewCenter.Y - Size * 0.5f;
|
||
|
||
TArray<FColor> Pixels;
|
||
Pixels.SetNumUninitialized(Res * Res);
|
||
|
||
for (int32 py = 0; py < Res; ++py)
|
||
for (int32 px = 0; px < Res; ++px)
|
||
{
|
||
const float wx = OriginX + (px + 0.5f) * Step;
|
||
const float wy = OriginY + (py + 0.5f) * Step;
|
||
|
||
FColor C = FColor::Black;
|
||
switch (BiomePreviewChannel)
|
||
{
|
||
case EBiomePreviewChannel::Relief:
|
||
{
|
||
const float r = Gen->SampleRelief(wx, wy, Ctx.Map.ReliefFrequency, Ctx.Map.ReliefContrast);
|
||
const uint8 v = (uint8)FMath::Clamp(r * 255.0f, 0.0f, 255.0f);
|
||
C = FColor(v, v, v);
|
||
break;
|
||
}
|
||
case EBiomePreviewChannel::Moisture:
|
||
{
|
||
const float m = Gen->SampleMoisture(wx, wy, Ctx.Map.MoistureFrequency);
|
||
const uint8 v = (uint8)FMath::Clamp(m * 255.0f, 0.0f, 255.0f);
|
||
C = FColor(0, v, (uint8)(255 - v)); // dry=blue → wet=green
|
||
break;
|
||
}
|
||
default: // Biome
|
||
{
|
||
const FBiomeSample S = Gen->SampleBiomeAt(wx, wy, Ctx);
|
||
if (Ctx.Biomes.IsValidIndex(S.DominantIndex))
|
||
{
|
||
C = Ctx.Biomes[S.DominantIndex].DebugColor;
|
||
if (S.NeighborWeight > 0.0f && Ctx.Biomes.IsValidIndex(S.NeighborIndex))
|
||
{
|
||
const FLinearColor A(C);
|
||
const FLinearColor Bn(Ctx.Biomes[S.NeighborIndex].DebugColor);
|
||
C = FLinearColor::LerpUsingHSV(A, Bn, S.NeighborWeight).ToFColor(true);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
C.A = 255;
|
||
Pixels[py * Res + px] = C;
|
||
}
|
||
|
||
// Encode PNG and write to Saved/.
|
||
IImageWrapperModule& Module = FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
|
||
const TSharedPtr<IImageWrapper> Wrapper = Module.CreateImageWrapper(EImageFormat::PNG);
|
||
if (!Wrapper.IsValid() ||
|
||
!Wrapper->SetRaw(Pixels.GetData(), (int64)Pixels.Num() * sizeof(FColor), Res, Res, ERGBFormat::BGRA, 8))
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] BakeBiomePreview: failed to encode image."));
|
||
return;
|
||
}
|
||
|
||
const TArray64<uint8>& Png = Wrapper->GetCompressed(100);
|
||
const FString Path = FPaths::ProjectSavedDir() / TEXT("BiomePreview.png");
|
||
if (FFileHelper::SaveArrayToFile(Png, *Path))
|
||
{
|
||
UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] Biome preview (%dx%d, %s) saved to %s"),
|
||
Res, Res, *UEnum::GetValueAsString(BiomePreviewChannel), *FPaths::ConvertRelativePathToFull(Path));
|
||
}
|
||
else
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] BakeBiomePreview: failed to write %s"), *Path);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::ClearAllModifications()
|
||
{
|
||
if (!DiffLayer) return;
|
||
|
||
DiffLayer->Clear();
|
||
|
||
// Regenerate all loaded chunks to restore procedural terrain
|
||
RegenerateAllChunks();
|
||
}
|
||
|
||
//=============================================================================
|
||
// SEED / SEASON MANAGEMENT
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::ChangeSeed(int32 NewSeed)
|
||
{
|
||
if (!Settings)
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned"));
|
||
return;
|
||
}
|
||
if (!Settings->Season.IsNull())
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelWorld] ChangeSeed rejected: a cooked Season owns the seed. Assign another season asset instead."));
|
||
return;
|
||
}
|
||
|
||
const int32 OldSeed = Settings->Seed;
|
||
const int32 OldSeason = Settings->CurrentSeason;
|
||
|
||
{
|
||
FScopedGenerationPause Guard(this);
|
||
if (!Guard.Acquired())
|
||
{
|
||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed: generation pause timed out; no mutation applied."));
|
||
return;
|
||
}
|
||
|
||
// 1. Update seed in Settings (the authoritative source)
|
||
Settings->Seed = NewSeed;
|
||
|
||
// 2. Increment season counter
|
||
Settings->CurrentSeason++;
|
||
|
||
// 3. Push new seed to Generator
|
||
if (Generator)
|
||
{
|
||
Generator->InitializeSettings(Settings);
|
||
}
|
||
|
||
// 4. Rebuild strate layout with the new seed.
|
||
// Strate assignments and passages all change.
|
||
if (StrateManager)
|
||
{
|
||
StrateManager->Initialize(Settings, NewSeed);
|
||
}
|
||
|
||
// 5. Clear all player modifications — carvings from the old world are meaningless
|
||
if (DiffLayer)
|
||
{
|
||
DiffLayer->Clear();
|
||
}
|
||
|
||
// 5b. Update content placement seed so the new world scatters differently.
|
||
if (ContentManager)
|
||
{
|
||
ContentManager->SetSeed(NewSeed);
|
||
ContentManager->ClearAll();
|
||
}
|
||
|
||
// 5c. Reset atmosphere — strate layout changed, re-apply on next Tick.
|
||
if (AtmosphereManager)
|
||
{
|
||
AtmosphereManager->Reset();
|
||
}
|
||
}
|
||
|
||
// 6. Unload all existing chunks and let Tick reload them with new generation
|
||
RegenerateAllChunks();
|
||
|
||
UE_LOG(LogTemp, Log,
|
||
TEXT("[VoxelWorld] Seed changed: %d -> %d (Season %d -> %d). All chunks regenerated, mods cleared."),
|
||
OldSeed, NewSeed, OldSeason, Settings->CurrentSeason);
|
||
}
|
||
|
||
int32 AVoxelWorld::GetCurrentSeed() const
|
||
{
|
||
return Settings ? Settings->GetEffectiveWorldSeed() : 0;
|
||
}
|
||
|
||
int32 AVoxelWorld::GetCurrentSeason() const
|
||
{
|
||
return Settings ? Settings->GetEffectiveSeasonNumber() : 0;
|
||
}
|
||
|
||
FString AVoxelWorld::GetCurrentSeasonContentHash() const
|
||
{
|
||
return StrateManager ? StrateManager->GetSeasonContentHash() : FString();
|
||
}
|
||
|
||
//=============================================================================
|
||
// REMESH DIRTY CHUNKS — re-queue affected chunks after terrain modification
|
||
//=============================================================================
|
||
|
||
void AVoxelWorld::RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords, const FVoxelTileKey* ExcludeTile)
|
||
{
|
||
// An edit changes every tile whose footprint intersects its affected level-0 chunks, including
|
||
// coarse render tiles. Re-meshing only level 0 leaves a pre-edit coarse component covering the
|
||
// same volume until a later LOD transition. The queue is drained FIRST and launched at
|
||
// BackgroundHigh; the old component remains visible until its post-edit replacement is applied.
|
||
auto TileCoversDirtyCoord = [&DirtyCoords](const FVoxelTileKey& Tile) -> bool
|
||
{
|
||
const int32 Span = 1 << Tile.Level;
|
||
const FIntVector MinChunk = Tile.Coord * Span;
|
||
const FIntVector MaxChunk = MinChunk + FIntVector(Span, Span, Span);
|
||
for (const FIntVector& Coord : DirtyCoords)
|
||
{
|
||
if (Coord.X >= MinChunk.X && Coord.X < MaxChunk.X
|
||
&& Coord.Y >= MinChunk.Y && Coord.Y < MaxChunk.Y
|
||
&& Coord.Z >= MinChunk.Z && Coord.Z < MaxChunk.Z)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
for (const FVoxelTileKey& Tile : LoadedTiles)
|
||
{
|
||
if (ExcludeTile && Tile == *ExcludeTile) continue; // handled synchronously this frame
|
||
if (IsDesired(Tile) && TileCoversDirtyCoord(Tile))
|
||
{
|
||
DirtyRemeshQueue.Add(Tile);
|
||
}
|
||
}
|
||
|
||
// A pending worker captured the pre-edit field. Mark its token obsolete so its result drains
|
||
// without publishing; keep the key in DirtyRemeshQueue so the submit budget requests it again.
|
||
for (const FVoxelTileKey& Tile : PendingTiles)
|
||
{
|
||
if (!IsDesired(Tile) || !TileCoversDirtyCoord(Tile)) continue;
|
||
DirtyRemeshQueue.Add(Tile);
|
||
if (TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>* Cancellation =
|
||
PendingTileCancellation.Find(Tile))
|
||
{
|
||
if (*Cancellation)
|
||
{
|
||
(*Cancellation)->bObsolete.store(true, std::memory_order_release);
|
||
}
|
||
}
|
||
}
|
||
if (DirtyRemeshQueue.Num() > 0)
|
||
{
|
||
// Wake the submit loop even if the streaming set had settled (idle player digging).
|
||
bAllChunksLoaded = false;
|
||
}
|
||
|
||
// Density volume: refill the clipmap cells overlapping each carved chunk so the shadow march
|
||
// sees the edit (GetDensityAt includes the diff layer). Cheap + local; covers all carve shapes.
|
||
if (DensityVolume && Settings && Settings->bEnableDensityVolume)
|
||
{
|
||
for (const FIntVector& Coord : DirtyCoords)
|
||
{
|
||
const FIntVector MinV = Coord * CHUNK_SIZE;
|
||
const FIntVector MaxV = MinV + FIntVector(CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE);
|
||
DensityVolume->MarkDirtyVoxelBox(MinV, MaxV);
|
||
}
|
||
}
|
||
|
||
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d pending"),
|
||
DirtyCoords.Num(), PendingTiles.Num());
|
||
}
|
||
|
||
UVolumeTexture* AVoxelWorld::GetDensityVolumeTexture(int32 Level) const
|
||
{
|
||
return DensityVolume ? DensityVolume->GetLevelTexture(Level) : nullptr;
|
||
}
|
||
|
||
//=============================================================================
|
||
// TERRAIN MATERIAL — density-volume / orb shadow params (MID-driven, see ApplyMeshToTile)
|
||
//=============================================================================
|
||
|
||
UMaterialInstanceDynamic* AVoxelWorld::GetOrCreateTerrainMID(UMaterialInterface* Base)
|
||
{
|
||
if (!Base) return nullptr;
|
||
if (TObjectPtr<UMaterialInstanceDynamic>* Found = TerrainMIDs.Find(Base))
|
||
{
|
||
return Found->Get();
|
||
}
|
||
UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(Base, this);
|
||
if (MID)
|
||
{
|
||
TerrainMIDs.Add(Base, MID);
|
||
SetVolumeParamsOnMID(MID); // seed with the current frame's params
|
||
}
|
||
return MID;
|
||
}
|
||
|
||
void AVoxelWorld::SetDensityVolumeMaterialParamsDisabled()
|
||
{
|
||
TVP0 = FLinearColor::Black;
|
||
TVP1 = FLinearColor::Black;
|
||
TVP2 = FLinearColor::Black;
|
||
TVP3 = FLinearColor::Black;
|
||
TVP4 = FLinearColor::Black;
|
||
TVP5 = FLinearColor(0.0f, VOXEL_SIZE, 0.0f, 0.0f);
|
||
TVP6 = FLinearColor::Black;
|
||
TVP7 = FLinearColor::Black;
|
||
TVP8 = FLinearColor::Black;
|
||
TVP9 = FLinearColor::Black;
|
||
LastBoundVolTex0.Reset();
|
||
|
||
for (TPair<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>>& Pair : TerrainMIDs)
|
||
{
|
||
SetVolumeParamsOnMID(Pair.Value.Get());
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeDensityVolume] disabled=1 fill_thread=0 uploads=0 tick_update=0 "
|
||
"VolP0..VolP4=black VolP5=(0,%.1f,0) VolP6..VolP9=black VolTex0..VolTex2=null"),
|
||
VOXEL_SIZE);
|
||
}
|
||
|
||
void AVoxelWorld::SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const
|
||
{
|
||
if (!MID) return;
|
||
// Static FNames — this runs per MID on every param change; no per-call FName construction.
|
||
static const FName VolPNames[10] = {
|
||
FName("VolP0"), FName("VolP1"), FName("VolP2"), FName("VolP3"), FName("VolP4"),
|
||
FName("VolP5"), FName("VolP6"), FName("VolP7"), FName("VolP8"), FName("VolP9") };
|
||
static const FName VolTexNames[3] = { FName("VolTex0"), FName("VolTex1"), FName("VolTex2") };
|
||
|
||
const FLinearColor* TVPs[10] = { &TVP0, &TVP1, &TVP2, &TVP3, &TVP4, &TVP5, &TVP6, &TVP7, &TVP8, &TVP9 };
|
||
for (int32 i = 0; i < 10; ++i) { MID->SetVectorParameterValue(VolPNames[i], *TVPs[i]); }
|
||
const bool bVolumeActive = DensityVolume && Settings && Settings->bEnableDensityVolume;
|
||
for (int32 L = 0; L < 3; ++L)
|
||
{
|
||
UVolumeTexture* Texture = bVolumeActive ? DensityVolume->GetLevelTexture(L) : nullptr;
|
||
MID->SetTextureParameterValue(VolTexNames[L], Texture);
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::UpdateTerrainMaterialParams()
|
||
{
|
||
if (!DensityVolume || !Settings || !Settings->bEnableDensityVolume) return;
|
||
|
||
// --- Per-level clipmap transforms (L0 = finest/near; L1-2 = coarser for shadow REACH) ---
|
||
// For each level the material maps WorldPos → RelPos = WorldPos - WindowOrigin → cellF = RelPos/Cell →
|
||
// toroidal UVW = frac((OriginMod + cellF + 0.5)/Res). OriginMod = OriginCells mod Res precomputed here
|
||
// so the shader never touches the large absolute cell coord (no float precision loss). The shader
|
||
// derives each level's cell size from L0's (cell_L = L0Cell * 2^L); Res is shared across levels.
|
||
const FTransform Xf = GetActorTransform();
|
||
int32 Res = 0;
|
||
bool bHave = false;
|
||
float CellWorldSize = VOXEL_SIZE; // L0 cm per cell
|
||
|
||
FLinearColor OriginC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
|
||
FLinearColor ModC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
|
||
for (int32 L = 0; L < 3; ++L)
|
||
{
|
||
FIntVector OriginCells(0, 0, 0);
|
||
float StepF = 1.0f;
|
||
int32 LRes = 0;
|
||
if (DensityVolume->GetLevelShaderParams(L, OriginCells, StepF, LRes) && LRes > 0)
|
||
{
|
||
const FVector OriginLocalCm = FVector(OriginCells.X, OriginCells.Y, OriginCells.Z) * (StepF * VOXEL_SIZE);
|
||
const FVector OW = Xf.TransformPosition(OriginLocalCm);
|
||
auto Mod = [LRes](int32 v) { const int32 m = v % LRes; return (float)((m < 0) ? m + LRes : m); };
|
||
OriginC[L] = FLinearColor(OW.X, OW.Y, OW.Z, 0.0f);
|
||
ModC[L] = FLinearColor(Mod(OriginCells.X), Mod(OriginCells.Y), Mod(OriginCells.Z), 0.0f);
|
||
if (L == 0) { bHave = true; Res = LRes; CellWorldSize = VOXEL_SIZE * StepF; }
|
||
}
|
||
}
|
||
// Track whether anything actually changed — the push below enqueues render-thread updates per MID,
|
||
// so on the (common) idle frames where the window didn't scroll and the orb didn't change, skip it.
|
||
bool bDirty = false;
|
||
auto SetTVP = [&bDirty](FLinearColor& Dst, const FLinearColor& V)
|
||
{
|
||
if (Dst != V) { Dst = V; bDirty = true; }
|
||
};
|
||
SetTVP(TVP0, OriginC[0]); SetTVP(TVP1, ModC[0]);
|
||
SetTVP(TVP6, OriginC[1]); SetTVP(TVP7, ModC[1]);
|
||
SetTVP(TVP8, OriginC[2]); SetTVP(TVP9, ModC[2]);
|
||
|
||
// --- Nearest active orb ---
|
||
FVoxelActiveOrb Best;
|
||
bool bHaveOrb = false;
|
||
if (ContentManager)
|
||
{
|
||
TArray<FVoxelActiveOrb> Orbs;
|
||
ContentManager->GetActiveOrbs(Orbs);
|
||
if (Orbs.Num() > 0)
|
||
{
|
||
const FVector P = GetPlayerPosition();
|
||
float BestD = FLT_MAX;
|
||
for (const FVoxelActiveOrb& O : Orbs)
|
||
{
|
||
const float D = FVector::DistSquared(O.WorldPos, P);
|
||
if (D < BestD) { BestD = D; Best = O; bHaveOrb = true; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// All data lives in .xyz (a Vector Parameter only delivers float3 into a Custom node). Intensity is
|
||
// premultiplied into the colour; Res / CellWorldSize / Enable go in TVP5.
|
||
const float Enable = (bHave && bHaveOrb && Res > 0) ? 1.0f : 0.0f;
|
||
const float Steps = (float)FMath::Clamp(Settings->DensityVolumeMarchSteps, 4, 256);
|
||
SetTVP(TVP2, FLinearColor(Best.WorldPos.X, Best.WorldPos.Y, Best.WorldPos.Z, 0.0f));
|
||
SetTVP(TVP3, FLinearColor(Best.Color.R * Best.Intensity, Best.Color.G * Best.Intensity, Best.Color.B * Best.Intensity, 0.0f));
|
||
SetTVP(TVP4, FLinearColor(Best.MaxShadowDistWorld, Best.FalloffWorld, Steps, 0.0f));
|
||
SetTVP(TVP5, FLinearColor((float)FMath::Max(Res, 0), CellWorldSize, Enable, 0.0f));
|
||
|
||
// Re-push if the L0 texture object itself was recreated (resolution change) even when the packed
|
||
// params happen to be identical — otherwise the MIDs would keep sampling the dropped texture.
|
||
UVolumeTexture* Tex0 = DensityVolume->GetLevelTexture(0);
|
||
if (LastBoundVolTex0.Get() != Tex0) { LastBoundVolTex0 = Tex0; bDirty = true; }
|
||
if (!bDirty) return;
|
||
|
||
// Push to every terrain MID (new MIDs are seeded on creation in GetOrCreateTerrainMID).
|
||
for (TPair<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>>& Pair : TerrainMIDs)
|
||
{
|
||
SetVolumeParamsOnMID(Pair.Value.Get());
|
||
}
|
||
}
|
||
|
||
void AVoxelWorld::UpdateOrbLightMPC()
|
||
{
|
||
if (!OrbLightMPC || !ContentManager) return;
|
||
|
||
TArray<FVoxelActiveOrb> Orbs;
|
||
ContentManager->GetActiveOrbs(Orbs);
|
||
|
||
// Nearest-first so Orb0..3 are the 4 closest orbs (the Light Function unions their pools; 4 is
|
||
// plenty since only nearby pools are visible and the player sits inside one or two at a time).
|
||
const FVector P = GetPlayerPosition();
|
||
Orbs.Sort([&P](const FVoxelActiveOrb& A, const FVoxelActiveOrb& B)
|
||
{
|
||
return FVector::DistSquared(A.WorldPos, P) < FVector::DistSquared(B.WorldPos, P);
|
||
});
|
||
|
||
static const FName OrbNames[4] = { FName("Orb0"), FName("Orb1"), FName("Orb2"), FName("Orb3") };
|
||
for (int32 i = 0; i < 4; ++i)
|
||
{
|
||
// (x,y,z) = orb WORLD position, .w = reach radius in cm (FalloffWorld = how far the pool
|
||
// extends). Unused slots = all-zero → radius 0 → the mask yields no pool for them.
|
||
FLinearColor V(0.f, 0.f, 0.f, 0.f);
|
||
if (i < Orbs.Num())
|
||
{
|
||
const FVoxelActiveOrb& O = Orbs[i];
|
||
V = FLinearColor((float)O.WorldPos.X, (float)O.WorldPos.Y, (float)O.WorldPos.Z, O.FalloffWorld);
|
||
}
|
||
// ALWAYS write, even when unchanged. A skip-if-identical cache BREAKS the lighting: the
|
||
// MPC's world INSTANCE can be reset/recreated behind our back (PIE init order, asset
|
||
// recompile), and a cached skip then leaves it holding defaults forever. The per-frame
|
||
// rewrite is what makes the collection self-healing — and 4 vector writes cost nothing.
|
||
UKismetMaterialLibrary::SetVectorParameterValue(this, OrbLightMPC, OrbNames[i], V);
|
||
}
|
||
}
|