From 5b2c36329dbd74479c3ede5cdcec77c0d17ce534 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 26 Aug 2026 17:37:22 +0200 Subject: [PATCH] Add CultGeometry isosurface extraction --- .../CultGeometryIsoSurface.cs | 193 ++++++++++++++++++ .../GameCult.Geometry.csproj | 4 +- .../ISOSURFACE-PROVENANCE.md | 32 +++ src/GameCult.Geometry/README.md | 23 ++- .../GeometryDocumentTests.cs | 7 +- .../IsoSurfaceTests.cs | 89 ++++++++ 6 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 src/GameCult.Geometry/CultGeometryIsoSurface.cs create mode 100644 src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md create mode 100644 tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs diff --git a/src/GameCult.Geometry/CultGeometryIsoSurface.cs b/src/GameCult.Geometry/CultGeometryIsoSurface.cs new file mode 100644 index 00000000..6fc9355c --- /dev/null +++ b/src/GameCult.Geometry/CultGeometryIsoSurface.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using CultMath; + +namespace GameCult.Geometry +{ + /// + /// Extracts engine-neutral triangle meshes from sampled scalar fields. + /// + public static class CultGeometryIsoSurface + { + private static readonly float3[] CubeCorners = + { + new(0f, 0f, 0f), + new(1f, 0f, 0f), + new(1f, 1f, 0f), + new(0f, 1f, 0f), + new(0f, 0f, 1f), + new(1f, 0f, 1f), + new(1f, 1f, 1f), + new(0f, 1f, 1f), + }; + + // A fixed six-tetrahedra decomposition keeps neighboring cube faces consistent. + private static readonly int[,] CubeTetrahedra = + { + { 0, 5, 1, 6 }, + { 0, 1, 2, 6 }, + { 0, 2, 3, 6 }, + { 0, 3, 7, 6 }, + { 0, 7, 4, 6 }, + { 0, 4, 5, 6 }, + }; + + /// + /// Extracts the surface with marching tetrahedra. + /// Values less than or equal to the isovalue are treated as inside. + /// + public static CultGeometryTriangleMesh Extract( + float[,,] samples, + float isoValue = 0f, + CultVec3 origin = default, + float cellSize = 1f) + { + if (samples == null) throw new ArgumentNullException(nameof(samples)); + if (samples.GetLength(0) < 2 || samples.GetLength(1) < 2 || samples.GetLength(2) < 2) + { + throw new ArgumentException("An isosurface field requires at least two samples on every axis.", nameof(samples)); + } + + if (!(cellSize > 0f) || float.IsInfinity(cellSize)) + { + throw new ArgumentOutOfRangeException(nameof(cellSize), "Cell size must be finite and positive."); + } + + var positions = new List(); + var normals = new List(); + var indices = new List(); + var originValue = new float3(origin.X, origin.Y, origin.Z); + var cornerPositions = new float3[8]; + var cornerValues = new float[8]; + var tetraPositions = new float3[4]; + var tetraValues = new float[4]; + + for (var x = 0; x < samples.GetLength(0) - 1; x++) + for (var y = 0; y < samples.GetLength(1) - 1; y++) + for (var z = 0; z < samples.GetLength(2) - 1; z++) + { + var cubeOrigin = originValue + new float3(x, y, z) * cellSize; + for (var corner = 0; corner < CubeCorners.Length; corner++) + { + var offset = CubeCorners[corner]; + cornerPositions[corner] = cubeOrigin + offset * cellSize; + cornerValues[corner] = samples[ + x + (int)offset.x, + y + (int)offset.y, + z + (int)offset.z]; + } + + for (var tetrahedron = 0; tetrahedron < CubeTetrahedra.GetLength(0); tetrahedron++) + { + for (var vertex = 0; vertex < 4; vertex++) + { + var corner = CubeTetrahedra[tetrahedron, vertex]; + tetraPositions[vertex] = cornerPositions[corner]; + tetraValues[vertex] = cornerValues[corner]; + } + + PolygonizeTetrahedron(tetraPositions, tetraValues, isoValue, positions, normals, indices); + } + } + + return new CultGeometryTriangleMesh + { + Positions = positions.ToArray(), + Normals = normals.ToArray(), + Indices = indices.ToArray(), + Uvs = Array.Empty(), + TriangleMaterials = new uint[indices.Count / 3], + }; + } + + private static void PolygonizeTetrahedron( + float3[] positions, + float[] values, + float isoValue, + List outputPositions, + List outputNormals, + List outputIndices) + { + Span inside = stackalloc int[4]; + Span outside = stackalloc int[4]; + var insideCount = 0; + var outsideCount = 0; + + for (var vertex = 0; vertex < 4; vertex++) + { + if (values[vertex] <= isoValue) inside[insideCount++] = vertex; + else outside[outsideCount++] = vertex; + } + + if (insideCount == 0 || insideCount == 4) return; + + var outward = Centroid(positions, outside, outsideCount) - Centroid(positions, inside, insideCount); + if (insideCount == 1 || outsideCount == 1) + { + var loneIsInside = insideCount == 1; + var lone = loneIsInside ? inside[0] : outside[0]; + var others = loneIsInside ? outside : inside; + var a = Interpolate(positions[lone], values[lone], positions[others[0]], values[others[0]], isoValue); + var b = Interpolate(positions[lone], values[lone], positions[others[1]], values[others[1]], isoValue); + var c = Interpolate(positions[lone], values[lone], positions[others[2]], values[others[2]], isoValue); + EmitOrientedTriangle(a, b, c, outward, outputPositions, outputNormals, outputIndices); + return; + } + + var p00 = Interpolate(positions[inside[0]], values[inside[0]], positions[outside[0]], values[outside[0]], isoValue); + var p01 = Interpolate(positions[inside[0]], values[inside[0]], positions[outside[1]], values[outside[1]], isoValue); + var p10 = Interpolate(positions[inside[1]], values[inside[1]], positions[outside[0]], values[outside[0]], isoValue); + var p11 = Interpolate(positions[inside[1]], values[inside[1]], positions[outside[1]], values[outside[1]], isoValue); + EmitOrientedTriangle(p00, p01, p11, outward, outputPositions, outputNormals, outputIndices); + EmitOrientedTriangle(p00, p11, p10, outward, outputPositions, outputNormals, outputIndices); + } + + private static float3 Interpolate(float3 first, float firstValue, float3 second, float secondValue, float isoValue) + { + var delta = secondValue - firstValue; + var amount = math.abs(delta) <= 1e-20f ? 0.5f : (isoValue - firstValue) / delta; + return math.lerp(first, second, amount); + } + + private static float3 Centroid(float3[] positions, Span vertices, int count) + { + var sum = float3.zero; + for (var index = 0; index < count; index++) sum += positions[vertices[index]]; + return sum / count; + } + + private static void EmitOrientedTriangle( + float3 a, + float3 b, + float3 c, + float3 outward, + List positions, + List normals, + List indices) + { + var normal = math.cross(b - a, c - a); + if (math.dot(normal, outward) < 0f) + { + (b, c) = (c, b); + normal = -normal; + } + + normal = math.normalize(normal); + var firstIndex = (uint)(positions.Count / 3); + Append(a, positions); + Append(b, positions); + Append(c, positions); + for (var vertex = 0; vertex < 3; vertex++) Append(normal, normals); + indices.Add(firstIndex); + indices.Add(firstIndex + 1); + indices.Add(firstIndex + 2); + } + + private static void Append(float3 value, List destination) + { + destination.Add(value.x); + destination.Add(value.y); + destination.Add(value.z); + } + } +} diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index a0192f69..a3ad9457 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -1,6 +1,6 @@ - Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir + CultGeometry algorithms and typed CultCache geometry artifacts GameCult.Geometry game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir README.md @@ -8,6 +8,7 @@ + @@ -15,5 +16,6 @@ + diff --git a/src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md b/src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md new file mode 100644 index 00000000..a15a4933 --- /dev/null +++ b/src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md @@ -0,0 +1,32 @@ +# Isosurface extraction provenance + +`CultGeometryIsoSurface` is a clean implementation of marching tetrahedra over +a regular scalar grid. The implementation follows the tetrahedral case shape +described by Peter Shirley and Allan Tuchman, “A Polygonal Approximation to +Direct Scalar Volume Rendering,” Computer Graphics 24(5), 1990, +doi:10.1145/99308.99322. + +The DELVE/HOLD port was behaviorally motivated by these unversioned local Unity +sources: + +- `D:\WIP4\Projects\VoxelTerrain\Assets\Scripts\Procedural\GenerateTerrain.cs` +- `D:\WIP4\Projects\VoxelTerrain\Assets\Plugins\MarchingCubes.cs` + +The source directory is not a Git checkout and the plugin carries no license or +author notice. Its code and lookup tables were therefore not copied. The +CultGeometry implementation uses a fresh six-tetrahedra decomposition, explicit +inside/outside classification, interpolation, winding, and flat-normal output. + +Deliberate behavior retained: + +- scalar samples less than or equal to the isovalue are inside; +- intersections are linearly interpolated along cell edges; +- vertices are emitted per triangle rather than welded; +- output is engine-neutral positions, normals, indices, and material slots. + +Deliberate changes: + +- no Unity mesh, physics, coroutine, navigation, or random dependency; +- no mutable global target, mode, or winding state; +- outward winding is derived from inside-to-outside direction; +- invalid fields and cell sizes fail at the public boundary. diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 200f14ed..1ea60768 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -1,8 +1,17 @@ # GameCult.Geometry -`GameCult.Geometry` defines shared geometry values plus CultCache-native -documents for procedural geometry domains, LOD build requests, selected-cut -manifests, and mesh chunk artifacts. +The project is known as **CultGeometry**. `GameCult.Geometry` is its clean .NET +package and namespace identity. + +`GameCult.Geometry` owns reusable procedural geometry algorithms together with +the CultCache-native documents that carry their domains, build requests, +selected cuts, and mesh chunk artifacts. + +Its first engine-neutral algorithm is `CultGeometryIsoSurface.Extract`, a +marching-tetrahedra extractor over regular scalar grids. It uses CultMath for +numeric primitives and emits `CultGeometryTriangleMesh`; Godot, Unity, native +workers, and other consumers adapt that neutral result into their own runtime +objects. See `ISOSURFACE-PROVENANCE.md` for the clean-port boundary. The package is deliberately substrate-shaped: @@ -33,10 +42,10 @@ runtime equivalents instead of bespoke `{ minX, minY, maxX, maxY }` payloads. ## Runtime Path -`vg-csg` emits domain documents, selected-cut manifests, and chunk artifacts as -MessagePack CultCache documents. A Unity runtime, local worker, or remote -geometry process should receive the same records through CultMesh/CultNet and -read them back as `CultGeometry*` types. +CultGeometry CSG (`GameCult.Geometry.Csg`) emits domain documents, selected-cut +manifests, and chunk artifacts as MessagePack CultCache documents. A Godot +runtime, local worker, or remote geometry process receives the same records +through CultMesh/CultNet and reads them back as `CultGeometry*` types. ```csharp using GameCult.Caching; diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index b39b5182..16e66b8e 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -192,7 +192,7 @@ namespace GameCult.Geometry.Tests chunk, new CultNetDocumentMessageOptions { - SourceRuntimeId = "vg-csg", + SourceRuntimeId = "gamecult.geometry.csg", SourceRole = "geometry-worker", Tags = ["ragnarok", "chunk"] }); @@ -282,7 +282,7 @@ namespace GameCult.Geometry.Tests StartServer = false, DatabaseOptions = new CultNetDatabaseOptions { - RuntimeId = "unity-runtime", + RuntimeId = "godot-runtime", DocumentRegistry = registry } })) @@ -314,7 +314,7 @@ namespace GameCult.Geometry.Tests { DomainId = "ragnarok-column", RootKey = "ragnarok-column", - SourceRuntime = "vg-csg", + SourceRuntime = "gamecult.geometry.csg", CreatedAt = "2026-05-29T00:00:00.0000000Z", Root = new CultGeometryDomainNode { @@ -359,6 +359,7 @@ namespace GameCult.Geometry.Tests return Path.Combine( TestContext.CurrentContext.TestDirectory, "Fixtures", + // Immutable interoperability fixtures emitted before the CSG package rename. "vg-csg-ragnarok", fileName); } diff --git a/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs new file mode 100644 index 00000000..90d5f7f6 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs @@ -0,0 +1,89 @@ +using System; +using FluentAssertions; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests +{ + [TestFixture] + public sealed class IsoSurfaceTests + { + [TestCase(-1f)] + [TestCase(1f)] + public void Uniform_field_has_no_surface(float value) + { + var samples = Filled(2, value); + + var mesh = CultGeometryIsoSurface.Extract(samples); + + mesh.Positions.Should().BeEmpty(); + mesh.Normals.Should().BeEmpty(); + mesh.Indices.Should().BeEmpty(); + } + + [Test] + public void Plane_field_interpolates_to_the_isovalue_and_faces_outward() + { + var samples = new float[2, 2, 2]; + for (var y = 0; y < 2; y++) + for (var z = 0; z < 2; z++) + { + samples[0, y, z] = -0.5f; + samples[1, y, z] = 0.5f; + } + + var mesh = CultGeometryIsoSurface.Extract(samples); + + mesh.TriangleCount.Should().BeGreaterThan(0); + for (var index = 0; index < mesh.Positions.Length; index += 3) + { + mesh.Positions[index].Should().BeApproximately(0.5f, 1e-6f); + } + + for (var index = 0; index < mesh.Normals.Length; index += 3) + { + mesh.Normals[index].Should().BeApproximately(1f, 1e-6f); + mesh.Normals[index + 1].Should().BeApproximately(0f, 1e-6f); + mesh.Normals[index + 2].Should().BeApproximately(0f, 1e-6f); + } + } + + [Test] + public void Extraction_is_deterministic_and_applies_origin_and_cell_size() + { + var samples = new float[2, 2, 2]; + samples[0, 0, 0] = -1f; + samples[1, 0, 0] = 1f; + samples[0, 1, 0] = 1f; + samples[1, 1, 0] = 1f; + samples[0, 0, 1] = 1f; + samples[1, 0, 1] = 1f; + samples[0, 1, 1] = 1f; + samples[1, 1, 1] = 1f; + + var first = CultGeometryIsoSurface.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f); + var second = CultGeometryIsoSurface.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f); + + second.Positions.Should().Equal(first.Positions); + second.Normals.Should().Equal(first.Normals); + second.Indices.Should().Equal(first.Indices); + first.Positions.Should().OnlyContain(value => value >= 10f); + } + + [Test] + public void Invalid_field_shape_is_rejected() + { + Action act = () => CultGeometryIsoSurface.Extract(new float[1, 2, 2]); + + act.Should().Throw(); + } + + private static float[,,] Filled(int size, float value) + { + var samples = new float[size, size, size]; + for (var x = 0; x < size; x++) + for (var y = 0; y < size; y++) + for (var z = 0; z < size; z++) samples[x, y, z] = value; + return samples; + } + } +}