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:
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user