diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11e7580..b321bc7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -682,6 +682,17 @@ driven by `EditorBrush*` props. position → seamless across chunk borders (both sides use identical pure samples). NO per-vertex `GetDensityAt` (was ~6/vertex, often as costly as the whole grid). `ComputeGradientNormal` is now unused. Only NORMALS changed vs the old path; geometry is identical. +- **Core hybrid sharp mesher (M2 round 3)** (`CoreMesherRecipe` / `MesherRecipe`): the property is + opt-in and defaults to `MarchingCubesV1`; `bUseVoxelForgeCore` remains separately default-off. + Reuse the existing density grid and route only LOD0 interior cells touched by stable type-2 plan + boxes through feature-aware dual contouring. A cell touching a tile boundary and every LOD1+ cell + stay MC; existing skirts remain enabled. Do not alter density queries, worker task/epoch handling, + thread-local MC grid caches, or LOD0-only collision policy for this recipe. The canonical feature + list and fixed cell order remain deterministic. BuildHost measurements show tagged corner/doorway/ + riser/ledge tiles about 5.6–7.4× the production MC meshing time, while an organic cave fixture is + bit-identical and its measured time is unchanged within noise; keep the recipe opt-in and use tags + only where the feature warrants that tile cost. MC-to-MC LOD borders still have five standalone + doorway topology failures, so skirts are a visual repair, not a topology guarantee. - **Surface column cache (T1.a)** (density path: the surface stack's `FSurfaceColumnSource` memo; `ClassifyTile`: `FSurfaceColumnCache` = LRU of `FSurfaceColumnBox`): the heightfield + sky-cap + biome blend are a PURE function of (XY, seed, strate) — **ZERO Z dependence** (climate/Voronoi are pure-XY; surface params are per-strate constant diff --git a/CODEMAP.md b/CODEMAP.md index 3506c7e..c9965c7 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -894,6 +894,21 @@ edits keep the full mesh path. The BuildHost commandlet adds height-anchor agree sign/size, geometry and per-tile-kind pawn-lag checks; see round 6 in [`Standalone/ASSEMBLER_M1_REPORT.md`](Standalone/ASSEMBLER_M1_REPORT.md). +M2 round 3 adds `EVoxelForgeCoreMesherRecipe` / `CoreMesherRecipe` in +[`Source/VoxelForge/Public/VoxelWorld.h`](Source/VoxelForge/Public/VoxelWorld.h), defaulting to +`MarchingCubesV1`; `VoxelWorld.cpp` maps the opt-in `WorldAssemblerHybridV1` choice to the Core +`MesherRecipe` and adds that choice to the Core content identity only while selected. Core extracts +stable type-2 world-plan boxes as sharp features at LOD0; the production mesher routes tagged +interior cells through `VoxelHybridSharpMesher.h`, while border-touching cells and LOD1+ remain MC. +The same-LOD border path stays independent and skirted. `VoxelForgeExploreCommandlet.cpp` owns the +five production-mesher fixtures, border probes and Core actor collision contacts. BuildHost evidence +and remaining doorway transition failures are recorded in +[`Standalone/UNREAL_INTEGRATION_REPORT.md`](Standalone/UNREAL_INTEGRATION_REPORT.md) and +[`Standalone/Research/WorldAssemblerM2/REPORT.md`](Standalone/Research/WorldAssemblerM2/REPORT.md). +Likely compile touchpoints are `VoxelWorld.h/.cpp`, `VoxelMarchingCubesMesher.h/.cpp`, +`VoxelHybridSharpMesher.h`, `Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h`, +`Standalone/Core/src/VoxelForgeCore.cpp`, and the editor commandlet/build rules. + [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/VoxelHybridSharpMesher.h b/Source/VoxelForge/Private/VoxelHybridSharpMesher.h new file mode 100644 index 0000000..ef08e9a --- /dev/null +++ b/Source/VoxelForge/Private/VoxelHybridSharpMesher.h @@ -0,0 +1,618 @@ +#pragma once + +#include "MarchingCubesTables.h" +#include "VoxelMarchingCubesMesher.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Feature-aware dual-contouring support for the opt-in Core recipe. +// Support du dual contouring sensible aux fonctions pour la recette Core optionnelle. +namespace VoxelHybridSharpMesher +{ + constexpr int32 EdgeCount = 12; + constexpr int32 CornerCount = 8; + constexpr double SubvoxelsPerVoxel = 256.0; + + constexpr int32 CornerOffsets[CornerCount][3] = { + {0,0,0}, {1,0,0}, {1,1,0}, {0,1,0}, + {0,0,1}, {1,0,1}, {1,1,1}, {0,1,1} + }; + constexpr int32 EdgeCorners[EdgeCount][2] = { + {0,1}, {1,2}, {2,3}, {3,0}, {4,5}, {5,6}, + {6,7}, {7,4}, {0,4}, {1,5}, {2,6}, {3,7} + }; + + inline double AxisValue(const FVector& V, int32 Axis) + { + return Axis == 0 ? V.X : (Axis == 1 ? V.Y : V.Z); + } + + inline void SetAxisValue(FVector& V, int32 Axis, double Value) + { + if (Axis == 0) V.X = Value; + else if (Axis == 1) V.Y = Value; + else V.Z = Value; + } + + struct FPlane + { + uint64 Id = 0; + FVector Normal = FVector::UpVector; + }; + + struct FRoot + { + bool bActive = false; + FVector Position = FVector::ZeroVector; + FVector Gradient = FVector::UpVector; + std::vector Planes; + uint64 FeatureId = 0; + }; + + struct FTriangle + { + FVector Positions[3]{}; + FVector Normals[3]{}; + uint64 SplitKeys[3]{}; + }; + + struct FResult + { + std::vector CoreCellDCEdges; + std::vector Triangles; + uint32 SharpCells = 0; + uint32 DualCells = 0; + uint32 ActiveEdgeRoots = 0; + uint32 TaggedEdgeRoots = 0; + double MinimumFeatureRootAbsSdf = std::numeric_limits::infinity(); + uint32 FeatureRootSamples = 0; + uint32 NearFeatureRootSamples = 0; + uint32 BorderFallbackPatches = 0; + + bool UsesDC(int32 X, int32 Y, int32 Z, int32 Cells) const + { + if (X < 0 || Y < 0 || Z < 0 || X >= Cells || Y >= Cells || Z >= Cells) + return false; + const std::size_t Cell = (static_cast(Z) * Cells + Y) * Cells + X; + const std::size_t Offset = Cell * EdgeCount; + for (int32 Edge = 0; Edge < EdgeCount && Offset + Edge < CoreCellDCEdges.size(); ++Edge) + if (CoreCellDCEdges[Offset + Edge] != 0) return true; + return false; + } + + bool UsesDCEdge(int32 X, int32 Y, int32 Z, int32 Edge, int32 Cells) const + { + if (X < 0 || Y < 0 || Z < 0 || X >= Cells || Y >= Cells || Z >= Cells) + return false; + const std::size_t Cell = (static_cast(Z) * Cells + Y) * Cells + X; + const std::size_t Offset = Cell * EdgeCount + static_cast(Edge); + return Offset < CoreCellDCEdges.size() && (CoreCellDCEdges[Offset] & 1u) != 0; + } + }; + + struct FCell + { + uint8 Case = 0; + uint8 ComponentCount = 0; + std::array EdgeComponent{}; + std::array ComponentSharp{}; + std::array ComponentUseDC{}; + std::array Dual{}; + + FCell() { EdgeComponent.fill(0xff); } + }; + + inline int32 FindLocal(std::array& Parent, int32 Value) + { + while (Parent[Value] != Value) + { + Parent[Value] = Parent[Parent[Value]]; + Value = Parent[Value]; + } + return Value; + } + + inline void UniteLocal(std::array& Parent, int32 A, int32 B) + { + A = FindLocal(Parent, A); + B = FindLocal(Parent, B); + if (A == B) return; + if (A > B) std::swap(A, B); + Parent[B] = static_cast(A); + } + + inline int32 FindLocalEdge(int32 CellX, int32 CellY, int32 CellZ, + int32 Axis, int32 EdgeX, int32 EdgeY, int32 EdgeZ) + { + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + const int32 A = EdgeCorners[Edge][0]; + const int32 B = EdgeCorners[Edge][1]; + const int32 AX = CornerOffsets[A][0], AY = CornerOffsets[A][1], AZ = CornerOffsets[A][2]; + const int32 BX = CornerOffsets[B][0], BY = CornerOffsets[B][1], BZ = CornerOffsets[B][2]; + const int32 LocalAxis = AX != BX ? 0 : AY != BY ? 1 : 2; + if (LocalAxis == Axis + && CellX + FMath::Min(AX, BX) == EdgeX + && CellY + FMath::Min(AY, BY) == EdgeY + && CellZ + FMath::Min(AZ, BZ) == EdgeZ) + return Edge; + } + return INDEX_NONE; + } + + inline FVector SolveQEF(const std::vector>& Constraints, + const FVector& Fallback, const FVector& Low, const FVector& High) + { + if (Constraints.empty()) return Fallback; + double A[3][4]{}; + constexpr double Lambda = 1.0e-10; + const double Center[3] = {Fallback.X, Fallback.Y, Fallback.Z}; + for (int32 I = 0; I < 3; ++I) { A[I][I] = Lambda; A[I][3] = Lambda * Center[I]; } + for (const auto& Constraint : Constraints) + { + FVector N = Constraint.second.GetSafeNormal(); + const double B = FVector::DotProduct(N, Constraint.first); + const double NV[3] = {N.X, N.Y, N.Z}; + for (int32 R = 0; R < 3; ++R) + { + for (int32 C = 0; C < 3; ++C) A[R][C] += NV[R] * NV[C]; + A[R][3] += NV[R] * B; + } + } + for (int32 Column = 0; Column < 3; ++Column) + { + int32 Pivot = Column; + for (int32 Row = Column + 1; Row < 3; ++Row) + if (FMath::Abs(A[Row][Column]) > FMath::Abs(A[Pivot][Column])) Pivot = Row; + if (FMath::Abs(A[Pivot][Column]) < 1.0e-14) return Fallback; + if (Pivot != Column) + for (int32 K = Column; K < 4; ++K) std::swap(A[Pivot][K], A[Column][K]); + const double Divisor = A[Column][Column]; + for (int32 K = Column; K < 4; ++K) A[Column][K] /= Divisor; + for (int32 Row = 0; Row < 3; ++Row) + { + if (Row == Column) continue; + const double Factor = A[Row][Column]; + for (int32 K = Column; K < 4; ++K) A[Row][K] -= Factor * A[Column][K]; + } + } + return FVector(FMath::Clamp(A[0][3], Low.X, High.X), + FMath::Clamp(A[1][3], Low.Y, High.Y), + FMath::Clamp(A[2][3], Low.Z, High.Z)); + } + + inline double BoxSDF(const FVoxelHybridSharpFeatureBox& Box, const FVector& P, + std::vector& OutPlanes) + { + FVector Low, High; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + SetAxisValue(Low, Axis, + static_cast(Box.MinimumSubvoxels[Axis]) / SubvoxelsPerVoxel); + SetAxisValue(High, Axis, + static_cast(Box.MaximumSubvoxels[Axis]) / SubvoxelsPerVoxel); + } + const FVector Center = (Low + High) * 0.5; + const FVector Half = (High - Low) * 0.5; + const FVector Delta = P - Center; + const FVector Q(FMath::Abs(Delta.X) - Half.X, + FMath::Abs(Delta.Y) - Half.Y, + FMath::Abs(Delta.Z) - Half.Z); + const double MaxQ = FMath::Max(Q.X, FMath::Max(Q.Y, Q.Z)); + const FVector Outside(FMath::Max(Q.X, 0.0), FMath::Max(Q.Y, 0.0), FMath::Max(Q.Z, 0.0)); + const double OutsideLength = Outside.Size(); + const double SDF = OutsideLength + FMath::Min(MaxQ, 0.0); + const double Tolerance = 1.0e-4; + if (FMath::Abs(MaxQ) <= Tolerance) + { + for (int32 Axis = 0; Axis < 3; ++Axis) + { + if (FMath::Abs(AxisValue(Q, Axis) - MaxQ) > Tolerance) continue; + FVector Normal = FVector::ZeroVector; + const bool bNegativeFace = AxisValue(Delta, Axis) < 0.0; + SetAxisValue(Normal, Axis, bNegativeFace ? -1.0 : 1.0); + uint64 FaceId = Box.StableId ^ (0x9e3779b97f4a7c15ull + + static_cast(Axis * 2 + (bNegativeFace ? 0 : 1) + 1)); + if (FaceId == 0) FaceId = static_cast(Axis + 1); + OutPlanes.push_back({FaceId, Normal}); + } + } + return SDF; + } + + inline uint64 SelectFeature(const FRoot& Root, int32 Axis) + { + if (Root.Planes.empty()) return 0; + std::size_t Selected = 0; + const auto Alignment = [Axis](const FVector& Normal) + { return FMath::Abs(AxisValue(Normal, Axis)); }; + for (std::size_t I = 1; I < Root.Planes.size(); ++I) + { + const double Candidate = Alignment(Root.Planes[I].Normal); + const double Current = Alignment(Root.Planes[Selected].Normal); + if (Candidate > Current + 1.0e-12 + || (FMath::Abs(Candidate - Current) <= 1.0e-12 + && Root.Planes[I].Id < Root.Planes[Selected].Id)) Selected = I; + } + return Root.Planes[Selected].Id; + } + + inline FVector NormalForFeature(const FRoot& Root, uint64 Feature) + { + for (const FPlane& Plane : Root.Planes) if (Plane.Id == Feature) return Plane.Normal; + return Root.Gradient; + } + + inline FResult Generate(int32 Cells, int32 Step, float IsoLevel, float GeometryVoxelSize, + const FIntVector& OriginVoxels, const FVector& ChunkWorldPos, + const TArray& Features, + const std::function& Sample, + const std::function& Gradient) + { + FResult Result; + if (Cells <= 0 || Features.IsEmpty()) return Result; + const int32 CellDim = Cells + 2; + const int32 NodeDim = Cells + 3; + const std::size_t CellCount = static_cast(CellDim) * CellDim * CellDim; + std::vector CellData(CellCount); + const auto CellIndex = [CellDim](int32 X, int32 Y, int32 Z) + { return (static_cast(Z + 1) * CellDim + (Y + 1)) * CellDim + (X + 1); }; + const auto NodePosition = [&](int32 X, int32 Y, int32 Z) + { return FVector(X * Step * GeometryVoxelSize, Y * Step * GeometryVoxelSize, + Z * Step * GeometryVoxelSize) + ChunkWorldPos; }; + + std::array, 3> Roots; + Roots[0].resize(static_cast(CellDim) * NodeDim * NodeDim); + Roots[1].resize(static_cast(NodeDim) * CellDim * NodeDim); + Roots[2].resize(static_cast(NodeDim) * NodeDim * CellDim); + const auto RootIndex = [CellDim, NodeDim](int32 Axis, int32 X, int32 Y, int32 Z) + { + const std::size_t NX = static_cast(X + 1); + const std::size_t NY = static_cast(Y + 1); + const std::size_t NZ = static_cast(Z + 1); + if (Axis == 0) return (NZ * NodeDim + NY) * CellDim + NX; + if (Axis == 1) return (NZ * CellDim + NY) * NodeDim + NX; + return (NZ * NodeDim + NY) * NodeDim + NX; + }; + + for (int32 Z = -1; Z <= Cells + 1; ++Z) + for (int32 Y = -1; Y <= Cells + 1; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + const float D0 = Sample(X, Y, Z), D1 = Sample(X + 1, Y, Z); + if ((D0 < IsoLevel) == (D1 < IsoLevel)) continue; + const double T = FMath::Clamp(static_cast(IsoLevel - D0) / (D1 - D0), 0.0, 1.0); + FRoot& Root = Roots[0][RootIndex(0, X, Y, Z)]; + Root.bActive = true; + ++Result.ActiveEdgeRoots; + Root.Position = FMath::Lerp(NodePosition(X, Y, Z), NodePosition(X + 1, Y, Z), T); + Root.Gradient = FMath::Lerp(Gradient(X, Y, Z), Gradient(X + 1, Y, Z), T).GetSafeNormal(); + } + for (int32 Z = -1; Z <= Cells + 1; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells + 1; ++X) + { + const float D0 = Sample(X, Y, Z), D1 = Sample(X, Y + 1, Z); + if ((D0 < IsoLevel) == (D1 < IsoLevel)) continue; + const double T = FMath::Clamp(static_cast(IsoLevel - D0) / (D1 - D0), 0.0, 1.0); + FRoot& Root = Roots[1][RootIndex(1, X, Y, Z)]; + Root.bActive = true; + ++Result.ActiveEdgeRoots; + Root.Position = FMath::Lerp(NodePosition(X, Y, Z), NodePosition(X, Y + 1, Z), T); + Root.Gradient = FMath::Lerp(Gradient(X, Y, Z), Gradient(X, Y + 1, Z), T).GetSafeNormal(); + } + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells + 1; ++Y) + for (int32 X = -1; X <= Cells + 1; ++X) + { + const float D0 = Sample(X, Y, Z), D1 = Sample(X, Y, Z + 1); + if ((D0 < IsoLevel) == (D1 < IsoLevel)) continue; + const double T = FMath::Clamp(static_cast(IsoLevel - D0) / (D1 - D0), 0.0, 1.0); + FRoot& Root = Roots[2][RootIndex(2, X, Y, Z)]; + Root.bActive = true; + ++Result.ActiveEdgeRoots; + Root.Position = FMath::Lerp(NodePosition(X, Y, Z), NodePosition(X, Y, Z + 1), T); + Root.Gradient = FMath::Lerp(Gradient(X, Y, Z), Gradient(X, Y, Z + 1), T).GetSafeNormal(); + } + + const auto GetRoot = [&](int32 Axis, int32 X, int32 Y, int32 Z) -> FRoot& + { return Roots[Axis][RootIndex(Axis, X, Y, Z)]; }; + const auto GetCellEdgeRoot = [&](int32 X, int32 Y, int32 Z, int32 Edge) -> FRoot& + { + const int32 A = EdgeCorners[Edge][0], B = EdgeCorners[Edge][1]; + const int32 AX = CornerOffsets[A][0], AY = CornerOffsets[A][1], AZ = CornerOffsets[A][2]; + const int32 BX = CornerOffsets[B][0], BY = CornerOffsets[B][1], BZ = CornerOffsets[B][2]; + const int32 Axis = AX != BX ? 0 : AY != BY ? 1 : 2; + return GetRoot(Axis, X + FMath::Min(AX, BX), Y + FMath::Min(AY, BY), Z + FMath::Min(AZ, BZ)); + }; + + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + FCell& Cell = CellData[CellIndex(X, Y, Z)]; + float Densities[CornerCount]{}; + for (int32 Corner = 0; Corner < CornerCount; ++Corner) + { + const int32 GX = X + CornerOffsets[Corner][0]; + const int32 GY = Y + CornerOffsets[Corner][1]; + const int32 GZ = Z + CornerOffsets[Corner][2]; + Densities[Corner] = Sample(GX, GY, GZ); + if (Densities[Corner] >= IsoLevel) Cell.Case |= static_cast(1u << Corner); + } + if (Cell.Case == 0 || Cell.Case == 255 || MCEdgeTable[Cell.Case] == 0) continue; + std::array Parent{}; + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) Parent[Edge] = static_cast(Edge); + for (int32 I = 0; I < 16 && MCTriTable[Cell.Case][I] >= 0; I += 3) + { + const int32 A = MCTriTable[Cell.Case][I]; + const int32 B = MCTriTable[Cell.Case][I + 1]; + const int32 C = MCTriTable[Cell.Case][I + 2]; + UniteLocal(Parent, A, B); UniteLocal(Parent, B, C); + } + std::array RootToComponent{}; + RootToComponent.fill(0xff); + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + const int32 A = EdgeCorners[Edge][0], B = EdgeCorners[Edge][1]; + if ((Densities[A] < IsoLevel) == (Densities[B] < IsoLevel)) continue; + FRoot& Root = GetCellEdgeRoot(X, Y, Z, Edge); + if (!Root.bActive) continue; + const int32 LocalRoot = FindLocal(Parent, Edge); + if (RootToComponent[LocalRoot] == 0xff) + RootToComponent[LocalRoot] = Cell.ComponentCount++; + const uint8 Component = RootToComponent[LocalRoot]; + Cell.EdgeComponent[Edge] = Component; + + const int32 OA = EdgeCorners[Edge][0]; + const int32 OB = EdgeCorners[Edge][1]; + const int32 AX = X + CornerOffsets[OA][0], AY = Y + CornerOffsets[OA][1], AZ = Z + CornerOffsets[OA][2]; + const int32 BX = X + CornerOffsets[OB][0], BY = Y + CornerOffsets[OB][1], BZ = Z + CornerOffsets[OB][2]; + const FVector RootVoxel = FVector(OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z) + + FVector(AX, AY, AZ) * Step + + FVector(BX - AX, BY - AY, BZ - AZ) * (Step * + FMath::Clamp(static_cast(IsoLevel - Densities[OA]) + / (Densities[OB] - Densities[OA]), 0.0, 1.0)); + for (const FVoxelHybridSharpFeatureBox& Feature : Features) + { + std::vector CandidatePlanes; + const double SDF = BoxSDF(Feature, RootVoxel, CandidatePlanes); + const double AbsSDF = FMath::Abs(SDF); + Result.MinimumFeatureRootAbsSdf = FMath::Min( + Result.MinimumFeatureRootAbsSdf, AbsSDF); + ++Result.FeatureRootSamples; + if (AbsSDF <= 1.0e-2) ++Result.NearFeatureRootSamples; + if (AbsSDF > 1.0e-4 || CandidatePlanes.empty()) continue; + for (const FPlane& Plane : CandidatePlanes) Root.Planes.push_back(Plane); + } + if (!Root.Planes.empty()) + { + ++Result.TaggedEdgeRoots; + std::sort(Root.Planes.begin(), Root.Planes.end(), + [](const FPlane& A, const FPlane& B) { return A.Id < B.Id; }); + Root.Planes.erase(std::unique(Root.Planes.begin(), Root.Planes.end(), + [](const FPlane& A, const FPlane& B) { return A.Id == B.Id; }), Root.Planes.end()); + Cell.ComponentSharp[Component] = 1; + if (X >= 0 && Y >= 0 && Z >= 0 && X < Cells && Y < Cells && Z < Cells) + ++Result.SharpCells; + } + } + } + + const std::size_t PatchCount = CellCount * EdgeCount; + std::vector PatchParent(PatchCount); + std::vector PatchRank(PatchCount, 0), PatchSharp(PatchCount, 0), PatchTouchesBorder(PatchCount, 0); + std::iota(PatchParent.begin(), PatchParent.end(), 0u); + const auto FindPatch = [&](uint32 Value, auto&& Self) -> uint32 + { + if (PatchParent[Value] != Value) PatchParent[Value] = Self(PatchParent[Value], Self); + return PatchParent[Value]; + }; + const auto UnitePatch = [&](uint32 A, uint32 B) + { + uint32 RA = FindPatch(A, FindPatch), RB = FindPatch(B, FindPatch); + if (RA == RB) return; + if (PatchRank[RA] < PatchRank[RB]) std::swap(RA, RB); + PatchParent[RB] = RA; + PatchSharp[RA] = static_cast(PatchSharp[RA] | PatchSharp[RB]); + PatchTouchesBorder[RA] = static_cast(PatchTouchesBorder[RA] | PatchTouchesBorder[RB]); + if (PatchRank[RA] == PatchRank[RB]) ++PatchRank[RA]; + }; + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + const FCell& Cell = CellData[CellIndex(X, Y, Z)]; + for (uint8 Component = 0; Component < Cell.ComponentCount; ++Component) + { + const uint32 Id = static_cast(CellIndex(X, Y, Z) * EdgeCount + Component); + PatchSharp[Id] = Cell.ComponentSharp[Component]; + const bool bCoreBorder = X >= 0 && Y >= 0 && Z >= 0 && X < Cells && Y < Cells && Z < Cells + && (X == 0 || Y == 0 || Z == 0 || X == Cells - 1 || Y == Cells - 1 || Z == Cells - 1); + PatchTouchesBorder[Id] = bCoreBorder ? 1 : 0; + } + } + + for (int32 Axis = 0; Axis < 3; ++Axis) + for (int32 Z = -1; Z <= Cells + (Axis == 2 ? 0 : 1); ++Z) + for (int32 Y = -1; Y <= Cells + (Axis == 1 ? 0 : 1); ++Y) + for (int32 X = -1; X <= Cells + (Axis == 0 ? 0 : 1); ++X) + { + if (!GetRoot(Axis, X, Y, Z).bActive) continue; + std::array, 4> Around{}; + if (Axis == 0) Around = {{{X,Y-1,Z-1},{X,Y,Z-1},{X,Y,Z},{X,Y-1,Z}}}; + else if (Axis == 1) Around = {{{X-1,Y,Z-1},{X-1,Y,Z},{X,Y,Z},{X,Y,Z-1}}}; + else Around = {{{X-1,Y-1,Z},{X,Y-1,Z},{X,Y,Z},{X-1,Y,Z}}}; + std::array Ids{}; + bool bValid = true; + for (int32 I = 0; I < 4; ++I) + { + const int32 CX = Around[I][0], CY = Around[I][1], CZ = Around[I][2]; + if (CX < -1 || CY < -1 || CZ < -1 || CX > Cells || CY > Cells || CZ > Cells) + { bValid = false; break; } + const int32 LocalEdge = FindLocalEdge(CX, CY, CZ, Axis, X, Y, Z); + const FCell& Cell = CellData[CellIndex(CX, CY, CZ)]; + if (LocalEdge == INDEX_NONE || Cell.EdgeComponent[LocalEdge] == 0xff) + { bValid = false; break; } + Ids[I] = static_cast(CellIndex(CX, CY, CZ) * EdgeCount + Cell.EdgeComponent[LocalEdge]); + } + if (bValid) for (int32 I = 1; I < 4; ++I) UnitePatch(Ids[0], Ids[I]); + } + + bool bSawBorderPatch = false; + std::vector PatchUseDC(PatchCount, 0); + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + const FCell& Cell = CellData[CellIndex(X, Y, Z)]; + for (uint8 Component = 0; Component < Cell.ComponentCount; ++Component) + { + const uint32 Id = static_cast(CellIndex(X, Y, Z) * EdgeCount + Component); + const uint32 Root = FindPatch(Id, FindPatch); + PatchUseDC[Root] = static_cast(PatchSharp[Root] && !PatchTouchesBorder[Root]); + if (PatchSharp[Root] && PatchTouchesBorder[Root]) bSawBorderPatch = true; + } + } + Result.BorderFallbackPatches = bSawBorderPatch ? 1u : 0u; + + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + FCell& Cell = CellData[CellIndex(X, Y, Z)]; + if (Cell.ComponentCount == 0) continue; + for (uint8 Component = 0; Component < Cell.ComponentCount; ++Component) + { + const uint32 Id = static_cast(CellIndex(X, Y, Z) * EdgeCount + Component); + Cell.ComponentUseDC[Component] = PatchUseDC[FindPatch(Id, FindPatch)]; + if (!Cell.ComponentUseDC[Component]) continue; + std::vector> Constraints; + FVector RootSum = FVector::ZeroVector; + int32 RootCount = 0; + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + if (Cell.EdgeComponent[Edge] != Component) continue; + FRoot& Root = GetCellEdgeRoot(X, Y, Z, Edge); + if (!Root.bActive) continue; + RootSum += Root.Position; + ++RootCount; + if (!Root.Planes.empty()) + for (const FPlane& Plane : Root.Planes) Constraints.emplace_back(Root.Position, Plane.Normal); + else Constraints.emplace_back(Root.Position, Root.Gradient); + } + const FVector Low = NodePosition(X, Y, Z); + const FVector High = NodePosition(X + 1, Y + 1, Z + 1); + const FVector Center = RootCount > 0 ? RootSum / RootCount : (Low + High) * 0.5; + Cell.Dual[Component] = SolveQEF(Constraints, Center, Low, High); + if (X >= 0 && Y >= 0 && Z >= 0 && X < Cells && Y < Cells && Z < Cells) + ++Result.DualCells; + } + } + + Result.CoreCellDCEdges.assign(static_cast(Cells) * Cells * Cells * EdgeCount, 0); + const auto AddTriangle = [&](const FVector& A, const FVector& B, const FVector& C, + const FVector& NA, const FVector& NB, const FVector& NC, + uint64 FA, uint64 FB, uint64 FC) + { + if (FVector::CrossProduct(B - A, C - A).SizeSquared() < 1.0e-12) return; + FTriangle T; + T.Positions[0] = A; T.Positions[1] = B; T.Positions[2] = C; + T.Normals[0] = NA.GetSafeNormal(); T.Normals[1] = NB.GetSafeNormal(); T.Normals[2] = NC.GetSafeNormal(); + T.SplitKeys[0] = FA; T.SplitKeys[1] = FB; T.SplitKeys[2] = FC; + Result.Triangles.push_back(T); + }; + + for (int32 Axis = 0; Axis < 3; ++Axis) + for (int32 Z = -1; Z <= Cells + (Axis == 2 ? 0 : 1); ++Z) + for (int32 Y = -1; Y <= Cells + (Axis == 1 ? 0 : 1); ++Y) + for (int32 X = -1; X <= Cells + (Axis == 0 ? 0 : 1); ++X) + { + FRoot& Root = GetRoot(Axis, X, Y, Z); + if (!Root.bActive) continue; + std::array, 4> Around{}; + if (Axis == 0) Around = {{{X,Y-1,Z-1},{X,Y,Z-1},{X,Y,Z},{X,Y-1,Z}}}; + else if (Axis == 1) Around = {{{X-1,Y,Z-1},{X-1,Y,Z},{X,Y,Z},{X,Y,Z-1}}}; + else Around = {{{X-1,Y-1,Z},{X,Y-1,Z},{X,Y,Z},{X-1,Y,Z}}}; + std::array Incident{}; + std::array Components{}; + bool bAllDC = true; + bool bValid = true; + for (int32 I = 0; I < 4; ++I) + { + const int32 CX = Around[I][0], CY = Around[I][1], CZ = Around[I][2]; + if (CX < -1 || CY < -1 || CZ < -1 || CX > Cells || CY > Cells || CZ > Cells) + { bValid = false; break; } + const int32 LocalEdge = FindLocalEdge(CX, CY, CZ, Axis, X, Y, Z); + const FCell& Cell = CellData[CellIndex(CX, CY, CZ)]; + if (LocalEdge == INDEX_NONE || Cell.EdgeComponent[LocalEdge] == 0xff) + { bValid = false; break; } + const uint8 Component = Cell.EdgeComponent[LocalEdge]; + Incident[I] = &Cell; + Components[I] = Component; + bAllDC &= Cell.ComponentUseDC[Component] != 0; + } + if (!bValid || !bAllDC) continue; + const int32 OwnerX = FMath::Min(FMath::Min(Around[0][0], Around[1][0]), FMath::Min(Around[2][0], Around[3][0])); + const int32 OwnerY = FMath::Min(FMath::Min(Around[0][1], Around[1][1]), FMath::Min(Around[2][1], Around[3][1])); + const int32 OwnerZ = FMath::Min(FMath::Min(Around[0][2], Around[1][2]), FMath::Min(Around[2][2], Around[3][2])); + if (OwnerX < 0 || OwnerY < 0 || OwnerZ < 0 || OwnerX >= Cells || OwnerY >= Cells || OwnerZ >= Cells) + continue; + + std::array Positions{}; + std::array Normals{}; + std::array SplitKeys{}; + for (int32 I = 0; I < 4; ++I) + { + const int32 CX = Around[I][0], CY = Around[I][1], CZ = Around[I][2]; + const uint8 Component = Components[I]; + Positions[I] = Incident[I]->Dual[Component]; + SplitKeys[I] = SelectFeature(Root, Axis); + Normals[I] = SplitKeys[I] != 0 ? NormalForFeature(Root, SplitKeys[I]) : Root.Gradient; + } + if (FVector::DotProduct(FVector::CrossProduct(Positions[1] - Positions[0], + Positions[2] - Positions[0]), Root.Gradient) < 0.0) + { + std::swap(Positions[1], Positions[3]); + std::swap(Normals[1], Normals[3]); + std::swap(SplitKeys[1], SplitKeys[3]); + } + const int32 Tie = (X + Y + Z + Axis) & 1; + if (Tie == 0) + { + AddTriangle(Positions[0], Positions[1], Positions[2], Normals[0], Normals[1], Normals[2], SplitKeys[0], SplitKeys[1], SplitKeys[2]); + AddTriangle(Positions[0], Positions[2], Positions[3], Normals[0], Normals[2], Normals[3], SplitKeys[0], SplitKeys[2], SplitKeys[3]); + } + else + { + AddTriangle(Positions[0], Positions[1], Positions[3], Normals[0], Normals[1], Normals[3], SplitKeys[0], SplitKeys[1], SplitKeys[3]); + AddTriangle(Positions[1], Positions[2], Positions[3], Normals[1], Normals[2], Normals[3], SplitKeys[1], SplitKeys[2], SplitKeys[3]); + } + } + + for (int32 Z = 0; Z < Cells; ++Z) + for (int32 Y = 0; Y < Cells; ++Y) + for (int32 X = 0; X < Cells; ++X) + { + const FCell& Cell = CellData[CellIndex(X, Y, Z)]; + if (!Cell.ComponentCount) continue; + const std::size_t CoreCell = (static_cast(Z) * Cells + Y) * Cells + X; + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + const uint8 Component = Cell.EdgeComponent[Edge]; + if (Component != 0xff && Cell.ComponentUseDC[Component]) + Result.CoreCellDCEdges[CoreCell * EdgeCount + Edge] = 1; + } + } + return Result; + } +} diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 3d76831..35b5296 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -4,6 +4,7 @@ #include "VoxelMarchingCubesMesher.h" #include "MarchingCubesTables.h" #include "VoxelDensityProfile.h" +#include "VoxelHybridSharpMesher.h" #if WITH_EDITOR #include "VoxelRedefinedDensityField.h" #endif @@ -144,6 +145,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // plus de (ré)allocation de hash-map par tuile (chaque worker a son propre exemplaire). static thread_local TMap VertexMap; VertexMap.Reset(); + static thread_local TMap> SplitNormalVertexMap; + SplitNormalVertexMap.Reset(); //========================================================================= // F17 — CLASSE DE SURFACE (sol vs plafond sky-cap), par vertex → par triangle @@ -164,7 +167,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b) — // plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la // normalise ici (fallback up si dégénérée). - auto GetOrCreateVertex = [&](const FVector& WorldPos, const FVector& RawNormal) -> int32 + auto GetOrCreateVertex = [&](const FVector& WorldPos, const FVector& RawNormal, + uint64 NormalSplitKey = 0) -> int32 { const FIntVector Key( FMath::RoundToInt(WorldPos.X * 100.0f), @@ -172,13 +176,27 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, FMath::RoundToInt(WorldPos.Z * 100.0f) ); - if (int32* Existing = VertexMap.Find(Key)) + if (NormalSplitKey != 0) + { + if (TMap* SplitMap = SplitNormalVertexMap.Find(Key)) + { + if (int32* Existing = SplitMap->Find(NormalSplitKey)) return *Existing; + } + } + else if (int32* Existing = VertexMap.Find(Key)) { return *Existing; } const int32 NewIndex = MeshData.Vertices.Num(); - VertexMap.Add(Key, NewIndex); + if (NormalSplitKey != 0) + { + SplitNormalVertexMap.FindOrAdd(Key).Add(NormalSplitKey, NewIndex); + } + else + { + VertexMap.Add(Key, NewIndex); + } MeshData.Vertices.Add(WorldPos); @@ -1050,6 +1068,31 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, SampleG(gx, gy, gz + 1) - SampleG(gx, gy, gz - 1)); }; + // DC is limited to tagged LOD0 patches. Coarse tiles and a connected patch that reaches a + // tile edge use MC there, preserving the existing MC-to-MC clipmap transition and skirts. + // Le DC reste limite aux surfaces LOD0 balisees; les patches connectes au bord restent MC. + VoxelHybridSharpMesher::FResult HybridResult; + MeshData.HybridFeatureCount = HybridSharpFeatures.Num(); + if (bUseHybridSharpMesher && Step == 1 && CellsPerAxis == CHUNK_SIZE + && CzLo == 0 && CzHi == CellsPerAxis - 1 && !HybridSharpFeatures.IsEmpty()) + { + MeshData.bHybridMesherInvoked = true; + HybridResult = VoxelHybridSharpMesher::Generate( + CellsPerAxis, Step, IsoLevel, GeometryVoxelSize, OriginVoxels, ChunkWorldPos, + HybridSharpFeatures, + [&](int32 X, int32 Y, int32 Z) { return SampleG(X, Y, Z); }, + [&](int32 X, int32 Y, int32 Z) { return GradAt(X, Y, Z); }); + } + MeshData.HybridSharpCellCount = HybridResult.SharpCells; + MeshData.HybridDualCellCount = HybridResult.DualCells; + MeshData.HybridActiveEdgeRootCount = HybridResult.ActiveEdgeRoots; + MeshData.HybridTaggedEdgeRootCount = HybridResult.TaggedEdgeRoots; + MeshData.HybridMinimumFeatureRootAbsSdf = HybridResult.MinimumFeatureRootAbsSdf; + MeshData.HybridFeatureRootSampleCount = HybridResult.FeatureRootSamples; + MeshData.HybridNearFeatureRootSampleCount = HybridResult.NearFeatureRootSamples; + MeshData.HybridBorderFallbackPatchCount = HybridResult.BorderFallbackPatches; + MeshData.HybridDCTriangleCount = static_cast(HybridResult.Triangles.size()); + //========================================================================= // ITÉRATION SUR LES CELLULES //========================================================================= @@ -1169,6 +1212,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 E0 = MCTriTable[CaseIndex][i]; const int32 E1 = MCTriTable[CaseIndex][i + 1]; const int32 E2 = MCTriTable[CaseIndex][i + 2]; + if (HybridResult.UsesDCEdge(cx, cy, cz, E0, CellsPerAxis)) + { + continue; + } const int32 Idx0 = GetOrCreateVertex(EdgeVertices[E0], EdgeNormals[E0]); const int32 Idx1 = GetOrCreateVertex(EdgeVertices[E1], EdgeNormals[E1]); const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]); @@ -1192,6 +1239,24 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } + // Add DC geometry to the same material runs as MC. Analytic feature IDs split corner normals. + // Ajouter la geometrie DC dans les memes groupes que MC; les IDs separent les normales nettes. + for (const VoxelHybridSharpMesher::FTriangle& Triangle : HybridResult.Triangles) + { + const int32 Idx0 = GetOrCreateVertex( + Triangle.Positions[0], Triangle.Normals[0], Triangle.SplitKeys[0]); + const int32 Idx1 = GetOrCreateVertex( + Triangle.Positions[1], Triangle.Normals[1], Triangle.SplitKeys[1]); + const int32 Idx2 = GetOrCreateVertex( + Triangle.Positions[2], Triangle.Normals[2], Triangle.SplitKeys[2]); + const int32 CapVotes = static_cast(VertexClasses[Idx0]) + + static_cast(VertexClasses[Idx1]) + static_cast(VertexClasses[Idx2]); + TArray& Dst = (CapVotes >= 2) ? CapTris : GroundTris; + Dst.Add(Idx0); + Dst.Add(Idx2); + Dst.Add(Idx1); + } + if (bProfileMesher) { VoxelDensityProfile::AddMeasurement( diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index d75622e..70776cf 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -8725,6 +8725,7 @@ void AVoxelWorld::HandleTileCollisionCookComplete( const int32 NumVertices = Pending->NumVertices; const int32 NumTriangles = Pending->NumTriangles; const int32 NumSkirtTriangles = Pending->NumSkirtTriangles; + const bool bPooledComponent = Pending->bPooledComponent; const int32 PendingCooksAtSubmit = Pending->PendingCooksAtSubmit; const bool bUseAsyncCook = Pending->bUseAsyncCook; const bool bUseComplexAsSimpleCollision = Pending->bUseComplexAsSimpleCollision; @@ -8745,6 +8746,15 @@ void AVoxelWorld::HandleTileCollisionCookComplete( CollisionSuppressedAtCycles.Remove(Tile); if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile)) { + // RMC caches local bounds on pooled components; rebinding after the async cook + // invalidates that cache and rebuilds scene-query bounds from the installed mesh. + // RMC met les bornes locales en cache sur les composants reutilises ; le rebranchement + // apres la cuisson invalide ce cache et recalcule les bornes des traces. + if (bPooledComponent && Comp->GetRealtimeMesh() == CompletedMesh) + { + Comp->SetRealtimeMesh(nullptr); + Comp->SetRealtimeMesh(CompletedMesh); + } Comp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); } CollisionReadyTiles.Add(Tile); @@ -11439,6 +11449,9 @@ void AVoxelWorld::LoadCoreTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority const VoxelForgeCore::TileKey CoreKey{{Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z}, Tile.Level}; VoxelForgeCore::TileGenerationOptions Options; + Options.mesherRecipe = CoreMesherRecipe == EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV1 + ? VoxelForgeCore::MesherRecipe::worldAssemblerHybridV1 + : VoxelForgeCore::MesherRecipe::marchingCubesV1; Options.hashDensity = bCaptureHashes; std::string CoreError; std::optional CoreMesh; @@ -12248,7 +12261,7 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) MeshComp = AcquireTileComponent( bProfileApply ? &ComponentCreationSeconds : nullptr, bProfileApply ? &ComponentRegistrationSeconds : nullptr, - bProfileApply ? &bComponentCreated : nullptr); + &bComponentCreated); TileComponents.Add(Tile, MeshComp); } if (!IsValid(MeshComp)) @@ -12263,6 +12276,7 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) } return false; } + const bool bWasPooledComponent = !bComponentAlreadyPresent && !bComponentCreated; const double ComponentSeconds = bProfileApply ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ComponentStartCycles) : 0.0; @@ -12407,6 +12421,7 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) Pending.NumTriangles = Result.NumTriangles; Pending.NumSkirtTriangles = Result.NumSkirtTriangles; Pending.PendingCooksAtSubmit = PendingCollisionCooks.Num(); + Pending.bPooledComponent = bWasPooledComponent; Pending.bUseAsyncCook = CollisionConfig.bUseAsyncCook; Pending.bUseComplexAsSimpleCollision = CollisionConfig.bUseComplexAsSimpleCollision; Pending.bShouldFastCookMeshes = CollisionConfig.bShouldFastCookMeshes; @@ -13275,7 +13290,8 @@ FString AVoxelWorld::GetCurrentSeasonContentHash() const { return FString(); } - if (AssemblyHash.IsEmpty()) + const bool bHybridMesher = CoreMesherRecipe == EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV1; + if (AssemblyHash.IsEmpty() && !bHybridMesher) { return SeasonHash; } @@ -13295,8 +13311,15 @@ FString AVoxelWorld::GetCurrentSeasonContentHash() const } } } - Payload += TEXT("|world_assembly="); - Payload += AssemblyHash; + if (!AssemblyHash.IsEmpty()) + { + Payload += TEXT("|world_assembly="); + Payload += AssemblyHash; + } + if (bHybridMesher) + { + Payload += TEXT("|core_mesher=WorldAssemblerHybridV1"); + } FTCHARToUTF8 Utf8Payload(*Payload); uint8 Digest[FSHA1::DigestSize]; FSHA1::HashBuffer(Utf8Payload.Get(), Utf8Payload.Length(), Digest); diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index 3b6856f..5ca8c70 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -38,6 +38,15 @@ struct FVoxelSharedDensityGrid } }; +// Stable plan data copied onto one worker-owned mesher for hybrid LOD0 generation. +// Donnees stables du plan copiees dans un mailleur de worker pour le mode hybride LOD0. +struct FVoxelHybridSharpFeatureBox +{ + uint64 StableId = 0; + int64 MinimumSubvoxels[3] = {}; + int64 MaximumSubvoxels[3] = {}; +}; + UCLASS(BlueprintType) class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject { @@ -103,6 +112,14 @@ public: // the canonical generator path rather than changing geometry. void SetSharedDensityGrid(const FVoxelSharedDensityGrid* InGrid) { SharedDensityGrid = InGrid; } + // The caller owns this mesher instance for one tile task; no shared mutable feature state. + // L'appelant possede cette instance pour une tuile; aucune donnee partagee modifiable. + void SetHybridSharpFeatures(TArray&& InFeatures) + { + HybridSharpFeatures = MoveTemp(InFeatures); + bUseHybridSharpMesher = true; + } + private: FORCEINLINE bool ShouldAbortWork() const { @@ -111,6 +128,8 @@ private: const std::atomic* ShutdownFlag = nullptr; const FVoxelSharedDensityGrid* SharedDensityGrid = nullptr; + TArray HybridSharpFeatures; + bool bUseHybridSharpMesher = false; float GeometryVoxelSizeCm = VOXEL_SIZE; public: diff --git a/Source/VoxelForge/Public/VoxelTypes.h b/Source/VoxelForge/Public/VoxelTypes.h index b6c7617..0f7b1fe 100644 --- a/Source/VoxelForge/Public/VoxelTypes.h +++ b/Source/VoxelForge/Public/VoxelTypes.h @@ -379,6 +379,20 @@ struct FVoxelMeshData // share without changing the emitted indices. int32 NumSkirtTriangles = 0; + // Hybrid recipe diagnostics. These are counters only; they are excluded from mesh hashes. + // Diagnostics de la recette hybride; ces compteurs ne font pas partie des hachages. + uint32 HybridSharpCellCount = 0; + uint32 HybridDualCellCount = 0; + uint32 HybridFeatureCount = 0; + uint32 HybridActiveEdgeRootCount = 0; + uint32 HybridTaggedEdgeRootCount = 0; + double HybridMinimumFeatureRootAbsSdf = 0.0; + uint32 HybridFeatureRootSampleCount = 0; + uint32 HybridNearFeatureRootSampleCount = 0; + uint32 HybridBorderFallbackPatchCount = 0; + uint32 HybridDCTriangleCount = 0; + bool bHybridMesherInvoked = false; + // F17 — classe de surface par TRIANGLE, portée par l'ORDRE du buffer d'indices : les // NumCeilingTriangles DERNIERS triangles de Triangles sont la classe "plafond sky-cap" // (polygroup 1 → slot matériau 1, sans ombre) ; tout ce qui précède est "sol/roche" @@ -394,6 +408,17 @@ struct FVoxelMeshData Colors.Empty(); NumSurfaceVertices = 0; NumSkirtTriangles = 0; + HybridSharpCellCount = 0; + HybridDualCellCount = 0; + HybridFeatureCount = 0; + HybridActiveEdgeRootCount = 0; + HybridTaggedEdgeRootCount = 0; + HybridMinimumFeatureRootAbsSdf = 0.0; + HybridFeatureRootSampleCount = 0; + HybridNearFeatureRootSampleCount = 0; + HybridBorderFallbackPatchCount = 0; + HybridDCTriangleCount = 0; + bHybridMesherInvoked = false; NumCeilingTriangles = 0; } diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index c1112e4..283c7df 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -72,6 +72,15 @@ enum class EVoxelAnchorPolicy : uint8 FullVisual UMETA(DisplayName = "Full Visual") // rendered too (a second local viewpoint) }; +/** Versioned Core mesh recipe. Hybrid requires tagged sharp plan features and affects only Core meshes. + * Recette de maillage Core versionnee. Hybrid exige des fonctions planaires balisees et ne touche qu'au Core. */ +UENUM(BlueprintType) +enum class EVoxelForgeCoreMesherRecipe : uint8 +{ + MarchingCubesV1 UMETA(DisplayName = "Marching Cubes V1"), + WorldAssemblerHybridV1 UMETA(DisplayName = "World Assembler Hybrid V1") +}; + /** A non-player streaming center: keep a small box of level-0 tiles loaded around Actor so it has * collision wherever it is. Plain internal struct (weak ptr is GC-safe without reflection). */ struct FVoxelStreamingAnchor @@ -269,6 +278,11 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Core", meta = (DisplayName = "Use VoxelForge Core")) bool bUseVoxelForgeCore = false; + /** Core-only, versioned mesher selection. Default keeps the existing Core hashes and geometry. + * Recette reservee au Core; la valeur par defaut conserve les hachages et la geometrie actuels. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Core", meta = (DisplayName = "Core Mesher Recipe")) + EVoxelForgeCoreMesherRecipe CoreMesherRecipe = EVoxelForgeCoreMesherRecipe::MarchingCubesV1; + /** Core-only clipmap radius, in level-0 Core tiles. Does not read Settings->ClipRadius. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Core", meta = (ClampMin = "1", ClampMax = "64")) int32 CoreClipRadius = 3; @@ -330,6 +344,7 @@ public: uint64 CollisionSubmittedCycles = 0; uint64 CollisionSubmittedFrame = 0; bool bBodyUpdatedEventSeen = false; + bool bPooledComponent = false; uint64 BodyUpdatedEventCycles = 0; uint64 BodyUpdatedEventFrame = 0; bool bPhysicsStateReadyAtBodyUpdate = false; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index b71687f..3b48fa3 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -21,6 +21,8 @@ #include "GameFramework/Pawn.h" #include "GameFramework/PlayerController.h" #include "GameFramework/WorldSettings.h" +#include "RealtimeMeshComponent.h" +#include "PhysicsEngine/BodySetup.h" #include "HAL/FileManager.h" #include "HAL/PlatformProcess.h" #include "HAL/PlatformTime.h" @@ -76,6 +78,7 @@ #include #include #include +#include #include #include #include @@ -789,6 +792,10 @@ struct FExploreArguments FString StrateReference; FString SettingsReference; FString CoreWorldAssemblyManifestPath; + bool bCoreHybridMesher = false; + bool bCoreHybridFixturePlacement = false; + bool bCoreHybridCliffOnly = false; + bool bCoreHybridFixturesOnly = false; bool bRender = true; bool bWalk = true; bool bExport = true; @@ -901,6 +908,18 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr int32 CoreWorldTest = 0; FParse::Value(*Params, TEXT("coreworldtest="), CoreWorldTest); OutArguments.bCoreWorldTest = CoreWorldTest != 0; + int32 CoreHybridMesher = 0; + FParse::Value(*Params, TEXT("corehybridmesher="), CoreHybridMesher); + OutArguments.bCoreHybridMesher = CoreHybridMesher != 0; + int32 CoreHybridFixturePlacement = 0; + FParse::Value(*Params, TEXT("corehybridfixtureplacement="), CoreHybridFixturePlacement); + OutArguments.bCoreHybridFixturePlacement = CoreHybridFixturePlacement != 0; + int32 CoreHybridCliffOnly = 0; + FParse::Value(*Params, TEXT("corehybridcliffonly="), CoreHybridCliffOnly); + OutArguments.bCoreHybridCliffOnly = CoreHybridCliffOnly != 0; + int32 CoreHybridFixturesOnly = 0; + FParse::Value(*Params, TEXT("corehybridfixturesonly="), CoreHybridFixturesOnly); + OutArguments.bCoreHybridFixturesOnly = CoreHybridFixturesOnly != 0; FParse::Value(*Params, TEXT("archetype="), ArchetypeText); FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); FParse::Value(*Params, TEXT("strateref="), OutArguments.StrateReference); @@ -1436,6 +1455,34 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutError = TEXT("coreworldtest requires settingsref and must omit strateref."); return false; } + if ((OutArguments.bCoreHybridMesher || OutArguments.bCoreHybridFixturePlacement + || OutArguments.bCoreHybridCliffOnly || OutArguments.bCoreHybridFixturesOnly) + && !OutArguments.bCoreWorldTest) + { + OutError = TEXT("Core hybrid tests require coreworldtest."); + return false; + } + if (OutArguments.bCoreHybridFixturePlacement && !OutArguments.bCoreHybridMesher) + { + OutError = TEXT("corehybridfixtureplacement requires corehybridmesher=1."); + return false; + } + if (OutArguments.bCoreHybridCliffOnly && !OutArguments.bCoreHybridMesher) + { + OutError = TEXT("corehybridcliffonly requires corehybridmesher=1."); + return false; + } + if (OutArguments.bCoreHybridFixturesOnly && !OutArguments.bCoreHybridMesher) + { + OutError = TEXT("corehybridfixturesonly requires corehybridmesher=1."); + return false; + } + if (OutArguments.bCoreHybridFixturesOnly + && (OutArguments.bCoreHybridCliffOnly || OutArguments.bCoreHybridFixturePlacement)) + { + OutError = TEXT("corehybridfixturesonly cannot be combined with the other focused hybrid modes."); + return false; + } if (!OutArguments.CoreWorldAssemblyManifestPath.IsEmpty() && !OutArguments.bCoreWorldTest) { OutError = TEXT("coreworldassembly is accepted only by coreworldtest."); @@ -13640,6 +13687,716 @@ bool RunStandaloneWorldExport(const FExploreArguments& Arguments, FString& OutEr return true; } +struct FHybridFixtureContactProbe +{ + FString Name; + FVector Start = FVector::ZeroVector; + FVector End = FVector::ZeroVector; + FVector Expected = FVector::ZeroVector; +}; + +bool GetHybridFixtureRenderAndCollisionBounds(URealtimeMeshComponent* Component, + FBox& OutRenderBounds, FBox& OutCollisionBounds) +{ + if (!IsValid(Component)) + { + return false; + } + const URealtimeMesh* RealtimeMesh = Component->GetRealtimeMesh(); + const UBodySetup* BodySetup = Component->GetBodySetup(); + if (RealtimeMesh == nullptr || BodySetup == nullptr) + { + return false; + } + const FBoxSphereBounds& RenderBounds = RealtimeMesh->GetLocalBounds(); + OutRenderBounds = FBox(RenderBounds.Origin - RenderBounds.BoxExtent, + RenderBounds.Origin + RenderBounds.BoxExtent); + OutCollisionBounds = FBox(EForceInit::ForceInit); + for (const Chaos::FTriangleMeshImplicitObjectPtr& BodyMesh : BodySetup->TriMeshGeometries) + { + if (!BodyMesh.IsValid()) continue; + const Chaos::FAABB3& BodyBounds = BodyMesh->BoundingBox(); + OutCollisionBounds += FVector(BodyBounds.Min()[0], BodyBounds.Min()[1], BodyBounds.Min()[2]); + OutCollisionBounds += FVector(BodyBounds.Max()[0], BodyBounds.Max()[1], BodyBounds.Max()[2]); + } + return OutRenderBounds.IsValid && OutCollisionBounds.IsValid; +} + +bool AddHybridFixturePlacement(UVoxelSettings* Settings, const FString& OutputDirectory, + std::vector& OutContacts, FString& OutManifestHash, + FString& OutError) +{ + FString SourceJson; + FString SourceHash; + FString SettingsError; + if (Settings == nullptr || !Settings->GetWorldAssemblyManifestJson( + SourceJson, SourceHash, SettingsError)) + { + OutError = FString::Printf(TEXT("Hybrid fixture placement needs a valid SurfaceWorld manifest: %s."), + *SettingsError); + return false; + } + + FTCHARToUTF8 SourceUtf8(*SourceJson); + std::string CoreError; + std::optional Manifest = + VoxelForgeCore::WorldAssemblyManifest::FromJson( + std::string_view(SourceUtf8.Get(), static_cast(SourceUtf8.Length())), CoreError); + if (!Manifest) + { + OutError = FString::Printf(TEXT("Core could not parse the hybrid fixture manifest: %s."), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + VoxelForgeCore::WorldAssemblyStratumPlanV1* SurfacePlan = nullptr; + for (VoxelForgeCore::WorldAssemblyStratumPlanV1& Plan : Manifest->plan.strates) + { + if (Plan.strateIndex == 1) + { + SurfacePlan = &Plan; + break; + } + } + if (SurfacePlan == nullptr) + { + OutError = TEXT("Hybrid fixture manifest has no SurfaceWorld strate 1."); + return false; + } + + const VoxelForgeCore::Heightfield2D BaseField(Manifest->recipe, *SurfacePlan); + if (!BaseField.isValid()) + { + OutError = TEXT("Hybrid fixture manifest has an invalid SurfaceWorld heightfield."); + return false; + } + + constexpr int64 Scale = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + struct FPlacementGroup + { + const TCHAR* Name; + int32 GroundX; + int32 GroundY; + int32 HeightVoxels; + }; + const FPlacementGroup Groups[] = { + {TEXT("corner"), 41, 166, 10}, + {TEXT("doorway"), 54, 166, 12}, + {TEXT("riser"), 46, 184, 8}, + {TEXT("ledge"), 56, 184, 12}, + }; + int64 GroupGroundSubvoxels[UE_ARRAY_COUNT(Groups)]{}; + for (int32 Index = 0; Index < UE_ARRAY_COUNT(Groups); ++Index) + { + const VoxelForgeCore::HeightfieldPointSample Ground = BaseField.point( + static_cast(Groups[Index].GroundX) * Scale, + static_cast(Groups[Index].GroundY) * Scale, 0); + GroupGroundSubvoxels[Index] = Ground.groundHeightSubvoxels; + } + + auto AddBox = [&](uint64 StableId, int32 GroupIndex, int32 MinX, int32 MaxX, + int32 MinY, int32 MaxY, int32 BottomOffset, int32 TopOffset) + { + VoxelForgeCore::WorldAssemblyFeatureV1 Feature; + Feature.stableId = StableId; + Feature.type = 2; + Feature.minimumSubvoxels = { + static_cast(MinX) * Scale, + static_cast(MinY) * Scale, + GroupGroundSubvoxels[GroupIndex] + static_cast(BottomOffset) * Scale}; + Feature.maximumSubvoxels = { + static_cast(MaxX) * Scale, + static_cast(MaxY) * Scale, + GroupGroundSubvoxels[GroupIndex] + static_cast(TopOffset) * Scale}; + SurfacePlan->features.push_back(Feature); + }; + + // Small, separated hard-box placements stay inside the (32..64, 160..192) LOD0 tile. + // De petits volumes durs separent restent dans la tuile LOD0 (32..64, 160..192). + AddBox(0x4d32460000000001ull, 0, 34, 43, 164, 168, 0, 10); + AddBox(0x4d32460000000002ull, 0, 39, 43, 168, 173, 0, 10); + AddBox(0x4d32460000000003ull, 1, 47, 51, 164, 168, 0, 12); + AddBox(0x4d32460000000004ull, 1, 57, 61, 164, 168, 0, 12); + AddBox(0x4d32460000000005ull, 1, 51, 57, 164, 168, 8, 12); + AddBox(0x4d32460000000006ull, 2, 34, 44, 180, 189, 0, 4); + AddBox(0x4d32460000000007ull, 2, 44, 48, 180, 189, 4, 8); + AddBox(0x4d32460000000008ull, 3, 50, 54, 180, 189, 0, 12); + AddBox(0x4d32460000000009ull, 3, 54, 63, 180, 189, 8, 10); + + if (!Manifest->finalize(CoreError)) + { + OutError = FString::Printf(TEXT("Hybrid fixture manifest failed validation: %s."), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + const std::string FixtureJsonUtf8 = Manifest->ToJson(); + const FString FixtureJson = UTF8_TO_TCHAR(FixtureJsonUtf8.c_str()); + FString ImportReport; + if (!Settings->SetWorldAssemblyManifestJson(FixtureJson, ImportReport)) + { + OutError = FString::Printf(TEXT("Could not install the in-memory hybrid fixture manifest: %s."), + *ImportReport); + return false; + } + OutManifestHash = FString::Printf(TEXT("%016llx"), + static_cast(Manifest->contentHash)); + const FString OutputPath = FPaths::Combine(OutputDirectory, + TEXT("world_assembly_hybrid_fixtures.json")); + if (!FFileHelper::SaveStringToFile( + FixtureJson, *OutputPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutError = FString::Printf(TEXT("Could not save the generated BuildHost fixture plan '%s'."), + *OutputPath); + return false; + } + + const auto AddTrace = [&](const TCHAR* Name, double X, double Y, double ContactZ, + int32 Axis, bool bTracePositive) + { + FHybridFixtureContactProbe Probe; + Probe.Name = Name; + Probe.Expected = FVector(X * 25.0, Y * 25.0, ContactZ * 25.0); + Probe.Start = Probe.Expected; + Probe.End = Probe.Expected; + if (Axis == 2) + { + Probe.Start.Z += bTracePositive ? 100.0 : -100.0; + Probe.End.Z -= bTracePositive ? 100.0 : -100.0; + } + else + { + const double Sign = bTracePositive ? 1.0 : -1.0; + Probe.Start[Axis] += Sign * 100.0; + Probe.End[Axis] -= Sign * 100.0; + } + OutContacts.push_back(Probe); + }; + const double CornerGround = static_cast(GroupGroundSubvoxels[0]) / Scale; + const double DoorGround = static_cast(GroupGroundSubvoxels[1]) / Scale; + const double RiserGround = static_cast(GroupGroundSubvoxels[2]) / Scale; + const double LedgeGround = static_cast(GroupGroundSubvoxels[3]) / Scale; + AddTrace(TEXT("corner_top"), 41.0, 166.0, CornerGround + 10.0, 2, true); + AddTrace(TEXT("doorway_lintel"), 54.0, 166.0, DoorGround + 12.0, 2, true); + AddTrace(TEXT("riser_face"), 54.0, 184.0, RiserGround + 6.0, 0, true); + AddTrace(TEXT("ledge_top"), 58.0, 184.0, LedgeGround + 10.0, 2, true); + AddTrace(TEXT("ledge_underside"), 58.0, 184.0, LedgeGround + 8.0, 2, false); + return true; +} + +struct FHybridAnalyticBox +{ + uint64 StableId = 0; + FVector Minimum = FVector::ZeroVector; + FVector Maximum = FVector::ZeroVector; +}; + +struct FHybridAnalyticFixture +{ + const TCHAR* Name = TEXT("fixture"); + std::vector Boxes; + bool bCaveChamber = false; + bool bSurfaceFloor = false; +}; + +double HybridFixtureBoxSdf(const FVector& Point, const FVector& Minimum, const FVector& Maximum) +{ + const FVector Center = (Minimum + Maximum) * 0.5; + const FVector Half = (Maximum - Minimum) * 0.5; + const FVector Delta = Point - Center; + const FVector Q(FMath::Abs(Delta.X) - Half.X, + FMath::Abs(Delta.Y) - Half.Y, + FMath::Abs(Delta.Z) - Half.Z); + const FVector Outside(FMath::Max(Q.X, 0.0), FMath::Max(Q.Y, 0.0), FMath::Max(Q.Z, 0.0)); + return Outside.Size() + FMath::Min(FMath::Max(Q.X, FMath::Max(Q.Y, Q.Z)), 0.0); +} + +FHybridAnalyticFixture MakeHybridAnalyticFixture(const TCHAR* Name, const FVector& Phase) +{ + FHybridAnalyticFixture Fixture; + Fixture.Name = Name; + auto AddBox = [&](uint64 Id, FVector Minimum, FVector Maximum) + { + Fixture.Boxes.push_back({Id, Minimum, Maximum}); + }; + if (FCString::Strcmp(Name, TEXT("corner90")) == 0) + { + AddBox(0x1001, FVector(6, 6, 6), FVector(23, 10, 26)); + AddBox(0x1002, FVector(6, 10, 6), FVector(10, 23, 26)); + } + else if (FCString::Strcmp(Name, TEXT("doorway")) == 0) + { + AddBox(0x2001, FVector(6, 8, 6), FVector(10, 12, 26)); + AddBox(0x2002, FVector(22, 8, 6), FVector(26, 12, 26)); + AddBox(0x2003, FVector(10, 8, 22), FVector(22, 12, 26)); + } + else if (FCString::Strcmp(Name, TEXT("riser1m")) == 0) + { + AddBox(0x3001, FVector(6, 8, 6), FVector(18, 24, 10)); + AddBox(0x3002, FVector(18, 8, 10), FVector(26, 24, 14)); + } + else if (FCString::Strcmp(Name, TEXT("cliff_ledge")) == 0) + { + AddBox(0x4001, FVector(6, 8, 6), FVector(10, 24, 26)); + AddBox(0x4002, FVector(10, 8, 22), FVector(26, 24, 26)); + } + else if (FCString::Strcmp(Name, TEXT("doorway_border")) == 0) + { + Fixture.bSurfaceFloor = true; + AddBox(0x5001, FVector(27, 12, 10), FVector(35, 18, 29)); + AddBox(0x5002, FVector(39, 12, 10), FVector(45, 18, 29)); + AddBox(0x5003, FVector(27, 12, 25), FVector(45, 18, 29)); + } + else + { + Fixture.bCaveChamber = true; + } + for (FHybridAnalyticBox& Box : Fixture.Boxes) + { + Box.Minimum += Phase; + Box.Maximum += Phase; + const auto QuantizePlanCoordinate = [](double Value) + { return std::round(Value * 256.0) / 256.0; }; + Box.Minimum.X = QuantizePlanCoordinate(Box.Minimum.X); + Box.Minimum.Y = QuantizePlanCoordinate(Box.Minimum.Y); + Box.Minimum.Z = QuantizePlanCoordinate(Box.Minimum.Z); + Box.Maximum.X = QuantizePlanCoordinate(Box.Maximum.X); + Box.Maximum.Y = QuantizePlanCoordinate(Box.Maximum.Y); + Box.Maximum.Z = QuantizePlanCoordinate(Box.Maximum.Z); + } + return Fixture; +} + +double EvaluateHybridAnalyticFixture(const FHybridAnalyticFixture& Fixture, + const FVector& Point, const FVector& Phase) +{ + const FVector Local = Point - Phase; + double Density = std::numeric_limits::infinity(); + if (Fixture.bSurfaceFloor) + { + const double Floor = 16.0 + 2.0 * std::sin(Local.X * 0.19) + + 1.4 * std::sin(Local.Y * 0.17); + Density = Local.Z - Floor; + } + if (Fixture.bCaveChamber) + { + Density = 8.0 - (Local - FVector(16, 16, 16)).Size(); + } + for (const FHybridAnalyticBox& Box : Fixture.Boxes) + { + Density = FMath::Min(Density, HybridFixtureBoxSdf(Local, + Box.Minimum - Phase, Box.Maximum - Phase)); + } + return Density; +} + +bool SameHybridMeshArrays(const FVoxelMeshData& A, const FVoxelMeshData& B) +{ + if (A.Vertices.Num() != B.Vertices.Num() || A.Normals.Num() != B.Normals.Num() + || A.UVs.Num() != B.UVs.Num() || A.Colors.Num() != B.Colors.Num() + || A.Triangles.Num() != B.Triangles.Num() + || A.NumSurfaceVertices != B.NumSurfaceVertices + || A.NumCeilingTriangles != B.NumCeilingTriangles + || A.NumSkirtTriangles != B.NumSkirtTriangles) + { + return false; + } + for (int32 I = 0; I < A.Vertices.Num(); ++I) + { + if (A.Vertices[I].X != B.Vertices[I].X || A.Vertices[I].Y != B.Vertices[I].Y + || A.Vertices[I].Z != B.Vertices[I].Z) return false; + } + for (int32 I = 0; I < A.Normals.Num(); ++I) + { + if (A.Normals[I].X != B.Normals[I].X || A.Normals[I].Y != B.Normals[I].Y + || A.Normals[I].Z != B.Normals[I].Z) return false; + } + for (int32 I = 0; I < A.UVs.Num(); ++I) + { + if (A.UVs[I].X != B.UVs[I].X || A.UVs[I].Y != B.UVs[I].Y) return false; + } + for (int32 I = 0; I < A.Colors.Num(); ++I) + { + if (A.Colors[I] != B.Colors[I]) return false; + } + for (int32 I = 0; I < A.Triangles.Num(); ++I) + { + if (A.Triangles[I] != B.Triangles[I]) return false; + } + return true; +} + +bool GenerateHybridFixtureMesh(UVoxelGenerator* Generator, const FHybridAnalyticFixture& Fixture, + const FVector& Phase, const FIntVector& Origin, int32 Step, int32 Cells, + bool bHybrid, FVoxelMeshData& OutMesh, double* OutMeshingMilliseconds, + FString& OutError) +{ + if (Generator == nullptr || Step <= 0 || Cells <= 0) + { + OutError = TEXT("Hybrid analytic fixture has no generator or an invalid tile lattice."); + return false; + } + FVoxelSharedDensityGrid Grid; + Grid.OriginVoxels = Origin + FIntVector(-Step, -Step, -Step); + Grid.Step = Step; + Grid.Dim = Cells + 3; + Grid.Samples.SetNumUninitialized(Grid.Dim * Grid.Dim * Grid.Dim); + for (int32 Z = -1; Z <= Cells + 1; ++Z) + for (int32 Y = -1; Y <= Cells + 1; ++Y) + for (int32 X = -1; X <= Cells + 1; ++X) + { + const FVector Point(Origin.X + X * Step, Origin.Y + Y * Step, Origin.Z + Z * Step); + const int32 Index = ((Z + 1) * Grid.Dim + (Y + 1)) * Grid.Dim + (X + 1); + Grid.Samples[Index] = static_cast(EvaluateHybridAnalyticFixture(Fixture, Point, Phase)); + } + + TArray SharpFeatures; + if (bHybrid) + { + for (const FHybridAnalyticBox& Feature : Fixture.Boxes) + { + FVoxelHybridSharpFeatureBox Box; + Box.StableId = Feature.StableId; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + const double Low = Axis == 0 ? Feature.Minimum.X + : Axis == 1 ? Feature.Minimum.Y : Feature.Minimum.Z; + const double High = Axis == 0 ? Feature.Maximum.X + : Axis == 1 ? Feature.Maximum.Y : Feature.Maximum.Z; + Box.MinimumSubvoxels[Axis] = static_cast(std::llround(Low * 256.0)); + Box.MaximumSubvoxels[Axis] = static_cast(std::llround(High * 256.0)); + } + SharpFeatures.Add(Box); + } + } + + UVoxelMarchingCubesMesher* Mesher = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + if (Mesher == nullptr) + { + OutError = TEXT("Could not allocate the production mesher for an analytic fixture."); + return false; + } + Mesher->SetGenerator(Generator); + Mesher->SetGeometryVoxelSizeCm(25.0f); + Mesher->bGenerateSkirts = false; + if (bHybrid) Mesher->SetHybridSharpFeatures(MoveTemp(SharpFeatures)); + const double StartSeconds = FPlatformTime::Seconds(); + OutMesh = Mesher->GenerateMesh(Origin, Step, Cells, nullptr, + INT32_MIN, INT32_MAX, nullptr, nullptr, &Grid); + if (OutMeshingMilliseconds != nullptr) + { + *OutMeshingMilliseconds = (FPlatformTime::Seconds() - StartSeconds) * 1000.0; + } + return true; +} + +using FHybridContourSegment = std::array; +using FHybridContourSet = std::set; + +FHybridContourSet ExtractHybridBoundaryContour(const FVoxelMeshData& Mesh, double PlaneXcm) +{ + FHybridContourSet Segments; + constexpr double PlaneTolerance = 0.01; + for (int32 I = 0; I + 2 < Mesh.Triangles.Num(); I += 3) + { + const int32 Indices[3] = {Mesh.Triangles[I], Mesh.Triangles[I + 1], Mesh.Triangles[I + 2]}; + for (int32 Edge = 0; Edge < 3; ++Edge) + { + const FVector& A = Mesh.Vertices[Indices[Edge]]; + const FVector& B = Mesh.Vertices[Indices[(Edge + 1) % 3]]; + if (FMath::Abs(A.X - PlaneXcm) > PlaneTolerance + || FMath::Abs(B.X - PlaneXcm) > PlaneTolerance) + { + continue; + } + int64 AY = static_cast(std::llround(A.Y * 100.0)); + int64 AZ = static_cast(std::llround(A.Z * 100.0)); + int64 BY = static_cast(std::llround(B.Y * 100.0)); + int64 BZ = static_cast(std::llround(B.Z * 100.0)); + if (AY == BY && AZ == BZ) continue; + if (std::tie(BY, BZ) < std::tie(AY, AZ)) + { + std::swap(AY, BY); + std::swap(AZ, BZ); + } + Segments.insert({AY, AZ, BY, BZ}); + } + } + return Segments; +} + +FHybridContourSet ClipHybridContourToBounds(const FHybridContourSet& Contour, + double MinYCm, double MaxYCm, double MinZCm, double MaxZCm) +{ + FHybridContourSet Clipped; + for (const FHybridContourSegment& Segment : Contour) + { + const double Y0 = static_cast(Segment[0]) / 100.0; + const double Z0 = static_cast(Segment[1]) / 100.0; + const double Y1 = static_cast(Segment[2]) / 100.0; + const double Z1 = static_cast(Segment[3]) / 100.0; + const double DY = Y1 - Y0; + const double DZ = Z1 - Z0; + double T0 = 0.0; + double T1 = 1.0; + const double P[4] = {-DY, DY, -DZ, DZ}; + const double Q[4] = { + Y0 - MinYCm, MaxYCm - Y0, Z0 - MinZCm, MaxZCm - Z0}; + bool bVisible = true; + for (int32 Plane = 0; Plane < 4; ++Plane) + { + if (FMath::Abs(P[Plane]) <= 1.0e-12) + { + if (Q[Plane] < 0.0) { bVisible = false; break; } + continue; + } + const double T = Q[Plane] / P[Plane]; + if (P[Plane] < 0.0) T0 = FMath::Max(T0, T); + else T1 = FMath::Min(T1, T); + if (T0 > T1) { bVisible = false; break; } + } + if (!bVisible) continue; + const int64 CY0 = static_cast(std::llround((Y0 + T0 * DY) * 100.0)); + const int64 CZ0 = static_cast(std::llround((Z0 + T0 * DZ) * 100.0)); + const int64 CY1 = static_cast(std::llround((Y0 + T1 * DY) * 100.0)); + const int64 CZ1 = static_cast(std::llround((Z0 + T1 * DZ) * 100.0)); + if (CY0 == CY1 && CZ0 == CZ1) continue; + if (std::tie(CY1, CZ1) < std::tie(CY0, CZ0)) + Clipped.insert({CY1, CZ1, CY0, CZ0}); + else + Clipped.insert({CY0, CZ0, CY1, CZ1}); + } + return Clipped; +} + +double HybridPointToSegmentCm(double Y, double Z, const FHybridContourSegment& Segment) +{ + const double AY = static_cast(Segment[0]) / 100.0; + const double AZ = static_cast(Segment[1]) / 100.0; + const double BY = static_cast(Segment[2]) / 100.0; + const double BZ = static_cast(Segment[3]) / 100.0; + const double DY = BY - AY, DZ = BZ - AZ; + const double Denominator = DY * DY + DZ * DZ; + const double T = Denominator > 1.0e-12 + ? FMath::Clamp(((Y - AY) * DY + (Z - AZ) * DZ) / Denominator, 0.0, 1.0) : 0.0; + return FMath::Sqrt(FMath::Square(Y - (AY + T * DY)) + FMath::Square(Z - (AZ + T * DZ))); +} + +double HybridContourSetMismatchCm(const FHybridContourSet& A, const FHybridContourSet& B, + int32& OutOverBudgetProbes) +{ + constexpr double BudgetCm = 50.0; + double Maximum = 0.0; + OutOverBudgetProbes = 0; + if (A.empty() != B.empty()) return std::numeric_limits::infinity(); + const auto Measure = [&](const FHybridContourSet& Source, const FHybridContourSet& Target) + { + for (const FHybridContourSegment& Segment : Source) + for (int32 Endpoint = 0; Endpoint < 2; ++Endpoint) + { + const double Y = static_cast(Segment[Endpoint * 2]) / 100.0; + const double Z = static_cast(Segment[Endpoint * 2 + 1]) / 100.0; + double Nearest = std::numeric_limits::infinity(); + for (const FHybridContourSegment& Candidate : Target) + Nearest = FMath::Min(Nearest, HybridPointToSegmentCm(Y, Z, Candidate)); + Maximum = FMath::Max(Maximum, Nearest); + if (Nearest > BudgetCm) ++OutOverBudgetProbes; + } + }; + Measure(A, B); + Measure(B, A); + return Maximum; +} + +bool RunHybridPluginMesherFixtures(UVoxelGenerator* Generator, FString& OutError) +{ + const FVector Phase(0.27, 0.41, 0.33); + const TCHAR* FixtureNames[] = { + TEXT("corner90"), TEXT("doorway"), TEXT("riser1m"), + TEXT("cliff_ledge"), TEXT("organic_cave_chamber")}; + int32 SharpDifferences = 0; + for (const TCHAR* FixtureName : FixtureNames) + { + const FHybridAnalyticFixture Fixture = MakeHybridAnalyticFixture(FixtureName, Phase); + std::vector McTimes, HybridTimes; + FVoxelMeshData McMesh, HybridMesh, RepeatedHybridMesh; + for (int32 Repeat = 0; Repeat < 3; ++Repeat) + { + double ElapsedMs = 0.0; + if (!GenerateHybridFixtureMesh(Generator, Fixture, Phase, FIntVector::ZeroValue, + 1, CHUNK_SIZE, false, McMesh, &ElapsedMs, OutError)) return false; + McTimes.push_back(ElapsedMs); + if (!GenerateHybridFixtureMesh(Generator, Fixture, Phase, FIntVector::ZeroValue, + 1, CHUNK_SIZE, true, HybridMesh, &ElapsedMs, OutError)) return false; + HybridTimes.push_back(ElapsedMs); + if (Repeat == 0) RepeatedHybridMesh = HybridMesh; + } + const bool bStable = SameHybridMeshArrays(RepeatedHybridMesh, HybridMesh); + const bool bExpectedEqual = Fixture.Boxes.empty(); + const bool bMatchesExpected = bExpectedEqual + ? SameHybridMeshArrays(McMesh, HybridMesh) + : !SameHybridMeshArrays(McMesh, HybridMesh); + if (!bStable || !bMatchesExpected) + { + OutError = FString::Printf( + TEXT("Production hybrid fixture failed: %s stable=%d expected_mc_identity=%d mc_vertices=%d hybrid_vertices=%d mc_triangles=%d hybrid_triangles=%d invoked=%d features=%u active_roots=%u feature_root_samples=%u near_roots=%u min_root_sdf=%.9f tagged_roots=%u sharp_cells=%u dual_cells=%u dc_triangles=%u border_fallback=%u."), + FixtureName, bStable ? 1 : 0, bExpectedEqual ? 1 : 0, + McMesh.Vertices.Num(), HybridMesh.Vertices.Num(), + McMesh.Triangles.Num() / 3, HybridMesh.Triangles.Num() / 3, + HybridMesh.bHybridMesherInvoked ? 1 : 0, HybridMesh.HybridFeatureCount, + HybridMesh.HybridActiveEdgeRootCount, + HybridMesh.HybridFeatureRootSampleCount, + HybridMesh.HybridNearFeatureRootSampleCount, + HybridMesh.HybridMinimumFeatureRootAbsSdf, + HybridMesh.HybridTaggedEdgeRootCount, + HybridMesh.HybridSharpCellCount, HybridMesh.HybridDualCellCount, + HybridMesh.HybridDCTriangleCount, HybridMesh.HybridBorderFallbackPatchCount); + return false; + } + if (!bExpectedEqual) ++SharpDifferences; + std::sort(McTimes.begin(), McTimes.end()); + std::sort(HybridTimes.begin(), HybridTimes.end()); + UE_LOG(LogTemp, Display, + TEXT("[WorldAssemblerHybridPluginFixture] name=%s lod=0 cells=%d mc_ms=%.3f hybrid_ms=%.3f ratio=%.3f mc_triangles=%d hybrid_triangles=%d exact_repeat=1"), + FixtureName, CHUNK_SIZE, McTimes[1], HybridTimes[1], + McTimes[1] > 0.0 ? HybridTimes[1] / McTimes[1] : 0.0, + McMesh.Triangles.Num() / 3, HybridMesh.Triangles.Num() / 3); + } + if (SharpDifferences != 4) + { + OutError = FString::Printf(TEXT("Expected four analytic sharp fixtures to exercise DC; got %d."), + SharpDifferences); + return false; + } + + // Coarse LOD uses MC regardless of the selected recipe, so its full arrays must match. + // Le LOD grossier reste sur MC, meme si la recette hybride est activee. + const FHybridAnalyticFixture CoarseFixture = MakeHybridAnalyticFixture(TEXT("doorway"), Phase); + FVoxelMeshData CoarseMc, CoarseHybrid; + if (!GenerateHybridFixtureMesh(Generator, CoarseFixture, Phase, FIntVector::ZeroValue, + 2, CHUNK_SIZE, false, CoarseMc, nullptr, OutError) + || !GenerateHybridFixtureMesh(Generator, CoarseFixture, Phase, FIntVector::ZeroValue, + 2, CHUNK_SIZE, true, CoarseHybrid, nullptr, OutError)) return false; + if (!SameHybridMeshArrays(CoarseMc, CoarseHybrid)) + { + OutError = TEXT("Hybrid altered a coarse LOD mesh; DC must remain LOD0-only."); + return false; + } + + // Independent same-LOD neighbors share the exact sampled contour at their common face. + // Des voisins LOD identique independants doivent partager exactement leur contour commun. + const FVector BorderPhase(0.23, 0.37, 0.19); + const FHybridAnalyticFixture BorderFixture = + MakeHybridAnalyticFixture(TEXT("doorway_border"), BorderPhase); + FVoxelMeshData McLeft, McRight, HybridLeft, HybridRight; + if (!GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(0, 0, 0), + 1, CHUNK_SIZE, false, McLeft, nullptr, OutError) + || !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(32, 0, 0), + 1, CHUNK_SIZE, false, McRight, nullptr, OutError) + || !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(0, 0, 0), + 1, CHUNK_SIZE, true, HybridLeft, nullptr, OutError) + || !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(32, 0, 0), + 1, CHUNK_SIZE, true, HybridRight, nullptr, OutError)) return false; + const FHybridContourSet McLeftBorder = ExtractHybridBoundaryContour(McLeft, 800.0); + const FHybridContourSet McRightBorder = ExtractHybridBoundaryContour(McRight, 800.0); + const FHybridContourSet HybridLeftBorder = ExtractHybridBoundaryContour(HybridLeft, 800.0); + const FHybridContourSet HybridRightBorder = ExtractHybridBoundaryContour(HybridRight, 800.0); + if (McLeftBorder.empty() || McLeftBorder != McRightBorder + || HybridLeftBorder.empty() || HybridLeftBorder != HybridRightBorder + || !SameHybridMeshArrays(McLeft, HybridLeft) + || !SameHybridMeshArrays(McRight, HybridRight)) + { + OutError = FString::Printf( + TEXT("Plugin same-LOD border failed: MC segments=%d/%d hybrid=%d/%d."), + static_cast(McLeftBorder.size()), static_cast(McRightBorder.size()), + static_cast(HybridLeftBorder.size()), static_cast(HybridRightBorder.size())); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[WorldAssemblerHybridPluginBorder] same_lod=PASS independent=1 mc_segments=%d hybrid_segments=%d border_fallback_mc_identity=1"), + static_cast(McLeftBorder.size()), static_cast(HybridLeftBorder.size())); + + // Compare the raw doorway contours at the 2:1 seam over all 125 sub-cell phases. + // Comparer les contours bruts de la porte sur la couture 2:1 pour 125 phases. + int32 LodOverBudgetCases = 0; + int32 LodUnmatchedProbes = 0; + double LodWorstMismatchCm = 0.0; + constexpr double Phases[5] = {0.0, 0.2, 0.4, 0.6, 0.8}; + const auto ContourBoundsText = [](const FHybridContourSet& Contour) + { + if (Contour.empty()) return FString(TEXT("empty")); + double MinY = std::numeric_limits::infinity(); + double MaxY = -std::numeric_limits::infinity(); + double MinZ = std::numeric_limits::infinity(); + double MaxZ = -std::numeric_limits::infinity(); + for (const FHybridContourSegment& Segment : Contour) + { + MinY = FMath::Min(MinY, static_cast(FMath::Min(Segment[0], Segment[2])) / 100.0); + MaxY = FMath::Max(MaxY, static_cast(FMath::Max(Segment[0], Segment[2])) / 100.0); + MinZ = FMath::Min(MinZ, static_cast(FMath::Min(Segment[1], Segment[3])) / 100.0); + MaxZ = FMath::Max(MaxZ, static_cast(FMath::Max(Segment[1], Segment[3])) / 100.0); + } + return FString::Printf(TEXT("(y=%.2f..%.2f,z=%.2f..%.2f)cm"), MinY, MaxY, MinZ, MaxZ); + }; + for (double PhaseX : Phases) + for (double PhaseY : Phases) + for (double PhaseZ : Phases) + { + const FVector SweepPhase(PhaseX, PhaseY, PhaseZ); + const FHybridAnalyticFixture Doorway = + MakeHybridAnalyticFixture(TEXT("doorway_border"), SweepPhase); + FVoxelMeshData FineMc, CoarseMcForPhase, FineHybrid; + if (!GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(0, 0, 0), + 1, CHUNK_SIZE, false, FineMc, nullptr, OutError) + || !GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(32, 0, 0), + 2, CHUNK_SIZE, false, CoarseMcForPhase, nullptr, OutError) + || !GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(0, 0, 0), + 1, CHUNK_SIZE, true, FineHybrid, nullptr, OutError)) return false; + if (!SameHybridMeshArrays(FineMc, FineHybrid)) + { + OutError = TEXT("LOD0 doorway cells touching the LOD border did not fall back to MC."); + return false; + } + const FHybridContourSet FineContour = ExtractHybridBoundaryContour(FineMc, 800.0); + const FHybridContourSet CoarseContour = ClipHybridContourToBounds( + ExtractHybridBoundaryContour(CoarseMcForPhase, 800.0), 0.0, 800.0, 0.0, 800.0); + if (FineContour.empty() || CoarseContour.empty()) + { + OutError = TEXT("The production doorway border probe did not produce a contour on both LODs."); + return false; + } + int32 OverBudgetProbes = 0; + const double MismatchCm = HybridContourSetMismatchCm( + FineContour, CoarseContour, OverBudgetProbes); + if (PhaseX == 0.0 && PhaseY == 0.0 && PhaseZ == 0.0) + { + UE_LOG(LogTemp, Display, + TEXT("[WorldAssemblerHybridPluginBorderProbe] phase=(0,0,0) plane_x_cm=800 fine_segments=%d fine_bounds=%s coarse_overlap_segments=%d coarse_overlap_bounds=%s mismatch_cm=%.3f"), + static_cast(FineContour.size()), *ContourBoundsText(FineContour), + static_cast(CoarseContour.size()), *ContourBoundsText(CoarseContour), + MismatchCm); + } + if (!FMath::IsFinite(MismatchCm)) + { + OutError = TEXT("The production doorway test found an empty fine/coarse border contour."); + return false; + } + LodWorstMismatchCm = FMath::Max(LodWorstMismatchCm, MismatchCm); + LodUnmatchedProbes += OverBudgetProbes; + if (OverBudgetProbes > 0) ++LodOverBudgetCases; + } + UE_LOG(LogTemp, Display, + TEXT("[WorldAssemblerHybridPluginBorder] lod0_lod1_doorway=1 cases=125 over_50cm_cases=%d over_50cm_probes=%d worst_contour_mismatch_cm=%.3f fine_border_hybrid_mc_identity=1 coarse_recipe=MC skirts_in_raw_probe=0"), + LodOverBudgetCases, LodUnmatchedProbes, LodWorstMismatchCm); + if (LodOverBudgetCases > 0) + { + OutError = FString::Printf( + TEXT("Production MC doorway contour exceeded the 50 cm border budget in %d/125 phases (worst %.3f cm)."), + LodOverBudgetCases, LodWorstMismatchCm); + return false; + } + return true; +} + bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FString& OutError) { UVoxelSettings* AuthoredSettings = LoadObject( @@ -13659,6 +14416,21 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin return false; } + if (Arguments.bCoreHybridFixturesOnly) + { + UVoxelGenerator* FixtureGenerator = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + if (FixtureGenerator == nullptr) + { + OutError = TEXT("Could not allocate the focused hybrid fixture generator."); + return false; + } + FixtureGenerator->InitializeSettings(AuthoredSettings); + if (!RunHybridPluginMesherFixtures(FixtureGenerator, OutError)) return false; + UE_LOG(LogTemp, Display, TEXT("[WorldAssemblerHybridPluginFixture] fixture_only=PASS")); + return true; + } + if (!Arguments.CoreWorldAssemblyManifestPath.IsEmpty()) { const FString ManifestPath = FPaths::ConvertRelativePathToFull( @@ -13683,6 +14455,21 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCoreWorldTest] %s"), *ImportReport); } + std::vector HybridFixtureContacts; + FString HybridFixtureManifestHash; + if (Arguments.bCoreHybridFixturePlacement + && !AddHybridFixturePlacement(AuthoredSettings, Arguments.OutDirectory, + HybridFixtureContacts, HybridFixtureManifestHash, OutError)) + { + return false; + } + if (Arguments.bCoreHybridFixturePlacement) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] hybrid_fixture_placement=1 boxes=9 contacts=%d manifest_hash=%s file=world_assembly_hybrid_fixtures.json"), + static_cast(HybridFixtureContacts.size()), *HybridFixtureManifestHash); + } + TStrongObjectPtr Manager(NewObject( GetTransientPackage(), NAME_None, RF_Transient)); int32 ResolvedSeed = AuthoredSettings->Seed; @@ -13710,6 +14497,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin TMap ExpectedPostEditHashes; TMap ExpectedTravelHashes; TMap ExpectedSurfaceTravelHashes; + std::optional ExpectedHybridFixtureVisualMesh; TSet ExpectedInitialDesiredSet; TSet ExpectedTravelDesiredSet; TSet ExpectedSurfaceTravelDesiredSet; @@ -13923,8 +14711,66 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin } ReferenceWorld.retainTileRegions(Desired); + if (Arguments.bCoreHybridCliffOnly) + { + // This is the sampled M1 type-4 cliff tile used by the round-2 standalone identity check. + // C'est la tuile de falaise native type 4 utilisee par le controle d'identite hors ligne. + const VoxelForgeCore::TileKey CliffKey{{-22, 13, -58}, 0}; + const std::vector CliffTiles{CliffKey}; + ReferenceWorld.retainTileRegions(CliffTiles); + if (!ReferenceWorld.compileTileRegion(CliffTiles, 16, Scheduler, CoreError)) + { + OutError = FString::Printf(TEXT("Native SurfaceWorld cliff region compile failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + VoxelForgeCore::TileGenerationOptions McOptions; + McOptions.hashDensity = true; + McOptions.mesherRecipe = VoxelForgeCore::MesherRecipe::marchingCubesV1; + VoxelForgeCore::TileGenerationOptions HybridOptions = McOptions; + HybridOptions.mesherRecipe = VoxelForgeCore::MesherRecipe::worldAssemblerHybridV1; + const auto McMesh = ReferenceWorld.generateTile(CliffKey, McOptions, CoreError); + const auto HybridMesh = ReferenceWorld.generateTile(CliffKey, HybridOptions, CoreError); + if (!McMesh || !HybridMesh || !McMesh->hasMeshHash || !HybridMesh->hasMeshHash) + { + OutError = FString::Printf(TEXT("Native SurfaceWorld cliff tile generation failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + bool bBitIdentical = McMesh->meshHash == HybridMesh->meshHash + && McMesh->payloadHash == HybridMesh->payloadHash + && McMesh->topologyHash == HybridMesh->topologyHash + && McMesh->vertices.size() == HybridMesh->vertices.size() + && McMesh->indices == HybridMesh->indices; + for (std::size_t I = 0; bBitIdentical && I < McMesh->vertices.size(); ++I) + { + const VoxelForgeCore::MeshVertex& A = McMesh->vertices[I]; + const VoxelForgeCore::MeshVertex& B = HybridMesh->vertices[I]; + bBitIdentical = A.position.x == B.position.x && A.position.y == B.position.y + && A.position.z == B.position.z && A.normal.x == B.normal.x + && A.normal.y == B.normal.y && A.normal.z == B.normal.z + && A.uv.x == B.uv.x && A.uv.y == B.uv.y + && A.color.r == B.color.r && A.color.g == B.color.g + && A.color.b == B.color.b && A.color.a == B.color.a; + } + if (!bBitIdentical) + { + OutError = TEXT("The production hybrid changed the sampled native SurfaceWorld cliff tile."); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[WorldAssemblerHybridNativeCliff] key=(-22,13,-58) lod=0 vertices=%llu triangles=%llu mesh_hash=0x%016llx bit_identical=1 type4_only=1"), + static_cast(McMesh->vertices.size()), + static_cast(McMesh->indices.size() / 3u), + static_cast(McMesh->meshHash)); + return true; + } + VoxelForgeCore::TileGenerationOptions Options; Options.hashDensity = true; + Options.mesherRecipe = Arguments.bCoreHybridMesher + ? VoxelForgeCore::MesherRecipe::worldAssemblerHybridV1 + : VoxelForgeCore::MesherRecipe::marchingCubesV1; std::vector AggregateRows; AggregateRows.reserve(Desired.size()); bool bFoundCarveVertex = false; @@ -14189,6 +15035,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin VoxelForgeCore::TileGenerationOptions TravelOptions; TravelOptions.hashDensity = true; + TravelOptions.mesherRecipe = Options.mesherRecipe; std::vector TravelRows; TravelRows.reserve(TravelDesired.size()); for (const VoxelForgeCore::TileKey& Key : TravelDesired) @@ -14300,7 +15147,17 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin UTF8_TO_TCHAR(CoreError.c_str())); return false; } - if ((Key.lod <= 1u || static_cast(Key.lod) == HorizonAuditLOD) + if (Arguments.bCoreHybridFixturePlacement && Key.lod == 0u + && Key.coordinate.x == 1 && Key.coordinate.y == 5) + { + ExpectedHybridFixtureVisualMesh = *Mesh; + } + // Keep the terrain geometry audit on the sampled native field, not the opt-in box-fixture tile. + // Garder l'audit de geometrie sur le champ natif echantillonne, hors de la tuile des volumes de test. + const bool bInjectedHybridFixtureTile = Arguments.bCoreHybridFixturePlacement + && Key.lod == 0u && Key.coordinate.x == 1 && Key.coordinate.y == 5; + if (!bInjectedHybridFixtureTile + && (Key.lod <= 1u || static_cast(Key.lod) == HorizonAuditLOD) && Mesh->groups.groundTriangleCount > 0u) { const int32 LOD = static_cast(Key.lod); @@ -14363,6 +15220,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin if (Mesh->groups.skirtTriangleCount > 0) ++SurfaceTravelTilesWithSkirts; } VoxelForgeCore::TileGenerationOptions NativeTimingOptions; + NativeTimingOptions.mesherRecipe = Options.mesherRecipe; for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired) { if (Key.lod > 1u) @@ -14736,6 +15594,9 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin Actor->Settings = AuthoredSettings; Actor->bManageAtmosphere = false; Actor->bUseVoxelForgeCore = true; + Actor->CoreMesherRecipe = Arguments.bCoreHybridMesher + ? EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV1 + : EVoxelForgeCoreMesherRecipe::MarchingCubesV1; Actor->CoreClipRadius = 3; Actor->CoreMaxClipLevel = 8; Actor->bCaptureVoxelForgeCoreHashesForValidation = true; @@ -14749,9 +15610,15 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin TestWorld->BeginPlay(); Actor->SetActorTickEnabled(true); UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeCoreWorldTest] world_initialized=1 world_begun=%d actor_initialized=%d actor_beginplay=%d"), + TEXT("[VoxelForgeCoreWorldTest] world_initialized=1 recipe=%s world_begun=%d actor_initialized=%d actor_beginplay=%d"), + Arguments.bCoreHybridMesher ? TEXT("WorldAssemblerHybridV1") : TEXT("MarchingCubesV1"), TestWorld->HasBegunPlay() ? 1 : 0, Actor->IsActorInitialized() ? 1 : 0, Actor->HasActorBegunPlay() ? 1 : 0); + if (Arguments.bCoreHybridMesher + && !RunHybridPluginMesherFixtures(Actor->Generator, OutError)) + { + return false; + } auto DesiredTilesLoaded = [](const AVoxelWorld* World) { @@ -15318,6 +16185,255 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin } } + if (!HybridFixtureContacts.empty()) + { + const FHybridFixtureContactProbe& FirstProbe = HybridFixtureContacts.front(); + const FVoxelTileKey FixtureTileKey(FIntVector( + FMath::FloorToInt(FirstProbe.Expected.X / (CoreVoxelSizeCm * CHUNK_SIZE)), + FMath::FloorToInt(FirstProbe.Expected.Y / (CoreVoxelSizeCm * CHUNK_SIZE)), + FMath::FloorToInt(FirstProbe.Expected.Z / (CoreVoxelSizeCm * CHUNK_SIZE))), 0); + const FVoxelForgeCoreTileHashRecord* FixtureActorHash = + Actor->VoxelForgeCoreTileHashes.Find(FixtureTileKey); + const FVoxelForgeCoreTileHashRecord* FixtureExpectedHash = + ExpectedSurfaceTravelHashes.Find(FixtureTileKey); + if (!ExpectedHybridFixtureVisualMesh || FixtureExpectedHash == nullptr || FixtureActorHash == nullptr + || !FixtureActorHash->bHasHashes + || FixtureActorHash->Mesh != FixtureExpectedHash->Mesh + || FixtureActorHash->Triangles != FixtureExpectedHash->Triangles) + { + OutError = FString::Printf( + TEXT("Hybrid fixture contact could not verify the actor render mesh against its standalone Core triangles: tile=(%d,%d,%d) visual_mesh=%d actor_hash=%d expected_hash=%d."), + FixtureTileKey.Coord.X, FixtureTileKey.Coord.Y, FixtureTileKey.Coord.Z, + ExpectedHybridFixtureVisualMesh ? 1 : 0, FixtureActorHash ? 1 : 0, + FixtureExpectedHash ? 1 : 0); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeHybridVisualMesh] tile=(%d,%d,%d) actor_hash_match=1 mesh=0x%016llx triangles=%d source=standalone_core_render_triangles"), + FixtureTileKey.Coord.X, FixtureTileKey.Coord.Y, FixtureTileKey.Coord.Z, + static_cast(FixtureActorHash->Mesh), FixtureActorHash->Triangles); + + FString HybridCollisionContactFailures; + for (const FHybridFixtureContactProbe& Probe : HybridFixtureContacts) + { + FHitResult PawnHit; + FHitResult PawnCapsuleHit; + FHitResult VisibilityHit; + FHitResult WorldStaticHit; + FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(VoxelForgeHybridFixtureContact), true); + FCollisionQueryParams CapsuleQueryParams( + SCENE_QUERY_STAT(VoxelForgeHybridFixtureCapsuleContact), false); + const bool bPawnHit = TestWorld->LineTraceSingleByChannel( + PawnHit, Probe.Start, Probe.End, ECC_Pawn, QueryParams); + const FCollisionShape PlayerCapsule = FCollisionShape::MakeCapsule(8.0f, 18.0f); + const bool bPawnCapsuleHit = TestWorld->SweepSingleByChannel( + PawnCapsuleHit, Probe.Start, Probe.End, FQuat::Identity, + ECC_Pawn, PlayerCapsule, CapsuleQueryParams); + const bool bVisibilityHit = TestWorld->LineTraceSingleByChannel( + VisibilityHit, Probe.Start, Probe.End, ECC_Visibility, QueryParams); + const bool bWorldStaticHit = TestWorld->LineTraceSingleByChannel( + WorldStaticHit, Probe.Start, Probe.End, ECC_WorldStatic, QueryParams); + URealtimeMeshComponent* FixtureComponent = nullptr; + const FVoxelTileKey ProbeTile(FIntVector( + FMath::FloorToInt(Probe.Expected.X / (CoreVoxelSizeCm * CHUNK_SIZE)), + FMath::FloorToInt(Probe.Expected.Y / (CoreVoxelSizeCm * CHUNK_SIZE)), + FMath::FloorToInt(Probe.Expected.Z / (CoreVoxelSizeCm * CHUNK_SIZE))), 0); + FixtureComponent = Actor->TileComponents.FindRef(ProbeTile); + FHitResult ObjectHit; + bool bObjectHit = false; + if (IsValid(FixtureComponent)) + { + FCollisionObjectQueryParams ObjectQueryParams; + ObjectQueryParams.AddObjectTypesToQuery(FixtureComponent->GetCollisionObjectType()); + bObjectHit = TestWorld->LineTraceSingleByObjectType( + ObjectHit, Probe.Start, Probe.End, ObjectQueryParams, QueryParams); + } + const bool bPawnLineContact = bPawnHit && PawnHit.GetActor() == Actor + && PawnHit.GetComponent() == FixtureComponent; + const bool bPawnCapsuleContact = bPawnCapsuleHit + && PawnCapsuleHit.GetActor() == Actor + && PawnCapsuleHit.GetComponent() == FixtureComponent; + const FHitResult* ContactHit = bPawnLineContact ? &PawnHit + : (bPawnCapsuleContact ? &PawnCapsuleHit : nullptr); + const double ContactErrorCm = ContactHit != nullptr + ? FVector::Dist(ContactHit->ImpactPoint, Probe.Expected) + : TNumericLimits::Max(); + FHitResult ComponentHit; + const bool bComponentHit = IsValid(FixtureComponent) + && FixtureComponent->LineTraceComponent( + ComponentHit, Probe.Start, Probe.End, QueryParams); + FBox RenderBounds(EForceInit::ForceInit); + FBox CollisionBounds(EForceInit::ForceInit); + const bool bHaveRenderCollisionBounds = GetHybridFixtureRenderAndCollisionBounds( + FixtureComponent, RenderBounds, CollisionBounds); + constexpr double BoundsToleranceCm = 0.25; + const bool bRenderCollisionBoundsMatch = bHaveRenderCollisionBounds + && FVector::Dist(RenderBounds.Min, CollisionBounds.Min) <= BoundsToleranceCm + && FVector::Dist(RenderBounds.Max, CollisionBounds.Max) <= BoundsToleranceCm; + const double VisualContactErrorCm = ContactHit != nullptr && bComponentHit + ? FVector::Dist(ContactHit->ImpactPoint, ComponentHit.ImpactPoint) + : TNumericLimits::Max(); + FVector VisualImpact = bComponentHit ? ComponentHit.ImpactPoint : FVector::ZeroVector; + const bool bVisualHit = ContactHit != nullptr && bComponentHit + && bRenderCollisionBoundsMatch; + // Le trace direct du composant et le BodySetup cuisine doivent suivre la meme section RMC. + // The direct component trace and cooked BodySetup must agree with the same RMC render section. + constexpr double AnalyticContactToleranceCm = CoreVoxelSizeCm; + constexpr double ComponentTraceToleranceCm = 2.0; + if (ContactHit == nullptr || ContactErrorCm > AnalyticContactToleranceCm + || !bVisualHit || VisualContactErrorCm > ComponentTraceToleranceCm) + { + const FTransform ComponentTransform = IsValid(FixtureComponent) + ? FixtureComponent->GetComponentTransform() : FTransform::Identity; + const FVector TransformedStart = ComponentTransform.TransformPosition(Probe.Start); + const FVector TransformedEnd = ComponentTransform.TransformPosition(Probe.End); + const FVector TransformedExpected = ComponentTransform.TransformPosition(Probe.Expected); + FHitResult TransformedWorldHit; + const bool bTransformedWorldHit = IsValid(FixtureComponent) + && TestWorld->LineTraceSingleByChannel( + TransformedWorldHit, TransformedStart, TransformedEnd, ECC_Pawn, QueryParams); + float DensityStart = TNumericLimits::Max(); + float DensityExpected = TNumericLimits::Max(); + float DensityEnd = TNumericLimits::Max(); + const bool bCoreDensityStart = Actor->QueryVoxelForgeCoreDensityAt( + Probe.Start, DensityStart); + const bool bCoreDensityExpected = Actor->QueryVoxelForgeCoreDensityAt( + Probe.Expected, DensityExpected); + const bool bCoreDensityEnd = Actor->QueryVoxelForgeCoreDensityAt( + Probe.End, DensityEnd); + const FVector ComponentLocation = IsValid(FixtureComponent) + ? FixtureComponent->GetComponentLocation() : FVector::ZeroVector; + const FVector ComponentOrigin = IsValid(FixtureComponent) + ? FixtureComponent->Bounds.Origin : FVector::ZeroVector; + const FVector ComponentExtent = IsValid(FixtureComponent) + ? FixtureComponent->Bounds.BoxExtent : FVector::ZeroVector; + const URealtimeMesh* FixtureMesh = IsValid(FixtureComponent) + ? FixtureComponent->GetRealtimeMesh() : nullptr; + const FBoxSphereBounds MeshLocalBounds = FixtureMesh != nullptr + ? FixtureMesh->GetLocalBounds() + : FBoxSphereBounds(FSphere(FVector::ZeroVector, 0.0f)); + const UBodySetup* BodySetup = IsValid(FixtureComponent) + ? FixtureComponent->GetBodySetup() : nullptr; + FVector BodyMeshMinimum(TNumericLimits::Max()); + FVector BodyMeshMaximum(-TNumericLimits::Max()); + int32 BodyMeshCount = 0; + if (BodySetup != nullptr) + { + BodyMeshCount = BodySetup->TriMeshGeometries.Num(); + for (const Chaos::FTriangleMeshImplicitObjectPtr& BodyMesh : BodySetup->TriMeshGeometries) + { + if (!BodyMesh.IsValid()) continue; + const Chaos::FAABB3& BodyBounds = BodyMesh->BoundingBox(); + const FVector BodyMinimum( + BodyBounds.Min()[0], BodyBounds.Min()[1], BodyBounds.Min()[2]); + const FVector BodyMaximum( + BodyBounds.Max()[0], BodyBounds.Max()[1], BodyBounds.Max()[2]); + BodyMeshMinimum.X = FMath::Min(BodyMeshMinimum.X, BodyMinimum.X); + BodyMeshMinimum.Y = FMath::Min(BodyMeshMinimum.Y, BodyMinimum.Y); + BodyMeshMinimum.Z = FMath::Min(BodyMeshMinimum.Z, BodyMinimum.Z); + BodyMeshMaximum.X = FMath::Max(BodyMeshMaximum.X, BodyMaximum.X); + BodyMeshMaximum.Y = FMath::Max(BodyMeshMaximum.Y, BodyMaximum.Y); + BodyMeshMaximum.Z = FMath::Max(BodyMeshMaximum.Z, BodyMaximum.Z); + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeHybridCollisionDiagnostic] fixture=%s tile=(%d,%d,%d) " + "ready=%d not_required=%d component=%d body=%d physics_state=%d " + "collision_enabled=%d pawn_response=%d visibility_response=%d object_type=%d " + "pawn_hit=%d pawn_capsule_hit=%d capsule_actor_match=%d capsule_component_match=%d component_trace_hit=%d component_trace_error_cm=%.3f bounds_match=%d " + "visibility_hit=%d world_static_hit=%d object_hit=%d component_hit=%d " + "transformed_world_hit=%d " + "component_location=(%.2f,%.2f,%.2f) bounds_origin=(%.2f,%.2f,%.2f) " + "component_rotation=(%.2f,%.2f,%.2f) component_scale=(%.4f,%.4f,%.4f) " + "transformed_expected=(%.2f,%.2f,%.2f) " + "bounds_extent=(%.2f,%.2f,%.2f) mesh_local_origin=(%.2f,%.2f,%.2f) " + "mesh_local_extent=(%.2f,%.2f,%.2f) body_trace_flag=%d double_sided=%d " + "body_created=%d body_failed=%d body_tri_meshes=%d " + "body_min=(%.2f,%.2f,%.2f) body_max=(%.2f,%.2f,%.2f) " + "core_density_valid=%d/%d/%d " + "core_density_start=%.4f core_density_expected=%.4f core_density_end=%.4f"), + *Probe.Name, ProbeTile.Coord.X, ProbeTile.Coord.Y, ProbeTile.Coord.Z, + Actor->CollisionReadyTiles.Contains(ProbeTile) ? 1 : 0, + Actor->CollisionNotRequiredTiles.Contains(ProbeTile) ? 1 : 0, + IsValid(FixtureComponent) ? 1 : 0, + IsValid(FixtureComponent) && FixtureComponent->GetBodySetup() != nullptr ? 1 : 0, + IsValid(FixtureComponent) && FixtureComponent->IsPhysicsStateCreated() ? 1 : 0, + IsValid(FixtureComponent) + ? static_cast(FixtureComponent->GetCollisionEnabled()) : -1, + IsValid(FixtureComponent) + ? static_cast(FixtureComponent->GetCollisionResponseToChannel(ECC_Pawn)) : -1, + IsValid(FixtureComponent) + ? static_cast(FixtureComponent->GetCollisionResponseToChannel(ECC_Visibility)) : -1, + IsValid(FixtureComponent) + ? static_cast(FixtureComponent->GetCollisionObjectType()) : -1, + bPawnHit ? 1 : 0, bPawnCapsuleHit ? 1 : 0, + bPawnCapsuleHit && PawnCapsuleHit.GetActor() == Actor ? 1 : 0, + bPawnCapsuleHit && PawnCapsuleHit.GetComponent() == FixtureComponent ? 1 : 0, + bComponentHit ? 1 : 0, VisualContactErrorCm, + bRenderCollisionBoundsMatch ? 1 : 0, + bVisibilityHit ? 1 : 0, + bWorldStaticHit ? 1 : 0, bObjectHit ? 1 : 0, + bComponentHit ? 1 : 0, bTransformedWorldHit ? 1 : 0, + ComponentLocation.X, ComponentLocation.Y, ComponentLocation.Z, + ComponentOrigin.X, ComponentOrigin.Y, ComponentOrigin.Z, + ComponentTransform.Rotator().Pitch, ComponentTransform.Rotator().Yaw, + ComponentTransform.Rotator().Roll, + ComponentTransform.GetScale3D().X, ComponentTransform.GetScale3D().Y, + ComponentTransform.GetScale3D().Z, + TransformedExpected.X, TransformedExpected.Y, TransformedExpected.Z, + ComponentExtent.X, ComponentExtent.Y, ComponentExtent.Z, + MeshLocalBounds.Origin.X, MeshLocalBounds.Origin.Y, MeshLocalBounds.Origin.Z, + MeshLocalBounds.BoxExtent.X, MeshLocalBounds.BoxExtent.Y, MeshLocalBounds.BoxExtent.Z, + BodySetup != nullptr ? static_cast(BodySetup->CollisionTraceFlag) : -1, + BodySetup != nullptr && BodySetup->bDoubleSidedGeometry ? 1 : 0, + BodySetup != nullptr && BodySetup->bCreatedPhysicsMeshes ? 1 : 0, + BodySetup != nullptr && BodySetup->bFailedToCreatePhysicsMeshes ? 1 : 0, + BodyMeshCount, + BodyMeshCount > 0 ? BodyMeshMinimum.X : 0.0, + BodyMeshCount > 0 ? BodyMeshMinimum.Y : 0.0, + BodyMeshCount > 0 ? BodyMeshMinimum.Z : 0.0, + BodyMeshCount > 0 ? BodyMeshMaximum.X : 0.0, + BodyMeshCount > 0 ? BodyMeshMaximum.Y : 0.0, + BodyMeshCount > 0 ? BodyMeshMaximum.Z : 0.0, + bCoreDensityStart ? 1 : 0, + bCoreDensityExpected ? 1 : 0, + bCoreDensityEnd ? 1 : 0, + DensityStart, DensityExpected, DensityEnd); + HybridCollisionContactFailures += FString::Printf( + TEXT("Hybrid cooked Pawn collision contact failed for %s: pawn_hit=%d pawn_capsule_hit=%d capsule_actor_match=%d capsule_component_match=%d component_trace_hit=%d component_trace_error_cm=%.3f bounds_match=%d field_contact_error_cm=%.3f visibility_hit=%d world_static_hit=%d expected=(%.2f,%.2f,%.2f) pawn_impact=(%.2f,%.2f,%.2f) capsule_impact=(%.2f,%.2f,%.2f)."), + *Probe.Name, bPawnHit ? 1 : 0, bPawnCapsuleHit ? 1 : 0, + bPawnCapsuleHit && PawnCapsuleHit.GetActor() == Actor ? 1 : 0, + bPawnCapsuleHit && PawnCapsuleHit.GetComponent() == FixtureComponent ? 1 : 0, + bComponentHit ? 1 : 0, VisualContactErrorCm, + bRenderCollisionBoundsMatch ? 1 : 0, ContactErrorCm, + bVisibilityHit ? 1 : 0, bWorldStaticHit ? 1 : 0, + Probe.Expected.X, Probe.Expected.Y, Probe.Expected.Z, + bPawnHit ? PawnHit.ImpactPoint.X : 0.0, + bPawnHit ? PawnHit.ImpactPoint.Y : 0.0, + bPawnHit ? PawnHit.ImpactPoint.Z : 0.0, + bPawnCapsuleHit ? PawnCapsuleHit.ImpactPoint.X : 0.0, + bPawnCapsuleHit ? PawnCapsuleHit.ImpactPoint.Y : 0.0, + bPawnCapsuleHit ? PawnCapsuleHit.ImpactPoint.Z : 0.0); + HybridCollisionContactFailures += TEXT("\n"); + continue; + } + const TCHAR* ContactMethod = bPawnLineContact ? TEXT("PawnLine") : TEXT("PawnCapsule"); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeHybridCollisionContact] fixture=%s method=%s channel=Pawn hit=1 actor_match=1 component_match=1 actor_core_hash_match=1 render_collision_bounds_match=1 component_trace_error_cm=%.3f field_contact_error_cm=%.3f impact=(%.2f,%.2f,%.2f) component_impact=(%.2f,%.2f,%.2f)"), + *Probe.Name, ContactMethod, VisualContactErrorCm, ContactErrorCm, + ContactHit->ImpactPoint.X, ContactHit->ImpactPoint.Y, ContactHit->ImpactPoint.Z, + VisualImpact.X, VisualImpact.Y, VisualImpact.Z); + } + if (!HybridCollisionContactFailures.IsEmpty()) + { + OutError = HybridCollisionContactFailures; + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeHybridCollisionContact] pass=1 probes=%d cooked_ready_tiles=%d"), + static_cast(HybridFixtureContacts.size()), ActualSurfaceCollisionReady); + } + TSet ActualSurfaceVisualDesiredSet; std::vector ActorSurfaceTravelDesired; ActorSurfaceTravelDesired.reserve(static_cast(Actor->DesiredSorted.Num())); diff --git a/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs b/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs index 79b6e22..47e1851 100644 --- a/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs +++ b/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs @@ -23,6 +23,7 @@ public class VoxelForgeEditor : ModuleRules { "Json", "ImageWrapper", + "RealtimeMeshComponent", "VoxelForge", "VoxelForgeCore", }); diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index f456d53..27b8237 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -62,6 +62,23 @@ namespace VoxelForgeCore std::uint32_t skirtTriangleCount = 0; }; + // Versioned render-mesher choices. marchingCubesV1 preserves the production Core path; + // worldAssemblerHybridV1 applies feature-aware DC only to tagged type-2 plan boxes at LOD0. + // Choix versionnes du mailleur. marchingCubesV1 conserve la voie Core actuelle; + // worldAssemblerHybridV1 applique le DC aux boites type 2 balisees, uniquement en LOD0. + enum class MesherRecipe : std::uint8_t + { + marchingCubesV1 = 0, + worldAssemblerHybridV1 = 1 + }; + + struct SharpFeatureBox + { + std::uint64_t stableId = 0; + std::array minimumSubvoxels{}; + std::array maximumSubvoxels{}; + }; + enum class TileClass : std::uint8_t { allSolid, allAir, mixed, unknown }; struct TileGenerationStats @@ -657,6 +674,7 @@ namespace VoxelForgeCore struct TileGenerationOptions { + MesherRecipe mesherRecipe = MesherRecipe::marchingCubesV1; std::int32_t bandMinZVoxel = -2147483647 - 1; std::int32_t bandMaxZVoxel = 2147483647; bool hashDensity = false; diff --git a/Standalone/Core/src/VoxelForgeCore.cpp b/Standalone/Core/src/VoxelForgeCore.cpp index 4e0591b..f4186d1 100644 --- a/Standalone/Core/src/VoxelForgeCore.cpp +++ b/Standalone/Core/src/VoxelForgeCore.cpp @@ -890,11 +890,10 @@ namespace VoxelForgeCore if(const auto found=impl_->tileRegions.find(key);found!=impl_->tileRegions.end()) page=found->second; } - const FStandaloneShapeWorld& tileShape=page?*page:*impl_->shape; + const FStandaloneShapeWorld* tileShapeContext=page?page.get():impl_->shape.get(); 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; @@ -904,6 +903,54 @@ namespace VoxelForgeCore const int32 level=key.lod,extent=CHUNK_SIZE< sharpFeatures; + if(options.mesherRecipe==MesherRecipe::worldAssemblerHybridV1&&level==0&&impl_->worldAssembly) + { + constexpr std::int64_t SubvoxelsPerVoxel=WorldAssemblySubvoxelsPerVoxel; + const std::array tileMin={ + static_cast(origin.X)*SubvoxelsPerVoxel, + static_cast(origin.Y)*SubvoxelsPerVoxel, + static_cast(origin.Z)*SubvoxelsPerVoxel}; + const std::array tileMax={ + static_cast(origin.X+extent)*SubvoxelsPerVoxel, + static_cast(origin.Y+extent)*SubvoxelsPerVoxel, + static_cast(origin.Z+extent)*SubvoxelsPerVoxel}; + for(const auto& stratum:impl_->worldAssembly->plan.strates) + for(const auto& feature:stratum.features) + { + // Type 2 is the plan's finite, hard box-solid exception; the type-4 shelf is + // smooth-min blended and therefore remains on MC until it carries sharp planes. + // Le type 2 est une boite solide nette; l'etagere type 4 reste MC car elle est lisse. + if(feature.type!=2)continue; + bool overlaps=true; + for(std::size_t axis=0;axis<3;++axis) + overlaps=overlaps&&feature.minimumSubvoxels[axis]<=tileMax[axis] + &&feature.maximumSubvoxels[axis]>=tileMin[axis]; + if(!overlaps)continue; + FVoxelHybridSharpFeatureBox box; + box.StableId=feature.stableId; + for(std::size_t axis=0;axis<3;++axis) + { + box.MinimumSubvoxels[axis]=feature.minimumSubvoxels[axis]; + box.MaximumSubvoxels[axis]=feature.maximumSubvoxels[axis]; + } + sharpFeatures.Add(box); + } + if(!sharpFeatures.IsEmpty()) + { + // Streamed analytic pages omit the native assembly plan. Use the immutable base + // shape only for tagged tiles so their mesher samples the same field as the tags. + // Les pages analytiques omettent le plan natif ; utiliser la base uniquement pour + // les tuiles balisees afin d'echantillonner le champ correspondant aux tags. + tileShapeContext=impl_->shape.get(); + mesher.SetHybridSharpFeatures(MoveTemp(sharpFeatures)); + } + } + const FStandaloneShapeWorld& tileShape=*tileShapeContext; + mesher.SetStandaloneShapeWorld(&tileShape); int32 cells=level +#include +#include +#include +#include +#include +#include +#include +#include + +// Feature-aware dual-contouring support for the opt-in Core recipe. +// Support du dual contouring sensible aux fonctions pour la recette Core optionnelle. +namespace VoxelHybridSharpMesher +{ + constexpr int32 EdgeCount = 12; + constexpr int32 CornerCount = 8; + constexpr double SubvoxelsPerVoxel = 256.0; + + constexpr int32 CornerOffsets[CornerCount][3] = { + {0,0,0}, {1,0,0}, {1,1,0}, {0,1,0}, + {0,0,1}, {1,0,1}, {1,1,1}, {0,1,1} + }; + constexpr int32 EdgeCorners[EdgeCount][2] = { + {0,1}, {1,2}, {2,3}, {3,0}, {4,5}, {5,6}, + {6,7}, {7,4}, {0,4}, {1,5}, {2,6}, {3,7} + }; + + inline double AxisValue(const FVector& V, int32 Axis) + { + return Axis == 0 ? V.X : (Axis == 1 ? V.Y : V.Z); + } + + inline void SetAxisValue(FVector& V, int32 Axis, double Value) + { + if (Axis == 0) V.X = Value; + else if (Axis == 1) V.Y = Value; + else V.Z = Value; + } + + struct FPlane + { + uint64 Id = 0; + FVector Normal = FVector::UpVector; + }; + + struct FRoot + { + bool bActive = false; + FVector Position = FVector::ZeroVector; + FVector Gradient = FVector::UpVector; + std::vector Planes; + uint64 FeatureId = 0; + }; + + struct FTriangle + { + FVector Positions[3]{}; + FVector Normals[3]{}; + uint64 SplitKeys[3]{}; + }; + + struct FResult + { + std::vector CoreCellDCEdges; + std::vector Triangles; + uint32 SharpCells = 0; + uint32 DualCells = 0; + uint32 BorderFallbackPatches = 0; + + bool UsesDC(int32 X, int32 Y, int32 Z, int32 Cells) const + { + if (X < 0 || Y < 0 || Z < 0 || X >= Cells || Y >= Cells || Z >= Cells) + return false; + const std::size_t Cell = (static_cast(Z) * Cells + Y) * Cells + X; + const std::size_t Offset = Cell * EdgeCount; + for (int32 Edge = 0; Edge < EdgeCount && Offset + Edge < CoreCellDCEdges.size(); ++Edge) + if (CoreCellDCEdges[Offset + Edge] != 0) return true; + return false; + } + + bool UsesDCEdge(int32 X, int32 Y, int32 Z, int32 Edge, int32 Cells) const + { + if (X < 0 || Y < 0 || Z < 0 || X >= Cells || Y >= Cells || Z >= Cells) + return false; + const std::size_t Cell = (static_cast(Z) * Cells + Y) * Cells + X; + const std::size_t Offset = Cell * EdgeCount + static_cast(Edge); + return Offset < CoreCellDCEdges.size() && (CoreCellDCEdges[Offset] & 1u) != 0; + } + }; + + struct FCell + { + uint8 Case = 0; + uint8 ComponentCount = 0; + std::array EdgeComponent{}; + std::array ComponentSharp{}; + std::array ComponentUseDC{}; + std::array Dual{}; + + FCell() { EdgeComponent.fill(0xff); } + }; + + inline int32 FindLocal(std::array& Parent, int32 Value) + { + while (Parent[Value] != Value) + { + Parent[Value] = Parent[Parent[Value]]; + Value = Parent[Value]; + } + return Value; + } + + inline void UniteLocal(std::array& Parent, int32 A, int32 B) + { + A = FindLocal(Parent, A); + B = FindLocal(Parent, B); + if (A == B) return; + if (A > B) std::swap(A, B); + Parent[B] = static_cast(A); + } + + inline int32 FindLocalEdge(int32 CellX, int32 CellY, int32 CellZ, + int32 Axis, int32 EdgeX, int32 EdgeY, int32 EdgeZ) + { + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + const int32 A = EdgeCorners[Edge][0]; + const int32 B = EdgeCorners[Edge][1]; + const int32 AX = CornerOffsets[A][0], AY = CornerOffsets[A][1], AZ = CornerOffsets[A][2]; + const int32 BX = CornerOffsets[B][0], BY = CornerOffsets[B][1], BZ = CornerOffsets[B][2]; + const int32 LocalAxis = AX != BX ? 0 : AY != BY ? 1 : 2; + if (LocalAxis == Axis + && CellX + FMath::Min(AX, BX) == EdgeX + && CellY + FMath::Min(AY, BY) == EdgeY + && CellZ + FMath::Min(AZ, BZ) == EdgeZ) + return Edge; + } + return INDEX_NONE; + } + + inline FVector SolveQEF(const std::vector>& Constraints, + const FVector& Fallback, const FVector& Low, const FVector& High) + { + if (Constraints.empty()) return Fallback; + double A[3][4]{}; + constexpr double Lambda = 1.0e-10; + const double Center[3] = {Fallback.X, Fallback.Y, Fallback.Z}; + for (int32 I = 0; I < 3; ++I) { A[I][I] = Lambda; A[I][3] = Lambda * Center[I]; } + for (const auto& Constraint : Constraints) + { + FVector N = Constraint.second.GetSafeNormal(); + const double B = FVector::DotProduct(N, Constraint.first); + const double NV[3] = {N.X, N.Y, N.Z}; + for (int32 R = 0; R < 3; ++R) + { + for (int32 C = 0; C < 3; ++C) A[R][C] += NV[R] * NV[C]; + A[R][3] += NV[R] * B; + } + } + for (int32 Column = 0; Column < 3; ++Column) + { + int32 Pivot = Column; + for (int32 Row = Column + 1; Row < 3; ++Row) + if (FMath::Abs(A[Row][Column]) > FMath::Abs(A[Pivot][Column])) Pivot = Row; + if (FMath::Abs(A[Pivot][Column]) < 1.0e-14) return Fallback; + if (Pivot != Column) + for (int32 K = Column; K < 4; ++K) std::swap(A[Pivot][K], A[Column][K]); + const double Divisor = A[Column][Column]; + for (int32 K = Column; K < 4; ++K) A[Column][K] /= Divisor; + for (int32 Row = 0; Row < 3; ++Row) + { + if (Row == Column) continue; + const double Factor = A[Row][Column]; + for (int32 K = Column; K < 4; ++K) A[Row][K] -= Factor * A[Column][K]; + } + } + return FVector(FMath::Clamp(A[0][3], Low.X, High.X), + FMath::Clamp(A[1][3], Low.Y, High.Y), + FMath::Clamp(A[2][3], Low.Z, High.Z)); + } + + inline double BoxSDF(const FVoxelHybridSharpFeatureBox& Box, const FVector& P, + std::vector& OutPlanes) + { + FVector Low, High; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + SetAxisValue(Low, Axis, + static_cast(Box.MinimumSubvoxels[Axis]) / SubvoxelsPerVoxel); + SetAxisValue(High, Axis, + static_cast(Box.MaximumSubvoxels[Axis]) / SubvoxelsPerVoxel); + } + const FVector Center = (Low + High) * 0.5; + const FVector Half = (High - Low) * 0.5; + const FVector Delta = P - Center; + const FVector Q(FMath::Abs(Delta.X) - Half.X, + FMath::Abs(Delta.Y) - Half.Y, + FMath::Abs(Delta.Z) - Half.Z); + const double MaxQ = FMath::Max(Q.X, FMath::Max(Q.Y, Q.Z)); + const FVector Outside(FMath::Max(Q.X, 0.0), FMath::Max(Q.Y, 0.0), FMath::Max(Q.Z, 0.0)); + const double OutsideLength = Outside.Size(); + const double SDF = OutsideLength + FMath::Min(MaxQ, 0.0); + const double Tolerance = 1.0e-4; + if (FMath::Abs(MaxQ) <= Tolerance) + { + for (int32 Axis = 0; Axis < 3; ++Axis) + { + if (FMath::Abs(AxisValue(Q, Axis) - MaxQ) > Tolerance) continue; + FVector Normal = FVector::ZeroVector; + const bool bNegativeFace = AxisValue(Delta, Axis) < 0.0; + SetAxisValue(Normal, Axis, bNegativeFace ? -1.0 : 1.0); + uint64 FaceId = Box.StableId ^ (0x9e3779b97f4a7c15ull + + static_cast(Axis * 2 + (bNegativeFace ? 0 : 1) + 1)); + if (FaceId == 0) FaceId = static_cast(Axis + 1); + OutPlanes.push_back({FaceId, Normal}); + } + } + return SDF; + } + + inline uint64 SelectFeature(const FRoot& Root, int32 Axis) + { + if (Root.Planes.empty()) return 0; + std::size_t Selected = 0; + const auto Alignment = [Axis](const FVector& Normal) + { return FMath::Abs(AxisValue(Normal, Axis)); }; + for (std::size_t I = 1; I < Root.Planes.size(); ++I) + { + const double Candidate = Alignment(Root.Planes[I].Normal); + const double Current = Alignment(Root.Planes[Selected].Normal); + if (Candidate > Current + 1.0e-12 + || (FMath::Abs(Candidate - Current) <= 1.0e-12 + && Root.Planes[I].Id < Root.Planes[Selected].Id)) Selected = I; + } + return Root.Planes[Selected].Id; + } + + inline FVector NormalForFeature(const FRoot& Root, uint64 Feature) + { + for (const FPlane& Plane : Root.Planes) if (Plane.Id == Feature) return Plane.Normal; + return Root.Gradient; + } + + inline FResult Generate(int32 Cells, int32 Step, float IsoLevel, float GeometryVoxelSize, + const FIntVector& OriginVoxels, const FVector& ChunkWorldPos, + const TArray& Features, + const std::function& Sample, + const std::function& Gradient) + { + FResult Result; + if (Cells <= 0 || Features.IsEmpty()) return Result; + const int32 CellDim = Cells + 2; + const int32 NodeDim = Cells + 3; + const std::size_t CellCount = static_cast(CellDim) * CellDim * CellDim; + std::vector CellData(CellCount); + const auto CellIndex = [CellDim](int32 X, int32 Y, int32 Z) + { return (static_cast(Z + 1) * CellDim + (Y + 1)) * CellDim + (X + 1); }; + const auto NodePosition = [&](int32 X, int32 Y, int32 Z) + { return FVector(X * Step * GeometryVoxelSize, Y * Step * GeometryVoxelSize, + Z * Step * GeometryVoxelSize) + ChunkWorldPos; }; + + std::array, 3> Roots; + Roots[0].resize(static_cast(CellDim) * NodeDim * NodeDim); + Roots[1].resize(static_cast(NodeDim) * CellDim * NodeDim); + Roots[2].resize(static_cast(NodeDim) * NodeDim * CellDim); + const auto RootIndex = [CellDim, NodeDim](int32 Axis, int32 X, int32 Y, int32 Z) + { + const std::size_t NX = static_cast(X + 1); + const std::size_t NY = static_cast(Y + 1); + const std::size_t NZ = static_cast(Z + 1); + if (Axis == 0) return (NZ * NodeDim + NY) * CellDim + NX; + if (Axis == 1) return (NZ * CellDim + NY) * NodeDim + NX; + return (NZ * NodeDim + NY) * NodeDim + NX; + }; + + for (int32 Z = -1; Z <= Cells + 1; ++Z) + for (int32 Y = -1; Y <= Cells + 1; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + const float D0 = Sample(X, Y, Z), D1 = Sample(X + 1, Y, Z); + if ((D0 < IsoLevel) == (D1 < IsoLevel)) continue; + const double T = FMath::Clamp(static_cast(IsoLevel - D0) / (D1 - D0), 0.0, 1.0); + FRoot& Root = Roots[0][RootIndex(0, X, Y, Z)]; + Root.bActive = true; + Root.Position = FMath::Lerp(NodePosition(X, Y, Z), NodePosition(X + 1, Y, Z), T); + Root.Gradient = FMath::Lerp(Gradient(X, Y, Z), Gradient(X + 1, Y, Z), T).GetSafeNormal(); + } + for (int32 Z = -1; Z <= Cells + 1; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells + 1; ++X) + { + const float D0 = Sample(X, Y, Z), D1 = Sample(X, Y + 1, Z); + if ((D0 < IsoLevel) == (D1 < IsoLevel)) continue; + const double T = FMath::Clamp(static_cast(IsoLevel - D0) / (D1 - D0), 0.0, 1.0); + FRoot& Root = Roots[1][RootIndex(1, X, Y, Z)]; + Root.bActive = true; + Root.Position = FMath::Lerp(NodePosition(X, Y, Z), NodePosition(X, Y + 1, Z), T); + Root.Gradient = FMath::Lerp(Gradient(X, Y, Z), Gradient(X, Y + 1, Z), T).GetSafeNormal(); + } + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells + 1; ++Y) + for (int32 X = -1; X <= Cells + 1; ++X) + { + const float D0 = Sample(X, Y, Z), D1 = Sample(X, Y, Z + 1); + if ((D0 < IsoLevel) == (D1 < IsoLevel)) continue; + const double T = FMath::Clamp(static_cast(IsoLevel - D0) / (D1 - D0), 0.0, 1.0); + FRoot& Root = Roots[2][RootIndex(2, X, Y, Z)]; + Root.bActive = true; + Root.Position = FMath::Lerp(NodePosition(X, Y, Z), NodePosition(X, Y, Z + 1), T); + Root.Gradient = FMath::Lerp(Gradient(X, Y, Z), Gradient(X, Y, Z + 1), T).GetSafeNormal(); + } + + const auto GetRoot = [&](int32 Axis, int32 X, int32 Y, int32 Z) -> FRoot& + { return Roots[Axis][RootIndex(Axis, X, Y, Z)]; }; + const auto GetCellEdgeRoot = [&](int32 X, int32 Y, int32 Z, int32 Edge) -> FRoot& + { + const int32 A = EdgeCorners[Edge][0], B = EdgeCorners[Edge][1]; + const int32 AX = CornerOffsets[A][0], AY = CornerOffsets[A][1], AZ = CornerOffsets[A][2]; + const int32 BX = CornerOffsets[B][0], BY = CornerOffsets[B][1], BZ = CornerOffsets[B][2]; + const int32 Axis = AX != BX ? 0 : AY != BY ? 1 : 2; + return GetRoot(Axis, X + FMath::Min(AX, BX), Y + FMath::Min(AY, BY), Z + FMath::Min(AZ, BZ)); + }; + + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + FCell& Cell = CellData[CellIndex(X, Y, Z)]; + float Densities[CornerCount]{}; + for (int32 Corner = 0; Corner < CornerCount; ++Corner) + { + const int32 GX = X + CornerOffsets[Corner][0]; + const int32 GY = Y + CornerOffsets[Corner][1]; + const int32 GZ = Z + CornerOffsets[Corner][2]; + Densities[Corner] = Sample(GX, GY, GZ); + if (Densities[Corner] >= IsoLevel) Cell.Case |= static_cast(1u << Corner); + } + if (Cell.Case == 0 || Cell.Case == 255 || MCEdgeTable[Cell.Case] == 0) continue; + std::array Parent{}; + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) Parent[Edge] = static_cast(Edge); + for (int32 I = 0; I < 16 && MCTriTable[Cell.Case][I] >= 0; I += 3) + { + const int32 A = MCTriTable[Cell.Case][I]; + const int32 B = MCTriTable[Cell.Case][I + 1]; + const int32 C = MCTriTable[Cell.Case][I + 2]; + UniteLocal(Parent, A, B); UniteLocal(Parent, B, C); + } + std::array RootToComponent{}; + RootToComponent.fill(0xff); + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + const int32 A = EdgeCorners[Edge][0], B = EdgeCorners[Edge][1]; + if ((Densities[A] < IsoLevel) == (Densities[B] < IsoLevel)) continue; + FRoot& Root = GetCellEdgeRoot(X, Y, Z, Edge); + if (!Root.bActive) continue; + const int32 LocalRoot = FindLocal(Parent, Edge); + if (RootToComponent[LocalRoot] == 0xff) + RootToComponent[LocalRoot] = Cell.ComponentCount++; + const uint8 Component = RootToComponent[LocalRoot]; + Cell.EdgeComponent[Edge] = Component; + + const int32 OA = EdgeCorners[Edge][0]; + const int32 OB = EdgeCorners[Edge][1]; + const int32 AX = X + CornerOffsets[OA][0], AY = Y + CornerOffsets[OA][1], AZ = Z + CornerOffsets[OA][2]; + const int32 BX = X + CornerOffsets[OB][0], BY = Y + CornerOffsets[OB][1], BZ = Z + CornerOffsets[OB][2]; + const FVector RootVoxel = FVector(OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z) + + FVector(AX, AY, AZ) * Step + + FVector(BX - AX, BY - AY, BZ - AZ) * (Step * + FMath::Clamp(static_cast(IsoLevel - Densities[OA]) + / (Densities[OB] - Densities[OA]), 0.0, 1.0)); + for (const FVoxelHybridSharpFeatureBox& Feature : Features) + { + std::vector CandidatePlanes; + const double SDF = BoxSDF(Feature, RootVoxel, CandidatePlanes); + if (FMath::Abs(SDF) > 1.0e-4 || CandidatePlanes.empty()) continue; + for (const FPlane& Plane : CandidatePlanes) Root.Planes.push_back(Plane); + } + if (!Root.Planes.empty()) + { + std::sort(Root.Planes.begin(), Root.Planes.end(), + [](const FPlane& A, const FPlane& B) { return A.Id < B.Id; }); + Root.Planes.erase(std::unique(Root.Planes.begin(), Root.Planes.end(), + [](const FPlane& A, const FPlane& B) { return A.Id == B.Id; }), Root.Planes.end()); + Cell.ComponentSharp[Component] = 1; + if (X >= 0 && Y >= 0 && Z >= 0 && X < Cells && Y < Cells && Z < Cells) + ++Result.SharpCells; + } + } + } + + const std::size_t PatchCount = CellCount * EdgeCount; + std::vector PatchParent(PatchCount); + std::vector PatchRank(PatchCount, 0), PatchSharp(PatchCount, 0), PatchTouchesBorder(PatchCount, 0); + std::iota(PatchParent.begin(), PatchParent.end(), 0u); + const auto FindPatch = [&](uint32 Value, auto&& Self) -> uint32 + { + if (PatchParent[Value] != Value) PatchParent[Value] = Self(PatchParent[Value], Self); + return PatchParent[Value]; + }; + const auto UnitePatch = [&](uint32 A, uint32 B) + { + uint32 RA = FindPatch(A, FindPatch), RB = FindPatch(B, FindPatch); + if (RA == RB) return; + if (PatchRank[RA] < PatchRank[RB]) std::swap(RA, RB); + PatchParent[RB] = RA; + PatchSharp[RA] = static_cast(PatchSharp[RA] | PatchSharp[RB]); + PatchTouchesBorder[RA] = static_cast(PatchTouchesBorder[RA] | PatchTouchesBorder[RB]); + if (PatchRank[RA] == PatchRank[RB]) ++PatchRank[RA]; + }; + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + const FCell& Cell = CellData[CellIndex(X, Y, Z)]; + for (uint8 Component = 0; Component < Cell.ComponentCount; ++Component) + { + const uint32 Id = static_cast(CellIndex(X, Y, Z) * EdgeCount + Component); + PatchSharp[Id] = Cell.ComponentSharp[Component]; + const bool bCoreBorder = X >= 0 && Y >= 0 && Z >= 0 && X < Cells && Y < Cells && Z < Cells + && (X == 0 || Y == 0 || Z == 0 || X == Cells - 1 || Y == Cells - 1 || Z == Cells - 1); + PatchTouchesBorder[Id] = bCoreBorder ? 1 : 0; + } + } + + for (int32 Axis = 0; Axis < 3; ++Axis) + for (int32 Z = -1; Z <= Cells + (Axis == 2 ? 0 : 1); ++Z) + for (int32 Y = -1; Y <= Cells + (Axis == 1 ? 0 : 1); ++Y) + for (int32 X = -1; X <= Cells + (Axis == 0 ? 0 : 1); ++X) + { + if (!GetRoot(Axis, X, Y, Z).bActive) continue; + std::array, 4> Around{}; + if (Axis == 0) Around = {{{X,Y-1,Z-1},{X,Y,Z-1},{X,Y,Z},{X,Y-1,Z}}}; + else if (Axis == 1) Around = {{{X-1,Y,Z-1},{X-1,Y,Z},{X,Y,Z},{X,Y,Z-1}}}; + else Around = {{{X-1,Y-1,Z},{X,Y-1,Z},{X,Y,Z},{X-1,Y,Z}}}; + std::array Ids{}; + bool bValid = true; + for (int32 I = 0; I < 4; ++I) + { + const int32 CX = Around[I][0], CY = Around[I][1], CZ = Around[I][2]; + if (CX < -1 || CY < -1 || CZ < -1 || CX > Cells || CY > Cells || CZ > Cells) + { bValid = false; break; } + const int32 LocalEdge = FindLocalEdge(CX, CY, CZ, Axis, X, Y, Z); + const FCell& Cell = CellData[CellIndex(CX, CY, CZ)]; + if (LocalEdge == INDEX_NONE || Cell.EdgeComponent[LocalEdge] == 0xff) + { bValid = false; break; } + Ids[I] = static_cast(CellIndex(CX, CY, CZ) * EdgeCount + Cell.EdgeComponent[LocalEdge]); + } + if (bValid) for (int32 I = 1; I < 4; ++I) UnitePatch(Ids[0], Ids[I]); + } + + bool bSawBorderPatch = false; + std::vector PatchUseDC(PatchCount, 0); + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + const FCell& Cell = CellData[CellIndex(X, Y, Z)]; + for (uint8 Component = 0; Component < Cell.ComponentCount; ++Component) + { + const uint32 Id = static_cast(CellIndex(X, Y, Z) * EdgeCount + Component); + const uint32 Root = FindPatch(Id, FindPatch); + PatchUseDC[Root] = static_cast(PatchSharp[Root] && !PatchTouchesBorder[Root]); + if (PatchSharp[Root] && PatchTouchesBorder[Root]) bSawBorderPatch = true; + } + } + Result.BorderFallbackPatches = bSawBorderPatch ? 1u : 0u; + + for (int32 Z = -1; Z <= Cells; ++Z) + for (int32 Y = -1; Y <= Cells; ++Y) + for (int32 X = -1; X <= Cells; ++X) + { + FCell& Cell = CellData[CellIndex(X, Y, Z)]; + if (Cell.ComponentCount == 0) continue; + for (uint8 Component = 0; Component < Cell.ComponentCount; ++Component) + { + const uint32 Id = static_cast(CellIndex(X, Y, Z) * EdgeCount + Component); + Cell.ComponentUseDC[Component] = PatchUseDC[FindPatch(Id, FindPatch)]; + if (!Cell.ComponentUseDC[Component]) continue; + std::vector> Constraints; + FVector RootSum = FVector::ZeroVector; + int32 RootCount = 0; + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + if (Cell.EdgeComponent[Edge] != Component) continue; + FRoot& Root = GetCellEdgeRoot(X, Y, Z, Edge); + if (!Root.bActive) continue; + RootSum += Root.Position; + ++RootCount; + if (!Root.Planes.empty()) + for (const FPlane& Plane : Root.Planes) Constraints.emplace_back(Root.Position, Plane.Normal); + else Constraints.emplace_back(Root.Position, Root.Gradient); + } + const FVector Low = NodePosition(X, Y, Z); + const FVector High = NodePosition(X + 1, Y + 1, Z + 1); + const FVector Center = RootCount > 0 ? RootSum / RootCount : (Low + High) * 0.5; + Cell.Dual[Component] = SolveQEF(Constraints, Center, Low, High); + if (X >= 0 && Y >= 0 && Z >= 0 && X < Cells && Y < Cells && Z < Cells) + ++Result.DualCells; + } + } + + Result.CoreCellDCEdges.assign(static_cast(Cells) * Cells * Cells * EdgeCount, 0); + const auto AddTriangle = [&](const FVector& A, const FVector& B, const FVector& C, + const FVector& NA, const FVector& NB, const FVector& NC, + uint64 FA, uint64 FB, uint64 FC) + { + if (FVector::CrossProduct(B - A, C - A).SizeSquared() < 1.0e-12) return; + FTriangle T; + T.Positions[0] = A; T.Positions[1] = B; T.Positions[2] = C; + T.Normals[0] = NA.GetSafeNormal(); T.Normals[1] = NB.GetSafeNormal(); T.Normals[2] = NC.GetSafeNormal(); + T.SplitKeys[0] = FA; T.SplitKeys[1] = FB; T.SplitKeys[2] = FC; + Result.Triangles.push_back(T); + }; + + for (int32 Axis = 0; Axis < 3; ++Axis) + for (int32 Z = -1; Z <= Cells + (Axis == 2 ? 0 : 1); ++Z) + for (int32 Y = -1; Y <= Cells + (Axis == 1 ? 0 : 1); ++Y) + for (int32 X = -1; X <= Cells + (Axis == 0 ? 0 : 1); ++X) + { + FRoot& Root = GetRoot(Axis, X, Y, Z); + if (!Root.bActive) continue; + std::array, 4> Around{}; + if (Axis == 0) Around = {{{X,Y-1,Z-1},{X,Y,Z-1},{X,Y,Z},{X,Y-1,Z}}}; + else if (Axis == 1) Around = {{{X-1,Y,Z-1},{X-1,Y,Z},{X,Y,Z},{X,Y,Z-1}}}; + else Around = {{{X-1,Y-1,Z},{X,Y-1,Z},{X,Y,Z},{X-1,Y,Z}}}; + std::array Incident{}; + std::array Components{}; + bool bAllDC = true; + bool bValid = true; + for (int32 I = 0; I < 4; ++I) + { + const int32 CX = Around[I][0], CY = Around[I][1], CZ = Around[I][2]; + if (CX < -1 || CY < -1 || CZ < -1 || CX > Cells || CY > Cells || CZ > Cells) + { bValid = false; break; } + const int32 LocalEdge = FindLocalEdge(CX, CY, CZ, Axis, X, Y, Z); + const FCell& Cell = CellData[CellIndex(CX, CY, CZ)]; + if (LocalEdge == INDEX_NONE || Cell.EdgeComponent[LocalEdge] == 0xff) + { bValid = false; break; } + const uint8 Component = Cell.EdgeComponent[LocalEdge]; + Incident[I] = &Cell; + Components[I] = Component; + bAllDC &= Cell.ComponentUseDC[Component] != 0; + } + if (!bValid || !bAllDC) continue; + const int32 OwnerX = FMath::Min(FMath::Min(Around[0][0], Around[1][0]), FMath::Min(Around[2][0], Around[3][0])); + const int32 OwnerY = FMath::Min(FMath::Min(Around[0][1], Around[1][1]), FMath::Min(Around[2][1], Around[3][1])); + const int32 OwnerZ = FMath::Min(FMath::Min(Around[0][2], Around[1][2]), FMath::Min(Around[2][2], Around[3][2])); + if (OwnerX < 0 || OwnerY < 0 || OwnerZ < 0 || OwnerX >= Cells || OwnerY >= Cells || OwnerZ >= Cells) + continue; + + std::array Positions{}; + std::array Normals{}; + std::array SplitKeys{}; + for (int32 I = 0; I < 4; ++I) + { + const int32 CX = Around[I][0], CY = Around[I][1], CZ = Around[I][2]; + const uint8 Component = Components[I]; + Positions[I] = Incident[I]->Dual[Component]; + SplitKeys[I] = SelectFeature(Root, Axis); + Normals[I] = SplitKeys[I] != 0 ? NormalForFeature(Root, SplitKeys[I]) : Root.Gradient; + } + if (FVector::DotProduct(FVector::CrossProduct(Positions[1] - Positions[0], + Positions[2] - Positions[0]), Root.Gradient) < 0.0) + { + std::swap(Positions[1], Positions[3]); + std::swap(Normals[1], Normals[3]); + std::swap(SplitKeys[1], SplitKeys[3]); + } + const int32 Tie = (X + Y + Z + Axis) & 1; + if (Tie == 0) + { + AddTriangle(Positions[0], Positions[1], Positions[2], Normals[0], Normals[1], Normals[2], SplitKeys[0], SplitKeys[1], SplitKeys[2]); + AddTriangle(Positions[0], Positions[2], Positions[3], Normals[0], Normals[2], Normals[3], SplitKeys[0], SplitKeys[2], SplitKeys[3]); + } + else + { + AddTriangle(Positions[0], Positions[1], Positions[3], Normals[0], Normals[1], Normals[3], SplitKeys[0], SplitKeys[1], SplitKeys[3]); + AddTriangle(Positions[1], Positions[2], Positions[3], Normals[1], Normals[2], Normals[3], SplitKeys[1], SplitKeys[2], SplitKeys[3]); + } + } + + for (int32 Z = 0; Z < Cells; ++Z) + for (int32 Y = 0; Y < Cells; ++Y) + for (int32 X = 0; X < Cells; ++X) + { + const FCell& Cell = CellData[CellIndex(X, Y, Z)]; + if (!Cell.ComponentCount) continue; + const std::size_t CoreCell = (static_cast(Z) * Cells + Y) * Cells + X; + for (int32 Edge = 0; Edge < EdgeCount; ++Edge) + { + const uint8 Component = Cell.EdgeComponent[Edge]; + if (Component != 0xff && Cell.ComponentUseDC[Component]) + Result.CoreCellDCEdges[CoreCell * EdgeCount + Edge] = 1; + } + } + return Result; + } +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index adee9ad..e273ce3 100644 --- a/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -2,6 +2,7 @@ // Implémentation du marching cubes density-only. #include "VoxelMarchingCubesMesher.h" +#include "VoxelHybridSharpMesher.h" #include "MarchingCubesTables.h" #include "StandaloneShapeWorld.h" #include "VoxelDensityProfile.h" @@ -164,6 +165,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // plus de (ré)allocation de hash-map par tuile (chaque worker a son propre exemplaire). static thread_local TMap VertexMap; VertexMap.Reset(); + static thread_local TMap> SplitNormalVertexMap; + SplitNormalVertexMap.Reset(); const int32 VertexCapacityHint=FMath::Clamp(InCellsPerAxis,2,CHUNK_SIZE); VertexMap.Reserve(VertexCapacityHint*VertexCapacityHint*2); @@ -230,7 +233,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, }; // Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b). - auto GetOrCreateVertex = [&](const FVector& WorldPos, const FVector& RawNormal) -> int32 + auto GetOrCreateVertex = [&](const FVector& WorldPos, const FVector& RawNormal, + uint64 NormalSplitKey = 0) -> int32 { const FIntVector Key( FMath::RoundToInt(WorldPos.X * 100.0f), @@ -238,13 +242,27 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, FMath::RoundToInt(WorldPos.Z * 100.0f) ); - if (int32* Existing = VertexMap.Find(Key)) + if (NormalSplitKey != 0) + { + if (TMap* SplitMap = SplitNormalVertexMap.Find(Key)) + { + if (int32* Existing = SplitMap->Find(NormalSplitKey)) return *Existing; + } + } + else if (int32* Existing = VertexMap.Find(Key)) { return *Existing; } const int32 NewIndex = MeshData.Vertices.Num(); - VertexMap.Add(Key, NewIndex); + if (NormalSplitKey != 0) + { + SplitNormalVertexMap.FindOrAdd(Key).Add(NormalSplitKey, NewIndex); + } + else + { + VertexMap.Add(Key, NewIndex); + } MeshData.Vertices.Add(WorldPos); @@ -1349,6 +1367,20 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, SampleGradientG(gx, gy, gz + 1) - SampleGradientG(gx, gy, gz - 1)); }; + // DC is limited to tagged LOD0 patches. Coarse tiles and a connected patch that reaches a + // tile edge use MC there, preserving the existing MC-to-MC clipmap transition and skirts. + // Le DC reste limite aux surfaces LOD0 balisees; les patches connectes au bord restent MC. + VoxelHybridSharpMesher::FResult HybridResult; + if (bUseHybridSharpMesher && Step == 1 && CellsPerAxis == CHUNK_SIZE + && CzLo == 0 && CzHi == CellsPerAxis - 1 && !HybridSharpFeatures.IsEmpty()) + { + HybridResult = VoxelHybridSharpMesher::Generate( + CellsPerAxis, Step, IsoLevel, GeometryVoxelSize, OriginVoxels, ChunkWorldPos, + HybridSharpFeatures, + [&](int32 X, int32 Y, int32 Z) { return SampleG(X, Y, Z); }, + [&](int32 X, int32 Y, int32 Z) { return GradAt(X, Y, Z); }); + } + //========================================================================= // ITÉRATION SUR LES CELLULES //========================================================================= @@ -1564,6 +1596,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 E0 = MCTriTable[CaseIndex][i]; const int32 E1 = MCTriTable[CaseIndex][i + 1]; const int32 E2 = MCTriTable[CaseIndex][i + 2]; + if (HybridResult.UsesDCEdge(cx, cy, cz, E0, CellsPerAxis)) + { + continue; + } const int32 Idx0 = GetOrCreateVertex(EdgeVertices[E0], EdgeNormals[E0]); const int32 Idx1 = GetOrCreateVertex(EdgeVertices[E1], EdgeNormals[E1]); const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]); @@ -1591,6 +1627,26 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } + // Add DC geometry to the same material runs as MC. Analytic feature IDs split corner normals. + // Ajouter la geometrie DC dans les memes groupes que MC; les IDs separent les normales nettes. + for (const VoxelHybridSharpMesher::FTriangle& Triangle : HybridResult.Triangles) + { + const int32 Idx0 = GetOrCreateVertex( + Triangle.Positions[0], Triangle.Normals[0], Triangle.SplitKeys[0]); + const int32 Idx1 = GetOrCreateVertex( + Triangle.Positions[1], Triangle.Normals[1], Triangle.SplitKeys[1]); + const int32 Idx2 = GetOrCreateVertex( + Triangle.Positions[2], Triangle.Normals[2], Triangle.SplitKeys[2]); + const int32 CapVotes = bStandaloneSkipMaterialGroups ? 0 + : static_cast(VertexClasses[Idx0]) + + static_cast(VertexClasses[Idx1]) + + static_cast(VertexClasses[Idx2]); + TArray& Dst = (CapVotes >= 2) ? CapTris : GroundTris; + Dst.Add(Idx0); + Dst.Add(Idx2); + Dst.Add(Idx1); + } + if (bProfileMesher) { VoxelDensityProfile::AddMeasurement( diff --git a/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index c8b594b..058d6fd 100644 --- a/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -41,6 +41,15 @@ struct FVoxelSharedDensityGrid } }; +// Stable plan data copied onto one worker-owned mesher for hybrid LOD0 generation. +// Donnees stables du plan copiees dans un mailleur de worker pour le mode hybride LOD0. +struct FVoxelHybridSharpFeatureBox +{ + uint64 StableId = 0; + int64 MinimumSubvoxels[3] = {}; + int64 MaximumSubvoxels[3] = {}; +}; + UCLASS(BlueprintType) class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject { @@ -111,6 +120,14 @@ public: // set this pointer and continue through Generator. void SetStandaloneShapeWorld(const FStandaloneShapeWorld* InWorld) { ShapeWorldContext = InWorld; } + // The caller owns this mesher instance for one tile task; no shared mutable feature state. + // L'appelant possede cette instance pour une tuile; aucune donnee partagee modifiable. + void SetHybridSharpFeatures(TArray&& InFeatures) + { + HybridSharpFeatures = MoveTemp(InFeatures); + bUseHybridSharpMesher = true; + } + private: FORCEINLINE bool ShouldAbortWork() const { @@ -120,6 +137,8 @@ private: const std::atomic* ShutdownFlag = nullptr; const FVoxelSharedDensityGrid* SharedDensityGrid = nullptr; const FStandaloneShapeWorld* ShapeWorldContext = nullptr; + TArray HybridSharpFeatures; + bool bUseHybridSharpMesher = false; float GeometryVoxelSizeCm = VOXEL_SIZE; public: diff --git a/Standalone/UNREAL_INTEGRATION_REPORT.md b/Standalone/UNREAL_INTEGRATION_REPORT.md index 6594b3e..a229b50 100644 --- a/Standalone/UNREAL_INTEGRATION_REPORT.md +++ b/Standalone/UNREAL_INTEGRATION_REPORT.md @@ -482,3 +482,47 @@ fix, the actor test passed. The final rerun after gating the manifest hash on th passed in 9m20s with 3,880,357,888 bytes peak sampled private memory (3.61 GiB). No `0xC0000142` retry or new UECC folder occurred. The owner's project was not opened or built and no `.uasset` was edited. + +### M2 round-3 hybrid mesher integration (2026-10-10) + +`WorldAssemblerHybridV1` is an opt-in Core recipe; `MarchingCubesV1` and +`bUseVoxelForgeCore=false` remain the defaults. Tagged type-2 plan boxes use sharp hybrid cells only +at LOD0 interior cells. Tile-border cells and LOD1+ use MC, with the existing skirt retained. The +full BuildHost actor commandlet passed its W1/W8/W16 matrix at the round-6 Core hashes, +anchor/edit/travel checks, height agreement, switch-off gate, Blueprint carve/fill, native +SurfaceWorld route and five cooked collision contacts. It ran in null-RHI BuildHost for 11 minutes +and logged zero engine errors. A PowerShell status-format cast initially made the outer wrapper +return 1 after the engine had passed; the script was fixed, focused fixture and cliff checks +returned 0, and the corrected full wrapper rerun also returned 0 in 11m06s at a sampled 3.73 GiB +peak. + +The BuildHost fixture plan has nine stable type-2 feature boxes for the corner, doorway, riser and +ledge, plus the existing type-4 SurfaceWorld cliff. Five contacts hit the Pawn channel and the same +RMC component; direct component traces agreed with world traces, and cooked Chaos body bounds +matched render bounds. Field contact error ranged from 0 to 20.332 cm. The separate sampled native +cliff check passed bit-identity: 10,231 vertices, 10,360 triangles, mesh hash +`0xfc7bd7a3cf997992`. Same-LOD independent borders passed with 63 segments; the 125-phase LOD0/LOD1 +doorway probe had no contours over 50 cm and a 22.844 cm worst mismatch. + +That LOD probe is not a topology proof. The standalone full transition suite still records five MC +doorway failures out of 625, with skirts serving as the current repair. At the 25 cm Core grid, the +17.68 cm corner bevel is 0.35 of a LOD1 sample and may round off or pop at the threshold. Null RHI +cannot show or measure this visually; owner review remains necessary. + +Plugin fixture timings show a 5.66–6.70× hybrid/MC meshing cost on the tagged sharp fixtures; the +organic cave is 0.992× MC. See the M2 round-3 tables and complete failed-run ledger in +[`WorldAssemblerM2/REPORT.md`](Research/WorldAssemblerM2/REPORT.md). BuildHost artifacts include +the owner-previewable [`surface_hybrid_fixture_plan.json`](Research/WorldAssemblerM2/round3/surface_hybrid_fixture_plan.json). + +Owner preview: duplicate the settings asset, import the fixture manifest through +`SourceWorldAssemblyManifestJson` / `ImportWorldAssemblyManifestJson`, enable Core on the test +`AVoxelWorld`, and select `World Assembler Hybrid V1`. Review the corner and doorway while crossing +the LOD distance, then compare riser and ledge contacts. The owner builds the plugin and judges the +live view. No owner `.uasset`, project build, or editor session was used here. + +New source files are the mirrored private headers +`Source/VoxelForge/Private/VoxelHybridSharpMesher.h` and +`Standalone/Source/VoxelForge/Private/VoxelHybridSharpMesher.h`; no new `.cpp` or generated header +was added. Likely compile touchpoints are `VoxelWorld.h/.cpp`, `VoxelTypes.h`, +`VoxelMarchingCubesMesher.h/.cpp`, both hybrid mesher headers, the Core public header and +`VoxelForgeCore.cpp`, `VoxelForgeExploreCommandlet.cpp`, and `VoxelForgeEditor.Build.cs`. diff --git a/Tools/TestVoxelForgeCoreWorldHost.ps1 b/Tools/TestVoxelForgeCoreWorldHost.ps1 index cefea99..e778ff3 100644 --- a/Tools/TestVoxelForgeCoreWorldHost.ps1 +++ b/Tools/TestVoxelForgeCoreWorldHost.ps1 @@ -1,7 +1,11 @@ [CmdletBinding()] param( [ValidateRange(60, 900)][int]$TimeoutSeconds = 900, - [string]$WorldAssemblyManifestPath = '' + [string]$WorldAssemblyManifestPath = '', + [ValidateSet('MarchingCubesV1', 'WorldAssemblerHybridV1')][string]$CoreMesherRecipe = 'MarchingCubesV1', + [switch]$EnableHybridFixturePlacement, + [switch]$HybridCliffOnly, + [switch]$HybridFixturesOnly ) # Exercise AVoxelWorld Core streaming, collision, anchor movement, and carving in BuildHost only. @@ -83,6 +87,21 @@ $ManifestData = Get-Content -LiteralPath $StagedWorldAssembly -Raw | ConvertFrom if ($ManifestData.content_hash -ne '8933693d9560ba9f') { throw "BuildHost world assembly input is not the reviewed round-4 manifest: $($ManifestData.content_hash)" } +if ($EnableHybridFixturePlacement -and $CoreMesherRecipe -ne 'WorldAssemblerHybridV1') { + throw 'Hybrid fixture placement requires -CoreMesherRecipe WorldAssemblerHybridV1.' +} +if ($HybridCliffOnly -and $CoreMesherRecipe -ne 'WorldAssemblerHybridV1') { + throw '-HybridCliffOnly requires -CoreMesherRecipe WorldAssemblerHybridV1.' +} +if ($HybridCliffOnly -and $EnableHybridFixturePlacement) { + throw '-HybridCliffOnly uses the reviewed native cliff manifest and cannot add the surface fixtures.' +} +if ($HybridFixturesOnly -and $CoreMesherRecipe -ne 'WorldAssemblerHybridV1') { + throw '-HybridFixturesOnly requires -CoreMesherRecipe WorldAssemblerHybridV1.' +} +if ($HybridFixturesOnly -and ($HybridCliffOnly -or $EnableHybridFixturePlacement)) { + throw '-HybridFixturesOnly cannot be combined with the other focused hybrid modes.' +} $CrashBefore = @{} foreach ($CrashRoot in $CrashRoots) { @@ -107,6 +126,10 @@ function Get-NewCrashFolders { return @($Found.ToArray()) } +$HybridMesherFlag = if ($CoreMesherRecipe -eq 'WorldAssemblerHybridV1') { 1 } else { 0 } +$HybridFixturePlacementFlag = if ($EnableHybridFixturePlacement) { 1 } else { 0 } +$HybridCliffOnlyFlag = if ($HybridCliffOnly) { 1 } else { 0 } +$HybridFixturesOnlyFlag = if ($HybridFixturesOnly) { 1 } else { 0 } $BaseArguments = @( ('-project="{0}"' -f $HostProject), '-nullrhi', '-unattended', '-nop4', '-nosplash', '-NoSound', @@ -119,6 +142,10 @@ $BaseArguments = @( '-modes=export', '-gameconfig=1', '-coreworldtest=1', '-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings', ('-coreworldassembly="{0}"' -f $StagedWorldAssembly), + ("-corehybridmesher=$HybridMesherFlag"), + ("-corehybridfixtureplacement=$HybridFixturePlacementFlag"), + ("-corehybridcliffonly=$HybridCliffOnlyFlag"), + ("-corehybridfixturesonly=$HybridFixturesOnlyFlag"), ('-out="{0}"' -f $OutputDirectory) ) @@ -199,6 +226,40 @@ if (-not (Test-Path -LiteralPath $FinalLog -PathType Leaf)) { $FinalLog = Join-Path $LogDirectory 'CoreWorldValidation.retry.log' } $Text = Get-Content -LiteralPath $FinalLog -Raw +if ($HybridFixturesOnly) { + foreach ($Fixture in @('corner90', 'doorway', 'riser1m', 'cliff_ledge', 'organic_cave_chamber')) { + $FixtureMarker = '\[WorldAssemblerHybridPluginFixture\] name=' + + [regex]::Escape($Fixture) + ' lod=0 .*exact_repeat=1' + if ($Text -notmatch $FixtureMarker) { + throw "BuildHost fixture log is missing '$Fixture'. See $FinalLog." + } + } + if ($Text -notmatch '\[WorldAssemblerHybridPluginBorder\] same_lod=PASS independent=1' -or + $Text -notmatch '\[WorldAssemblerHybridPluginBorder\] lod0_lod1_doorway=1 cases=125 over_50cm_cases=0') { + throw "BuildHost fixture log is missing passing border probes. See $FinalLog." + } + $NewCrashes = @(Get-NewCrashFolders) + if ($NewCrashes.Count -gt 0) { + throw "New UECC crash folders appeared during plugin fixture checks: $($NewCrashes -join '; ')" + } + Write-Host "VOXELFORGE_CORE_HYBRID_FIXTURES_PASS recipe=$CoreMesherRecipe analytic=5 borders=1" + Write-Host "Core actor log: $FinalLog" + Write-Host "Peak commandlet private bytes sampled: $PeakPrivateBytes" + return +} +if ($HybridCliffOnly) { + if ($Text -notmatch '\[WorldAssemblerHybridNativeCliff\] key=\(-22,13,-58\) lod=0 .*bit_identical=1 type4_only=1') { + throw "BuildHost log is missing the sampled native SurfaceWorld cliff identity proof. See $FinalLog." + } + $NewCrashes = @(Get-NewCrashFolders) + if ($NewCrashes.Count -gt 0) { + throw "New UECC crash folders appeared during the native cliff check: $($NewCrashes -join '; ')" + } + Write-Host "VOXELFORGE_CORE_HYBRID_CLIFF_PASS recipe=$CoreMesherRecipe bit_identical=1" + Write-Host "Core actor log: $FinalLog" + Write-Host "Peak commandlet private bytes sampled: $PeakPrivateBytes" + return +} foreach ($Phase in @('initial', 'anchor', 'edit', 'surface_travel', 'surface_strate_travel')) { if ($Text -notmatch "\[VoxelForgeCoreWorldTest\] pass phase=$Phase") { throw "BuildHost Core actor log is missing the '$Phase' validation marker. See $FinalLog." @@ -207,8 +268,42 @@ foreach ($Phase in @('initial', 'anchor', 'edit', 'surface_travel', 'surface_str if ($Text -notmatch '\[VoxelForgeCoreWorldTest\] worker_hash_matrix=1/8/16 stable=1') { throw "BuildHost Core actor log is missing the W1/W8/W16 determinism marker. See $FinalLog." } -if ($Text -notmatch '\[VoxelForgeCoreWorldTest\] world_assembly_hash=8933693d9560ba9f plans=1 native=1') { - throw "BuildHost Core actor log does not prove the reviewed native SurfaceWorld plan was attached. See $FinalLog." +$ExpectedRuntimeAssemblyHash = $ManifestData.content_hash +if ($EnableHybridFixturePlacement) { + $FixtureHashMatch = [regex]::Match($Text, + '\[VoxelForgeCoreWorldTest\] hybrid_fixture_placement=1 boxes=9 contacts=5 manifest_hash=([0-9a-f]{16})') + if (-not $FixtureHashMatch.Success) { + throw "BuildHost actor log is missing the generated hybrid fixture manifest hash. See $FinalLog." + } + $ExpectedRuntimeAssemblyHash = $FixtureHashMatch.Groups[1].Value +} +$AssemblyMarker = '\[VoxelForgeCoreWorldTest\] world_assembly_hash=' + + [regex]::Escape($ExpectedRuntimeAssemblyHash) + ' plans=1 native=1' +if ($Text -notmatch $AssemblyMarker) { + throw "BuildHost actor log does not prove the expected native SurfaceWorld plan was attached. See $FinalLog." +} +$RecipeMarker = '\[VoxelForgeCoreWorldTest\] world_initialized=1 recipe=' + + [regex]::Escape($CoreMesherRecipe) +if ($Text -notmatch $RecipeMarker) { + throw "BuildHost actor log does not confirm the requested $CoreMesherRecipe runtime selection. See $FinalLog." +} +if ($CoreMesherRecipe -eq 'WorldAssemblerHybridV1') { + foreach ($Fixture in @('corner90', 'doorway', 'riser1m', 'cliff_ledge', 'organic_cave_chamber')) { + $FixtureMarker = '\[WorldAssemblerHybridPluginFixture\] name=' + + [regex]::Escape($Fixture) + ' lod=0 .*exact_repeat=1' + if ($Text -notmatch $FixtureMarker) { + throw "BuildHost log is missing the production mesher fixture '$Fixture'. See $FinalLog." + } + } + if ($Text -notmatch '\[WorldAssemblerHybridPluginBorder\] same_lod=PASS independent=1') { + throw "BuildHost log is missing the independent same-LOD border proof. See $FinalLog." + } + if ($Text -notmatch '\[WorldAssemblerHybridPluginBorder\] lod0_lod1_doorway=1 cases=125 over_50cm_cases=0') { + throw "BuildHost log is missing a passing 125-phase MC doorway contour result. See $FinalLog." + } +} +if ($EnableHybridFixturePlacement -and $Text -notmatch '\[VoxelForgeHybridCollisionContact\] pass=1 probes=5') { + throw "BuildHost log is missing the cooked hybrid fixture trace contacts. See $FinalLog." } if ($Text -notmatch '\[VoxelForgeCoreWorldTest\] native_biome_field=1 .*sampled_biomes=[2-9]') { throw "BuildHost Core actor log does not prove native SurfaceWorld biome assignment. See $FinalLog." @@ -241,6 +336,6 @@ $NewCrashes = @(Get-NewCrashFolders) if ($NewCrashes.Count -gt 0) { throw "New UECC crash folders appeared during Core actor validation: $($NewCrashes -join '; ')" } -Write-Host 'VOXELFORGE_CORE_WORLD_PASS streaming=1 collision=1 anchor_move=1 carve=1' +Write-Host "VOXELFORGE_CORE_WORLD_PASS recipe=$CoreMesherRecipe fixtures=$HybridFixturePlacementFlag streaming=1 collision=1 anchor_move=1 carve=1" Write-Host "Core actor log: $FinalLog" Write-Host "Peak commandlet private bytes sampled: $PeakPrivateBytes"