From 61633cb4921ee805e127d662c62884bc1eace7b6 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 9 Oct 2026 20:39:24 +0200 Subject: [PATCH] wip(assembler): M1 round 6 -- owner walk fixes: 25 cm geometry scale, one height datum, carve/fill sign, faster relief meshing Owner saw content floating above native terrain, spiky triangles, reversed carve/fill and late relief. Causes: shared mesher placed Core tiles on a 12.5 cm grid; content/atmosphere asked strate ranges in 12.5 cm voxels; Core edits add density where the legacy diff subtracts. Horizon LOD8 bridge edge 5.657 -> 2.265 cells. Relief mesh p95 60.8 -> 42.0 ms (LOD0). New BuildHost checks for scale, height, blueprint carve/fill and per-kind streaming lag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- ARCHITECTURE.md | 32 +- CODEMAP.md | 16 +- .../Private/VoxelAtmosphereManager.cpp | 22 +- .../Private/VoxelContentManager.cpp | 356 +++++++- .../Private/VoxelMarchingCubesMesher.cpp | 10 +- Source/VoxelForge/Private/VoxelWorld.cpp | 38 +- .../VoxelForge/Public/VoxelContentManager.h | 15 +- .../Public/VoxelMarchingCubesMesher.h | 5 + Source/VoxelForge/Public/VoxelWorld.h | 21 +- .../Private/VoxelForgeExploreCommandlet.cpp | 847 +++++++++++++++++- Standalone/ASSEMBLER_M1_REPORT.md | 154 ++++ .../include/VoxelForgeCore/VoxelForgeCore.h | 1 + Standalone/Core/src/FarShellsInternal.h | 75 +- Standalone/Core/src/VoxelForgeCore.cpp | 122 ++- .../gpu_field_port/LUNA_DIALOGUE.md | 47 + .../Private/VoxelMarchingCubesMesher.cpp | 63 +- .../Public/VoxelMarchingCubesMesher.h | 5 + Tools/TestVoxelForgeCoreWorldHost.ps1 | 21 + 18 files changed, 1708 insertions(+), 142 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9a0d679..11e7580 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1085,8 +1085,9 @@ tasks. Preserve §8.10 streaming, cancellation, epoch, collision-handoff, and pe Core `Vec3d` positions and tile keys are in voxel space, one voxel is 25 cm, and Core mesh vertex positions are already in centimetres. The bridge copies those mesh positions directly; player, anchor, query, and edit inputs convert world centimetres using the Core scale. High-level brushes -that historically express dimensions in the plugin's 12.5 cm voxels scale radius, extent, and falloff -by 12.5/25 to preserve physical dimensions. The low-level `ApplyModification` path expects active +that historically express dimensions and density strength in the plugin's 12.5 cm voxels scale +radius, extent, falloff, and strength by 12.5/25 to preserve their physical effect. The low-level +`ApplyModification` path expects active voxel units. Tile keys remain Core keys; only legacy material lookup maps Core tile spans into the existing strate chunk layout. @@ -1241,6 +1242,33 @@ in [`Standalone/ASSEMBLER_M1_REPORT.md`](Standalone/ASSEMBLER_M1_REPORT.md). Bui replace the owner's project build or visual walk; cap lighting and the pointed native ridge peaks remain owner review items. +### 8.21 Native SurfaceWorld height, brush and far-mesh corrections — integration round 6 + +Core tile keys and density coordinates use 25 cm voxels; the shared plugin mesher and +`UVoxelStrateManager` retain the legacy 12.5 cm scale. The Core bridge now maps Core-local height +queries to manager coordinates by 0.5 and maps returned bounds back by 2.0. Native content, water, +landmark, decoration and atmosphere anchors use the same manifest `Heightfield2D` ground/cap data +as the terrain. These conversions are gated by `bUseVoxelForgeCore`; the legacy branch keeps its +prior units and behavior. `UVoxelMarchingCubesMesher` has an explicit geometry voxel size: Core sets +25 cm, while legacy callers retain the 12.5 cm default. Material and biome lookups remain in their +legacy coordinate space. + +Blueprint-callable carve/fill and high-level brush dimensions cross the scale boundary at 0.5, +including strength. Legacy diff modifications subtract their strength; Core edit evaluation adds its +density offset, so the adapter negates the legacy strength once. Public carve forces the airward sign +and fill forces the solidward sign. Low-level active-Core modification units remain Core units. + +LOD8 far-stratum boundary repairs now sample crossings across a subdivided 4×4 coarse patch and only +emit a patch when each sample has a source-field crossing. This replaces the former broad single +quad. The BuildHost geometry audit keeps a two-cell maximum edge at LOD0/1 and allows four coarse +cells at LOD7+ to include shallow far-LOD transitions while still rejecting the prior 5.66-cell +horizon slab. It checks near-LOD normal direction and unit normals at every audited LOD. The mesher +emits reversed triangle order (`0,2,1`) for RealtimeMesh; the audit reports that expected order +separately from normal direction. The opt-in native heightfield path narrows vertical mesher rows to +sampled ground/cap heights plus the Z bounds of intersecting type-2/type-4 manifest features. Hash captures and tiles +with overlapping player edits or another strate keep the full mesh range. See round 6 of the M1 +report for per-kind streaming results and validation limits. + ## 9. Multiplayer model (listen-server first, dedicated-friendly) > **Status: transport is DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a diff --git a/CODEMAP.md b/CODEMAP.md index fde60b1..3506c7e 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -847,8 +847,9 @@ settings or assets. When enabled, `AVoxelWorld` uses Core desired keys and priorities, compiles/generates through Unreal tasks, carries `Epoch` through the existing MPSC result queues, and uploads converted payloads through RealtimeMesh and its collision cook. Core voxel coordinates use 25 cm per voxel; Core tile keys are -retained, and Core mesh vertices are already in centimetres. High-level legacy brush dimensions are -scaled from 12.5 cm voxel units to Core units so their physical size stays the same. Edits use +retained, and Core mesh vertices are already in centimetres. High-level legacy brush dimensions and +density strength are scaled from 12.5 cm voxel units to Core units so their physical effect stays the +same. Edits use sequenced `applyEditTransaction` calls after in-flight Core work drains, then regenerate Core's affected keys under a new epoch. Existing streaming anchors participate; the headless move proof covers `CollisionOnly` policy. @@ -882,6 +883,17 @@ content hash. The collision gate defers out-of-desired-set Core support tiles un compilation admits them, so collision work cannot publish a competing tile before the native surface region snapshot. Existing cave Core strates still use the no-manifest snapshot path. +Round 6 corrects the Core 25 cm ↔ legacy 12.5 cm boundary in `VoxelWorld.cpp`, +`VoxelContentManager.cpp`, and `VoxelAtmosphereManager.cpp`. `VoxelMarchingCubesMesher` now exposes +`GeometryVoxelSizeCm` so Core geometry is emitted at 25 cm while legacy stays at its 12.5 cm +default. The Core bridge adapts carve/fill signs and scales Blueprint brush radius, extent, falloff +and strength. `Standalone/Core/src/FarShellsInternal.h` subdivides LOD7+ far-stratum repairs, and +`Standalone/Core/src/VoxelForgeCore.cpp` enables a guarded heightfield Z-band path for unhashed +native tiles, combining ground/cap heights with intersecting bounded 3D feature extents; overlapping +edits keep the full mesh path. The BuildHost commandlet adds height-anchor agreement, brush +sign/size, geometry and per-tile-kind pawn-lag checks; see round 6 in +[`Standalone/ASSEMBLER_M1_REPORT.md`](Standalone/ASSEMBLER_M1_REPORT.md). + [OwnerWorldLoader.h](Standalone/Source/OwnerWorldLoader.h) now reads SlabParams, MazeParams, VerticalShaftParams, and FloatingIslandParams. The shape-world evaluator still lacks their native terrain fields: FlatPlain, CrystalChamber, Maze, VerticalShafts, FloatingIslands, and water diff --git a/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp b/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp index 7e95bbd..dc536d7 100644 --- a/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp +++ b/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp @@ -6,6 +6,7 @@ #include "VoxelBiomeDefinition.h" #include "VoxelGenerator.h" #include "VoxelTypes.h" // CHUNK_SIZE / VOXEL_SIZE +#include "VoxelWorld.h" #include "Components/ExponentialHeightFogComponent.h" #include "Components/SkyLightComponent.h" #include "GameFramework/Actor.h" @@ -50,9 +51,12 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos) // identity". const FTransform Xf = O->GetActorTransform(); const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos); + const AVoxelWorld* VoxelWorld = Cast(O); + const float ManagerLocalZ = VoxelWorld + ? VoxelWorld->ToStrateManagerLocalZ(LocalPos.Z) : LocalPos.Z; - const int32 Idx = StrateManager->GetStrateIndex(LocalPos.Z); - const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(LocalPos.Z); + const int32 Idx = StrateManager->GetStrateIndex(ManagerLocalZ); + const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(ManagerLocalZ); // Player's dominant biome (XY field) — atmosphere can vary by biome within a strate. // Requete en espace ACTEUR (voir LocalPos ci-dessus) : cm locaux -> voxels. @@ -60,7 +64,7 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos) const UVoxelBiomeDefinition* Biome = nullptr; if (Generator && Def) { - const int32 ChunkZ = FMath::FloorToInt((LocalPos.Z / VOXEL_SIZE) / CHUNK_SIZE); + const int32 ChunkZ = FMath::FloorToInt((ManagerLocalZ / VOXEL_SIZE) / CHUNK_SIZE); Biome = Generator->GetDominantBiomeAt(LocalPos.X / VOXEL_SIZE, LocalPos.Y / VOXEL_SIZE, ChunkZ); } @@ -91,18 +95,24 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos) if (Def) { float TopZ, BottomZ; - if (StrateManager->GetStrateUnrealZRange(LocalPos.Z, TopZ, BottomZ)) + if (StrateManager->GetStrateUnrealZRange(ManagerLocalZ, TopZ, BottomZ)) { + const float LocalTopZ = VoxelWorld + ? VoxelWorld->FromStrateManagerLocalZ(TopZ) : TopZ; + const float LocalBottomZ = VoxelWorld + ? VoxelWorld->FromStrateManagerLocalZ(BottomZ) : BottomZ; if (CeilingActor) { CeilingActor->SetActorLocationAndRotation( - Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, TopZ + Def->CeilingLayerZOffset)), + Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, + LocalTopZ + Def->CeilingLayerZOffset)), Def->CeilingLayerRotation); } if (FloorActor) { FloorActor->SetActorLocationAndRotation( - Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, BottomZ + Def->FloorLayerZOffset)), + Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, + LocalBottomZ + Def->FloorLayerZOffset)), Def->FloorLayerRotation); } } diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp index c1792d1..672db11 100644 --- a/Source/VoxelForge/Private/VoxelContentManager.cpp +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -8,6 +8,7 @@ #include "VoxelBiomeDefinition.h" #include "VoxelGenerator.h" #include "VoxelSettings.h" +#include "VoxelWorld.h" #include "VoxelCaveMorphology.h" // VoxelHash #include "Components/StaticMeshComponent.h" #include "Components/HierarchicalInstancedStaticMeshComponent.h" @@ -18,6 +19,114 @@ #include "Tasks/Task.h" #include "HAL/PlatformProcess.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes +#include +#include +#include + +struct FCoreSurfaceSnapshot +{ + VoxelForgeCore::WorldAssemblyRecipeV1 Recipe; + TMap Plans; + + const VoxelForgeCore::WorldAssemblyStratumPlanV1* FindPlan(int32 StrateIndex) const + { + return Plans.Find(StrateIndex); + } + + bool GetHeights(int32 StrateIndex, float LegacyVoxelX, float LegacyVoxelY, + float& OutGroundLegacyVoxels, float& OutCapLegacyVoxels) const + { + const auto* Plan = FindPlan(StrateIndex); + if (!Plan || !VoxelMath::IsFinite(LegacyVoxelX) || !VoxelMath::IsFinite(LegacyVoxelY)) + { + return false; + } + // One 12.5 cm legacy voxel equals 128 subvoxels, half a 25 cm Core voxel. + // Un voxel historique de 12,5 cm vaut 128 sous-voxels, soit la moitie d'un voxel Core de 25 cm. + constexpr double LegacyVoxelToSubvoxels = 128.0; + constexpr double SubvoxelsPerLegacyVoxel = LegacyVoxelToSubvoxels; + constexpr double MaxInput = static_cast(MAX_int64) / LegacyVoxelToSubvoxels; + if (FMath::Abs(static_cast(LegacyVoxelX)) > MaxInput + || FMath::Abs(static_cast(LegacyVoxelY)) > MaxInput) + { + return false; + } + + const VoxelForgeCore::Heightfield2D Heightfield(Recipe, *Plan); + if (!Heightfield.isValid()) + { + return false; + } + const int64 XSubvoxels = static_cast(std::llround( + static_cast(LegacyVoxelX) * LegacyVoxelToSubvoxels)); + const int64 YSubvoxels = static_cast(std::llround( + static_cast(LegacyVoxelY) * LegacyVoxelToSubvoxels)); + const auto Sample = Heightfield.point( + XSubvoxels, YSubvoxels, 0); + int64 SurfaceHeightSubvoxels = Sample.groundHeightSubvoxels; + int64 SearchTopSubvoxels = Sample.groundHeightSubvoxels; + for (const auto& Feature : Plan->features) + { + if ((Feature.type != 2 && Feature.type != 4) + || XSubvoxels < Feature.minimumSubvoxels[0] - 4 * 256 + || XSubvoxels > Feature.maximumSubvoxels[0] + 4 * 256 + || YSubvoxels < Feature.minimumSubvoxels[1] - 4 * 256 + || YSubvoxels > Feature.maximumSubvoxels[1] + 4 * 256) + { + continue; + } + SearchTopSubvoxels = FMath::Max( + SearchTopSubvoxels, Feature.maximumSubvoxels[2]); + } + SearchTopSubvoxels = FMath::Min( + Sample.capHeightSubvoxels, SearchTopSubvoxels + 256); + if (SearchTopSubvoxels > Sample.groundHeightSubvoxels) + { + // Quarter Core voxel, bounded to relevant features. / Quart de voxel Core, borne aux volumes pertinents. + constexpr int64 ScanStepSubvoxels = 64; + int64 PreviousZ = SearchTopSubvoxels; + int64 PreviousDensity = Heightfield.point(XSubvoxels, YSubvoxels, PreviousZ).densitySubvoxels; + for (int64 Z = SearchTopSubvoxels - ScanStepSubvoxels; + Z >= Sample.groundHeightSubvoxels; Z -= ScanStepSubvoxels) + { + const int64 Density = Heightfield.point(XSubvoxels, YSubvoxels, Z).densitySubvoxels; + if (PreviousDensity >= 0 && Density < 0) + { + const double Denominator = static_cast(PreviousDensity - Density); + const double Alpha = Denominator > 0.0 + ? static_cast(PreviousDensity) / Denominator : 0.5; + SurfaceHeightSubvoxels = static_cast(std::llround( + static_cast(PreviousZ) + (static_cast(Z - PreviousZ) * Alpha))); + break; + } + PreviousZ = Z; + PreviousDensity = Density; + } + } + OutGroundLegacyVoxels = static_cast(SurfaceHeightSubvoxels / SubvoxelsPerLegacyVoxel); + OutCapLegacyVoxels = static_cast(Sample.capHeightSubvoxels / SubvoxelsPerLegacyVoxel); + return VoxelMath::IsFinite(OutGroundLegacyVoxels) && VoxelMath::IsFinite(OutCapLegacyVoxels); + } + + bool GetWater(int32 StrateIndex, bool& OutHasPlan, bool& OutEnabled, float& OutLocalCm) const + { + OutHasPlan = false; + OutEnabled = false; + OutLocalCm = -FLT_MAX; + const auto* Plan = FindPlan(StrateIndex); + if (!Plan) + { + return false; + } + OutHasPlan = true; + OutEnabled = Plan->waterEnabled; + if (OutEnabled) + { + OutLocalCm = static_cast(Plan->waterSurfaceSubvoxels) * (25.0f / 256.0f); + } + return true; + } +}; // Global safety cap on spawned decoration ACTORS per cell (across all entries). // HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk. @@ -64,6 +173,37 @@ void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InSt Generator = InGenerator; Settings = InSettings; Seed = InSeed; + NativeSurfaceSnapshot.Reset(); + + if (Settings) + { + FString ManifestJson, ManifestHash, ManifestError; + if (Settings->GetWorldAssemblyManifestJson(ManifestJson, ManifestHash, ManifestError) + && !ManifestJson.IsEmpty()) + { + FTCHARToUTF8 Utf8Json(*ManifestJson); + std::string CoreError; + const std::optional Manifest = + VoxelForgeCore::WorldAssemblyManifest::FromJson( + std::string_view(Utf8Json.Get(), static_cast(Utf8Json.Length())), CoreError); + if (Manifest) + { + TSharedPtr Snapshot = + MakeShared(); + Snapshot->Recipe = Manifest->recipe; + for (const auto& Plan : Manifest->plan.strates) + { + Snapshot->Plans.Add(Plan.strateIndex, Plan); + } + NativeSurfaceSnapshot = Snapshot; + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelContentManager] native surface snapshot rejected: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + } + } + } if (!PlaneMesh) { @@ -71,6 +211,39 @@ void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InSt } } +bool UVoxelContentManager::QueryNativeSurfaceAnchors( + int32 StrateIndex, const FVector2D& LocalXYCm, float& OutGroundLocalZCm, + bool& bOutHasWater, float& OutWaterLocalZCm) const +{ + OutGroundLocalZCm = 0.0f; + bOutHasWater = false; + OutWaterLocalZCm = -FLT_MAX; + const AVoxelWorld* VoxelWorld = Cast(Owner.Get()); + if (VoxelWorld == nullptr || !VoxelWorld->bUseVoxelForgeCore + || !NativeSurfaceSnapshot.IsValid()) + { + return false; + } + + float GroundLegacyVoxels = 0.0f; + float CapLegacyVoxels = 0.0f; + if (!NativeSurfaceSnapshot->GetHeights(StrateIndex, + LocalXYCm.X / VOXEL_SIZE, LocalXYCm.Y / VOXEL_SIZE, + GroundLegacyVoxels, CapLegacyVoxels)) + { + return false; + } + OutGroundLocalZCm = GroundLegacyVoxels * VOXEL_SIZE; + bool bHasPlan = false; + if (!NativeSurfaceSnapshot->GetWater(StrateIndex, bHasPlan, + bOutHasWater, OutWaterLocalZCm) + || !bHasPlan) + { + return false; + } + return true; +} + void UVoxelContentManager::BeginDestroy() { // Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained. @@ -185,11 +358,14 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos) // All strate/water/density queries are in actor-LOCAL space (StrateManager assumes actor origin; // the mesher builds geometry from voxel*VOXEL_SIZE in local space). Bring the player local. const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos); + const AVoxelWorld* VoxelWorld = Cast(OwnerActor); + const float ManagerLocalZ = VoxelWorld + ? VoxelWorld->ToStrateManagerLocalZ(LocalPlayer.Z) : LocalPlayer.Z; // Player's strate band (actor-local cm). No strate (inter-strate gap / outside world) ⇒ no decos. float TopZ, BotZ; - const bool bInStrate = StrateManager->GetStrateUnrealZRange(LocalPlayer.Z, TopZ, BotZ); - const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(LocalPlayer.Z) : INT32_MIN; + const bool bInStrate = StrateManager->GetStrateUnrealZRange(ManagerLocalZ, TopZ, BotZ); + const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(ManagerLocalZ) : INT32_MIN; if (!bInStrate) { @@ -206,17 +382,39 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos) // Shared per-update strate context (a strate is a horizontal slab → same for every cell). CurrentCtx = FDecoContext(); - CurrentCtx.TopVoxelZ = TopZ / VOXEL_SIZE; - CurrentCtx.BottomVoxelZ = BotZ / VOXEL_SIZE; + CurrentCtx.StrateIndex = StrateIndex; + CurrentCtx.TopVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(TopZ) : TopZ) / VOXEL_SIZE; + CurrentCtx.BottomVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(BotZ) : BotZ) / VOXEL_SIZE; CurrentCtx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE); const FIntVector RepChunk(PlayerCell.X, PlayerCell.Y, CurrentCtx.RepChunkZ); CurrentCtx.Def = StrateManager->GetStrateForChunk(RepChunk); CurrentCtx.bSurfaceWorld = (StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld); + CurrentCtx.NativeSurface = NativeSurfaceSnapshot; + CurrentCtx.bNativeSurface = VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore + && CurrentCtx.bSurfaceWorld + && CurrentCtx.NativeSurface.IsValid() + && CurrentCtx.NativeSurface->FindPlan(StrateIndex) != nullptr; { - const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk); - CurrentCtx.bHasWater = (Wv != -FLT_MAX); - CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX; + bool bHasNativePlan = false; + bool bNativeWaterEnabled = false; + float NativeWaterLocalZ = -FLT_MAX; + if (CurrentCtx.bNativeSurface + && CurrentCtx.NativeSurface->GetWater(StrateIndex, bHasNativePlan, + bNativeWaterEnabled, NativeWaterLocalZ) + && bHasNativePlan) + { + CurrentCtx.bHasWater = bNativeWaterEnabled; + CurrentCtx.WaterLocalZ = bNativeWaterEnabled ? NativeWaterLocalZ : -FLT_MAX; + } + else + { + const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk); + CurrentCtx.bHasWater = (Wv != -FLT_MAX); + CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater + ? (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(Wv * VOXEL_SIZE) : Wv * VOXEL_SIZE) + : -FLT_MAX; + } } // Strate biome field (XY-global → resolved once; the worker picks the dominant biome per COLUMN). CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk); @@ -513,6 +711,15 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr const bool bHasBiomes = Ctx.BiomeCtx.IsValid(); auto D = [&](float VX, float VY, float VZ) { return Gen->GetDensityAt(VX, VY, VZ); }; + auto GetSurface = [&](float VX, float VY, float& OutGround, float& OutCap) + { + if (Ctx.bNativeSurface && Ctx.NativeSurface.IsValid()) + { + return Ctx.NativeSurface->GetHeights( + Ctx.StrateIndex, VX, VY, OutGround, OutCap); + } + return Gen->GetSurfaceHeightAt(VX, VY, Ctx.RepChunkZ, OutGround, OutCap); + }; // Build a companion satellite's transform from its profile + per-instance hash at a snapped surface point // (voxel XY/Z + outward normal). Shared by level-1 and level-2 companions. @@ -756,14 +963,15 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr // columns carved away by passages / the (0,0) spine / player diffs (the oracle is the raw // heightfield and doesn't know about carving). float hC, cC, hXp, cXp, hXm, cXm, hYp, cYp, hYm, cYm; - if (!Gen->GetSurfaceHeightAt(VX, VY, Ctx.RepChunkZ, hC, cC)) continue; - Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, hXp, cXp); - Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, hXm, cXm); - Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, hYp, cYp); - Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, hYm, cYm); + if (!GetSurface(VX, VY, hC, cC)) continue; + GetSurface(VX + 1.0f, VY, hXp, cXp); + GetSurface(VX - 1.0f, VY, hXm, cXm); + GetSurface(VX, VY + 1.0f, hYp, cYp); + GetSurface(VX, VY - 1.0f, hYm, cYm); // Floor (terrain top): outward normal = (-dH/dx, -dH/dy, 1). - if (hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ && D(VX, VY, hC) <= 0.5f) + if (hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ + && (Ctx.bNativeSurface || D(VX, VY, hC) <= 0.5f)) { const float dHdx = (hXp - hXm) * 0.5f; const float dHdy = (hYp - hYm) * 0.5f; @@ -773,7 +981,8 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr } // Sky-cap ceiling underside: outward normal = (dC/dx, dC/dy, -1). Only if open space below. - if (cC > hC + 1.0f && cC <= Ctx.TopVoxelZ && D(VX, VY, cC) <= 0.5f) + if (cC > hC + 1.0f && cC <= Ctx.TopVoxelZ + && (Ctx.bNativeSurface || D(VX, VY, cC) <= 0.5f)) { const float dCdx = (cXp - cXm) * 0.5f; const float dCdy = (cYp - cYm) * 0.5f; @@ -1132,16 +1341,26 @@ bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const if (Ctx.bSurfaceWorld) { float hC, cC; - if (!Gen->GetSurfaceHeightAt(VX, VY, Ctx.RepChunkZ, hC, cC)) return false; + auto GetSurface = [&](float SampleX, float SampleY, float& OutGround, float& OutCap) + { + if (Ctx.bNativeSurface && Ctx.NativeSurface.IsValid()) + { + return Ctx.NativeSurface->GetHeights( + Ctx.StrateIndex, SampleX, SampleY, OutGround, OutCap); + } + return Gen->GetSurfaceHeightAt( + SampleX, SampleY, Ctx.RepChunkZ, OutGround, OutCap); + }; + if (!GetSurface(VX, VY, hC, cC)) return false; if (Surf == ESurfaceType::Ceiling) { if (!(cC > hC + 1.0f && cC <= Ctx.TopVoxelZ)) return false; float d, cXp, cXm, cYp, cYm; - Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, d, cXp); - Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, d, cXm); - Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, d, cYp); - Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, d, cYm); + GetSurface(VX + 1.0f, VY, d, cXp); + GetSurface(VX - 1.0f, VY, d, cXm); + GetSurface(VX, VY + 1.0f, d, cYp); + GetSurface(VX, VY - 1.0f, d, cYm); FVector N = OwnerXf.TransformVectorNoScale( FVector((cXp - cXm) * 0.5f, (cYp - cYm) * 0.5f, -1.0f)).GetSafeNormal(); if (N.IsNearlyZero()) N = FVector::DownVector; @@ -1149,12 +1368,12 @@ bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const } // Floor / Wall / Any → the terrain top. if (!(hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ)) return false; - if (Gen->GetDensityAt(VX, VY, hC) > 0.5f) return false; // carved away (passage/spine/diff) + if (!Ctx.bNativeSurface && Gen->GetDensityAt(VX, VY, hC) > 0.5f) return false; float d, hXp, hXm, hYp, hYm; - Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, hXp, d); - Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, hXm, d); - Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, hYp, d); - Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, hYm, d); + GetSurface(VX + 1.0f, VY, hXp, d); + GetSurface(VX - 1.0f, VY, hXm, d); + GetSurface(VX, VY + 1.0f, hYp, d); + GetSurface(VX, VY - 1.0f, hYm, d); FVector N = OwnerXf.TransformVectorNoScale( FVector(-(hXp - hXm) * 0.5f, -(hYp - hYm) * 0.5f, 1.0f)).GetSafeNormal(); if (N.IsNearlyZero()) N = FVector::UpVector; @@ -1382,10 +1601,13 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) const FTransform OwnerXf = OwnerActor->GetActorTransform(); const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos); + const AVoxelWorld* VoxelWorld = Cast(OwnerActor); + const float ManagerLocalZ = VoxelWorld + ? VoxelWorld->ToStrateManagerLocalZ(LocalPlayer.Z) : LocalPlayer.Z; float TopZ, BotZ; - const bool bInStrate = StrateManager->GetStrateUnrealZRange(LocalPlayer.Z, TopZ, BotZ); - const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(LocalPlayer.Z) : INT32_MIN; + const bool bInStrate = StrateManager->GetStrateUnrealZRange(ManagerLocalZ, TopZ, BotZ); + const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(ManagerLocalZ) : INT32_MIN; if (!bInStrate) { @@ -1403,8 +1625,9 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) // Shared strate context (a strate is a horizontal slab → same everywhere this update). FDecoContext Ctx; - Ctx.TopVoxelZ = TopZ / VOXEL_SIZE; - Ctx.BottomVoxelZ = BotZ / VOXEL_SIZE; + Ctx.StrateIndex = StrateIndex; + Ctx.TopVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(TopZ) : TopZ) / VOXEL_SIZE; + Ctx.BottomVoxelZ = (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(BotZ) : BotZ) / VOXEL_SIZE; Ctx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE); const FIntVector RepChunk(FMath::FloorToInt(LocalPlayer.X / ChunkWorld), FMath::FloorToInt(LocalPlayer.Y / ChunkWorld), Ctx.RepChunkZ); @@ -1416,10 +1639,30 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) } Ctx.Def = Def; Ctx.bSurfaceWorld = (StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld); + Ctx.NativeSurface = NativeSurfaceSnapshot; + Ctx.bNativeSurface = VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore + && Ctx.bSurfaceWorld && Ctx.NativeSurface.IsValid() + && Ctx.NativeSurface->FindPlan(StrateIndex) != nullptr; { - const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk); - Ctx.bHasWater = (Wv != -FLT_MAX); - Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX; + bool bHasNativePlan = false; + bool bNativeWaterEnabled = false; + float NativeWaterLocalZ = -FLT_MAX; + if (Ctx.bNativeSurface + && Ctx.NativeSurface->GetWater(StrateIndex, bHasNativePlan, + bNativeWaterEnabled, NativeWaterLocalZ) + && bHasNativePlan) + { + Ctx.bHasWater = bNativeWaterEnabled; + Ctx.WaterLocalZ = bNativeWaterEnabled ? NativeWaterLocalZ : -FLT_MAX; + } + else + { + const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk); + Ctx.bHasWater = (Wv != -FLT_MAX); + Ctx.WaterLocalZ = Ctx.bHasWater + ? (VoxelWorld ? VoxelWorld->FromStrateManagerLocalZ(Wv * VOXEL_SIZE) : Wv * VOXEL_SIZE) + : -FLT_MAX; + } } // Landmarks use the same per-column biome gates as decorations. Keep the complete POD biome // context in the worker/game-thread placement context; a null/default context would silently @@ -1585,16 +1828,51 @@ void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos) const FTransform Xf = OwnerActor->GetActorTransform(); const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos); + const AVoxelWorld* VoxelWorld = Cast(OwnerActor); + const float LocalToManagerScale = VoxelWorld + ? VOXEL_SIZE / VoxelWorld->GetActiveVoxelSizeCm() : 1.0f; + FVector ManagerLocalPos = LocalPos * LocalToManagerScale; + if (VoxelWorld) + { + ManagerLocalPos.Z = VoxelWorld->ToStrateManagerLocalZ(LocalPos.Z); + } // Player chunk (voxel→chunk) for the strate / water-level lookup. const float ChunkWorld = (float)CHUNK_SIZE * VOXEL_SIZE; // 1 chunk footprint in cm const FIntVector PlayerChunk( - FMath::FloorToInt(LocalPos.X / ChunkWorld), - FMath::FloorToInt(LocalPos.Y / ChunkWorld), - FMath::FloorToInt(LocalPos.Z / ChunkWorld)); + FMath::FloorToInt(ManagerLocalPos.X / ChunkWorld), + FMath::FloorToInt(ManagerLocalPos.Y / ChunkWorld), + FMath::FloorToInt(ManagerLocalPos.Z / ChunkWorld)); - const float WaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(PlayerChunk); - if (WaterVoxelZ == -FLT_MAX) // current strate has no water → hide the plane + float WaterLocalZ = -FLT_MAX; + const int32 StrateIndex = StrateManager->GetStrateIndex(ManagerLocalPos.Z); + bool bHasNativePlan = false; + bool bNativeWaterEnabled = false; + float NativeWaterLocalZ = -FLT_MAX; + if (VoxelWorld != nullptr && VoxelWorld->bUseVoxelForgeCore + && NativeSurfaceSnapshot.IsValid() + && NativeSurfaceSnapshot->GetWater(StrateIndex, bHasNativePlan, + bNativeWaterEnabled, NativeWaterLocalZ) + && bHasNativePlan) + { + if (bNativeWaterEnabled) + { + WaterLocalZ = NativeWaterLocalZ; + } + } + else + { + const float WaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(PlayerChunk); + if (WaterVoxelZ != -FLT_MAX) + { + const float ManagerWaterLocalZ = WaterVoxelZ * VOXEL_SIZE; + WaterLocalZ = VoxelWorld + ? VoxelWorld->FromStrateManagerLocalZ(ManagerWaterLocalZ) : ManagerWaterLocalZ; + } + } + + // No water in this strate means the plane stays hidden. / Sans eau dans cette strate, le plan reste masque. + if (WaterLocalZ == -FLT_MAX) { if (WaterPlane) { WaterPlane->SetVisibility(false); } LastWaterZ = -FLT_MAX; @@ -1609,7 +1887,7 @@ void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos) FMath::FloorToInt(LocalPos.X / ChunkWorld), FMath::FloorToInt(LocalPos.Y / ChunkWorld)); - if (WaterPlane && WaterPlane->IsVisible() && WaterVoxelZ == LastWaterZ && Cell == LastWaterCell) + if (WaterPlane && WaterPlane->IsVisible() && WaterLocalZ == LastWaterZ && Cell == LastWaterCell) return; // nothing changed if (!WaterPlane) @@ -1632,7 +1910,7 @@ void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos) const FVector LocalCenter(((float)Cell.X + 0.5f) * ChunkWorld, ((float)Cell.Y + 0.5f) * ChunkWorld, - WaterVoxelZ * VOXEL_SIZE); + WaterLocalZ); WaterPlane->SetWorldLocation(Xf.TransformPosition(LocalCenter)); WaterPlane->SetVisibility(true); @@ -1641,7 +1919,7 @@ void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos) if (Def->WaterMaterial) { WaterPlane->SetMaterial(0, Def->WaterMaterial); } } - LastWaterZ = WaterVoxelZ; + LastWaterZ = WaterLocalZ; LastWaterCell = Cell; } diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 1f978f5..3d76831 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -119,6 +119,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT // grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen. Step = FMath::Max(1, Step); + const float GeometryVoxelSize = VoxelMath::IsFinite(GeometryVoxelSizeCm) + ? FMath::Max(1.0f, GeometryVoxelSizeCm) : VOXEL_SIZE; // T2.b — octave bias for THIS tile: drop LODOctaveDrop octaves per Step doubling from // the generator's per-voxel volumetric noise (sub-cell octaves can't shape a coarse @@ -130,7 +132,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, TGuardValue SampleStepGuard(VoxelGenLOD::SampleStep, Step); // World-cm origin of the tile's min corner (positions are built relative to this). - const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE; + const FVector ChunkWorldPos = FVector(OriginVoxels) * GeometryVoxelSize; //========================================================================= // VERTEX DEDUPLICATION MAP @@ -1120,7 +1122,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 GY = cy + CornerOffsets[i].Y; const int32 GZ = cz + CornerOffsets[i].Z; Positions[i] = ChunkWorldPos - + FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE; + + FVector(GX * Step, GY * Step, GZ * Step) * GeometryVoxelSize; Gradients[i] = GradAt(GX, GY, GZ); } if (bSampleCell) @@ -1224,10 +1226,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, MeshData.NumSurfaceVertices = MeshData.Vertices.Num(); if (bGenerateSkirts && (GroundTris.Num() + CapTris.Num()) > 0) { - const float ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE; + const float ExtentCm = (float)(CellsPerAxis * Step) * GeometryVoxelSize; const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z; const float MaxX = MinX + ExtentCm, MaxY = MinY + ExtentCm, MaxZ = MinZ + ExtentCm; - const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * VOXEL_SIZE; + const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * GeometryVoxelSize; auto OnBoundaryPlane = [&](const FVector& A, const FVector& B) -> bool { diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 0a5a039..d75622e 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -2403,6 +2403,7 @@ void AVoxelWorld::BeginPlay() Generator->InitializeSettings(Settings); Mesher->SetGenerator(Generator); + Mesher->SetGeometryVoxelSizeCm(GetActiveVoxelSizeCm()); Mesher->SetShutdownFlag(&bShuttingDown); Mesher->bGenerateSkirts = Settings->bGenerateSkirts; Mesher->SkirtCells = Settings->SkirtCells; @@ -12476,10 +12477,21 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const // GetStrateIndex veut des cm ACTEUR-LOCAUX (il convertit cm -> voxels lui-meme). // GetStrateIndex wants actor-LOCAL cm (it does the cm -> voxel conversion itself). const float LocalZCm = WorldToLocalCm(WorldPosition).Z; - const float StrateManagerZCm = bUseVoxelForgeCore + return StrateManager->GetStrateIndex(ToStrateManagerLocalZ(LocalZCm)); +} + +float AVoxelWorld::ToStrateManagerLocalZ(float LocalZCm) const +{ + return bUseVoxelForgeCore ? (LocalZCm / GetActiveVoxelSizeCm()) * VOXEL_SIZE : LocalZCm; - return StrateManager->GetStrateIndex(StrateManagerZCm); +} + +float AVoxelWorld::FromStrateManagerLocalZ(float ManagerLocalZCm) const +{ + return bUseVoxelForgeCore + ? (ManagerLocalZCm / VOXEL_SIZE) * GetActiveVoxelSizeCm() + : ManagerLocalZCm; } FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const @@ -12610,8 +12622,9 @@ void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength { FVoxelModification Mod; Mod.Center = WorldToLocalVoxel(Position); // world cm → voxel space - Mod.Radius = Radius * GetLegacyVoxelToActiveVoxelScale(); - Mod.Strength = -FMath::Abs(Strength); // force negative for carving + const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); + Mod.Radius = Radius * VoxelScale; + Mod.Strength = -FMath::Abs(Strength) * VoxelScale; // force negative for carving / Force le signe negatif pour creuser. ApplyModification(Mod); } @@ -12619,8 +12632,9 @@ void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength) { FVoxelModification Mod; Mod.Center = WorldToLocalVoxel(Position); - Mod.Radius = Radius * GetLegacyVoxelToActiveVoxelScale(); - Mod.Strength = FMath::Abs(Strength); // force positive for filling + const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); + Mod.Radius = Radius * VoxelScale; + Mod.Strength = FMath::Abs(Strength) * VoxelScale; // force positive for filling / Force le signe positif pour remplir. ApplyModification(Mod); } @@ -12811,7 +12825,9 @@ void AVoxelWorld::ApplyCoreModification(const FVoxelModification& Modification) Brush.halfExtent = {Modification.BoxExtent.X, Modification.BoxExtent.Y, Modification.BoxExtent.Z}; Brush.radius = FMath::Max(0.0f, Modification.Radius); Brush.falloff = FMath::Max(0.0f, Modification.Falloff); - Brush.strength = Modification.Strength; + // Legacy subtracts the modification offset; Core's densityOffset operation adds it. + // Le legacy soustrait le decalage; l'operation densityOffset de Core l'ajoute. + Brush.strength = -Modification.Strength; switch (Modification.Shape) { case EVoxelBrushShape::Box: @@ -12971,7 +12987,7 @@ void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strengt Mod.BoxExtent = ExtentVoxels * VoxelScale; Mod.Falloff *= VoxelScale; Mod.Radius = ExtentVoxels.GetMax() * VoxelScale; // budget proxy - Mod.Strength = -FMath::Abs(Strength); // carve + Mod.Strength = -FMath::Abs(Strength) * VoxelScale; // carve / creuser ApplyModification(Mod); } @@ -12984,7 +13000,7 @@ void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength Mod.BoxExtent = ExtentVoxels * VoxelScale; Mod.Falloff *= VoxelScale; Mod.Radius = ExtentVoxels.GetMax() * VoxelScale; - Mod.Strength = FMath::Abs(Strength); // fill + Mod.Strength = FMath::Abs(Strength) * VoxelScale; // fill / remplir ApplyModification(Mod); } @@ -12997,7 +13013,7 @@ void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxel const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); Mod.Radius = RadiusVoxels * VoxelScale; Mod.Falloff *= VoxelScale; - Mod.Strength = -FMath::Abs(Strength); + Mod.Strength = -FMath::Abs(Strength) * VoxelScale; ApplyModification(Mod); } @@ -13010,7 +13026,7 @@ void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); Mod.Radius = RadiusVoxels * VoxelScale; Mod.Falloff *= VoxelScale; - Mod.Strength = FMath::Abs(Strength); + Mod.Strength = FMath::Abs(Strength) * VoxelScale; ApplyModification(Mod); } diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h index a41cc5b..b07d2ac 100644 --- a/Source/VoxelForge/Public/VoxelContentManager.h +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -59,6 +59,7 @@ class UStaticMesh; class UStaticMeshComponent; class UHierarchicalInstancedStaticMeshComponent; class UMaterialInterface; +struct FCoreSurfaceSnapshot; // An active mini-sun light orb (a placed FStrateLandmark with bIsLightOrb). The terrain material marches // the density volume toward the nearest of these for raymarched shadows. Plain struct (not reflected); @@ -97,6 +98,11 @@ public: * plane). */ void UpdateWater(const FVector& PlayerWorldPos); + /** Read immutable Core ground/water anchors for placement. / Lit les ancres natives sol/eau pour le placement Core. */ + bool QueryNativeSurfaceAnchors(int32 StrateIndex, const FVector2D& LocalXYCm, + float& OutGroundLocalZCm, bool& bOutHasWater, + float& OutWaterLocalZCm) const; + //--- DECORATIONS (distance-based world grid, async march) ---------------- /** Stream decoration cells around the player: recompute the desired set on cell/strate change, * launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */ @@ -239,12 +245,15 @@ private: struct FDecoContext { const UVoxelStrateDefinition* Def = nullptr; // game-thread only (biome/decoration resolve) + int32 StrateIndex = INDEX_NONE; int32 RepChunkZ = 0; // representative chunk-Z for biome lookups float TopVoxelZ = 0.0f; // strate band, voxel coords (march range) float BottomVoxelZ = 0.0f; float WaterLocalZ = -FLT_MAX; // water surface, actor-local cm (-FLT_MAX = no water) bool bHasWater = false; bool bSurfaceWorld = false; // heightfield archetype → use the GetSurfaceHeightAt oracle + bool bNativeSurface = false; // Core manifest is the height oracle and water datum / Le manifeste Core definit le relief et le niveau d'eau. + TSharedPtr NativeSurface; // Strate biome field (PODs only → worker-safe). Empty ⇒ biomes disabled for this strate. The // worker resolves the dominant biome PER COLUMN (ResolveBiomeSampleAt) so decoration borders @@ -337,6 +346,10 @@ private: UPROPERTY() UVoxelSettings* Settings = nullptr; + // Immutable Core manifest snapshot captured by decoration workers; workers never touch UObjects. + // Instantane immuable du manifeste Core pour les workers de decoration; aucun worker ne touche aux UObjects. + TSharedPtr NativeSurfaceSnapshot; + int32 Seed = 0; // Engine unit plane (/Engine/BasicShapes/Plane) reused for every water surface. @@ -391,6 +404,6 @@ private: // Cached state so UpdateWater is a cheap no-op when neither the water level nor the snapped // player cell changed since last frame. - float LastWaterZ = -FLT_MAX; // strate water level (voxel Z) + float LastWaterZ = -FLT_MAX; // actor-local water height (cm) / Hauteur d'eau locale a l'acteur (cm). FIntPoint LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN); // snapped XY grid cell }; diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index c0ad880..3b6856f 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -90,6 +90,10 @@ public: void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; } + // Core tile coordinates use 25 cm voxels; legacy generation keeps 12.5 cm. + // Les coordonnees des tuiles Core utilisent des voxels de 25 cm; le legacy reste a 12,5 cm. + void SetGeometryVoxelSizeCm(float InVoxelSizeCm) { GeometryVoxelSizeCm = InVoxelSizeCm; } + // AVoxelWorld installs its bShuttingDown flag here. The commandlet path leaves it null. // This is intentionally read-only: workers may observe shutdown, but never mutate world state. void SetShutdownFlag(const std::atomic* InShutdownFlag) { ShutdownFlag = InShutdownFlag; } @@ -107,6 +111,7 @@ private: const std::atomic* ShutdownFlag = nullptr; const FVoxelSharedDensityGrid* SharedDensityGrid = nullptr; + float GeometryVoxelSizeCm = VOXEL_SIZE; public: diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index bf052f3..c1112e4 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -426,7 +426,8 @@ public: * @param Position - World-space center of the carve brush * @param Radius - Brush radius in legacy Runtime voxels (12.5 cm); Core converts it to * 25 cm voxels to preserve the physical brush size. - * @param Strength - How aggressively to carve (default 10, higher = deeper) + * @param Strength - Legacy Runtime density offset; Core scales it to 25 cm units. + * Offset de densite Runtime historique; Core le convertit en unite de 25 cm. */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") void CarveAtPosition(FVector Position, float Radius = 6.0f, float Strength = 10.0f); @@ -438,7 +439,8 @@ public: * @param Position - World-space center of the fill brush * @param Radius - Brush radius in legacy Runtime voxels (12.5 cm); Core converts it to * 25 cm voxels to preserve the physical brush size. - * @param Strength - How aggressively to fill (default 10, higher = more solid) + * @param Strength - Legacy Runtime density offset; Core scales it to 25 cm units. + * Offset de densite Runtime historique; Core le convertit en unite de 25 cm. */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") void FillAtPosition(FVector Position, float Radius = 6.0f, float Strength = 10.0f); @@ -446,7 +448,8 @@ public: /** * Box brush carve/fill. Position is world-space (Unreal units); ExtentVoxels is the * box half-size in legacy Runtime voxels (12.5 cm). Core converts it to 25 cm voxels. - * Strength magnitude controls aggressiveness (sign forced). + * Strength is a legacy-voxel density offset; Core scales it to 25 cm units and forces its sign. + * La force est un decalage de densite historique; Core la convertit en unite de 25 cm et impose le signe. */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") void CarveBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f); @@ -455,8 +458,9 @@ public: void FillBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f); /** - * Capsule brush carve/fill between two world-space points (Unreal units), with a tube radius - * in legacy Runtime voxels (12.5 cm). Core converts it to 25 cm voxels. + * Capsule brush carve/fill between two world-space points (Unreal units). Tube radius and density offset + * use legacy Runtime voxels (12.5 cm); Core converts both to 25 cm units. + * Creuse/remplit une capsule entre deux points monde. Rayon et decalage Runtime historique sont convertis en unite Core de 25 cm. * Great for boring tunnels or laying solid pillars/walls. */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") @@ -572,6 +576,11 @@ public: UFUNCTION(BlueprintPure, Category = "Voxel World|Strate") int32 GetStrateAtPosition(FVector WorldPosition) const; + // Convert actor-local physical Z to/from UVoxelStrateManager's legacy 12.5 cm space. + // Convertit Z local physique vers/depuis l'espace historique de UVoxelStrateManager. + float ToStrateManagerLocalZ(float LocalZCm) const; + float FromStrateManagerLocalZ(float ManagerLocalZCm) const; + /** * Probe the biome field at a world location (e.g. a mouse line-trace hit). Returns the dominant + * neighbour biome, the climate fields, the border blend weight, and the dominant biome's decoration @@ -626,6 +635,8 @@ public: private: friend class FScopedGenerationPause; + friend class UVoxelContentManager; + friend class UVoxelAtmosphereManager; #if WITH_EDITOR /** Last editor research-field selection applied to loaded tiles. */ diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 804a397..b71687f 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -3,13 +3,19 @@ #include "VoxelForgeRegionEvalExperiment.h" #include "CoreMinimal.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" #include "Async/ParallelFor.h" #include "Commandlets/Commandlet.h" +#include "Engine/Blueprint.h" #include "Containers/StringConv.h" #include "Components/SceneComponent.h" #include "Engine/Engine.h" #include "Engine/GameInstance.h" #include "Engine/World.h" +#include "GameFramework/Character.h" +#include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/Actor.h" #include "GameFramework/GameModeBase.h" #include "GameFramework/Pawn.h" @@ -44,6 +50,7 @@ #include "VoxelDensityAblation.h" #include "VoxelDensityProfile.h" #include "VoxelGenerator.h" +#include "VoxelContentManager.h" #include "VoxelGeometryHash.h" #include "VoxelMarchingCubesMesher.h" #include "VoxelRedefinedDensityField.h" @@ -74,6 +81,445 @@ #include #include +namespace +{ + enum class EAssemblerTileKind : uint8 + { + FlatColumn, + Relief, + Mixed, + Unknown + }; + + struct FAssemblerTileTimingSample + { + int32 LOD = 0; + EAssemblerTileKind Kind = EAssemblerTileKind::Unknown; + double ServiceSeconds = 0.0; + double ModelReadySeconds = 0.0; + double ClassificationMilliseconds = 0.0; + double ColumnMilliseconds = 0.0; + double MeshingMilliseconds = 0.0; + bool bHeightfieldBandLimited = false; + }; + + const TCHAR* AssemblerTileKindName(EAssemblerTileKind Kind) + { + switch (Kind) + { + case EAssemblerTileKind::FlatColumn: return TEXT("flat_column"); + case EAssemblerTileKind::Relief: return TEXT("relief"); + case EAssemblerTileKind::Mixed: return TEXT("mixed"); + default: return TEXT("unknown"); + } + } + + EAssemblerTileKind ClassifyAssemblerTile(const VoxelForgeCore::TileMesh& Mesh) + { + const VoxelForgeCore::TileGenerationStats& Stats = Mesh.diagnostics; + if (Stats.featureRefinedResolution) + { + return EAssemblerTileKind::Relief; + } + + double MinGroundZ = std::numeric_limits::infinity(); + double MaxGroundZ = -std::numeric_limits::infinity(); + const std::size_t GroundIndexCount = std::min( + Mesh.indices.size(), static_cast(Mesh.groups.groundTriangleCount) * 3u); + const std::size_t ProbeCount = std::min(64u, GroundIndexCount); + for (std::size_t Probe = 0; Probe < ProbeCount; ++Probe) + { + const std::size_t Index = ProbeCount <= 1u ? 0u + : (Probe * (GroundIndexCount - 1u)) / (ProbeCount - 1u); + const std::uint32_t VertexIndex = Mesh.indices[Index]; + if (VertexIndex >= Mesh.vertices.size()) + { + return EAssemblerTileKind::Unknown; + } + const double Z = Mesh.vertices[VertexIndex].position.z; + if (!std::isfinite(Z)) + { + return EAssemblerTileKind::Unknown; + } + MinGroundZ = std::min(MinGroundZ, Z); + MaxGroundZ = std::max(MaxGroundZ, Z); + } + const int32 LOD = FMath::Clamp(static_cast(Mesh.key.lod), 0, 20); + const double ReliefThresholdCm = 4.0 * 25.0 * static_cast(1u << LOD); + if (std::isfinite(MinGroundZ) && MaxGroundZ - MinGroundZ > ReliefThresholdCm) + { + return EAssemblerTileKind::Relief; + } + if (Stats.meshingSkipped || Stats.tileClass == VoxelForgeCore::TileClass::allAir + || Stats.tileClass == VoxelForgeCore::TileClass::allSolid) + { + return EAssemblerTileKind::FlatColumn; + } + if (Stats.tileClass == VoxelForgeCore::TileClass::mixed || !Mesh.indices.empty()) + { + return EAssemblerTileKind::Mixed; + } + if (Stats.unknownBoxes > 0 || Stats.tileClass == VoxelForgeCore::TileClass::unknown) + { + return EAssemblerTileKind::Unknown; + } + return EAssemblerTileKind::Unknown; + } + + bool TryParseBlueprintPinNumber(const UEdGraphPin* Pin, double& OutValue) + { + if (Pin == nullptr || Pin->DefaultValue.IsEmpty()) + { + return false; + } + return LexTryParseString(OutValue, *Pin->DefaultValue) && std::isfinite(OutValue); + } + + bool ReadStagedPlayerSpeeds(float& OutWalkCmPerSecond, float& OutSprintCmPerSecond, + FString& OutError) + { + const UClass* CharacterClass = LoadClass(nullptr, + TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter.BP_FirstPersonCharacter_C")); + const UClass* ControllerClass = LoadClass(nullptr, + TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonPlayerController.BP_FirstPersonPlayerController_C")); + const ACharacter* CharacterDefault = CharacterClass + ? Cast(CharacterClass->GetDefaultObject()) : nullptr; + if (CharacterDefault == nullptr || CharacterDefault->GetCharacterMovement() == nullptr) + { + OutError = TEXT("BuildHost staged BP_FirstPersonCharacter CDO or CharacterMovement is missing."); + return false; + } + + OutWalkCmPerSecond = CharacterDefault->GetCharacterMovement()->MaxWalkSpeed; + double BestSprintSpeed = 0.0; + FString BestSprintSource; + const UBlueprint* CharacterBlueprint = LoadObject(nullptr, + TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter.BP_FirstPersonCharacter")); + const UBlueprint* ControllerBlueprint = LoadObject(nullptr, + TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonPlayerController.BP_FirstPersonPlayerController")); + const auto ScanBlueprintGraphs = [&](const UBlueprint* Blueprint, const TCHAR* BlueprintName) + { + if (Blueprint == nullptr) + { + return; + } + const auto ScanGraph = [&](const UEdGraph* Graph) + { + if (Graph == nullptr) + { + return; + } + for (const UEdGraphNode* Node : Graph->Nodes) + { + if (Node == nullptr) + { + continue; + } + for (const UEdGraphPin* Pin : Node->Pins) + { + if (Pin == nullptr || Pin->Direction != EGPD_Input + || !Pin->PinName.ToString().Contains(TEXT("MaxWalkSpeed"), ESearchCase::IgnoreCase)) + { + continue; + } + double Value = 0.0; + bool bFoundValue = TryParseBlueprintPinNumber(Pin, Value); + if (!bFoundValue) + { + for (const UEdGraphPin* LinkedPin : Pin->LinkedTo) + { + if (TryParseBlueprintPinNumber(LinkedPin, Value)) + { + bFoundValue = true; + break; + } + } + } + if (bFoundValue && Value > static_cast(OutWalkCmPerSecond) + && Value > BestSprintSpeed) + { + BestSprintSpeed = Value; + BestSprintSource = FString::Printf(TEXT("%s.%s"), BlueprintName, + *Node->GetName()); + } + } + } + }; + for (const auto& GraphReference : Blueprint->UbergraphPages) + { + ScanGraph(GraphReference); + } + for (const auto& GraphReference : Blueprint->FunctionGraphs) + { + ScanGraph(GraphReference); + } + }; + ScanBlueprintGraphs(CharacterBlueprint, TEXT("BP_FirstPersonCharacter")); + ScanBlueprintGraphs(ControllerBlueprint, TEXT("BP_FirstPersonPlayerController")); + + if (!std::isfinite(OutWalkCmPerSecond) || OutWalkCmPerSecond <= 0.0f + || !std::isfinite(BestSprintSpeed) || BestSprintSpeed <= OutWalkCmPerSecond) + { + OutError = TEXT("BuildHost staged Blueprint graphs do not expose a MaxWalkSpeed input above the character CDO walk speed."); + return false; + } + OutSprintCmPerSecond = static_cast(BestSprintSpeed); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgePawnSpeedFixture] source=staged_blueprints walk_cm_s=%.3f sprint_cm_s=%.3f sprint_graph=%s"), + OutWalkCmPerSecond, OutSprintCmPerSecond, *BestSprintSource); + return true; + } + + struct FAssemblerGeometryAudit + { + bool bValid = false; + int32 LOD = 0; + FIntVector Tile = FIntVector::ZeroValue; + double ExpectedTileSpanCm = 0.0; + double CellWorldCm = 0.0; + double SpanXcm = 0.0; + double SpanYcm = 0.0; + double MaxTriangleEdgeCm = 0.0; + VoxelForgeCore::Vec3d MaxEdgeStart; + VoxelForgeCore::Vec3d MaxEdgeEnd; + std::uint64_t MaxEdgeTriangleIndex = 0; + int32 NormalSamples = 0; + int32 NormalOrientationMismatches = 0; + int32 TriangleSamples = 0; + int32 TriangleWindingMismatches = 0; + int32 SkirtTrianglesExcluded = 0; + }; + + bool AuditAssemblerCoreTile(const VoxelForgeCore::World& World, + const VoxelForgeCore::TileMesh& Mesh, + FAssemblerGeometryAudit& OutAudit, + FString& OutError) + { + if (Mesh.indices.empty() || Mesh.indices.size() % 3u != 0u || Mesh.vertices.empty()) + { + OutError = TEXT("Core geometry audit received a malformed triangle mesh."); + return false; + } + const std::size_t SurfaceVertexCount = std::min( + Mesh.vertices.size(), Mesh.groups.surfaceVertexCount); + if (SurfaceVertexCount == 0u) + { + OutError = TEXT("Core geometry audit mesh contains no surface vertices."); + return false; + } + + double MinX = std::numeric_limits::infinity(); + double MaxX = -std::numeric_limits::infinity(); + double MinY = std::numeric_limits::infinity(); + double MaxY = -std::numeric_limits::infinity(); + for (std::size_t Index = 0; Index < SurfaceVertexCount; ++Index) + { + const VoxelForgeCore::MeshVertex& Vertex = Mesh.vertices[Index]; + if (!std::isfinite(Vertex.position.x) || !std::isfinite(Vertex.position.y) + || !std::isfinite(Vertex.position.z)) + { + OutError = TEXT("Core geometry audit found a non-finite surface vertex."); + return false; + } + MinX = std::min(MinX, Vertex.position.x); + MaxX = std::max(MaxX, Vertex.position.x); + MinY = std::min(MinY, Vertex.position.y); + MaxY = std::max(MaxY, Vertex.position.y); + } + + const int32 LOD = static_cast(Mesh.key.lod); + const VoxelForgeCore::WorldConfiguration& Configuration = World.configuration(); + const int32 ExtentVoxels = CHUNK_SIZE << FMath::Clamp(LOD, 0, 20); + const int32 Cells = LOD < Configuration.fullResolutionLodCount + ? CHUNK_SIZE : FMath::Max(1, Configuration.coarseTileCells); + const double Step = static_cast(FMath::Max(1, ExtentVoxels / Cells)); + const int32 StepVoxels = FMath::Max(1, FMath::RoundToInt(Step)); + const double CellWorldCm = 25.0 * Step; + // Les coutures lointaines peuvent depasser deux cellules en plan sans former une pointe verticale. + // Far LOD transitions can span slightly over two horizontal cells without creating a vertical spike. + const double MaxEdgeCellMultiple = LOD >= 7 ? 4.0 : 2.0; + const double MaxEdgeWorldCm = MaxEdgeCellMultiple * CellWorldCm; + const int32 PreviousOctaveBias = VoxelGenLOD::GetThreadOctaveBias(); + const int32 PreviousSampleStep = VoxelGenLOD::GetThreadSampleStep(); + struct FRestoreAssemblerLodSampleState + { + int32 OctaveBias; + int32 SampleStep; + ~FRestoreAssemblerLodSampleState() + { + VoxelGenLOD::SetThreadOctaveBias(OctaveBias); + VoxelGenLOD::SetThreadSampleStep(SampleStep); + } + } RestoreLodSampleState{PreviousOctaveBias, PreviousSampleStep}; + const int32 OctaveBias = Configuration.lodOctaveDrop > 0 && StepVoxels > 1 + ? Configuration.lodOctaveDrop * static_cast(FMath::FloorLog2( + static_cast(StepVoxels))) : 0; + VoxelGenLOD::SetThreadOctaveBias(OctaveBias); + VoxelGenLOD::SetThreadSampleStep(StepVoxels); + for (std::size_t Index = 0; Index < Mesh.indices.size(); Index += 3u) + { + const std::size_t TriangleIndex = Index / 3u; + if (TriangleIndex < Mesh.skirtFaceMasks.size() + && Mesh.skirtFaceMasks[TriangleIndex] != 0u) + { + ++OutAudit.SkirtTrianglesExcluded; + continue; + } + const std::uint32_t IA = Mesh.indices[Index]; + const std::uint32_t IB = Mesh.indices[Index + 1u]; + const std::uint32_t IC = Mesh.indices[Index + 2u]; + if (IA >= Mesh.vertices.size() || IB >= Mesh.vertices.size() || IC >= Mesh.vertices.size()) + { + OutError = TEXT("Core geometry audit found an out-of-range triangle index."); + return false; + } + const auto& A = Mesh.vertices[IA].position; + const auto& B = Mesh.vertices[IB].position; + const auto& C = Mesh.vertices[IC].position; + const auto Distance = [](const auto& P, const auto& Q) + { + const double DX = P.x - Q.x; + const double DY = P.y - Q.y; + const double DZ = P.z - Q.z; + return std::sqrt(DX * DX + DY * DY + DZ * DZ); + }; + const double EdgeAB = Distance(A, B); + const double EdgeBC = Distance(B, C); + const double EdgeCA = Distance(C, A); + const double Edge = std::max({EdgeAB, EdgeBC, EdgeCA}); + const VoxelForgeCore::Vec3d* EdgeStart = &A; + const VoxelForgeCore::Vec3d* EdgeEnd = &B; + if (EdgeBC >= EdgeAB && EdgeBC >= EdgeCA) + { + EdgeStart = &B; + EdgeEnd = &C; + } + else if (EdgeCA >= EdgeAB) + { + EdgeStart = &C; + EdgeEnd = &A; + } + if (!std::isfinite(Edge) || Edge > MaxEdgeWorldCm + 0.1) + { + OutError = FString::Printf( + TEXT("Core geometry audit found an oversized edge %.3f cm (bound %.3f cm; delta=(%.3f,%.3f,%.3f), start=(%.3f,%.3f,%.3f), end=(%.3f,%.3f,%.3f)) in tile (%d,%d,%d) lod=%d triangle=%llu."), + Edge, MaxEdgeWorldCm, EdgeEnd->x - EdgeStart->x, EdgeEnd->y - EdgeStart->y, + EdgeEnd->z - EdgeStart->z, EdgeStart->x, EdgeStart->y, EdgeStart->z, + EdgeEnd->x, EdgeEnd->y, EdgeEnd->z, Mesh.key.coordinate.x, + Mesh.key.coordinate.y, Mesh.key.coordinate.z, LOD, + static_cast(TriangleIndex)); + return false; + } + if (Edge > OutAudit.MaxTriangleEdgeCm) + { + OutAudit.MaxTriangleEdgeCm = Edge; + OutAudit.MaxEdgeTriangleIndex = static_cast(TriangleIndex); + if (EdgeAB >= EdgeBC && EdgeAB >= EdgeCA) + { + OutAudit.MaxEdgeStart = A; + OutAudit.MaxEdgeEnd = B; + } + else if (EdgeBC >= EdgeCA) + { + OutAudit.MaxEdgeStart = B; + OutAudit.MaxEdgeEnd = C; + } + else + { + OutAudit.MaxEdgeStart = C; + OutAudit.MaxEdgeEnd = A; + } + } + + const double ABX = B.x - A.x, ABY = B.y - A.y, ABZ = B.z - A.z; + const double ACX = C.x - A.x, ACY = C.y - A.y, ACZ = C.z - A.z; + const double FaceX = ABY * ACZ - ABZ * ACY; + const double FaceY = ABZ * ACX - ABX * ACZ; + const double FaceZ = ABX * ACY - ABY * ACX; + const VoxelForgeCore::MeshVertex& NormalA = Mesh.vertices[IA]; + const VoxelForgeCore::MeshVertex& NormalB = Mesh.vertices[IB]; + const VoxelForgeCore::MeshVertex& NormalC = Mesh.vertices[IC]; + const double Alignment = FaceX * (NormalA.normal.x + NormalB.normal.x + NormalC.normal.x) + + FaceY * (NormalA.normal.y + NormalB.normal.y + NormalC.normal.y) + + FaceZ * (NormalA.normal.z + NormalB.normal.z + NormalC.normal.z); + const double FaceLengthSq = FaceX * FaceX + FaceY * FaceY + FaceZ * FaceZ; + if (FaceLengthSq > 1.0e-10) + { + if (Alignment < -1.0e-10 * std::sqrt(FaceLengthSq)) + { + // RealtimeMesh requires MC indices in reversed 0,2,1 order; this reports the expected convention. + // RealtimeMesh exige l'ordre MC inverse 0,2,1; ce compte documente la convention attendue. + ++OutAudit.TriangleWindingMismatches; + } + ++OutAudit.TriangleSamples; + } + } + + const std::size_t NormalProbeCount = std::min(96u, SurfaceVertexCount); + for (std::size_t Probe = 0; Probe < NormalProbeCount; ++Probe) + { + const std::size_t Index = NormalProbeCount <= 1u ? 0u + : Probe * (SurfaceVertexCount - 1u) / (NormalProbeCount - 1u); + const VoxelForgeCore::MeshVertex& Vertex = Mesh.vertices[Index]; + const double NX = Vertex.normal.x; + const double NY = Vertex.normal.y; + const double NZ = Vertex.normal.z; + const double NormalLengthSq = NX * NX + NY * NY + NZ * NZ; + if (!std::isfinite(NormalLengthSq) || std::abs(NormalLengthSq - 1.0) > 0.02) + { + OutError = FString::Printf( + TEXT("Core geometry audit found a non-unit normal (length squared %.5f)."), + NormalLengthSq); + return false; + } + // Garder la sonde locale evite de traverser plusieurs reliefs aux LOD lointains. + // Keep the probe local so coarse-LOD checks do not cross several terrain features. + if (LOD <= 1) + { + // Le champ complet ne represente pas la surface lointaine decimee par le LOD. + // The full-resolution field does not represent the decimated far-LOD surface. + const double NormalOffsetVoxels = std::min(0.5 * Step, 1.0); + const VoxelForgeCore::Vec3d Positive{ + Vertex.position.x / 25.0 + NX * NormalOffsetVoxels, + Vertex.position.y / 25.0 + NY * NormalOffsetVoxels, + Vertex.position.z / 25.0 + NZ * NormalOffsetVoxels}; + const VoxelForgeCore::Vec3d Negative{ + Vertex.position.x / 25.0 - NX * NormalOffsetVoxels, + Vertex.position.y / 25.0 - NY * NormalOffsetVoxels, + Vertex.position.z / 25.0 - NZ * NormalOffsetVoxels}; + const float PositiveDensity = World.densityAt(Positive); + const float NegativeDensity = World.densityAt(Negative); + if (PositiveDensity + 0.01f < NegativeDensity) + { + ++OutAudit.NormalOrientationMismatches; + } + ++OutAudit.NormalSamples; + } + } + if (OutAudit.NormalSamples > 0 + && OutAudit.NormalOrientationMismatches * 10 > OutAudit.NormalSamples) + { + OutError = FString::Printf( + TEXT("Core geometry audit found %d/%d normals pointing toward denser material in tile (%d,%d,%d) lod=%d step=%d octave_bias=%d."), + OutAudit.NormalOrientationMismatches, OutAudit.NormalSamples, + Mesh.key.coordinate.x, Mesh.key.coordinate.y, Mesh.key.coordinate.z, + LOD, StepVoxels, OctaveBias); + return false; + } + + OutAudit.bValid = true; + OutAudit.LOD = LOD; + OutAudit.Tile = FIntVector(static_cast(Mesh.key.coordinate.x), + static_cast(Mesh.key.coordinate.y), + static_cast(Mesh.key.coordinate.z)); + OutAudit.ExpectedTileSpanCm = static_cast(ExtentVoxels) * 25.0; + OutAudit.CellWorldCm = CellWorldCm; + OutAudit.SpanXcm = MaxX - MinX; + OutAudit.SpanYcm = MaxY - MinY; + return true; + } +} + namespace { using FExploreJsonWriter = TJsonWriter>; @@ -13205,6 +13651,14 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin return false; } + float StagedWalkSpeedCmPerSecond = 0.0f; + float StagedSprintSpeedCmPerSecond = 0.0f; + if (!ReadStagedPlayerSpeeds( + StagedWalkSpeedCmPerSecond, StagedSprintSpeedCmPerSecond, OutError)) + { + return false; + } + if (!Arguments.CoreWorldAssemblyManifestPath.IsEmpty()) { const FString ManifestPath = FPaths::ConvertRelativePathToFull( @@ -13265,7 +13719,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin TArray ExpectedAffectedTiles; FVector CarveLocalPositionCm = FVector::ZeroVector; FVoxelTileKey CarveTargetTile; - constexpr float CarveBrushRadiusLegacyVoxels = 5.0f; + constexpr float CarveBrushRadiusLegacyVoxels = 6.0f; constexpr double CoreVoxelSizeCm = 25.0; const double CarveBrushRadiusCoreVoxels = CarveBrushRadiusLegacyVoxels * static_cast(VOXEL_SIZE) / CoreVoxelSizeCm; @@ -13280,6 +13734,8 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin VoxelForgeCore::ShapeBuildRequest MatchedTimingInitialRequest; double NativeSurfaceRegionCompileSeconds = 0.0; double NativeSurfaceTileGenerationSeconds = 0.0; + std::vector NativeSurfaceTileSamples; + std::vector BridgeSurfaceTileSamples; std::uint64_t NativeSurfaceTriangleCount = 0; std::size_t ExpectedInitialTileCount = 0; std::size_t ExpectedTravelTileCount = 0; @@ -13637,7 +14093,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin Brush.halfExtent = {5.0, 5.0, 5.0}; Brush.radius = CarveBrushRadiusCoreVoxels; Brush.falloff = 2.0f; - Brush.strength = -40.0f; + Brush.strength = 5.0f; Transaction.brushes.push_back(Brush); const std::optional Edit = ReferenceWorld.applyEditTransaction(Transaction, Desired, CoreError); @@ -13821,6 +14277,17 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin SurfaceTravelRegionsBuilt = SurfaceTravelCompileStats.regionsBuilt; std::vector SurfaceTravelRows; SurfaceTravelRows.reserve(SurfaceTravelDesired.size()); + FAssemblerGeometryAudit GeometryAuditL0; + FAssemblerGeometryAudit GeometryAuditL1; + FAssemblerGeometryAudit GeometryAuditHorizon; + int32 GeometryAuditL0Distance = MAX_int32; + int32 GeometryAuditL1Distance = MAX_int32; + int32 GeometryAuditHorizonDistance = MAX_int32; + int32 HorizonAuditLOD = 0; + for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired) + { + HorizonAuditLOD = FMath::Max(HorizonAuditLOD, static_cast(Key.lod)); + } for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired) { const double TileGenerationStart = FPlatformTime::Seconds(); @@ -13833,8 +14300,52 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin UTF8_TO_TCHAR(CoreError.c_str())); return false; } - NativeSurfaceTileGenerationSeconds += FPlatformTime::Seconds() - - TileGenerationStart; + if ((Key.lod <= 1u || static_cast(Key.lod) == HorizonAuditLOD) + && Mesh->groups.groundTriangleCount > 0u) + { + const int32 LOD = static_cast(Key.lod); + const int32 ExtentVoxels = CHUNK_SIZE << LOD; + const FIntVector TargetTile( + FMath::FloorToInt(SurfaceCameraVoxels.x / ExtentVoxels), + FMath::FloorToInt(SurfaceCameraVoxels.y / ExtentVoxels), + FMath::FloorToInt(SurfaceCameraVoxels.z / ExtentVoxels)); + const FIntVector TileCoord( + static_cast(Key.coordinate.x), static_cast(Key.coordinate.y), + static_cast(Key.coordinate.z)); + const int32 Distance = FMath::Abs(TileCoord.X - TargetTile.X) + + FMath::Abs(TileCoord.Y - TargetTile.Y) + FMath::Abs(TileCoord.Z - TargetTile.Z); + FAssemblerGeometryAudit Candidate; + FString GeometryError; + if (!AuditAssemblerCoreTile(ReferenceWorld, *Mesh, Candidate, GeometryError)) + { + OutError = FString::Printf(TEXT("Native surface mesh geometry audit failed: %s"), + *GeometryError); + return false; + } + const double SpanRatioX = Candidate.SpanXcm / Candidate.ExpectedTileSpanCm; + const double SpanRatioY = Candidate.SpanYcm / Candidate.ExpectedTileSpanCm; + if (SpanRatioX >= 0.75 && SpanRatioY >= 0.75 + && SpanRatioX <= 1.05 && SpanRatioY <= 1.05) + { + if (LOD == 0 && Distance < GeometryAuditL0Distance) + { + GeometryAuditL0 = Candidate; + GeometryAuditL0Distance = Distance; + } + else if (LOD == 1 && Distance < GeometryAuditL1Distance) + { + GeometryAuditL1 = Candidate; + GeometryAuditL1Distance = Distance; + } + if (LOD == HorizonAuditLOD && Distance < GeometryAuditHorizonDistance) + { + GeometryAuditHorizon = Candidate; + GeometryAuditHorizonDistance = Distance; + } + } + } + const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart; + NativeSurfaceTileGenerationSeconds += TileGenerationSeconds; NativeSurfaceTriangleCount += Mesh->indices.size() / 3; const FVoxelTileKey RuntimeKey( FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod); @@ -13851,6 +14362,80 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin {Record.Density, Record.Mesh, Record.Payload, Record.Topology}}); if (Mesh->groups.skirtTriangleCount > 0) ++SurfaceTravelTilesWithSkirts; } + VoxelForgeCore::TileGenerationOptions NativeTimingOptions; + for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired) + { + if (Key.lod > 1u) + { + continue; + } + const double TileGenerationStart = FPlatformTime::Seconds(); + const std::optional Mesh = + ReferenceWorld.generateTile(Key, NativeTimingOptions, CoreError); + if (!Mesh) + { + OutError = FString::Printf(TEXT("Native unhashed streaming sample failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart; + NativeSurfaceTileSamples.push_back({ + static_cast(Key.lod), ClassifyAssemblerTile(*Mesh), TileGenerationSeconds, + 0.0, Mesh->diagnostics.classificationMilliseconds, + Mesh->diagnostics.columnComputeMilliseconds, Mesh->diagnostics.meshingMilliseconds, + Mesh->diagnostics.heightfieldBandLimited}); + } + if (!GeometryAuditL0.bValid || !GeometryAuditL1.bValid || !GeometryAuditHorizon.bValid) + { + OutError = FString::Printf( + TEXT("Native geometry audit missed full-span tiles (l0=%d l1=%d horizon_lod=%d horizon=%d)."), + GeometryAuditL0.bValid ? 1 : 0, GeometryAuditL1.bValid ? 1 : 0, + HorizonAuditLOD, GeometryAuditHorizon.bValid ? 1 : 0); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] geometry_scale_agreement=PASS legacy_default_cm=%.1f core_voxel_cm=%.1f " + "pre_fix_scale_ratio=%.4f lod0_expected_cm=%.1f span_cm=(%.1f,%.1f) ratio=(%.4f,%.4f) max_edge_cm=%.2f normal_samples=%d normal_mismatches=%d rmesh_reversed_winding=%d/%d skirts_excluded=%d " + "lod1_expected_cm=%.1f span_cm=(%.1f,%.1f) ratio=(%.4f,%.4f) max_edge_cm=%.2f normal_samples=%d normal_mismatches=%d rmesh_reversed_winding=%d/%d skirts_excluded=%d " + "horizon_lod=%d expected_cm=%.1f span_cm=(%.1f,%.1f) ratio=(%.4f,%.4f) max_edge_cm=%.2f normal_samples=%d normal_mismatches=%d rmesh_reversed_winding=%d/%d skirts_excluded=%d"), + VOXEL_SIZE, CoreVoxelSizeCm, + VOXEL_SIZE / CoreVoxelSizeCm, + GeometryAuditL0.ExpectedTileSpanCm, GeometryAuditL0.SpanXcm, GeometryAuditL0.SpanYcm, + GeometryAuditL0.SpanXcm / GeometryAuditL0.ExpectedTileSpanCm, + GeometryAuditL0.SpanYcm / GeometryAuditL0.ExpectedTileSpanCm, + GeometryAuditL0.MaxTriangleEdgeCm, GeometryAuditL0.NormalSamples, + GeometryAuditL0.NormalOrientationMismatches, GeometryAuditL0.TriangleWindingMismatches, + GeometryAuditL0.TriangleSamples, GeometryAuditL0.SkirtTrianglesExcluded, + GeometryAuditL1.ExpectedTileSpanCm, GeometryAuditL1.SpanXcm, GeometryAuditL1.SpanYcm, + GeometryAuditL1.SpanXcm / GeometryAuditL1.ExpectedTileSpanCm, + GeometryAuditL1.SpanYcm / GeometryAuditL1.ExpectedTileSpanCm, + GeometryAuditL1.MaxTriangleEdgeCm, GeometryAuditL1.NormalSamples, + GeometryAuditL1.NormalOrientationMismatches, GeometryAuditL1.TriangleWindingMismatches, + GeometryAuditL1.TriangleSamples, GeometryAuditL1.SkirtTrianglesExcluded, + HorizonAuditLOD, GeometryAuditHorizon.ExpectedTileSpanCm, + GeometryAuditHorizon.SpanXcm, GeometryAuditHorizon.SpanYcm, + GeometryAuditHorizon.SpanXcm / GeometryAuditHorizon.ExpectedTileSpanCm, + GeometryAuditHorizon.SpanYcm / GeometryAuditHorizon.ExpectedTileSpanCm, + GeometryAuditHorizon.MaxTriangleEdgeCm, GeometryAuditHorizon.NormalSamples, + GeometryAuditHorizon.NormalOrientationMismatches, + GeometryAuditHorizon.TriangleWindingMismatches, GeometryAuditHorizon.TriangleSamples, + GeometryAuditHorizon.SkirtTrianglesExcluded); + const auto LogGeometryAuditTile = [](const FAssemblerGeometryAudit& Audit) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeGeometryAudit] lod=%d tile=(%d,%d,%d) max_edge_cm=%.3f edge_cells=%.3f triangle=%llu endpoints_cm=((%.2f,%.2f,%.2f),(%.2f,%.2f,%.2f)) normal=%d/%d rmesh_reversed_winding=%d/%d skirt_triangles_excluded=%d"), + Audit.LOD, Audit.Tile.X, Audit.Tile.Y, Audit.Tile.Z, + Audit.MaxTriangleEdgeCm, Audit.MaxTriangleEdgeCm / FMath::Max(Audit.CellWorldCm, 1.0), + static_cast(Audit.MaxEdgeTriangleIndex), + Audit.MaxEdgeStart.x, Audit.MaxEdgeStart.y, Audit.MaxEdgeStart.z, + Audit.MaxEdgeEnd.x, Audit.MaxEdgeEnd.y, Audit.MaxEdgeEnd.z, + Audit.NormalOrientationMismatches, Audit.NormalSamples, + Audit.TriangleWindingMismatches, Audit.TriangleSamples, + Audit.SkirtTrianglesExcluded); + }; + LogGeometryAuditTile(GeometryAuditL0); + LogGeometryAuditTile(GeometryAuditL1); + LogGeometryAuditTile(GeometryAuditHorizon); const FIntVector SurfaceCollisionCenter( FMath::FloorToInt(SurfaceCameraVoxels.x / CHUNK_SIZE), FMath::FloorToInt(SurfaceCameraVoxels.y / CHUNK_SIZE), @@ -13965,7 +14550,6 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin const double TileGenerationStart = FPlatformTime::Seconds(); const std::optional Mesh = BridgeWorld.generateTile(Key, BridgeTileOptions, BridgeError); - BridgeSurfaceTileGenerationSeconds += FPlatformTime::Seconds() - TileGenerationStart; if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash || !Mesh->hasPayloadHash || !Mesh->hasTopologyHash) { @@ -13973,6 +14557,8 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin UTF8_TO_TCHAR(BridgeError.c_str())); return false; } + const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart; + BridgeSurfaceTileGenerationSeconds += TileGenerationSeconds; BridgeSurfaceTriangleCount += Mesh->indices.size() / 3; ++BridgeSurfaceTilesGenerated; } @@ -13981,6 +14567,97 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin OutError = TEXT("Matched legacy-bridge timing did not generate the complete surface key set."); return false; } + VoxelForgeCore::TileGenerationOptions BridgeTimingOptions; + for (const VoxelForgeCore::TileKey& Key : MatchedTimingSurfaceDesired) + { + if (Key.lod > 1u) + { + continue; + } + const double TileGenerationStart = FPlatformTime::Seconds(); + const std::optional Mesh = + BridgeWorld.generateTile(Key, BridgeTimingOptions, BridgeError); + if (!Mesh) + { + OutError = FString::Printf(TEXT("Legacy-bridge unhashed streaming sample failed: %s"), + UTF8_TO_TCHAR(BridgeError.c_str())); + return false; + } + const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart; + BridgeSurfaceTileSamples.push_back({ + static_cast(Key.lod), ClassifyAssemblerTile(*Mesh), TileGenerationSeconds, + 0.0, Mesh->diagnostics.classificationMilliseconds, + Mesh->diagnostics.columnComputeMilliseconds, Mesh->diagnostics.meshingMilliseconds, + Mesh->diagnostics.heightfieldBandLimited}); + } + + const auto AssignModelReadyTimes = [](std::vector& Samples) + { + constexpr int32 WorkerSlots = 16; + std::array WorkerReadySeconds{}; + for (FAssemblerTileTimingSample& Sample : Samples) + { + int32 EarliestWorker = 0; + for (int32 Worker = 1; Worker < WorkerSlots; ++Worker) + { + if (WorkerReadySeconds[Worker] < WorkerReadySeconds[EarliestWorker]) + { + EarliestWorker = Worker; + } + } + Sample.ModelReadySeconds = WorkerReadySeconds[EarliestWorker] + Sample.ServiceSeconds; + WorkerReadySeconds[EarliestWorker] = Sample.ModelReadySeconds; + } + }; + const auto Percentile = [](std::vector Values, double Fraction) + { + if (Values.empty()) return 0.0; + std::sort(Values.begin(), Values.end()); + const std::size_t Index = static_cast(FMath::Clamp( + FMath::CeilToInt(Fraction * static_cast(Values.size() - 1u)), + 0, static_cast(Values.size() - 1u))); + return Values[Index]; + }; + const auto EmitKindTiming = [&](const TCHAR* Backend, + std::vector& Samples) + { + AssignModelReadyTimes(Samples); + for (int32 LOD = 0; LOD <= 1; ++LOD) + { + for (int32 KindIndex = 0; KindIndex < 4; ++KindIndex) + { + const EAssemblerTileKind Kind = static_cast(KindIndex); + std::vector ServiceMs, ModelReadySeconds, ClassificationMs, ColumnMs, MeshingMs; + int32 HeightfieldBandedTiles = 0; + for (const FAssemblerTileTimingSample& Sample : Samples) + { + if (Sample.LOD != LOD || Sample.Kind != Kind) continue; + ServiceMs.push_back(Sample.ServiceSeconds * 1000.0); + ModelReadySeconds.push_back(Sample.ModelReadySeconds); + ClassificationMs.push_back(Sample.ClassificationMilliseconds); + ColumnMs.push_back(Sample.ColumnMilliseconds); + MeshingMs.push_back(Sample.MeshingMilliseconds); + if(Sample.bHeightfieldBandLimited)++HeightfieldBandedTiles; + } + const double ReadyP95 = Percentile(ModelReadySeconds, 0.95); + UE_LOG(LogTemp, Display, + TEXT("[WorldAssemblerPawnLag] sample=unhashed_runtime_fastpath backend=%s lod=%d kind=%s tiles=%d heightfield_banded=%d " + "service_ms[p50=%.3f p95=%.3f] worker_stage_ms_p95[classify=%.3f column=%.3f mesh=%.3f] " + "model_workers=16 model_ready_p95_s=%.6f walk_lag_p95_m=%.3f sprint_lag_p95_m=%.3f " + "worker_order=desired_distance"), + Backend, LOD, AssemblerTileKindName(Kind), static_cast(ServiceMs.size()), + HeightfieldBandedTiles, + Percentile(ServiceMs, 0.50), Percentile(ServiceMs, 0.95), + Percentile(ClassificationMs, 0.95), Percentile(ColumnMs, 0.95), + Percentile(MeshingMs, 0.95), ReadyP95, + ReadyP95 * static_cast(StagedWalkSpeedCmPerSecond) / 100.0, + ReadyP95 * static_cast(StagedSprintSpeedCmPerSecond) / 100.0); + } + } + }; + EmitKindTiming(TEXT("core_native"), NativeSurfaceTileSamples); + EmitKindTiming(TEXT("legacy_surface_bridge"), BridgeSurfaceTileSamples); + const double NativeTotal = NativeSurfaceRegionCompileSeconds + NativeSurfaceTileGenerationSeconds; const double BridgeTotal = BridgeSurfaceRegionCompileSeconds @@ -14230,6 +14907,91 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin static_cast(InitialAggregates[2]), static_cast(InitialAggregates[3])); + if (Actor->ContentManager == nullptr) + { + OutError = TEXT("Core height agreement proof has no content manager."); + return false; + } + const FVector SurfaceLocalPosition( + SurfaceCameraVoxels.x * CoreVoxelSizeCm, + SurfaceCameraVoxels.y * CoreVoxelSizeCm, + SurfaceCameraVoxels.z * CoreVoxelSizeCm); + const FVector SurfaceWorldPosition = Actor->GetActorTransform().TransformPosition(SurfaceLocalPosition); + const int32 LegacyUnscaledStrateIndex = Manager->GetStrateIndex(SurfaceLocalPosition.Z); + const int32 ResolvedStrateIndex = Actor->GetStrateAtPosition(SurfaceWorldPosition); + float ContentGroundLocalZ = 0.0f; + bool bContentHasWater = false; + float ContentWaterLocalZ = -FLT_MAX; + if (ResolvedStrateIndex != 1 + || !Actor->ContentManager->QueryNativeSurfaceAnchors(ResolvedStrateIndex, + FVector2D(SurfaceLocalPosition.X, SurfaceLocalPosition.Y), + ContentGroundLocalZ, bContentHasWater, ContentWaterLocalZ)) + { + OutError = FString::Printf( + TEXT("Core height agreement failed: raw-strate=%d resolved-strate=%d content-anchor=missing."), + LegacyUnscaledStrateIndex, ResolvedStrateIndex); + return false; + } + const float ManagerLocalZ = Actor->ToStrateManagerLocalZ(SurfaceLocalPosition.Z); + float ManagerTopZ = 0.0f, ManagerBottomZ = 0.0f; + if (!Manager->GetStrateUnrealZRange(ManagerLocalZ, ManagerTopZ, ManagerBottomZ)) + { + OutError = TEXT("Core height agreement failed: manager has no corrected strate bounds."); + return false; + } + const float AtmosphereTopLocalZ = Actor->FromStrateManagerLocalZ(ManagerTopZ); + const float AtmosphereBottomLocalZ = Actor->FromStrateManagerLocalZ(ManagerBottomZ); + const FVector GroundQueryLocal( + SurfaceLocalPosition.X, SurfaceLocalPosition.Y, ContentGroundLocalZ + CoreVoxelSizeCm); + const FVector GroundQueryWorld = Actor->GetActorTransform().TransformPosition(GroundQueryLocal); + float TerrainGroundWorldZ = 0.0f, TerrainCeilingWorldZ = 0.0f; + if (!Actor->GetVoxelSurfaceHeightAt(GroundQueryWorld, TerrainGroundWorldZ, TerrainCeilingWorldZ)) + { + OutError = TEXT("Core height agreement failed: native terrain returned no ground anchor."); + return false; + } + const float ContentGroundWorldZ = Actor->GetActorTransform().TransformPosition( + FVector(SurfaceLocalPosition.X, SurfaceLocalPosition.Y, ContentGroundLocalZ)).Z; + const float GroundAgreementCm = FMath::Abs(TerrainGroundWorldZ - ContentGroundWorldZ); + if (GroundAgreementCm > 25.0f + || ContentGroundLocalZ < AtmosphereBottomLocalZ + || ContentGroundLocalZ > AtmosphereTopLocalZ + || (bContentHasWater && (ContentWaterLocalZ < AtmosphereBottomLocalZ + || ContentWaterLocalZ > AtmosphereTopLocalZ))) + { + OutError = FString::Printf( + TEXT("Core height agreement failed: terrain/content delta=%.3f cm ground=%.3f water=%.3f atmosphere=(%.3f,%.3f)."), + GroundAgreementCm, ContentGroundLocalZ, ContentWaterLocalZ, + AtmosphereBottomLocalZ, AtmosphereTopLocalZ); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] height_agreement=PASS legacy_unscaled_strate=%d corrected_strate=%d terrain_ground_cm=%.3f content_ground_cm=%.3f water_enabled=%d water_cm=%.3f atmosphere_bounds_cm=(%.3f,%.3f) ground_delta_cm=%.3f"), + LegacyUnscaledStrateIndex, ResolvedStrateIndex, TerrainGroundWorldZ, + ContentGroundLocalZ, bContentHasWater ? 1 : 0, ContentWaterLocalZ, + AtmosphereBottomLocalZ, AtmosphereTopLocalZ, GroundAgreementCm); + if (!Actor->bUseVoxelForgeCore) + { + OutError = TEXT("BuildHost height agreement fixture unexpectedly has Core disabled."); + return false; + } + Actor->bUseVoxelForgeCore = false; + float DisabledGroundLocalZ = 0.0f; + bool bDisabledHasWater = false; + float DisabledWaterLocalZ = -FLT_MAX; + const bool bNativeAnchorEscapedSwitch = Actor->ContentManager->QueryNativeSurfaceAnchors( + ResolvedStrateIndex, FVector2D(SurfaceLocalPosition.X, SurfaceLocalPosition.Y), + DisabledGroundLocalZ, bDisabledHasWater, DisabledWaterLocalZ); + Actor->bUseVoxelForgeCore = true; + if (bNativeAnchorEscapedSwitch) + { + OutError = TEXT("BuildHost native content anchor remained available with Core disabled."); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] switch_off_content_gate=PASS native_anchor_when_disabled=0 legacy_voxel_cm=%.1f"), + VOXEL_SIZE); + AActor* Anchor = TestWorld->SpawnActor(AActor::StaticClass()); if (Anchor == nullptr) { @@ -14283,8 +15045,38 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCoreWorldTest] pass phase=anchor old=(20,0,0) new=(21,0,0) requested=1 released=1")); - Actor->CarveAtPosition(Actor->GetActorTransform().TransformPosition(CarveLocalPositionCm), - CarveBrushRadiusLegacyVoxels, 40.0f); + const FVector CarveWorldPosition = Actor->GetActorTransform().TransformPosition(CarveLocalPositionCm); + const FVector InsideBrushWorldPosition = CarveWorldPosition + FVector(60.0f, 0.0f, 0.0f); + const FVector OutsideBrushWorldPosition = CarveWorldPosition + FVector(80.0f, 0.0f, 0.0f); + float CarveDensityBefore = 0.0f, InsideDensityBefore = 0.0f, OutsideDensityBefore = 0.0f; + if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, CarveDensityBefore) + || !Actor->QueryVoxelForgeCoreDensityAt(InsideBrushWorldPosition, InsideDensityBefore) + || !Actor->QueryVoxelForgeCoreDensityAt(OutsideBrushWorldPosition, OutsideDensityBefore)) + { + OutError = TEXT("Core Blueprint carve fixture could not read its pre-edit density samples."); + return false; + } + Actor->CarveAtPosition(CarveWorldPosition, CarveBrushRadiusLegacyVoxels, 10.0f); + float CarveDensityAfter = 0.0f, InsideDensityAfter = 0.0f, OutsideDensityAfter = 0.0f; + if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, CarveDensityAfter) + || !Actor->QueryVoxelForgeCoreDensityAt(InsideBrushWorldPosition, InsideDensityAfter) + || !Actor->QueryVoxelForgeCoreDensityAt(OutsideBrushWorldPosition, OutsideDensityAfter)) + { + OutError = TEXT("Core Blueprint carve fixture could not read its post-edit density samples."); + return false; + } + const float CarveCenterDelta = CarveDensityAfter - CarveDensityBefore; + const float CarveInsideDelta = InsideDensityAfter - InsideDensityBefore; + const float CarveOutsideDelta = OutsideDensityAfter - OutsideDensityBefore; + if (!FMath::IsNearlyEqual(CarveCenterDelta, 5.0f, 0.01f) + || !(CarveInsideDelta > 0.0f && CarveInsideDelta < CarveCenterDelta) + || !FMath::IsNearlyZero(CarveOutsideDelta, 0.001f)) + { + OutError = FString::Printf( + TEXT("Blueprint carve sign/size failed: center=%.4f inside60cm=%.4f outside80cm=%.4f; expected +5, partial +, zero."), + CarveCenterDelta, CarveInsideDelta, CarveOutsideDelta); + return false; + } if (Actor->LastVoxelForgeCoreEditSequence != 1 || Actor->LastVoxelForgeCoreEditDigest != ExpectedEditDigest || Actor->LastVoxelForgeCoreAffectedTiles != ExpectedAffectedTiles) @@ -14319,6 +15111,9 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin ExpectedAffectedTiles.Num(), CarveTargetTile.Coord.X, CarveTargetTile.Coord.Y, CarveTargetTile.Coord.Z, CarveTargetTile.Level, static_cast(ExpectedPostEditHashes.FindChecked(CarveTargetTile).Mesh)); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] blueprint_carve=PASS radius_legacy=6 strength_legacy=10 center_delta=%.4f inside_60cm_delta=%.4f outside_80cm_delta=%.4f"), + CarveCenterDelta, CarveInsideDelta, CarveOutsideDelta); Actor->UnregisterStreamingAnchor(Anchor); Anchor->Destroy(); @@ -14427,11 +15222,11 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin static_cast(TravelAggregates[2]), static_cast(TravelAggregates[3])); - const FVector SurfaceWorldPosition( + const FVector SurfaceTravelWorldPosition( SurfaceCameraVoxels.x * CoreVoxelSizeCm, SurfaceCameraVoxels.y * CoreVoxelSizeCm, SurfaceCameraVoxels.z * CoreVoxelSizeCm); - TravelPawn->SetActorLocation(SurfaceWorldPosition); + TravelPawn->SetActorLocation(SurfaceTravelWorldPosition); auto WaitForSurfaceVisualTiles = [&]() { const double Deadline = FPlatformTime::Seconds() + 120.0; @@ -14612,6 +15407,40 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin static_cast(SurfaceTravelAggregates[1]), static_cast(SurfaceTravelAggregates[2]), static_cast(SurfaceTravelAggregates[3])); + const FVector FillInsideWorldPosition = CarveWorldPosition + FVector(60.0f, 0.0f, 0.0f); + const FVector FillOutsideWorldPosition = CarveWorldPosition + FVector(80.0f, 0.0f, 0.0f); + float FillDensityBefore = 0.0f, FillInsideDensityBefore = 0.0f, FillOutsideDensityBefore = 0.0f; + if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, FillDensityBefore) + || !Actor->QueryVoxelForgeCoreDensityAt(FillInsideWorldPosition, FillInsideDensityBefore) + || !Actor->QueryVoxelForgeCoreDensityAt(FillOutsideWorldPosition, FillOutsideDensityBefore)) + { + OutError = TEXT("Core Blueprint fill fixture could not read its pre-edit size samples."); + return false; + } + Actor->FillAtPosition(CarveWorldPosition, 6.0f, 10.0f); + float FillDensityAfter = 0.0f, FillInsideDensityAfter = 0.0f, FillOutsideDensityAfter = 0.0f; + if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, FillDensityAfter) + || !Actor->QueryVoxelForgeCoreDensityAt(FillInsideWorldPosition, FillInsideDensityAfter) + || !Actor->QueryVoxelForgeCoreDensityAt(FillOutsideWorldPosition, FillOutsideDensityAfter)) + { + OutError = TEXT("Core Blueprint fill fixture could not read its post-edit size samples."); + return false; + } + const float FillCenterDelta = FillDensityAfter - FillDensityBefore; + const float FillInsideDelta = FillInsideDensityAfter - FillInsideDensityBefore; + const float FillOutsideDelta = FillOutsideDensityAfter - FillOutsideDensityBefore; + if (!FMath::IsNearlyEqual(FillCenterDelta, -5.0f, 0.01f) + || !(FillInsideDelta < 0.0f && FillInsideDelta > FillCenterDelta) + || !FMath::IsNearlyZero(FillOutsideDelta, 0.001f)) + { + OutError = FString::Printf( + TEXT("Blueprint fill sign/size failed: center=%.4f inside60cm=%.4f outside80cm=%.4f; expected -5, partial -, zero."), + FillCenterDelta, FillInsideDelta, FillOutsideDelta); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] blueprint_fill=PASS radius_legacy=6 strength_legacy=10 center_delta=%.4f inside_60cm_delta=%.4f outside_80cm_delta=%.4f"), + FillCenterDelta, FillInsideDelta, FillOutsideDelta); return true; } diff --git a/Standalone/ASSEMBLER_M1_REPORT.md b/Standalone/ASSEMBLER_M1_REPORT.md index 5af9505..5a7629f 100644 --- a/Standalone/ASSEMBLER_M1_REPORT.md +++ b/Standalone/ASSEMBLER_M1_REPORT.md @@ -651,3 +651,157 @@ New source files: none. Modified source files: `Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp`, `Source/VoxelForge/Private/VoxelWorld.cpp`, and `Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp`. Modified BuildHost test tool: `Tools/TestVoxelForgeCoreWorldHost.ps1`. + +## Round 6 — native surface walk defects and streaming + +The owner built `f67b519` with Core on, the reviewed assembly manifest imported, and then saw the +native terrain below the grass, trees, water plane and strate content. This round corrected the +Core/legacy scale boundary, adapted the manifest surface datum into the plugin's shared systems, +repaired the far-shell bridge and Blueprint brush adapter, and narrowed safe native surface mesh +work. BuildHost staged the same five owner `.uasset` copies as the earlier actor test and verified +their hashes; manifest hash was `8933693d9560ba9f`. No owner asset or editor was touched. + +### Four symptoms: causes and paired evidence + +| Symptom | Cause and before → after evidence | +|---|---| +| LOD0 triangles poke through; horizon is worse | Core keys were 25 cm while the shared mesher still emitted at its 12.5 cm legacy default, so native spans were half size; the far-shell repair also bridged a broad unsampled LOD8 crossing with one quad. The owner’s [before screenshot](../Standalone/owner_shots/assembler_m1/28-surface-below-content.jpg) is the visual baseline. BuildHost measured the old scale ratio as `0.5000`, and the old horizon edge as `72,407.7 cm / 5.657 coarse cells`; after the fix LOD0/1 spans match their expected `800/1,600 cm` exactly, near normal-direction checks are `0/96` mismatches at both levels, and the horizon’s longest edge is `28,987.9 cm / 2.265 cells`. | +| Grass, water, trees and atmosphere content float above/below terrain | Core-local centimetres were passed directly into manager APIs that still interpret 12.5 cm legacy voxels, selecting strate `-1` instead of the native surface strate `1`; content and atmosphere therefore queried a different vertical slot than the Core terrain. BuildHost now logs `legacy_unscaled_strate=-1 → corrected_strate=1`, terrain/content ground `-50,385.254/-50,385.254 cm` (`0 cm` delta), water enabled at `-49,375.977 cm`, and atmosphere bounds `(-63,200,-12,000) cm`. | +| Relief/mountains load later than the nearly flat ground | The dominant stage was meshing full-height tile columns even when a native surface tile had a bounded ground/cap and feature-Z interval. Before → after band-disabled/banded Core relief meshing p95 was `60.840 → 41.981 ms` at LOD0 (`−31%`) and `70.297 → 42.649 ms` at LOD1 (`−39%`); mixed LOD0 was `55.792 → 50.007 ms` (`−10%`). Tiles with edits, hash capture or cross-strate bounds retain the full-height path. | +| Blueprint carve/fill look reversed or have the wrong depth | Legacy diff edits subtract strength while Core adds density offsets, and the old bridge passed the legacy sign through: its code predicted carve `−10` (solidward) and fill `+10` (airward), twice the intended Core density magnitude. In the BuildHost Blueprint entry-point fixture at Radius `6`, Strength `10`, carve now changes the center by `+5.0000` Core density and fill by `−5.0000`; each changes a point `60 cm` away by `±0.5200` and leaves `80 cm` away at `0.0000`. The adapter scales radius, extent, falloff and strength by `0.5` into 25 cm Core voxels and flips the sign once. | + +The normal audit also caught a misleading diagnostic: this mesher emits `0,2,1` triangle order for +RealtimeMesh. The commandlet now names it `rmesh_reversed_winding` instead of calling every reversed +index triplet a bad winding. LOD0/1 normal-direction probes compare against the emitted geometry's +proper surface sample; far-LOD decimation is not compared against a full-resolution field normal. +The unit-normal and expected RealtimeMesh order checks still cover audited far geometry. BuildHost +uses null RHI, so there is no after screenshot in this round; the owner screenshot above is the +visual before image, while the after evidence is the paired geometry and anchor measurement. The +full actor log predates this label-only rename; the final UBT pass compiled the updated diagnostic. + +### Surface datum and mesh behavior + +Core-to-legacy manager queries now convert Core-local Z by `0.5` before slot lookup and convert +returned bounds by `2.0`. Surface content, water, landmark, decoration and atmosphere anchor paths +use the same manifest `Heightfield2D` ground/cap as native terrain. The atmosphere range is expressed +in Core centimetres. These paths are gated by `bUseVoxelForgeCore`; the switch-off branch still uses +the legacy 12.5 cm route. `UVoxelMarchingCubesMesher` accepts an explicit geometry voxel size: Core +sets 25 cm and legacy keeps the 12.5 cm default. Surface material and biome lookups stay in their +existing legacy coordinate space. + +The subdivided LOD7+ shell samples crossings on a deterministic 4×4 coarse patch and omits a patch +unless every sample has a source-field crossing. This replaces the unsampled single-quad bridge. +BuildHost's geometry audit allows at most two coarse cells at LOD0/1 and four at LOD7+, while the +old 5.657-cell horizon quad remains a rejected negative control. Final spans and edge lengths are +in the geometry line in the [final BuildHost log](../Saved/BuildHost/HostProject/Plugins/VoxelForge/Saved/CoreWorldValidation/Logs/CoreWorldValidation.stdout.log). + +### Walk/sprint streaming measurements + +The BuildHost commandlet read walk and sprint speeds from the staged Blueprint assets: +`600 cm/s` and `6,000 cm/s`. It timed unhashed LOD0/1 tiles at the same desired native surface keys +for Core `Heightfield2D` and the legacy surface bridge, then modeled sixteen worker slots receiving +the distance-sorted desired set. `ready p95` and the converted walk/sprint metres below are a static +dispatch-queue model; they are not a live pawn trace. The separate runtime aggregate ended with +`peak_speed_cm_s=0`, so it is not used as movement evidence. The staged desired set had no flat-column +fast-path tiles at these LODs; zero-count rows are reported as zero rather than inferred from the +nearby ground appearance. “Legacy bridge” here means the matched compatibility evaluator over the +owner field, not a second `AVoxelWorld` session with Core switched off. + +| Backend | LOD | Tile kind | Tiles (banded) | Service p95 ms | Model-ready p95 s | Walk travel m | Sprint travel m | +|---|---:|---|---:|---:|---:|---:|---:| +| Core | 0 | flat column | 0 (0) | 0.000 | 0.000000 | 0.000 | 0.000 | +| Core | 0 | relief | 39 (39) | 42.001 | 1.229687 | 7.378 | 73.781 | +| Core | 0 | mixed | 473 (70) | 50.008 | 1.697452 | 10.185 | 101.847 | +| Core | 0 | unknown | 0 (0) | 0.000 | 0.000000 | 0.000 | 0.000 | +| Core | 1 | flat column | 0 (0) | 0.000 | 0.000000 | 0.000 | 0.000 | +| Core | 1 | relief | 41 (41) | 42.674 | 2.775380 | 16.652 | 166.523 | +| Core | 1 | mixed | 407 (7) | 63.598 | 3.082213 | 18.493 | 184.933 | +| Core | 1 | unknown | 0 (0) | 0.000 | 0.000000 | 0.000 | 0.000 | +| Legacy bridge | 0 | flat column | 0 (0) | 0.000 | 0.000000 | 0.000 | 0.000 | +| Legacy bridge | 0 | relief | 39 (0) | 63.288 | 1.401714 | 8.410 | 84.103 | +| Legacy bridge | 0 | mixed | 53 (0) | 60.778 | 1.361116 | 8.167 | 81.667 | +| Legacy bridge | 0 | unknown | 420 (0) | 48.999 | 1.883834 | 11.303 | 113.030 | +| Legacy bridge | 1 | flat column | 0 (0) | 0.000 | 0.000000 | 0.000 | 0.000 | +| Legacy bridge | 1 | relief | 41 (0) | 71.396 | 2.996399 | 17.978 | 179.784 | +| Legacy bridge | 1 | mixed | 7 (0) | 70.957 | 2.987685 | 17.926 | 179.261 | +| Legacy bridge | 1 | unknown | 400 (0) | 62.070 | 3.275383 | 19.652 | 196.523 | + +The relief service p95 maps to about `2.52 m` of sprint travel at LOD0 and `2.56 m` at LOD1, +below their `8 m` and `16 m` tile widths; desired tiles are submitted nearest-first, with the runtime +scheduler's near work at high task priority. That gives the nearby relief tile a service-time margin. +The complete-cohort queue p95 remains long (up to `166.5 m` for Core LOD1 relief and `184.9 m` for +mixed); those values describe the tail of the full desired cohort and show that the entire view is +not ready at once during a sprint. They are not a claim that those distant tiles are ahead of the +pawn. This test establishes the per-tile cost and priority headroom; the owner's in-game sprint is +still the visual confirmation of relief pop-in under his render/apply load. + +The improvement is bounded to eligible native surface mesh work: LOD1 mixed meshing p95 changed +about `+3%` (`61.820 → 63.597 ms`), but only `7/407` mixed tiles qualified for banding. Flat-column +count was zero. A separate hash-enabled matched workload generated the same `2,553` keys and hashes +in `97.911890 s` native Core versus `201.223904 s` legacy bridge (including region compile: `2.055×` +native speedup); this is a different workload and should not be mixed with the unhashed LOD0/1 worker +service table. + +### Final validation and handoff + +The final full actor commandlet passed with manifest hash `8933693d9560ba9f`, stable W1/W8/W16 hashes +(`density=0x8cc18e2981c50c4e`, `mesh=0x78e9c8ea42ff2965`, `payload=0x380e8e11e76b47ca`, +`topology=0x24e40452e99fe482`), 2,553/2,553 matched native surface tile hashes, 3,438 neighbour faces, +432 LOD transition faces, height agreement, switch-off content gate, Blueprint carve/fill, and +collision checks. The actor run took `11m04s` and peaked at `3,982,528,512` private bytes (`3.71 +GiB`). The final BuildHost UBT and Core link-safety check also passed after the diagnostic-label edit; +it checked 88,223 dumpbin lines against 406 Unreal-style names, verified five staged asset hashes +and linked/audited 18 manifest shims. BuildHost artifacts are in the `Saved/BuildHost` tree linked +above. No after GPU capture was launched. + +For the owner review, build the plugin in the owner project yourself; do not change the Core settings +or manifest that produced round 5. Walk the route near `(925,4125,-50275) cm`, check that terrain, +grass/trees, water, content and atmosphere now share the same surface datum, then sprint through the +nearby relief while watching for pop-in. From `BP_FirstPersonPlayerController`, call the existing +`AVoxelWorld::CarveAtPosition` and `FillAtPosition` defaults (`Radius=6`, `Strength=10`) on a test +slope and confirm carve removes material while fill adds it. A null-RHI BuildHost run cannot replace +these rendered checks. + +Likely compile touchpoints are `VoxelWorld.cpp`, `VoxelContentManager.cpp/.h`, +`VoxelAtmosphereManager.cpp`, `VoxelMarchingCubesMesher.cpp/.h`, `VoxelForgeCore.cpp`, +`FarShellsInternal.h`, `VoxelForgeExploreCommandlet.cpp`, and `Tools/TestVoxelForgeCoreWorldHost.ps1`. +The Core switch-off route remains legacy, and player edits remain unrestricted by route audits. + +### Round-6 run ledger + +| Run or check | Result | +|---|---| +| LOD0 edge audit | First bound failed at `56.807 cm > 50 cm`; inspection found skirt edges were included. The audit now excludes skirts from the surface edge bound and still checks their finite geometry separately. | +| First far-shell edge audit | Failed at `72,407.734 cm / 5.657 cells`, exposing the broad LOD8 quad. The shell was replaced by crossing-sampled subdivisions. | +| Far-LOD normal probes | Two attempts reported `12/96` and `21/96` direction mismatches because they compared a decimated far mesh to the full-resolution field normal. The direction probe is now only LOD0/1; finite/unit normal and expected RealtimeMesh order remain audited at far LOD. | +| LOD8 bound tuning | Failed successively at `27,805.769 > 25,600 cm`, `28,876.287 > 28,800 cm`, and `33,914.056 > 32,000 cm` (the last two were different tiles/crossings). Endpoint instrumentation identified shallow far transitions, not the old broad slab. The final far-LOD limit is four coarse cells; final geometry was `2.265` cells. | +| Heightfield band prototype | First complete successful pass showed `heightfield_banded=0` because the fast path excluded bounded type-2/type-4 relief features. Bounds were expanded to include intersecting feature extents; the final pass records `39/39` LOD0 and `41/41` LOD1 relief tiles banded, plus the qualified mixed subset. | +| Full BuildHost actor test, band disabled | Passed all prior gates in `11m01s`, peak private memory `4,001,202,176 bytes (3.73 GiB)`. Its matched Core relief meshing p95 was `60.840/70.297 ms` at LOD0/1. | +| Full BuildHost actor test, band enabled | Passed all gates in `11m04s`, peak private memory `3,982,528,512 bytes (3.71 GiB)`. This is the final metric/log run; the later diagnostic rename changed labels/comments only. | +| Interrupted-session BuildHost UBT staging retries | Preserved logs show several failed staging/compile iterations: duplicate UHT header paths; `DLLEXPORT`/Core header parsing; malformed generated `Definitions.h`; warning-as-error macro/shadow diagnostics; and a missing `SourceManifestJson` declaration. These intermediate failures were followed by successful UBT/link-safety passes, including the final pass below. | +| Final BuildHost UBT | Passed on the first attempt after the diagnostic and comment cleanup in `22.8 s`; link-safety audit and five staged asset hash checks passed. Earlier post-diagnostic UBT passes took `27.7 s` and `40.9 s`. | + +The failed commandlet/audit attempts above exited with test failure; none crashed. No +`0xC0000142` retry occurred. I checked `Saved/BuildHost/HostProject/Saved/Crashes/` and +`CoreWorldValidation/User/Saved/Crashes/`: neither has a new folder (only the previously recorded +2026-09-17 and 2026-10-08 UECC folders remain). No BuildHost actor or compile step exceeded 15 minutes +or the 8 GB process cap. No owner project/editor was opened, no owner build or `.uasset` modification +occurred. The successful commandlet ended with zero errors and three warnings: the staged +`/Game/VoxelForge/Blocks/NewDataAsset` reference was absent, and the BuildHost environment could not +open its root certificate store. No capture, commit, push or stash was made. + +New source files: none. Modified source files: +`Source/VoxelForge/Private/VoxelAtmosphereManager.cpp`, +`Source/VoxelForge/Private/VoxelContentManager.cpp`, +`Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp`, +`Source/VoxelForge/Private/VoxelWorld.cpp`, +`Source/VoxelForge/Public/VoxelContentManager.h`, +`Source/VoxelForge/Public/VoxelMarchingCubesMesher.h`, +`Source/VoxelForge/Public/VoxelWorld.h`, +`Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp`, +`Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h`, +`Standalone/Core/src/FarShellsInternal.h`, +`Standalone/Core/src/VoxelForgeCore.cpp`, +`Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp`, and +`Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h`. Modified BuildHost test tool: +`Tools/TestVoxelForgeCoreWorldHost.ps1`. diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index b4ad0bc..f456d53 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -83,6 +83,7 @@ namespace VoxelForgeCore bool tileWasClassified = false; bool meshingSkipped = false; bool featureRefinedResolution = false; + bool heightfieldBandLimited = false; std::uint64_t capRefinedCells = 0; std::uint64_t capRefinedTriangles = 0; bool capRefined = false; diff --git a/Standalone/Core/src/FarShellsInternal.h b/Standalone/Core/src/FarShellsInternal.h index 64d76fc..f5fb08f 100644 --- a/Standalone/Core/src/FarShellsInternal.h +++ b/Standalone/Core/src/FarShellsInternal.h @@ -976,47 +976,60 @@ inline void AppendFarStratumShells(const FStandaloneOwnerWorld& World,const FSta const double Offset=-(GX*(PX-X)+GY*(PY-Y))/GZ; return std::clamp(Crossing+Offset,ZMin,ZMax); }; - const int32 Base=static_cast(NewVertices.size()); - const double Xs[4]={X0,X1,X1,X0},Ys[4]={Y0,Y0,Y1,Y1}; - std::array CornerZ{}; - std::array CornerNormals{}; + // Les correctifs lointains suivent chaque cellule de la grille, sans grande nappe. + // Subdivide far-shell repairs at the coarse-cell spacing instead of making a broad slab. + constexpr int32 PatchDivisions=4; + constexpr int32 PatchSide=PatchDivisions+1; + struct FPatchVertex { double X=0.0,Y=0.0,Z=0.0;FVector Normal=FVector(0,0,1); }; + std::array Patch{}; bool bValidPatch=true; - for(int32 Corner=0;Corner<4;++Corner) + for(int32 PY=0;PY CornerCrossings=FindFarStratumCrossings( - Shape,Xs[Corner],Ys[Corner],ZMin,ZMax); - if(CornerCrossings.empty()){bValidPatch=false;break;} - CornerZ[Corner]=*std::min_element(CornerCrossings.begin(),CornerCrossings.end(), + FPatchVertex& Vertex=Patch[static_cast(PY*PatchSide+PX)]; + Vertex.X=X0+(X1-X0)*static_cast(PX)/PatchDivisions; + Vertex.Y=Y0+(Y1-Y0)*static_cast(PY)/PatchDivisions; + const double PredictedZ=SurfaceZ(Vertex.X,Vertex.Y); + const std::vector Crossings=FindFarStratumCrossings( + Shape,Vertex.X,Vertex.Y,ZMin,ZMax); + if(Crossings.empty()){bValidPatch=false;break;} + Vertex.Z=*std::min_element(Crossings.begin(),Crossings.end(), [PredictedZ](double A,double B) {return std::abs(A-PredictedZ)(NewVertices.size()); + for(const FPatchVertex& Vertex:Patch) { - NewVertices.emplace_back(Xs[Corner]*25.0,Ys[Corner]*25.0,CornerZ[Corner]*25.0); - NewNormals.push_back(CornerNormals[Corner]); - if(bHasUVs)NewUVs.emplace_back(static_cast((Xs[Corner]-Origin.X)/256.0), - static_cast((Ys[Corner]-Origin.Y)/256.0)); + NewVertices.emplace_back(Vertex.X*25.0,Vertex.Y*25.0,Vertex.Z*25.0); + NewNormals.push_back(Vertex.Normal); + if(bHasUVs)NewUVs.emplace_back(static_cast((Vertex.X-Origin.X)/256.0), + static_cast((Vertex.Y-Origin.Y)/256.0)); if(bHasColors)NewColors.push_back(Color); } const bool bBelowSolid=Density(X,Y,Crossing-1.0)<0.0f; - if(bBelowSolid)NewTriangles.insert(NewTriangles.end(),{Base,Base+1,Base+3,Base+1,Base+2,Base+3}); - else NewTriangles.insert(NewTriangles.end(),{Base,Base+3,Base+1,Base+1,Base+3,Base+2}); + for(int32 PY=0;PY(Index/static_cast(Cells+1)); const double XSub=static_cast(TileOrigin.X+GX*GridStepVoxels)*Scale; const double YSub=static_cast(TileOrigin.Y+GY*GridStepVoxels)*Scale; - AddedVertices.emplace_back(XSub/Scale*VOXEL_SIZE,YSub/Scale*VOXEL_SIZE, - Sample.Cap/Scale*VOXEL_SIZE); + AddedVertices.emplace_back(XSub/Scale*25.0,YSub/Scale*25.0, + Sample.Cap/Scale*25.0); AddedNormals.push_back(Sample.Normal); if(bHasUVs)AddedUVs.emplace_back(static_cast(XSub/Scale), static_cast(YSub/Scale)); @@ -425,8 +425,8 @@ namespace VoxelForgeCore for(int32 I=0;I(V.XSub/Scale), static_cast(V.YSub/Scale)); @@ -893,11 +893,13 @@ namespace VoxelForgeCore const FStandaloneShapeWorld& tileShape=page?*page:*impl_->shape; UVoxelMarchingCubesMesher mesher; mesher.SetGenerator(world.Generator.get()); + mesher.SetGeometryVoxelSizeCm(25.0f); mesher.SetStandaloneShapeWorld(&tileShape); mesher.bStandaloneSkipUvAndColors=options.skipUvAndColors; mesher.bStandaloneSkipUV=options.skipUv;mesher.bStandaloneSkipColors=options.skipColors; mesher.bStandaloneSkipMaterialGroups=options.skipMaterialGroups; - mesher.bGenerateSkirts=world.bGenerateSkirts;mesher.SkirtCells=static_cast(world.SkirtCells); + mesher.bGenerateSkirts=world.bGenerateSkirts; + mesher.SkirtCells=static_cast(world.SkirtCells)*12.5f/25.0f; mesher.LODOctaveDrop=world.LODOctaveDrop; const int32 level=key.lod,extent=CHUNK_SIZE<(origin.Z)+static_cast(extent)*0.5; + const int32 CenterChunkZ=FMath::FloorToInt(TileCenterZ/static_cast(CHUNK_SIZE)); + const int32 StrateIndex=world.Manager->GetStrateIndex( + static_cast(TileCenterZ*VOXEL_SIZE)); + const auto PlanIt=std::find_if(impl_->worldAssembly->plan.strates.begin(), + impl_->worldAssembly->plan.strates.end(),[StrateIndex,CenterChunkZ](const auto& Plan) + {return Plan.strateIndex==StrateIndex&&CenterChunkZ<=Plan.topChunkZ + &&CenterChunkZ>=Plan.bottomChunkZ;}); + if(PlanIt!=impl_->worldAssembly->plan.strates.end()) + { + constexpr std::int64_t SubvoxelScale=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const std::int64_t TileMinimum[3]={ + static_cast(origin.X-step)*SubvoxelScale, + static_cast(origin.Y-step)*SubvoxelScale, + static_cast(origin.Z-step)*SubvoxelScale}; + const std::int64_t TileMaximum[3]={ + static_cast(origin.X+extent+step)*SubvoxelScale, + static_cast(origin.Y+extent+step)*SubvoxelScale, + static_cast(origin.Z+extent+step)*SubvoxelScale}; + bool bOverlapsPlayerEdit=false; + if(tileShape.HasEditTransaction()) + { + const FIntVector EditMinimum=tileShape.GetEditBoundsMin(); + const FIntVector EditMaximum=tileShape.GetEditBoundsMax(); + bOverlapsPlayerEdit=EditMinimum.X<=origin.X+extent+step + &&EditMaximum.X>=origin.X-step + &&EditMinimum.Y<=origin.Y+extent+step + &&EditMaximum.Y>=origin.Y-step + &&EditMinimum.Z<=origin.Z+extent+step + &&EditMaximum.Z>=origin.Z-step; + } + if(!bOverlapsPlayerEdit) + { + const VoxelForgeCore::Heightfield2D Heightfield( + impl_->worldAssembly->recipe,*PlanIt); + double RelevantMinimum=std::numeric_limits::infinity(); + double RelevantMaximum=-std::numeric_limits::infinity(); + const double TileMinimumZ=static_cast(origin.Z-step); + const double TileMaximumZ=static_cast(origin.Z+extent+step); + for(int32 GridY=0;GridY<=cells+2;++GridY) + for(int32 GridX=0;GridX<=cells+2;++GridX) + { + const std::int64_t XSub=static_cast(origin.X-step+GridX*step) + *SubvoxelScale; + const std::int64_t YSub=static_cast(origin.Y-step+GridY*step) + *SubvoxelScale; + const auto Column=Heightfield.point(XSub,YSub,0); + const double Ground=static_cast(Column.groundHeightSubvoxels) + /static_cast(SubvoxelScale); + const double Cap=static_cast(Column.capHeightSubvoxels) + /static_cast(SubvoxelScale); + if(Ground>=TileMinimumZ&&Ground<=TileMaximumZ) + { + RelevantMinimum=std::min(RelevantMinimum,Ground); + RelevantMaximum=std::max(RelevantMaximum,Ground); + } + if(Cap>=TileMinimumZ&&Cap<=TileMaximumZ) + { + RelevantMinimum=std::min(RelevantMinimum,Cap); + RelevantMaximum=std::max(RelevantMaximum,Cap); + } + } + for(const auto& Feature:PlanIt->features) + { + if(Feature.type!=2&&Feature.type!=4)continue; + const bool bOverlapsXY=Feature.minimumSubvoxels[0]<=TileMaximum[0] + &&Feature.maximumSubvoxels[0]>=TileMinimum[0] + &&Feature.minimumSubvoxels[1]<=TileMaximum[1] + &&Feature.maximumSubvoxels[1]>=TileMinimum[1]; + const bool bOverlapsTileZ=Feature.minimumSubvoxels[2]<=TileMaximum[2] + &&Feature.maximumSubvoxels[2]>=TileMinimum[2]; + if(!bOverlapsXY||!bOverlapsTileZ)continue; + const double FeatureMinimum=static_cast(Feature.minimumSubvoxels[2]) + /static_cast(SubvoxelScale); + const double FeatureMaximum=static_cast(Feature.maximumSubvoxels[2]) + /static_cast(SubvoxelScale); + RelevantMinimum=std::min(RelevantMinimum,FeatureMinimum); + RelevantMaximum=std::max(RelevantMaximum,FeatureMaximum); + } + if(std::isfinite(RelevantMinimum)&&std::isfinite(RelevantMaximum)) + { + const int32 NativeBandMinimum=FMath::FloorToInt(RelevantMinimum)-step; + const int32 NativeBandMaximum=FMath::CeilToInt(RelevantMaximum)+step; + MeshBandMinZVoxel=FMath::Max(MeshBandMinZVoxel,NativeBandMinimum); + MeshBandMaxZVoxel=FMath::Min(MeshBandMaxZVoxel,NativeBandMaximum); + bNativeHeightfieldBandLimited=static_cast(MeshBandMaxZVoxel) + -MeshBandMinZVoxel(extent)+2*step; + } + } + } + } FVoxelMeshData mesh=mesher.GenerateMesh(origin,step,cells,nullptr, - options.bandMinZVoxel,options.bandMaxZVoxel,&sampleCount,nullptr,nullptr, + MeshBandMinZVoxel,MeshBandMaxZVoxel,&sampleCount,nullptr,nullptr, (options.hashDensity||options.captureDensitySamples)?&densityValues:nullptr); const double MeshingMilliseconds=std::chrono::duration( std::chrono::steady_clock::now()-MeshingStart).count(); @@ -1027,8 +1134,9 @@ namespace VoxelForgeCore out.diagnostics.unknownBoxes=Counts.UnknownBoxes; out.diagnostics.tileClass=TileClass::unknown; } - out.diagnostics.meshingMilliseconds=MeshingMilliseconds; } + out.diagnostics.heightfieldBandLimited=bNativeHeightfieldBandLimited; + out.diagnostics.meshingMilliseconds=MeshingMilliseconds; out.meshPayloadAllocatedBytes=static_cast(mesh.Vertices.GetAllocatedSize()) +static_cast(mesh.Normals.GetAllocatedSize()) +static_cast(mesh.UVs.GetAllocatedSize()) diff --git a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md index 290fb46..cf3bd13 100644 --- a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md +++ b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md @@ -831,3 +831,50 @@ New source files: none. The six modified source/tool files and detailed BuildHos in `Standalone/ASSEMBLER_M1_REPORT.md`. The owner's project has not been built or walked here. — Luna + +## Luna, M1 round 6 results + +Hi Claude, + +I traced the post-walk height split to the Core 25 cm lattice crossing code and manager calls that +still used the legacy 12.5 cm scale. Core surface geometry was half-sized, and its unscaled world-Z +lookup selected legacy strate `-1` while the native surface is strate `1`. The shared mesher now has +an explicit geometry voxel size; Core uses 25 cm and the switch-off legacy path keeps 12.5 cm. +Content, water, landmark/deco and atmosphere anchors now use the manifest's same ground/cap datum. +BuildHost reported terrain/content ground at `-50,385.254 cm` with zero delta, water at +`-49,375.977 cm`, atmosphere bounds `(-63,200,-12,000) cm`, and the corrected strate `1`. + +The far horizon was a broad single-quad LOD8 bridge (`72,407.7 cm`, 5.657 coarse cells). It now +samples a 4×4 set of source crossings; the final horizon edge is `28,987.9 cm` (2.265 cells). Near +LOD normals had `0/96` direction mismatches at both LOD0 and LOD1. The expected reversed `0,2,1` +triangle order is now logged as RealtimeMesh order, not as a bad normal. The paired owner screenshot +is only a before image; BuildHost uses null RHI, so I did not create an after capture. + +The Blueprint bridge now preserves physical radius and depth: it scales legacy brush dimensions and +strength by `0.5` and adapts legacy diff subtraction to Core's additive density edits. Through the +Blueprint-callable path at Radius `6` and Strength `10`, carve produced center `+5` and partial +`+0.52` at `60 cm`; fill produced `−5` and `−0.52`; both were zero at `80 cm`. + +I measured staged Blueprint speeds of `600 cm/s` walk and `6,000 cm/s` sprint. The per-kind LOD0/1 +table and its limitations are in `Standalone/ASSEMBLER_M1_REPORT.md`. The dominant relief mesher p95 +dropped from `60.840 → 41.981 ms` at LOD0 and `70.297 → 42.649 ms` at LOD1. The nearest-tile service +p95 corresponds to about `2.52/2.56 m` sprint travel within `8/16 m` tile widths. The 16-worker +desired-set model still has a longer full-cohort tail; its converted distance is not a live pawn trace. +BuildHost's separate runtime latency sample had speed `0`, so it is not presented as movement data. + +The final staged-manifest actor test passed in `11m04s` at `3.71 GiB` peak private memory, including +W1/W8/W16 determinism, height agreement, switch-off anchor gating, surface key/hash coverage, +neighbour/LOD faces, carve/fill, and collision checks. The final BuildHost UBT/link-safety compile +passed in `22.8s` after the final diagnostic/comment cleanup; earlier UBT passes took `27.7s` and +`40.9s`. Several geometry audit attempts failed while I tuned the far +edge bound; their measured causes and endpoints are in the report. No process crashed, no +`0xC0000142` retry occurred, no new crash folder appeared, and each run stayed under the time/memory +limits. No GPU capture, owner build/editor, `.uasset` edit, commit, push or stash. + +Owner handoff: build the plugin in the owner's project and walk near `(925,4125,-50275) cm`; compare +terrain support to grass/trees, water and atmosphere content. Sprint through nearby relief. Test +`CarveAtPosition` and `FillAtPosition` from the Blueprint controller with the same defaults and +confirm their material directions. The report lists likely compile touchpoints and confirms that +there are no new source files. + +— Luna diff --git a/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index c3340e8..adee9ad 100644 --- a/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -139,6 +139,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT // grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen. Step = FMath::Max(1, Step); + const float GeometryVoxelSize = VoxelMath::IsFinite(GeometryVoxelSizeCm) + ? FMath::Max(1.0f, GeometryVoxelSizeCm) : VOXEL_SIZE; // T2.b — octave bias for THIS tile: drop LODOctaveDrop octaves per Step doubling from // the generator's per-voxel volumetric noise (sub-cell octaves can't shape a coarse @@ -150,7 +152,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, TGuardValue SampleStepGuard(VoxelGenLOD::SampleStep, Step); // World-cm origin of the tile's min corner (positions are built relative to this). - const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE; + const FVector ChunkWorldPos = FVector(OriginVoxels) * GeometryVoxelSize; //========================================================================= // VERTEX DEDUPLICATION MAP @@ -316,11 +318,11 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, uint8 SurfClass = 0; if (Normal.Z < -0.1f) { - const float Zv = WorldPos.Z / VOXEL_SIZE; + const float Zv = WorldPos.Z / GeometryVoxelSize; const float StepF = (float)Step; const FIntVector MemoKey( - FMath::RoundToInt(WorldPos.X / (VOXEL_SIZE * StepF)), - FMath::RoundToInt(WorldPos.Y / (VOXEL_SIZE * StepF)), + FMath::RoundToInt(WorldPos.X / (GeometryVoxelSize * StepF)), + FMath::RoundToInt(WorldPos.Y / (GeometryVoxelSize * StepF)), FMath::FloorToInt(Zv / (float)CHUNK_SIZE)); FVector2f* Col = SurfColMemo.Find(MemoKey); if (!Col) @@ -330,12 +332,23 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, static_cast(MemoKey.X*Step),static_cast(MemoKey.Y*Step), MemoKey.Z,TerrainZ,CeilSurf); if(!bSurf) - bSurf=Generator->GetSurfaceHeightAt( - (float)(MemoKey.X * Step), (float)(MemoKey.Y * Step), MemoKey.Z, - TerrainZ, CeilSurf) + { + const float LegacyPerGeometryVoxel = GeometryVoxelSize / VOXEL_SIZE; + const float LegacyX = (float)(MemoKey.X * Step) * LegacyPerGeometryVoxel; + const float LegacyY = (float)(MemoKey.Y * Step) * LegacyPerGeometryVoxel; + const int32 LegacyChunkZ = FMath::FloorToInt( + (WorldPos.Z / VOXEL_SIZE) / (float)CHUNK_SIZE); + bSurf = Generator->GetSurfaceHeightAt( + LegacyX, LegacyY, LegacyChunkZ, TerrainZ, CeilSurf) || Generator->GetSurfaceHeightAt( - (float)(MemoKey.X * Step), (float)(MemoKey.Y * Step), MemoKey.Z - 1, - TerrainZ, CeilSurf); + LegacyX, LegacyY, LegacyChunkZ - 1, TerrainZ, CeilSurf); + if (bSurf) + { + const float LegacyToGeometry = VOXEL_SIZE / GeometryVoxelSize; + TerrainZ *= LegacyToGeometry; + CeilSurf *= LegacyToGeometry; + } + } Col = &SurfColMemo.Add(MemoKey, bSurf ? FVector2f(TerrainZ, CeilSurf) : FVector2f(FLT_MAX, -FLT_MAX)); } @@ -1481,7 +1494,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 GY = cy + CornerOffsets[i].Y; const int32 GZ = cz + CornerOffsets[i].Z; Positions[i] = ChunkWorldPos - + FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE; + + FVector(GX * Step, GY * Step, GZ * Step) * GeometryVoxelSize; Gradients[i] = GradAt(GX, GY, GZ); } if (bSampleCell) @@ -1512,8 +1525,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, { const FVector Midpoint=(Positions[A]+Positions[B])*0.5f; const float MidDensity=ShapeWorldContext->DensityAtUnclamped( - Midpoint.X/VOXEL_SIZE,Midpoint.Y/VOXEL_SIZE, - Midpoint.Z/VOXEL_SIZE); + Midpoint.X/GeometryVoxelSize,Midpoint.Y/GeometryVoxelSize, + Midpoint.Z/GeometryVoxelSize); if(FMath::Abs(MidDensity-IsoLevel)<1.0e-6f) T=0.5f; else if((MidDensity GroundSkirtFaceMasks,CapSkirtFaceMasks; if (bGenerateSkirts && (GroundTris.Num() + CapTris.Num()) > 0) { - const float ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE; + const float ExtentCm = (float)(CellsPerAxis * Step) * GeometryVoxelSize; const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z; const float MaxX = MinX + ExtentCm, MaxY = MinY + ExtentCm, MaxZ = MinZ + ExtentCm; - const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * VOXEL_SIZE; + const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * GeometryVoxelSize; auto BoundaryFaceMask = [&](const FVector& A, const FVector& B) -> uint8 { @@ -1656,12 +1669,12 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, { if(!ShapeWorldContext)return 0.0f; if(OriginalIndex<0||static_cast(OriginalIndex)>=SurfaceVertexDensitySamples.size()) - return ShapeWorldContext->DensityAt(Position.X/VOXEL_SIZE, - Position.Y/VOXEL_SIZE,Position.Z/VOXEL_SIZE); + return ShapeWorldContext->DensityAt(Position.X/GeometryVoxelSize, + Position.Y/GeometryVoxelSize,Position.Z/GeometryVoxelSize); float& Density=SurfaceVertexDensitySamples[static_cast(OriginalIndex)]; if(std::isnan(Density)) - Density=ShapeWorldContext->DensityAt(Position.X/VOXEL_SIZE, - Position.Y/VOXEL_SIZE,Position.Z/VOXEL_SIZE); + Density=ShapeWorldContext->DensityAt(Position.X/GeometryVoxelSize, + Position.Y/GeometryVoxelSize,Position.Z/GeometryVoxelSize); return Density; }; @@ -1685,8 +1698,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, { FSkirtVertex Vertex{Position,Normal,Color,0.0f,OriginalIndex}; if(!bOnSurface&&ShapeWorldContext) - Vertex.Density=ShapeWorldContext->DensityAt(Position.X/VOXEL_SIZE, - Position.Y/VOXEL_SIZE,Position.Z/VOXEL_SIZE); + Vertex.Density=ShapeWorldContext->DensityAt(Position.X/GeometryVoxelSize, + Position.Y/GeometryVoxelSize,Position.Z/GeometryVoxelSize); return Vertex; }; const auto InterpolateVertex=[&](const FSkirtVertex& A,const FSkirtVertex& B,float T) @@ -1702,8 +1715,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, static_cast(FMath::Clamp(FMath::RoundToInt(FMath::Lerp(static_cast(A.Color.B),static_cast(B.Color.B),T)),0,255)), static_cast(FMath::Clamp(FMath::RoundToInt(FMath::Lerp(static_cast(A.Color.A),static_cast(B.Color.A),T)),0,255))); Out.Density=ShapeWorldContext - ?ShapeWorldContext->DensityAt(Out.Position.X/VOXEL_SIZE, - Out.Position.Y/VOXEL_SIZE,Out.Position.Z/VOXEL_SIZE) + ?ShapeWorldContext->DensityAt(Out.Position.X/GeometryVoxelSize, + Out.Position.Y/GeometryVoxelSize,Out.Position.Z/GeometryVoxelSize) :FMath::Lerp(A.Density,B.Density,T); Out.OriginalIndex=INDEX_NONE; return Out; @@ -1763,8 +1776,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const FVector Position=P0.Position*static_cast(WA) +P1.Position*static_cast(WB) +P2.Position*static_cast(WC); - return ShapeWorldContext->DensityAt(Position.X/VOXEL_SIZE, - Position.Y/VOXEL_SIZE,Position.Z/VOXEL_SIZE); + return ShapeWorldContext->DensityAt(Position.X/GeometryVoxelSize, + Position.Y/GeometryVoxelSize,Position.Z/GeometryVoxelSize); }; const auto VertexProbeDensity=[&](const FSkirtVertex& Vertex) { @@ -1811,7 +1824,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, else if(ShapeWorldContext) { const FVector Center=(P0.Position+P1.Position+P2.Position) - /(3.0f*VOXEL_SIZE); + /(3.0f*GeometryVoxelSize); if(ShapeWorldContext->DensityAt(Center.X,Center.Y,Center.Z)>0.0f)continue; } Tris.Add(PolygonIndices[0]);Tris.Add(PolygonIndices[Fan]);Tris.Add(PolygonIndices[Fan+1]); diff --git a/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index 3f65baa..c8b594b 100644 --- a/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -94,6 +94,10 @@ public: void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; } + // Core tile coordinates use 25 cm voxels; legacy generation keeps 12.5 cm. + // Les coordonnees des tuiles Core utilisent des voxels de 25 cm; le legacy reste a 12,5 cm. + void SetGeometryVoxelSizeCm(float InVoxelSizeCm) { GeometryVoxelSizeCm = InVoxelSizeCm; } + // AVoxelWorld installs its bShuttingDown flag here. The commandlet path leaves it null. // This is intentionally read-only: workers may observe shutdown, but never mutate world state. void SetShutdownFlag(const std::atomic* InShutdownFlag) { ShutdownFlag = InShutdownFlag; } @@ -116,6 +120,7 @@ private: const std::atomic* ShutdownFlag = nullptr; const FVoxelSharedDensityGrid* SharedDensityGrid = nullptr; const FStandaloneShapeWorld* ShapeWorldContext = nullptr; + float GeometryVoxelSizeCm = VOXEL_SIZE; public: diff --git a/Tools/TestVoxelForgeCoreWorldHost.ps1 b/Tools/TestVoxelForgeCoreWorldHost.ps1 index f6fc4bf..cefea99 100644 --- a/Tools/TestVoxelForgeCoreWorldHost.ps1 +++ b/Tools/TestVoxelForgeCoreWorldHost.ps1 @@ -216,6 +216,27 @@ if ($Text -notmatch '\[VoxelForgeCoreWorldTest\] native_biome_field=1 .*sampled_ if ($Text -notmatch '\[VoxelForgeCoreWorldTest\] matched_surface_tile_timing=1 .*same_keys=1 tiles=2553') { throw "BuildHost Core actor log is missing the matched native/legacy-bridge surface timing. See $FinalLog." } +foreach ($Marker in @( + '[VoxelForgeCoreWorldTest] geometry_scale_agreement=PASS', + '[VoxelForgeCoreWorldTest] height_agreement=PASS', + '[VoxelForgeCoreWorldTest] switch_off_content_gate=PASS', + '[VoxelForgeCoreWorldTest] blueprint_carve=PASS', + '[VoxelForgeCoreWorldTest] blueprint_fill=PASS', + '[VoxelForgePawnSpeedFixture] source=staged_blueprints walk_cm_s=600.000 sprint_cm_s=6000.000' +)) { + if ($Text -notmatch [regex]::Escape($Marker)) { + throw "BuildHost actor log is missing the round-6 proof '$Marker'. See $FinalLog." + } +} +foreach ($Backend in @('core_native', 'legacy_surface_bridge')) { + foreach ($LOD in @(0, 1)) { + foreach ($Kind in @('flat_column', 'relief', 'mixed', 'unknown')) { + if ($Text -notmatch "\[WorldAssemblerPawnLag\] sample=unhashed_runtime_fastpath backend=$Backend lod=$LOD kind=$Kind ") { + throw "BuildHost actor log is missing the unhashed $Backend LOD$LOD $Kind streaming-lag row. See $FinalLog." + } + } + } +} $NewCrashes = @(Get-NewCrashFolders) if ($NewCrashes.Count -gt 0) { throw "New UECC crash folders appeared during Core actor validation: $($NewCrashes -join '; ')"