Add CultGeometry isosurface extraction
This commit is contained in:
parent
ea88cfcd05
commit
5b2c36329d
6 changed files with 337 additions and 11 deletions
193
src/GameCult.Geometry/CultGeometryIsoSurface.cs
Normal file
193
src/GameCult.Geometry/CultGeometryIsoSurface.cs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CultMath;
|
||||
|
||||
namespace GameCult.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts engine-neutral triangle meshes from sampled scalar fields.
|
||||
/// </summary>
|
||||
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 },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the <paramref name="isoValue"/> surface with marching tetrahedra.
|
||||
/// Values less than or equal to the isovalue are treated as inside.
|
||||
/// </summary>
|
||||
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<float>();
|
||||
var normals = new List<float>();
|
||||
var indices = new List<uint>();
|
||||
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<float>(),
|
||||
TriangleMaterials = new uint[indices.Count / 3],
|
||||
};
|
||||
}
|
||||
|
||||
private static void PolygonizeTetrahedron(
|
||||
float3[] positions,
|
||||
float[] values,
|
||||
float isoValue,
|
||||
List<float> outputPositions,
|
||||
List<float> outputNormals,
|
||||
List<uint> outputIndices)
|
||||
{
|
||||
Span<int> inside = stackalloc int[4];
|
||||
Span<int> 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<int> 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<float> positions,
|
||||
List<float> normals,
|
||||
List<uint> 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<float> destination)
|
||||
{
|
||||
destination.Add(value.x);
|
||||
destination.Add(value.y);
|
||||
destination.Add(value.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir</Description>
|
||||
<Description>CultGeometry algorithms and typed CultCache geometry artifacts</Description>
|
||||
<PackageId>GameCult.Geometry</PackageId>
|
||||
<PackageTags>game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
|
||||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
|
|
@ -15,5 +16,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="ISOSURFACE-PROVENANCE.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
32
src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md
Normal file
32
src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
89
tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs
Normal file
89
tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs
Normal file
|
|
@ -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<ArgumentException>();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue