fix(world): the voxel field is in ACTOR space -- make every entry point agree

The city's hole is not at world (0,0), so the AVoxelWorld actor has to move.
It couldn't: the plugin was only HALF actor-relative, and had been for a long
time without symptoms because the actor always sat at the origin.

Already correct: GetBiomeAtWorldLocation, GetVoxelSurfaceHeightAt,
UpdateDecorations, UpdateLandmarks, UpdateWater, UVoxelDensityVolume::Update,
the passage debug draw, the clipmap material.

Not correct: the streaming centre, the streaming anchors, all six carve/fill
entry points, GetStrateAtPosition, and UVoxelAtmosphereManager::UpdateForPlayer
-- whose own comment documented the assumption out loud ("actor at
origin/identity"). Carving would have landed offset; the strate lookup would
have been quietly wrong by the actor's Z.

The trap worth recording: because the content managers already convert
internally, converting PlayerLastPos once at the source would have DOUBLE-
converted four call sites. The fix has to be per-boundary. That was only
visible after reading each consumer -- the estimate before that read was wrong
in both directions.

WorldToLocalCm / WorldToLocalVoxel / LocalVoxelToWorld are now declared as the
only sanctioned boundary between Unreal coordinates and voxel coordinates, and
the real deliverable is the invariant next to them: no `/ VOXEL_SIZE` applied
to a parameter named `World*` outside those three functions. A grep can check
that. The risk was never a missed site among the ones found -- it is the next
one nobody notices.

Also adds a translation-only guard in BeginPlay. Tiles, ClassifyBox verdicts,
the clipmap window, culling and streaming distance all assume world-axis-
aligned boxes; a rotation or non-unit scale breaks that structurally rather
than gracefully, so it logs an Error naming what was set.

With the actor at the origin every conversion is identity, so this is a no-op
until the actor actually moves. Built and confirmed working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-08-23 17:47:23 +02:00
1 parent dfa1d5f5b1
commit 302cf16681
5 files changed
+608 -21

No files matched your search

@@ -42,17 +42,27 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos)
AActor* O = Owner.Get();
if (!O) return;
const int32 Idx = StrateManager->GetStrateIndex(PlayerWorldPos.Z);
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(PlayerWorldPos.Z);
// Le champ de densite est en espace ACTEUR : les requetes de strate et de biome veulent des
// coordonnees LOCALES. Le placement d acteurs, lui, veut du MONDE. Les deux cohabitent ici, ce
// qui est exactement pourquoi ce fichier supposait "acteur a l origine" avant le 2026-08-17.
// The density field is in ACTOR space: strate and biome queries want LOCAL coordinates, while
// actor placement wants WORLD. Both live in this function - which is exactly why it used to
// assume "actor at origin / identity".
const FTransform Xf = O->GetActorTransform();
const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos);
const int32 Idx = StrateManager->GetStrateIndex(LocalPos.Z);
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(LocalPos.Z);
// Player's dominant biome (XY field) — atmosphere can vary by biome within a strate.
// Convention matches GetStrateAt: world cm = voxel * VOXEL_SIZE (actor at origin/identity).
// Requete en espace ACTEUR (voir LocalPos ci-dessus) : cm locaux -> voxels.
// Queried in ACTOR space (see LocalPos above): local cm -> voxels.
const UVoxelBiomeDefinition* Biome = nullptr;
if (Generator && Def)
{
const int32 ChunkZ = FMath::FloorToInt((PlayerWorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
Biome = Generator->GetDominantBiomeAt(PlayerWorldPos.X / VOXEL_SIZE,
PlayerWorldPos.Y / VOXEL_SIZE, ChunkZ);
const int32 ChunkZ = FMath::FloorToInt((LocalPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
Biome = Generator->GetDominantBiomeAt(LocalPos.X / VOXEL_SIZE,
LocalPos.Y / VOXEL_SIZE, ChunkZ);
}
// React on strate change (full apply) OR biome change within a strate (fog/sky only —
@@ -81,18 +91,18 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos)
if (Def)
{
float TopZ, BottomZ;
if (StrateManager->GetStrateUnrealZRange(PlayerWorldPos.Z, TopZ, BottomZ))
if (StrateManager->GetStrateUnrealZRange(LocalPos.Z, TopZ, BottomZ))
{
if (CeilingActor)
{
CeilingActor->SetActorLocationAndRotation(
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, TopZ + Def->CeilingLayerZOffset),
Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, TopZ + Def->CeilingLayerZOffset)),
Def->CeilingLayerRotation);
}
if (FloorActor)
{
FloorActor->SetActorLocationAndRotation(
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, BottomZ + Def->FloorLayerZOffset),
Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, BottomZ + Def->FloorLayerZOffset)),
Def->FloorLayerRotation);
}
}
+59 -12
View File
@@ -458,6 +458,29 @@ void AVoxelWorld::BeginPlay()
}
// 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);
@@ -583,6 +606,29 @@ void AVoxelWorld::Tick(float DeltaTime)
#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);
}
FVector AVoxelWorld::GetPlayerPosition() const
{
// This one is tricky with Unreal's API, so I'll give you more help:
@@ -1100,7 +1146,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
const int32 MaxTasks = GetMaxConcurrentTasks();
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); // player's level-0 tile
const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile
CurrentCenterChunk = CenterChunk;
// Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the
@@ -1116,7 +1162,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
bAnchorsMoved = true;
continue;
}
const FIntVector AC = WorldToChunkCoord(A->GetActorLocation());
const FIntVector AC = WorldToChunkCoord(WorldToLocalCm(A->GetActorLocation()));
if (AC != StreamingAnchors[i].LastChunk)
{
StreamingAnchors[i].LastChunk = AC;
@@ -1818,8 +1864,9 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
{
if (!StrateManager) return -1;
// GetStrateIndex expects Unreal world units — it converts internally.
return StrateManager->GetStrateIndex(WorldPosition.Z);
// 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
@@ -1878,7 +1925,7 @@ bool AVoxelWorld::GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfa
void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength)
{
FVoxelModification Mod;
Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space
Mod.Center = WorldToLocalVoxel(Position); // world cm → voxel space
Mod.Radius = Radius;
Mod.Strength = -FMath::Abs(Strength); // force negative for carving
ApplyModification(Mod);
@@ -1887,7 +1934,7 @@ void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
{
FVoxelModification Mod;
Mod.Center = Position / VOXEL_SIZE;
Mod.Center = WorldToLocalVoxel(Position);
Mod.Radius = Radius;
Mod.Strength = FMath::Abs(Strength); // force positive for filling
ApplyModification(Mod);
@@ -1932,7 +1979,7 @@ void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strengt
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Box;
Mod.Center = Position / VOXEL_SIZE;
Mod.Center = WorldToLocalVoxel(Position);
Mod.BoxExtent = ExtentVoxels;
Mod.Radius = ExtentVoxels.GetMax(); // budget proxy
Mod.Strength = -FMath::Abs(Strength); // carve
@@ -1943,7 +1990,7 @@ void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Box;
Mod.Center = Position / VOXEL_SIZE;
Mod.Center = WorldToLocalVoxel(Position);
Mod.BoxExtent = ExtentVoxels;
Mod.Radius = ExtentVoxels.GetMax();
Mod.Strength = FMath::Abs(Strength); // fill
@@ -1954,8 +2001,8 @@ void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxel
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Capsule;
Mod.Center = WorldA / VOXEL_SIZE;
Mod.CapsuleEnd = WorldB / VOXEL_SIZE;
Mod.Center = WorldToLocalVoxel(WorldA);
Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
Mod.Radius = RadiusVoxels;
Mod.Strength = -FMath::Abs(Strength);
ApplyModification(Mod);
@@ -1965,8 +2012,8 @@ void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Capsule;
Mod.Center = WorldA / VOXEL_SIZE;
Mod.CapsuleEnd = WorldB / VOXEL_SIZE;
Mod.Center = WorldToLocalVoxel(WorldA);
Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
Mod.Radius = RadiusVoxels;
Mod.Strength = FMath::Abs(Strength);
ApplyModification(Mod);
+35
View File
@@ -318,6 +318,41 @@ public:
* @param WorldPosition - Position in world space (Unreal units)
* @return Strate index (0 = topmost), or -1 if outside all strates
*/
//=========================================================================
// ESPACE MONDE <-> ESPACE ACTEUR / WORLD <-> ACTOR SPACE
//=========================================================================
// Le champ de densite est defini en espace ACTEUR : (0,0) est l origine de CET acteur, pas
// celle du monde Unreal. Tout point venant d Unreal (position du pion, hit de trace, ancre de
// streaming) DOIT passer par ces helpers avant de toucher quoi que ce soit cote voxel.
//
// The density field is authored in ACTOR space: (0,0) is THIS actor s origin, not Unreal s
// world origin. Any point arriving from Unreal (pawn position, trace hit, streaming anchor)
// MUST go through these helpers before touching anything voxel-side.
//
// INVARIANT VERIFIABLE PAR GREP / GREP-CHECKABLE INVARIANT:
// aucun `/ VOXEL_SIZE` applique a un parametre nomme `World*` hors de ces fonctions.
// no `/ VOXEL_SIZE` applied to a parameter named `World*` outside these functions.
// C est la vraie garantie : le risque n est pas un site rate parmi ceux deja trouves, c est
// le SITE SUIVANT que personne ne verra. The risk is not a missed site among those already
// found - it is the NEXT one nobody notices.
//
// La racine est Static (voir BeginPlay) => la transform ne bouge pas en jeu, donc la relire a
// chaque appel est gratuit et ne demande aucune invalidation.
// The root is Static (see BeginPlay), so the transform cannot change during play: re-reading
// it per call is free and needs no invalidation.
/** World cm -> actor-local cm. */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector WorldToLocalCm(FVector WorldPos) const;
/** World cm -> actor-local VOXEL coords (what every density / diff-layer API expects). */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector WorldToLocalVoxel(FVector WorldPos) const;
/** Actor-local VOXEL coords -> world cm (placing actors, debug draws, UI markers). */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector LocalVoxelToWorld(FVector VoxelPos) const;
UFUNCTION(BlueprintPure, Category = "Voxel World|Strate")
int32 GetStrateAtPosition(FVector WorldPosition) const;