From 38b79a8d897ac5e2f3b3b24313a051b8b9a47a30 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 8 May 2026 01:49:44 +0100 Subject: [PATCH 01/23] Seed HLSL-shaped math library --- .gitignore | 7 ++ CultMath.slnx | 8 ++ README.md | 43 ++++++++ docs/design.md | 21 ++++ global.json | 6 ++ src/CultMath/CultMath.csproj | 21 ++++ src/CultMath/float2.cs | 26 +++++ src/CultMath/float3.cs | 31 ++++++ src/CultMath/float4.cs | 31 ++++++ src/CultMath/math.cs | 114 +++++++++++++++++++++ tests/CultMath.Tests/CultMath.Tests.csproj | 20 ++++ tests/CultMath.Tests/MathTests.cs | 38 +++++++ 12 files changed, 366 insertions(+) create mode 100644 .gitignore create mode 100644 CultMath.slnx create mode 100644 README.md create mode 100644 docs/design.md create mode 100644 global.json create mode 100644 src/CultMath/CultMath.csproj create mode 100644 src/CultMath/float2.cs create mode 100644 src/CultMath/float3.cs create mode 100644 src/CultMath/float4.cs create mode 100644 src/CultMath/math.cs create mode 100644 tests/CultMath.Tests/CultMath.Tests.csproj create mode 100644 tests/CultMath.Tests/MathTests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..59e67361 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +artifacts/ +*.user +*.suo +.vs/ +.idea/ diff --git a/CultMath.slnx b/CultMath.slnx new file mode 100644 index 00000000..95834573 --- /dev/null +++ b/CultMath.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..c3b1809d --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# CultMath + +Clean-room C# math shaped for people who think in HLSL. + +CultMath provides lowercase vector types and shader-style intrinsics so +math-heavy renderer code can move between C# and HLSL without turning every +line into a translation exercise. It is not a Unity.Mathematics clone. It is a +small, Aquarium-born library built from public shader semantics and plain .NET. + +## Current Surface + +- `float2`, `float3`, `float4` +- component-wise arithmetic +- scalar-to-vector splats +- swizzles for common lanes +- conversions to and from `System.Numerics` +- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, + `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, + `cross`, `length`, `distance`, `normalize`, and `reflect` + +## Example + +```csharp +using CultMath; + +float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f)); +float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f))); +float glow = math.smoothstep(0.2f, 1.0f, rim); +``` + +## Build + +```powershell +dotnet build CultMath.slnx +dotnet test CultMath.slnx +``` + +## Doctrine + +The goal is mechanical sympathy between CPU-side authoring and GPU-side shader +math. If an intrinsic exists here, it should behave like the shader concept +unless .NET numeric rules make that impossible. Weird deviations get tests and +documentation, not folklore. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 00000000..62102739 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,21 @@ +# Design + +CultMath exists to make renderer math readable in both places it needs to live: +C# host code and HLSL shader code. + +## Rules + +- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, and `math`. +- Implement public shader semantics directly; do not copy Unity.Mathematics. +- Prefer small immutable value types. +- Prefer component-wise overloads over clever generic machinery until the API + earns that complexity. +- Keep the first surface boring and exact. Noise, matrices, quaternions, and + packing helpers come later. + +## Non-Goals + +- Compiling C# to shaders. +- Replacing `System.Numerics` for general .NET work. +- Recreating every Unity.Mathematics type because a spreadsheet somewhere says + “coverage.” That way lies decorative obesity. diff --git a/global.json b/global.json new file mode 100644 index 00000000..99b1511d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.202", + "rollForward": "latestFeature" + } +} diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj new file mode 100644 index 00000000..f06b8bed --- /dev/null +++ b/src/CultMath/CultMath.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + CultMath + CultMath + CultMath + Clean-room C# math shaped for HLSL-style renderer code. + GameCult + https://github.com/GameCult/CultMath + README.md + $(NoWarn);CS8981 + + + + + + + diff --git a/src/CultMath/float2.cs b/src/CultMath/float2.cs new file mode 100644 index 00000000..1f1d66b1 --- /dev/null +++ b/src/CultMath/float2.cs @@ -0,0 +1,26 @@ +namespace CultMath; + +public readonly record struct float2(float x, float y) +{ + public static readonly float2 zero = new(0.0f, 0.0f); + public static readonly float2 one = new(1.0f, 1.0f); + + public float this[int index] => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator float2(float value) => new(value, value); + public static explicit operator System.Numerics.Vector2(float2 value) => new(value.x, value.y); + public static explicit operator float2(System.Numerics.Vector2 value) => new(value.X, value.Y); + + public static float2 operator +(float2 value) => value; + public static float2 operator -(float2 value) => new(-value.x, -value.y); + public static float2 operator +(float2 left, float2 right) => new(left.x + right.x, left.y + right.y); + public static float2 operator -(float2 left, float2 right) => new(left.x - right.x, left.y - right.y); + public static float2 operator *(float2 left, float2 right) => new(left.x * right.x, left.y * right.y); + public static float2 operator /(float2 left, float2 right) => new(left.x / right.x, left.y / right.y); + public static float2 operator %(float2 left, float2 right) => new(left.x % right.x, left.y % right.y); +} diff --git a/src/CultMath/float3.cs b/src/CultMath/float3.cs new file mode 100644 index 00000000..037e8838 --- /dev/null +++ b/src/CultMath/float3.cs @@ -0,0 +1,31 @@ +namespace CultMath; + +public readonly record struct float3(float x, float y, float z) +{ + public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); + public static readonly float3 one = new(1.0f, 1.0f, 1.0f); + + public float2 xy => new(x, y); + public float2 xz => new(x, z); + public float2 yz => new(y, z); + + public float this[int index] => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator float3(float value) => new(value, value, value); + public static explicit operator System.Numerics.Vector3(float3 value) => new(value.x, value.y, value.z); + public static explicit operator float3(System.Numerics.Vector3 value) => new(value.X, value.Y, value.Z); + + public static float3 operator +(float3 value) => value; + public static float3 operator -(float3 value) => new(-value.x, -value.y, -value.z); + public static float3 operator +(float3 left, float3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); + public static float3 operator -(float3 left, float3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); + public static float3 operator *(float3 left, float3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); + public static float3 operator /(float3 left, float3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); + public static float3 operator %(float3 left, float3 right) => new(left.x % right.x, left.y % right.y, left.z % right.z); +} diff --git a/src/CultMath/float4.cs b/src/CultMath/float4.cs new file mode 100644 index 00000000..d9ec0982 --- /dev/null +++ b/src/CultMath/float4.cs @@ -0,0 +1,31 @@ +namespace CultMath; + +public readonly record struct float4(float x, float y, float z, float w) +{ + public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); + public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); + + public float2 xy => new(x, y); + public float3 xyz => new(x, y, z); + + public float this[int index] => index switch + { + 0 => x, + 1 => y, + 2 => z, + 3 => w, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator float4(float value) => new(value, value, value, value); + public static explicit operator System.Numerics.Vector4(float4 value) => new(value.x, value.y, value.z, value.w); + public static explicit operator float4(System.Numerics.Vector4 value) => new(value.X, value.Y, value.Z, value.W); + + public static float4 operator +(float4 value) => value; + public static float4 operator -(float4 value) => new(-value.x, -value.y, -value.z, -value.w); + public static float4 operator +(float4 left, float4 right) => new(left.x + right.x, left.y + right.y, left.z + right.z, left.w + right.w); + public static float4 operator -(float4 left, float4 right) => new(left.x - right.x, left.y - right.y, left.z - right.z, left.w - right.w); + public static float4 operator *(float4 left, float4 right) => new(left.x * right.x, left.y * right.y, left.z * right.z, left.w * right.w); + public static float4 operator /(float4 left, float4 right) => new(left.x / right.x, left.y / right.y, left.z / right.z, left.w / right.w); + public static float4 operator %(float4 left, float4 right) => new(left.x % right.x, left.y % right.y, left.z % right.z, left.w % right.w); +} diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs new file mode 100644 index 00000000..6b0cf455 --- /dev/null +++ b/src/CultMath/math.cs @@ -0,0 +1,114 @@ +namespace CultMath; + +public static class math +{ + public const float PI = MathF.PI; + public const float TAU = MathF.PI * 2.0f; + public const float HALF_PI = MathF.PI * 0.5f; + + public static float radians(float degrees) => degrees * (PI / 180.0f); + public static float degrees(float radians) => radians * (180.0f / PI); + + public static float abs(float value) => MathF.Abs(value); + public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); + public static float3 abs(float3 value) => new(abs(value.x), abs(value.y), abs(value.z)); + public static float4 abs(float4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); + + public static float floor(float value) => MathF.Floor(value); + public static float2 floor(float2 value) => new(floor(value.x), floor(value.y)); + public static float3 floor(float3 value) => new(floor(value.x), floor(value.y), floor(value.z)); + public static float4 floor(float4 value) => new(floor(value.x), floor(value.y), floor(value.z), floor(value.w)); + + public static float ceil(float value) => MathF.Ceiling(value); + public static float2 ceil(float2 value) => new(ceil(value.x), ceil(value.y)); + public static float3 ceil(float3 value) => new(ceil(value.x), ceil(value.y), ceil(value.z)); + public static float4 ceil(float4 value) => new(ceil(value.x), ceil(value.y), ceil(value.z), ceil(value.w)); + + public static float frac(float value) => value - floor(value); + public static float2 frac(float2 value) => value - floor(value); + public static float3 frac(float3 value) => value - floor(value); + public static float4 frac(float4 value) => value - floor(value); + + public static float min(float left, float right) => MathF.Min(left, right); + public static float2 min(float2 left, float2 right) => new(min(left.x, right.x), min(left.y, right.y)); + public static float3 min(float3 left, float3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); + public static float4 min(float4 left, float4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); + + public static float max(float left, float right) => MathF.Max(left, right); + public static float2 max(float2 left, float2 right) => new(max(left.x, right.x), max(left.y, right.y)); + public static float3 max(float3 left, float3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); + public static float4 max(float4 left, float4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); + + public static float clamp(float value, float minimum, float maximum) => min(max(value, minimum), maximum); + public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum); + public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum); + public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum); + + public static float saturate(float value) => clamp(value, 0.0f, 1.0f); + public static float2 saturate(float2 value) => clamp(value, 0.0f, 1.0f); + public static float3 saturate(float3 value) => clamp(value, 0.0f, 1.0f); + public static float4 saturate(float4 value) => clamp(value, 0.0f, 1.0f); + + public static float lerp(float start, float end, float amount) => start + (end - start) * amount; + public static float2 lerp(float2 start, float2 end, float2 amount) => start + (end - start) * amount; + public static float3 lerp(float3 start, float3 end, float3 amount) => start + (end - start) * amount; + public static float4 lerp(float4 start, float4 end, float4 amount) => start + (end - start) * amount; + + public static float step(float edge, float value) => value < edge ? 0.0f : 1.0f; + public static float2 step(float2 edge, float2 value) => new(step(edge.x, value.x), step(edge.y, value.y)); + public static float3 step(float3 edge, float3 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z)); + public static float4 step(float4 edge, float4 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z), step(edge.w, value.w)); + + public static float smoothstep(float minimum, float maximum, float value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float2 smoothstep(float2 minimum, float2 maximum, float2 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float3 smoothstep(float3 minimum, float3 maximum, float3 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float4 smoothstep(float4 minimum, float4 maximum, float4 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float dot(float2 left, float2 right) => left.x * right.x + left.y * right.y; + public static float dot(float3 left, float3 right) => left.x * right.x + left.y * right.y + left.z * right.z; + public static float dot(float4 left, float4 right) => left.x * right.x + left.y * right.y + left.z * right.z + left.w * right.w; + + public static float3 cross(float3 left, float3 right) => + new( + left.y * right.z - left.z * right.y, + left.z * right.x - left.x * right.z, + left.x * right.y - left.y * right.x); + + public static float lengthsq(float2 value) => dot(value, value); + public static float lengthsq(float3 value) => dot(value, value); + public static float lengthsq(float4 value) => dot(value, value); + + public static float length(float2 value) => MathF.Sqrt(lengthsq(value)); + public static float length(float3 value) => MathF.Sqrt(lengthsq(value)); + public static float length(float4 value) => MathF.Sqrt(lengthsq(value)); + + public static float distance(float2 left, float2 right) => length(left - right); + public static float distance(float3 left, float3 right) => length(left - right); + public static float distance(float4 left, float4 right) => length(left - right); + + public static float2 normalize(float2 value) => value / MathF.Max(length(value), 1.0e-20f); + public static float3 normalize(float3 value) => value / MathF.Max(length(value), 1.0e-20f); + public static float4 normalize(float4 value) => value / MathF.Max(length(value), 1.0e-20f); + + public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; + public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; +} diff --git a/tests/CultMath.Tests/CultMath.Tests.csproj b/tests/CultMath.Tests/CultMath.Tests.csproj new file mode 100644 index 00000000..1e77eba1 --- /dev/null +++ b/tests/CultMath.Tests/CultMath.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs new file mode 100644 index 00000000..80b0ed95 --- /dev/null +++ b/tests/CultMath.Tests/MathTests.cs @@ -0,0 +1,38 @@ +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +public sealed class MathTests +{ + [Fact] + public void FracMatchesShaderStylePositiveAndNegativeValues() + { + Assert.Equal(0.25f, math.frac(1.25f)); + Assert.Equal(0.75f, math.frac(-1.25f)); + } + + [Fact] + public void VectorOperationsAreComponentWise() + { + var value = new float3(1.0f, 2.0f, 3.0f) * new float3(4.0f, 5.0f, 6.0f); + + Assert.Equal(new float3(4.0f, 10.0f, 18.0f), value); + } + + [Fact] + public void NormalizeProducesUnitLength() + { + var value = math.normalize(new float3(3.0f, 4.0f, 0.0f)); + + Assert.Equal(1.0f, math.length(value), precision: 5); + } + + [Fact] + public void SmoothstepUsesHermiteRamp() + { + Assert.Equal(0.0f, math.smoothstep(0.0f, 1.0f, -1.0f)); + Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); + Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); + } +} From 5cef6d951e3bf8bf49c89d81675a88605b5aa599 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 20:33:04 +0100 Subject: [PATCH 02/23] Add shader-shaped vector constructors and trig intrinsics --- src/CultMath/float3.cs | 5 +++++ src/CultMath/float4.cs | 10 ++++++++++ src/CultMath/math.cs | 8 ++++++++ tests/CultMath.Tests/MathTests.cs | 17 +++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/src/CultMath/float3.cs b/src/CultMath/float3.cs index 037e8838..70cfa02b 100644 --- a/src/CultMath/float3.cs +++ b/src/CultMath/float3.cs @@ -5,6 +5,11 @@ public readonly record struct float3(float x, float y, float z) public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); public static readonly float3 one = new(1.0f, 1.0f, 1.0f); + public float3(float2 xy, float z) + : this(xy.x, xy.y, z) + { + } + public float2 xy => new(x, y); public float2 xz => new(x, z); public float2 yz => new(y, z); diff --git a/src/CultMath/float4.cs b/src/CultMath/float4.cs index d9ec0982..c7bfb404 100644 --- a/src/CultMath/float4.cs +++ b/src/CultMath/float4.cs @@ -5,6 +5,16 @@ public readonly record struct float4(float x, float y, float z, float w) public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); + public float4(float2 xy, float z, float w) + : this(xy.x, xy.y, z, w) + { + } + + public float4(float3 xyz, float w) + : this(xyz.x, xyz.y, xyz.z, w) + { + } + public float2 xy => new(x, y); public float3 xyz => new(x, y, z); diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index 6b0cf455..6d44f900 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -8,6 +8,14 @@ public static class math public static float radians(float degrees) => degrees * (PI / 180.0f); public static float degrees(float radians) => radians * (180.0f / PI); + public static float sin(float value) => MathF.Sin(value); + public static float cos(float value) => MathF.Cos(value); + public static float tan(float value) => MathF.Tan(value); + public static float asin(float value) => MathF.Asin(value); + public static float acos(float value) => MathF.Acos(value); + public static float atan(float value) => MathF.Atan(value); + public static float atan2(float y, float x) => MathF.Atan2(y, x); + public static float sqrt(float value) => MathF.Sqrt(value); public static float abs(float value) => MathF.Abs(value); public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 80b0ed95..8576a770 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -20,6 +20,14 @@ public sealed class MathTests Assert.Equal(new float3(4.0f, 10.0f, 18.0f), value); } + [Fact] + public void VectorConstructorsAcceptLowerDimensionVectors() + { + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), new float3(new float2(1.0f, 2.0f), 3.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float2(1.0f, 2.0f), 3.0f, 4.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); + } + [Fact] public void NormalizeProducesUnitLength() { @@ -35,4 +43,13 @@ public sealed class MathTests Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); } + + [Fact] + public void TrigIntrinsicsUseRadians() + { + Assert.Equal(1.0f, math.sin(math.HALF_PI), precision: 5); + Assert.Equal(-1.0f, math.cos(math.PI), precision: 5); + Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); + Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); + } } From bfe356fe92e26a641d4db0ba592f23d7ee00396b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 3 Jun 2026 02:58:56 +0100 Subject: [PATCH 03/23] Add Rust-backed Voronoi batch kernel --- .gitignore | 9 +- README.md | 32 +++-- docs/design.md | 7 +- native/cultmath-core/Cargo.lock | 7 ++ native/cultmath-core/Cargo.toml | 13 ++ native/cultmath-core/src/lib.rs | 188 +++++++++++++++++++++++++++++ src/CultMath/Color32.cs | 4 + src/CultMath/CultMath.csproj | 1 + src/CultMath/CultMathTone.cs | 10 ++ src/CultMath/Voronoi.cs | 190 ++++++++++++++++++++++++++++++ tests/CultMath.Tests/MathTests.cs | 16 +++ 11 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 native/cultmath-core/Cargo.lock create mode 100644 native/cultmath-core/Cargo.toml create mode 100644 native/cultmath-core/src/lib.rs create mode 100644 src/CultMath/Color32.cs create mode 100644 src/CultMath/CultMathTone.cs create mode 100644 src/CultMath/Voronoi.cs diff --git a/.gitignore b/.gitignore index 59e67361..6eae4393 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ +bin/ +obj/ **/bin/ **/obj/ -artifacts/ -*.user -*.suo -.vs/ -.idea/ +target/ +**/target/ diff --git a/README.md b/README.md index c3b1809d..b693d258 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # CultMath -Clean-room C# math shaped for people who think in HLSL. +Clean-room math shaped for people who think in HLSL. -CultMath provides lowercase vector types and shader-style intrinsics so -math-heavy renderer code can move between C# and HLSL without turning every -line into a translation exercise. It is not a Unity.Mathematics clone. It is a -small, Aquarium-born library built from public shader semantics and plain .NET. +CultMath provides lowercase vector types, shader-style intrinsics, and native +hot-path kernels so math-heavy renderer code can move between C#, Rust, and HLSL +without turning every line into a translation exercise. It is not a +Unity.Mathematics clone. It is a small, Aquarium-born library built from public +shader semantics. ## Current Surface @@ -17,6 +18,17 @@ small, Aquarium-born library built from public shader semantics and plain .NET. - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, `cross`, `length`, `distance`, `normalize`, and `reflect` +- `Voronoi.SampleTones`, a C# batch surface that calls the Rust + `cultmath-core` native kernel when `cultmath_core` is available and falls back + to the managed parity path otherwise. + +## Native Core + +The canonical hot-path body lives in `native/cultmath-core`. It exports a C ABI +for batch kernels such as `cultmath_apollonian_voronoi_tones`. + +Runtime wrappers own syntax and integration. Rust owns optimized primitive +kernel behavior and parity fixtures. ## Example @@ -33,11 +45,13 @@ float glow = math.smoothstep(0.2f, 1.0f, rim); ```powershell dotnet build CultMath.slnx dotnet test CultMath.slnx +cargo test --manifest-path native/cultmath-core/Cargo.toml +cargo build --release --manifest-path native/cultmath-core/Cargo.toml ``` ## Doctrine -The goal is mechanical sympathy between CPU-side authoring and GPU-side shader -math. If an intrinsic exists here, it should behave like the shader concept -unless .NET numeric rules make that impossible. Weird deviations get tests and -documentation, not folklore. +The goal is mechanical sympathy between CPU-side authoring, native kernels, and +GPU-side shader math. If an intrinsic exists here, it should behave like the +shader concept unless a runtime's numeric rules make that impossible. Weird +deviations get tests and documentation, not folklore. diff --git a/docs/design.md b/docs/design.md index 62102739..3c87fb51 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,11 +1,14 @@ # Design -CultMath exists to make renderer math readable in both places it needs to live: -C# host code and HLSL shader code. +CultMath exists to make renderer math readable in every place it needs to live: +Rust hot kernels, C# host code, and HLSL shader code. ## Rules - Keep type names HLSL-shaped: `float2`, `float3`, `float4`, and `math`. +- Keep Rust as the owner of native hot-path kernels and parity fixtures. +- Keep C#, shader, and scripting surfaces as runtime wrappers, not competing + math authorities. - Implement public shader semantics directly; do not copy Unity.Mathematics. - Prefer small immutable value types. - Prefer component-wise overloads over clever generic machinery until the API diff --git a/native/cultmath-core/Cargo.lock b/native/cultmath-core/Cargo.lock new file mode 100644 index 00000000..09598b97 --- /dev/null +++ b/native/cultmath-core/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cultmath-core" +version = "0.1.0" diff --git a/native/cultmath-core/Cargo.toml b/native/cultmath-core/Cargo.toml new file mode 100644 index 00000000..cf7819a0 --- /dev/null +++ b/native/cultmath-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cultmath-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "cultmath_core" +crate-type = ["cdylib", "staticlib"] + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/native/cultmath-core/src/lib.rs b/native/cultmath-core/src/lib.rs new file mode 100644 index 00000000..5a484608 --- /dev/null +++ b/native/cultmath-core/src/lib.rs @@ -0,0 +1,188 @@ +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CultMathColor32 { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +#[no_mangle] +pub unsafe extern "C" fn cultmath_apollonian_voronoi_tones( + xs: *const f32, + ys: *const f32, + tones: *const u8, + spans: *const f32, + count: usize, + resolution_y: f32, + frame_index: i32, + out_colors: *mut CultMathColor32, +) -> i32 { + if xs.is_null() || ys.is_null() || tones.is_null() || spans.is_null() || out_colors.is_null() { + return -1; + } + + let xs = unsafe { std::slice::from_raw_parts(xs, count) }; + let ys = unsafe { std::slice::from_raw_parts(ys, count) }; + let tones = unsafe { std::slice::from_raw_parts(tones, count) }; + let spans = unsafe { std::slice::from_raw_parts(spans, count) }; + let out_colors = unsafe { std::slice::from_raw_parts_mut(out_colors, count) }; + for index in 0..count { + out_colors[index] = tone_color( + xs[index], + ys[index], + resolution_y, + frame_index, + tones[index], + spans[index], + ); + } + + 0 +} + +fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> CultMathColor32 { + let sample = stable_sample(px, py, resolution_y, frame_index, tone, span); + let luminance = saturate(sample.0 * 0.299 + sample.1 * 0.587 + sample.2 * 0.114); + match tone { + 3 => { + let glow = 0.62 + luminance * 0.38; + color(255.0 * glow, 96.0 * glow, 14.0 * glow) + } + 4 => { + let glow = 0.68 + luminance * 0.32; + color(245.0 * glow, 248.0 * glow, 252.0 * glow) + } + _ => { + let (gain, lift) = match tone { + 1 => (0.25, 6.0), + 2 => (0.95, 20.0), + _ => (0.16, 2.0), + }; + color( + lift + sample.0 * 255.0 * gain, + lift + sample.1 * 255.0 * gain, + lift + sample.2 * 255.0 * gain, + ) + } + } +} + +fn stable_sample(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> (f32, f32, f32) { + let seed = px * 12.9898 + py * 78.233 + hash1((tone as f32 + 1.0) * 19.19); + let jitter_x = (hash1(seed) - 0.5) * span; + let jitter_y = (hash1(seed + 37.17) - 0.5) * span; + voronoi(px + jitter_x, py + jitter_y, resolution_y, frame_index) +} + +fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, f32) { + let time = frame_index as f32 / 120.0; + let scale = 6.0; + let x = scale * px / resolution_y.max(1.0); + let y = scale * py / resolution_y.max(1.0); + let nx = x.floor(); + let ny = y.floor(); + let fx = frac(x); + let fy = frac(y); + let mut distance = 8.0; + let mut red = 0.0; + let mut green = 0.0; + let mut blue = 0.0; + let smoothness = 0.005; + + for j in -2..=2 { + for i in -2..=2 { + let gx = i as f32; + let gy = j as f32; + let (mut ox, mut oy) = hash2(nx + gx, ny + gy); + let weight = ox * 0.5 + 0.5; + ox = 0.5 + 0.5 * (time + 6.2831 * ox).sin(); + oy = 0.5 + 0.5 * (time + 6.2831 * oy).sin(); + let dx = (gx - fx + ox).abs(); + let dy = (gy - fy + oy).abs(); + let d = dx.max(dy) * weight; + let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0); + let candidate_red = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); + let candidate_green = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 3.0).sin(); + let candidate_blue = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); + let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness); + let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness); + distance = lerp(distance, d, h) - correction; + red = lerp(red, candidate_red, h) - correction; + green = lerp(green, candidate_green, h) - correction; + blue = lerp(blue, candidate_blue, h) - correction; + } + } + + let edge_dimming = 1.0 - 0.1 * smoothstep(0.04, 0.05, distance); + ( + (red * edge_dimming).max(0.0), + (green * edge_dimming).max(0.0), + (blue * edge_dimming).max(0.0), + ) +} + +fn hash1(value: f32) -> f32 { + frac(value.sin() * 43758.5453) +} + +fn hash2(x: f32, y: f32) -> (f32, f32) { + ( + frac((x * 127.1 + y * 311.7).sin() * 43758.5453), + frac((x * 269.5 + y * 183.3).sin() * 43758.5453), + ) +} + +fn frac(value: f32) -> f32 { + value - value.floor() +} + +fn saturate(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + let t = saturate((value - edge0) / (edge1 - edge0)); + t * t * (3.0 - 2.0 * t) +} + +fn lerp(start: f32, end: f32, amount: f32) -> f32 { + start + (end - start) * amount +} + +fn color(r: f32, g: f32, b: f32) -> CultMathColor32 { + CultMathColor32 { + r: r.clamp(0.0, 255.0) as u8, + g: g.clamp(0.0, 255.0) as u8, + b: b.clamp(0.0, 255.0) as u8, + a: 255, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_sampler_writes_colors() { + let xs = [0.0, 20.0, 100.0]; + let ys = [0.0, 30.0, 140.0]; + let tones = [0, 3, 4]; + let spans = [1920.0, 8.0, 8.0]; + let mut out = [CultMathColor32::default(); 3]; + let rc = unsafe { + cultmath_apollonian_voronoi_tones( + xs.as_ptr(), + ys.as_ptr(), + tones.as_ptr(), + spans.as_ptr(), + xs.len(), + 1080.0, + 12, + out.as_mut_ptr(), + ) + }; + assert_eq!(rc, 0); + assert!(out.iter().any(|color| color.r != 0 || color.g != 0 || color.b != 0)); + } +} diff --git a/src/CultMath/Color32.cs b/src/CultMath/Color32.cs new file mode 100644 index 00000000..e9a2b481 --- /dev/null +++ b/src/CultMath/Color32.cs @@ -0,0 +1,4 @@ +namespace CultMath; + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public readonly record struct Color32(byte r, byte g, byte b, byte a = 255); diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index f06b8bed..83f6fb9d 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -12,6 +12,7 @@ https://github.com/GameCult/CultMath README.md $(NoWarn);CS8981 + true diff --git a/src/CultMath/CultMathTone.cs b/src/CultMath/CultMathTone.cs new file mode 100644 index 00000000..25dfffeb --- /dev/null +++ b/src/CultMath/CultMathTone.cs @@ -0,0 +1,10 @@ +namespace CultMath; + +public enum CultMathTone : byte +{ + Background = 0, + Panel = 1, + Edge = 2, + Header = 3, + Body = 4, +} diff --git a/src/CultMath/Voronoi.cs b/src/CultMath/Voronoi.cs new file mode 100644 index 00000000..52e6acb6 --- /dev/null +++ b/src/CultMath/Voronoi.cs @@ -0,0 +1,190 @@ +using System.Runtime.InteropServices; + +namespace CultMath; + +public static class Voronoi +{ + public static bool NativeAvailable { get; private set; } = true; + + public static Color32 SampleTone(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + Span xs = stackalloc[] { x }; + Span ys = stackalloc[] { y }; + Span tones = stackalloc[] { tone }; + Span spans = stackalloc[] { span }; + Span colors = stackalloc Color32[1]; + SampleTones(xs, ys, tones, spans, resolutionY, frameIndex, colors); + return colors[0]; + } + + public static void SampleTones( + ReadOnlySpan xs, + ReadOnlySpan ys, + ReadOnlySpan tones, + ReadOnlySpan spans, + float resolutionY, + int frameIndex, + Span colors) + { + if (xs.Length != ys.Length || xs.Length != tones.Length || xs.Length != spans.Length || xs.Length != colors.Length) + { + throw new ArgumentException("CultMath Voronoi batch spans must have equal length."); + } + + if (xs.IsEmpty) + { + return; + } + + if (NativeAvailable && TrySampleNative(xs, ys, tones, spans, resolutionY, frameIndex, colors)) + { + return; + } + + for (var index = 0; index < xs.Length; index++) + { + colors[index] = SampleManaged(xs[index], ys[index], resolutionY, frameIndex, tones[index], spans[index]); + } + } + + private static unsafe bool TrySampleNative( + ReadOnlySpan xs, + ReadOnlySpan ys, + ReadOnlySpan tones, + ReadOnlySpan spans, + float resolutionY, + int frameIndex, + Span colors) + { + try + { + fixed (float* xPtr = xs) + fixed (float* yPtr = ys) + fixed (CultMathTone* tonePtr = tones) + fixed (float* spanPtr = spans) + fixed (Color32* colorPtr = colors) + { + var result = Native.cultmath_apollonian_voronoi_tones( + xPtr, + yPtr, + (byte*)tonePtr, + spanPtr, + (nuint)xs.Length, + resolutionY, + frameIndex, + colorPtr); + if (result == 0) + { + return true; + } + } + } + catch (DllNotFoundException) + { + NativeAvailable = false; + } + catch (EntryPointNotFoundException) + { + NativeAvailable = false; + } + + return false; + } + + private static Color32 SampleManaged(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + var sample = StableSample(x, y, resolutionY, frameIndex, tone, span); + var luminance = math.saturate(sample.x * 0.299f + sample.y * 0.587f + sample.z * 0.114f); + if (tone == CultMathTone.Header) + { + var glow = 0.62f + luminance * 0.38f; + return new Color32((byte)(255 * glow), (byte)(96 * glow), (byte)(14 * glow)); + } + + if (tone == CultMathTone.Body) + { + var glow = 0.68f + luminance * 0.32f; + return new Color32((byte)(245 * glow), (byte)(248 * glow), (byte)(252 * glow)); + } + + var gain = tone switch + { + CultMathTone.Panel => 0.25f, + CultMathTone.Edge => 0.95f, + _ => 0.16f, + }; + var lift = tone switch + { + CultMathTone.Panel => 6.0f, + CultMathTone.Edge => 20.0f, + _ => 2.0f, + }; + return new Color32( + (byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255), + (byte)Math.Clamp(lift + sample.y * 255.0f * gain, 0, 255), + (byte)Math.Clamp(lift + sample.z * 255.0f * gain, 0, 255)); + } + + private static float3 StableSample(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + var seed = x * 12.9898f + y * 78.233f + Hash1(((byte)tone + 1.0f) * 19.19f); + var jitter = new float2((Hash1(seed) - 0.5f) * span, (Hash1(seed + 37.17f) - 0.5f) * span); + return SampleField(x + jitter.x, y + jitter.y, resolutionY, frameIndex); + } + + private static float3 SampleField(float px, float py, float resolutionY, int frameIndex) + { + var time = frameIndex / 120.0f; + var p = new float2(6.0f * px / Math.Max(1.0f, resolutionY), 6.0f * py / Math.Max(1.0f, resolutionY)); + var n = math.floor(p); + var f = math.frac(p); + var distance = 8.0f; + var color = float3.zero; + const float smoothness = 0.005f; + for (var j = -2; j <= 2; j++) + { + for (var i = -2; i <= 2; i++) + { + var g = new float2(i, j); + var o = Hash2(n + g); + var weight = o.x * 0.5f + 0.5f; + o = 0.5f + 0.5f * new float2(math.sin(time + 6.2831f * o.x), math.sin(time + 6.2831f * o.y)); + var d2 = math.abs(g - f + o); + var d = Math.Max(d2.x, d2.y) * weight; + var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f))); + var candidate = 0.5f + 0.5f * new float3( + math.sin(seed * 2.5f + 3.5f + 2.0f), + math.sin(seed * 2.5f + 3.5f + 3.0f), + math.sin(seed * 2.5f + 3.5f + 2.0f)); + var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness); + var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness); + distance = math.lerp(distance, d, h) - correction; + color = math.lerp(color, candidate, h) - correction; + } + } + + return math.max(float3.zero, color * (1.0f - 0.1f * math.smoothstep(0.04f, 0.05f, distance))); + } + + private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f); + + private static float2 Hash2(float2 value) + { + var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f))); + return math.frac(new float2(math.sin(p.x) * 43758.5453f, math.sin(p.y) * 43758.5453f)); + } + + private static unsafe partial class Native + { + [DllImport("cultmath_core", CallingConvention = CallingConvention.Cdecl)] + public static extern int cultmath_apollonian_voronoi_tones( + float* xs, + float* ys, + byte* tones, + float* spans, + nuint count, + float resolutionY, + int frameIndex, + Color32* outColors); + } +} diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 8576a770..2e0cea93 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -52,4 +52,20 @@ public sealed class MathTests Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); } + + [Fact] + public void VoronoiBatchProducesToneMappedColors() + { + var xs = new[] { 0.0f, 24.0f, 96.0f }; + var ys = new[] { 0.0f, 36.0f, 144.0f }; + var tones = new[] { CultMathTone.Background, CultMathTone.Header, CultMathTone.Body }; + var spans = new[] { 1920.0f, 8.0f, 8.0f }; + var colors = new Color32[xs.Length]; + + Voronoi.SampleTones(xs, ys, tones, spans, 1080.0f, 12, colors); + + Assert.NotEqual(new Color32(), colors[0]); + Assert.True(colors[1].r > colors[1].g); + Assert.True(colors[2].r > 120 && colors[2].g > 120 && colors[2].b > 120); + } } From b79b6d047fc850fa7ed38e0f4cc673b25ab50b23 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 3 Jun 2026 04:08:48 +0100 Subject: [PATCH 04/23] Broaden Voronoi color spectrum --- native/cultmath-core/src/lib.rs | 37 +++++++++++++++++---- src/CultMath/Voronoi.cs | 58 +++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/native/cultmath-core/src/lib.rs b/native/cultmath-core/src/lib.rs index 5a484608..46e7ced6 100644 --- a/native/cultmath-core/src/lib.rs +++ b/native/cultmath-core/src/lib.rs @@ -55,9 +55,9 @@ fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, s } _ => { let (gain, lift) = match tone { - 1 => (0.25, 6.0), - 2 => (0.95, 20.0), - _ => (0.16, 2.0), + 1 => (0.42, 14.0), + 2 => (1.05, 24.0), + _ => (0.24, 4.0), }; color( lift + sample.0 * 255.0 * gain, @@ -102,9 +102,8 @@ fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, let dy = (gy - fy + oy).abs(); let d = dx.max(dy) * weight; let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0); - let candidate_red = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); - let candidate_green = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 3.0).sin(); - let candidate_blue = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); + let hue = frac((nx + gx) * 0.173 + (ny + gy) * 0.379 + seed * 0.431); + let (candidate_red, candidate_green, candidate_blue) = pastel_spectrum(hue); let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness); let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness); distance = lerp(distance, d, h) - correction; @@ -150,6 +149,32 @@ fn lerp(start: f32, end: f32, amount: f32) -> f32 { start + (end - start) * amount } +fn pastel_spectrum(hue: f32) -> (f32, f32, f32) { + hsv_to_rgb(hue, 0.52, 0.98) +} + +fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) { + let h = frac(hue) * 6.0; + let c = value * saturation; + let x = c * (1.0 - ((h % 2.0) - 1.0).abs()); + let m = value - c; + let (r, g, b) = if h < 1.0 { + (c, x, 0.0) + } else if h < 2.0 { + (x, c, 0.0) + } else if h < 3.0 { + (0.0, c, x) + } else if h < 4.0 { + (0.0, x, c) + } else if h < 5.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + (r + m, g + m, b + m) +} + fn color(r: f32, g: f32, b: f32) -> CultMathColor32 { CultMathColor32 { r: r.clamp(0.0, 255.0) as u8, diff --git a/src/CultMath/Voronoi.cs b/src/CultMath/Voronoi.cs index 52e6acb6..9791a8e0 100644 --- a/src/CultMath/Voronoi.cs +++ b/src/CultMath/Voronoi.cs @@ -109,15 +109,15 @@ public static class Voronoi var gain = tone switch { - CultMathTone.Panel => 0.25f, - CultMathTone.Edge => 0.95f, - _ => 0.16f, + CultMathTone.Panel => 0.42f, + CultMathTone.Edge => 1.05f, + _ => 0.24f, }; var lift = tone switch { - CultMathTone.Panel => 6.0f, - CultMathTone.Edge => 20.0f, - _ => 2.0f, + CultMathTone.Panel => 14.0f, + CultMathTone.Edge => 24.0f, + _ => 4.0f, }; return new Color32( (byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255), @@ -152,10 +152,8 @@ public static class Voronoi var d2 = math.abs(g - f + o); var d = Math.Max(d2.x, d2.y) * weight; var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f))); - var candidate = 0.5f + 0.5f * new float3( - math.sin(seed * 2.5f + 3.5f + 2.0f), - math.sin(seed * 2.5f + 3.5f + 3.0f), - math.sin(seed * 2.5f + 3.5f + 2.0f)); + var hue = math.frac((n.x + g.x) * 0.173f + (n.y + g.y) * 0.379f + seed * 0.431f); + var candidate = PastelSpectrum(hue); var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness); var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness); distance = math.lerp(distance, d, h) - correction; @@ -168,6 +166,46 @@ public static class Voronoi private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f); + private static float3 PastelSpectrum(float hue) + { + return HsvToRgb(hue, 0.52f, 0.98f); + } + + private static float3 HsvToRgb(float hue, float saturation, float value) + { + var h = math.frac(hue) * 6.0f; + var c = value * saturation; + var x = c * (1.0f - Math.Abs(h % 2.0f - 1.0f)); + var m = value - c; + float3 rgb; + if (h < 1.0f) + { + rgb = new float3(c, x, 0.0f); + } + else if (h < 2.0f) + { + rgb = new float3(x, c, 0.0f); + } + else if (h < 3.0f) + { + rgb = new float3(0.0f, c, x); + } + else if (h < 4.0f) + { + rgb = new float3(0.0f, x, c); + } + else if (h < 5.0f) + { + rgb = new float3(x, 0.0f, c); + } + else + { + rgb = new float3(c, 0.0f, x); + } + + return rgb + m; + } + private static float2 Hash2(float2 value) { var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f))); From f535801cd0ceb273fabb96a9bd8ad508489ef28c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 10 Jun 2026 21:22:43 +0100 Subject: [PATCH 05/23] Promote shader math primitives --- README.md | 30 ++- docs/design.md | 9 +- shaders/CultMath.hlsl | 254 ++++++++++++++++++++++++ src/CultMath/CultMath.csproj | 1 + src/CultMath/math.cs | 202 +++++++++++++++++++ tests/CultMath.Tests/HlslMirrorTests.cs | 63 ++++++ tests/CultMath.Tests/MathTests.cs | 48 +++++ 7 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 shaders/CultMath.hlsl create mode 100644 tests/CultMath.Tests/HlslMirrorTests.cs diff --git a/README.md b/README.md index b693d258..ef69df80 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ shader semantics. - conversions to and from `System.Numerics` - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, - `cross`, `length`, `distance`, `normalize`, and `reflect` + `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential + `decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and + deterministic value-noise primitives +- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side + parity. HLSL already owns `float2`, `float3`, and `float4`; the include + exposes `cultmath_*` functions for shared semantics, including CultMath's + safe `normalize` contract. - `Voronoi.SampleTones`, a C# batch surface that calls the Rust `cultmath-core` native kernel when `cultmath_core` is available and falls back to the managed parity path otherwise. @@ -38,8 +44,25 @@ using CultMath; float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f)); float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f))); float glow = math.smoothstep(0.2f, 1.0f, rim); +float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f)); ``` +```hlsl +#include "CultMath.hlsl" + +float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1)); +float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0))); +float glow = cultmath_smoothstep(0.2, 1.0, rim); +float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5)); +``` + +## Provenance + +The small spline, damping, and pursuit helpers were promoted from Aetheria's +local `AetheriaMath` surface. The value-noise family follows public shader +literature from Inigo Quilez's noise and mini-spline articles, expressed in +CultMath names with tests instead of copied project-local helper drift. + ## Build ```powershell @@ -55,3 +78,8 @@ The goal is mechanical sympathy between CPU-side authoring, native kernels, and GPU-side shader math. If an intrinsic exists here, it should behave like the shader concept unless a runtime's numeric rules make that impossible. Weird deviations get tests and documentation, not folklore. + +CultMath is still not a shader generator. Projects should include +`shaders/CultMath.hlsl` directly or add that directory to their shader include +path. If a shared operation needs CPU/GPU parity, put the primitive here and +test it here before copying local math helpers into a renderer or daemon. diff --git a/docs/design.md b/docs/design.md index 3c87fb51..8250ba78 100644 --- a/docs/design.md +++ b/docs/design.md @@ -9,16 +9,21 @@ Rust hot kernels, C# host code, and HLSL shader code. - Keep Rust as the owner of native hot-path kernels and parity fixtures. - Keep C#, shader, and scripting surfaces as runtime wrappers, not competing math authorities. +- Keep `shaders/CultMath.hlsl` as the canonical shader mirror for shared + intrinsics. Project-local shader helpers should move here once more than one + organ needs them or once CPU/GPU parity matters. - Implement public shader semantics directly; do not copy Unity.Mathematics. - Prefer small immutable value types. - Prefer component-wise overloads over clever generic machinery until the API earns that complexity. -- Keep the first surface boring and exact. Noise, matrices, quaternions, and - packing helpers come later. +- Keep the surface boring and exact. Noise primitives are allowed when they are + source-grounded, shader-shaped, and shared by more than one project. Matrices, + quaternions, and packing helpers still need an owner before they enter. ## Non-Goals - Compiling C# to shaders. +- Generating HLSL from Rust or Rust from HLSL. - Replacing `System.Numerics` for general .NET work. - Recreating every Unity.Mathematics type because a spreadsheet somewhere says “coverage.” That way lies decorative obesity. diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl new file mode 100644 index 00000000..3cbb3fdd --- /dev/null +++ b/shaders/CultMath.hlsl @@ -0,0 +1,254 @@ +#ifndef CULTMATH_HLSL +#define CULTMATH_HLSL + +// CultMath HLSL mirror. +// +// HLSL already owns the float2/float3/float4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +static const float CULTMATH_PI = 3.14159265358979323846; +static const float CULTMATH_TAU = 6.28318530717958647692; +static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +float2 cultmath_frac(float2 value) { return value - floor(value); } +float3 cultmath_frac(float3 value) { return value - floor(value); } +float4 cultmath_frac(float4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } +float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } +float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } + +float cultmath_saturate(float value) { return cultmath_clamp(value, 0.0, 1.0); } +float2 cultmath_saturate(float2 value) { return cultmath_clamp(value, float2(0.0, 0.0), float2(1.0, 1.0)); } +float3 cultmath_saturate(float3 value) { return cultmath_clamp(value, float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0)); } +float4 cultmath_saturate(float4 value) { return cultmath_clamp(value, float4(0.0, 0.0, 0.0, 0.0), float4(1.0, 1.0, 1.0, 1.0)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } +float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } +float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) +{ + float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); +} + +float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) +{ + float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); +} + +float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) +{ + float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +float cultmath_lengthsq(float2 value) { return dot(value, value); } +float cultmath_lengthsq(float3 value) { return dot(value, value); } +float cultmath_lengthsq(float4 value) { return dot(value, value); } + +float cultmath_distance(float2 left, float2 right) { return length(left - right); } +float cultmath_distance(float3 left, float3 right) { return length(left - right); } +float cultmath_distance(float4 left, float4 right) { return length(left - right); } + +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + +float2 cultmath_reflect(float2 incident, float2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float3 cultmath_reflect(float3 incident, float3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float cultmath_csum(float2 value) { return value.x + value.y; } +float cultmath_csum(float3 value) { return value.x + value.y + value.z; } +float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } +float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } + +float cultmath_value_noise(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + float2(1.0, 0.0)); + float c = cultmath_hash(cell + float2(0.0, 1.0)); + float d = cultmath_hash(cell + float2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) +{ + return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; +} + +float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) +{ + float2 position = uv * scale; + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 base_uv = cell / scale; + + float row0 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, + local.x); + float row1 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, + local.x); + float row2 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, + local.x); + float row3 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, + local.x); + + return cultmath_catmullrom(row0, row1, row2, row3, local.y); +} + +#endif diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index 83f6fb9d..f43adce3 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -17,6 +17,7 @@ + diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index 6d44f900..817aa7fd 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -6,6 +6,16 @@ public static class math public const float TAU = MathF.PI * 2.0f; public const float HALF_PI = MathF.PI * 0.5f; + public static float2 float2(float x, float y) => new(x, y); + public static float2 float2(float value) => new(value, value); + public static float3 float3(float x, float y, float z) => new(x, y, z); + public static float3 float3(float2 xy, float z) => new(xy, z); + public static float3 float3(float value) => new(value, value, value); + public static float4 float4(float x, float y, float z, float w) => new(x, y, z, w); + public static float4 float4(float2 xy, float z, float w) => new(xy, z, w); + public static float4 float4(float3 xyz, float w) => new(xyz, w); + public static float4 float4(float value) => new(value, value, value, value); + public static float radians(float degrees) => degrees * (PI / 180.0f); public static float degrees(float radians) => radians * (180.0f / PI); public static float sin(float value) => MathF.Sin(value); @@ -16,6 +26,10 @@ public static class math public static float atan(float value) => MathF.Atan(value); public static float atan2(float y, float x) => MathF.Atan2(y, x); public static float sqrt(float value) => MathF.Sqrt(value); + public static float exp(float value) => MathF.Exp(value); + public static float2 exp(float2 value) => new(exp(value.x), exp(value.y)); + public static float3 exp(float3 value) => new(exp(value.x), exp(value.y), exp(value.z)); + public static float4 exp(float4 value) => new(exp(value.x), exp(value.y), exp(value.z), exp(value.w)); public static float abs(float value) => MathF.Abs(value); public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); @@ -119,4 +133,192 @@ public static class math public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; + + public static float csum(float2 value) => value.x + value.y; + public static float csum(float3 value) => value.x + value.y + value.z; + public static float csum(float4 value) => value.x + value.y + value.z + value.w; + + public static float decay(float source, float lambda, float dt) => source * exp(-lambda * dt); + public static float2 decay(float2 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float3 decay(float3 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float4 decay(float4 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float2 decay(float2 source, float2 lambda, float dt) => source * exp(-lambda * dt); + public static float3 decay(float3 source, float3 lambda, float dt) => source * exp(-lambda * dt); + public static float4 decay(float4 source, float4 lambda, float dt) => source * exp(-lambda * dt); + + public static float damp(float start, float end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float2 damp(float2 start, float2 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float3 damp(float3 start, float3 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float4 damp(float4 start, float4 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + + public static float first_order_intercept_time(float shotSpeed, float3 targetRelativePosition, float3 targetRelativeVelocity) + { + var velocitySquared = lengthsq(targetRelativeVelocity); + if (velocitySquared < 0.001f) + { + return 0.0f; + } + + var a = velocitySquared - shotSpeed * shotSpeed; + if (abs(a) < 0.001f) + { + var time = -lengthsq(targetRelativePosition) / (2.0f * dot(targetRelativeVelocity, targetRelativePosition)); + return max(time, 0.0f); + } + + var b = 2.0f * dot(targetRelativeVelocity, targetRelativePosition); + var c = lengthsq(targetRelativePosition); + var determinant = b * b - 4.0f * a * c; + + if (determinant > 0.0f) + { + var root = sqrt(determinant); + var t1 = (-b + root) / (2.0f * a); + var t2 = (-b - root) / (2.0f * a); + if (t1 > 0.0f) + { + return t2 > 0.0f ? min(t1, t2) : t1; + } + + return max(t2, 0.0f); + } + + if (determinant < 0.0f) + { + return 0.0f; + } + + return max(-b / (2.0f * a), 0.0f); + } + + public static float3 first_order_intercept( + float3 shooterPosition, + float3 shooterVelocity, + float shotSpeed, + float3 targetPosition, + float3 targetVelocity) + { + var targetRelativePosition = targetPosition - shooterPosition; + var targetRelativeVelocity = targetVelocity - shooterVelocity; + var time = first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); + return targetPosition + time * targetRelativeVelocity; + } + + public static float distance_to_segment(float2 point, float2 start, float2 end, out float2 closest) + { + var segment = end - start; + var segmentLengthSq = lengthsq(segment); + if (segmentLengthSq <= 0.0f) + { + closest = start; + return distance(point, start); + } + + var t = saturate(dot(point - start, segment) / segmentLengthSq); + closest = start + segment * t; + return distance(point, closest); + } + + public static float catmullrom(float p0, float p1, float p2, float p3, float t) + { + var t2 = t * t; + var t3 = t2 * t; + return 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3); + } + + public static float2 catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float3 catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float4 catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float quadratic_bezier(float p0, float p1, float p2, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; + } + + public static float3 quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; + } + + public static float cubic_bezier(float p0, float p1, float p2, float p3, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; + } + + public static float3 cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; + } + + public static float smoothstep01(float value) => smoothstep(0.0f, 1.0f, value); + + public static float smootherstep(float value) + { + value = saturate(value); + return value * value * value * (value * (value * 6.0f - 15.0f) + 10.0f); + } + + public static float hash(float value) => frac(sin(value) * 43758.5453f); + public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f))); + public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f))); + + public static float value_noise(float2 position) + { + var cell = floor(position); + var local = frac(position); + var u = local * local * (3.0f - 2.0f * local); + + var a = hash(cell); + var b = hash(cell + new float2(1.0f, 0.0f)); + var c = hash(cell + new float2(0.0f, 1.0f)); + var d = hash(cell + new float2(1.0f, 1.0f)); + + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); + } + + public static float value_noise_bicubic(float2 position) + { + var cell = floor(position); + var local = frac(position); + + var y0 = catmullrom( + hash(cell + new float2(-1.0f, -1.0f)), + hash(cell + new float2(0.0f, -1.0f)), + hash(cell + new float2(1.0f, -1.0f)), + hash(cell + new float2(2.0f, -1.0f)), + local.x); + var y1 = catmullrom( + hash(cell + new float2(-1.0f, 0.0f)), + hash(cell + new float2(0.0f, 0.0f)), + hash(cell + new float2(1.0f, 0.0f)), + hash(cell + new float2(2.0f, 0.0f)), + local.x); + var y2 = catmullrom( + hash(cell + new float2(-1.0f, 1.0f)), + hash(cell + new float2(0.0f, 1.0f)), + hash(cell + new float2(1.0f, 1.0f)), + hash(cell + new float2(2.0f, 1.0f)), + local.x); + var y3 = catmullrom( + hash(cell + new float2(-1.0f, 2.0f)), + hash(cell + new float2(0.0f, 2.0f)), + hash(cell + new float2(1.0f, 2.0f)), + hash(cell + new float2(2.0f, 2.0f)), + local.x); + + return catmullrom(y0, y1, y2, y3, local.y); + } } diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs new file mode 100644 index 00000000..91a95f4d --- /dev/null +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -0,0 +1,63 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class HlslMirrorTests +{ + [Fact] + public void HlslMirrorPublishesCultMathPrimitives() + { + var include = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl")); + var requiredSymbols = new[] + { + "cultmath_radians", + "cultmath_degrees", + "cultmath_frac", + "cultmath_clamp", + "cultmath_saturate", + "cultmath_lerp", + "cultmath_step", + "cultmath_smoothstep", + "cultmath_smootherstep", + "cultmath_lengthsq", + "cultmath_distance", + "cultmath_normalize", + "cultmath_reflect", + "cultmath_csum", + "cultmath_decay", + "cultmath_damp", + "cultmath_catmullrom", + "cultmath_quadratic_bezier", + "cultmath_cubic_bezier", + "cultmath_hash", + "cultmath_value_noise", + "cultmath_value_noise_bicubic", + "cultmath_value_noise_texture", + "cultmath_value_noise_texture_bicubic", + }; + + foreach (var symbol in requiredSymbols) + { + Assert.Contains(symbol, include); + } + + Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include); + Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CultMath.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate CultMath repository root."); + } +} diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 2e0cea93..18fa203a 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -23,6 +23,9 @@ public sealed class MathTests [Fact] public void VectorConstructorsAcceptLowerDimensionVectors() { + Assert.Equal(new float2(1.0f, 2.0f), math.float2(1.0f, 2.0f)); + Assert.Equal(new float3(1.0f, 1.0f, 1.0f), math.float3(1.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); Assert.Equal(new float3(1.0f, 2.0f, 3.0f), new float3(new float2(1.0f, 2.0f), 3.0f)); Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float2(1.0f, 2.0f), 3.0f, 4.0f)); Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); @@ -42,6 +45,7 @@ public sealed class MathTests Assert.Equal(0.0f, math.smoothstep(0.0f, 1.0f, -1.0f)); Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); + Assert.Equal(0.5f, math.smootherstep(0.5f), precision: 5); } [Fact] @@ -53,6 +57,50 @@ public sealed class MathTests Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); } + [Fact] + public void AetheriaPrimitivesRemainSmallAndDeterministic() + { + Assert.Equal(6.0f, math.csum(new float3(1.0f, 2.0f, 3.0f))); + Assert.Equal(MathF.Exp(-2.0f), math.decay(1.0f, 2.0f, 1.0f), precision: 5); + Assert.Equal(1.0f - MathF.Exp(-2.0f), math.damp(0.0f, 1.0f, 2.0f, 1.0f), precision: 5); + + Assert.Equal(15.0f, math.catmullrom(0.0f, 10.0f, 20.0f, 30.0f, 0.5f), precision: 5); + Assert.Equal(0.25f, math.quadratic_bezier(0.0f, 0.0f, 1.0f, 0.5f), precision: 5); + Assert.Equal(0.5f, math.cubic_bezier(0.0f, 0.0f, 1.0f, 1.0f, 0.5f), precision: 5); + } + + [Fact] + public void SegmentDistanceReportsClosestPoint() + { + var distance = math.distance_to_segment(new float2(2.0f, 3.0f), new float2(0.0f, 0.0f), new float2(4.0f, 0.0f), out var closest); + + Assert.Equal(3.0f, distance, precision: 5); + Assert.Equal(new float2(2.0f, 0.0f), closest); + } + + [Fact] + public void FirstOrderInterceptSolvesSimpleClosingTarget() + { + var time = math.first_order_intercept_time( + 2.0f, + new float3(10.0f, 0.0f, 0.0f), + new float3(-1.0f, 0.0f, 0.0f)); + + Assert.Equal(10.0f / 3.0f, time, precision: 5); + } + + [Fact] + public void ValueNoiseIsDeterministicAndSmoothAtCellCorners() + { + var position = new float2(12.25f, -4.5f); + + Assert.Equal(math.value_noise(position), math.value_noise(position)); + Assert.Equal(math.hash(new float2(3.0f, 5.0f)), math.value_noise(new float2(3.0f, 5.0f)), precision: 5); + + var bicubic = math.value_noise_bicubic(position); + Assert.True(bicubic > -0.25f && bicubic < 1.25f); + } + [Fact] public void VoronoiBatchProducesToneMappedColors() { From 2c9f81fbf1abec2228569bb5d672aad76b5f1d8e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 10 Jun 2026 21:30:49 +0100 Subject: [PATCH 06/23] Add HLSL SPIR-V tool scripts --- .gitignore | 1 + README.md | 16 ++++++++++ tools/compile-hlsl-spirv.ps1 | 58 ++++++++++++++++++++++++++++++++++++ tools/get-dxc.ps1 | 42 ++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 tools/compile-hlsl-spirv.ps1 create mode 100644 tools/get-dxc.ps1 diff --git a/.gitignore b/.gitignore index 6eae4393..5ae20c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ obj/ **/obj/ target/ **/target/ +.tools/ diff --git a/README.md b/README.md index ef69df80..7627b773 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,22 @@ cargo test --manifest-path native/cultmath-core/Cargo.toml cargo build --release --manifest-path native/cultmath-core/Cargo.toml ``` +## Shader Tooling + +CultMath keeps portable DXC outside git under `.tools/`: + +```powershell +.\tools\get-dxc.ps1 +.\tools\compile-hlsl-spirv.ps1 ` + -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` + -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` + -IncludePath E:\Projects\CultMath\shaders +``` + +DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the +script, but the current Windows DXC release compiled this shader successfully +with DXC's default Vulkan target while rejecting the dotted target-env value. + ## Doctrine The goal is mechanical sympathy between CPU-side authoring, native kernels, and diff --git a/tools/compile-hlsl-spirv.ps1 b/tools/compile-hlsl-spirv.ps1 new file mode 100644 index 00000000..98270cb0 --- /dev/null +++ b/tools/compile-hlsl-spirv.ps1 @@ -0,0 +1,58 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ShaderPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [string]$EntryPoint = "main", + [string]$Profile = "cs_6_0", + [string[]]$IncludePath = @(), + [string]$TargetEnv = "", + [string]$DxcPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $DxcPath) { + $DxcPath = Join-Path $repoRoot ".tools\dxc\current\bin\x64\dxc.exe" +} + +if (-not (Test-Path $DxcPath)) { + throw "DXC not found at '$DxcPath'. Run tools\get-dxc.ps1 first or pass -DxcPath." +} + +$resolvedShader = Resolve-Path $ShaderPath +$resolvedIncludes = @((Join-Path $repoRoot "shaders")) +foreach ($path in $IncludePath) { + $resolvedIncludes += (Resolve-Path $path) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +$arguments = @( + "-T", $Profile, + "-E", $EntryPoint, + "-spirv" +) + +if ($TargetEnv) { + $arguments += "-fspv-target-env=$TargetEnv" +} + +foreach ($include in $resolvedIncludes) { + $arguments += @("-I", $include) +} + +$arguments += @($resolvedShader, "-Fo", $OutputPath) + +& $DxcPath @arguments +if ($LASTEXITCODE -ne 0) { + throw "dxc.exe failed with exit code $LASTEXITCODE." +} + +Get-Item -LiteralPath $OutputPath diff --git a/tools/get-dxc.ps1 b/tools/get-dxc.ps1 new file mode 100644 index 00000000..2f3363f3 --- /dev/null +++ b/tools/get-dxc.ps1 @@ -0,0 +1,42 @@ +param( + [string]$InstallRoot = ".tools\dxc" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installPath = Join-Path $repoRoot $InstallRoot +New-Item -ItemType Directory -Force -Path $installPath | Out-Null + +$release = Invoke-RestMethod ` + -Uri "https://api.github.com/repos/microsoft/DirectXShaderCompiler/releases/latest" ` + -Headers @{ "User-Agent" = "CultMath-DXC-Bootstrap" } + +$asset = $release.assets | + Where-Object { $_.name -like "dxc_*.zip" } | + Select-Object -First 1 + +if (-not $asset) { + throw "No Windows dxc_*.zip asset found on the latest DirectXShaderCompiler release." +} + +$zipPath = Join-Path $installPath $asset.name +Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue + +& curl.exe -L --retry 5 --retry-delay 2 --fail -o $zipPath $asset.browser_download_url +if ($LASTEXITCODE -ne 0) { + throw "curl.exe failed with exit code $LASTEXITCODE." +} + +$currentPath = Join-Path $installPath "current" +Remove-Item -LiteralPath $currentPath -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $currentPath | Out-Null +Expand-Archive -LiteralPath $zipPath -DestinationPath $currentPath -Force + +$dxcPath = Join-Path $currentPath "bin\x64\dxc.exe" +if (-not (Test-Path $dxcPath)) { + throw "DXC x64 executable was not found after extraction." +} + +& $dxcPath --version +Write-Host "DXC installed at $dxcPath" From 788a75dba537be7b475e3b240a5189363272eb1d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 11 Jun 2026 06:57:22 +0100 Subject: [PATCH 07/23] Add SPIR-V to Metal tooling --- README.md | 9 +++++++++ tools/compile-hlsl-spirv.ps1 | 11 +++++++++++ tools/compile-spirv-metal.ps1 | 34 ++++++++++++++++++++++++++++++++++ tools/get-spirv-cross.ps1 | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 tools/compile-spirv-metal.ps1 create mode 100644 tools/get-spirv-cross.ps1 diff --git a/README.md b/README.md index 7627b773..d6623e46 100644 --- a/README.md +++ b/README.md @@ -78,15 +78,24 @@ CultMath keeps portable DXC outside git under `.tools/`: ```powershell .\tools\get-dxc.ps1 +.\tools\get-spirv-cross.ps1 .\tools\compile-hlsl-spirv.ps1 ` -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` -IncludePath E:\Projects\CultMath\shaders +.\tools\compile-spirv-metal.ps1 ` + -SpirvPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` + -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal ``` DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the script, but the current Windows DXC release compiled this shader successfully with DXC's default Vulkan target while rejecting the dotted target-env value. +The SPIR-V script applies default Vulkan register shifts for cbuffers, textures, +UAVs, and samplers so cross-compiled Metal buffers do not alias by resource +class. SPIRV-Cross emits Metal Shading Language from SPIR-V on Windows; building +an iPad-ready `.metallib` still belongs to Apple's Metal compiler on an Apple +toolchain. ## Doctrine diff --git a/tools/compile-hlsl-spirv.ps1 b/tools/compile-hlsl-spirv.ps1 index 98270cb0..280e08d5 100644 --- a/tools/compile-hlsl-spirv.ps1 +++ b/tools/compile-hlsl-spirv.ps1 @@ -9,6 +9,10 @@ param( [string]$Profile = "cs_6_0", [string[]]$IncludePath = @(), [string]$TargetEnv = "", + [int]$VulkanCBufferShift = 0, + [int]$VulkanTextureShift = 10, + [int]$VulkanUavShift = 20, + [int]$VulkanSamplerShift = 30, [string]$DxcPath = "" ) @@ -44,6 +48,13 @@ if ($TargetEnv) { $arguments += "-fspv-target-env=$TargetEnv" } +$arguments += @( + "-fvk-b-shift", $VulkanCBufferShift.ToString(), "0", + "-fvk-t-shift", $VulkanTextureShift.ToString(), "0", + "-fvk-u-shift", $VulkanUavShift.ToString(), "0", + "-fvk-s-shift", $VulkanSamplerShift.ToString(), "0" +) + foreach ($include in $resolvedIncludes) { $arguments += @("-I", $include) } diff --git a/tools/compile-spirv-metal.ps1 b/tools/compile-spirv-metal.ps1 new file mode 100644 index 00000000..490e6331 --- /dev/null +++ b/tools/compile-spirv-metal.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SpirvPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [int]$MslVersion = 23000, + [string]$SpirvCrossPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $SpirvCrossPath) { + $SpirvCrossPath = Join-Path $repoRoot ".tools\spirv-cross\build\spirv-cross.exe" +} + +if (-not (Test-Path $SpirvCrossPath)) { + throw "SPIRV-Cross not found at '$SpirvCrossPath'. Run tools\get-spirv-cross.ps1 first or pass -SpirvCrossPath." +} + +$resolvedSpirv = Resolve-Path $SpirvPath +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +& $SpirvCrossPath $resolvedSpirv --msl --msl-version $MslVersion --output $OutputPath +if ($LASTEXITCODE -ne 0) { + throw "spirv-cross failed with exit code $LASTEXITCODE." +} + +Get-Item -LiteralPath $OutputPath diff --git a/tools/get-spirv-cross.ps1 b/tools/get-spirv-cross.ps1 new file mode 100644 index 00000000..f5105b9a --- /dev/null +++ b/tools/get-spirv-cross.ps1 @@ -0,0 +1,34 @@ +param( + [string]$InstallRoot = ".tools\spirv-cross" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installPath = Join-Path $repoRoot $InstallRoot +$sourcePath = Join-Path $installPath "src" +$buildPath = Join-Path $installPath "build" + +New-Item -ItemType Directory -Force -Path $installPath | Out-Null + +if (-not (Test-Path $sourcePath)) { + git clone --depth 1 https://github.com/KhronosGroup/SPIRV-Cross.git $sourcePath +} + +cmake ` + -S $sourcePath ` + -B $buildPath ` + -G Ninja ` + -DSPIRV_CROSS_CLI=ON ` + -DSPIRV_CROSS_ENABLE_TESTS=OFF ` + -DSPIRV_CROSS_ENABLE_C_API=OFF + +cmake --build $buildPath --target spirv-cross --config Release + +$spirvCrossPath = Join-Path $buildPath "spirv-cross.exe" +if (-not (Test-Path $spirvCrossPath)) { + throw "SPIRV-Cross executable was not found after build." +} + +& $spirvCrossPath --version +Write-Host "SPIRV-Cross installed at $spirvCrossPath" From 26d9996d7b41e9fae09ba47976137e58259a4be9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 02:53:02 +0100 Subject: [PATCH 08/23] Add SIMD SoA batch math kernels --- README.md | 3 + src/CultMath/BatchMath.cs | 147 ++++++++++++++++++++++++++++++ tests/CultMath.Tests/MathTests.cs | 37 ++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/CultMath/BatchMath.cs diff --git a/README.md b/README.md index d6623e46..1050e4ec 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ shader semantics. - scalar-to-vector splats - swizzles for common lanes - conversions to and from `System.Numerics` +- `BatchMath` SoA kernels over `Span` / `ReadOnlySpan` using + `System.Numerics.Vector` when available, including radial falloff + acceleration and semi-implicit Euler integration - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential diff --git a/src/CultMath/BatchMath.cs b/src/CultMath/BatchMath.cs new file mode 100644 index 00000000..df62e07c --- /dev/null +++ b/src/CultMath/BatchMath.cs @@ -0,0 +1,147 @@ +using System.Numerics; + +namespace CultMath; + +public static class BatchMath +{ + private const float MinimumDistanceSquared = 1.0e-8f; + + public static int LaneCount => Vector.Count; + + public static bool IsHardwareAccelerated => Vector.IsHardwareAccelerated; + + public static void Clear(Span values) => values.Clear(); + + public static void AddRadialFalloffAcceleration2D( + ReadOnlySpan positionX, + ReadOnlySpan positionY, + float centerX, + float centerY, + float strength, + float radius, + Span accelerationX, + Span accelerationY) + { + ValidateEqualLengths(positionX.Length, positionY.Length, accelerationX.Length, accelerationY.Length); + + if (radius <= 0.0f || strength == 0.0f || positionX.Length == 0) + { + return; + } + + var radiusVector = new Vector(radius); + var centerXVector = new Vector(centerX); + var centerYVector = new Vector(centerY); + var strengthVector = new Vector(strength); + var one = Vector.One; + var zero = Vector.Zero; + var minimumDistanceSquared = new Vector(MinimumDistanceSquared); + + var i = 0; + var lanes = Vector.Count; + for (; i <= positionX.Length - lanes; i += lanes) + { + var px = new Vector(positionX.Slice(i, lanes)); + var py = new Vector(positionY.Slice(i, lanes)); + var dx = centerXVector - px; + var dy = centerYVector - py; + var distanceSquared = Vector.Max(dx * dx + dy * dy, minimumDistanceSquared); + var distance = Vector.SquareRoot(distanceSquared); + var inRange = Vector.LessThanOrEqual(distance, radiusVector); + var falloff = Vector.Max(zero, one - distance / radiusVector); + var magnitude = strengthVector * falloff; + var ax = Vector.ConditionalSelect(inRange, dx / distance * magnitude, zero); + var ay = Vector.ConditionalSelect(inRange, dy / distance * magnitude, zero); + + (new Vector(accelerationX.Slice(i, lanes)) + ax).CopyTo(accelerationX.Slice(i, lanes)); + (new Vector(accelerationY.Slice(i, lanes)) + ay).CopyTo(accelerationY.Slice(i, lanes)); + } + + for (; i < positionX.Length; i++) + { + var dx = centerX - positionX[i]; + var dy = centerY - positionY[i]; + var distanceSquared = MathF.Max(dx * dx + dy * dy, MinimumDistanceSquared); + var distance = MathF.Sqrt(distanceSquared); + if (distance > radius) + { + continue; + } + + var falloff = MathF.Max(0.0f, 1.0f - distance / radius); + var magnitude = strength * falloff; + accelerationX[i] += dx / distance * magnitude; + accelerationY[i] += dy / distance * magnitude; + } + } + + public static void IntegrateSemiImplicitEuler2D( + float deltaTime, + ReadOnlySpan dynamicMask, + Span positionX, + Span positionY, + Span velocityX, + Span velocityY, + ReadOnlySpan accelerationX, + ReadOnlySpan accelerationY) + { + if (deltaTime <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); + } + + ValidateEqualLengths( + dynamicMask.Length, + positionX.Length, + positionY.Length, + velocityX.Length, + velocityY.Length, + accelerationX.Length, + accelerationY.Length); + + var delta = new Vector(deltaTime); + var i = 0; + var lanes = Vector.Count; + for (; i <= positionX.Length - lanes; i += lanes) + { + var mask = new Vector(dynamicMask.Slice(i, lanes)); + var vx = new Vector(velocityX.Slice(i, lanes)); + var vy = new Vector(velocityY.Slice(i, lanes)); + var px = new Vector(positionX.Slice(i, lanes)); + var py = new Vector(positionY.Slice(i, lanes)); + var ax = new Vector(accelerationX.Slice(i, lanes)); + var ay = new Vector(accelerationY.Slice(i, lanes)); + + vx += ax * delta * mask; + vy += ay * delta * mask; + px += vx * delta * mask; + py += vy * delta * mask; + + vx.CopyTo(velocityX.Slice(i, lanes)); + vy.CopyTo(velocityY.Slice(i, lanes)); + px.CopyTo(positionX.Slice(i, lanes)); + py.CopyTo(positionY.Slice(i, lanes)); + } + + for (; i < positionX.Length; i++) + { + var mask = dynamicMask[i]; + velocityX[i] += accelerationX[i] * deltaTime * mask; + velocityY[i] += accelerationY[i] * deltaTime * mask; + positionX[i] += velocityX[i] * deltaTime * mask; + positionY[i] += velocityY[i] * deltaTime * mask; + } + } + + private static void ValidateEqualLengths(params int[] lengths) + { + var length = lengths[0]; + for (var i = 1; i < lengths.Length; i++) + { + if (lengths[i] != length) + { + throw new ArgumentException("CultMath batch spans must have equal length."); + } + } + } +} diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 18fa203a..44dd1290 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -116,4 +116,41 @@ public sealed class MathTests Assert.True(colors[1].r > colors[1].g); Assert.True(colors[2].r > 120 && colors[2].g > 120 && colors[2].b > 120); } + + [Fact] + public void BatchRadialFalloffAccelerationMatchesScalarContract() + { + var xs = new[] { 0.0f, 5.0f, 10.0f }; + var ys = new[] { 0.0f, 0.0f, 0.0f }; + var ax = new float[xs.Length]; + var ay = new float[xs.Length]; + + BatchMath.AddRadialFalloffAcceleration2D(xs, ys, 10.0f, 0.0f, 10.0f, 20.0f, ax, ay); + + Assert.True(BatchMath.LaneCount >= 4); + Assert.Equal(5.0f, ax[0], precision: 5); + Assert.Equal(7.5f, ax[1], precision: 5); + Assert.Equal(0.0f, ax[2], precision: 5); + Assert.Equal(0.0f, ay[0], precision: 5); + } + + [Fact] + public void BatchEulerIntegrationHonorsDynamicMask() + { + var dynamicMask = new[] { 1.0f, 0.0f }; + var px = new[] { 0.0f, 0.0f }; + var py = new[] { 0.0f, 0.0f }; + var vx = new[] { 1.0f, 1.0f }; + var vy = new[] { 0.0f, 0.0f }; + var ax = new[] { 2.0f, 2.0f }; + var ay = new[] { 0.0f, 4.0f }; + + BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay); + + Assert.Equal(2.0f, vx[0], precision: 5); + Assert.Equal(1.0f, px[0], precision: 5); + Assert.Equal(1.0f, vx[1], precision: 5); + Assert.Equal(0.0f, px[1], precision: 5); + Assert.Equal(0.0f, py[1], precision: 5); + } } From ab6a2c6456ec4b48311857daf24494f76cd1ca78 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 19:33:59 +0100 Subject: [PATCH 09/23] Complete portable CultMath runtime surface --- CultMath.slnx.meta | 7 + Directory.Build.props | 6 + Directory.Build.props.meta | 7 + README.md.meta | 7 + docs.meta | 8 ++ docs/design.md.meta | 7 + global.json.meta | 7 + native.meta | 8 ++ native/cultmath-core.meta | 8 ++ native/cultmath-core/Cargo.lock.meta | 7 + native/cultmath-core/Cargo.toml.meta | 7 + native/cultmath-core/src.meta | 8 ++ native/cultmath-core/src/lib.rs.meta | 7 + package.json | 14 ++ package.json.meta | 7 + shaders.meta | 8 ++ shaders/CultMath.hlsl | 84 +++++++++++ shaders/CultMath.hlsl.meta | 7 + src.meta | 8 ++ src/CultMath.meta | 8 ++ src/CultMath/BatchMath.cs | 128 +++++++++++++++-- src/CultMath/BatchMath.cs.meta | 2 + src/CultMath/Color32.cs.meta | 2 + src/CultMath/CultMath.asmdef | 14 ++ src/CultMath/CultMath.asmdef.meta | 7 + src/CultMath/CultMath.csproj | 3 +- src/CultMath/CultMath.csproj.meta | 7 + src/CultMath/CultMathTone.cs.meta | 2 + src/CultMath/IsExternalInit.cs | 5 + src/CultMath/IsExternalInit.cs.meta | 2 + src/CultMath/Random.cs | 62 ++++++++ src/CultMath/Random.cs.meta | 2 + src/CultMath/SphericalErosion.cs | 108 ++++++++++++++ src/CultMath/Voronoi.cs | 1 + src/CultMath/Voronoi.cs.meta | 2 + src/CultMath/bool2.cs | 18 +++ src/CultMath/bool2.cs.meta | 2 + src/CultMath/double2.cs | 25 ++++ src/CultMath/double2.cs.meta | 2 + src/CultMath/double3.cs | 36 +++++ src/CultMath/double3.cs.meta | 2 + src/CultMath/float2.cs | 4 +- src/CultMath/float2.cs.meta | 2 + src/CultMath/float3.cs | 49 ++++++- src/CultMath/float3.cs.meta | 2 + src/CultMath/float4.cs | 21 ++- src/CultMath/float4.cs.meta | 2 + src/CultMath/int2.cs | 29 ++++ src/CultMath/int2.cs.meta | 2 + src/CultMath/math.cs | 52 +++++++ src/CultMath/math.cs.meta | 2 + src/CultMath/quaternion.cs | 68 +++++++++ src/CultMath/quaternion.cs.meta | 2 + src/CultMath/rect.cs | 21 +++ src/CultMath/rect.cs.meta | 2 + tests.meta | 8 ++ tests/CultMath.Tests.meta | 8 ++ .../CultMath.Tests/CultMath.Tests.csproj.meta | 7 + tests/CultMath.Tests/HlslMirrorTests.cs | 9 ++ tests/CultMath.Tests/HlslMirrorTests.cs.meta | 2 + tests/CultMath.Tests/MathTests.cs | 134 ++++++++++++++++++ tests/CultMath.Tests/MathTests.cs.meta | 2 + tests/CultMath.Tests/SphericalErosionTests.cs | 27 ++++ tools.meta | 8 ++ tools/compile-hlsl-spirv.ps1.meta | 7 + tools/compile-spirv-metal.ps1.meta | 7 + tools/get-dxc.ps1.meta | 7 + tools/get-spirv-cross.ps1.meta | 7 + 68 files changed, 1130 insertions(+), 23 deletions(-) create mode 100644 CultMath.slnx.meta create mode 100644 Directory.Build.props create mode 100644 Directory.Build.props.meta create mode 100644 README.md.meta create mode 100644 docs.meta create mode 100644 docs/design.md.meta create mode 100644 global.json.meta create mode 100644 native.meta create mode 100644 native/cultmath-core.meta create mode 100644 native/cultmath-core/Cargo.lock.meta create mode 100644 native/cultmath-core/Cargo.toml.meta create mode 100644 native/cultmath-core/src.meta create mode 100644 native/cultmath-core/src/lib.rs.meta create mode 100644 package.json create mode 100644 package.json.meta create mode 100644 shaders.meta create mode 100644 shaders/CultMath.hlsl.meta create mode 100644 src.meta create mode 100644 src/CultMath.meta create mode 100644 src/CultMath/BatchMath.cs.meta create mode 100644 src/CultMath/Color32.cs.meta create mode 100644 src/CultMath/CultMath.asmdef create mode 100644 src/CultMath/CultMath.asmdef.meta create mode 100644 src/CultMath/CultMath.csproj.meta create mode 100644 src/CultMath/CultMathTone.cs.meta create mode 100644 src/CultMath/IsExternalInit.cs create mode 100644 src/CultMath/IsExternalInit.cs.meta create mode 100644 src/CultMath/Random.cs create mode 100644 src/CultMath/Random.cs.meta create mode 100644 src/CultMath/SphericalErosion.cs create mode 100644 src/CultMath/Voronoi.cs.meta create mode 100644 src/CultMath/bool2.cs create mode 100644 src/CultMath/bool2.cs.meta create mode 100644 src/CultMath/double2.cs create mode 100644 src/CultMath/double2.cs.meta create mode 100644 src/CultMath/double3.cs create mode 100644 src/CultMath/double3.cs.meta create mode 100644 src/CultMath/float2.cs.meta create mode 100644 src/CultMath/float3.cs.meta create mode 100644 src/CultMath/float4.cs.meta create mode 100644 src/CultMath/int2.cs create mode 100644 src/CultMath/int2.cs.meta create mode 100644 src/CultMath/math.cs.meta create mode 100644 src/CultMath/quaternion.cs create mode 100644 src/CultMath/quaternion.cs.meta create mode 100644 src/CultMath/rect.cs create mode 100644 src/CultMath/rect.cs.meta create mode 100644 tests.meta create mode 100644 tests/CultMath.Tests.meta create mode 100644 tests/CultMath.Tests/CultMath.Tests.csproj.meta create mode 100644 tests/CultMath.Tests/HlslMirrorTests.cs.meta create mode 100644 tests/CultMath.Tests/MathTests.cs.meta create mode 100644 tests/CultMath.Tests/SphericalErosionTests.cs create mode 100644 tools.meta create mode 100644 tools/compile-hlsl-spirv.ps1.meta create mode 100644 tools/compile-spirv-metal.ps1.meta create mode 100644 tools/get-dxc.ps1.meta create mode 100644 tools/get-spirv-cross.ps1.meta diff --git a/CultMath.slnx.meta b/CultMath.slnx.meta new file mode 100644 index 00000000..a33f7974 --- /dev/null +++ b/CultMath.slnx.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d310c959fd7a1d24a981ea6766ee3c59 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..4df13bd3 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + $(MSBuildThisFileDirectory).tools\dotnet\bin\$(MSBuildProjectName)\ + $(MSBuildThisFileDirectory).tools\dotnet\obj\$(MSBuildProjectName)\ + + diff --git a/Directory.Build.props.meta b/Directory.Build.props.meta new file mode 100644 index 00000000..f41d119d --- /dev/null +++ b/Directory.Build.props.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e041df73afea0094cae9b8d5dcc5e4eb +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 00000000..4411da72 --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 867daebafe0ce48479cf043461ee6bdf +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs.meta b/docs.meta new file mode 100644 index 00000000..ee5fab29 --- /dev/null +++ b/docs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: de535b89253410b41b5989f712b80af4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/design.md.meta b/docs/design.md.meta new file mode 100644 index 00000000..0d31ca6a --- /dev/null +++ b/docs/design.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 07021a8be816e3448a9490c10845b0aa +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/global.json.meta b/global.json.meta new file mode 100644 index 00000000..6c2c0eec --- /dev/null +++ b/global.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 28e4ce693c46cbd4b990c5790745d62f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/native.meta b/native.meta new file mode 100644 index 00000000..f4135336 --- /dev/null +++ b/native.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9d1cb2ac88c530b4d9da456523115e12 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/native/cultmath-core.meta b/native/cultmath-core.meta new file mode 100644 index 00000000..48a2ef76 --- /dev/null +++ b/native/cultmath-core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a42d65478346c354c8b4f353c5581b49 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/native/cultmath-core/Cargo.lock.meta b/native/cultmath-core/Cargo.lock.meta new file mode 100644 index 00000000..9ca3ec60 --- /dev/null +++ b/native/cultmath-core/Cargo.lock.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f1c365f7eead2684ba621124e653bdab +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/native/cultmath-core/Cargo.toml.meta b/native/cultmath-core/Cargo.toml.meta new file mode 100644 index 00000000..8875de09 --- /dev/null +++ b/native/cultmath-core/Cargo.toml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cdd2d4eab053b764b9a06d36b080e43e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/native/cultmath-core/src.meta b/native/cultmath-core/src.meta new file mode 100644 index 00000000..8e5232db --- /dev/null +++ b/native/cultmath-core/src.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7de448b7e258d2c448bb7391051cc628 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/native/cultmath-core/src/lib.rs.meta b/native/cultmath-core/src/lib.rs.meta new file mode 100644 index 00000000..2fa8aee8 --- /dev/null +++ b/native/cultmath-core/src/lib.rs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b06c69c432a633f4584830d6b28d4473 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 00000000..6da2a17a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "org.gamecult.cultmath", + "displayName": "CultMath", + "version": "0.1.0", + "unity": "2021.3", + "description": "Clean-room C# math primitives shaped for HLSL-style renderer and simulation code.", + "keywords": [ + "GameCult", + "Math", + "Simulation" + ], + "license": "MPL-2.0", + "category": "Scripting" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 00000000..23ed71cf --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1abf066549a826c4ba7a30d6676735c0 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/shaders.meta b/shaders.meta new file mode 100644 index 00000000..46f9d445 --- /dev/null +++ b/shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3bed74ebcba445543800218dbe185034 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl index 3cbb3fdd..07b2b8a9 100644 --- a/shaders/CultMath.hlsl +++ b/shaders/CultMath.hlsl @@ -95,6 +95,18 @@ float3 cultmath_reflect(float3 incident, float3 normal_value) return incident - 2.0 * dot(normal_value, incident) * normal_value; } +float2 cultmath_rotate(float2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return float2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +float2 cultmath_rotate_degrees(float2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + float cultmath_csum(float2 value) { return value.x + value.y; } float cultmath_csum(float3 value) { return value.x + value.y + value.z; } float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } @@ -251,4 +263,76 @@ float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, Sampl return cultmath_catmullrom(row0, row1, row2, row3, local.y); } +struct CultMathSphericalErosionParameters +{ + float frequency; float amplitude; float lacunarity; float gain; + float slope_strength; float detail; float seed; int octaves; +}; + +struct CultMathSphericalErosionSample +{ + float height_offset; float ridge; float gully; float3 gradient; +}; + +float cultmath_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) +{ + float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); + return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; +} + +float cultmath_spherical_directional_wave(float3 position, float3 flow, float seed) +{ + float3 cell = floor(position); + float3 local = cultmath_frac(position); + float3 weights = local * local * (3.0 - 2.0 * local); + float z0 = cultmath_lerp( + cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), + cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); + float z1 = cultmath_lerp( + cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), + cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); + return cultmath_lerp(z0, z1, weights.z); +} + +float3 cultmath_spherical_stable_tangent(float3 position) +{ + float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); + return cultmath_normalize(cross(axis, position)); +} + +CultMathSphericalErosionSample cultmath_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, CultMathSphericalErosionParameters p) +{ + float3 position = cultmath_normalize(unit_position); + float3 gradient = base_gradient - position * dot(base_gradient, position); + float slope = length(gradient); + float3 flow = slope > 1.0e-6 ? -gradient / slope : cultmath_spherical_stable_tangent(position); + float frequency = max(p.frequency, 0.001); + float amplitude = p.amplitude; + float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); + float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; + CultMathSphericalErosionSample result = (CultMathSphericalErosionSample)0; + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float phase = cultmath_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); + float wave = cos(phase); + float wave_slope = sin(phase); + float faded = cultmath_lerp(fade_target, wave, combi_mask); + result.height_offset += faded * amplitude; + result.ridge = max(result.ridge, cultmath_saturate(faded)); + result.gully = max(result.gully, cultmath_saturate(-faded)); + float3 across = cultmath_normalize(cross(position, flow)); + gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); + gradient -= position * dot(gradient, position); + slope = length(gradient); + flow = slope > 1.0e-6 ? -gradient / slope : cultmath_spherical_stable_tangent(position); + float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); + combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; + fade_target = faded; + frequency *= max(p.lacunarity, 1.01); + amplitude *= cultmath_saturate(p.gain); + } + result.gradient = gradient; + return result; +} + #endif diff --git a/shaders/CultMath.hlsl.meta b/shaders/CultMath.hlsl.meta new file mode 100644 index 00000000..d6b8542e --- /dev/null +++ b/shaders/CultMath.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 33174674795143c4a905408718f956f8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src.meta b/src.meta new file mode 100644 index 00000000..3e88fcb9 --- /dev/null +++ b/src.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 64f48d0cae0810e4ba9903e9094fe4c6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/CultMath.meta b/src/CultMath.meta new file mode 100644 index 00000000..381b726d --- /dev/null +++ b/src/CultMath.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e857b457b93397d4fb4ad8481cb72e4c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/CultMath/BatchMath.cs b/src/CultMath/BatchMath.cs index df62e07c..04c042d9 100644 --- a/src/CultMath/BatchMath.cs +++ b/src/CultMath/BatchMath.cs @@ -1,3 +1,4 @@ +using System; using System.Numerics; namespace CultMath; @@ -39,10 +40,19 @@ public static class BatchMath var i = 0; var lanes = Vector.Count; + var pxBuffer = new float[lanes]; + var pyBuffer = new float[lanes]; + var axBuffer = new float[lanes]; + var ayBuffer = new float[lanes]; for (; i <= positionX.Length - lanes; i += lanes) { - var px = new Vector(positionX.Slice(i, lanes)); - var py = new Vector(positionY.Slice(i, lanes)); + CopySpanToArray(positionX.Slice(i, lanes), pxBuffer); + CopySpanToArray(positionY.Slice(i, lanes), pyBuffer); + CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer); + CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer); + + var px = new Vector(pxBuffer); + var py = new Vector(pyBuffer); var dx = centerXVector - px; var dy = centerYVector - py; var distanceSquared = Vector.Max(dx * dx + dy * dy, minimumDistanceSquared); @@ -53,8 +63,10 @@ public static class BatchMath var ax = Vector.ConditionalSelect(inRange, dx / distance * magnitude, zero); var ay = Vector.ConditionalSelect(inRange, dy / distance * magnitude, zero); - (new Vector(accelerationX.Slice(i, lanes)) + ax).CopyTo(accelerationX.Slice(i, lanes)); - (new Vector(accelerationY.Slice(i, lanes)) + ay).CopyTo(accelerationY.Slice(i, lanes)); + (new Vector(axBuffer) + ax).CopyTo(axBuffer); + (new Vector(ayBuffer) + ay).CopyTo(ayBuffer); + CopyArrayToSpan(axBuffer, accelerationX.Slice(i, lanes)); + CopyArrayToSpan(ayBuffer, accelerationY.Slice(i, lanes)); } for (; i < positionX.Length; i++) @@ -75,6 +87,35 @@ public static class BatchMath } } + public static void AddRadialFalloffAcceleration2D( + ReadOnlySpan positions, + float2 center, + float strength, + float radius, + Span acceleration) + { + ValidateEqualLengths(positions.Length, acceleration.Length); + + if (radius <= 0.0f || strength == 0.0f || positions.Length == 0) + { + return; + } + + for (var i = 0; i < positions.Length; i++) + { + var delta = center - positions[i]; + var distanceSquared = MathF.Max(delta.x * delta.x + delta.y * delta.y, MinimumDistanceSquared); + var distance = MathF.Sqrt(distanceSquared); + if (distance > radius) + { + continue; + } + + var falloff = MathF.Max(0.0f, 1.0f - distance / radius); + acceleration[i] += delta / distance * (strength * falloff); + } + } + public static void IntegrateSemiImplicitEuler2D( float deltaTime, ReadOnlySpan dynamicMask, @@ -102,25 +143,44 @@ public static class BatchMath var delta = new Vector(deltaTime); var i = 0; var lanes = Vector.Count; + var maskBuffer = new float[lanes]; + var pxBuffer = new float[lanes]; + var pyBuffer = new float[lanes]; + var vxBuffer = new float[lanes]; + var vyBuffer = new float[lanes]; + var axBuffer = new float[lanes]; + var ayBuffer = new float[lanes]; for (; i <= positionX.Length - lanes; i += lanes) { - var mask = new Vector(dynamicMask.Slice(i, lanes)); - var vx = new Vector(velocityX.Slice(i, lanes)); - var vy = new Vector(velocityY.Slice(i, lanes)); - var px = new Vector(positionX.Slice(i, lanes)); - var py = new Vector(positionY.Slice(i, lanes)); - var ax = new Vector(accelerationX.Slice(i, lanes)); - var ay = new Vector(accelerationY.Slice(i, lanes)); + CopySpanToArray(dynamicMask.Slice(i, lanes), maskBuffer); + CopySpanToArray(velocityX.Slice(i, lanes), vxBuffer); + CopySpanToArray(velocityY.Slice(i, lanes), vyBuffer); + CopySpanToArray(positionX.Slice(i, lanes), pxBuffer); + CopySpanToArray(positionY.Slice(i, lanes), pyBuffer); + CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer); + CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer); + + var mask = new Vector(maskBuffer); + var vx = new Vector(vxBuffer); + var vy = new Vector(vyBuffer); + var px = new Vector(pxBuffer); + var py = new Vector(pyBuffer); + var ax = new Vector(axBuffer); + var ay = new Vector(ayBuffer); vx += ax * delta * mask; vy += ay * delta * mask; px += vx * delta * mask; py += vy * delta * mask; - vx.CopyTo(velocityX.Slice(i, lanes)); - vy.CopyTo(velocityY.Slice(i, lanes)); - px.CopyTo(positionX.Slice(i, lanes)); - py.CopyTo(positionY.Slice(i, lanes)); + vx.CopyTo(vxBuffer); + vy.CopyTo(vyBuffer); + px.CopyTo(pxBuffer); + py.CopyTo(pyBuffer); + CopyArrayToSpan(vxBuffer, velocityX.Slice(i, lanes)); + CopyArrayToSpan(vyBuffer, velocityY.Slice(i, lanes)); + CopyArrayToSpan(pxBuffer, positionX.Slice(i, lanes)); + CopyArrayToSpan(pyBuffer, positionY.Slice(i, lanes)); } for (; i < positionX.Length; i++) @@ -133,6 +193,28 @@ public static class BatchMath } } + public static void IntegrateSemiImplicitEuler2D( + float deltaTime, + ReadOnlySpan dynamicMask, + Span position, + Span velocity, + ReadOnlySpan acceleration) + { + if (deltaTime <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); + } + + ValidateEqualLengths(dynamicMask.Length, position.Length, velocity.Length, acceleration.Length); + + for (var i = 0; i < position.Length; i++) + { + var mask = dynamicMask[i]; + velocity[i] += acceleration[i] * deltaTime * mask; + position[i] += velocity[i] * deltaTime * mask; + } + } + private static void ValidateEqualLengths(params int[] lengths) { var length = lengths[0]; @@ -144,4 +226,20 @@ public static class BatchMath } } } + + private static void CopySpanToArray(ReadOnlySpan source, float[] destination) + { + for (var i = 0; i < source.Length; i++) + { + destination[i] = source[i]; + } + } + + private static void CopyArrayToSpan(float[] source, Span destination) + { + for (var i = 0; i < destination.Length; i++) + { + destination[i] = source[i]; + } + } } diff --git a/src/CultMath/BatchMath.cs.meta b/src/CultMath/BatchMath.cs.meta new file mode 100644 index 00000000..fe7c6033 --- /dev/null +++ b/src/CultMath/BatchMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4771320e190bdd408dd296c293a9d0e \ No newline at end of file diff --git a/src/CultMath/Color32.cs.meta b/src/CultMath/Color32.cs.meta new file mode 100644 index 00000000..71e1ba55 --- /dev/null +++ b/src/CultMath/Color32.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfa14a9aacb8ac4448761568643f2bc6 \ No newline at end of file diff --git a/src/CultMath/CultMath.asmdef b/src/CultMath/CultMath.asmdef new file mode 100644 index 00000000..449549f6 --- /dev/null +++ b/src/CultMath/CultMath.asmdef @@ -0,0 +1,14 @@ +{ + "name": "CultMath", + "rootNamespace": "CultMath", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/src/CultMath/CultMath.asmdef.meta b/src/CultMath/CultMath.asmdef.meta new file mode 100644 index 00000000..5a26d2c5 --- /dev/null +++ b/src/CultMath/CultMath.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0b66b909d13e18468f7aa56221531e9 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index f43adce3..01901b6b 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -1,9 +1,10 @@ - net8.0 + net8.0;netstandard2.1 enable enable + latest CultMath CultMath CultMath diff --git a/src/CultMath/CultMath.csproj.meta b/src/CultMath/CultMath.csproj.meta new file mode 100644 index 00000000..78fc304d --- /dev/null +++ b/src/CultMath/CultMath.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 77e1a0a2512910f41885650a7d0d6f60 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/CultMath/CultMathTone.cs.meta b/src/CultMath/CultMathTone.cs.meta new file mode 100644 index 00000000..f41d1475 --- /dev/null +++ b/src/CultMath/CultMathTone.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4bfc66f9f7e992448f884c2c338d50a \ No newline at end of file diff --git a/src/CultMath/IsExternalInit.cs b/src/CultMath/IsExternalInit.cs new file mode 100644 index 00000000..b05eb4a1 --- /dev/null +++ b/src/CultMath/IsExternalInit.cs @@ -0,0 +1,5 @@ +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} diff --git a/src/CultMath/IsExternalInit.cs.meta b/src/CultMath/IsExternalInit.cs.meta new file mode 100644 index 00000000..ca761bcb --- /dev/null +++ b/src/CultMath/IsExternalInit.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de2b3c3e7f03c354aa2b84fbbc55b497 \ No newline at end of file diff --git a/src/CultMath/Random.cs b/src/CultMath/Random.cs new file mode 100644 index 00000000..9931a87f --- /dev/null +++ b/src/CultMath/Random.cs @@ -0,0 +1,62 @@ +namespace CultMath; + +public struct Random +{ + private const uint DefaultSeed = 0x6E624EB7u; + + public uint state; + + public Random(uint seed) + { + state = seed == 0 ? DefaultSeed : seed; + } + + public static Random CreateFromIndex(uint index) => new(index + DefaultSeed); + + public void InitState(uint seed) + { + state = seed == 0 ? DefaultSeed : seed; + } + + public uint NextUInt() + { + var x = state == 0 ? DefaultSeed : state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + state = x == 0 ? DefaultSeed : x; + return state; + } + + public int NextInt(int max) => NextInt(0, max); + + public int NextInt(int min, int max) + { + if (max <= min) + { + return min; + } + + return min + (int)(NextUInt() % (uint)(max - min)); + } + + public float NextFloat() => (NextUInt() >> 8) * (1.0f / 16777216.0f); + + public float NextFloat(float max) => NextFloat() * max; + + public float NextFloat(float min, float max) => min + NextFloat() * (max - min); + + public float2 NextFloat2() => new(NextFloat(), NextFloat()); + + public float2 NextFloat2(float min, float max) => new(NextFloat(min, max), NextFloat(min, max)); + + public float2 NextFloat2(float2 min, float2 max) => min + NextFloat2() * (max - min); + + public float2 NextFloat2Direction() + { + var angle = NextFloat(0.0f, math.TAU); + return new float2(math.cos(angle), math.sin(angle)); + } + + public float3 NextFloat3(float3 min, float3 max) => min + new float3(NextFloat(), NextFloat(), NextFloat()) * (max - min); +} diff --git a/src/CultMath/Random.cs.meta b/src/CultMath/Random.cs.meta new file mode 100644 index 00000000..55032ded --- /dev/null +++ b/src/CultMath/Random.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c1a557259026da4ca20b54264852c13 \ No newline at end of file diff --git a/src/CultMath/SphericalErosion.cs b/src/CultMath/SphericalErosion.cs new file mode 100644 index 00000000..93ddb0ec --- /dev/null +++ b/src/CultMath/SphericalErosion.cs @@ -0,0 +1,108 @@ +namespace CultMath; + +public readonly record struct SphericalErosionParameters( + float Frequency, + float Amplitude, + float Lacunarity, + float Gain, + float SlopeStrength, + float Detail, + float Seed, + int Octaves) +{ + public static SphericalErosionParameters Default => new(18.0f, 0.035f, 2.03f, 0.52f, 6.0f, 1.35f, 0.0f, 5); +} + +public readonly record struct SphericalErosionSample(float HeightOffset, float Ridge, float Gully, float3 Gradient); + +/// +/// Stateless, point-evaluable erosion-like synthesis on a unit sphere. +/// The matching shader body is cultmath_spherical_erosion in CultMath.hlsl. +/// Position and gradient are planet-space; the gradient is projected tangent +/// internally so cube faces and tile request order never enter the result. +/// +public static class SphericalErosion +{ + public static SphericalErosionSample Sample( + float3 unitPosition, + float baseHeight, + float3 baseGradient, + SphericalErosionParameters parameters) + { + var position = math.normalize(unitPosition); + var gradient = baseGradient - position * math.dot(baseGradient, position); + var slope = math.length(gradient); + var flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + var frequency = math.max(parameters.Frequency, 0.001f); + var amplitude = parameters.Amplitude; + var combiMask = EaseOut(math.saturate(slope * parameters.SlopeStrength)); + var fadeTarget = math.saturate(baseHeight * 0.5f + 0.5f) * 2.0f - 1.0f; + var heightOffset = 0.0f; + var ridge = 0.0f; + var gully = 0.0f; + + var octaveCount = math.min(math.max(parameters.Octaves, 0), 8); + for (var octave = 0; octave < octaveCount; octave++) + { + var phase = DirectionalCellWave(position * frequency, flow, parameters.Seed + octave * 19.19f); + var wave = math.cos(phase); + var waveSlope = math.sin(phase); + var faded = math.lerp(fadeTarget, wave, combiMask); + heightOffset += faded * amplitude; + ridge = math.max(ridge, math.saturate(faded)); + gully = math.max(gully, math.saturate(-faded)); + + var across = math.normalize(math.cross(position, flow)); + gradient += across * (math.sign(waveSlope) * amplitude * frequency * combiMask); + gradient -= position * math.dot(gradient, position); + slope = math.length(gradient); + flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + + var newMask = EaseOut(math.saturate(math.abs(waveSlope) * 1.35f)); + combiMask = PowInverse(combiMask, parameters.Detail) * newMask; + fadeTarget = faded; + frequency *= math.max(parameters.Lacunarity, 1.01f); + amplitude *= math.saturate(parameters.Gain); + } + + return new SphericalErosionSample(heightOffset, ridge, gully, gradient); + } + + private static float DirectionalCellWave(float3 position, float3 flow, float seed) + { + var cell = math.floor(position); + var local = math.frac(position); + var weights = local * local * (3.0f - 2.0f * local); + var x00 = CellWave(position, flow, cell + new float3(0, 0, 0), seed); + var x10 = CellWave(position, flow, cell + new float3(1, 0, 0), seed); + var x01 = CellWave(position, flow, cell + new float3(0, 1, 0), seed); + var x11 = CellWave(position, flow, cell + new float3(1, 1, 0), seed); + var y00 = math.lerp(x00, x10, weights.x); + var y10 = math.lerp(x01, x11, weights.x); + var z0 = math.lerp(y00, y10, weights.y); + var x00z = CellWave(position, flow, cell + new float3(0, 0, 1), seed); + var x10z = CellWave(position, flow, cell + new float3(1, 0, 1), seed); + var x01z = CellWave(position, flow, cell + new float3(0, 1, 1), seed); + var x11z = CellWave(position, flow, cell + new float3(1, 1, 1), seed); + var z1 = math.lerp(math.lerp(x00z, x10z, weights.x), math.lerp(x01z, x11z, weights.x), weights.y); + return math.lerp(z0, z1, weights.z); + } + + private static float CellWave(float3 position, float3 flow, float3 cell, float seed) + { + var pivot = cell + new float3( + math.hash(cell + seed + 11.7f), + math.hash(cell.yzx + seed + 37.1f), + math.hash(cell.zyx + seed + 73.9f)); + return math.dot(position - pivot, flow) * math.TAU + math.hash(cell + seed + 101.3f) * math.TAU; + } + + private static float3 StableTangent(float3 position) + { + var axis = math.abs(position.z) < 0.8f ? new float3(0, 0, 1) : new float3(0, 1, 0); + return math.normalize(math.cross(axis, position)); + } + + private static float EaseOut(float value) => 1.0f - (1.0f - value) * (1.0f - value); + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), math.max(power, 0.001f)); +} diff --git a/src/CultMath/Voronoi.cs b/src/CultMath/Voronoi.cs index 9791a8e0..38b52d81 100644 --- a/src/CultMath/Voronoi.cs +++ b/src/CultMath/Voronoi.cs @@ -1,3 +1,4 @@ +using System; using System.Runtime.InteropServices; namespace CultMath; diff --git a/src/CultMath/Voronoi.cs.meta b/src/CultMath/Voronoi.cs.meta new file mode 100644 index 00000000..6c738085 --- /dev/null +++ b/src/CultMath/Voronoi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb530f1428ac8ff43939b986986ae959 \ No newline at end of file diff --git a/src/CultMath/bool2.cs b/src/CultMath/bool2.cs new file mode 100644 index 00000000..95ab29eb --- /dev/null +++ b/src/CultMath/bool2.cs @@ -0,0 +1,18 @@ +using System; + +namespace CultMath; + +public record struct bool2(bool x, bool y) +{ + public static readonly bool2 @false = new(false, false); + public static readonly bool2 @true = new(true, true); + + public bool this[int index] => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator bool2(bool value) => new(value, value); +} diff --git a/src/CultMath/bool2.cs.meta b/src/CultMath/bool2.cs.meta new file mode 100644 index 00000000..477ff144 --- /dev/null +++ b/src/CultMath/bool2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 725efb2725e987449a3782452f5a95e0 \ No newline at end of file diff --git a/src/CultMath/double2.cs b/src/CultMath/double2.cs new file mode 100644 index 00000000..000c3cb0 --- /dev/null +++ b/src/CultMath/double2.cs @@ -0,0 +1,25 @@ +using System; + +namespace CultMath; + +public record struct double2(double x, double y) +{ + public static readonly double2 zero = new(0.0, 0.0); + public static readonly double2 one = new(1.0, 1.0); + + public double this[int index] => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator double2(double value) => new(value, value); + + public static double2 operator +(double2 value) => value; + public static double2 operator -(double2 value) => new(-value.x, -value.y); + public static double2 operator +(double2 left, double2 right) => new(left.x + right.x, left.y + right.y); + public static double2 operator -(double2 left, double2 right) => new(left.x - right.x, left.y - right.y); + public static double2 operator *(double2 left, double2 right) => new(left.x * right.x, left.y * right.y); + public static double2 operator /(double2 left, double2 right) => new(left.x / right.x, left.y / right.y); +} diff --git a/src/CultMath/double2.cs.meta b/src/CultMath/double2.cs.meta new file mode 100644 index 00000000..61243b2a --- /dev/null +++ b/src/CultMath/double2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bbe739a5f0216c3459762d2b5ee7401a \ No newline at end of file diff --git a/src/CultMath/double3.cs b/src/CultMath/double3.cs new file mode 100644 index 00000000..b1427505 --- /dev/null +++ b/src/CultMath/double3.cs @@ -0,0 +1,36 @@ +using System; + +namespace CultMath; + +public record struct double3(double x, double y, double z) +{ + public static readonly double3 zero = new(0.0, 0.0, 0.0); + public static readonly double3 one = new(1.0, 1.0, 1.0); + + public double2 xy + { + get => new(x, y); + set + { + x = value.x; + y = value.y; + } + } + + public double this[int index] => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator double3(double value) => new(value, value, value); + + public static double3 operator +(double3 value) => value; + public static double3 operator -(double3 value) => new(-value.x, -value.y, -value.z); + public static double3 operator +(double3 left, double3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); + public static double3 operator -(double3 left, double3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); + public static double3 operator *(double3 left, double3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); + public static double3 operator /(double3 left, double3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); +} diff --git a/src/CultMath/double3.cs.meta b/src/CultMath/double3.cs.meta new file mode 100644 index 00000000..302d13d8 --- /dev/null +++ b/src/CultMath/double3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1cbaafd219703ee43ae279aa5cbf407f \ No newline at end of file diff --git a/src/CultMath/float2.cs b/src/CultMath/float2.cs index 1f1d66b1..5298494b 100644 --- a/src/CultMath/float2.cs +++ b/src/CultMath/float2.cs @@ -1,6 +1,8 @@ +using System; + namespace CultMath; -public readonly record struct float2(float x, float y) +public record struct float2(float x, float y) { public static readonly float2 zero = new(0.0f, 0.0f); public static readonly float2 one = new(1.0f, 1.0f); diff --git a/src/CultMath/float2.cs.meta b/src/CultMath/float2.cs.meta new file mode 100644 index 00000000..c82f69ae --- /dev/null +++ b/src/CultMath/float2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fa066a0aed5494f499d462a2ebc51e50 \ No newline at end of file diff --git a/src/CultMath/float3.cs b/src/CultMath/float3.cs index 70cfa02b..e9833ae4 100644 --- a/src/CultMath/float3.cs +++ b/src/CultMath/float3.cs @@ -1,6 +1,8 @@ +using System; + namespace CultMath; -public readonly record struct float3(float x, float y, float z) +public record struct float3(float x, float y, float z) { public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); public static readonly float3 one = new(1.0f, 1.0f, 1.0f); @@ -10,9 +12,48 @@ public readonly record struct float3(float x, float y, float z) { } - public float2 xy => new(x, y); - public float2 xz => new(x, z); - public float2 yz => new(y, z); + public float2 xy + { + get => new(x, y); + set + { + x = value.x; + y = value.y; + } + } + + public float2 xz + { + get => new(x, z); + set + { + x = value.x; + z = value.y; + } + } + + public float2 yz + { + get => new(y, z); + set + { + y = value.x; + z = value.y; + } + } + + public float2 zy + { + get => new(z, y); + set + { + z = value.x; + y = value.y; + } + } + + public float3 yzx => new(y, z, x); + public float3 zyx => new(z, y, x); public float this[int index] => index switch { diff --git a/src/CultMath/float3.cs.meta b/src/CultMath/float3.cs.meta new file mode 100644 index 00000000..3c649c4a --- /dev/null +++ b/src/CultMath/float3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 58a355dd1cf6b9f48a7595eea0c0e1f2 \ No newline at end of file diff --git a/src/CultMath/float4.cs b/src/CultMath/float4.cs index c7bfb404..b6eed097 100644 --- a/src/CultMath/float4.cs +++ b/src/CultMath/float4.cs @@ -1,6 +1,8 @@ +using System; + namespace CultMath; -public readonly record struct float4(float x, float y, float z, float w) +public record struct float4(float x, float y, float z, float w) { public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); @@ -15,7 +17,22 @@ public readonly record struct float4(float x, float y, float z, float w) { } - public float2 xy => new(x, y); + public float4(float x, float3 yzw) + : this(x, yzw.x, yzw.y, yzw.z) + { + } + + public float2 xy + { + get => new(x, y); + set + { + x = value.x; + y = value.y; + } + } + public float3 xyw => new(x, y, w); + public float3 yzx => new(y, z, x); public float3 xyz => new(x, y, z); public float this[int index] => index switch diff --git a/src/CultMath/float4.cs.meta b/src/CultMath/float4.cs.meta new file mode 100644 index 00000000..206444e0 --- /dev/null +++ b/src/CultMath/float4.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe24fbdab33538849bc6be0afbb0a692 \ No newline at end of file diff --git a/src/CultMath/int2.cs b/src/CultMath/int2.cs new file mode 100644 index 00000000..7c2f350b --- /dev/null +++ b/src/CultMath/int2.cs @@ -0,0 +1,29 @@ +using System; + +namespace CultMath; + +public record struct int2(int x, int y) +{ + public static readonly int2 zero = new(0, 0); + public static readonly int2 one = new(1, 1); + + public int this[int index] => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator int2(int value) => new(value, value); + public static implicit operator float2(int2 value) => new(value.x, value.y); + public static explicit operator System.Numerics.Vector2(int2 value) => new(value.x, value.y); + public static explicit operator int2(System.Numerics.Vector2 value) => new((int)value.X, (int)value.Y); + + public static int2 operator +(int2 value) => value; + public static int2 operator -(int2 value) => new(-value.x, -value.y); + public static int2 operator +(int2 left, int2 right) => new(left.x + right.x, left.y + right.y); + public static int2 operator -(int2 left, int2 right) => new(left.x - right.x, left.y - right.y); + public static int2 operator *(int2 left, int2 right) => new(left.x * right.x, left.y * right.y); + public static int2 operator /(int2 left, int2 right) => new(left.x / right.x, left.y / right.y); + public static int2 operator %(int2 left, int2 right) => new(left.x % right.x, left.y % right.y); +} diff --git a/src/CultMath/int2.cs.meta b/src/CultMath/int2.cs.meta new file mode 100644 index 00000000..5920328a --- /dev/null +++ b/src/CultMath/int2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 577b174a998aaa4478ec26e7a6782f46 \ No newline at end of file diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index 817aa7fd..874554ad 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -1,3 +1,5 @@ +using System; + namespace CultMath; public static class math @@ -8,13 +10,20 @@ public static class math public static float2 float2(float x, float y) => new(x, y); public static float2 float2(float value) => new(value, value); + public static bool2 bool2(bool x, bool y) => new(x, y); + public static bool2 bool2(bool value) => new(value, value); + public static int2 int2(int x, int y) => new(x, y); + public static int2 int2(int value) => new(value, value); public static float3 float3(float x, float y, float z) => new(x, y, z); public static float3 float3(float2 xy, float z) => new(xy, z); public static float3 float3(float value) => new(value, value, value); public static float4 float4(float x, float y, float z, float w) => new(x, y, z, w); public static float4 float4(float2 xy, float z, float w) => new(xy, z, w); public static float4 float4(float3 xyz, float w) => new(xyz, w); + public static float4 float4(float x, float3 yzw) => new(x, yzw); public static float4 float4(float value) => new(value, value, value, value); + public static rect rect(float2 min, float2 max) => new(min, max); + public static rect rect(float minX, float minY, float maxX, float maxY) => new(minX, minY, maxX, maxY); public static float radians(float degrees) => degrees * (PI / 180.0f); public static float degrees(float radians) => radians * (180.0f / PI); @@ -26,16 +35,26 @@ public static class math public static float atan(float value) => MathF.Atan(value); public static float atan2(float y, float x) => MathF.Atan2(y, x); public static float sqrt(float value) => MathF.Sqrt(value); + public static float pow(float value, float power) => MathF.Pow(value, power); + public static bool isinf(float value) => float.IsInfinity(value); public static float exp(float value) => MathF.Exp(value); + public static double exp(double value) => Math.Exp(value); public static float2 exp(float2 value) => new(exp(value.x), exp(value.y)); public static float3 exp(float3 value) => new(exp(value.x), exp(value.y), exp(value.z)); public static float4 exp(float4 value) => new(exp(value.x), exp(value.y), exp(value.z), exp(value.w)); + public static double2 exp(double2 value) => new(exp(value.x), exp(value.y)); + public static double3 exp(double3 value) => new(exp(value.x), exp(value.y), exp(value.z)); public static float abs(float value) => MathF.Abs(value); public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); public static float3 abs(float3 value) => new(abs(value.x), abs(value.y), abs(value.z)); public static float4 abs(float4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); + public static float sign(float value) => MathF.Sign(value); + public static float2 sign(float2 value) => new(sign(value.x), sign(value.y)); + public static float3 sign(float3 value) => new(sign(value.x), sign(value.y), sign(value.z)); + public static float4 sign(float4 value) => new(sign(value.x), sign(value.y), sign(value.z), sign(value.w)); + public static float floor(float value) => MathF.Floor(value); public static float2 floor(float2 value) => new(floor(value.x), floor(value.y)); public static float3 floor(float3 value) => new(floor(value.x), floor(value.y), floor(value.z)); @@ -52,11 +71,13 @@ public static class math public static float4 frac(float4 value) => value - floor(value); public static float min(float left, float right) => MathF.Min(left, right); + public static int min(int left, int right) => Math.Min(left, right); public static float2 min(float2 left, float2 right) => new(min(left.x, right.x), min(left.y, right.y)); public static float3 min(float3 left, float3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); public static float4 min(float4 left, float4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); public static float max(float left, float right) => MathF.Max(left, right); + public static int max(int left, int right) => Math.Max(left, right); public static float2 max(float2 left, float2 right) => new(max(left.x, right.x), max(left.y, right.y)); public static float3 max(float3 left, float3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); public static float4 max(float4 left, float4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); @@ -72,9 +93,20 @@ public static class math public static float4 saturate(float4 value) => clamp(value, 0.0f, 1.0f); public static float lerp(float start, float end, float amount) => start + (end - start) * amount; + public static double lerp(double start, double end, double amount) => start + (end - start) * amount; public static float2 lerp(float2 start, float2 end, float2 amount) => start + (end - start) * amount; public static float3 lerp(float3 start, float3 end, float3 amount) => start + (end - start) * amount; public static float4 lerp(float4 start, float4 end, float4 amount) => start + (end - start) * amount; + public static double2 lerp(double2 start, double2 end, double2 amount) => start + (end - start) * amount; + public static double3 lerp(double3 start, double3 end, double3 amount) => start + (end - start) * amount; + + public static float unlerp(float start, float end, float value) => (value - start) / (end - start); + public static double unlerp(double start, double end, double value) => (value - start) / (end - start); + public static float2 unlerp(float2 start, float2 end, float2 value) => (value - start) / (end - start); + public static float3 unlerp(float3 start, float3 end, float3 value) => (value - start) / (end - start); + public static float4 unlerp(float4 start, float4 end, float4 value) => (value - start) / (end - start); + public static double2 unlerp(double2 start, double2 end, double2 value) => (value - start) / (end - start); + public static double3 unlerp(double3 start, double3 end, double3 value) => (value - start) / (end - start); public static float step(float edge, float value) => value < edge ? 0.0f : 1.0f; public static float2 step(float2 edge, float2 value) => new(step(edge.x, value.x), step(edge.y, value.y)); @@ -130,10 +162,30 @@ public static class math public static float2 normalize(float2 value) => value / MathF.Max(length(value), 1.0e-20f); public static float3 normalize(float3 value) => value / MathF.Max(length(value), 1.0e-20f); public static float4 normalize(float4 value) => value / MathF.Max(length(value), 1.0e-20f); + public static quaternion normalize(quaternion value) + { + var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w); + if (length <= 1.0e-20f) + { + return quaternion.identity; + } + + var invLength = 1.0f / length; + return new quaternion(value.x * invLength, value.y * invLength, value.z * invLength, value.w * invLength); + } public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; + public static float2 rotate(float2 value, float radians) + { + var s = sin(radians); + var c = cos(radians); + return new float2(value.x * c - value.y * s, value.x * s + value.y * c); + } + + public static float2 rotate_degrees(float2 value, float degrees) => rotate(value, radians(degrees)); + public static float csum(float2 value) => value.x + value.y; public static float csum(float3 value) => value.x + value.y + value.z; public static float csum(float4 value) => value.x + value.y + value.z + value.w; diff --git a/src/CultMath/math.cs.meta b/src/CultMath/math.cs.meta new file mode 100644 index 00000000..9222af7d --- /dev/null +++ b/src/CultMath/math.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d9b4cacc40d169429ee743d19577cb7 \ No newline at end of file diff --git a/src/CultMath/quaternion.cs b/src/CultMath/quaternion.cs new file mode 100644 index 00000000..fe8dda8e --- /dev/null +++ b/src/CultMath/quaternion.cs @@ -0,0 +1,68 @@ +namespace CultMath; + +public record struct quaternion(float x, float y, float z, float w) +{ + public static readonly quaternion identity = new(0.0f, 0.0f, 0.0f, 1.0f); + + public static quaternion LookRotation(float3 forward, float3 up) + { + forward = math.normalize(forward); + up = math.normalize(up); + + var right = math.normalize(math.cross(up, forward)); + up = math.cross(forward, right); + + var m00 = right.x; + var m01 = up.x; + var m02 = forward.x; + var m10 = right.y; + var m11 = up.y; + var m12 = forward.y; + var m20 = right.z; + var m21 = up.z; + var m22 = forward.z; + + var trace = m00 + m11 + m22; + if (trace > 0.0f) + { + var s = math.sqrt(trace + 1.0f) * 2.0f; + return math.normalize(new quaternion( + (m21 - m12) / s, + (m02 - m20) / s, + (m10 - m01) / s, + 0.25f * s)); + } + + if (m00 > m11 && m00 > m22) + { + var s = math.sqrt(1.0f + m00 - m11 - m22) * 2.0f; + return math.normalize(new quaternion( + 0.25f * s, + (m01 + m10) / s, + (m02 + m20) / s, + (m21 - m12) / s)); + } + + if (m11 > m22) + { + var s = math.sqrt(1.0f + m11 - m00 - m22) * 2.0f; + return math.normalize(new quaternion( + (m01 + m10) / s, + 0.25f * s, + (m12 + m21) / s, + (m02 - m20) / s)); + } + + { + var s = math.sqrt(1.0f + m22 - m00 - m11) * 2.0f; + return math.normalize(new quaternion( + (m02 + m20) / s, + (m12 + m21) / s, + 0.25f * s, + (m10 - m01) / s)); + } + } + + public static implicit operator quaternion(float4 value) => new(value.x, value.y, value.z, value.w); + public static implicit operator float4(quaternion value) => new(value.x, value.y, value.z, value.w); +} diff --git a/src/CultMath/quaternion.cs.meta b/src/CultMath/quaternion.cs.meta new file mode 100644 index 00000000..ae6e8a11 --- /dev/null +++ b/src/CultMath/quaternion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12f3627d9250dc348be9c1a1056ec7d0 \ No newline at end of file diff --git a/src/CultMath/rect.cs b/src/CultMath/rect.cs new file mode 100644 index 00000000..67b210b9 --- /dev/null +++ b/src/CultMath/rect.cs @@ -0,0 +1,21 @@ +namespace CultMath; + +public readonly record struct rect(float2 min, float2 max) +{ + public rect(float minX, float minY, float maxX, float maxY) + : this(math.float2(math.min(minX, maxX), math.min(minY, maxY)), + math.float2(math.max(minX, maxX), math.max(minY, maxY))) + { + } + + public float2 size => max - min; + public float2 center => (min + max) * 0.5f; + + public bool Contains(float2 point) + { + return point.x >= min.x && + point.x <= max.x && + point.y >= min.y && + point.y <= max.y; + } +} diff --git a/src/CultMath/rect.cs.meta b/src/CultMath/rect.cs.meta new file mode 100644 index 00000000..9f7598ed --- /dev/null +++ b/src/CultMath/rect.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b00dc5f108967db47ac2f54a47033e33 \ No newline at end of file diff --git a/tests.meta b/tests.meta new file mode 100644 index 00000000..a35356c4 --- /dev/null +++ b/tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4ca61dedbfb3c5d46b0acaf23202b5c2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/CultMath.Tests.meta b/tests/CultMath.Tests.meta new file mode 100644 index 00000000..3f1451c6 --- /dev/null +++ b/tests/CultMath.Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bcee65f13f85daa45b0cf3fdaa901779 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/CultMath.Tests/CultMath.Tests.csproj.meta b/tests/CultMath.Tests/CultMath.Tests.csproj.meta new file mode 100644 index 00000000..709184bb --- /dev/null +++ b/tests/CultMath.Tests/CultMath.Tests.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 636051db121e19146a0baf92b790415c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs index 91a95f4d..ed9119b7 100644 --- a/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -4,6 +4,14 @@ namespace CultMath.Tests; public sealed class HlslMirrorTests { + [Fact] + public void HlslMirrorPublishesSphericalErosionKernel() + { + var source = File.ReadAllText(FindHlslMirror()); + Assert.Contains("cultmath_spherical_erosion", source); + Assert.Contains("CultMathSphericalErosionParameters", source); + } + [Fact] public void HlslMirrorPublishesCultMathPrimitives() { @@ -23,6 +31,7 @@ public sealed class HlslMirrorTests "cultmath_distance", "cultmath_normalize", "cultmath_reflect", + "cultmath_rotate", "cultmath_csum", "cultmath_decay", "cultmath_damp", diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs.meta b/tests/CultMath.Tests/HlslMirrorTests.cs.meta new file mode 100644 index 00000000..1fcf44b3 --- /dev/null +++ b/tests/CultMath.Tests/HlslMirrorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d7eb9df849ea0148a71419bbfe2352c \ No newline at end of file diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 44dd1290..bb816d47 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -23,6 +23,7 @@ public sealed class MathTests [Fact] public void VectorConstructorsAcceptLowerDimensionVectors() { + Assert.Equal(new int2(1, 2), math.int2(1, 2)); Assert.Equal(new float2(1.0f, 2.0f), math.float2(1.0f, 2.0f)); Assert.Equal(new float3(1.0f, 1.0f, 1.0f), math.float3(1.0f)); Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); @@ -31,6 +32,112 @@ public sealed class MathTests Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); } + [Fact] + public void RectNormalizesBoundsAndContainsPoints() + { + var viewport = math.rect(10.0f, 20.0f, -5.0f, -2.0f); + + Assert.Equal(new float2(-5.0f, -2.0f), viewport.min); + Assert.Equal(new float2(10.0f, 20.0f), viewport.max); + Assert.Equal(new float2(15.0f, 22.0f), viewport.size); + Assert.Equal(new float2(2.5f, 9.0f), viewport.center); + Assert.True(viewport.Contains(new float2(0.0f, 0.0f))); + Assert.False(viewport.Contains(new float2(11.0f, 0.0f))); + } + + [Fact] + public void VectorsExposeCommonReadOnlySwizzles() + { + Assert.Equal(new float3(2.0f, 3.0f, 1.0f), new float3(1.0f, 2.0f, 3.0f).yzx); + Assert.Equal(new float3(3.0f, 2.0f, 1.0f), new float3(1.0f, 2.0f, 3.0f).zyx); + Assert.Equal(new float2(3.0f, 2.0f), new float3(1.0f, 2.0f, 3.0f).zy); + Assert.Equal(new float3(1.0f, 2.0f, 4.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f).xyw); + Assert.Equal(new float3(2.0f, 3.0f, 1.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f).yzx); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); + } + + [Fact] + public void VectorsSupportUnityStyleFieldAndSwizzleMutation() + { + var position = new float3(1.0f, 2.0f, 3.0f); + position.y = 5.0f; + position.xz = new float2(8.0f, 13.0f); + + Assert.Equal(new float3(8.0f, 5.0f, 13.0f), position); + + var bounds = new float4(1.0f, 2.0f, 3.0f, 4.0f); + bounds.xy = new float2(-1.0f, -2.0f); + + Assert.Equal(new float4(-1.0f, -2.0f, 3.0f, 4.0f), bounds); + } + + [Fact] + public void DoubleVectorsSupportDampedMath() + { + var value = new double3(1.0, 2.0, 3.0); + value.xy = new double2(5.0, 8.0); + + Assert.Equal(new double3(5.0, 8.0, 3.0), value); + Assert.Equal(new double2(Math.Exp(-2.0), Math.Exp(-4.0)), math.exp(new double2(-2.0, -4.0))); + Assert.Equal(new double3(2.0, 4.0, 6.0), math.lerp(new double3(0.0, 0.0, 0.0), new double3(4.0, 8.0, 12.0), new double3(0.5, 0.5, 0.5))); + } + + [Fact] + public void Int2SupportsGridArithmeticAndFloatPromotion() + { + var cell = new int2(2, 3); + + Assert.Equal(new int2(3, 5), cell + new int2(1, 2)); + Assert.Equal(new int2(4, 6), cell * new int2(2, 2)); + Assert.Equal(3, cell[1]); + + float2 promoted = cell; + Assert.Equal(new float2(2.0f, 3.0f), promoted); + } + + [Fact] + public void Bool2SupportsHullGridStyleStorage() + { + bool2 conductivity = true; + + conductivity.y = false; + + Assert.True(conductivity.x); + Assert.False(conductivity[1]); + Assert.Equal(new bool2(false, true), math.bool2(false, true)); + } + + [Fact] + public void QuaternionLookRotationProducesUnitOrientation() + { + var rotation = quaternion.LookRotation(new float3(0.0f, 0.0f, 1.0f), new float3(0.0f, 1.0f, 0.0f)); + + Assert.Equal(quaternion.identity, rotation); + Assert.Equal(1.0f, math.length((float4)quaternion.LookRotation(new float3(1.0f, 0.0f, 0.0f), new float3(0.0f, 1.0f, 0.0f))), precision: 5); + } + + [Fact] + public void RandomIsDeterministicAndUnityShaped() + { + var a = new Random(1234); + var b = new Random(1234); + + Assert.Equal(a.NextUInt(), b.NextUInt()); + Assert.Equal(a.NextFloat(), b.NextFloat()); + + var bounded = a.NextFloat(-2.0f, 4.0f); + Assert.True(bounded >= -2.0f && bounded < 4.0f); + + var direction = a.NextFloat2Direction(); + Assert.Equal(1.0f, math.length(direction), precision: 5); + + var point = a.NextFloat3(new float3(-1.0f, -2.0f, -3.0f), new float3(1.0f, 2.0f, 3.0f)); + Assert.True(point.x >= -1.0f && point.x < 1.0f); + Assert.True(point.y >= -2.0f && point.y < 2.0f); + Assert.True(point.z >= -3.0f && point.z < 3.0f); + } + [Fact] public void NormalizeProducesUnitLength() { @@ -55,12 +162,39 @@ public sealed class MathTests Assert.Equal(-1.0f, math.cos(math.PI), precision: 5); Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); + Assert.Equal(2, math.min(2, 5)); + Assert.Equal(5, math.max(2, 5)); + Assert.True(math.isinf(float.PositiveInfinity)); + } + + [Fact] + public void SignIsComponentWiseForSimulationVectors() + { + Assert.Equal(-1.0f, math.sign(-12.0f)); + Assert.Equal(0.0f, math.sign(0.0f)); + Assert.Equal(1.0f, math.sign(12.0f)); + Assert.Equal(new float3(-1.0f, 0.0f, 1.0f), math.sign(new float3(-2.0f, 0.0f, 4.0f))); + } + + [Fact] + public void RotateTurnsFloat2CounterClockwiseInRadians() + { + var rotated = math.rotate(new float2(1.0f, 0.0f), math.HALF_PI); + + Assert.Equal(0.0f, rotated.x, precision: 5); + Assert.Equal(1.0f, rotated.y, precision: 5); + + var rotatedDegrees = math.rotate_degrees(new float2(0.0f, 1.0f), 90.0f); + Assert.Equal(-1.0f, rotatedDegrees.x, precision: 5); + Assert.Equal(0.0f, rotatedDegrees.y, precision: 5); } [Fact] public void AetheriaPrimitivesRemainSmallAndDeterministic() { Assert.Equal(6.0f, math.csum(new float3(1.0f, 2.0f, 3.0f))); + Assert.Equal(0.25f, math.unlerp(10.0f, 30.0f, 15.0f)); + Assert.Equal(new float2(0.25f, 0.75f), math.unlerp(new float2(10.0f, 10.0f), new float2(30.0f, 30.0f), new float2(15.0f, 25.0f))); Assert.Equal(MathF.Exp(-2.0f), math.decay(1.0f, 2.0f, 1.0f), precision: 5); Assert.Equal(1.0f - MathF.Exp(-2.0f), math.damp(0.0f, 1.0f, 2.0f, 1.0f), precision: 5); diff --git a/tests/CultMath.Tests/MathTests.cs.meta b/tests/CultMath.Tests/MathTests.cs.meta new file mode 100644 index 00000000..277df5a3 --- /dev/null +++ b/tests/CultMath.Tests/MathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7a709e5345da214aa650e8c796fcaff \ No newline at end of file diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs b/tests/CultMath.Tests/SphericalErosionTests.cs new file mode 100644 index 00000000..8ae04859 --- /dev/null +++ b/tests/CultMath.Tests/SphericalErosionTests.cs @@ -0,0 +1,27 @@ +namespace CultMath.Tests; + +public sealed class SphericalErosionTests +{ + [Fact] + public void SampleIsDeterministicAndScaleInvariant() + { + var parameters = SphericalErosionParameters.Default with { Seed = 42.0f }; + var a = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var b = SphericalErosion.Sample(new float3(3.1f, -7.4f, 5.9f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + + Assert.Equal(a, b); + Assert.InRange(a.HeightOffset, -0.08f, 0.08f); + Assert.InRange(a.Ridge, 0.0f, 1.0f); + Assert.InRange(a.Gully, 0.0f, 1.0f); + } + + [Fact] + public void NearbySamplesRemainContinuousAcrossAChartBoundary() + { + var parameters = SphericalErosionParameters.Default; + var left = SphericalErosion.Sample(math.normalize(new float3(1.0f, 0.99999f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + var right = SphericalErosion.Sample(math.normalize(new float3(0.99999f, 1.0f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + + Assert.InRange(math.abs(left.HeightOffset - right.HeightOffset), 0.0f, 0.0002f); + } +} diff --git a/tools.meta b/tools.meta new file mode 100644 index 00000000..3594fa6a --- /dev/null +++ b/tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 697162910ffd9614ba9655d31af9afa4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tools/compile-hlsl-spirv.ps1.meta b/tools/compile-hlsl-spirv.ps1.meta new file mode 100644 index 00000000..31e7948a --- /dev/null +++ b/tools/compile-hlsl-spirv.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b47e7016a99a5d148bc44c399c13c4d3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tools/compile-spirv-metal.ps1.meta b/tools/compile-spirv-metal.ps1.meta new file mode 100644 index 00000000..2e7e74c2 --- /dev/null +++ b/tools/compile-spirv-metal.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6150c8f461e471b4c8641fd32d1984ef +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tools/get-dxc.ps1.meta b/tools/get-dxc.ps1.meta new file mode 100644 index 00000000..c9ab0d82 --- /dev/null +++ b/tools/get-dxc.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 86d0b9a1006042745b7fff523831049c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tools/get-spirv-cross.ps1.meta b/tools/get-spirv-cross.ps1.meta new file mode 100644 index 00000000..12ccdd8e --- /dev/null +++ b/tools/get-spirv-cross.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dcf41da07502f4b439f8fc5ad25d16c9 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 430cbc9695aba7a03f1f87c82ed84d1eba61a9e5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 19:49:33 +0100 Subject: [PATCH 10/23] Add faithful advanced erosion filter core --- README.md | 5 + THIRD-PARTY-NOTICES.md | 21 +++ shaders/AdvancedErosionFilter.hlsl | 112 +++++++++++++++ shaders/CultMath.hlsl | 2 + src/CultMath/AdvancedErosionFilter.cs | 129 ++++++++++++++++++ .../AdvancedErosionFilterTests.cs | 44 ++++++ tests/CultMath.Tests/HlslMirrorTests.cs | 13 +- tests/CultMath.Tests/SphericalErosionTests.cs | 2 + 8 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 THIRD-PARTY-NOTICES.md create mode 100644 shaders/AdvancedErosionFilter.hlsl create mode 100644 src/CultMath/AdvancedErosionFilter.cs create mode 100644 tests/CultMath.Tests/AdvancedErosionFilterTests.cs diff --git a/README.md b/README.md index 1050e4ec..1228736a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ local `AetheriaMath` surface. The value-noise family follows public shader literature from Inigo Quilez's noise and mini-spline articles, expressed in CultMath names with tests instead of copied project-local helper drift. +The advanced erosion filter is an MPL-2.0 adaptation of Rune Skovbo Johansen's +published filter. Its faithful planar core is isolated in +`AdvancedErosionFilter.cs` and `AdvancedErosionFilter.hlsl`; spherical mapping +is a separate CultMath concern. See `THIRD-PARTY-NOTICES.md`. + ## Build ```powershell diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..c8976207 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,21 @@ +# Third-Party Notices + +## Advanced Terrain Erosion Filter + +`src/CultMath/AdvancedErosionFilter.cs` and +`shaders/AdvancedErosionFilter.hlsl` adapt the mathematical procedure from +Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**: + +- Copyright 2025 Rune Skovbo Johansen. +- Upstream: +- Explanation: +- License: Mozilla Public License 2.0, + + +The CultMath files retain the MPL notice and separate the faithful planar +filter from CultMath's spherical adaptation. Modifications include C#/HLSL +type lowering, explicit bounded octave counts, finite zero-rounding behavior, +and CultMath naming. + +The technique builds on earlier work credited by the upstream author to Fewes +and Clay John. The hash form follows the public shader math of Inigo Quilez. diff --git a/shaders/AdvancedErosionFilter.hlsl b/shaders/AdvancedErosionFilter.hlsl new file mode 100644 index 00000000..6c35dd47 --- /dev/null +++ b/shaders/AdvancedErosionFilter.hlsl @@ -0,0 +1,112 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +#ifndef CULTMATH_ADVANCED_EROSION_FILTER_HLSL +#define CULTMATH_ADVANCED_EROSION_FILTER_HLSL + +struct CultMathAdvancedErosionParameters +{ + float scale; float strength; float gully_weight; float detail; + float4 rounding; float4 onset; float2 assumed_slope; + float cell_scale; float normalization; int octaves; float lacunarity; float gain; +}; + +struct CultMathAdvancedErosionResult +{ + float3 delta; float magnitude; float ridge_map; float fade_target; +}; + +float2 cultmath_erosion_hash2(float2 input) +{ + float2 k = float2(0.3183099, 0.3678794); + float2 x = input * k + k.yx; + return -1.0 + 2.0 * frac(16.0 * k * frac(x.x * x.y * (x.x + x.y))); +} + +float4 cultmath_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) +{ + float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; + float offset = offset_cycles * 6.28318530718; + float2 cell = floor(position); + float2 local = frac(position); + float2 phase = 0.0; + float weight_sum = 0.0; + [unroll] for (int x = -1; x <= 2; x++) + [unroll] for (int y = -1; y <= 2; y++) + { + float2 grid_offset = float2(x, y); + float2 grid_point = cell + grid_offset; + float2 random_offset = cultmath_erosion_hash2(grid_point) * 0.5; + float2 delta = local - grid_offset - random_offset; + float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); + weight_sum += weight; + float wave = dot(delta, side_direction) + offset; + phase += float2(cos(wave), sin(wave)) * weight; + } + float2 interpolated = phase / max(weight_sum, 1.0e-20); + float magnitude = max(1.0 - saturate(normalization), length(interpolated)); + return float4(interpolated / max(magnitude, 1.0e-20), side_direction); +} + +float cultmath_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } +float cultmath_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } +float cultmath_erosion_smooth_start(float value, float smoothing) +{ + return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; +} +float2 cultmath_erosion_safe_normalize(float2 value) +{ + float value_length = length(value); + return abs(value_length) > 1.0e-10 ? value / value_length : value; +} + +CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p) +{ + float strength = p.strength * p.scale; + fade_target = clamp(fade_target, -1.0, 1.0); + float3 input_value = base_height_and_slope; + float3 height_and_slope = input_value; + float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); + float2 slope = height_and_slope.yz; + float slope_length = max(length(slope), 1.0e-10); + float magnitude = 0.0; + float rounding_multiplier = 1.0; + float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; + float combined_mask = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); + float ridge_mask = cultmath_erosion_ease_out(slope_length * p.onset.z); + float ridge_fade = fade_target; + float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); + + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float4 phacelle = cultmath_phacelle_noise(position * frequency, cultmath_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); + float2 derivative_direction = phacelle.zw * -frequency; + float sloping = abs(phacelle.y); + gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; + float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); + float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); + height_and_slope += faded * strength; + magnitude += strength; + fade_target = faded.x; + float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; + float new_mask = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); + combined_mask = cultmath_erosion_pow_inverse(combined_mask, p.detail) * new_mask; + ridge_fade = lerp(ridge_fade, gullies.x, ridge_mask); + ridge_mask *= cultmath_erosion_ease_out(sloping * p.onset.w); + strength *= p.gain; + frequency *= p.lacunarity; + rounding_multiplier *= p.rounding.w; + } + + CultMathAdvancedErosionResult result; + result.delta = height_and_slope - input_value; + result.magnitude = magnitude; + result.ridge_map = ridge_fade * (1.0 - ridge_mask); + result.fade_target = fade_target; + return result; +} + +#endif diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl index 07b2b8a9..c55a1790 100644 --- a/shaders/CultMath.hlsl +++ b/shaders/CultMath.hlsl @@ -263,6 +263,8 @@ float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, Sampl return cultmath_catmullrom(row0, row1, row2, row3, local.y); } +#include "AdvancedErosionFilter.hlsl" + struct CultMathSphericalErosionParameters { float frequency; float amplitude; float lacunarity; float gain; diff --git a/src/CultMath/AdvancedErosionFilter.cs b/src/CultMath/AdvancedErosionFilter.cs new file mode 100644 index 00000000..f004c327 --- /dev/null +++ b/src/CultMath/AdvancedErosionFilter.cs @@ -0,0 +1,129 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +namespace CultMath; + +public readonly record struct AdvancedErosionParameters( + float Scale, + float Strength, + float GullyWeight, + float Detail, + float4 Rounding, + float4 Onset, + float2 AssumedSlope, + float CellScale, + float Normalization, + int Octaves, + float Lacunarity, + float Gain) +{ + public static AdvancedErosionParameters Default => new( + 0.15f, 0.22f, 0.5f, 1.5f, + new float4(0.1f, 0.0f, 0.1f, 2.0f), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 1.0f), + 0.7f, 0.5f, 5, 2.0f, 0.5f); +} + +public readonly record struct AdvancedErosionResult(float3 Delta, float Magnitude, float RidgeMap, float FadeTarget); + +public static class AdvancedErosionFilter +{ + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p) + { + var strength = p.Strength * p.Scale; + fadeTarget = math.clamp(fadeTarget, -1.0f, 1.0f); + var input = baseHeightAndSlope; + var heightAndSlope = input; + var frequency = 1.0f / math.max(p.Scale * p.CellScale, 1.0e-10f); + var slope = new float2(heightAndSlope.y, heightAndSlope.z); + var slopeLength = math.max(math.length(slope), 1.0e-10f); + var magnitude = 0.0f; + var roundingMultiplier = 1.0f; + + var inputRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(fadeTarget + 0.5f)) * p.Rounding.z; + var combinedMask = EaseOut(SmoothStart(slopeLength * p.Onset.x, inputRounding * p.Onset.x)); + var ridgeMask = EaseOut(slopeLength * p.Onset.z); + var ridgeFade = fadeTarget; + var gullySlope = math.lerp(slope, slope / slopeLength * p.AssumedSlope.x, p.AssumedSlope.y); + + var octaves = math.min(math.max(p.Octaves, 0), 8); + for (var octave = 0; octave < octaves; octave++) + { + var phacelle = Phacelle(position * frequency, SafeNormalize(gullySlope), p.CellScale, 0.25f, p.Normalization); + var derivativeDirection = new float2(phacelle.z, phacelle.w) * -frequency; + var sloping = math.abs(phacelle.y); + gullySlope += math.sign(phacelle.y) * derivativeDirection * strength * p.GullyWeight; + + var gullies = new float3(phacelle.x, phacelle.y * derivativeDirection.x, phacelle.y * derivativeDirection.y); + var faded = math.lerp(new float3(fadeTarget, 0.0f, 0.0f), gullies * p.GullyWeight, combinedMask); + heightAndSlope += faded * strength; + magnitude += strength; + fadeTarget = faded.x; + + var octaveRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(phacelle.x + 0.5f)) * roundingMultiplier; + var newMask = EaseOut(SmoothStart(sloping * p.Onset.y, octaveRounding * p.Onset.y)); + combinedMask = PowInverse(combinedMask, p.Detail) * newMask; + + ridgeFade = math.lerp(ridgeFade, gullies.x, ridgeMask); + ridgeMask *= EaseOut(sloping * p.Onset.w); + strength *= p.Gain; + frequency *= p.Lacunarity; + roundingMultiplier *= p.Rounding.w; + } + + return new AdvancedErosionResult(heightAndSlope - input, magnitude, ridgeFade * (1.0f - ridgeMask), fadeTarget); + } + + public static float4 Phacelle(float2 position, float2 normalDirection, float frequency, float offsetCycles, float normalization) + { + var sideDirection = new float2(-normalDirection.y, normalDirection.x) * frequency * math.TAU; + var offset = offsetCycles * math.TAU; + var cell = math.floor(position); + var local = math.frac(position); + var phase = float2.zero; + var weightSum = 0.0f; + + for (var x = -1; x <= 2; x++) + for (var y = -1; y <= 2; y++) + { + var gridOffset = new float2(x, y); + var gridPoint = cell + gridOffset; + var randomOffset = Hash2(gridPoint) * 0.5f; + var delta = local - gridOffset - randomOffset; + var weight = math.max(0.0f, math.exp(-math.dot(delta, delta) * 2.0f) - 0.01111f); + weightSum += weight; + var wave = math.dot(delta, sideDirection) + offset; + phase += new float2(math.cos(wave), math.sin(wave)) * weight; + } + + var interpolated = phase / math.max(weightSum, 1.0e-20f); + var magnitude = math.max(1.0f - math.saturate(normalization), math.length(interpolated)); + var normalized = interpolated / math.max(magnitude, 1.0e-20f); + return new float4(normalized, sideDirection.x, sideDirection.y); + } + + private static float2 Hash2(float2 input) + { + var k = new float2(0.3183099f, 0.3678794f); + var x = input * k + new float2(k.y, k.x); + var scalar = math.frac(x.x * x.y * (x.x + x.y)); + return -float2.one + 2.0f * math.frac(16.0f * k * scalar); + } + + private static float2 SafeNormalize(float2 value) + { + var length = math.length(value); + return math.abs(length) > 1.0e-10f ? value / length : value; + } + + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), power); + private static float EaseOut(float value) { var inverse = 1.0f - math.saturate(value); return 1.0f - inverse * inverse; } + private static float SmoothStart(float value, float smoothing) => smoothing <= 1.0e-10f || value >= smoothing + ? value - 0.5f * smoothing + : 0.5f * value * value / smoothing; +} diff --git a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs new file mode 100644 index 00000000..cccebd64 --- /dev/null +++ b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs @@ -0,0 +1,44 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class AdvancedErosionFilterTests +{ + [Fact] + public void DefaultReferenceFixtureIsStable() + { + var result = AdvancedErosionFilter.Sample( + new float2(0.42f, 0.31f), + new float3(0.5f, 0.1f, -0.2f), + 0.0f, + AdvancedErosionParameters.Default); + + Assert.Equal(0.0639375f, result.Magnitude, precision: 7); + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.Delta.y)); + Assert.True(float.IsFinite(result.Delta.z)); + Assert.InRange(result.RidgeMap, -1.0f, 1.0f); + } + + [Fact] + public void FlatInputAndZeroRoundingRemainFinite() + { + var parameters = AdvancedErosionParameters.Default with { Rounding = float4.zero }; + var result = AdvancedErosionFilter.Sample(new float2(0.25f, 0.75f), float3.zero, 0.0f, parameters); + + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.RidgeMap)); + Assert.True(float.IsFinite(result.FadeTarget)); + } + + [Fact] + public void PhacellePartialNormalizationIsFiniteAtEndpoints() + { + foreach (var normalization in new[] { 0.0f, 0.5f, 1.0f }) + { + var sample = AdvancedErosionFilter.Phacelle(new float2(1.7f, -3.2f), new float2(0.6f, -0.8f), 0.7f, 0.25f, normalization); + Assert.True(float.IsFinite(sample.x)); + Assert.True(float.IsFinite(sample.y)); + } + } +} diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs index ed9119b7..0e904a32 100644 --- a/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -7,11 +7,22 @@ public sealed class HlslMirrorTests [Fact] public void HlslMirrorPublishesSphericalErosionKernel() { - var source = File.ReadAllText(FindHlslMirror()); + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl")); Assert.Contains("cultmath_spherical_erosion", source); Assert.Contains("CultMathSphericalErosionParameters", source); } + [Fact] + public void HlslMirrorPublishesFaithfulAdvancedErosionKernel() + { + var mirror = Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl"); + var source = File.ReadAllText(mirror); + Assert.Contains("AdvancedErosionFilter.hlsl", source); + var advanced = File.ReadAllText(Path.Combine(Path.GetDirectoryName(mirror)!, "AdvancedErosionFilter.hlsl")); + Assert.Contains("cultmath_phacelle_noise", advanced); + Assert.Contains("cultmath_advanced_erosion_filter", advanced); + } + [Fact] public void HlslMirrorPublishesCultMathPrimitives() { diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs b/tests/CultMath.Tests/SphericalErosionTests.cs index 8ae04859..53d37bce 100644 --- a/tests/CultMath.Tests/SphericalErosionTests.cs +++ b/tests/CultMath.Tests/SphericalErosionTests.cs @@ -1,3 +1,5 @@ +using Xunit; + namespace CultMath.Tests; public sealed class SphericalErosionTests From 33c017b284c11a0b54e54dc8aefef2a920a90a55 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 19:57:24 +0100 Subject: [PATCH 11/23] Make erosion cell hashing cross-device deterministic --- THIRD-PARTY-NOTICES.md | 5 ++++- shaders/AdvancedErosionFilter.hlsl | 11 ++++++++--- src/CultMath/AdvancedErosionFilter.cs | 14 ++++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index c8976207..8339c5fe 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -15,7 +15,10 @@ Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**: The CultMath files retain the MPL notice and separate the faithful planar filter from CultMath's spherical adaptation. Modifications include C#/HLSL type lowering, explicit bounded octave counts, finite zero-rounding behavior, -and CultMath naming. +and CultMath naming. CultMath replaces the reference floating hash with a +shared integer avalanche hash so C# and GPU evaluation select identical cell +pivots across devices; this changes the stochastic pattern, not the filter +mechanics. The technique builds on earlier work credited by the upstream author to Fewes and Clay John. The hash form follows the public shader math of Inigo Quilez. diff --git a/shaders/AdvancedErosionFilter.hlsl b/shaders/AdvancedErosionFilter.hlsl index 6c35dd47..38c3ef19 100644 --- a/shaders/AdvancedErosionFilter.hlsl +++ b/shaders/AdvancedErosionFilter.hlsl @@ -19,11 +19,16 @@ struct CultMathAdvancedErosionResult float3 delta; float magnitude; float ridge_map; float fade_target; }; +float cultmath_erosion_hash_unit(int2 cell, uint salt) +{ + uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0 / 16777216.0); +} float2 cultmath_erosion_hash2(float2 input) { - float2 k = float2(0.3183099, 0.3678794); - float2 x = input * k + k.yx; - return -1.0 + 2.0 * frac(16.0 * k * frac(x.x * x.y * (x.x + x.y))); + int2 cell = (int2)input; + return float2(cultmath_erosion_hash_unit(cell, 0x68bc21ebu), cultmath_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; } float4 cultmath_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) diff --git a/src/CultMath/AdvancedErosionFilter.cs b/src/CultMath/AdvancedErosionFilter.cs index f004c327..6780223f 100644 --- a/src/CultMath/AdvancedErosionFilter.cs +++ b/src/CultMath/AdvancedErosionFilter.cs @@ -109,10 +109,16 @@ public static class AdvancedErosionFilter private static float2 Hash2(float2 input) { - var k = new float2(0.3183099f, 0.3678794f); - var x = input * k + new float2(k.y, k.x); - var scalar = math.frac(x.x * x.y * (x.x + x.y)); - return -float2.one + 2.0f * math.frac(16.0f * k * scalar); + var x = (int)input.x; + var y = (int)input.y; + return new float2(HashUnit(x, y, 0x68bc21ebu), HashUnit(x, y, 0x02e5be93u)) * 2.0f - float2.one; + } + + private static float HashUnit(int x, int y, uint salt) + { + var value = unchecked((uint)x * 0x8da6b343u ^ (uint)y * 0xd8163841u ^ salt); + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0f / 16777216.0f); } private static float2 SafeNormalize(float2 value) From ddeac557cb2dbc9b47e14010ce5e4b9ce1252c98 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 20:02:45 +0100 Subject: [PATCH 12/23] Add physical erosion frequency bands --- shaders/AdvancedErosionFilter.hlsl | 42 ++++++++++++-- src/CultMath/AdvancedErosionFilter.cs | 14 +++-- src/CultMath/ErosionFrequencyBands.cs | 57 +++++++++++++++++++ .../ErosionFrequencyBandsTests.cs | 34 +++++++++++ 4 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 src/CultMath/ErosionFrequencyBands.cs create mode 100644 tests/CultMath.Tests/ErosionFrequencyBandsTests.cs diff --git a/shaders/AdvancedErosionFilter.hlsl b/shaders/AdvancedErosionFilter.hlsl index 38c3ef19..a0a350e5 100644 --- a/shaders/AdvancedErosionFilter.hlsl +++ b/shaders/AdvancedErosionFilter.hlsl @@ -19,6 +19,28 @@ struct CultMathAdvancedErosionResult float3 delta; float magnitude; float ridge_map; float fade_target; }; +struct CultMathErosionBandSelection +{ + int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound; +}; + +CultMathErosionBandSelection cultmath_select_erosion_bands(float base_wavelength, float sample_spacing, int maximum_octaves, float lacunarity, float base_amplitude, float gain, float transition_ratio) +{ + base_wavelength=max(base_wavelength,1.0e-6); sample_spacing=max(sample_spacing,0.0); lacunarity=max(lacunarity,1.01); transition_ratio=max(transition_ratio,1.01); maximum_octaves=min(max(maximum_octaves,0),16); + float nyquist=sample_spacing*2.0, wavelength=base_wavelength, final_weight=0.0, finest=0.0; int active=0; + [loop] for(int octave=0; octave=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); + } + CultMathErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; +} + float cultmath_erosion_hash_unit(int2 cell, uint salt) { uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; @@ -68,7 +90,7 @@ float2 cultmath_erosion_safe_normalize(float2 value) return abs(value_length) > 1.0e-10 ? value / value_length : value; } -CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p) +CultMathAdvancedErosionResult cultmath_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p, CultMathErosionBandSelection band) { float strength = p.strength * p.scale; fade_target = clamp(fade_target, -1.0, 1.0); @@ -85,7 +107,8 @@ CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, float ridge_fade = fade_target; float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); - [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); + [loop] for (int octave = 0; octave < octave_count; octave++) { float4 phacelle = cultmath_phacelle_noise(position * frequency, cultmath_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); float2 derivative_direction = phacelle.zw * -frequency; @@ -93,14 +116,15 @@ CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); - height_and_slope += faded * strength; - magnitude += strength; + float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; + height_and_slope += faded * strength * octave_weight; + magnitude += strength * octave_weight; fade_target = faded.x; float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; float new_mask = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); combined_mask = cultmath_erosion_pow_inverse(combined_mask, p.detail) * new_mask; - ridge_fade = lerp(ridge_fade, gullies.x, ridge_mask); - ridge_mask *= cultmath_erosion_ease_out(sloping * p.onset.w); + ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); + ridge_mask *= lerp(1.0, cultmath_erosion_ease_out(sloping * p.onset.w), octave_weight); strength *= p.gain; frequency *= p.lacunarity; rounding_multiplier *= p.rounding.w; @@ -114,4 +138,10 @@ CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, return result; } +CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p) +{ + CultMathErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; + return cultmath_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); +} + #endif diff --git a/src/CultMath/AdvancedErosionFilter.cs b/src/CultMath/AdvancedErosionFilter.cs index 6780223f..65db3671 100644 --- a/src/CultMath/AdvancedErosionFilter.cs +++ b/src/CultMath/AdvancedErosionFilter.cs @@ -34,6 +34,9 @@ public readonly record struct AdvancedErosionResult(float3 Delta, float Magnitud public static class AdvancedErosionFilter { public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p) + => Sample(position, baseHeightAndSlope, fadeTarget, p, new ErosionBandSelection(p.Octaves, 1.0f, 0.0f, 0.0f)); + + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p, ErosionBandSelection band) { var strength = p.Strength * p.Scale; fadeTarget = math.clamp(fadeTarget, -1.0f, 1.0f); @@ -51,7 +54,7 @@ public static class AdvancedErosionFilter var ridgeFade = fadeTarget; var gullySlope = math.lerp(slope, slope / slopeLength * p.AssumedSlope.x, p.AssumedSlope.y); - var octaves = math.min(math.max(p.Octaves, 0), 8); + var octaves = math.min(math.min(math.max(p.Octaves, 0), 8), math.max(band.ActiveOctaves, 0)); for (var octave = 0; octave < octaves; octave++) { var phacelle = Phacelle(position * frequency, SafeNormalize(gullySlope), p.CellScale, 0.25f, p.Normalization); @@ -61,16 +64,17 @@ public static class AdvancedErosionFilter var gullies = new float3(phacelle.x, phacelle.y * derivativeDirection.x, phacelle.y * derivativeDirection.y); var faded = math.lerp(new float3(fadeTarget, 0.0f, 0.0f), gullies * p.GullyWeight, combinedMask); - heightAndSlope += faded * strength; - magnitude += strength; + var octaveWeight = octave == octaves - 1 ? math.saturate(band.FinalOctaveWeight) : 1.0f; + heightAndSlope += faded * strength * octaveWeight; + magnitude += strength * octaveWeight; fadeTarget = faded.x; var octaveRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(phacelle.x + 0.5f)) * roundingMultiplier; var newMask = EaseOut(SmoothStart(sloping * p.Onset.y, octaveRounding * p.Onset.y)); combinedMask = PowInverse(combinedMask, p.Detail) * newMask; - ridgeFade = math.lerp(ridgeFade, gullies.x, ridgeMask); - ridgeMask *= EaseOut(sloping * p.Onset.w); + ridgeFade = math.lerp(ridgeFade, math.lerp(ridgeFade, gullies.x, ridgeMask), octaveWeight); + ridgeMask *= math.lerp(1.0f, EaseOut(sloping * p.Onset.w), octaveWeight); strength *= p.Gain; frequency *= p.Lacunarity; roundingMultiplier *= p.Rounding.w; diff --git a/src/CultMath/ErosionFrequencyBands.cs b/src/CultMath/ErosionFrequencyBands.cs new file mode 100644 index 00000000..3c63eb62 --- /dev/null +++ b/src/CultMath/ErosionFrequencyBands.cs @@ -0,0 +1,57 @@ +namespace CultMath; + +public readonly record struct ErosionBandSelection( + int ActiveOctaves, + float FinalOctaveWeight, + float FinestIncludedWavelength, + float UnresolvedHeightBound) +{ + public bool HasSamples => ActiveOctaves > 0; +} + +public static class ErosionFrequencyBands +{ + public static ErosionBandSelection Select( + float baseWavelength, + float sampleSpacing, + int maximumOctaves, + float lacunarity, + float baseAmplitude, + float gain, + float transitionRatio = 2.0f) + { + baseWavelength = math.max(baseWavelength, 1.0e-6f); + sampleSpacing = math.max(sampleSpacing, 0.0f); + lacunarity = math.max(lacunarity, 1.01f); + transitionRatio = math.max(transitionRatio, 1.01f); + maximumOctaves = math.min(math.max(maximumOctaves, 0), 16); + var nyquistWavelength = sampleSpacing * 2.0f; + var wavelength = baseWavelength; + var active = 0; + var finalWeight = 0.0f; + var finest = 0.0f; + + for (var octave = 0; octave < maximumOctaves; octave++) + { + var weight = nyquistWavelength <= 0.0f + ? 1.0f + : math.smoothstep(nyquistWavelength, nyquistWavelength * transitionRatio, wavelength); + if (weight <= 0.0f) break; + active++; + finalWeight = weight; + finest = wavelength; + wavelength /= lacunarity; + } + + var omittedAmplitude = 0.0f; + var amplitude = math.abs(baseAmplitude); + for (var octave = 0; octave < maximumOctaves; octave++) + { + if (octave >= active) omittedAmplitude += amplitude; + else if (octave == active - 1) omittedAmplitude += amplitude * (1.0f - finalWeight); + amplitude *= math.abs(gain); + } + + return new ErosionBandSelection(active, active > 0 ? finalWeight : 0.0f, finest, omittedAmplitude); + } +} diff --git a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs new file mode 100644 index 00000000..f57213a4 --- /dev/null +++ b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs @@ -0,0 +1,34 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class ErosionFrequencyBandsTests +{ + [Fact] + public void RadiusIndependentWorldWavelengthSelectsSameBands() + { + var small = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + var large = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + Assert.Equal(small, large); + Assert.Equal(3, small.ActiveOctaves); + } + + [Fact] + public void TransitionPartiallyWeightsLastResolvableOctave() + { + var selection = ErosionFrequencyBands.Select(1_000.0f, 50.0f, 8, 2.0f, 1.0f, 0.5f); + Assert.Equal(4, selection.ActiveOctaves); + Assert.InRange(selection.FinalOctaveWeight, 0.0f, 1.0f); + Assert.True(selection.UnresolvedHeightBound > 0.0f); + } + + [Fact] + public void BandedFilterPreservesLowOctaveResultExactly() + { + var p = AdvancedErosionParameters.Default; + var band = new ErosionBandSelection(2, 1.0f, 0.0f, 0.0f); + var banded = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p, band); + var truncated = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p with { Octaves = 2 }); + Assert.Equal(truncated, banded); + } +} From e870663fe038d5ed532a48243b93bb376796ab2d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 00:41:25 +0100 Subject: [PATCH 13/23] Make spherical erosion diagnostics finite --- src/CultMath/float3.cs | 2 ++ tests/CultMath.Tests/SphericalErosionTests.cs | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/CultMath/float3.cs b/src/CultMath/float3.cs index e9833ae4..4a4efeba 100644 --- a/src/CultMath/float3.cs +++ b/src/CultMath/float3.cs @@ -67,6 +67,8 @@ public record struct float3(float x, float y, float z) public static explicit operator System.Numerics.Vector3(float3 value) => new(value.x, value.y, value.z); public static explicit operator float3(System.Numerics.Vector3 value) => new(value.X, value.Y, value.Z); + public override readonly string ToString() => $"float3({x:R}, {y:R}, {z:R})"; + public static float3 operator +(float3 value) => value; public static float3 operator -(float3 value) => new(-value.x, -value.y, -value.z); public static float3 operator +(float3 left, float3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs b/tests/CultMath.Tests/SphericalErosionTests.cs index 53d37bce..f634ba6d 100644 --- a/tests/CultMath.Tests/SphericalErosionTests.cs +++ b/tests/CultMath.Tests/SphericalErosionTests.cs @@ -9,9 +9,14 @@ public sealed class SphericalErosionTests { var parameters = SphericalErosionParameters.Default with { Seed = 42.0f }; var a = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var repeated = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); var b = SphericalErosion.Sample(new float3(3.1f, -7.4f, 5.9f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); - Assert.Equal(a, b); + Assert.Equal(a, repeated); + Assert.InRange(math.abs(a.HeightOffset-b.HeightOffset),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Ridge-b.Ridge),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Gully-b.Gully),0.0f,5.0e-5f); + Assert.InRange(math.length(a.Gradient-b.Gradient),0.0f,5.0e-6f); Assert.InRange(a.HeightOffset, -0.08f, 0.08f); Assert.InRange(a.Ridge, 0.0f, 1.0f); Assert.InRange(a.Gully, 0.0f, 1.0f); From 15f27173ddf4bf6d5d0380b0d79cd644ce42f6e8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 00:45:25 +0100 Subject: [PATCH 14/23] Share planetary radial refinement semantics --- shaders/CultMath.hlsl | 19 ++++++++++++ src/CultMath/PlanetaryRadialRefinement.cs | 28 ++++++++++++++++++ .../PlanetaryRadialRefinementTests.cs | 29 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/CultMath/PlanetaryRadialRefinement.cs create mode 100644 tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl index c55a1790..e7281c99 100644 --- a/shaders/CultMath.hlsl +++ b/shaders/CultMath.hlsl @@ -337,4 +337,23 @@ CultMathSphericalErosionSample cultmath_spherical_erosion(float3 unit_position, return result; } +float3 cultmath_planetary_radial_refinement_step( + float3 position, + float3 ray_direction, + float3 center, + float target_radius, + float minimum_derivative, + float maximum_correction) +{ + float3 local=position-center; + float radius=length(local); + float3 radial=local/max(radius,0.0001); + float error=radius-target_radius; + float derivative=dot(ray_direction,radial); + float safe_minimum=max(minimum_derivative,0.0001); + float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); + float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); + return position+ray_direction*correction; +} + #endif diff --git a/src/CultMath/PlanetaryRadialRefinement.cs b/src/CultMath/PlanetaryRadialRefinement.cs new file mode 100644 index 00000000..7188a513 --- /dev/null +++ b/src/CultMath/PlanetaryRadialRefinement.cs @@ -0,0 +1,28 @@ +namespace CultMath; + +public static class PlanetaryRadialRefinement +{ + public static float3 Step( + float3 position, + float3 rayDirection, + float3 center, + float targetRadius, + float minimumDerivative = 0.2f, + float maximumCorrection = 0.08f) + { + var local = position - center; + var radius = math.length(local); + var radial = local / math.max(radius, 0.0001f); + var error = radius - targetRadius; + var derivative = math.dot(rayDirection, radial); + var safeMinimum = math.max(minimumDerivative, 0.0001f); + var safeDerivative = math.abs(derivative) > safeMinimum + ? derivative + : derivative < 0.0f ? -safeMinimum : safeMinimum; + var correction = math.clamp( + -error / safeDerivative, + -math.abs(maximumCorrection), + math.abs(maximumCorrection)); + return position + rayDirection * correction; + } +} diff --git a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs new file mode 100644 index 00000000..d0263b67 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs @@ -0,0 +1,29 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryRadialRefinementTests +{ + [Fact] + public void FourBoundedStepsConvergeFromTheRasterCaptureShell() + { + var center=new float3(0.3f,-0.2f,0.7f); + var radial=math.normalize(new float3(0.2f,-0.4f,0.9f)); + var targetRadius=3.85f; + var target=center+radial*targetRadius; + var origin=target+math.normalize(radial+new float3(0.12f,-0.05f,0.02f))*6.0f; + var position=center+radial*(targetRadius+0.30f); + var ray=math.normalize(position-origin); + for(var step=0;step<4;step++) + position=PlanetaryRadialRefinement.Step(position,ray,center,targetRadius); + Assert.InRange(math.abs(math.length(position-center)-targetRadius),0.0f,1.0e-4f); + } + + [Fact] + public void StepClampsWeakDerivativeCorrections() + { + var position=new float3(0,0,4.1f); + var result=PlanetaryRadialRefinement.Step(position,new float3(1,0,0),float3.zero,3.8f); + Assert.InRange(math.length(result-position),0.07999f,0.08001f); + } +} From 42fef7cdd70e8d8b98fab56a58cfd1a361120db7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 02:14:11 +0100 Subject: [PATCH 15/23] Build portable planetary field runtime --- README.md | 6 + docs/design.md | 6 + docs/planetary.md | 117 +++++++ shaders/CultMath.hlsl | 1 + shaders/Planetary.hlsl | 260 ++++++++++++++ src/CultMath/CultMath.csproj | 2 + src/CultMath/PlanetaryField.cs | 213 ++++++++++++ src/CultMath/PlanetaryPages.cs | 185 ++++++++++ src/CultMath/PlanetaryPatch.cs | 42 +++ src/CultMath/PlanetaryProjection.cs | 320 ++++++++++++++++++ src/CultMath/PlanetaryQueries.cs | 191 +++++++++++ src/CultMath/PlanetaryResidency.cs | 144 ++++++++ src/CultMath/PlanetaryTopology.cs | 170 ++++++++++ tests/CultMath.Tests/HlslMirrorTests.cs | 16 + tests/CultMath.Tests/PlanetaryFieldTests.cs | 80 +++++ tests/CultMath.Tests/PlanetaryPageTests.cs | 57 ++++ tests/CultMath.Tests/PlanetaryPatchTests.cs | 40 +++ .../PlanetaryProjectionTests.cs | 73 ++++ tests/CultMath.Tests/PlanetaryQueryTests.cs | 46 +++ .../CultMath.Tests/PlanetaryResidencyTests.cs | 57 ++++ .../CultMath.Tests/PlanetaryTopologyTests.cs | 55 +++ unity/Runtime/CultMath.Unity.asmdef | 16 + unity/Runtime/PlanetaryPageUpload.cs | 81 +++++ unity/Runtime/PlanetaryPatchMeshAdapter.cs | 36 ++ 24 files changed, 2214 insertions(+) create mode 100644 docs/planetary.md create mode 100644 shaders/Planetary.hlsl create mode 100644 src/CultMath/PlanetaryField.cs create mode 100644 src/CultMath/PlanetaryPages.cs create mode 100644 src/CultMath/PlanetaryPatch.cs create mode 100644 src/CultMath/PlanetaryProjection.cs create mode 100644 src/CultMath/PlanetaryQueries.cs create mode 100644 src/CultMath/PlanetaryResidency.cs create mode 100644 src/CultMath/PlanetaryTopology.cs create mode 100644 tests/CultMath.Tests/PlanetaryFieldTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryPageTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryPatchTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryProjectionTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryQueryTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryResidencyTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryTopologyTests.cs create mode 100644 unity/Runtime/CultMath.Unity.asmdef create mode 100644 unity/Runtime/PlanetaryPageUpload.cs create mode 100644 unity/Runtime/PlanetaryPatchMeshAdapter.cs diff --git a/README.md b/README.md index 1228736a..29eb115f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ shader semantics. - `Voronoi.SampleTones`, a C# batch surface that calls the Rust `cultmath-core` native kernel when `cultmath_core` is available and falls back to the managed parity path otherwise. +- `PlanetaryField`, a scale-aware CPU/HLSL spherical field contract with + deterministic identity, cube-sphere topology, residual pages, LOD residency, + ray/path/region queries, coarse patches, and projected map-tile baking. See + `docs/planetary.md`. +- `CultMath.Unity`, an engine-facing assembly containing only Unity mesh and + page-upload adapters for the canonical planetary contracts. ## Native Core diff --git a/docs/design.md b/docs/design.md index 8250ba78..f4ca5b49 100644 --- a/docs/design.md +++ b/docs/design.md @@ -19,6 +19,12 @@ Rust hot kernels, C# host code, and HLSL shader code. - Keep the surface boring and exact. Noise primitives are allowed when they are source-grounded, shader-shaped, and shared by more than one project. Matrices, quaternions, and packing helpers still need an owner before they enter. +- Planetary geometry is a scale-aware field query, not a renderer asset. CPU + simulation is the reference consumer; pages, patches, and maps are derived + lowerings carrying field identity and unresolved error. +- Application base fields and gameplay meaning enter through narrow inputs. + CultMath does not own biomes, resources, traversability, render materials, or + committed game consequences. ## Non-Goals diff --git a/docs/planetary.md b/docs/planetary.md new file mode 100644 index 00000000..8196c1c7 --- /dev/null +++ b/docs/planetary.md @@ -0,0 +1,117 @@ +# Planetary Field Runtime + +CultMath owns the deterministic, renderer-independent planetary field. The same +definition can be queried by a CPU simulation, baked into residual pages, +sampled through a map projection, or evaluated by HLSL. Render resources and +gameplay interpretation remain consumer responsibilities. + +## Authority + +- `PlanetaryFieldDefinition` identifies radius, seed, erosion parameters, and a + version that includes the consumer-owned base-field version. +- `IPlanetaryBaseField` supplies deterministic application-authored base + evidence without making CultMath depend on an application. +- `PlanetaryQueryScale` makes the physical footprint and accepted unresolved + error explicit. There is deliberately no scale-free authoritative + `HeightAt` call. +- `PlanetarySurfaceSample` carries displacement, tangent gradient, normal, + slope, ridge/gully evidence, resolved wavelength, and unresolved bound. +- pages, residency, patches, and map tiles are disposable lowerings of that + contract. + +The C# surface targets .NET and Unity-compatible `netstandard2.1`. The matching +HLSL lives in `shaders/Planetary.hlsl` and is included by `CultMath.hlsl`. + +## Point and batch queries + +```csharp +var erosion = AdvancedErosionParameters.Default with +{ + Scale = radius * 0.075f, + Strength = 0.12f, + Octaves = 7, +}; +var field = PlanetaryFieldDefinition.Create( + baseFieldVersion: 17, + radius, + seed: 42, + erosion); +var scale = PlanetaryQueryScale.AtFootprint( + footprintMeters: 10, + maximumUnresolvedHeight: 0.5f); + +PlanetaryBaseFieldSample basis = source.Sample(direction); +PlanetarySurfaceSample sample = PlanetaryField.Sample( + field, direction, basis, scale); +``` + +`PlanetaryField.SampleBatch` writes into a caller-owned span. `SamplePosition` +accepts `double3`, reduces the astronomical position to a stable unit direction, +and then evaluates the same float field contract. + +Higher-level CPU queries include bounded ray intersection, segment clearance, +great-circle profiles, and region summaries. Their answers remain field queries; +only a consuming simulation can turn them into committed game consequences. + +## Cube-sphere topology and pages + +`PlanetaryTopology` owns the tangent/QSC cube-sphere mapping, inverse face +coordinates, tile selection, local page coordinates, and radial-graph surface +normal. `PlanetaryPageSampling` owns bordered directions, nominal spacing, +bilinear sampling, and summaries. + +`PlanetaryPageBaker` evaluates a root page at its physical sample spacing. A +child stores `child filtered field - parent filtered field`. A +`PlanetaryPageSetSampling` query adds every containing ancestor contribution +and applies transition weights only to residuals. + +`PlanetaryLodSelector` selects six roots plus one camera-facing ancestor chain +from the unresolved-height error. `PlanetaryResidualResidency` owns arrival and +departure presentation weights, content and presentation versions, and nothing +about field truth. + +## Patches + +`PlanetaryPatch.CreateFace` and `CreateCubeSphere` produce renderer-neutral +coarse meshes from the canonical topology. HLSL calls +`cultmath_planetary_face_direction` for procedural vertices and +`cultmath_planetary_radial_refinement_step` for bounded pixel refinement. + +Renderers still own cameras, clip space, depth, materials, buffers, dispatch, +and draw submission. + +The optional `CultMath.Unity` assembly converts a canonical face patch into a +Unity `Mesh` and converts a residency snapshot into Unity-native page input and +metadata payloads. It owns no field parameters, page selection, or simulation +state. Unity render code remains responsible for allocating/binding graphics +buffers and dispatching application-specific base-field entry points. + +## Maps + +`PlanetaryProjection` supplies forward and inverse transforms for: + +- equirectangular; +- Web Mercator with an explicit polar cutoff; +- Equal Earth; +- orthographic; +- azimuthal equidistant and equal-area; +- cube atlas; +- local tangent/gnomonic views. + +`PlanetaryMapTileBaker` maps each valid tile sample through the inverse +projection and canonical field query. `PlanetaryMapTileKey` includes field, +projection, layer, layout, and query-scale identity. Palettes, contours, labels, +and strategic overlays do not enter CultMath. + +## Numeric contract + +Integer hashes and published field versions are exact. Basic arithmetic follows +CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as +`atan` and `tan` use measured tolerances rather than fictional bit identity. +Every filtered terrain query carries an unresolved-height bound so consumers can +reject insufficient fidelity explicitly. + +Current D3D12 parity evidence covers the advanced erosion kernel and the QSC +forward/inverse topology. The remaining production page and patch shaders use +the common functions and retain their seam, residual, summary, and radial-hit +integration probes in Fensalir. diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl index e7281c99..cf76c8c2 100644 --- a/shaders/CultMath.hlsl +++ b/shaders/CultMath.hlsl @@ -264,6 +264,7 @@ float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, Sampl } #include "AdvancedErosionFilter.hlsl" +#include "Planetary.hlsl" struct CultMathSphericalErosionParameters { diff --git a/shaders/Planetary.hlsl b/shaders/Planetary.hlsl new file mode 100644 index 00000000..729c6332 --- /dev/null +++ b/shaders/Planetary.hlsl @@ -0,0 +1,260 @@ +#ifndef CULTMATH_PLANETARY_HLSL +#define CULTMATH_PLANETARY_HLSL + +static const float CULTMATH_PLANETARY_QUARTER_PI = 0.78539816339744830962; + +float3 cultmath_planetary_face_direction(int face, float2 coordinate) +{ + float2 tangent = tan(coordinate * CULTMATH_PLANETARY_QUARTER_PI); + float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) + : face == 1 ? float3(-1.0, tangent.y, tangent.x) + : face == 2 ? float3(tangent.x, 1.0, -tangent.y) + : face == 3 ? float3(tangent.x, -1.0, tangent.y) + : face == 4 ? float3(tangent.x, tangent.y, 1.0) + : float3(-tangent.x, tangent.y, -1.0); + return cultmath_normalize(cube); +} + +float2 cultmath_planetary_face_coordinate(float3 direction, out int face) +{ + float3 d = cultmath_normalize(direction); + float3 a = abs(d); + float2 cube_uv; + if (a.x >= a.y && a.x >= a.z) + { + if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } + else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } + } + else if (a.y >= a.z) + { + if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } + else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } + } + else + { + if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } + else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } + } + return atan(cube_uv) / CULTMATH_PLANETARY_QUARTER_PI; +} + +bool cultmath_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) +{ + int face; + float2 uv = cultmath_planetary_face_coordinate(direction, face); + local = 0.0; + if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; + float axis_tiles = exp2(tile_address.y); + float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; + float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); + if (any(abs(canonical - tile_address.zw) > 0.25)) return false; + local = scaled - tile_address.zw; + return all(local >= 0.0) && all(local <= 1.0); +} + +float3 cultmath_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) +{ + direction = cultmath_normalize(direction); + float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); + return cultmath_normalize(direction - tangent_gradient); +} + +float2 cultmath_planetary_equirectangular_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); +} + +float3 cultmath_planetary_equirectangular_inverse(float2 coordinate) +{ + float longitude = coordinate.x * CULTMATH_PI; + float latitude = coordinate.y * CULTMATH_HALF_PI; + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +static const float CULTMATH_EQUAL_EARTH_A1 = 1.340264; +static const float CULTMATH_EQUAL_EARTH_A2 = -0.081106; +static const float CULTMATH_EQUAL_EARTH_A3 = 0.000893; +static const float CULTMATH_EQUAL_EARTH_A4 = 0.003796; +static const float CULTMATH_EQUAL_EARTH_M = 0.8660254037844386; +static const float CULTMATH_EQUAL_EARTH_X_MAX = 2.706629; +static const float CULTMATH_EQUAL_EARTH_Y_MAX = 1.3173628; + +float cultmath_equal_earth_y(float theta) +{ + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + return theta * (CULTMATH_EQUAL_EARTH_A1 + CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (CULTMATH_EQUAL_EARTH_A3 + CULTMATH_EQUAL_EARTH_A4 * theta2)); +} + +float2 cultmath_planetary_equal_earth_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + float longitude = atan2(direction.y, direction.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float theta = asin(CULTMATH_EQUAL_EARTH_M * sin(latitude)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = CULTMATH_EQUAL_EARTH_M * (CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2)); + return float2(longitude * cos(theta) / denominator / CULTMATH_EQUAL_EARTH_X_MAX, cultmath_equal_earth_y(theta) / CULTMATH_EQUAL_EARTH_Y_MAX); +} + +float3 cultmath_planetary_equal_earth_inverse(float2 coordinate) +{ + float target_y = coordinate.y * CULTMATH_EQUAL_EARTH_Y_MAX; + float theta = target_y / CULTMATH_EQUAL_EARTH_A1; + [unroll] for (int iteration = 0; iteration < 8; iteration++) + { + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float derivative = CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2); + theta -= (cultmath_equal_earth_y(theta) - target_y) / derivative; + } + float latitude = asin(clamp(sin(theta) / CULTMATH_EQUAL_EARTH_M, -1.0, 1.0)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = CULTMATH_EQUAL_EARTH_M * (CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2)); + float longitude = coordinate.x * CULTMATH_EQUAL_EARTH_X_MAX * denominator / cos(theta); + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +struct CultMathPlanetaryFieldDefinition +{ + float radius; + int seed; + CultMathAdvancedErosionParameters erosion; +}; + +struct CultMathPlanetaryBaseFieldSample +{ + float radial_displacement; + float3 radial_gradient; + float field_value; + float3 field_gradient; + float fade_target; +}; + +struct CultMathPlanetarySurfaceSample +{ + float radial_displacement; + float3 tangent_gradient; + float3 surface_normal; + float slope; + float ridge; + float gully; + float finest_resolved_wavelength; + float unresolved_height_bound; +}; + +struct CultMathPlanetaryPageSample +{ + float4 height_gradient; + float2 masks; +}; + +CultMathPlanetaryPageSample cultmath_planetary_page_lerp( + CultMathPlanetaryPageSample a, + CultMathPlanetaryPageSample b, + CultMathPlanetaryPageSample c, + CultMathPlanetaryPageSample d, + float2 fraction) +{ + CultMathPlanetaryPageSample result; + result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); + result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); + return result; +} + +CultMathPlanetaryPageSample cultmath_planetary_residual_sample( + CultMathPlanetarySurfaceSample child, + CultMathPlanetarySurfaceSample parent, + bool has_parent) +{ + CultMathPlanetaryPageSample result; + result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); + if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); + result.masks = float2(child.ridge, child.gully); + return result; +} + +float4 cultmath_planetary_summary_empty() +{ + return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); +} + +float4 cultmath_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) +{ + summary.x = min(summary.x, height_gradient.x); + summary.y = max(summary.y, height_gradient.x); + summary.z = max(summary.z, length(height_gradient.yzw)); + summary.w = max(summary.w, max(unresolved_height, 0.0)); + return summary; +} + +float cultmath_planetary_pow4(float value) { float square = value * value; return square * square; } + +CultMathPlanetarySurfaceSample cultmath_planetary_field_sample( + CultMathPlanetaryFieldDefinition definition, + float3 unit_direction, + CultMathPlanetaryBaseFieldSample base_sample, + float footprint_meters) +{ + float3 direction = cultmath_normalize(unit_direction); + float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); + float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); + CultMathAdvancedErosionParameters p = definition.erosion; + CultMathErosionBandSelection band = cultmath_select_erosion_bands( + p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, + p.strength * p.scale, p.gain, 2.0); + float3 powers = float3( + cultmath_planetary_pow4(abs(direction.x)), + cultmath_planetary_pow4(abs(direction.y)), + cultmath_planetary_pow4(abs(direction.z))); + float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); + float3 weights = powers / power_sum; + float3 world = direction * definition.radius; + float seed = (float)definition.seed; + CultMathAdvancedErosionResult xy = cultmath_advanced_erosion_filter_banded( + world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), + float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); + CultMathAdvancedErosionResult yz = cultmath_advanced_erosion_filter_banded( + world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), + float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); + CultMathAdvancedErosionResult zx = cultmath_advanced_erosion_filter_banded( + world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), + float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); + float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; + float3 erosion_gradient = float3( + xy.delta.y * weights.z + zx.delta.z * weights.y, + xy.delta.z * weights.z + yz.delta.y * weights.x, + yz.delta.z * weights.x + zx.delta.y * weights.y); + float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); + float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); + float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); + power_gradient_x -= direction * dot(power_gradient_x, direction); + power_gradient_y -= direction * dot(power_gradient_y, direction); + power_gradient_z -= direction * dot(power_gradient_z, direction); + float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; + float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); + float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; + float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; + float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; + erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; + float3 gradient = radial_gradient + erosion_gradient; + gradient -= direction * dot(gradient, direction); + float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; + CultMathPlanetarySurfaceSample result; + result.radial_displacement = base_sample.radial_displacement + erosion_height; + result.tangent_gradient = gradient; + result.surface_normal = cultmath_planetary_surface_normal(direction, gradient); + result.slope = length(gradient); + result.ridge = saturate(ridge_evidence * 0.5 + 0.5); + result.gully = saturate(0.5 - ridge_evidence * 0.5); + result.finest_resolved_wavelength = band.finest_included_wavelength; + result.unresolved_height_bound = band.unresolved_height_bound; + return result; +} + +#endif diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index 01901b6b..61b57c98 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/CultMath/PlanetaryField.cs b/src/CultMath/PlanetaryField.cs new file mode 100644 index 00000000..13f36c0a --- /dev/null +++ b/src/CultMath/PlanetaryField.cs @@ -0,0 +1,213 @@ +namespace CultMath; + +public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight) +{ + public static PlanetaryQueryScale AtFootprint(float footprintMeters, float maximumUnresolvedHeight = float.PositiveInfinity) + => new PlanetaryQueryScale(footprintMeters, maximumUnresolvedHeight).Validate(); + + public PlanetaryQueryScale Validate() + { + if (!float.IsFinite(FootprintMeters) || FootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(FootprintMeters)); + if (float.IsNaN(MaximumUnresolvedHeight) || MaximumUnresolvedHeight < 0) throw new ArgumentOutOfRangeException(nameof(MaximumUnresolvedHeight)); + return this; + } +} + +public readonly record struct PlanetaryFieldDefinition( + ulong FieldVersion, + float Radius, + int Seed, + AdvancedErosionParameters Erosion) +{ + public static PlanetaryFieldDefinition Create( + ulong baseFieldVersion, + float radius, + int seed, + AdvancedErosionParameters erosion) + => new PlanetaryFieldDefinition(PlanetaryFieldIdentity.Compute(baseFieldVersion, radius, seed, erosion), radius, seed, erosion).Validate(); + + public PlanetaryFieldDefinition Validate() + { + if (FieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + if (!float.IsFinite(Radius) || Radius <= 0) throw new ArgumentOutOfRangeException(nameof(Radius)); + if (!float.IsFinite(Erosion.Scale) || Erosion.Scale <= 0) throw new ArgumentOutOfRangeException(nameof(Erosion)); + return this; + } +} + +public static class PlanetaryFieldIdentity +{ + public static ulong Compute(ulong baseFieldVersion, float radius, int seed, in AdvancedErosionParameters erosion) + { + if (baseFieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(baseFieldVersion)); + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + Add(baseFieldVersion); + Add((uint)BitConverter.SingleToInt32Bits(radius)); Add((uint)seed); + Add((uint)BitConverter.SingleToInt32Bits(erosion.Scale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Strength)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.GullyWeight)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Detail)); + AddFloat4(erosion.Rounding); AddFloat4(erosion.Onset); + Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.x)); Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.y)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.CellScale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Normalization)); + Add((uint)erosion.Octaves); Add((uint)BitConverter.SingleToInt32Bits(erosion.Lacunarity)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Gain)); + return hash == 0 ? 1 : hash; + + void Add(ulong value) => hash = unchecked((hash ^ value) * prime); + void AddFloat4(float4 value) + { + Add((uint)BitConverter.SingleToInt32Bits(value.x)); Add((uint)BitConverter.SingleToInt32Bits(value.y)); + Add((uint)BitConverter.SingleToInt32Bits(value.z)); Add((uint)BitConverter.SingleToInt32Bits(value.w)); + } + } +} + +/// +/// Application-supplied base evidence at one unit spherical direction. +/// FieldValue and FieldGradient drive erosion. RadialDisplacement and +/// RadialGradient describe the uneroded surface in metres. +/// +public readonly record struct PlanetaryBaseFieldSample( + float RadialDisplacement, + float3 RadialGradient, + float FieldValue, + float3 FieldGradient, + float FadeTarget); + +public readonly record struct PlanetarySurfaceSample( + ulong FieldVersion, + float3 UnitDirection, + float Radius, + float RadialDisplacement, + float3 TangentGradient, + float3 SurfaceNormal, + float Slope, + float Ridge, + float Gully, + float FinestResolvedWavelength, + float UnresolvedHeightBound) +{ + public float SurfaceRadius => Radius + RadialDisplacement; + public float3 PlanetLocalPosition => UnitDirection * SurfaceRadius; + public bool Meets(PlanetaryQueryScale scale) => UnresolvedHeightBound <= scale.MaximumUnresolvedHeight; +} + +public static class PlanetaryField +{ + public static PlanetarySurfaceSample Sample( + in PlanetaryFieldDefinition definition, + float3 unitDirection, + in PlanetaryBaseFieldSample baseSample, + in PlanetaryQueryScale scale) + { + definition.Validate(); + scale.Validate(); + PlanetaryTopology.ValidateDirection(unitDirection); + PlanetaryTopology.ValidateFinite(baseSample.RadialGradient, nameof(baseSample)); + PlanetaryTopology.ValidateFinite(baseSample.FieldGradient, nameof(baseSample)); + if (!float.IsFinite(baseSample.RadialDisplacement) || !float.IsFinite(baseSample.FieldValue) || !float.IsFinite(baseSample.FadeTarget)) + throw new ArgumentOutOfRangeException(nameof(baseSample)); + + var direction = math.normalize(unitDirection); + var radialGradient = Tangent(direction, baseSample.RadialGradient); + var fieldGradient = Tangent(direction, baseSample.FieldGradient); + var p = definition.Erosion; + var band = ErosionFrequencyBands.Select( + p.Scale * p.CellScale, + scale.FootprintMeters, + p.Octaves, + p.Lacunarity, + p.Strength * p.Scale, + p.Gain); + + var powers = new float3( + Pow4(MathF.Abs(direction.x)), + Pow4(MathF.Abs(direction.y)), + Pow4(MathF.Abs(direction.z))); + var powerSum = math.max(powers.x + powers.y + powers.z, 1.0e-6f); + var weights = powers / powerSum; + var world = direction * definition.Radius; + var seed = definition.Seed; + var xy = AdvancedErosionFilter.Sample( + world.xy + new float2(713 + seed * 0.754877666f, -291 + seed * 0.569840296f), + new float3(baseSample.FieldValue, fieldGradient.x, fieldGradient.y), baseSample.FadeTarget, p, band); + var yz = AdvancedErosionFilter.Sample( + world.yz + new float2(-431 + seed * 0.438289027f, 887 + seed * 0.328438163f), + new float3(baseSample.FieldValue, fieldGradient.y, fieldGradient.z), baseSample.FadeTarget, p, band); + var zx = AdvancedErosionFilter.Sample( + new float2(world.z, world.x) + new float2(197 + seed * 0.219783071f, 557 + seed * 0.145898034f), + new float3(baseSample.FieldValue, fieldGradient.z, fieldGradient.x), baseSample.FadeTarget, p, band); + + var erosionHeight = xy.Delta.x * weights.z + yz.Delta.x * weights.x + zx.Delta.x * weights.y; + var erosionGradient = new float3( + xy.Delta.y * weights.z + zx.Delta.z * weights.y, + xy.Delta.z * weights.z + yz.Delta.y * weights.x, + yz.Delta.z * weights.x + zx.Delta.y * weights.y); + var powerGradientX = Tangent(direction, new float3(4 * direction.x * direction.x * direction.x / definition.Radius, 0, 0)); + var powerGradientY = Tangent(direction, new float3(0, 4 * direction.y * direction.y * direction.y / definition.Radius, 0)); + var powerGradientZ = Tangent(direction, new float3(0, 0, 4 * direction.z * direction.z * direction.z / definition.Radius)); + var sumGradient = powerGradientX + powerGradientY + powerGradientZ; + var weightGradientX = (powerGradientX * powerSum - sumGradient * powers.x) / (powerSum * powerSum); + var weightGradientY = (powerGradientY * powerSum - sumGradient * powers.y) / (powerSum * powerSum); + var weightGradientZ = (powerGradientZ * powerSum - sumGradient * powers.z) / (powerSum * powerSum); + erosionGradient += yz.Delta.x * weightGradientX + zx.Delta.x * weightGradientY + xy.Delta.x * weightGradientZ; + var gradient = Tangent(direction, radialGradient + erosionGradient); + var ridgeEvidence = xy.RidgeMap * weights.z + yz.RidgeMap * weights.x + zx.RidgeMap * weights.y; + var ridge = math.saturate(ridgeEvidence * 0.5f + 0.5f); + var gully = math.saturate(0.5f - ridgeEvidence * 0.5f); + var displacement = baseSample.RadialDisplacement + erosionHeight; + + return new( + definition.FieldVersion, + direction, + definition.Radius, + displacement, + gradient, + PlanetaryTopology.SurfaceNormal(direction, gradient), + math.length(gradient), + ridge, + gully, + band.FinestIncludedWavelength, + band.UnresolvedHeightBound); + } + + public static void SampleBatch( + in PlanetaryFieldDefinition definition, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source, + Span destination) + where TSource : IPlanetaryBaseField + { + if (destination.Length < directions.Length) throw new ArgumentException("Destination is shorter than directions.", nameof(destination)); + for (var i = 0; i < directions.Length; i++) + { + var direction = math.normalize(directions[i]); + var baseSample = source.Sample(direction); + destination[i] = Sample(definition, direction, baseSample, scale); + } + } + + public static PlanetarySurfaceSample SamplePosition( + in PlanetaryFieldDefinition definition, + double3 planetLocalPosition, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (!double.IsFinite(planetLocalPosition.x) || !double.IsFinite(planetLocalPosition.y) || !double.IsFinite(planetLocalPosition.z)) + throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var length = Math.Sqrt(planetLocalPosition.x * planetLocalPosition.x + planetLocalPosition.y * planetLocalPosition.y + planetLocalPosition.z * planetLocalPosition.z); + if (length < 1.0e-12) throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var direction = new float3((float)(planetLocalPosition.x / length), (float)(planetLocalPosition.y / length), (float)(planetLocalPosition.z / length)); + return Sample(definition, direction, source.Sample(direction), scale); + } + + private static float3 Tangent(float3 direction, float3 gradient) => gradient - direction * math.dot(gradient, direction); + private static float Pow4(float value) { var square = value * value; return square * square; } +} + +public interface IPlanetaryBaseField +{ + PlanetaryBaseFieldSample Sample(float3 unitDirection); +} diff --git a/src/CultMath/PlanetaryPages.cs b/src/CultMath/PlanetaryPages.cs new file mode 100644 index 00000000..c9cff51f --- /dev/null +++ b/src/CultMath/PlanetaryPages.cs @@ -0,0 +1,185 @@ +namespace CultMath; + +public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryPageLayout Validate() + { + if (InteriorSize < 2) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + if (BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(BorderSize)); + return this; + } +} + +public readonly record struct PlanetaryPageSummary( + float MinimumHeight, + float MaximumHeight, + float MaximumSlope, + float UnresolvedHeightBound, + float AngularTexelSize); + +public readonly record struct PlanetaryPageSample(float Height, float3 TangentGradient, float Ridge, float Gully); + +public static class PlanetaryPageSampling +{ + public static float3 Direction(PlanetaryPageLayout page, int storageX, int storageY) + { + page.Validate(); + if ((uint)storageX >= (uint)page.StorageSize || (uint)storageY >= (uint)page.StorageSize) + throw new ArgumentOutOfRangeException(nameof(storageX)); + var localU = (storageX - page.BorderSize) / (double)(page.InteriorSize - 1); + var localV = (storageY - page.BorderSize) / (double)(page.InteriorSize - 1); + return DirectionAtLocal(page, localU, localV); + } + + public static float3 DirectionAtLocal(PlanetaryPageLayout page, double localU, double localV) + { + page.Validate(); + if (!double.IsFinite(localU) || !double.IsFinite(localV)) throw new ArgumentOutOfRangeException(nameof(localU)); + var count = page.Tile.AxisTileCount; + return PlanetaryTopology.Direction(new( + page.Tile.Face, + -1.0 + 2.0 * (page.Tile.X + localU) / count, + -1.0 + 2.0 * (page.Tile.Y + localV) / count)); + } + + public static float AngularTexelSize(PlanetaryPageLayout page) + { + var center = page.BorderSize + (page.InteriorSize - 1) / 2; + var a = Direction(page, center, center); + var b = Direction(page, Math.Min(center + 1, page.StorageSize - 1), center); + return MathF.Acos(math.clamp(math.dot(a, b), -1.0f, 1.0f)); + } + + public static float NominalAngularTexelSize(PlanetaryPageLayout page) + { + page.Validate(); + return MathF.PI * 0.5f / (page.Tile.AxisTileCount * (page.InteriorSize - 1)); + } + + public static PlanetaryPageSample Bilinear( + ReadOnlySpan samples, + PlanetaryPageLayout page, + float2 local) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var texel = local * (page.InteriorSize - 1) + page.BorderSize; + var x0 = Math.Clamp((int)MathF.Floor(texel.x), 0, page.StorageSize - 1); + var y0 = Math.Clamp((int)MathF.Floor(texel.y), 0, page.StorageSize - 1); + var x1 = Math.Min(x0 + 1, page.StorageSize - 1); + var y1 = Math.Min(y0 + 1, page.StorageSize - 1); + var tx = math.frac(texel.x); + var ty = math.frac(texel.y); + var a = Lerp(samples[y0 * page.StorageSize + x0], samples[y0 * page.StorageSize + x1], tx); + var b = Lerp(samples[y1 * page.StorageSize + x0], samples[y1 * page.StorageSize + x1], tx); + return Lerp(a, b, ty); + } + + public static PlanetaryPageSummary Summarize(ReadOnlySpan samples, PlanetaryPageLayout page, float unresolvedHeightBound) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var min = float.PositiveInfinity; + var max = float.NegativeInfinity; + var maxSlope = 0.0f; + foreach (var sample in samples) + { + min = MathF.Min(min, sample.Height); + max = MathF.Max(max, sample.Height); + maxSlope = MathF.Max(maxSlope, math.length(sample.TangentGradient)); + } + return new(min, max, maxSlope, MathF.Max(unresolvedHeightBound, 0), AngularTexelSize(page)); + } + + private static PlanetaryPageSample Lerp(PlanetaryPageSample a, PlanetaryPageSample b, float t) => new( + math.lerp(a.Height, b.Height, t), + math.lerp(a.TangentGradient, b.TangentGradient, t), + math.lerp(a.Ridge, b.Ridge, t), + math.lerp(a.Gully, b.Gully, t)); +} + +public readonly record struct PlanetaryBakedPage( + PlanetaryPageLayout Layout, + PlanetaryPageSample[] Samples, + PlanetaryPageSummary Summary); + +public static class PlanetaryPageBaker +{ + public static PlanetaryBakedPage Bake( + in PlanetaryFieldDefinition field, + PlanetaryPageLayout layout, + TSource source) + where TSource : IPlanetaryBaseField + { + layout.Validate(); field.Validate(); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * field.Radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * field.Radius; + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + var unresolved = 0.0f; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + var baseSample = source.Sample(direction); + var child = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(spacing)); + var height = child.RadialDisplacement; + var gradient = child.TangentGradient; + if (parentSpacing > 0) + { + var parent = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(parentSpacing)); + height -= parent.RadialDisplacement; + gradient -= parent.TangentGradient; + } + samples[y * layout.StorageSize + x] = new(height, gradient, child.Ridge, child.Gully); + unresolved = MathF.Max(unresolved, child.UnresolvedHeightBound); + } + return new(layout, samples, PlanetaryPageSampling.Summarize(samples, layout, unresolved)); + } +} + +public readonly record struct PlanetaryResidentPage(PlanetaryBakedPage Page, float Blend) +{ + public PlanetaryResidentPage Validate() + { + if (!float.IsFinite(Blend) || Blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(Blend)); + Page.Layout.Validate(); + if (Page.Samples.Length != Page.Layout.StorageSize * Page.Layout.StorageSize) throw new ArgumentException("Page payload does not match layout."); + return this; + } +} + +public static class PlanetaryPageSetSampling +{ + public static bool TrySample(ReadOnlySpan pages, float3 direction, out PlanetaryPageSample sample) + { + PlanetaryTopology.ValidateDirection(direction); + var height = 0.0f; + var gradient = float3.zero; + var ridge = 0.0f; + var gully = 0.0f; + var found = false; + var deepestLevel = -1; + foreach (var resident in pages) + { + resident.Validate(); + if (!PlanetaryTopology.TryLocalCoordinate(direction, resident.Page.Layout.Tile, out var local)) continue; + var contribution = PlanetaryPageSampling.Bilinear(resident.Page.Samples, resident.Page.Layout, local); + var blend = math.saturate(resident.Blend); + height += contribution.Height * blend; + gradient += contribution.TangentGradient * blend; + if (resident.Page.Layout.Tile.Level >= deepestLevel) + { + ridge = found ? math.lerp(ridge, contribution.Ridge, blend) : contribution.Ridge; + gully = found ? math.lerp(gully, contribution.Gully, blend) : contribution.Gully; + deepestLevel = resident.Page.Layout.Tile.Level; + } + found = true; + } + sample = new(height, gradient, ridge, gully); + return found; + } +} diff --git a/src/CultMath/PlanetaryPatch.cs b/src/CultMath/PlanetaryPatch.cs new file mode 100644 index 00000000..0fd3cbb6 --- /dev/null +++ b/src/CultMath/PlanetaryPatch.cs @@ -0,0 +1,42 @@ +namespace CultMath; + +public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection); + +public readonly record struct PlanetaryPatchMesh( + PlanetaryCubeFace Face, + int CellsPerAxis, + PlanetaryPatchVertex[] Vertices, + int[] Indices); + +public static class PlanetaryPatch +{ + public static PlanetaryPatchMesh CreateFace(PlanetaryCubeFace face, int cellsPerAxis) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (cellsPerAxis < 1 || cellsPerAxis > 4096) throw new ArgumentOutOfRangeException(nameof(cellsPerAxis)); + var axisVertices = cellsPerAxis + 1; + var vertices = new PlanetaryPatchVertex[checked(axisVertices * axisVertices)]; + for (var y = 0; y < axisVertices; y++) + for (var x = 0; x < axisVertices; x++) + { + var local = new float2(x / (float)cellsPerAxis, y / (float)cellsPerAxis); + vertices[y * axisVertices + x] = new(local, PlanetaryTopology.Direction(new(face, local.x * 2 - 1, local.y * 2 - 1))); + } + var indices = new int[checked(cellsPerAxis * cellsPerAxis * 6)]; + var write = 0; + for (var y = 0; y < cellsPerAxis; y++) + for (var x = 0; x < cellsPerAxis; x++) + { + var lowerLeft = y * axisVertices + x; + var lowerRight = lowerLeft + 1; + var upperLeft = lowerLeft + axisVertices; + var upperRight = upperLeft + 1; + indices[write++] = lowerLeft; indices[write++] = lowerRight; indices[write++] = upperRight; + indices[write++] = lowerLeft; indices[write++] = upperRight; indices[write++] = upperLeft; + } + return new(face, cellsPerAxis, vertices, indices); + } + + public static PlanetaryPatchMesh[] CreateCubeSphere(int cellsPerAxis) + => ((PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))).Select(face => CreateFace(face, cellsPerAxis)).ToArray(); +} diff --git a/src/CultMath/PlanetaryProjection.cs b/src/CultMath/PlanetaryProjection.cs new file mode 100644 index 00000000..f849fe20 --- /dev/null +++ b/src/CultMath/PlanetaryProjection.cs @@ -0,0 +1,320 @@ +namespace CultMath; + +public enum PlanetaryProjectionKind +{ + Equirectangular, + WebMercator, + EqualEarth, + Orthographic, + AzimuthalEquidistant, + AzimuthalEqualArea, + CubeAtlas, + LocalTangent, +} + +public readonly record struct PlanetaryProjectionParameters( + PlanetaryProjectionKind Kind, + double CenterLongitude = 0, + double CenterLatitude = 0, + double Scale = 1) +{ + public PlanetaryProjectionParameters Validate() + { + if (!double.IsFinite(CenterLongitude) || !double.IsFinite(CenterLatitude) || !double.IsFinite(Scale) || Scale <= 0) + throw new ArgumentOutOfRangeException(nameof(Scale)); + if (CenterLatitude is < -Math.PI / 2 or > Math.PI / 2) throw new ArgumentOutOfRangeException(nameof(CenterLatitude)); + return this; + } +} + +public static class PlanetaryProjection +{ + private const double EqualEarthA1 = 1.340264; + private const double EqualEarthA2 = -0.081106; + private const double EqualEarthA3 = 0.000893; + private const double EqualEarthA4 = 0.003796; + private static readonly double EqualEarthM = Math.Sqrt(3) / 2; + private static readonly double EqualEarthXMax = Math.PI / (EqualEarthM * EqualEarthA1); + private static readonly double EqualEarthYMax = EqualEarthY(Math.Asin(EqualEarthM)); + public static readonly double WebMercatorMaximumLatitude = Math.Atan(Math.Sinh(Math.PI)); + + public static bool TryForward(float3 direction, PlanetaryProjectionParameters parameters, out double2 coordinate) + { + parameters.Validate(); + PlanetaryTopology.ValidateDirection(direction); + var d = math.normalize(direction); + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + { + var face = PlanetaryTopology.FaceCoordinate(d); + var index = (int)face.Face; + var column = index % 3; + var row = index / 3; + coordinate = new( + (-1 + (column + (face.U + 1) * 0.5) * (2.0 / 3.0)) / parameters.Scale, + (1 - (row + (face.V + 1) * 0.5)) / parameters.Scale); + return true; + } + + var longitude = Math.Atan2(d.y, d.x); + var latitude = Math.Asin(Math.Clamp(d.z, -1, 1)); + var deltaLongitude = WrapLongitude(longitude - parameters.CenterLongitude); + var centered = CenteredForward(deltaLongitude, latitude, parameters, out coordinate); + coordinate /= parameters.Scale; + return centered; + } + + public static bool TryInverse(double2 coordinate, PlanetaryProjectionParameters parameters, out float3 direction) + { + parameters.Validate(); + if (!double.IsFinite(coordinate.x) || !double.IsFinite(coordinate.y)) + { + direction = float3.zero; + return false; + } + coordinate *= parameters.Scale; + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + return TryInverseCubeAtlas(coordinate, out direction); + + if (!CenteredInverse(coordinate, parameters, out var deltaLongitude, out var latitude)) + { + direction = float3.zero; + return false; + } + var longitude = WrapLongitude(deltaLongitude + parameters.CenterLongitude); + var cosLatitude = Math.Cos(latitude); + direction = math.normalize(new float3( + (float)(cosLatitude * Math.Cos(longitude)), + (float)(cosLatitude * Math.Sin(longitude)), + (float)Math.Sin(latitude))); + return true; + } + + private static bool CenteredForward(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + coordinate = new(longitude / Math.PI, latitude / (Math.PI * 0.5)); + return true; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(latitude) > WebMercatorMaximumLatitude) { coordinate = double2.zero; return false; } + coordinate = new(longitude / Math.PI, Math.Asinh(Math.Tan(latitude)) / Math.PI); + return true; + case PlanetaryProjectionKind.EqualEarth: + { + var theta = Math.Asin(EqualEarthM * Math.Sin(latitude)); + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2)); + coordinate = new(longitude * Math.Cos(theta) / denominator / EqualEarthXMax, EqualEarthY(theta) / EqualEarthYMax); + return true; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return ForwardAzimuthal(longitude, latitude, p, out coordinate); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool CenteredInverse(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + if (Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = coordinate.y * Math.PI * 0.5; return Math.Abs(coordinate.x) <= 1; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = Math.Atan(Math.Sinh(coordinate.y * Math.PI)); return true; + case PlanetaryProjectionKind.EqualEarth: + { + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + var targetY = coordinate.y * EqualEarthYMax; + var theta = targetY / EqualEarthA1; + for (var i = 0; i < 8; i++) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var f = EqualEarthY(theta) - targetY; + var derivative = EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2); + theta -= f / derivative; + } + var sinLatitude = Math.Sin(theta) / EqualEarthM; + if (Math.Abs(sinLatitude) > 1.0000000001) return false; + latitude = Math.Asin(Math.Clamp(sinLatitude, -1, 1)); + var thetaSquared = theta * theta; + var thetaSixth = thetaSquared * thetaSquared * thetaSquared; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * thetaSquared + thetaSixth * (7 * EqualEarthA3 + 9 * EqualEarthA4 * thetaSquared)); + longitude = coordinate.x * EqualEarthXMax * denominator / Math.Cos(theta); + return Math.Abs(longitude) <= Math.PI + 1.0e-10; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return InverseAzimuthal(coordinate, p, out longitude, out latitude); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool ForwardAzimuthal(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + var sin = Math.Sin(latitude); var cos = Math.Cos(latitude); var cosLon = Math.Cos(longitude); + var cosC = Math.Clamp(sin0 * sin + cos0 * cos * cosLon, -1, 1); + var rawX = cos * Math.Sin(longitude); + var rawY = cos0 * sin - sin0 * cos * cosLon; + double factor; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: + if (cosC < 0) { coordinate = double2.zero; return false; } + factor = 1; break; + case PlanetaryProjectionKind.AzimuthalEquidistant: + { + var c = Math.Acos(cosC); factor = c < 1.0e-12 ? 1 : c / Math.Sin(c); factor /= Math.PI; break; + } + case PlanetaryProjectionKind.AzimuthalEqualArea: + factor = Math.Sqrt(2 / Math.Max(1 + cosC, 1.0e-20)) * 0.5; break; + case PlanetaryProjectionKind.LocalTangent: + if (cosC <= 0) { coordinate = double2.zero; return false; } + factor = 1 / cosC; break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + coordinate = new(rawX * factor, rawY * factor); + return true; + } + + private static bool InverseAzimuthal(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + var rho = Math.Sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y); + double c; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: if (rho > 1) return false; c = Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.AzimuthalEquidistant: if (rho > 1) return false; c = rho * Math.PI; break; + case PlanetaryProjectionKind.AzimuthalEqualArea: if (rho > 1) return false; c = 2 * Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.LocalTangent: c = Math.Atan(rho); break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + if (rho < 1.0e-12) { latitude = p.CenterLatitude; return true; } + var sinC = Math.Sin(c); var cosC = Math.Cos(c); + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + latitude = Math.Asin(Math.Clamp(cosC * sin0 + coordinate.y * sinC * cos0 / rho, -1, 1)); + longitude = Math.Atan2(coordinate.x * sinC, rho * cos0 * cosC - coordinate.y * sin0 * sinC); + return true; + } + + private static bool TryInverseCubeAtlas(double2 coordinate, out float3 direction) + { + direction = float3.zero; + if (coordinate.x is < -1 or > 1 || coordinate.y is < -1 or > 1) return false; + var column = Math.Min((int)((coordinate.x + 1) * 1.5), 2); + var row = Math.Min((int)((1 - coordinate.y)), 1); + var u = ((coordinate.x + 1) * 1.5 - column) * 2 - 1; + var v = ((1 - coordinate.y) - row) * 2 - 1; + direction = PlanetaryTopology.Direction(new((PlanetaryCubeFace)(row * 3 + column), u, v)); + return true; + } + + private static double EqualEarthY(double theta) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + return theta * (EqualEarthA1 + EqualEarthA2 * theta2 + theta6 * (EqualEarthA3 + EqualEarthA4 * theta2)); + } + + private static double WrapLongitude(double longitude) + { + longitude %= Math.PI * 2; + if (longitude > Math.PI) longitude -= Math.PI * 2; + if (longitude < -Math.PI) longitude += Math.PI * 2; + return longitude; + } +} + +public readonly record struct PlanetaryMapTileLayout( + PlanetaryProjectionParameters Projection, + int Level, + int X, + int Y, + int InteriorSize, + int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryMapTileLayout Validate() + { + Projection.Validate(); + if (Level is < 0 or > 30) throw new ArgumentOutOfRangeException(nameof(Level)); + var count = 1 << Level; + if ((uint)X >= (uint)count || (uint)Y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(X)); + if (InteriorSize < 2 || BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + return this; + } +} + +public static class PlanetaryMapTileSampling +{ + public static bool TryDirection(PlanetaryMapTileLayout tile, int storageX, int storageY, out float3 direction) + { + tile.Validate(); + if ((uint)storageX >= (uint)tile.StorageSize || (uint)storageY >= (uint)tile.StorageSize) throw new ArgumentOutOfRangeException(nameof(storageX)); + var count = 1 << tile.Level; + var localU = (storageX - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var localV = (storageY - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var map = new double2(-1 + 2 * (tile.X + localU) / count, 1 - 2 * (tile.Y + localV) / count); + return PlanetaryProjection.TryInverse(map, tile.Projection, out direction); + } +} + +public readonly record struct PlanetaryMapTileKey( + ulong FieldVersion, + uint ProjectionVersion, + uint LayerId, + PlanetaryMapTileLayout Layout, + PlanetaryQueryScale QueryScale) +{ + public PlanetaryMapTileKey Validate() + { + if (FieldVersion == 0 || ProjectionVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + Layout.Validate(); QueryScale.Validate(); + return this; + } +} + +public readonly record struct PlanetarySurfaceMapTile( + PlanetaryMapTileKey Key, + PlanetarySurfaceSample[] Samples, + bool[] Validity); + +public static class PlanetaryMapTileBaker +{ + public static PlanetarySurfaceMapTile Bake( + in PlanetaryFieldDefinition field, + PlanetaryMapTileKey key, + TSource source) + where TSource : IPlanetaryBaseField + { + field.Validate(); key.Validate(); + if (field.FieldVersion != key.FieldVersion) throw new ArgumentException("Tile field version does not match definition.", nameof(key)); + var count = key.Layout.StorageSize * key.Layout.StorageSize; + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var y = 0; y < key.Layout.StorageSize; y++) + for (var x = 0; x < key.Layout.StorageSize; x++) + { + var index = y * key.Layout.StorageSize + x; + if (!PlanetaryMapTileSampling.TryDirection(key.Layout, x, y, out var direction)) continue; + samples[index] = PlanetaryField.Sample(field, direction, source.Sample(direction), key.QueryScale); + validity[index] = true; + } + return new(key, samples, validity); + } +} diff --git a/src/CultMath/PlanetaryQueries.cs b/src/CultMath/PlanetaryQueries.cs new file mode 100644 index 00000000..1f11ca77 --- /dev/null +++ b/src/CultMath/PlanetaryQueries.cs @@ -0,0 +1,191 @@ +namespace CultMath; + +public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement) +{ + public PlanetaryRadialBounds Validate() + { + if (!float.IsFinite(MinimumDisplacement) || !float.IsFinite(MaximumDisplacement) || MinimumDisplacement > MaximumDisplacement) + throw new ArgumentOutOfRangeException(nameof(MinimumDisplacement)); + return this; + } +} + +public readonly record struct PlanetaryRayHit( + double Distance, + double3 PlanetLocalPosition, + PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryPathSample(double Distance, PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryRegionSummary( + float MinimumDisplacement, + float MaximumDisplacement, + float MaximumSlope, + float MaximumUnresolvedHeight); + +public static class PlanetaryQueries +{ + public static bool TryIntersectRay( + in PlanetaryFieldDefinition field, + double3 rayOrigin, + double3 rayDirection, + double maximumDistance, + in PlanetaryQueryScale scale, + in PlanetaryRadialBounds bounds, + TSource source, + out PlanetaryRayHit hit) + where TSource : IPlanetaryBaseField + { + field.Validate(); scale.Validate(); bounds.Validate(); + ValidateFinite(rayOrigin, nameof(rayOrigin)); ValidateFinite(rayDirection, nameof(rayDirection)); + if (!double.IsFinite(maximumDistance) || maximumDistance <= 0) throw new ArgumentOutOfRangeException(nameof(maximumDistance)); + var directionLength = Length(rayDirection); + if (directionLength < 1.0e-15) throw new ArgumentOutOfRangeException(nameof(rayDirection)); + var direction = rayDirection / directionLength; + var outerRadius = field.Radius + bounds.MaximumDisplacement + scale.MaximumUnresolvedHeight; + if (!double.IsFinite(outerRadius)) outerRadius = field.Radius + bounds.MaximumDisplacement; + if (!TrySphereInterval(rayOrigin, direction, outerRadius, out var enter, out var leave)) { hit = default; return false; } + var start = Math.Max(enter, 0); + var end = Math.Min(leave, maximumDistance); + if (start > end) { hit = default; return false; } + + var previousDistance = start; + var previous = SignedSurfaceDistance(field, rayOrigin + direction * previousDistance, scale, source, out var previousSample); + if (previous <= 0) + { + hit = new(previousDistance, rayOrigin + direction * previousDistance, previousSample); + return true; + } + var minimumStep = Math.Max(scale.FootprintMeters * 0.5, field.Radius * 1.0e-7); + for (var iteration = 0; iteration < 512 && previousDistance < end; iteration++) + { + var nextDistance = Math.Min(previousDistance + Math.Max(previous * 0.5, minimumStep), end); + var next = SignedSurfaceDistance(field, rayOrigin + direction * nextDistance, scale, source, out var nextSample); + if (next <= 0) + { + var low = previousDistance; + var high = nextDistance; + var surface = nextSample; + for (var refine = 0; refine < 32; refine++) + { + var middle = (low + high) * 0.5; + var value = SignedSurfaceDistance(field, rayOrigin + direction * middle, scale, source, out var middleSample); + if (value > 0) low = middle; + else { high = middle; surface = middleSample; } + } + var position = rayOrigin + direction * high; + hit = new(high, position, surface); + return true; + } + if (nextDistance >= end) break; + previousDistance = nextDistance; + previous = next; + previousSample = nextSample; + } + hit = default; + return false; + } + + public static double SegmentClearance( + in PlanetaryFieldDefinition field, + double3 start, + double3 end, + double sampleSpacing, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + ValidateFinite(start, nameof(start)); ValidateFinite(end, nameof(end)); + if (!double.IsFinite(sampleSpacing) || sampleSpacing <= 0) throw new ArgumentOutOfRangeException(nameof(sampleSpacing)); + var delta = end - start; + var distance = Length(delta); + var steps = Math.Max((int)Math.Ceiling(distance / sampleSpacing), 1); + var minimum = double.PositiveInfinity; + for (var i = 0; i <= steps; i++) + { + var position = start + delta * (i / (double)steps); + minimum = Math.Min(minimum, SignedSurfaceDistance(field, position, scale, source, out _)); + } + return minimum; + } + + public static PlanetaryPathSample[] SampleGreatCircle( + in PlanetaryFieldDefinition field, + float3 startDirection, + float3 endDirection, + int segmentCount, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (segmentCount < 1) throw new ArgumentOutOfRangeException(nameof(segmentCount)); + PlanetaryTopology.ValidateDirection(startDirection); PlanetaryTopology.ValidateDirection(endDirection); + var a = math.normalize(startDirection); var b = math.normalize(endDirection); + var angle = MathF.Acos(math.clamp(math.dot(a, b), -1, 1)); + var sinAngle = MathF.Sin(angle); + var result = new PlanetaryPathSample[segmentCount + 1]; + for (var i = 0; i <= segmentCount; i++) + { + var t = i / (float)segmentCount; + var direction = sinAngle < 1.0e-6f + ? math.normalize(math.lerp(a, b, t)) + : math.normalize(a * (MathF.Sin((1 - t) * angle) / sinAngle) + b * (MathF.Sin(t * angle) / sinAngle)); + var sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + result[i] = new(angle * field.Radius * t, sample); + } + return result; + } + + public static PlanetaryRegionSummary Summarize( + in PlanetaryFieldDefinition field, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (directions.IsEmpty) throw new ArgumentException("At least one direction is required.", nameof(directions)); + var min = float.PositiveInfinity; var max = float.NegativeInfinity; var slope = 0.0f; var unresolved = 0.0f; + foreach (var direction in directions) + { + var normalized = math.normalize(direction); + var sample = PlanetaryField.Sample(field, normalized, source.Sample(normalized), scale); + min = MathF.Min(min, sample.RadialDisplacement); + max = MathF.Max(max, sample.RadialDisplacement); + slope = MathF.Max(slope, sample.Slope); + unresolved = MathF.Max(unresolved, sample.UnresolvedHeightBound); + } + return new(min, max, slope, unresolved); + } + + private static double SignedSurfaceDistance( + in PlanetaryFieldDefinition field, + double3 position, + in PlanetaryQueryScale scale, + TSource source, + out PlanetarySurfaceSample sample) + where TSource : IPlanetaryBaseField + { + var radius = Length(position); + if (radius < 1.0e-15) { sample = default; return double.NegativeInfinity; } + var direction = new float3((float)(position.x / radius), (float)(position.y / radius), (float)(position.z / radius)); + sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + return radius - sample.SurfaceRadius; + } + + private static bool TrySphereInterval(double3 origin, double3 direction, double radius, out double enter, out double leave) + { + var b = Dot(origin, direction); + var c = Dot(origin, origin) - radius * radius; + var discriminant = b * b - c; + if (discriminant < 0) { enter = leave = 0; return false; } + var root = Math.Sqrt(discriminant); + enter = -b - root; leave = -b + root; return leave >= 0; + } + + private static double Dot(double3 a, double3 b) => a.x * b.x + a.y * b.y + a.z * b.z; + private static double Length(double3 value) => Math.Sqrt(Dot(value, value)); + private static void ValidateFinite(double3 value, string name) + { + if (!double.IsFinite(value.x) || !double.IsFinite(value.y) || !double.IsFinite(value.z)) throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/CultMath/PlanetaryResidency.cs b/src/CultMath/PlanetaryResidency.cs new file mode 100644 index 00000000..e5dca115 --- /dev/null +++ b/src/CultMath/PlanetaryResidency.cs @@ -0,0 +1,144 @@ +namespace CultMath; + +public readonly record struct PlanetaryLodParameters( + int MaximumLevel, + int PageInteriorSize, + int PageBorderSize, + float ViewportHeightPixels, + float MinimumFootprintMeters) +{ + public PlanetaryLodParameters Validate() + { + if (MaximumLevel is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(MaximumLevel)); + _ = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), PageInteriorSize, PageBorderSize).Validate(); + if (!float.IsFinite(ViewportHeightPixels) || ViewportHeightPixels <= 0) throw new ArgumentOutOfRangeException(nameof(ViewportHeightPixels)); + if (!float.IsFinite(MinimumFootprintMeters) || MinimumFootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(MinimumFootprintMeters)); + return this; + } +} + +public static class PlanetaryLodSelector +{ + public static int SelectLevel( + in PlanetaryFieldDefinition field, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + field.Validate(); parameters.Validate(); + if (!float.IsFinite(cameraDistanceFromCenter) || cameraDistanceFromCenter < 0) throw new ArgumentOutOfRangeException(nameof(cameraDistanceFromCenter)); + var altitude = MathF.Max(cameraDistanceFromCenter - field.Radius, 0.001f); + var footprint = MathF.Max(altitude / parameters.ViewportHeightPixels * 2, parameters.MinimumFootprintMeters); + var erosion = field.Erosion; + for (var level = 0; level <= parameters.MaximumLevel; level++) + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, level, 0, 0), parameters.PageInteriorSize, parameters.PageBorderSize); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(page) * field.Radius; + var unresolved = ErosionFrequencyBands.Select( + erosion.Scale * erosion.CellScale, spacing, erosion.Octaves, + erosion.Lacunarity, erosion.Strength * erosion.Scale, erosion.Gain).UnresolvedHeightBound; + if (unresolved <= footprint) return level; + } + return parameters.MaximumLevel; + } + + public static PlanetaryTileAddress[] SelectAncestorChain( + in PlanetaryFieldDefinition field, + float3 cameraDirection, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + PlanetaryTopology.ValidateDirection(cameraDirection); + var level = SelectLevel(field, cameraDistanceFromCenter, parameters); + var result = new PlanetaryTileAddress[6 + level]; + for (var face = 0; face < 6; face++) result[face] = new((PlanetaryCubeFace)face, 0, 0, 0); + var leaf = PlanetaryTopology.TileAt(cameraDirection, level); + for (var currentLevel = level; currentLevel >= 1; currentLevel--) + { + result[6 + currentLevel - 1] = leaf; + leaf = leaf.Parent(); + } + return result; + } +} + +public readonly record struct PlanetaryResidentTile(PlanetaryTileAddress Tile, float Blend, bool Departing); + +public readonly record struct PlanetaryResidencySnapshot( + ulong ContentVersion, + ulong PresentationVersion, + PlanetaryResidentTile[] Tiles); + +/// Owns presentation residency only. It never owns field content. +public sealed class PlanetaryResidualResidency +{ + private readonly Dictionary residents = []; + private readonly Dictionary arrivals = []; + private readonly Dictionary departures = []; + private float lastTime = float.NegativeInfinity; + + public void Reset() + { + residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity; + } + + public PlanetaryResidencySnapshot Update(ReadOnlySpan desired, float timeSeconds, float transitionSeconds) + { + if (!float.IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + if (!float.IsFinite(transitionSeconds) || transitionSeconds < 0) throw new ArgumentOutOfRangeException(nameof(transitionSeconds)); + if (timeSeconds < lastTime) Reset(); + lastTime = timeSeconds; + + var desiredKeys = new HashSet(); + foreach (var tile in desired) + { + var key = tile.StableKey; + desiredKeys.Add(key); + residents[key] = tile; + if (!arrivals.ContainsKey(key)) arrivals[key] = timeSeconds; + departures.Remove(key); + } + + foreach (var pair in residents.ToArray()) + { + if (pair.Value.Level == 0 || desiredKeys.Contains(pair.Key)) continue; + if (!departures.ContainsKey(pair.Key)) + { + var startingBlend = transitionSeconds == 0 + ? 1 + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + departures[pair.Key] = (timeSeconds, startingBlend); + } + if (transitionSeconds > 0 && timeSeconds - departures[pair.Key].Time < transitionSeconds) continue; + residents.Remove(pair.Key); arrivals.Remove(pair.Key); departures.Remove(pair.Key); + } + + var ordered = residents + .OrderBy(pair => pair.Value.Level) + .ThenBy(pair => pair.Value.Face) + .ThenBy(pair => pair.Value.X) + .ThenBy(pair => pair.Value.Y) + .ToArray(); + var tiles = new PlanetaryResidentTile[ordered.Length]; + for (var i = 0; i < ordered.Length; i++) + { + var pair = ordered[i]; + var departing = departures.TryGetValue(pair.Key, out var departure); + var blend = pair.Value.Level == 0 || transitionSeconds == 0 + ? 1 + : departing + ? departure.StartingBlend * Math.Clamp(1 - (timeSeconds - departure.Time) / transitionSeconds, 0, 1) + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + tiles[i] = new(pair.Value, blend, departing); + } + return new(Hash(ordered.Select(pair => pair.Key)), Hash(tiles.Select(tile => tile.Tile.StableKey ^ (uint)BitConverter.SingleToInt32Bits(tile.Blend))), tiles); + } + + private static ulong Hash(IEnumerable values) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + foreach (var value in values) hash = unchecked((hash ^ value) * prime); + return hash == 0 ? 1 : hash; + } +} diff --git a/src/CultMath/PlanetaryTopology.cs b/src/CultMath/PlanetaryTopology.cs new file mode 100644 index 00000000..c8535079 --- /dev/null +++ b/src/CultMath/PlanetaryTopology.cs @@ -0,0 +1,170 @@ +namespace CultMath; + +public enum PlanetaryCubeFace +{ + PositiveX, + NegativeX, + PositiveY, + NegativeY, + PositiveZ, + NegativeZ, +} + +public readonly record struct PlanetaryFaceCoordinate(PlanetaryCubeFace Face, double U, double V) +{ + public PlanetaryFaceCoordinate Validate(bool allowOutsideFace = false) + { + if (!double.IsFinite(U) || !double.IsFinite(V)) throw new ArgumentOutOfRangeException(nameof(U)); + if (!allowOutsideFace && (U is < -1.0 or > 1.0 || V is < -1.0 or > 1.0)) + throw new ArgumentOutOfRangeException(nameof(U), "Face coordinates must be in [-1, 1]."); + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), Face)) throw new ArgumentOutOfRangeException(nameof(Face)); + return this; + } +} + +public readonly record struct PlanetaryTileAddress +{ + public const int MaxLevel = 30; + + public PlanetaryTileAddress(PlanetaryCubeFace face, int level, int x, int y) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (level is < 0 or > MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var count = 1 << level; + if ((uint)x >= (uint)count) throw new ArgumentOutOfRangeException(nameof(x)); + if ((uint)y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(y)); + Face = face; + Level = level; + X = x; + Y = y; + } + + public PlanetaryCubeFace Face { get; } + public int Level { get; } + public int X { get; } + public int Y { get; } + public int AxisTileCount => 1 << Level; + + public PlanetaryTileAddress Parent() => Level == 0 + ? throw new InvalidOperationException("Root cube tile has no parent.") + : new(Face, Level - 1, X >> 1, Y >> 1); + + public PlanetaryTileAddress Child(int childX, int childY) + { + if ((uint)childX > 1) throw new ArgumentOutOfRangeException(nameof(childX)); + if ((uint)childY > 1) throw new ArgumentOutOfRangeException(nameof(childY)); + if (Level == MaxLevel) throw new InvalidOperationException($"Level {MaxLevel} cannot be subdivided."); + return new(Face, Level + 1, (X << 1) | childX, (Y << 1) | childY); + } + + public PlanetaryFaceCoordinate PositionAt(double localU, double localV) + { + if (!double.IsFinite(localU) || localU is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localU)); + if (!double.IsFinite(localV) || localV is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localV)); + var count = AxisTileCount; + return new(Face, -1.0 + 2.0 * (X + localU) / count, -1.0 + 2.0 * (Y + localV) / count); + } + + public ulong StableKey + { + get + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + hash = unchecked((hash ^ (uint)Face) * prime); + hash = unchecked((hash ^ (uint)Level) * prime); + hash = unchecked((hash ^ (uint)X) * prime); + hash = unchecked((hash ^ (uint)Y) * prime); + return hash == 0 ? 1 : hash; + } + } + public override string ToString() => $"{Face}/L{Level:D2}/{X}/{Y}"; +} + +public static class PlanetaryTopology +{ + private const float QuarterPi = MathF.PI * 0.25f; + + public static float3 Direction(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = MathF.Tan((float)coordinate.U * QuarterPi); + var v = MathF.Tan((float)coordinate.V * QuarterPi); + var cube = coordinate.Face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(coordinate)), + }; + return math.normalize(cube); + } + + public static PlanetaryFaceCoordinate FaceCoordinate(float3 direction) + { + ValidateDirection(direction); + var d = math.normalize(direction); + var a = math.abs(d); + if (a.x >= a.y && a.x >= a.z) + return d.x >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveX, -d.z / a.x, d.y / a.x) + : FromCubeUv(PlanetaryCubeFace.NegativeX, d.z / a.x, d.y / a.x); + if (a.y >= a.z) + return d.y >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveY, d.x / a.y, -d.z / a.y) + : FromCubeUv(PlanetaryCubeFace.NegativeY, d.x / a.y, d.z / a.y); + return d.z >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveZ, d.x / a.z, d.y / a.z) + : FromCubeUv(PlanetaryCubeFace.NegativeZ, -d.x / a.z, d.y / a.z); + } + + public static PlanetaryTileAddress TileAt(float3 direction, int level) + { + if (level is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var face = FaceCoordinate(direction); + var count = 1 << level; + var x = Math.Clamp((int)((face.U * 0.5 + 0.5) * count), 0, count - 1); + var y = Math.Clamp((int)((face.V * 0.5 + 0.5) * count), 0, count - 1); + return new(face.Face, level, x, y); + } + + public static bool TryLocalCoordinate(float3 direction, PlanetaryTileAddress tile, out float2 local) + { + var face = FaceCoordinate(direction); + if (face.Face != tile.Face) { local = float2.zero; return false; } + var scaledU = (face.U * 0.5 + 0.5) * tile.AxisTileCount; + var scaledV = (face.V * 0.5 + 0.5) * tile.AxisTileCount; + local = new((float)(scaledU - tile.X), (float)(scaledV - tile.Y)); + return local.x is >= 0 and <= 1 && local.y is >= 0 and <= 1; + } + + public static float3 SurfaceNormal(float3 unitDirection, float3 worldDistanceTangentGradient) + { + ValidateDirection(unitDirection); + ValidateFinite(worldDistanceTangentGradient, nameof(worldDistanceTangentGradient)); + var direction = math.normalize(unitDirection); + var tangent = worldDistanceTangentGradient - direction * math.dot(worldDistanceTangentGradient, direction); + return math.normalize(direction - tangent); + } + + private static PlanetaryFaceCoordinate FromCubeUv(PlanetaryCubeFace face, float cubeU, float cubeV) => new( + face, + MathF.Atan(cubeU) / QuarterPi, + MathF.Atan(cubeV) / QuarterPi); + + internal static void ValidateDirection(float3 direction) + { + ValidateFinite(direction, nameof(direction)); + if (math.length(direction) < 1.0e-12f) throw new ArgumentOutOfRangeException(nameof(direction)); + } + + internal static void ValidateFinite(float3 value, string name) + { + if (!float.IsFinite(value.x) || !float.IsFinite(value.y) || !float.IsFinite(value.z)) + throw new ArgumentOutOfRangeException(name); + } +} diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs index 0e904a32..567b038b 100644 --- a/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -65,6 +65,22 @@ public sealed class HlslMirrorTests Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); } + [Fact] + public void HlslMirrorPublishesPlanetaryTopologyAndField() + { + var root = GetRepositoryRoot(); + var include = File.ReadAllText(Path.Combine(root, "shaders", "CultMath.hlsl")); + var planetary = File.ReadAllText(Path.Combine(root, "shaders", "Planetary.hlsl")); + Assert.Contains("Planetary.hlsl", include); + Assert.Contains("cultmath_planetary_face_direction", planetary); + Assert.Contains("cultmath_planetary_face_coordinate", planetary); + Assert.Contains("cultmath_planetary_page_local", planetary); + Assert.Contains("cultmath_planetary_field_sample", planetary); + Assert.Contains("cultmath_planetary_equirectangular_forward", planetary); + Assert.Contains("cultmath_planetary_equal_earth_forward", planetary); + Assert.Contains("CultMathPlanetarySurfaceSample", planetary); + } + private static string GetRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/CultMath.Tests/PlanetaryFieldTests.cs b/tests/CultMath.Tests/PlanetaryFieldTests.cs new file mode 100644 index 00000000..4dc75325 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryFieldTests.cs @@ -0,0 +1,80 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryFieldTests +{ + private static readonly PlanetaryFieldDefinition Definition = new( + 0x7f59_6d11_02ab_9c31UL, + 8.4f, + 42, + AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + + [Fact] + public void SampleIsDeterministicScaleAwareAndTangent() + { + var direction = math.normalize(new float3(0.31f, -0.72f, 0.61f)); + var source = new FixtureField(); + var coarse = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.1f)); + var fine = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + var repeated = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + + Assert.Equal(fine, repeated); + Assert.Equal(Definition.FieldVersion, fine.FieldVersion); + Assert.True(fine.UnresolvedHeightBound <= coarse.UnresolvedHeightBound); + Assert.True(fine.FinestResolvedWavelength <= coarse.FinestResolvedWavelength); + Assert.InRange(MathF.Abs(math.dot(fine.UnitDirection, fine.TangentGradient)), 0, 2.0e-5f); + Assert.Equal(1, math.length(fine.SurfaceNormal), 5); + } + + [Fact] + public void BatchAndDoublePositionUseThePointContract() + { + var directions = new[] + { + math.normalize(new float3(1, 2, 3)), + math.normalize(new float3(-2, 0.5f, 1)), + math.normalize(new float3(0.1f, -0.2f, -1)), + }; + var output = new PlanetarySurfaceSample[directions.Length]; + var source = new FixtureField(); + var scale = PlanetaryQueryScale.AtFootprint(0.05f); + PlanetaryField.SampleBatch(Definition, directions, scale, source, output); + + for (var i = 0; i < directions.Length; i++) + { + var normalized = math.normalize(directions[i]); + var expected = PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), scale); + Assert.InRange(MathF.Abs(expected.RadialDisplacement - output[i].RadialDisplacement), 0, 1.0e-6f); + Assert.InRange(math.distance(expected.TangentGradient, output[i].TangentGradient), 0, 1.0e-6f); + } + + var d = directions[0]; + var position = new double3(d.x * 8.0e11, d.y * 8.0e11, d.z * 8.0e11); + var positioned = PlanetaryField.SamplePosition(Definition, position, scale, source); + Assert.InRange(math.distance(positioned.UnitDirection, d), 0, 2.0e-6f); + } + + [Fact] + public void FieldIdentityChangesWithEveryLoadBearingInput() + { + var erosion = AdvancedErosionParameters.Default; + var baseline = PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion); + Assert.Equal(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(4, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.5f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 8, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion with { Gain = 0.49f }).FieldVersion); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var fieldGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + fieldGradient -= direction * math.dot(fieldGradient, direction); + return new(value * 0.1f, fieldGradient * 0.1f, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/tests/CultMath.Tests/PlanetaryPageTests.cs b/tests/CultMath.Tests/PlanetaryPageTests.cs new file mode 100644 index 00000000..c4e4045b --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryPageTests.cs @@ -0,0 +1,57 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryPageTests +{ + [Fact] + public void BilinearSamplingAndSummaryUseOnePageContract() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 3, 1); + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + samples[y * layout.StorageSize + x] = new(x + y * 10, new float3(x, y, 0), x / 10.0f, y / 10.0f); + + var sample = PlanetaryPageSampling.Bilinear(samples, layout, new float2(0.25f, 0.75f)); + Assert.Equal(26.5f, sample.Height, 4); + Assert.Equal(new float3(1.5f, 2.5f, 0), sample.TangentGradient); + var summary = PlanetaryPageSampling.Summarize(samples, layout, 0.125f); + Assert.Equal(0, summary.MinimumHeight); + Assert.Equal(44, summary.MaximumHeight); + Assert.Equal(0.125f, summary.UnresolvedHeightBound); + Assert.True(summary.MaximumSlope > 0); + } + + [Fact] + public void ChildPageStoresOnlyResidualAndComposesBackToDirectField() + { + var field = new PlanetaryFieldDefinition(7, 8.4f, 3, AdvancedErosionParameters.Default with + { + Scale = 0.63f, + Strength = 0.12f, + Octaves = 7, + }); + var source = new FixtureField(); + var root = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 17, 2), source); + var child = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2), source); + var direction = PlanetaryPageSampling.DirectionAtLocal(child.Layout, 0.5, 0.5); + var pages = new[] { new PlanetaryResidentPage(root, 1), new PlanetaryResidentPage(child, 1) }; + Assert.True(PlanetaryPageSetSampling.TrySample(pages, direction, out var composed)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(child.Layout) * field.Radius; + var direct = PlanetaryField.Sample(field, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(spacing)); + Assert.InRange(MathF.Abs(composed.Height - direct.RadialDisplacement), 0, 2.0e-5f); + Assert.InRange(math.distance(composed.TangentGradient, direct.TangentGradient), 0, 3.0e-5f); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.4f - direction.y * 0.2f + direction.z * 0.5f; + var gradient = new float3(0.4f, -0.2f, 0.5f); + gradient -= direction * math.dot(gradient, direction); + return new(value * 0.08f, gradient * 0.08f, value, gradient, value); + } + } +} diff --git a/tests/CultMath.Tests/PlanetaryPatchTests.cs b/tests/CultMath.Tests/PlanetaryPatchTests.cs new file mode 100644 index 00000000..6564c773 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryPatchTests.cs @@ -0,0 +1,40 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryPatchTests +{ + [Fact] + public void CubeSpherePatchMeshUsesCanonicalTopology() + { + var meshes = PlanetaryPatch.CreateCubeSphere(8); + Assert.Equal(6, meshes.Length); + foreach (var mesh in meshes) + { + Assert.Equal(81, mesh.Vertices.Length); + Assert.Equal(8 * 8 * 6, mesh.Indices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(1, math.length(vertex.UnitDirection), 5)); + foreach (var index in mesh.Indices) Assert.InRange(index, 0, mesh.Vertices.Length - 1); + } + } + + [Fact] + public void NeighborFaceCornersAreSingleValued() + { + var directions = PlanetaryPatch.CreateCubeSphere(1) + .SelectMany(mesh => mesh.Vertices.Select(vertex => vertex.UnitDirection)) + .ToArray(); + var groups = directions.GroupBy(direction => new + { + X = Math.Sign(direction.x), + Y = Math.Sign(direction.y), + Z = Math.Sign(direction.z), + }); + Assert.Equal(8, groups.Count()); + Assert.All(groups, group => + { + var first = group.First(); + Assert.All(group, direction => Assert.InRange(math.distance(first, direction), 0, 1.0e-6f)); + }); + } +} diff --git a/tests/CultMath.Tests/PlanetaryProjectionTests.cs b/tests/CultMath.Tests/PlanetaryProjectionTests.cs new file mode 100644 index 00000000..ad811d6e --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryProjectionTests.cs @@ -0,0 +1,73 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryProjectionTests +{ + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular)] + [InlineData(PlanetaryProjectionKind.WebMercator)] + [InlineData(PlanetaryProjectionKind.EqualEarth)] + [InlineData(PlanetaryProjectionKind.Orthographic)] + [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] + [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] + [InlineData(PlanetaryProjectionKind.LocalTangent)] + public void ProjectionRoundTripsVisibleDirections(PlanetaryProjectionKind kind) + { + var parameters = new PlanetaryProjectionParameters(kind, 0.31, -0.22); + foreach (var longitude in new[] { -0.4, -0.1, 0.2, 0.65 }) + foreach (var latitude in new[] { -0.6, -0.15, 0.3, 0.7 }) + { + var direction = Direction(longitude + parameters.CenterLongitude, latitude); + if (!PlanetaryProjection.TryForward(direction, parameters, out var map)) continue; + Assert.True(PlanetaryProjection.TryInverse(map, parameters, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 3.0e-5f); + } + } + + [Fact] + public void CubeAtlasRoundTripsEveryFaceInterior() + { + var projection = new PlanetaryProjectionParameters(PlanetaryProjectionKind.CubeAtlas); + foreach (var face in Enum.GetValues()) + { + var direction = PlanetaryTopology.Direction(new(face, 0.23, -0.41)); + Assert.True(PlanetaryProjection.TryForward(direction, projection, out var map)); + Assert.True(PlanetaryProjection.TryInverse(map, projection, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 2.0e-6f); + } + } + + [Fact] + public void MercatorRejectsPolarSingularity() + { + var northPole = new float3(0, 0, 1); + Assert.False(PlanetaryProjection.TryForward(northPole, new(PlanetaryProjectionKind.WebMercator), out _)); + } + + [Fact] + public void MapTileBakerCarriesFieldIdentityAndProjectionValidity() + { + var field = new PlanetaryFieldDefinition(99, 10, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout( + new(PlanetaryProjectionKind.Orthographic), 0, 0, 0, 9, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 7, layout, PlanetaryQueryScale.AtFootprint(0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + Assert.Equal(key, tile.Key); + Assert.Equal(layout.StorageSize * layout.StorageSize, tile.Samples.Length); + Assert.Contains(tile.Validity, valid => valid); + Assert.Contains(tile.Validity, valid => !valid); + Assert.All(tile.Samples.Where((_, index) => tile.Validity[index]), sample => Assert.Equal(field.FieldVersion, sample.FieldVersion)); + } + + private static float3 Direction(double longitude, double latitude) + { + var cos = Math.Cos(latitude); + return new((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 unitDirection) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/CultMath.Tests/PlanetaryQueryTests.cs b/tests/CultMath.Tests/PlanetaryQueryTests.cs new file mode 100644 index 00000000..48d865f4 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryQueryTests.cs @@ -0,0 +1,46 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryQueryTests +{ + private static readonly PlanetaryFieldDefinition Field = new(12, 10, 0, AdvancedErosionParameters.Default with { Strength = 0, Octaves = 0 }); + private static readonly PlanetaryQueryScale Scale = PlanetaryQueryScale.AtFootprint(0.1f, 0); + + [Fact] + public void RayIntersectionAndClearanceUseTheCpuSurfaceContract() + { + var source = new BulgedSphere(); + Assert.True(PlanetaryQueries.TryIntersectRay( + Field, new double3(0, 0, 20), new double3(0, 0, -1), 30, + Scale, new PlanetaryRadialBounds(-1, 1), source, out var hit)); + Assert.InRange(hit.Distance, 8.99999, 9.00001); + Assert.InRange(hit.PlanetLocalPosition.z, 10.99999, 11.00001); + var clearance = PlanetaryQueries.SegmentClearance( + Field, new double3(-2, 0, 12), new double3(2, 0, 12), 0.1, Scale, source); + Assert.True(clearance > 0.8); + } + + [Fact] + public void GreatCircleAndRegionQueriesReturnVersionedSamples() + { + var source = new BulgedSphere(); + var path = PlanetaryQueries.SampleGreatCircle(Field, new float3(1, 0, 0), new float3(0, 1, 0), 8, Scale, source); + Assert.Equal(9, path.Length); + Assert.Equal(MathF.PI * 5, path[^1].Distance, 4); + Assert.All(path, point => Assert.Equal(Field.FieldVersion, point.Surface.FieldVersion)); + var summary = PlanetaryQueries.Summarize(Field, path.Select(point => point.Surface.UnitDirection).ToArray(), Scale, source); + Assert.True(summary.MaximumDisplacement >= summary.MinimumDisplacement); + Assert.True(summary.MaximumSlope >= 0); + } + + private readonly struct BulgedSphere : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var displacement = direction.z; + var gradient = new float3(0, 0, 1) - direction * direction.z; + return new(displacement, gradient, 0, float3.zero, 0); + } + } +} diff --git a/tests/CultMath.Tests/PlanetaryResidencyTests.cs b/tests/CultMath.Tests/PlanetaryResidencyTests.cs new file mode 100644 index 00000000..d2b89b7a --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryResidencyTests.cs @@ -0,0 +1,57 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryResidencyTests +{ + private static readonly PlanetaryFieldDefinition Field = new( + 1, 8.4f, 0, AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + private static readonly PlanetaryLodParameters Lod = new(5, 17, 2, 720, 8.4f / 65536); + + [Fact] + public void SelectorReturnsSixRootsAndOneAncestorChain() + { + var direction = math.normalize(new float3(-0.2f, 0.91f, 0.37f)); + var selected = PlanetaryLodSelector.SelectAncestorChain(Field, direction, 8.45f, Lod); + Assert.Equal(6, selected.Count(tile => tile.Level == 0)); + var descendants = selected.Where(tile => tile.Level > 0).OrderBy(tile => tile.Level).ToArray(); + for (var i = 1; i < descendants.Length; i++) Assert.Equal(descendants[i - 1], descendants[i].Parent()); + if (descendants.Length > 0) Assert.Equal(PlanetaryTopology.TileAt(direction, descendants[^1].Level), descendants[^1]); + } + + [Fact] + public void ResidencyFadesResidualsWithoutChangingContentDuringPresentation() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(1, 0); + var residency = new PlanetaryResidualResidency(); + var arrival = residency.Update(new[] { root, child }, 0, 1); + var midpoint = residency.Update(new[] { root, child }, 0.5f, 1); + var settled = residency.Update(new[] { root, child }, 1, 1); + Assert.Equal(arrival.ContentVersion, midpoint.ContentVersion); + Assert.Equal(midpoint.ContentVersion, settled.ContentVersion); + Assert.Equal(0, arrival.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.5f, midpoint.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(1, settled.Tiles.Single(tile => tile.Tile == child).Blend); + + var leaving = residency.Update(new[] { root }, 1.25f, 1); + Assert.True(leaving.Tiles.Single(tile => tile.Tile == child).Departing); + Assert.Equal(1, leaving.Tiles.Single(tile => tile.Tile == child).Blend); + var gone = residency.Update(new[] { root }, 2.25f, 1); + Assert.DoesNotContain(gone.Tiles, tile => tile.Tile == child); + } + + [Fact] + public void ReversingAnArrivalDepartsFromItsCurrentWeightWithoutJumping() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(0, 1); + var residency = new PlanetaryResidualResidency(); + _ = residency.Update(new[] { root, child }, 0, 1); + var arriving = residency.Update(new[] { root, child }, 0.25f, 1); + var departing = residency.Update(new[] { root }, 0.25f, 1); + Assert.Equal(0.25f, arriving.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.25f, departing.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.True(departing.Tiles.Single(tile => tile.Tile == child).Departing); + } +} diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs b/tests/CultMath.Tests/PlanetaryTopologyTests.cs new file mode 100644 index 00000000..6e6b0838 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryTopologyTests.cs @@ -0,0 +1,55 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryTopologyTests +{ + [Fact] + public void FaceDirectionRoundTripsAcrossEveryFace() + { + foreach (var face in Enum.GetValues()) + foreach (var u in new[] { -0.9, -0.25, 0.0, 0.4, 0.9 }) + foreach (var v in new[] { -0.85, -0.1, 0.3, 0.88 }) + { + var direction = PlanetaryTopology.Direction(new(face, u, v)); + var roundTrip = PlanetaryTopology.FaceCoordinate(direction); + Assert.Equal(face, roundTrip.Face); + Assert.Equal(u, roundTrip.U, 5); + Assert.Equal(v, roundTrip.V, 5); + } + } + + [Fact] + public void SiblingPagesShareBitStableBoundaryDirections() + { + var left = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 17, 2); + var right = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2); + for (var y = 0; y < left.InteriorSize; y++) + { + var a = PlanetaryPageSampling.Direction(left, left.BorderSize + left.InteriorSize - 1, left.BorderSize + y); + var b = PlanetaryPageSampling.Direction(right, right.BorderSize, right.BorderSize + y); + Assert.Equal(a, b); + } + } + + [Fact] + public void DirectionSelectsContainingTileAndLocalCoordinate() + { + var expected = new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 5, 19, 7); + var direction = PlanetaryPageSampling.DirectionAtLocal(new(expected, 17, 2), 0.37, 0.61); + Assert.Equal(expected, PlanetaryTopology.TileAt(direction, expected.Level)); + Assert.True(PlanetaryTopology.TryLocalCoordinate(direction, expected, out var local)); + Assert.Equal(0.37f, local.x, 4); + Assert.Equal(0.61f, local.y, 4); + } + + [Fact] + public void BorderSamplesDoNotRequireNeighborResidency() + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), 17, 3); + var edge = PlanetaryPageSampling.Direction(page, page.BorderSize + page.InteriorSize - 1, page.BorderSize + 8); + var border = PlanetaryPageSampling.Direction(page, page.StorageSize - 1, page.BorderSize + 8); + Assert.NotEqual(edge, border); + Assert.Equal(1, math.length(border), 5); + } +} diff --git a/unity/Runtime/CultMath.Unity.asmdef b/unity/Runtime/CultMath.Unity.asmdef new file mode 100644 index 00000000..632c4e4c --- /dev/null +++ b/unity/Runtime/CultMath.Unity.asmdef @@ -0,0 +1,16 @@ +{ + "name": "CultMath.Unity", + "rootNamespace": "CultMath.Unity", + "references": [ + "CultMath" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/unity/Runtime/PlanetaryPageUpload.cs b/unity/Runtime/PlanetaryPageUpload.cs new file mode 100644 index 00000000..f0b168cd --- /dev/null +++ b/unity/Runtime/PlanetaryPageUpload.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using UnityEngine; + +namespace CultMath.Unity; + +[StructLayout(LayoutKind.Sequential)] +public readonly struct PlanetaryPageInputGpu +{ + public PlanetaryPageInputGpu(Vector4 directionRadius, Vector4 sampling) + { + DirectionRadius = directionRadius; + Sampling = sampling; + } + + public readonly Vector4 DirectionRadius; + public readonly Vector4 Sampling; +} + +[StructLayout(LayoutKind.Sequential)] +public readonly struct PlanetaryPageMetadataGpu +{ + public PlanetaryPageMetadataGpu(Vector4 address, Vector4 layout, Vector4 bounds, Vector4 state) + { + Address = address; + Layout = layout; + Bounds = bounds; + State = state; + } + + public readonly Vector4 Address; + public readonly Vector4 Layout; + public readonly Vector4 Bounds; + public readonly Vector4 State; +} + +public readonly record struct PlanetaryPageUpload( + PlanetaryPageInputGpu[] Inputs, + PlanetaryPageMetadataGpu[] Metadata, + int OutputSampleCount, + ulong ContentVersion, + ulong PresentationVersion); + +public static class PlanetaryPageUploadBuilder +{ + public static PlanetaryPageUpload Build( + in PlanetaryResidencySnapshot snapshot, + int interiorSize, + int borderSize, + float radius) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var inputs = new List(); + var metadata = new PlanetaryPageMetadataGpu[snapshot.Tiles.Length]; + foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index))) + { + var layout = new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate(); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; + var parentSpacing = resident.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(resident.Tile.Parent(), interiorSize, borderSize)) * radius; + var outputOffset = inputs.Count; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + inputs.Add(new( + new Vector4(direction.x, direction.y, direction.z, radius), + new Vector4(spacing, parentSpacing, 0, 0))); + } + metadata[pageIndex] = new( + new Vector4((int)resident.Tile.Face, resident.Tile.Level, resident.Tile.X, resident.Tile.Y), + new Vector4(outputOffset, layout.StorageSize, layout.InteriorSize, layout.BorderSize), + Vector4.zero, + new Vector4(1, resident.Blend, PlanetaryPageSampling.NominalAngularTexelSize(layout), spacing)); + } + return new(inputs.ToArray(), metadata, inputs.Count, snapshot.ContentVersion, snapshot.PresentationVersion); + } +} diff --git a/unity/Runtime/PlanetaryPatchMeshAdapter.cs b/unity/Runtime/PlanetaryPatchMeshAdapter.cs new file mode 100644 index 00000000..69a72a2a --- /dev/null +++ b/unity/Runtime/PlanetaryPatchMeshAdapter.cs @@ -0,0 +1,36 @@ +using System; +using UnityEngine; +using UnityEngine.Rendering; + +namespace CultMath.Unity; + +public static class PlanetaryPatchMeshAdapter +{ + public static Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var source = PlanetaryPatch.CreateFace(face, cellsPerAxis); + var vertices = new Vector3[source.Vertices.Length]; + var normals = new Vector3[source.Vertices.Length]; + var uv = new Vector2[source.Vertices.Length]; + for (var i = 0; i < source.Vertices.Length; i++) + { + var direction = source.Vertices[i].UnitDirection; + var normal = new Vector3(direction.x, direction.y, direction.z); + vertices[i] = normal * radius; + normals[i] = normal; + uv[i] = new Vector2(source.Vertices[i].LocalCoordinate.x, source.Vertices[i].LocalCoordinate.y); + } + var mesh = new Mesh + { + name = $"CultMath Planetary {face} {cellsPerAxis}x{cellsPerAxis}", + indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, + vertices = vertices, + normals = normals, + uv = uv, + triangles = source.Indices, + }; + mesh.RecalculateBounds(); + return mesh; + } +} From 102403efac25efea5986c1f114c11437ae1a6b03 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 02:52:13 +0100 Subject: [PATCH 16/23] Complete portable planetary presentation contracts --- .gitignore | 1 + CultMath.slnx | 3 + README.md | 14 ++ .../CultMath.PlanetaryViewer.Editor.asmdef | 18 +++ .../Editor/CultMathPlanetaryViewerMenu.cs | 26 ++++ Samples~/PlanetaryViewer/README.md | 16 ++ .../Runtime/CultMath.PlanetaryViewer.asmdef | 17 +++ .../Runtime/CultMathPlanetaryViewer.cs | 139 ++++++++++++++++++ .../Runtime/CultMathPlanetaryViewer.shader | 90 ++++++++++++ THIRD-PARTY-NOTICES.md.meta | 7 + .../CultMath.PlanetaryTileFixture.csproj | 11 ++ .../CultMath.PlanetaryTileFixture/Program.cs | 22 +++ docs/planetary.md | 29 +++- docs/planetary.md.meta | 7 + package.json | 9 +- scripts.meta | 8 + scripts/test-web-planetary-tile.ps1 | 9 ++ scripts/test-web-planetary-tile.ps1.meta | 7 + shaders/AdvancedErosionFilter.hlsl.meta | 7 + shaders/Planetary.hlsl | 132 +++++++++++++++++ shaders/Planetary.hlsl.meta | 7 + src/CultMath/AdvancedErosionFilter.cs.meta | 2 + src/CultMath/ErosionFrequencyBands.cs.meta | 2 + src/CultMath/PlanetaryField.cs | 2 + src/CultMath/PlanetaryField.cs.meta | 2 + src/CultMath/PlanetaryGpuPages.cs | 55 +++++++ src/CultMath/PlanetaryGpuPages.cs.meta | 2 + src/CultMath/PlanetaryMapTileEncoding.cs | 117 +++++++++++++++ src/CultMath/PlanetaryMapTileEncoding.cs.meta | 2 + src/CultMath/PlanetaryPages.cs | 2 + src/CultMath/PlanetaryPages.cs.meta | 2 + src/CultMath/PlanetaryPatch.cs | 3 + src/CultMath/PlanetaryPatch.cs.meta | 2 + src/CultMath/PlanetaryProjection.cs | 2 + src/CultMath/PlanetaryProjection.cs.meta | 2 + src/CultMath/PlanetaryQueries.cs | 2 + src/CultMath/PlanetaryQueries.cs.meta | 2 + .../PlanetaryRadialRefinement.cs.meta | 2 + src/CultMath/PlanetaryResidency.cs | 10 +- src/CultMath/PlanetaryResidency.cs.meta | 2 + src/CultMath/PlanetaryTopology.cs | 21 +++ src/CultMath/PlanetaryTopology.cs.meta | 2 + src/CultMath/SphericalErosion.cs.meta | 2 + .../AdvancedErosionFilterTests.cs.meta | 2 + .../ErosionFrequencyBandsTests.cs.meta | 2 + tests/CultMath.Tests/HlslMirrorTests.cs | 2 + .../PlanetaryFieldTests.cs.meta | 2 + tests/CultMath.Tests/PlanetaryGpuPageTests.cs | 25 ++++ .../PlanetaryGpuPageTests.cs.meta | 2 + .../PlanetaryMapTileEncodingTests.cs | 52 +++++++ .../PlanetaryMapTileEncodingTests.cs.meta | 2 + .../CultMath.Tests/PlanetaryPageTests.cs.meta | 2 + .../PlanetaryPatchTests.cs.meta | 2 + .../PlanetaryProjectionTests.cs.meta | 2 + .../PlanetaryQueryTests.cs.meta | 2 + .../PlanetaryRadialRefinementTests.cs.meta | 2 + .../PlanetaryResidencyTests.cs.meta | 2 + .../CultMath.Tests/PlanetaryTopologyTests.cs | 8 + .../PlanetaryTopologyTests.cs.meta | 2 + .../SphericalErosionTests.cs.meta | 2 + tools/CultMath.PlanetaryTileFixture.meta | 8 + unity.meta | 8 + unity/Runtime.meta | 8 + unity/Runtime/CultMath.Unity.asmdef.meta | 7 + unity/Runtime/PlanetaryPageUpload.cs | 52 ++++--- unity/Runtime/PlanetaryPageUpload.cs.meta | 2 + .../Runtime/PlanetaryPatchMeshAdapter.cs.meta | 2 + web.meta | 8 + web/planetary-tile.cross-runtime.test.mjs | 21 +++ ...planetary-tile.cross-runtime.test.mjs.meta | 7 + web/planetary-tile.test.mjs | 39 +++++ web/planetary-tile.test.mjs.meta | 7 + web/planetary-tile.ts | 89 +++++++++++ web/planetary-tile.ts.meta | 7 + 74 files changed, 1166 insertions(+), 31 deletions(-) create mode 100644 Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef create mode 100644 Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs create mode 100644 Samples~/PlanetaryViewer/README.md create mode 100644 Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef create mode 100644 Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs create mode 100644 Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader create mode 100644 THIRD-PARTY-NOTICES.md.meta create mode 100644 Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj create mode 100644 Tools~/CultMath.PlanetaryTileFixture/Program.cs create mode 100644 docs/planetary.md.meta create mode 100644 scripts.meta create mode 100644 scripts/test-web-planetary-tile.ps1 create mode 100644 scripts/test-web-planetary-tile.ps1.meta create mode 100644 shaders/AdvancedErosionFilter.hlsl.meta create mode 100644 shaders/Planetary.hlsl.meta create mode 100644 src/CultMath/AdvancedErosionFilter.cs.meta create mode 100644 src/CultMath/ErosionFrequencyBands.cs.meta create mode 100644 src/CultMath/PlanetaryField.cs.meta create mode 100644 src/CultMath/PlanetaryGpuPages.cs create mode 100644 src/CultMath/PlanetaryGpuPages.cs.meta create mode 100644 src/CultMath/PlanetaryMapTileEncoding.cs create mode 100644 src/CultMath/PlanetaryMapTileEncoding.cs.meta create mode 100644 src/CultMath/PlanetaryPages.cs.meta create mode 100644 src/CultMath/PlanetaryPatch.cs.meta create mode 100644 src/CultMath/PlanetaryProjection.cs.meta create mode 100644 src/CultMath/PlanetaryQueries.cs.meta create mode 100644 src/CultMath/PlanetaryRadialRefinement.cs.meta create mode 100644 src/CultMath/PlanetaryResidency.cs.meta create mode 100644 src/CultMath/PlanetaryTopology.cs.meta create mode 100644 src/CultMath/SphericalErosion.cs.meta create mode 100644 tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta create mode 100644 tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryFieldTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryGpuPageTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs create mode 100644 tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryPageTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryPatchTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryQueryTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta create mode 100644 tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta create mode 100644 tests/CultMath.Tests/SphericalErosionTests.cs.meta create mode 100644 tools/CultMath.PlanetaryTileFixture.meta create mode 100644 unity.meta create mode 100644 unity/Runtime.meta create mode 100644 unity/Runtime/CultMath.Unity.asmdef.meta create mode 100644 unity/Runtime/PlanetaryPageUpload.cs.meta create mode 100644 unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta create mode 100644 web.meta create mode 100644 web/planetary-tile.cross-runtime.test.mjs create mode 100644 web/planetary-tile.cross-runtime.test.mjs.meta create mode 100644 web/planetary-tile.test.mjs create mode 100644 web/planetary-tile.test.mjs.meta create mode 100644 web/planetary-tile.ts create mode 100644 web/planetary-tile.ts.meta diff --git a/.gitignore b/.gitignore index 5ae20c8e..3779002b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ obj/ **/obj/ target/ **/target/ +/native/cultmath-core/target.meta .tools/ diff --git a/CultMath.slnx b/CultMath.slnx index 95834573..05d17886 100644 --- a/CultMath.slnx +++ b/CultMath.slnx @@ -5,4 +5,7 @@ + + + diff --git a/README.md b/README.md index 29eb115f..c60a9912 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,20 @@ cargo test --manifest-path native/cultmath-core/Cargo.toml cargo build --release --manifest-path native/cultmath-core/Cargo.toml ``` +## Unity compiler + +CultMath uses C# 10 record structs and file-scoped namespaces. Unity projects +must select the compiler's latest language mode with an `Assets/csc.rsp` file: + +```text +-langversion:latest +``` + +The package's Unity smoke project verifies this contract against Unity 6. The +setting is already present in current GameCult Unity clients; it belongs to the +consumer project because Unity only reads compiler response files from its +`Assets` root. + ## Shader Tooling CultMath keeps portable DXC outside git under `.tools/`: diff --git a/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef b/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef new file mode 100644 index 00000000..10512326 --- /dev/null +++ b/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "CultMath.PlanetaryViewer.Editor", + "rootNamespace": "CultMath.Unity.Samples.Editor", + "references": [ + "CultMath.PlanetaryViewer" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs b/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs new file mode 100644 index 00000000..ade28c30 --- /dev/null +++ b/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs @@ -0,0 +1,26 @@ +using UnityEditor; +using UnityEngine; + +namespace CultMath.Unity.Samples.Editor; + +public static class CultMathPlanetaryViewerMenu +{ + [MenuItem("GameObject/CultMath/Planetary Field Viewer", false, 10)] + public static void CreateViewer() + { + var root = new GameObject("CultMath Planetary Field Viewer"); + Undo.RegisterCreatedObjectUndo(root, "Create CultMath Planetary Viewer"); + root.AddComponent(); + Selection.activeGameObject = root; + + if (Camera.main != null) return; + var cameraObject = new GameObject("Planetary Viewer Camera"); + Undo.RegisterCreatedObjectUndo(cameraObject, "Create Planetary Viewer Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0, 0, -24); + cameraObject.transform.LookAt(root.transform); + var camera = cameraObject.AddComponent(); + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 1000; + } +} diff --git a/Samples~/PlanetaryViewer/README.md b/Samples~/PlanetaryViewer/README.md new file mode 100644 index 00000000..ec4dcc3a --- /dev/null +++ b/Samples~/PlanetaryViewer/README.md @@ -0,0 +1,16 @@ +# Planetary Field Viewer + +Import this sample through Unity's Package Manager, then choose +`GameObject > CultMath > Planetary Field Viewer`. + +The host project must contain `Assets/csc.rsp` with +`-langversion:latest`, as documented in the CultMath package README. + +The generated six-face mesh uses `PlanetaryPatch`. Its shader calls +`cultmath_planetary_field_sample` from the packaged CultMath HLSL, while the +component exposes the matching CPU `Sample` method. Seed, radius, physical +sample footprint, erosion settings, and base-field equations are shared between +the two bodies. + +This is a renderer integration fixture, not a gameplay owner. It deliberately +contains no Aetheria state, biomes, resources, or simulation decisions. diff --git a/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef b/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef new file mode 100644 index 00000000..2d3676f4 --- /dev/null +++ b/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef @@ -0,0 +1,17 @@ +{ + "name": "CultMath.PlanetaryViewer", + "rootNamespace": "CultMath.Unity.Samples", + "references": [ + "CultMath", + "CultMath.Unity" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs new file mode 100644 index 00000000..06113728 --- /dev/null +++ b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace CultMath.Unity.Samples; + +[ExecuteAlways] +public sealed class CultMathPlanetaryViewer : MonoBehaviour +{ + [Min(0.001f)] public float Radius = 8.4f; + [Range(1, 256)] public int CellsPerFace = 64; + public int Seed = 42; + [Min(0.0001f)] public float SampleFootprint = 0.01f; + public Shader? PlanetShader; + public Vector3 LightDirection = new(-0.4f, 0.7f, -0.6f); + public Color LowColor = new(0.055f, 0.16f, 0.09f, 1); + public Color HighColor = new(0.62f, 0.48f, 0.28f, 1); + public Color RidgeColor = new(0.88f, 0.83f, 0.7f, 1); + + private readonly List meshes = new(); + private Material? material; + private int builtCells; + + public PlanetaryFieldDefinition Definition => PlanetaryFieldDefinition.Create( + 1, + Radius, + Seed, + ViewerErosion(Radius)); + + public PlanetarySurfaceSample Sample(float3 direction) + { + var source = new ViewerBaseField(Radius); + var normalized = math.normalize(direction); + return PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), PlanetaryQueryScale.AtFootprint(SampleFootprint)); + } + + private void OnEnable() => Rebuild(); + + private void OnValidate() + { + Radius = Mathf.Max(Radius, 0.001f); + CellsPerFace = Mathf.Clamp(CellsPerFace, 1, 256); + SampleFootprint = Mathf.Max(SampleFootprint, 0.0001f); + if (isActiveAndEnabled) Rebuild(); + } + + private void Update() + { + if (builtCells != CellsPerFace || material == null) Rebuild(); + ApplyMaterial(); + } + + private void Rebuild() + { + ClearGenerated(); + PlanetShader ??= Shader.Find("CultMath/Planetary Field Viewer"); + if (PlanetShader == null) return; + material = new Material(PlanetShader) { name = "CultMath Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; + foreach (var face in (PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))) + { + var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh(face, CellsPerFace); + mesh.hideFlags = HideFlags.HideAndDontSave; + meshes.Add(mesh); + var child = new GameObject(face.ToString()) { hideFlags = HideFlags.DontSave }; + child.transform.SetParent(transform, false); + child.AddComponent().sharedMesh = mesh; + child.AddComponent().sharedMaterial = material; + } + builtCells = CellsPerFace; + ApplyMaterial(); + } + + private void ApplyMaterial() + { + if (material == null) return; + var p = ViewerErosion(Radius); + material.SetFloat("_Radius", Radius); + material.SetFloat("_SampleFootprint", SampleFootprint); + material.SetInt("_Seed", Seed); + material.SetFloat("_ErosionScale", p.Scale); + material.SetFloat("_ErosionStrength", p.Strength); + material.SetFloat("_GullyWeight", p.GullyWeight); + material.SetFloat("_Detail", p.Detail); + material.SetVector("_Rounding", new(p.Rounding.x, p.Rounding.y, p.Rounding.z, p.Rounding.w)); + material.SetVector("_Onset", new(p.Onset.x, p.Onset.y, p.Onset.z, p.Onset.w)); + material.SetVector("_AssumedSlope", new(p.AssumedSlope.x, p.AssumedSlope.y, 0, 0)); + material.SetFloat("_CellScale", p.CellScale); + material.SetFloat("_Normalization", p.Normalization); + material.SetInt("_Octaves", p.Octaves); + material.SetFloat("_Lacunarity", p.Lacunarity); + material.SetFloat("_Gain", p.Gain); + material.SetVector("_LightDirection", LightDirection.normalized); + material.SetColor("_LowColor", LowColor); + material.SetColor("_HighColor", HighColor); + material.SetColor("_RidgeColor", RidgeColor); + } + + private void OnDisable() => ClearGenerated(); + private void OnDestroy() => ClearGenerated(); + + private void ClearGenerated() + { + for (var i = transform.childCount - 1; i >= 0; i--) DestroyGenerated(transform.GetChild(i).gameObject); + foreach (var mesh in meshes) DestroyGenerated(mesh); + meshes.Clear(); + if (material != null) DestroyGenerated(material); + material = null; + builtCells = 0; + } + + private static void DestroyGenerated(UnityEngine.Object value) + { + if (Application.isPlaying) Destroy(value); + else DestroyImmediate(value); + } + + private static AdvancedErosionParameters ViewerErosion(float radius) => new( + radius * 0.075f, 0.12f, 0.58f, 1.45f, + new float4(0.1f, 0.015f, 0.1f, 2), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2, 0.5f); + + private readonly struct ViewerBaseField : IPlanetaryBaseField + { + private readonly float radius; + public ViewerBaseField(float radius) => this.radius = radius; + + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var angularGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + angularGradient -= direction * math.dot(angularGradient, direction); + var fieldGradient = angularGradient / radius; + var radialDisplacement = value * radius * 0.035f; + var radialGradient = angularGradient * 0.035f; + return new(radialDisplacement, radialGradient, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader new file mode 100644 index 00000000..b5f7274f --- /dev/null +++ b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader @@ -0,0 +1,90 @@ +Shader "CultMath/Planetary Field Viewer" +{ + Properties + { + _LowColor ("Low Color", Color) = (0.055, 0.16, 0.09, 1) + _HighColor ("High Color", Color) = (0.62, 0.48, 0.28, 1) + _RidgeColor ("Ridge Color", Color) = (0.88, 0.83, 0.7, 1) + } + SubShader + { + Tags { "RenderType"="Opaque" "Queue"="Geometry" } + Pass + { + Cull Back + ZWrite On + HLSLPROGRAM + #pragma target 5.0 + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + #include "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" + + float _Radius; + float _SampleFootprint; + int _Seed; + float _ErosionScale, _ErosionStrength, _GullyWeight, _Detail; + float4 _Rounding, _Onset, _AssumedSlope; + float _CellScale, _Normalization, _Lacunarity, _Gain; + int _Octaves; + float3 _LightDirection; + float4 _LowColor, _HighColor, _RidgeColor; + + struct Attributes { float4 vertex : POSITION; }; + struct Varyings + { + float4 position : SV_POSITION; + float3 worldNormal : TEXCOORD0; + float height : TEXCOORD1; + float ridge : TEXCOORD2; + }; + + CultMathPlanetarySurfaceSample viewer_sample(float3 direction) + { + direction = normalize(direction); + float value = direction.x * 0.6 + direction.y * direction.z * 0.3; + float3 angular_gradient = float3(0.6, direction.z * 0.3, direction.y * 0.3); + angular_gradient -= direction * dot(angular_gradient, direction); + CultMathPlanetaryBaseFieldSample base_sample; + base_sample.radial_displacement = value * _Radius * 0.035; + base_sample.radial_gradient = angular_gradient * 0.035; + base_sample.field_value = value; + base_sample.field_gradient = angular_gradient / _Radius; + base_sample.fade_target = clamp(value, -1.0, 1.0); + CultMathAdvancedErosionParameters erosion; + erosion.scale = _ErosionScale; erosion.strength = _ErosionStrength; + erosion.gully_weight = _GullyWeight; erosion.detail = _Detail; + erosion.rounding = _Rounding; erosion.onset = _Onset; + erosion.assumed_slope = _AssumedSlope.xy; erosion.cell_scale = _CellScale; + erosion.normalization = _Normalization; erosion.octaves = _Octaves; + erosion.lacunarity = _Lacunarity; erosion.gain = _Gain; + CultMathPlanetaryFieldDefinition definition; + definition.radius = _Radius; definition.seed = _Seed; definition.erosion = erosion; + return cultmath_planetary_field_sample(definition, direction, base_sample, _SampleFootprint); + } + + Varyings vert(Attributes input) + { + float3 direction = normalize(input.vertex.xyz); + CultMathPlanetarySurfaceSample sample = viewer_sample(direction); + float3 local = direction * (_Radius + sample.radial_displacement); + Varyings output; + output.position = UnityObjectToClipPos(float4(local, 1)); + output.worldNormal = UnityObjectToWorldNormal(sample.surface_normal); + output.height = sample.radial_displacement / max(_Radius * 0.1, 1.0e-5); + output.ridge = sample.ridge; + return output; + } + + float4 frag(Varyings input) : SV_Target + { + float height = saturate(input.height * 0.5 + 0.5); + float3 albedo = lerp(_LowColor.rgb, _HighColor.rgb, height); + albedo = lerp(albedo, _RidgeColor.rgb, saturate((input.ridge - 0.55) * 2.5)); + float diffuse = saturate(dot(normalize(input.worldNormal), normalize(-_LightDirection))) * 0.82 + 0.08; + return float4(albedo * diffuse, 1); + } + ENDHLSL + } + } +} diff --git a/THIRD-PARTY-NOTICES.md.meta b/THIRD-PARTY-NOTICES.md.meta new file mode 100644 index 00000000..4b43ddaa --- /dev/null +++ b/THIRD-PARTY-NOTICES.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7c28c8631c7d4054eb751d7ce952df33 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj b/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj new file mode 100644 index 00000000..9a6f8bdd --- /dev/null +++ b/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/Tools~/CultMath.PlanetaryTileFixture/Program.cs b/Tools~/CultMath.PlanetaryTileFixture/Program.cs new file mode 100644 index 00000000..1952fa2d --- /dev/null +++ b/Tools~/CultMath.PlanetaryTileFixture/Program.cs @@ -0,0 +1,22 @@ +using CultMath; + +if (args.Length != 1) throw new ArgumentException("Expected one output path."); +var field = PlanetaryFieldDefinition.Create(0x55aa, 10, 7, AdvancedErosionParameters.Default); +var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 0, 0, 0, 5, 1); +var key = new PlanetaryMapTileKey(field.FieldVersion, 3, 29, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.125f)); +var tile = PlanetaryMapTileBaker.Bake(field, key, new FixtureField()); +var path = Path.GetFullPath(args[0]); +Directory.CreateDirectory(Path.GetDirectoryName(path)!); +using var stream = File.Create(path); +PlanetaryMapTileEncoding.Write(stream, tile); + +readonly struct FixtureField : IPlanetaryBaseField +{ + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.2f + direction.z * 0.4f; + var gradient = new float3(0.2f, 0, 0.4f); + gradient -= direction * math.dot(gradient, direction); + return new(value, gradient, value, gradient * 0.1f, value); + } +} diff --git a/docs/planetary.md b/docs/planetary.md index 8196c1c7..3503429b 100644 --- a/docs/planetary.md +++ b/docs/planetary.md @@ -80,11 +80,17 @@ coarse meshes from the canonical topology. HLSL calls Renderers still own cameras, clip space, depth, materials, buffers, dispatch, and draw submission. +`PlanetaryGpuPageBuilder` owns the renderer-neutral page input and metadata +payload. Host adapters may convert its `float4` values to native vector types, +but do not recalculate directions, spacing, addressing, or transition state. + The optional `CultMath.Unity` assembly converts a canonical face patch into a -Unity `Mesh` and converts a residency snapshot into Unity-native page input and -metadata payloads. It owns no field parameters, page selection, or simulation -state. Unity render code remains responsible for allocating/binding graphics -buffers and dispatching application-specific base-field entry points. +Unity `Mesh` and converts the shared GPU payload into Unity-native structs. It +owns no field parameters, page selection, or simulation state. The package's +Planetary Field Viewer sample renders the shared HLSL field and reports matching +CPU queries. Unity render code remains responsible for allocating/binding +graphics buffers and dispatching application-specific base-field entry points. +The host Unity project must set `-langversion:latest` in `Assets/csc.rsp`. ## Maps @@ -103,6 +109,12 @@ projection and canonical field query. `PlanetaryMapTileKey` includes field, projection, layer, layout, and query-scale identity. Palettes, contours, labels, and strategic overlays do not enter CultMath. +`PlanetaryMapTileEncoding` writes the resulting evidence as a versioned +little-endian `CMPT` binary asset. `web/planetary-tile.ts` decodes that exact +format into browser-native values without defining another terrain generator. +Typed CultMesh documents should carry the tile key and asset reference; the +binary payload is derived CDN/cache data, not committed game state. + ## Numeric contract Integer hashes and published field versions are exact. Basic arithmetic follows @@ -111,7 +123,8 @@ CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as Every filtered terrain query carries an unresolved-height bound so consumers can reject insufficient fidelity explicitly. -Current D3D12 parity evidence covers the advanced erosion kernel and the QSC -forward/inverse topology. The remaining production page and patch shaders use -the common functions and retain their seam, residual, summary, and radial-hit -integration probes in Fensalir. +Current D3D12 parity evidence covers the advanced erosion kernel, QSC topology, +and forward/inverse transforms for every supported map projection, including +center and scale parameters. Production page and patch shaders use the common +functions and retain their seam, residual, summary, and radial-hit integration +probes in Fensalir. diff --git a/docs/planetary.md.meta b/docs/planetary.md.meta new file mode 100644 index 00000000..af364c25 --- /dev/null +++ b/docs/planetary.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 23a5751e4cececc4e99e024058d69add +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 6da2a17a..25643608 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,12 @@ "Simulation" ], "license": "MPL-2.0", - "category": "Scripting" + "category": "Scripting", + "samples": [ + { + "displayName": "Planetary Field Viewer", + "description": "Runnable Unity viewer using the canonical CultMath CPU and HLSL planetary field.", + "path": "Samples~/PlanetaryViewer" + } + ] } diff --git a/scripts.meta b/scripts.meta new file mode 100644 index 00000000..6f03c566 --- /dev/null +++ b/scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ddff5b782cf14e43ae5cda048e040d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/test-web-planetary-tile.ps1 b/scripts/test-web-planetary-tile.ps1 new file mode 100644 index 00000000..5221fb36 --- /dev/null +++ b/scripts/test-web-planetary-tile.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt" +dotnet run --project (Join-Path $root "Tools~\CultMath.PlanetaryTileFixture\CultMath.PlanetaryTileFixture.csproj") -- $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile fixture generation failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "web\planetary-tile.cross-runtime.test.mjs") $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile web decoding failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "web\planetary-tile.test.mjs") +if ($LASTEXITCODE -ne 0) { throw "Planetary tile decoder unit test failed." } diff --git a/scripts/test-web-planetary-tile.ps1.meta b/scripts/test-web-planetary-tile.ps1.meta new file mode 100644 index 00000000..ffd73234 --- /dev/null +++ b/scripts/test-web-planetary-tile.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 235e8f203bd1c4d40a8fa211273106ba +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/shaders/AdvancedErosionFilter.hlsl.meta b/shaders/AdvancedErosionFilter.hlsl.meta new file mode 100644 index 00000000..a354e330 --- /dev/null +++ b/shaders/AdvancedErosionFilter.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3252ac02e8853ef4b9d1fbc5204b5c6b +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/shaders/Planetary.hlsl b/shaders/Planetary.hlsl index 729c6332..81e312b7 100644 --- a/shaders/Planetary.hlsl +++ b/shaders/Planetary.hlsl @@ -120,6 +120,138 @@ float3 cultmath_planetary_equal_earth_inverse(float2 coordinate) return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); } +float cultmath_planetary_wrap_longitude(float longitude) +{ + longitude = fmod(longitude, CULTMATH_TAU); + if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; + if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; + return longitude; +} + +float3 cultmath_planetary_lon_lat_direction(float longitude, float latitude) +{ + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float cultmath_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } +float cultmath_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } + +bool cultmath_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) +{ + direction = cultmath_normalize(direction); + float longitude = cultmath_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); + float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); + float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); + float factor = 1.0; + if (kind == 3) + { + if (cos_c < 0.0) { coordinate = 0.0; return false; } + } + else if (kind == 4) + { + float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; + } + else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; + else + { + if (cos_c <= 0.0) { coordinate = 0.0; return false; } + factor = 1.0 / cos_c; + } + coordinate = raw * factor; + return true; +} + +bool cultmath_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) +{ + float rho = length(coordinate); + float c; + if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } + else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } + else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } + else c = atan(rho); + if (rho < 1.0e-6) { direction = cultmath_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } + float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); + float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); + direction = cultmath_planetary_lon_lat_direction(cultmath_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); + return true; +} + +bool cultmath_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) +{ + int face; + float2 face_coordinate = cultmath_planetary_face_coordinate(direction, face); + int column = face % 3; + int row = face / 3; + coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); + return true; +} + +bool cultmath_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) +{ + if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } + int column = min((int)((coordinate.x + 1.0) * 1.5), 2); + int row = min((int)(1.0 - coordinate.y), 1); + float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; + float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; + direction = cultmath_planetary_face_direction(row * 3 + column, float2(u, v)); + return true; +} + +bool cultmath_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) +{ + float scale = max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) + { + bool valid = cultmath_planetary_cube_atlas_forward(direction, coordinate); + coordinate /= scale; return valid; + } + if (kind >= 3) + { + bool valid = cultmath_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); + coordinate /= scale; return valid; + } + direction = cultmath_normalize(direction); + float longitude = cultmath_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); + else if (kind == 1) + { + const float maximum_latitude = 1.4844222297453324; + if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } + coordinate = float2(longitude / CULTMATH_PI, cultmath_planetary_asinh(tan(latitude)) / CULTMATH_PI); + } + else + { + float3 centered = cultmath_planetary_lon_lat_direction(longitude, latitude); + coordinate = cultmath_planetary_equal_earth_forward(centered); + } + coordinate /= scale; + return true; +} + +bool cultmath_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) +{ + coordinate *= max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) return cultmath_planetary_cube_atlas_inverse(coordinate, direction); + if (kind >= 3) return cultmath_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); + if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } + float longitude, latitude; + if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } + else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(cultmath_planetary_sinh(coordinate.y * CULTMATH_PI)); } + else + { + float3 centered = cultmath_planetary_equal_earth_inverse(coordinate); + longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); + } + direction = cultmath_planetary_lon_lat_direction(cultmath_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); + return true; +} + struct CultMathPlanetaryFieldDefinition { float radius; diff --git a/shaders/Planetary.hlsl.meta b/shaders/Planetary.hlsl.meta new file mode 100644 index 00000000..f7771355 --- /dev/null +++ b/shaders/Planetary.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bb055ad5328888e479e90ae3fe62c3fe +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/CultMath/AdvancedErosionFilter.cs.meta b/src/CultMath/AdvancedErosionFilter.cs.meta new file mode 100644 index 00000000..52f7ca19 --- /dev/null +++ b/src/CultMath/AdvancedErosionFilter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bec15fdcbe97a7f44a24bdb3ff79e1e7 \ No newline at end of file diff --git a/src/CultMath/ErosionFrequencyBands.cs.meta b/src/CultMath/ErosionFrequencyBands.cs.meta new file mode 100644 index 00000000..cae99643 --- /dev/null +++ b/src/CultMath/ErosionFrequencyBands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 05e7969589dd09b43bd0fda672d25a0b \ No newline at end of file diff --git a/src/CultMath/PlanetaryField.cs b/src/CultMath/PlanetaryField.cs index 13f36c0a..4d5596e5 100644 --- a/src/CultMath/PlanetaryField.cs +++ b/src/CultMath/PlanetaryField.cs @@ -1,3 +1,5 @@ +using System; + namespace CultMath; public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight) diff --git a/src/CultMath/PlanetaryField.cs.meta b/src/CultMath/PlanetaryField.cs.meta new file mode 100644 index 00000000..3e83f637 --- /dev/null +++ b/src/CultMath/PlanetaryField.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b61d6354e3aab054e94721e77e619bfa \ No newline at end of file diff --git a/src/CultMath/PlanetaryGpuPages.cs b/src/CultMath/PlanetaryGpuPages.cs new file mode 100644 index 00000000..024d26b0 --- /dev/null +++ b/src/CultMath/PlanetaryGpuPages.cs @@ -0,0 +1,55 @@ +using System; + +namespace CultMath; + +public readonly record struct PlanetaryGpuPageInput(float4 DirectionRadius, float4 Sampling); + +public readonly record struct PlanetaryGpuPageMetadata(float4 Address, float4 Layout, float4 Bounds, float4 State); + +public readonly record struct PlanetaryGpuPageContent( + PlanetaryPageLayout Layout, + PlanetaryGpuPageInput[] Inputs, + float SampleSpacing, + float ParentSampleSpacing); + +public static class PlanetaryGpuPageBuilder +{ + public static PlanetaryGpuPageContent BuildContent(PlanetaryPageLayout layout, float radius) + { + layout.Validate(); + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * radius; + var inputs = new PlanetaryGpuPageInput[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + inputs[y * layout.StorageSize + x] = new( + new float4(direction, radius), + new float4(spacing, parentSpacing, 0, 0)); + } + return new(layout, inputs, spacing, parentSpacing); + } + + public static PlanetaryGpuPageMetadata Metadata( + in PlanetaryGpuPageContent content, + int outputOffset, + float blend, + float4 bounds = default) + { + content.Layout.Validate(); + if (content.Inputs == null || content.Inputs.Length != content.Layout.StorageSize * content.Layout.StorageSize) + throw new ArgumentException("GPU page content does not match its layout.", nameof(content)); + if (outputOffset < 0) throw new ArgumentOutOfRangeException(nameof(outputOffset)); + if (!float.IsFinite(blend) || blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(blend)); + var tile = content.Layout.Tile; + return new( + new float4((int)tile.Face, tile.Level, tile.X, tile.Y), + new float4(outputOffset, content.Layout.StorageSize, content.Layout.InteriorSize, content.Layout.BorderSize), + bounds, + new float4(1, blend, PlanetaryPageSampling.NominalAngularTexelSize(content.Layout), content.SampleSpacing)); + } +} diff --git a/src/CultMath/PlanetaryGpuPages.cs.meta b/src/CultMath/PlanetaryGpuPages.cs.meta new file mode 100644 index 00000000..7afe847c --- /dev/null +++ b/src/CultMath/PlanetaryGpuPages.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42173c60766948a4e8c376687033532e \ No newline at end of file diff --git a/src/CultMath/PlanetaryMapTileEncoding.cs b/src/CultMath/PlanetaryMapTileEncoding.cs new file mode 100644 index 00000000..ec556617 --- /dev/null +++ b/src/CultMath/PlanetaryMapTileEncoding.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using System.Text; + +namespace CultMath; + +public static class PlanetaryMapTileEncoding +{ + public const uint FormatVersion = 1; + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("CMPT"); + + public static void Write(Stream stream, in PlanetarySurfaceMapTile tile, bool leaveOpen = false) + { + if (stream == null || !stream.CanWrite) throw new ArgumentException("A writable stream is required.", nameof(stream)); + tile.Key.Validate(); + if (tile.Samples == null || tile.Validity == null || tile.Samples.Length != tile.Validity.Length) + throw new ArgumentException("Tile samples and validity must have equal lengths.", nameof(tile)); + var expectedCount = tile.Key.Layout.StorageSize * tile.Key.Layout.StorageSize; + if (tile.Samples.Length != expectedCount) throw new ArgumentException("Tile payload does not match its layout.", nameof(tile)); + + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen); + writer.Write(Magic); + writer.Write(FormatVersion); + writer.Write(tile.Key.FieldVersion); + writer.Write(tile.Key.ProjectionVersion); + writer.Write(tile.Key.LayerId); + var projection = tile.Key.Layout.Projection; + writer.Write((int)projection.Kind); + writer.Write(projection.CenterLongitude); + writer.Write(projection.CenterLatitude); + writer.Write(projection.Scale); + writer.Write(tile.Key.Layout.Level); + writer.Write(tile.Key.Layout.X); + writer.Write(tile.Key.Layout.Y); + writer.Write(tile.Key.Layout.InteriorSize); + writer.Write(tile.Key.Layout.BorderSize); + writer.Write(tile.Key.QueryScale.FootprintMeters); + writer.Write(tile.Key.QueryScale.MaximumUnresolvedHeight); + writer.Write(tile.Samples.Length); + for (var i = 0; i < tile.Samples.Length; i++) + { + writer.Write(tile.Validity[i]); + if (!tile.Validity[i]) continue; + WriteSample(writer, tile.Samples[i], tile.Key.FieldVersion); + } + } + + public static PlanetarySurfaceMapTile Read(Stream stream, bool leaveOpen = false) + { + if (stream == null || !stream.CanRead) throw new ArgumentException("A readable stream is required.", nameof(stream)); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen); + var magic = reader.ReadBytes(Magic.Length); + if (magic.Length != Magic.Length || !magic.AsSpan().SequenceEqual(Magic)) throw new InvalidDataException("Not a CultMath planetary tile."); + var version = reader.ReadUInt32(); + if (version != FormatVersion) throw new InvalidDataException($"Unsupported planetary tile version {version}."); + var fieldVersion = reader.ReadUInt64(); + var projectionVersion = reader.ReadUInt32(); + var layerId = reader.ReadUInt32(); + var kindValue = reader.ReadInt32(); + if (!Enum.IsDefined(typeof(PlanetaryProjectionKind), kindValue)) throw new InvalidDataException("Unknown planetary projection kind."); + var projection = new PlanetaryProjectionParameters( + (PlanetaryProjectionKind)kindValue, + reader.ReadDouble(), reader.ReadDouble(), reader.ReadDouble()).Validate(); + var layout = new PlanetaryMapTileLayout( + projection, + reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), + reader.ReadInt32(), reader.ReadInt32()).Validate(); + var scale = new PlanetaryQueryScale(reader.ReadSingle(), reader.ReadSingle()).Validate(); + var key = new PlanetaryMapTileKey(fieldVersion, projectionVersion, layerId, layout, scale).Validate(); + var count = reader.ReadInt32(); + if (count != layout.StorageSize * layout.StorageSize) throw new InvalidDataException("Planetary tile sample count does not match layout."); + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var i = 0; i < count; i++) + { + validity[i] = reader.ReadBoolean(); + if (validity[i]) samples[i] = ReadSample(reader, fieldVersion); + } + if (stream.CanSeek && stream.Position != stream.Length) throw new InvalidDataException("Planetary tile has trailing bytes."); + return new(key, samples, validity); + } + + private static void WriteSample(BinaryWriter writer, PlanetarySurfaceSample sample, ulong fieldVersion) + { + if (sample.FieldVersion != fieldVersion) throw new ArgumentException("Sample field version does not match tile identity."); + Write(writer, sample.UnitDirection); + writer.Write(sample.Radius); + writer.Write(sample.RadialDisplacement); + Write(writer, sample.TangentGradient); + Write(writer, sample.SurfaceNormal); + writer.Write(sample.Slope); + writer.Write(sample.Ridge); + writer.Write(sample.Gully); + writer.Write(sample.FinestResolvedWavelength); + writer.Write(sample.UnresolvedHeightBound); + } + + private static PlanetarySurfaceSample ReadSample(BinaryReader reader, ulong fieldVersion) + { + var direction = ReadFloat3(reader); + var radius = reader.ReadSingle(); + var displacement = reader.ReadSingle(); + var gradient = ReadFloat3(reader); + var normal = ReadFloat3(reader); + return new( + fieldVersion, direction, radius, displacement, gradient, normal, + reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), + reader.ReadSingle(), reader.ReadSingle()); + } + + private static void Write(BinaryWriter writer, float3 value) + { + writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); + } + + private static float3 ReadFloat3(BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); +} diff --git a/src/CultMath/PlanetaryMapTileEncoding.cs.meta b/src/CultMath/PlanetaryMapTileEncoding.cs.meta new file mode 100644 index 00000000..76484f65 --- /dev/null +++ b/src/CultMath/PlanetaryMapTileEncoding.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6898f6de57df20646a0b9077bb923592 \ No newline at end of file diff --git a/src/CultMath/PlanetaryPages.cs b/src/CultMath/PlanetaryPages.cs index c9cff51f..699a5c1d 100644 --- a/src/CultMath/PlanetaryPages.cs +++ b/src/CultMath/PlanetaryPages.cs @@ -1,3 +1,5 @@ +using System; + namespace CultMath; public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize) diff --git a/src/CultMath/PlanetaryPages.cs.meta b/src/CultMath/PlanetaryPages.cs.meta new file mode 100644 index 00000000..91fdc27d --- /dev/null +++ b/src/CultMath/PlanetaryPages.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7af9a0354faa56348a2e07188739a0ae \ No newline at end of file diff --git a/src/CultMath/PlanetaryPatch.cs b/src/CultMath/PlanetaryPatch.cs index 0fd3cbb6..30c852ff 100644 --- a/src/CultMath/PlanetaryPatch.cs +++ b/src/CultMath/PlanetaryPatch.cs @@ -1,3 +1,6 @@ +using System; +using System.Linq; + namespace CultMath; public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection); diff --git a/src/CultMath/PlanetaryPatch.cs.meta b/src/CultMath/PlanetaryPatch.cs.meta new file mode 100644 index 00000000..efd8156a --- /dev/null +++ b/src/CultMath/PlanetaryPatch.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04651571f7d240a418c1f6aafa217fa6 \ No newline at end of file diff --git a/src/CultMath/PlanetaryProjection.cs b/src/CultMath/PlanetaryProjection.cs index f849fe20..a82ebfdb 100644 --- a/src/CultMath/PlanetaryProjection.cs +++ b/src/CultMath/PlanetaryProjection.cs @@ -1,3 +1,5 @@ +using System; + namespace CultMath; public enum PlanetaryProjectionKind diff --git a/src/CultMath/PlanetaryProjection.cs.meta b/src/CultMath/PlanetaryProjection.cs.meta new file mode 100644 index 00000000..7b1e6654 --- /dev/null +++ b/src/CultMath/PlanetaryProjection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5021202cf626f7442a57961255a8a217 \ No newline at end of file diff --git a/src/CultMath/PlanetaryQueries.cs b/src/CultMath/PlanetaryQueries.cs index 1f11ca77..54a9a3e9 100644 --- a/src/CultMath/PlanetaryQueries.cs +++ b/src/CultMath/PlanetaryQueries.cs @@ -1,3 +1,5 @@ +using System; + namespace CultMath; public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement) diff --git a/src/CultMath/PlanetaryQueries.cs.meta b/src/CultMath/PlanetaryQueries.cs.meta new file mode 100644 index 00000000..6fd11413 --- /dev/null +++ b/src/CultMath/PlanetaryQueries.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2ec43c567214b8247bda19b5c120c200 \ No newline at end of file diff --git a/src/CultMath/PlanetaryRadialRefinement.cs.meta b/src/CultMath/PlanetaryRadialRefinement.cs.meta new file mode 100644 index 00000000..32af2655 --- /dev/null +++ b/src/CultMath/PlanetaryRadialRefinement.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82e4db521edc253499220458b417fd1e \ No newline at end of file diff --git a/src/CultMath/PlanetaryResidency.cs b/src/CultMath/PlanetaryResidency.cs index e5dca115..6918ac57 100644 --- a/src/CultMath/PlanetaryResidency.cs +++ b/src/CultMath/PlanetaryResidency.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using System.Linq; + namespace CultMath; public readonly record struct PlanetaryLodParameters( @@ -71,9 +75,9 @@ public readonly record struct PlanetaryResidencySnapshot( /// Owns presentation residency only. It never owns field content. public sealed class PlanetaryResidualResidency { - private readonly Dictionary residents = []; - private readonly Dictionary arrivals = []; - private readonly Dictionary departures = []; + private readonly Dictionary residents = new(); + private readonly Dictionary arrivals = new(); + private readonly Dictionary departures = new(); private float lastTime = float.NegativeInfinity; public void Reset() diff --git a/src/CultMath/PlanetaryResidency.cs.meta b/src/CultMath/PlanetaryResidency.cs.meta new file mode 100644 index 00000000..24347624 --- /dev/null +++ b/src/CultMath/PlanetaryResidency.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0585ea15edd7f1c4fa272df4911a8d6f \ No newline at end of file diff --git a/src/CultMath/PlanetaryTopology.cs b/src/CultMath/PlanetaryTopology.cs index c8535079..57d562cc 100644 --- a/src/CultMath/PlanetaryTopology.cs +++ b/src/CultMath/PlanetaryTopology.cs @@ -1,3 +1,5 @@ +using System; + namespace CultMath; public enum PlanetaryCubeFace @@ -104,6 +106,14 @@ public static class PlanetaryTopology return math.normalize(cube); } + public static float3 NormalizedCubeDirection(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = (float)coordinate.U; + var v = (float)coordinate.V; + return math.normalize(CubeVector(coordinate.Face, u, v)); + } + public static PlanetaryFaceCoordinate FaceCoordinate(float3 direction) { ValidateDirection(direction); @@ -156,6 +166,17 @@ public static class PlanetaryTopology MathF.Atan(cubeU) / QuarterPi, MathF.Atan(cubeV) / QuarterPi); + private static float3 CubeVector(PlanetaryCubeFace face, float u, float v) => face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(face)), + }; + internal static void ValidateDirection(float3 direction) { ValidateFinite(direction, nameof(direction)); diff --git a/src/CultMath/PlanetaryTopology.cs.meta b/src/CultMath/PlanetaryTopology.cs.meta new file mode 100644 index 00000000..411a8bdb --- /dev/null +++ b/src/CultMath/PlanetaryTopology.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88f6789c4384f7846ac4be37613fe5ed \ No newline at end of file diff --git a/src/CultMath/SphericalErosion.cs.meta b/src/CultMath/SphericalErosion.cs.meta new file mode 100644 index 00000000..82db7730 --- /dev/null +++ b/src/CultMath/SphericalErosion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59ec2db73065f6c4483ad90f5e17850e \ No newline at end of file diff --git a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta new file mode 100644 index 00000000..253ee425 --- /dev/null +++ b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a7c85309318ae3f4ba75ac28a4f661cf \ No newline at end of file diff --git a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta new file mode 100644 index 00000000..ef15b7ee --- /dev/null +++ b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 095f8ed13d2f2f74baefd23310395256 \ No newline at end of file diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs index 567b038b..5e1451cd 100644 --- a/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -78,6 +78,8 @@ public sealed class HlslMirrorTests Assert.Contains("cultmath_planetary_field_sample", planetary); Assert.Contains("cultmath_planetary_equirectangular_forward", planetary); Assert.Contains("cultmath_planetary_equal_earth_forward", planetary); + Assert.Contains("cultmath_planetary_projection_forward", planetary); + Assert.Contains("cultmath_planetary_projection_inverse", planetary); Assert.Contains("CultMathPlanetarySurfaceSample", planetary); } diff --git a/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta b/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta new file mode 100644 index 00000000..1710b5ff --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c747cfda80df21d41a57412d8d07c08a \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs new file mode 100644 index 00000000..ea17b4b9 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs @@ -0,0 +1,25 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryGpuPageTests +{ + [Fact] + public void UploadContentUsesCanonicalDirectionsSpacingAndMetadata() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.NegativeY, 3, 4, 2), 9, 2); + var content = PlanetaryGpuPageBuilder.BuildContent(layout, 8.4f); + Assert.Equal(layout.StorageSize * layout.StorageSize, content.Inputs.Length); + Assert.True(content.ParentSampleSpacing > content.SampleSpacing); + var first = content.Inputs[0]; + Assert.Equal(PlanetaryPageSampling.Direction(layout, 0, 0), first.DirectionRadius.xyz); + Assert.Equal(8.4f, first.DirectionRadius.w); + Assert.Equal(content.SampleSpacing, first.Sampling.x); + Assert.Equal(content.ParentSampleSpacing, first.Sampling.y); + + var metadata = PlanetaryGpuPageBuilder.Metadata(content, 37, 0.25f); + Assert.Equal(new float4((int)layout.Tile.Face, layout.Tile.Level, layout.Tile.X, layout.Tile.Y), metadata.Address); + Assert.Equal(new float4(37, layout.StorageSize, layout.InteriorSize, layout.BorderSize), metadata.Layout); + Assert.Equal(0.25f, metadata.State.y); + } +} diff --git a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta new file mode 100644 index 00000000..00f4a3a5 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 87266638e8010f546ab6c430a712868b \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs new file mode 100644 index 00000000..112cb31f --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs @@ -0,0 +1,52 @@ +using System.IO; +using Xunit; + +namespace CultMath.Tests; + +public sealed class PlanetaryMapTileEncodingTests +{ + [Fact] + public void BinaryTileRoundTripsIdentityValidityAndSurfaceEvidence() + { + var field = PlanetaryFieldDefinition.Create(5, 10, 3, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 1, 1, 0, 5, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 2, 17, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.Position = 0; + var decoded = PlanetaryMapTileEncoding.Read(bytes); + + Assert.Equal(tile.Key, decoded.Key); + Assert.Equal(tile.Validity, decoded.Validity); + for (var i = 0; i < tile.Samples.Length; i++) + if (tile.Validity[i]) Assert.Equal(tile.Samples[i], decoded.Samples[i]); + } + + [Fact] + public void DecoderRejectsForeignMagicAndVersions() + { + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 }))); + var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 }; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes))); + } + + [Fact] + public void DecoderRejectsTrailingBytes() + { + var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.WriteByte(0xff); + bytes.Position = 0; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(bytes)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta new file mode 100644 index 00000000..027754e8 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e916ef83478d48b43ac99777f5e0f731 \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryPageTests.cs.meta b/tests/CultMath.Tests/PlanetaryPageTests.cs.meta new file mode 100644 index 00000000..70a43864 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryPageTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c3ae5598d3b9164a9bd3a736a22ba9a \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta b/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta new file mode 100644 index 00000000..85990473 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 36840ef44326f84409f850163b00e1f7 \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta b/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta new file mode 100644 index 00000000..7f37f78f --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f370036bb1663524fb4a4d47dfd50401 \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta b/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta new file mode 100644 index 00000000..d72b2cb6 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 93cf8771fdd520b41a4e931a6566a80a \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta new file mode 100644 index 00000000..b9f3bea9 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d34c0018ee2b700448b16540eca8ebcf \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta b/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta new file mode 100644 index 00000000..be7de2b4 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ece3b02dbd3d7448baabee7f1fba0ab \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs b/tests/CultMath.Tests/PlanetaryTopologyTests.cs index 6e6b0838..759928d8 100644 --- a/tests/CultMath.Tests/PlanetaryTopologyTests.cs +++ b/tests/CultMath.Tests/PlanetaryTopologyTests.cs @@ -52,4 +52,12 @@ public sealed class PlanetaryTopologyTests Assert.NotEqual(edge, border); Assert.Equal(1, math.length(border), 5); } + + [Fact] + public void NormalizedCubeBaselineSharesFaceEdges() + { + var a = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveZ, 1, -0.4)); + var b = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveX, -1, -0.4)); + Assert.InRange(math.distance(a, b), 0, 1.0e-6f); + } } diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta b/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta new file mode 100644 index 00000000..b6930142 --- /dev/null +++ b/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be3b0ec07fe65344db889d1e98448113 \ No newline at end of file diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs.meta b/tests/CultMath.Tests/SphericalErosionTests.cs.meta new file mode 100644 index 00000000..00ff1de0 --- /dev/null +++ b/tests/CultMath.Tests/SphericalErosionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 60eb9e9e9f8ae2948b13403f5080168e \ No newline at end of file diff --git a/tools/CultMath.PlanetaryTileFixture.meta b/tools/CultMath.PlanetaryTileFixture.meta new file mode 100644 index 00000000..7f40b6ae --- /dev/null +++ b/tools/CultMath.PlanetaryTileFixture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7197a2202273a9445b974bdd69109b1a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity.meta b/unity.meta new file mode 100644 index 00000000..de47a618 --- /dev/null +++ b/unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 366efeaef9f68224b80229332c7ecfc6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Runtime.meta b/unity/Runtime.meta new file mode 100644 index 00000000..8e0678f8 --- /dev/null +++ b/unity/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8226365c03c5e1a438408b7e42b98740 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Runtime/CultMath.Unity.asmdef.meta b/unity/Runtime/CultMath.Unity.asmdef.meta new file mode 100644 index 00000000..2dd3a36a --- /dev/null +++ b/unity/Runtime/CultMath.Unity.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1906c397c420ae4eb222a3f09f36843 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Runtime/PlanetaryPageUpload.cs b/unity/Runtime/PlanetaryPageUpload.cs index f0b168cd..1a61a707 100644 --- a/unity/Runtime/PlanetaryPageUpload.cs +++ b/unity/Runtime/PlanetaryPageUpload.cs @@ -36,12 +36,28 @@ public readonly struct PlanetaryPageMetadataGpu public readonly Vector4 State; } -public readonly record struct PlanetaryPageUpload( - PlanetaryPageInputGpu[] Inputs, - PlanetaryPageMetadataGpu[] Metadata, - int OutputSampleCount, - ulong ContentVersion, - ulong PresentationVersion); +public readonly struct PlanetaryPageUpload +{ + public PlanetaryPageUpload( + PlanetaryPageInputGpu[] inputs, + PlanetaryPageMetadataGpu[] metadata, + int outputSampleCount, + ulong contentVersion, + ulong presentationVersion) + { + Inputs = inputs; + Metadata = metadata; + OutputSampleCount = outputSampleCount; + ContentVersion = contentVersion; + PresentationVersion = presentationVersion; + } + + public PlanetaryPageInputGpu[] Inputs { get; } + public PlanetaryPageMetadataGpu[] Metadata { get; } + public int OutputSampleCount { get; } + public ulong ContentVersion { get; } + public ulong PresentationVersion { get; } +} public static class PlanetaryPageUploadBuilder { @@ -57,25 +73,23 @@ public static class PlanetaryPageUploadBuilder foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index))) { var layout = new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate(); - var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; - var parentSpacing = resident.Tile.Level == 0 - ? 0 - : PlanetaryPageSampling.NominalAngularTexelSize(new(resident.Tile.Parent(), interiorSize, borderSize)) * radius; + var content = PlanetaryGpuPageBuilder.BuildContent(layout, radius); var outputOffset = inputs.Count; - for (var y = 0; y < layout.StorageSize; y++) - for (var x = 0; x < layout.StorageSize; x++) + foreach (var input in content.Inputs) { - var direction = PlanetaryPageSampling.Direction(layout, x, y); inputs.Add(new( - new Vector4(direction.x, direction.y, direction.z, radius), - new Vector4(spacing, parentSpacing, 0, 0))); + ToUnity(input.DirectionRadius), + ToUnity(input.Sampling))); } + var commonMetadata = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend); metadata[pageIndex] = new( - new Vector4((int)resident.Tile.Face, resident.Tile.Level, resident.Tile.X, resident.Tile.Y), - new Vector4(outputOffset, layout.StorageSize, layout.InteriorSize, layout.BorderSize), - Vector4.zero, - new Vector4(1, resident.Blend, PlanetaryPageSampling.NominalAngularTexelSize(layout), spacing)); + ToUnity(commonMetadata.Address), + ToUnity(commonMetadata.Layout), + ToUnity(commonMetadata.Bounds), + ToUnity(commonMetadata.State)); } return new(inputs.ToArray(), metadata, inputs.Count, snapshot.ContentVersion, snapshot.PresentationVersion); } + + private static Vector4 ToUnity(float4 value) => new(value.x, value.y, value.z, value.w); } diff --git a/unity/Runtime/PlanetaryPageUpload.cs.meta b/unity/Runtime/PlanetaryPageUpload.cs.meta new file mode 100644 index 00000000..87bcb61a --- /dev/null +++ b/unity/Runtime/PlanetaryPageUpload.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7edd3bb2edb2e6448a2d54fc082c27dc \ No newline at end of file diff --git a/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta b/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta new file mode 100644 index 00000000..69af0abc --- /dev/null +++ b/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ad7b581e14ef774d8435069bb77ca6a \ No newline at end of file diff --git a/web.meta b/web.meta new file mode 100644 index 00000000..d65ff3e6 --- /dev/null +++ b/web.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: daf45471f8b5f4f44a2cb86e77e010db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/web/planetary-tile.cross-runtime.test.mjs b/web/planetary-tile.cross-runtime.test.mjs new file mode 100644 index 00000000..2b457219 --- /dev/null +++ b/web/planetary-tile.cross-runtime.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { decodePlanetarySurfaceTile } from "./planetary-tile.ts"; + +if (process.argv.length !== 3) throw new Error("Expected a generated CMPT fixture path"); +const bytes = await readFile(process.argv[2]); +const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +const tile = decodePlanetarySurfaceTile(arrayBuffer); +assert.equal(tile.header.projectionVersion, 3); +assert.equal(tile.header.layerId, 29); +assert.equal(tile.header.projectionKind, 2); +assert.equal(tile.header.interiorSize, 5); +assert.equal(tile.header.borderSize, 1); +assert.equal(tile.storageSize, 7); +assert.equal(tile.samples.length, 49); +assert.ok(tile.samples.some(sample => sample !== null)); +for (const sample of tile.samples) { + if (sample === null) continue; + assert.ok(Number.isFinite(sample.radialDisplacement)); + assert.ok(Number.isFinite(sample.unresolvedHeightBound)); +} diff --git a/web/planetary-tile.cross-runtime.test.mjs.meta b/web/planetary-tile.cross-runtime.test.mjs.meta new file mode 100644 index 00000000..3c38da54 --- /dev/null +++ b/web/planetary-tile.cross-runtime.test.mjs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7bc397e5660d2b6448919c41983d35e8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/web/planetary-tile.test.mjs b/web/planetary-tile.test.mjs new file mode 100644 index 00000000..cb054f28 --- /dev/null +++ b/web/planetary-tile.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { decodePlanetarySurfaceTile } from "./planetary-tile.ts"; + +const bytes = new ArrayBuffer(152); +const view = new DataView(bytes); +let offset = 0; +const u8 = value => view.setUint8(offset++, value); +const u32 = value => { view.setUint32(offset, value, true); offset += 4; }; +const i32 = value => { view.setInt32(offset, value, true); offset += 4; }; +const u64 = value => { view.setBigUint64(offset, value, true); offset += 8; }; +const f32 = value => { view.setFloat32(offset, value, true); offset += 4; }; +const f64 = value => { view.setFloat64(offset, value, true); offset += 8; }; + +for (const character of "CMPT") u8(character.charCodeAt(0)); +u32(1); u64(0x1234n); u32(2); u32(17); i32(0); +f64(0); f64(0); f64(1); +i32(0); i32(0); i32(0); i32(2); i32(0); +f32(10); f32(0.5); i32(4); +u8(1); +for (const value of [1, 0, 0]) f32(value); +f32(8.4); f32(0.25); +for (const value of [0, 0.1, 0]) f32(value); +for (const value of [0.99, -0.1, 0]) f32(value); +for (const value of [0.1, 0.8, 0.2, 2.5, 0.05]) f32(value); +u8(0); u8(0); u8(0); +assert.equal(offset, bytes.byteLength); + +const tile = decodePlanetarySurfaceTile(bytes); +assert.equal(tile.header.fieldVersion, 0x1234n); +assert.equal(tile.header.layerId, 17); +assert.equal(tile.storageSize, 2); +assert.equal(tile.samples.length, 4); +assert.equal(tile.samples[0]?.radialDisplacement, 0.25); +assert.equal(tile.samples[1], null); + +assert.throws(() => decodePlanetarySurfaceTile(bytes.slice(0, bytes.byteLength - 1)), /Truncated/); +const trailing = new Uint8Array(bytes.byteLength + 1); +trailing.set(new Uint8Array(bytes)); +assert.throws(() => decodePlanetarySurfaceTile(trailing.buffer), /trailing/); diff --git a/web/planetary-tile.test.mjs.meta b/web/planetary-tile.test.mjs.meta new file mode 100644 index 00000000..d622095b --- /dev/null +++ b/web/planetary-tile.test.mjs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 04c93ecab90d465499aa3d60722e6fc3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/web/planetary-tile.ts b/web/planetary-tile.ts new file mode 100644 index 00000000..e68d70fa --- /dev/null +++ b/web/planetary-tile.ts @@ -0,0 +1,89 @@ +export const CULTMATH_PLANETARY_TILE_VERSION = 1; + +export interface PlanetaryTileHeader { + fieldVersion: bigint; + projectionVersion: number; + layerId: number; + projectionKind: number; + centerLongitude: number; + centerLatitude: number; + projectionScale: number; + level: number; + x: number; + y: number; + interiorSize: number; + borderSize: number; + footprintMeters: number; + maximumUnresolvedHeight: number; +} + +export interface PlanetaryTileSample { + direction: readonly [number, number, number]; + radius: number; + radialDisplacement: number; + tangentGradient: readonly [number, number, number]; + surfaceNormal: readonly [number, number, number]; + slope: number; + ridge: number; + gully: number; + finestResolvedWavelength: number; + unresolvedHeightBound: number; +} + +export interface PlanetarySurfaceTile { + header: PlanetaryTileHeader; + storageSize: number; + samples: readonly (PlanetaryTileSample | null)[]; +} + +export function decodePlanetarySurfaceTile(bytes: ArrayBuffer): PlanetarySurfaceTile { + const view = new DataView(bytes); + let offset = 0; + const u8 = () => view.getUint8(offset++); + const u32 = () => { const value = view.getUint32(offset, true); offset += 4; return value; }; + const i32 = () => { const value = view.getInt32(offset, true); offset += 4; return value; }; + const f32 = () => { const value = view.getFloat32(offset, true); offset += 4; return value; }; + const f64 = () => { const value = view.getFloat64(offset, true); offset += 8; return value; }; + const u64 = () => { const value = view.getBigUint64(offset, true); offset += 8; return value; }; + const vector3 = (): [number, number, number] => [f32(), f32(), f32()]; + const requireRemaining = (count: number) => { + if (offset + count > view.byteLength) throw new Error("Truncated CultMath planetary tile"); + }; + + requireRemaining(8); + if (String.fromCharCode(u8(), u8(), u8(), u8()) !== "CMPT") throw new Error("Not a CultMath planetary tile"); + const version = u32(); + if (version !== CULTMATH_PLANETARY_TILE_VERSION) throw new Error(`Unsupported CultMath planetary tile version ${version}`); + requireRemaining(76); + const header: PlanetaryTileHeader = { + fieldVersion: u64(), + projectionVersion: u32(), + layerId: u32(), + projectionKind: i32(), + centerLongitude: f64(), + centerLatitude: f64(), + projectionScale: f64(), + level: i32(), x: i32(), y: i32(), interiorSize: i32(), borderSize: i32(), + footprintMeters: f32(), maximumUnresolvedHeight: f32(), + }; + const count = i32(); + const storageSize = header.interiorSize + header.borderSize * 2; + if (count !== storageSize * storageSize) throw new Error("CultMath planetary tile sample count does not match layout"); + const samples: (PlanetaryTileSample | null)[] = new Array(count); + for (let index = 0; index < count; index++) { + requireRemaining(1); + if (u8() === 0) { samples[index] = null; continue; } + requireRemaining(64); + samples[index] = { + direction: vector3(), + radius: f32(), + radialDisplacement: f32(), + tangentGradient: vector3(), + surfaceNormal: vector3(), + slope: f32(), ridge: f32(), gully: f32(), + finestResolvedWavelength: f32(), unresolvedHeightBound: f32(), + }; + } + if (offset !== view.byteLength) throw new Error("CultMath planetary tile has trailing bytes"); + return { header, storageSize, samples }; +} diff --git a/web/planetary-tile.ts.meta b/web/planetary-tile.ts.meta new file mode 100644 index 00000000..337da3bd --- /dev/null +++ b/web/planetary-tile.ts.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f284e64628320844196b7aaea76475a4 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From cb481b4c75444426a693781a0e5b35ade08a8938 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 17 Jul 2026 07:45:28 +0100 Subject: [PATCH 17/23] Add Unity-compatible simplex noise semantics --- THIRD-PARTY-NOTICES.md | 10 ++++++ shaders/CultMath.hlsl | 36 ++++++++++++++++++++ src/CultMath/math.cs | 44 +++++++++++++++++++++++++ tests/CultMath.Tests/HlslMirrorTests.cs | 1 + tests/CultMath.Tests/MathTests.cs | 10 ++++++ 5 files changed, 101 insertions(+) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 8339c5fe..54e280f6 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,5 +1,15 @@ # Third-Party Notices +## 2D Simplex Noise + +`math.simplex_noise` and `cultmath_simplex_noise` port the textureless 2D +simplex-noise function by Ian McEwan and Ashima Arts, as distributed by Stefan +Gustavson's `webgl-noise` project and Unity Mathematics. + +- Copyright 2011 Ashima Arts. +- Upstream: +- License: MIT. + ## Advanced Terrain Erosion Filter `src/CultMath/AdvancedErosionFilter.cs` and diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl index cf76c8c2..84d244bb 100644 --- a/shaders/CultMath.hlsl +++ b/shaders/CultMath.hlsl @@ -73,6 +73,42 @@ float cultmath_smootherstep(float value) return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); } +float2 cultmath_simplex_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_simplex_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_simplex_permute(float3 value) { return cultmath_simplex_mod289(((value * 34.0) + 1.0) * value); } + +float cultmath_simplex_noise(float2 value) +{ + const float4 c = float4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + float2 i = floor(value + dot(value, c.yy)); + float2 x0 = value - i + dot(i, c.xx); + float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0); + float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_simplex_mod289(i); + float3 p = cultmath_simplex_permute( + cultmath_simplex_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0)); + float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + float3 x = 2.0 * frac(p * c.w) - 1.0; + float3 h = abs(x) - 0.5; + float3 ox = floor(x + 0.5); + float3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + float3 g = float3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + float cultmath_lengthsq(float2 value) { return dot(value, value); } float cultmath_lengthsq(float3 value) { return dot(value, value); } float cultmath_lengthsq(float4 value) { return dot(value, value); } diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index 874554ad..e15fbf08 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -327,6 +327,50 @@ public static class math public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f))); public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f))); + // Ashima Arts / Ian McEwan 2D simplex noise, kept component-explicit so + // the C# and HLSL mirrors preserve the same float32 evaluation order. + public static float simplex_noise(float2 value) + { + var c = new float4( + 0.211324865405187f, + 0.366025403784439f, + -0.577350269189626f, + 0.024390243902439f); + var i = floor(value + dot(value, new float2(c.y, c.y))); + var x0 = value - i + dot(i, new float2(c.x, c.x)); + var i1 = x0.x > x0.y ? new float2(1.0f, 0.0f) : new float2(0.0f, 1.0f); + var x12 = new float4( + x0.x + c.x - i1.x, + x0.y + c.x - i1.y, + x0.x + c.z, + x0.y + c.z); + + i = simplex_mod289(i); + var p = simplex_permute(simplex_permute(i.y + new float3(0.0f, i1.y, 1.0f)) + i.x + new float3(0.0f, i1.x, 1.0f)); + var m = max(0.5f - new float3( + dot(x0, x0), + x12.x * x12.x + x12.y * x12.y, + x12.z * x12.z + x12.w * x12.w), 0.0f); + m = m * m; + m = m * m; + + var x = 2.0f * frac(p * c.w) - 1.0f; + var h = abs(x) - 0.5f; + var ox = floor(x + 0.5f); + var a0 = x - ox; + m *= 1.79284291400159f - 0.85373472095314f * (a0 * a0 + h * h); + + var g = new float3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0f * dot(m, g); + } + + private static float2 simplex_mod289(float2 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; + private static float3 simplex_mod289(float3 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; + private static float3 simplex_permute(float3 value) => simplex_mod289(((value * 34.0f) + 1.0f) * value); + public static float value_noise(float2 position) { var cell = floor(position); diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs index 5e1451cd..47f58971 100644 --- a/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -51,6 +51,7 @@ public sealed class HlslMirrorTests "cultmath_cubic_bezier", "cultmath_hash", "cultmath_value_noise", + "cultmath_simplex_noise", "cultmath_value_noise_bicubic", "cultmath_value_noise_texture", "cultmath_value_noise_texture_bicubic", diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index bb816d47..6979bdd6 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -235,6 +235,16 @@ public sealed class MathTests Assert.True(bicubic > -0.25f && bicubic < 1.25f); } + [Theory] + [InlineData(0.0f, 0.0f, 0.0f)] + [InlineData(0.25f, -0.5f, -0.425866783f)] + [InlineData(12.25f, -4.5f, 0.258312f)] + [InlineData(536.5106f, 536.5106f, -0.6901103f)] + public void SimplexNoiseMatchesUnityMathematics(float x, float y, float expected) + { + Assert.Equal(expected, math.simplex_noise(new float2(x, y)), precision: 6); + } + [Fact] public void VoronoiBatchProducesToneMappedColors() { From 6a162606e4bed08de8b3250d708db9b841c92d1b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:47:38 +0100 Subject: [PATCH 18/23] Transfer planetary geometry to GameCult.Geometry --- CultMath.slnx | 3 - README.md | 11 - .../CultMath.PlanetaryViewer.Editor.asmdef | 18 - .../Editor/CultMathPlanetaryViewerMenu.cs | 26 -- Samples~/PlanetaryViewer/README.md | 16 - .../Runtime/CultMath.PlanetaryViewer.asmdef | 17 - .../Runtime/CultMathPlanetaryViewer.cs | 139 ------- .../Runtime/CultMathPlanetaryViewer.shader | 90 ---- THIRD-PARTY-NOTICES.md | 23 - .../CultMath.PlanetaryTileFixture.csproj | 11 - .../CultMath.PlanetaryTileFixture/Program.cs | 22 - docs/design.md | 6 - docs/planetary.md | 130 ------ docs/planetary.md.meta | 7 - package.json | 9 +- scripts/test-web-planetary-tile.ps1 | 9 - scripts/test-web-planetary-tile.ps1.meta | 7 - shaders/AdvancedErosionFilter.hlsl | 147 ------- shaders/AdvancedErosionFilter.hlsl.meta | 7 - shaders/CultMath.hlsl | 94 ----- shaders/Planetary.hlsl | 392 ------------------ shaders/Planetary.hlsl.meta | 7 - src/CultMath/AdvancedErosionFilter.cs | 139 ------- src/CultMath/AdvancedErosionFilter.cs.meta | 2 - src/CultMath/CultMath.csproj | 2 - src/CultMath/ErosionFrequencyBands.cs | 57 --- src/CultMath/ErosionFrequencyBands.cs.meta | 2 - src/CultMath/PlanetaryField.cs | 215 ---------- src/CultMath/PlanetaryField.cs.meta | 2 - src/CultMath/PlanetaryGpuPages.cs | 55 --- src/CultMath/PlanetaryGpuPages.cs.meta | 2 - src/CultMath/PlanetaryMapTileEncoding.cs | 117 ------ src/CultMath/PlanetaryMapTileEncoding.cs.meta | 2 - src/CultMath/PlanetaryPages.cs | 187 --------- src/CultMath/PlanetaryPages.cs.meta | 2 - src/CultMath/PlanetaryPatch.cs | 45 -- src/CultMath/PlanetaryPatch.cs.meta | 2 - src/CultMath/PlanetaryProjection.cs | 322 -------------- src/CultMath/PlanetaryProjection.cs.meta | 2 - src/CultMath/PlanetaryQueries.cs | 193 --------- src/CultMath/PlanetaryQueries.cs.meta | 2 - src/CultMath/PlanetaryRadialRefinement.cs | 28 -- .../PlanetaryRadialRefinement.cs.meta | 2 - src/CultMath/PlanetaryResidency.cs | 148 ------- src/CultMath/PlanetaryResidency.cs.meta | 2 - src/CultMath/PlanetaryTopology.cs | 191 --------- src/CultMath/PlanetaryTopology.cs.meta | 2 - src/CultMath/SphericalErosion.cs | 108 ----- src/CultMath/SphericalErosion.cs.meta | 2 - src/CultMath/math.cs | 3 - src/CultMath/rect.cs | 21 - src/CultMath/rect.cs.meta | 2 - .../AdvancedErosionFilterTests.cs | 44 -- .../AdvancedErosionFilterTests.cs.meta | 2 - .../ErosionFrequencyBandsTests.cs | 34 -- .../ErosionFrequencyBandsTests.cs.meta | 2 - tests/CultMath.Tests/HlslMirrorTests.cs | 37 +- tests/CultMath.Tests/MathTests.cs | 13 - tests/CultMath.Tests/PlanetaryFieldTests.cs | 80 ---- .../PlanetaryFieldTests.cs.meta | 2 - tests/CultMath.Tests/PlanetaryGpuPageTests.cs | 25 -- .../PlanetaryGpuPageTests.cs.meta | 2 - .../PlanetaryMapTileEncodingTests.cs | 52 --- .../PlanetaryMapTileEncodingTests.cs.meta | 2 - tests/CultMath.Tests/PlanetaryPageTests.cs | 57 --- .../CultMath.Tests/PlanetaryPageTests.cs.meta | 2 - tests/CultMath.Tests/PlanetaryPatchTests.cs | 40 -- .../PlanetaryPatchTests.cs.meta | 2 - .../PlanetaryProjectionTests.cs | 73 ---- .../PlanetaryProjectionTests.cs.meta | 2 - tests/CultMath.Tests/PlanetaryQueryTests.cs | 46 -- .../PlanetaryQueryTests.cs.meta | 2 - .../PlanetaryRadialRefinementTests.cs | 29 -- .../PlanetaryRadialRefinementTests.cs.meta | 2 - .../CultMath.Tests/PlanetaryResidencyTests.cs | 57 --- .../PlanetaryResidencyTests.cs.meta | 2 - .../CultMath.Tests/PlanetaryTopologyTests.cs | 63 --- .../PlanetaryTopologyTests.cs.meta | 2 - tests/CultMath.Tests/SphericalErosionTests.cs | 34 -- .../SphericalErosionTests.cs.meta | 2 - tools/CultMath.PlanetaryTileFixture.meta | 8 - unity/Runtime/PlanetaryPageUpload.cs | 95 ----- unity/Runtime/PlanetaryPageUpload.cs.meta | 2 - unity/Runtime/PlanetaryPatchMeshAdapter.cs | 36 -- .../Runtime/PlanetaryPatchMeshAdapter.cs.meta | 2 - web/planetary-tile.cross-runtime.test.mjs | 21 - ...planetary-tile.cross-runtime.test.mjs.meta | 7 - web/planetary-tile.test.mjs | 39 -- web/planetary-tile.test.mjs.meta | 7 - web/planetary-tile.ts | 89 ---- web/planetary-tile.ts.meta | 7 - 91 files changed, 6 insertions(+), 4067 deletions(-) delete mode 100644 Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef delete mode 100644 Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs delete mode 100644 Samples~/PlanetaryViewer/README.md delete mode 100644 Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef delete mode 100644 Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs delete mode 100644 Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader delete mode 100644 Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj delete mode 100644 Tools~/CultMath.PlanetaryTileFixture/Program.cs delete mode 100644 docs/planetary.md delete mode 100644 docs/planetary.md.meta delete mode 100644 scripts/test-web-planetary-tile.ps1 delete mode 100644 scripts/test-web-planetary-tile.ps1.meta delete mode 100644 shaders/AdvancedErosionFilter.hlsl delete mode 100644 shaders/AdvancedErosionFilter.hlsl.meta delete mode 100644 shaders/Planetary.hlsl delete mode 100644 shaders/Planetary.hlsl.meta delete mode 100644 src/CultMath/AdvancedErosionFilter.cs delete mode 100644 src/CultMath/AdvancedErosionFilter.cs.meta delete mode 100644 src/CultMath/ErosionFrequencyBands.cs delete mode 100644 src/CultMath/ErosionFrequencyBands.cs.meta delete mode 100644 src/CultMath/PlanetaryField.cs delete mode 100644 src/CultMath/PlanetaryField.cs.meta delete mode 100644 src/CultMath/PlanetaryGpuPages.cs delete mode 100644 src/CultMath/PlanetaryGpuPages.cs.meta delete mode 100644 src/CultMath/PlanetaryMapTileEncoding.cs delete mode 100644 src/CultMath/PlanetaryMapTileEncoding.cs.meta delete mode 100644 src/CultMath/PlanetaryPages.cs delete mode 100644 src/CultMath/PlanetaryPages.cs.meta delete mode 100644 src/CultMath/PlanetaryPatch.cs delete mode 100644 src/CultMath/PlanetaryPatch.cs.meta delete mode 100644 src/CultMath/PlanetaryProjection.cs delete mode 100644 src/CultMath/PlanetaryProjection.cs.meta delete mode 100644 src/CultMath/PlanetaryQueries.cs delete mode 100644 src/CultMath/PlanetaryQueries.cs.meta delete mode 100644 src/CultMath/PlanetaryRadialRefinement.cs delete mode 100644 src/CultMath/PlanetaryRadialRefinement.cs.meta delete mode 100644 src/CultMath/PlanetaryResidency.cs delete mode 100644 src/CultMath/PlanetaryResidency.cs.meta delete mode 100644 src/CultMath/PlanetaryTopology.cs delete mode 100644 src/CultMath/PlanetaryTopology.cs.meta delete mode 100644 src/CultMath/SphericalErosion.cs delete mode 100644 src/CultMath/SphericalErosion.cs.meta delete mode 100644 src/CultMath/rect.cs delete mode 100644 src/CultMath/rect.cs.meta delete mode 100644 tests/CultMath.Tests/AdvancedErosionFilterTests.cs delete mode 100644 tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta delete mode 100644 tests/CultMath.Tests/ErosionFrequencyBandsTests.cs delete mode 100644 tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryFieldTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryFieldTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryGpuPageTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryPageTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryPageTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryPatchTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryPatchTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryProjectionTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryQueryTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryQueryTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryResidencyTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta delete mode 100644 tests/CultMath.Tests/PlanetaryTopologyTests.cs delete mode 100644 tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta delete mode 100644 tests/CultMath.Tests/SphericalErosionTests.cs delete mode 100644 tests/CultMath.Tests/SphericalErosionTests.cs.meta delete mode 100644 tools/CultMath.PlanetaryTileFixture.meta delete mode 100644 unity/Runtime/PlanetaryPageUpload.cs delete mode 100644 unity/Runtime/PlanetaryPageUpload.cs.meta delete mode 100644 unity/Runtime/PlanetaryPatchMeshAdapter.cs delete mode 100644 unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta delete mode 100644 web/planetary-tile.cross-runtime.test.mjs delete mode 100644 web/planetary-tile.cross-runtime.test.mjs.meta delete mode 100644 web/planetary-tile.test.mjs delete mode 100644 web/planetary-tile.test.mjs.meta delete mode 100644 web/planetary-tile.ts delete mode 100644 web/planetary-tile.ts.meta diff --git a/CultMath.slnx b/CultMath.slnx index 05d17886..95834573 100644 --- a/CultMath.slnx +++ b/CultMath.slnx @@ -5,7 +5,4 @@ - - - diff --git a/README.md b/README.md index c60a9912..b471c7fd 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,6 @@ shader semantics. - `Voronoi.SampleTones`, a C# batch surface that calls the Rust `cultmath-core` native kernel when `cultmath_core` is available and falls back to the managed parity path otherwise. -- `PlanetaryField`, a scale-aware CPU/HLSL spherical field contract with - deterministic identity, cube-sphere topology, residual pages, LOD residency, - ray/path/region queries, coarse patches, and projected map-tile baking. See - `docs/planetary.md`. -- `CultMath.Unity`, an engine-facing assembly containing only Unity mesh and - page-upload adapters for the canonical planetary contracts. ## Native Core @@ -72,11 +66,6 @@ local `AetheriaMath` surface. The value-noise family follows public shader literature from Inigo Quilez's noise and mini-spline articles, expressed in CultMath names with tests instead of copied project-local helper drift. -The advanced erosion filter is an MPL-2.0 adaptation of Rune Skovbo Johansen's -published filter. Its faithful planar core is isolated in -`AdvancedErosionFilter.cs` and `AdvancedErosionFilter.hlsl`; spherical mapping -is a separate CultMath concern. See `THIRD-PARTY-NOTICES.md`. - ## Build ```powershell diff --git a/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef b/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef deleted file mode 100644 index 10512326..00000000 --- a/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "CultMath.PlanetaryViewer.Editor", - "rootNamespace": "CultMath.Unity.Samples.Editor", - "references": [ - "CultMath.PlanetaryViewer" - ], - "includePlatforms": [ - "Editor" - ], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} diff --git a/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs b/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs deleted file mode 100644 index ade28c30..00000000 --- a/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs +++ /dev/null @@ -1,26 +0,0 @@ -using UnityEditor; -using UnityEngine; - -namespace CultMath.Unity.Samples.Editor; - -public static class CultMathPlanetaryViewerMenu -{ - [MenuItem("GameObject/CultMath/Planetary Field Viewer", false, 10)] - public static void CreateViewer() - { - var root = new GameObject("CultMath Planetary Field Viewer"); - Undo.RegisterCreatedObjectUndo(root, "Create CultMath Planetary Viewer"); - root.AddComponent(); - Selection.activeGameObject = root; - - if (Camera.main != null) return; - var cameraObject = new GameObject("Planetary Viewer Camera"); - Undo.RegisterCreatedObjectUndo(cameraObject, "Create Planetary Viewer Camera"); - cameraObject.tag = "MainCamera"; - cameraObject.transform.position = new Vector3(0, 0, -24); - cameraObject.transform.LookAt(root.transform); - var camera = cameraObject.AddComponent(); - camera.nearClipPlane = 0.01f; - camera.farClipPlane = 1000; - } -} diff --git a/Samples~/PlanetaryViewer/README.md b/Samples~/PlanetaryViewer/README.md deleted file mode 100644 index ec4dcc3a..00000000 --- a/Samples~/PlanetaryViewer/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Planetary Field Viewer - -Import this sample through Unity's Package Manager, then choose -`GameObject > CultMath > Planetary Field Viewer`. - -The host project must contain `Assets/csc.rsp` with -`-langversion:latest`, as documented in the CultMath package README. - -The generated six-face mesh uses `PlanetaryPatch`. Its shader calls -`cultmath_planetary_field_sample` from the packaged CultMath HLSL, while the -component exposes the matching CPU `Sample` method. Seed, radius, physical -sample footprint, erosion settings, and base-field equations are shared between -the two bodies. - -This is a renderer integration fixture, not a gameplay owner. It deliberately -contains no Aetheria state, biomes, resources, or simulation decisions. diff --git a/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef b/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef deleted file mode 100644 index 2d3676f4..00000000 --- a/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "CultMath.PlanetaryViewer", - "rootNamespace": "CultMath.Unity.Samples", - "references": [ - "CultMath", - "CultMath.Unity" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} diff --git a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs deleted file mode 100644 index 06113728..00000000 --- a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; - -namespace CultMath.Unity.Samples; - -[ExecuteAlways] -public sealed class CultMathPlanetaryViewer : MonoBehaviour -{ - [Min(0.001f)] public float Radius = 8.4f; - [Range(1, 256)] public int CellsPerFace = 64; - public int Seed = 42; - [Min(0.0001f)] public float SampleFootprint = 0.01f; - public Shader? PlanetShader; - public Vector3 LightDirection = new(-0.4f, 0.7f, -0.6f); - public Color LowColor = new(0.055f, 0.16f, 0.09f, 1); - public Color HighColor = new(0.62f, 0.48f, 0.28f, 1); - public Color RidgeColor = new(0.88f, 0.83f, 0.7f, 1); - - private readonly List meshes = new(); - private Material? material; - private int builtCells; - - public PlanetaryFieldDefinition Definition => PlanetaryFieldDefinition.Create( - 1, - Radius, - Seed, - ViewerErosion(Radius)); - - public PlanetarySurfaceSample Sample(float3 direction) - { - var source = new ViewerBaseField(Radius); - var normalized = math.normalize(direction); - return PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), PlanetaryQueryScale.AtFootprint(SampleFootprint)); - } - - private void OnEnable() => Rebuild(); - - private void OnValidate() - { - Radius = Mathf.Max(Radius, 0.001f); - CellsPerFace = Mathf.Clamp(CellsPerFace, 1, 256); - SampleFootprint = Mathf.Max(SampleFootprint, 0.0001f); - if (isActiveAndEnabled) Rebuild(); - } - - private void Update() - { - if (builtCells != CellsPerFace || material == null) Rebuild(); - ApplyMaterial(); - } - - private void Rebuild() - { - ClearGenerated(); - PlanetShader ??= Shader.Find("CultMath/Planetary Field Viewer"); - if (PlanetShader == null) return; - material = new Material(PlanetShader) { name = "CultMath Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; - foreach (var face in (PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))) - { - var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh(face, CellsPerFace); - mesh.hideFlags = HideFlags.HideAndDontSave; - meshes.Add(mesh); - var child = new GameObject(face.ToString()) { hideFlags = HideFlags.DontSave }; - child.transform.SetParent(transform, false); - child.AddComponent().sharedMesh = mesh; - child.AddComponent().sharedMaterial = material; - } - builtCells = CellsPerFace; - ApplyMaterial(); - } - - private void ApplyMaterial() - { - if (material == null) return; - var p = ViewerErosion(Radius); - material.SetFloat("_Radius", Radius); - material.SetFloat("_SampleFootprint", SampleFootprint); - material.SetInt("_Seed", Seed); - material.SetFloat("_ErosionScale", p.Scale); - material.SetFloat("_ErosionStrength", p.Strength); - material.SetFloat("_GullyWeight", p.GullyWeight); - material.SetFloat("_Detail", p.Detail); - material.SetVector("_Rounding", new(p.Rounding.x, p.Rounding.y, p.Rounding.z, p.Rounding.w)); - material.SetVector("_Onset", new(p.Onset.x, p.Onset.y, p.Onset.z, p.Onset.w)); - material.SetVector("_AssumedSlope", new(p.AssumedSlope.x, p.AssumedSlope.y, 0, 0)); - material.SetFloat("_CellScale", p.CellScale); - material.SetFloat("_Normalization", p.Normalization); - material.SetInt("_Octaves", p.Octaves); - material.SetFloat("_Lacunarity", p.Lacunarity); - material.SetFloat("_Gain", p.Gain); - material.SetVector("_LightDirection", LightDirection.normalized); - material.SetColor("_LowColor", LowColor); - material.SetColor("_HighColor", HighColor); - material.SetColor("_RidgeColor", RidgeColor); - } - - private void OnDisable() => ClearGenerated(); - private void OnDestroy() => ClearGenerated(); - - private void ClearGenerated() - { - for (var i = transform.childCount - 1; i >= 0; i--) DestroyGenerated(transform.GetChild(i).gameObject); - foreach (var mesh in meshes) DestroyGenerated(mesh); - meshes.Clear(); - if (material != null) DestroyGenerated(material); - material = null; - builtCells = 0; - } - - private static void DestroyGenerated(UnityEngine.Object value) - { - if (Application.isPlaying) Destroy(value); - else DestroyImmediate(value); - } - - private static AdvancedErosionParameters ViewerErosion(float radius) => new( - radius * 0.075f, 0.12f, 0.58f, 1.45f, - new float4(0.1f, 0.015f, 0.1f, 2), - new float4(1.25f, 1.25f, 2.8f, 1.5f), - new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2, 0.5f); - - private readonly struct ViewerBaseField : IPlanetaryBaseField - { - private readonly float radius; - public ViewerBaseField(float radius) => this.radius = radius; - - public PlanetaryBaseFieldSample Sample(float3 direction) - { - var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; - var angularGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); - angularGradient -= direction * math.dot(angularGradient, direction); - var fieldGradient = angularGradient / radius; - var radialDisplacement = value * radius * 0.035f; - var radialGradient = angularGradient * 0.035f; - return new(radialDisplacement, radialGradient, value, fieldGradient, math.clamp(value, -1, 1)); - } - } -} diff --git a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader deleted file mode 100644 index b5f7274f..00000000 --- a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader +++ /dev/null @@ -1,90 +0,0 @@ -Shader "CultMath/Planetary Field Viewer" -{ - Properties - { - _LowColor ("Low Color", Color) = (0.055, 0.16, 0.09, 1) - _HighColor ("High Color", Color) = (0.62, 0.48, 0.28, 1) - _RidgeColor ("Ridge Color", Color) = (0.88, 0.83, 0.7, 1) - } - SubShader - { - Tags { "RenderType"="Opaque" "Queue"="Geometry" } - Pass - { - Cull Back - ZWrite On - HLSLPROGRAM - #pragma target 5.0 - #pragma vertex vert - #pragma fragment frag - #include "UnityCG.cginc" - #include "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" - - float _Radius; - float _SampleFootprint; - int _Seed; - float _ErosionScale, _ErosionStrength, _GullyWeight, _Detail; - float4 _Rounding, _Onset, _AssumedSlope; - float _CellScale, _Normalization, _Lacunarity, _Gain; - int _Octaves; - float3 _LightDirection; - float4 _LowColor, _HighColor, _RidgeColor; - - struct Attributes { float4 vertex : POSITION; }; - struct Varyings - { - float4 position : SV_POSITION; - float3 worldNormal : TEXCOORD0; - float height : TEXCOORD1; - float ridge : TEXCOORD2; - }; - - CultMathPlanetarySurfaceSample viewer_sample(float3 direction) - { - direction = normalize(direction); - float value = direction.x * 0.6 + direction.y * direction.z * 0.3; - float3 angular_gradient = float3(0.6, direction.z * 0.3, direction.y * 0.3); - angular_gradient -= direction * dot(angular_gradient, direction); - CultMathPlanetaryBaseFieldSample base_sample; - base_sample.radial_displacement = value * _Radius * 0.035; - base_sample.radial_gradient = angular_gradient * 0.035; - base_sample.field_value = value; - base_sample.field_gradient = angular_gradient / _Radius; - base_sample.fade_target = clamp(value, -1.0, 1.0); - CultMathAdvancedErosionParameters erosion; - erosion.scale = _ErosionScale; erosion.strength = _ErosionStrength; - erosion.gully_weight = _GullyWeight; erosion.detail = _Detail; - erosion.rounding = _Rounding; erosion.onset = _Onset; - erosion.assumed_slope = _AssumedSlope.xy; erosion.cell_scale = _CellScale; - erosion.normalization = _Normalization; erosion.octaves = _Octaves; - erosion.lacunarity = _Lacunarity; erosion.gain = _Gain; - CultMathPlanetaryFieldDefinition definition; - definition.radius = _Radius; definition.seed = _Seed; definition.erosion = erosion; - return cultmath_planetary_field_sample(definition, direction, base_sample, _SampleFootprint); - } - - Varyings vert(Attributes input) - { - float3 direction = normalize(input.vertex.xyz); - CultMathPlanetarySurfaceSample sample = viewer_sample(direction); - float3 local = direction * (_Radius + sample.radial_displacement); - Varyings output; - output.position = UnityObjectToClipPos(float4(local, 1)); - output.worldNormal = UnityObjectToWorldNormal(sample.surface_normal); - output.height = sample.radial_displacement / max(_Radius * 0.1, 1.0e-5); - output.ridge = sample.ridge; - return output; - } - - float4 frag(Varyings input) : SV_Target - { - float height = saturate(input.height * 0.5 + 0.5); - float3 albedo = lerp(_LowColor.rgb, _HighColor.rgb, height); - albedo = lerp(albedo, _RidgeColor.rgb, saturate((input.ridge - 0.55) * 2.5)); - float diffuse = saturate(dot(normalize(input.worldNormal), normalize(-_LightDirection))) * 0.82 + 0.08; - return float4(albedo * diffuse, 1); - } - ENDHLSL - } - } -} diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 54e280f6..c283df2b 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -9,26 +9,3 @@ Gustavson's `webgl-noise` project and Unity Mathematics. - Copyright 2011 Ashima Arts. - Upstream: - License: MIT. - -## Advanced Terrain Erosion Filter - -`src/CultMath/AdvancedErosionFilter.cs` and -`shaders/AdvancedErosionFilter.hlsl` adapt the mathematical procedure from -Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**: - -- Copyright 2025 Rune Skovbo Johansen. -- Upstream: -- Explanation: -- License: Mozilla Public License 2.0, - - -The CultMath files retain the MPL notice and separate the faithful planar -filter from CultMath's spherical adaptation. Modifications include C#/HLSL -type lowering, explicit bounded octave counts, finite zero-rounding behavior, -and CultMath naming. CultMath replaces the reference floating hash with a -shared integer avalanche hash so C# and GPU evaluation select identical cell -pivots across devices; this changes the stochastic pattern, not the filter -mechanics. - -The technique builds on earlier work credited by the upstream author to Fewes -and Clay John. The hash form follows the public shader math of Inigo Quilez. diff --git a/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj b/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj deleted file mode 100644 index 9a6f8bdd..00000000 --- a/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - Exe - net10.0 - enable - enable - - - - - diff --git a/Tools~/CultMath.PlanetaryTileFixture/Program.cs b/Tools~/CultMath.PlanetaryTileFixture/Program.cs deleted file mode 100644 index 1952fa2d..00000000 --- a/Tools~/CultMath.PlanetaryTileFixture/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CultMath; - -if (args.Length != 1) throw new ArgumentException("Expected one output path."); -var field = PlanetaryFieldDefinition.Create(0x55aa, 10, 7, AdvancedErosionParameters.Default); -var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 0, 0, 0, 5, 1); -var key = new PlanetaryMapTileKey(field.FieldVersion, 3, 29, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.125f)); -var tile = PlanetaryMapTileBaker.Bake(field, key, new FixtureField()); -var path = Path.GetFullPath(args[0]); -Directory.CreateDirectory(Path.GetDirectoryName(path)!); -using var stream = File.Create(path); -PlanetaryMapTileEncoding.Write(stream, tile); - -readonly struct FixtureField : IPlanetaryBaseField -{ - public PlanetaryBaseFieldSample Sample(float3 direction) - { - var value = direction.x * 0.2f + direction.z * 0.4f; - var gradient = new float3(0.2f, 0, 0.4f); - gradient -= direction * math.dot(gradient, direction); - return new(value, gradient, value, gradient * 0.1f, value); - } -} diff --git a/docs/design.md b/docs/design.md index f4ca5b49..8250ba78 100644 --- a/docs/design.md +++ b/docs/design.md @@ -19,12 +19,6 @@ Rust hot kernels, C# host code, and HLSL shader code. - Keep the surface boring and exact. Noise primitives are allowed when they are source-grounded, shader-shaped, and shared by more than one project. Matrices, quaternions, and packing helpers still need an owner before they enter. -- Planetary geometry is a scale-aware field query, not a renderer asset. CPU - simulation is the reference consumer; pages, patches, and maps are derived - lowerings carrying field identity and unresolved error. -- Application base fields and gameplay meaning enter through narrow inputs. - CultMath does not own biomes, resources, traversability, render materials, or - committed game consequences. ## Non-Goals diff --git a/docs/planetary.md b/docs/planetary.md deleted file mode 100644 index 3503429b..00000000 --- a/docs/planetary.md +++ /dev/null @@ -1,130 +0,0 @@ -# Planetary Field Runtime - -CultMath owns the deterministic, renderer-independent planetary field. The same -definition can be queried by a CPU simulation, baked into residual pages, -sampled through a map projection, or evaluated by HLSL. Render resources and -gameplay interpretation remain consumer responsibilities. - -## Authority - -- `PlanetaryFieldDefinition` identifies radius, seed, erosion parameters, and a - version that includes the consumer-owned base-field version. -- `IPlanetaryBaseField` supplies deterministic application-authored base - evidence without making CultMath depend on an application. -- `PlanetaryQueryScale` makes the physical footprint and accepted unresolved - error explicit. There is deliberately no scale-free authoritative - `HeightAt` call. -- `PlanetarySurfaceSample` carries displacement, tangent gradient, normal, - slope, ridge/gully evidence, resolved wavelength, and unresolved bound. -- pages, residency, patches, and map tiles are disposable lowerings of that - contract. - -The C# surface targets .NET and Unity-compatible `netstandard2.1`. The matching -HLSL lives in `shaders/Planetary.hlsl` and is included by `CultMath.hlsl`. - -## Point and batch queries - -```csharp -var erosion = AdvancedErosionParameters.Default with -{ - Scale = radius * 0.075f, - Strength = 0.12f, - Octaves = 7, -}; -var field = PlanetaryFieldDefinition.Create( - baseFieldVersion: 17, - radius, - seed: 42, - erosion); -var scale = PlanetaryQueryScale.AtFootprint( - footprintMeters: 10, - maximumUnresolvedHeight: 0.5f); - -PlanetaryBaseFieldSample basis = source.Sample(direction); -PlanetarySurfaceSample sample = PlanetaryField.Sample( - field, direction, basis, scale); -``` - -`PlanetaryField.SampleBatch` writes into a caller-owned span. `SamplePosition` -accepts `double3`, reduces the astronomical position to a stable unit direction, -and then evaluates the same float field contract. - -Higher-level CPU queries include bounded ray intersection, segment clearance, -great-circle profiles, and region summaries. Their answers remain field queries; -only a consuming simulation can turn them into committed game consequences. - -## Cube-sphere topology and pages - -`PlanetaryTopology` owns the tangent/QSC cube-sphere mapping, inverse face -coordinates, tile selection, local page coordinates, and radial-graph surface -normal. `PlanetaryPageSampling` owns bordered directions, nominal spacing, -bilinear sampling, and summaries. - -`PlanetaryPageBaker` evaluates a root page at its physical sample spacing. A -child stores `child filtered field - parent filtered field`. A -`PlanetaryPageSetSampling` query adds every containing ancestor contribution -and applies transition weights only to residuals. - -`PlanetaryLodSelector` selects six roots plus one camera-facing ancestor chain -from the unresolved-height error. `PlanetaryResidualResidency` owns arrival and -departure presentation weights, content and presentation versions, and nothing -about field truth. - -## Patches - -`PlanetaryPatch.CreateFace` and `CreateCubeSphere` produce renderer-neutral -coarse meshes from the canonical topology. HLSL calls -`cultmath_planetary_face_direction` for procedural vertices and -`cultmath_planetary_radial_refinement_step` for bounded pixel refinement. - -Renderers still own cameras, clip space, depth, materials, buffers, dispatch, -and draw submission. - -`PlanetaryGpuPageBuilder` owns the renderer-neutral page input and metadata -payload. Host adapters may convert its `float4` values to native vector types, -but do not recalculate directions, spacing, addressing, or transition state. - -The optional `CultMath.Unity` assembly converts a canonical face patch into a -Unity `Mesh` and converts the shared GPU payload into Unity-native structs. It -owns no field parameters, page selection, or simulation state. The package's -Planetary Field Viewer sample renders the shared HLSL field and reports matching -CPU queries. Unity render code remains responsible for allocating/binding -graphics buffers and dispatching application-specific base-field entry points. -The host Unity project must set `-langversion:latest` in `Assets/csc.rsp`. - -## Maps - -`PlanetaryProjection` supplies forward and inverse transforms for: - -- equirectangular; -- Web Mercator with an explicit polar cutoff; -- Equal Earth; -- orthographic; -- azimuthal equidistant and equal-area; -- cube atlas; -- local tangent/gnomonic views. - -`PlanetaryMapTileBaker` maps each valid tile sample through the inverse -projection and canonical field query. `PlanetaryMapTileKey` includes field, -projection, layer, layout, and query-scale identity. Palettes, contours, labels, -and strategic overlays do not enter CultMath. - -`PlanetaryMapTileEncoding` writes the resulting evidence as a versioned -little-endian `CMPT` binary asset. `web/planetary-tile.ts` decodes that exact -format into browser-native values without defining another terrain generator. -Typed CultMesh documents should carry the tile key and asset reference; the -binary payload is derived CDN/cache data, not committed game state. - -## Numeric contract - -Integer hashes and published field versions are exact. Basic arithmetic follows -CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as -`atan` and `tan` use measured tolerances rather than fictional bit identity. -Every filtered terrain query carries an unresolved-height bound so consumers can -reject insufficient fidelity explicitly. - -Current D3D12 parity evidence covers the advanced erosion kernel, QSC topology, -and forward/inverse transforms for every supported map projection, including -center and scale parameters. Production page and patch shaders use the common -functions and retain their seam, residual, summary, and radial-hit integration -probes in Fensalir. diff --git a/docs/planetary.md.meta b/docs/planetary.md.meta deleted file mode 100644 index af364c25..00000000 --- a/docs/planetary.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 23a5751e4cececc4e99e024058d69add -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/package.json b/package.json index 25643608..6da2a17a 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,5 @@ "Simulation" ], "license": "MPL-2.0", - "category": "Scripting", - "samples": [ - { - "displayName": "Planetary Field Viewer", - "description": "Runnable Unity viewer using the canonical CultMath CPU and HLSL planetary field.", - "path": "Samples~/PlanetaryViewer" - } - ] + "category": "Scripting" } diff --git a/scripts/test-web-planetary-tile.ps1 b/scripts/test-web-planetary-tile.ps1 deleted file mode 100644 index 5221fb36..00000000 --- a/scripts/test-web-planetary-tile.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -$ErrorActionPreference = "Stop" -$root = Split-Path -Parent $PSScriptRoot -$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt" -dotnet run --project (Join-Path $root "Tools~\CultMath.PlanetaryTileFixture\CultMath.PlanetaryTileFixture.csproj") -- $fixture -if ($LASTEXITCODE -ne 0) { throw "Planetary tile fixture generation failed." } -node --no-warnings --experimental-strip-types (Join-Path $root "web\planetary-tile.cross-runtime.test.mjs") $fixture -if ($LASTEXITCODE -ne 0) { throw "Planetary tile web decoding failed." } -node --no-warnings --experimental-strip-types (Join-Path $root "web\planetary-tile.test.mjs") -if ($LASTEXITCODE -ne 0) { throw "Planetary tile decoder unit test failed." } diff --git a/scripts/test-web-planetary-tile.ps1.meta b/scripts/test-web-planetary-tile.ps1.meta deleted file mode 100644 index ffd73234..00000000 --- a/scripts/test-web-planetary-tile.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 235e8f203bd1c4d40a8fa211273106ba -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/shaders/AdvancedErosionFilter.hlsl b/shaders/AdvancedErosionFilter.hlsl deleted file mode 100644 index a0a350e5..00000000 --- a/shaders/AdvancedErosionFilter.hlsl +++ /dev/null @@ -1,147 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this file, -// You can obtain one at https://mozilla.org/MPL/2.0/. -// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain -// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn - -#ifndef CULTMATH_ADVANCED_EROSION_FILTER_HLSL -#define CULTMATH_ADVANCED_EROSION_FILTER_HLSL - -struct CultMathAdvancedErosionParameters -{ - float scale; float strength; float gully_weight; float detail; - float4 rounding; float4 onset; float2 assumed_slope; - float cell_scale; float normalization; int octaves; float lacunarity; float gain; -}; - -struct CultMathAdvancedErosionResult -{ - float3 delta; float magnitude; float ridge_map; float fade_target; -}; - -struct CultMathErosionBandSelection -{ - int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound; -}; - -CultMathErosionBandSelection cultmath_select_erosion_bands(float base_wavelength, float sample_spacing, int maximum_octaves, float lacunarity, float base_amplitude, float gain, float transition_ratio) -{ - base_wavelength=max(base_wavelength,1.0e-6); sample_spacing=max(sample_spacing,0.0); lacunarity=max(lacunarity,1.01); transition_ratio=max(transition_ratio,1.01); maximum_octaves=min(max(maximum_octaves,0),16); - float nyquist=sample_spacing*2.0, wavelength=base_wavelength, final_weight=0.0, finest=0.0; int active=0; - [loop] for(int octave=0; octave=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); - } - CultMathErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; -} - -float cultmath_erosion_hash_unit(int2 cell, uint salt) -{ - uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; - value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; - return (value & 0x00ffffffu) * (1.0 / 16777216.0); -} -float2 cultmath_erosion_hash2(float2 input) -{ - int2 cell = (int2)input; - return float2(cultmath_erosion_hash_unit(cell, 0x68bc21ebu), cultmath_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; -} - -float4 cultmath_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) -{ - float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; - float offset = offset_cycles * 6.28318530718; - float2 cell = floor(position); - float2 local = frac(position); - float2 phase = 0.0; - float weight_sum = 0.0; - [unroll] for (int x = -1; x <= 2; x++) - [unroll] for (int y = -1; y <= 2; y++) - { - float2 grid_offset = float2(x, y); - float2 grid_point = cell + grid_offset; - float2 random_offset = cultmath_erosion_hash2(grid_point) * 0.5; - float2 delta = local - grid_offset - random_offset; - float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); - weight_sum += weight; - float wave = dot(delta, side_direction) + offset; - phase += float2(cos(wave), sin(wave)) * weight; - } - float2 interpolated = phase / max(weight_sum, 1.0e-20); - float magnitude = max(1.0 - saturate(normalization), length(interpolated)); - return float4(interpolated / max(magnitude, 1.0e-20), side_direction); -} - -float cultmath_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } -float cultmath_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } -float cultmath_erosion_smooth_start(float value, float smoothing) -{ - return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; -} -float2 cultmath_erosion_safe_normalize(float2 value) -{ - float value_length = length(value); - return abs(value_length) > 1.0e-10 ? value / value_length : value; -} - -CultMathAdvancedErosionResult cultmath_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p, CultMathErosionBandSelection band) -{ - float strength = p.strength * p.scale; - fade_target = clamp(fade_target, -1.0, 1.0); - float3 input_value = base_height_and_slope; - float3 height_and_slope = input_value; - float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); - float2 slope = height_and_slope.yz; - float slope_length = max(length(slope), 1.0e-10); - float magnitude = 0.0; - float rounding_multiplier = 1.0; - float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; - float combined_mask = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); - float ridge_mask = cultmath_erosion_ease_out(slope_length * p.onset.z); - float ridge_fade = fade_target; - float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); - - int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); - [loop] for (int octave = 0; octave < octave_count; octave++) - { - float4 phacelle = cultmath_phacelle_noise(position * frequency, cultmath_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); - float2 derivative_direction = phacelle.zw * -frequency; - float sloping = abs(phacelle.y); - gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; - float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); - float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); - float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; - height_and_slope += faded * strength * octave_weight; - magnitude += strength * octave_weight; - fade_target = faded.x; - float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; - float new_mask = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); - combined_mask = cultmath_erosion_pow_inverse(combined_mask, p.detail) * new_mask; - ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); - ridge_mask *= lerp(1.0, cultmath_erosion_ease_out(sloping * p.onset.w), octave_weight); - strength *= p.gain; - frequency *= p.lacunarity; - rounding_multiplier *= p.rounding.w; - } - - CultMathAdvancedErosionResult result; - result.delta = height_and_slope - input_value; - result.magnitude = magnitude; - result.ridge_map = ridge_fade * (1.0 - ridge_mask); - result.fade_target = fade_target; - return result; -} - -CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p) -{ - CultMathErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; - return cultmath_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); -} - -#endif diff --git a/shaders/AdvancedErosionFilter.hlsl.meta b/shaders/AdvancedErosionFilter.hlsl.meta deleted file mode 100644 index a354e330..00000000 --- a/shaders/AdvancedErosionFilter.hlsl.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 3252ac02e8853ef4b9d1fbc5204b5c6b -ShaderIncludeImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl index 84d244bb..4c00fb69 100644 --- a/shaders/CultMath.hlsl +++ b/shaders/CultMath.hlsl @@ -299,98 +299,4 @@ float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, Sampl return cultmath_catmullrom(row0, row1, row2, row3, local.y); } -#include "AdvancedErosionFilter.hlsl" -#include "Planetary.hlsl" - -struct CultMathSphericalErosionParameters -{ - float frequency; float amplitude; float lacunarity; float gain; - float slope_strength; float detail; float seed; int octaves; -}; - -struct CultMathSphericalErosionSample -{ - float height_offset; float ridge; float gully; float3 gradient; -}; - -float cultmath_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) -{ - float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); - return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; -} - -float cultmath_spherical_directional_wave(float3 position, float3 flow, float seed) -{ - float3 cell = floor(position); - float3 local = cultmath_frac(position); - float3 weights = local * local * (3.0 - 2.0 * local); - float z0 = cultmath_lerp( - cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), - cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); - float z1 = cultmath_lerp( - cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), - cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); - return cultmath_lerp(z0, z1, weights.z); -} - -float3 cultmath_spherical_stable_tangent(float3 position) -{ - float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); - return cultmath_normalize(cross(axis, position)); -} - -CultMathSphericalErosionSample cultmath_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, CultMathSphericalErosionParameters p) -{ - float3 position = cultmath_normalize(unit_position); - float3 gradient = base_gradient - position * dot(base_gradient, position); - float slope = length(gradient); - float3 flow = slope > 1.0e-6 ? -gradient / slope : cultmath_spherical_stable_tangent(position); - float frequency = max(p.frequency, 0.001); - float amplitude = p.amplitude; - float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); - float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; - CultMathSphericalErosionSample result = (CultMathSphericalErosionSample)0; - [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) - { - float phase = cultmath_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); - float wave = cos(phase); - float wave_slope = sin(phase); - float faded = cultmath_lerp(fade_target, wave, combi_mask); - result.height_offset += faded * amplitude; - result.ridge = max(result.ridge, cultmath_saturate(faded)); - result.gully = max(result.gully, cultmath_saturate(-faded)); - float3 across = cultmath_normalize(cross(position, flow)); - gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); - gradient -= position * dot(gradient, position); - slope = length(gradient); - flow = slope > 1.0e-6 ? -gradient / slope : cultmath_spherical_stable_tangent(position); - float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); - combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; - fade_target = faded; - frequency *= max(p.lacunarity, 1.01); - amplitude *= cultmath_saturate(p.gain); - } - result.gradient = gradient; - return result; -} - -float3 cultmath_planetary_radial_refinement_step( - float3 position, - float3 ray_direction, - float3 center, - float target_radius, - float minimum_derivative, - float maximum_correction) -{ - float3 local=position-center; - float radius=length(local); - float3 radial=local/max(radius,0.0001); - float error=radius-target_radius; - float derivative=dot(ray_direction,radial); - float safe_minimum=max(minimum_derivative,0.0001); - float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); - float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); - return position+ray_direction*correction; -} - #endif diff --git a/shaders/Planetary.hlsl b/shaders/Planetary.hlsl deleted file mode 100644 index 81e312b7..00000000 --- a/shaders/Planetary.hlsl +++ /dev/null @@ -1,392 +0,0 @@ -#ifndef CULTMATH_PLANETARY_HLSL -#define CULTMATH_PLANETARY_HLSL - -static const float CULTMATH_PLANETARY_QUARTER_PI = 0.78539816339744830962; - -float3 cultmath_planetary_face_direction(int face, float2 coordinate) -{ - float2 tangent = tan(coordinate * CULTMATH_PLANETARY_QUARTER_PI); - float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) - : face == 1 ? float3(-1.0, tangent.y, tangent.x) - : face == 2 ? float3(tangent.x, 1.0, -tangent.y) - : face == 3 ? float3(tangent.x, -1.0, tangent.y) - : face == 4 ? float3(tangent.x, tangent.y, 1.0) - : float3(-tangent.x, tangent.y, -1.0); - return cultmath_normalize(cube); -} - -float2 cultmath_planetary_face_coordinate(float3 direction, out int face) -{ - float3 d = cultmath_normalize(direction); - float3 a = abs(d); - float2 cube_uv; - if (a.x >= a.y && a.x >= a.z) - { - if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } - else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } - } - else if (a.y >= a.z) - { - if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } - else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } - } - else - { - if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } - else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } - } - return atan(cube_uv) / CULTMATH_PLANETARY_QUARTER_PI; -} - -bool cultmath_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) -{ - int face; - float2 uv = cultmath_planetary_face_coordinate(direction, face); - local = 0.0; - if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; - float axis_tiles = exp2(tile_address.y); - float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; - float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); - if (any(abs(canonical - tile_address.zw) > 0.25)) return false; - local = scaled - tile_address.zw; - return all(local >= 0.0) && all(local <= 1.0); -} - -float3 cultmath_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) -{ - direction = cultmath_normalize(direction); - float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); - return cultmath_normalize(direction - tangent_gradient); -} - -float2 cultmath_planetary_equirectangular_forward(float3 direction) -{ - direction = cultmath_normalize(direction); - return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); -} - -float3 cultmath_planetary_equirectangular_inverse(float2 coordinate) -{ - float longitude = coordinate.x * CULTMATH_PI; - float latitude = coordinate.y * CULTMATH_HALF_PI; - float cos_latitude = cos(latitude); - return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); -} - -static const float CULTMATH_EQUAL_EARTH_A1 = 1.340264; -static const float CULTMATH_EQUAL_EARTH_A2 = -0.081106; -static const float CULTMATH_EQUAL_EARTH_A3 = 0.000893; -static const float CULTMATH_EQUAL_EARTH_A4 = 0.003796; -static const float CULTMATH_EQUAL_EARTH_M = 0.8660254037844386; -static const float CULTMATH_EQUAL_EARTH_X_MAX = 2.706629; -static const float CULTMATH_EQUAL_EARTH_Y_MAX = 1.3173628; - -float cultmath_equal_earth_y(float theta) -{ - float theta2 = theta * theta; - float theta6 = theta2 * theta2 * theta2; - return theta * (CULTMATH_EQUAL_EARTH_A1 + CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (CULTMATH_EQUAL_EARTH_A3 + CULTMATH_EQUAL_EARTH_A4 * theta2)); -} - -float2 cultmath_planetary_equal_earth_forward(float3 direction) -{ - direction = cultmath_normalize(direction); - float longitude = atan2(direction.y, direction.x); - float latitude = asin(clamp(direction.z, -1.0, 1.0)); - float theta = asin(CULTMATH_EQUAL_EARTH_M * sin(latitude)); - float theta2 = theta * theta; - float theta6 = theta2 * theta2 * theta2; - float denominator = CULTMATH_EQUAL_EARTH_M * (CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2)); - return float2(longitude * cos(theta) / denominator / CULTMATH_EQUAL_EARTH_X_MAX, cultmath_equal_earth_y(theta) / CULTMATH_EQUAL_EARTH_Y_MAX); -} - -float3 cultmath_planetary_equal_earth_inverse(float2 coordinate) -{ - float target_y = coordinate.y * CULTMATH_EQUAL_EARTH_Y_MAX; - float theta = target_y / CULTMATH_EQUAL_EARTH_A1; - [unroll] for (int iteration = 0; iteration < 8; iteration++) - { - float theta2 = theta * theta; - float theta6 = theta2 * theta2 * theta2; - float derivative = CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2); - theta -= (cultmath_equal_earth_y(theta) - target_y) / derivative; - } - float latitude = asin(clamp(sin(theta) / CULTMATH_EQUAL_EARTH_M, -1.0, 1.0)); - float theta2 = theta * theta; - float theta6 = theta2 * theta2 * theta2; - float denominator = CULTMATH_EQUAL_EARTH_M * (CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2)); - float longitude = coordinate.x * CULTMATH_EQUAL_EARTH_X_MAX * denominator / cos(theta); - float cos_latitude = cos(latitude); - return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); -} - -float cultmath_planetary_wrap_longitude(float longitude) -{ - longitude = fmod(longitude, CULTMATH_TAU); - if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; - if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; - return longitude; -} - -float3 cultmath_planetary_lon_lat_direction(float longitude, float latitude) -{ - float cos_latitude = cos(latitude); - return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); -} - -float cultmath_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } -float cultmath_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } - -bool cultmath_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) -{ - direction = cultmath_normalize(direction); - float longitude = cultmath_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); - float latitude = asin(clamp(direction.z, -1.0, 1.0)); - float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); - float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); - float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); - float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); - float factor = 1.0; - if (kind == 3) - { - if (cos_c < 0.0) { coordinate = 0.0; return false; } - } - else if (kind == 4) - { - float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; - } - else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; - else - { - if (cos_c <= 0.0) { coordinate = 0.0; return false; } - factor = 1.0 / cos_c; - } - coordinate = raw * factor; - return true; -} - -bool cultmath_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) -{ - float rho = length(coordinate); - float c; - if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } - else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } - else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } - else c = atan(rho); - if (rho < 1.0e-6) { direction = cultmath_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } - float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); - float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); - float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); - direction = cultmath_planetary_lon_lat_direction(cultmath_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); - return true; -} - -bool cultmath_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) -{ - int face; - float2 face_coordinate = cultmath_planetary_face_coordinate(direction, face); - int column = face % 3; - int row = face / 3; - coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); - return true; -} - -bool cultmath_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) -{ - if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } - int column = min((int)((coordinate.x + 1.0) * 1.5), 2); - int row = min((int)(1.0 - coordinate.y), 1); - float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; - float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; - direction = cultmath_planetary_face_direction(row * 3 + column, float2(u, v)); - return true; -} - -bool cultmath_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) -{ - float scale = max(center_lon_lat_scale.z, 1.0e-20); - if (kind == 6) - { - bool valid = cultmath_planetary_cube_atlas_forward(direction, coordinate); - coordinate /= scale; return valid; - } - if (kind >= 3) - { - bool valid = cultmath_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); - coordinate /= scale; return valid; - } - direction = cultmath_normalize(direction); - float longitude = cultmath_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); - float latitude = asin(clamp(direction.z, -1.0, 1.0)); - if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); - else if (kind == 1) - { - const float maximum_latitude = 1.4844222297453324; - if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } - coordinate = float2(longitude / CULTMATH_PI, cultmath_planetary_asinh(tan(latitude)) / CULTMATH_PI); - } - else - { - float3 centered = cultmath_planetary_lon_lat_direction(longitude, latitude); - coordinate = cultmath_planetary_equal_earth_forward(centered); - } - coordinate /= scale; - return true; -} - -bool cultmath_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) -{ - coordinate *= max(center_lon_lat_scale.z, 1.0e-20); - if (kind == 6) return cultmath_planetary_cube_atlas_inverse(coordinate, direction); - if (kind >= 3) return cultmath_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); - if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } - float longitude, latitude; - if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } - else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(cultmath_planetary_sinh(coordinate.y * CULTMATH_PI)); } - else - { - float3 centered = cultmath_planetary_equal_earth_inverse(coordinate); - longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); - } - direction = cultmath_planetary_lon_lat_direction(cultmath_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); - return true; -} - -struct CultMathPlanetaryFieldDefinition -{ - float radius; - int seed; - CultMathAdvancedErosionParameters erosion; -}; - -struct CultMathPlanetaryBaseFieldSample -{ - float radial_displacement; - float3 radial_gradient; - float field_value; - float3 field_gradient; - float fade_target; -}; - -struct CultMathPlanetarySurfaceSample -{ - float radial_displacement; - float3 tangent_gradient; - float3 surface_normal; - float slope; - float ridge; - float gully; - float finest_resolved_wavelength; - float unresolved_height_bound; -}; - -struct CultMathPlanetaryPageSample -{ - float4 height_gradient; - float2 masks; -}; - -CultMathPlanetaryPageSample cultmath_planetary_page_lerp( - CultMathPlanetaryPageSample a, - CultMathPlanetaryPageSample b, - CultMathPlanetaryPageSample c, - CultMathPlanetaryPageSample d, - float2 fraction) -{ - CultMathPlanetaryPageSample result; - result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); - result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); - return result; -} - -CultMathPlanetaryPageSample cultmath_planetary_residual_sample( - CultMathPlanetarySurfaceSample child, - CultMathPlanetarySurfaceSample parent, - bool has_parent) -{ - CultMathPlanetaryPageSample result; - result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); - if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); - result.masks = float2(child.ridge, child.gully); - return result; -} - -float4 cultmath_planetary_summary_empty() -{ - return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); -} - -float4 cultmath_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) -{ - summary.x = min(summary.x, height_gradient.x); - summary.y = max(summary.y, height_gradient.x); - summary.z = max(summary.z, length(height_gradient.yzw)); - summary.w = max(summary.w, max(unresolved_height, 0.0)); - return summary; -} - -float cultmath_planetary_pow4(float value) { float square = value * value; return square * square; } - -CultMathPlanetarySurfaceSample cultmath_planetary_field_sample( - CultMathPlanetaryFieldDefinition definition, - float3 unit_direction, - CultMathPlanetaryBaseFieldSample base_sample, - float footprint_meters) -{ - float3 direction = cultmath_normalize(unit_direction); - float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); - float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); - CultMathAdvancedErosionParameters p = definition.erosion; - CultMathErosionBandSelection band = cultmath_select_erosion_bands( - p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, - p.strength * p.scale, p.gain, 2.0); - float3 powers = float3( - cultmath_planetary_pow4(abs(direction.x)), - cultmath_planetary_pow4(abs(direction.y)), - cultmath_planetary_pow4(abs(direction.z))); - float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); - float3 weights = powers / power_sum; - float3 world = direction * definition.radius; - float seed = (float)definition.seed; - CultMathAdvancedErosionResult xy = cultmath_advanced_erosion_filter_banded( - world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), - float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); - CultMathAdvancedErosionResult yz = cultmath_advanced_erosion_filter_banded( - world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), - float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); - CultMathAdvancedErosionResult zx = cultmath_advanced_erosion_filter_banded( - world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), - float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); - float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; - float3 erosion_gradient = float3( - xy.delta.y * weights.z + zx.delta.z * weights.y, - xy.delta.z * weights.z + yz.delta.y * weights.x, - yz.delta.z * weights.x + zx.delta.y * weights.y); - float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); - float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); - float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); - power_gradient_x -= direction * dot(power_gradient_x, direction); - power_gradient_y -= direction * dot(power_gradient_y, direction); - power_gradient_z -= direction * dot(power_gradient_z, direction); - float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; - float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); - float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; - float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; - float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; - erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; - float3 gradient = radial_gradient + erosion_gradient; - gradient -= direction * dot(gradient, direction); - float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; - CultMathPlanetarySurfaceSample result; - result.radial_displacement = base_sample.radial_displacement + erosion_height; - result.tangent_gradient = gradient; - result.surface_normal = cultmath_planetary_surface_normal(direction, gradient); - result.slope = length(gradient); - result.ridge = saturate(ridge_evidence * 0.5 + 0.5); - result.gully = saturate(0.5 - ridge_evidence * 0.5); - result.finest_resolved_wavelength = band.finest_included_wavelength; - result.unresolved_height_bound = band.unresolved_height_bound; - return result; -} - -#endif diff --git a/shaders/Planetary.hlsl.meta b/shaders/Planetary.hlsl.meta deleted file mode 100644 index f7771355..00000000 --- a/shaders/Planetary.hlsl.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: bb055ad5328888e479e90ae3fe62c3fe -ShaderIncludeImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/src/CultMath/AdvancedErosionFilter.cs b/src/CultMath/AdvancedErosionFilter.cs deleted file mode 100644 index 65db3671..00000000 --- a/src/CultMath/AdvancedErosionFilter.cs +++ /dev/null @@ -1,139 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this file, -// You can obtain one at https://mozilla.org/MPL/2.0/. -// -// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain -// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn - -namespace CultMath; - -public readonly record struct AdvancedErosionParameters( - float Scale, - float Strength, - float GullyWeight, - float Detail, - float4 Rounding, - float4 Onset, - float2 AssumedSlope, - float CellScale, - float Normalization, - int Octaves, - float Lacunarity, - float Gain) -{ - public static AdvancedErosionParameters Default => new( - 0.15f, 0.22f, 0.5f, 1.5f, - new float4(0.1f, 0.0f, 0.1f, 2.0f), - new float4(1.25f, 1.25f, 2.8f, 1.5f), - new float2(0.7f, 1.0f), - 0.7f, 0.5f, 5, 2.0f, 0.5f); -} - -public readonly record struct AdvancedErosionResult(float3 Delta, float Magnitude, float RidgeMap, float FadeTarget); - -public static class AdvancedErosionFilter -{ - public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p) - => Sample(position, baseHeightAndSlope, fadeTarget, p, new ErosionBandSelection(p.Octaves, 1.0f, 0.0f, 0.0f)); - - public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p, ErosionBandSelection band) - { - var strength = p.Strength * p.Scale; - fadeTarget = math.clamp(fadeTarget, -1.0f, 1.0f); - var input = baseHeightAndSlope; - var heightAndSlope = input; - var frequency = 1.0f / math.max(p.Scale * p.CellScale, 1.0e-10f); - var slope = new float2(heightAndSlope.y, heightAndSlope.z); - var slopeLength = math.max(math.length(slope), 1.0e-10f); - var magnitude = 0.0f; - var roundingMultiplier = 1.0f; - - var inputRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(fadeTarget + 0.5f)) * p.Rounding.z; - var combinedMask = EaseOut(SmoothStart(slopeLength * p.Onset.x, inputRounding * p.Onset.x)); - var ridgeMask = EaseOut(slopeLength * p.Onset.z); - var ridgeFade = fadeTarget; - var gullySlope = math.lerp(slope, slope / slopeLength * p.AssumedSlope.x, p.AssumedSlope.y); - - var octaves = math.min(math.min(math.max(p.Octaves, 0), 8), math.max(band.ActiveOctaves, 0)); - for (var octave = 0; octave < octaves; octave++) - { - var phacelle = Phacelle(position * frequency, SafeNormalize(gullySlope), p.CellScale, 0.25f, p.Normalization); - var derivativeDirection = new float2(phacelle.z, phacelle.w) * -frequency; - var sloping = math.abs(phacelle.y); - gullySlope += math.sign(phacelle.y) * derivativeDirection * strength * p.GullyWeight; - - var gullies = new float3(phacelle.x, phacelle.y * derivativeDirection.x, phacelle.y * derivativeDirection.y); - var faded = math.lerp(new float3(fadeTarget, 0.0f, 0.0f), gullies * p.GullyWeight, combinedMask); - var octaveWeight = octave == octaves - 1 ? math.saturate(band.FinalOctaveWeight) : 1.0f; - heightAndSlope += faded * strength * octaveWeight; - magnitude += strength * octaveWeight; - fadeTarget = faded.x; - - var octaveRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(phacelle.x + 0.5f)) * roundingMultiplier; - var newMask = EaseOut(SmoothStart(sloping * p.Onset.y, octaveRounding * p.Onset.y)); - combinedMask = PowInverse(combinedMask, p.Detail) * newMask; - - ridgeFade = math.lerp(ridgeFade, math.lerp(ridgeFade, gullies.x, ridgeMask), octaveWeight); - ridgeMask *= math.lerp(1.0f, EaseOut(sloping * p.Onset.w), octaveWeight); - strength *= p.Gain; - frequency *= p.Lacunarity; - roundingMultiplier *= p.Rounding.w; - } - - return new AdvancedErosionResult(heightAndSlope - input, magnitude, ridgeFade * (1.0f - ridgeMask), fadeTarget); - } - - public static float4 Phacelle(float2 position, float2 normalDirection, float frequency, float offsetCycles, float normalization) - { - var sideDirection = new float2(-normalDirection.y, normalDirection.x) * frequency * math.TAU; - var offset = offsetCycles * math.TAU; - var cell = math.floor(position); - var local = math.frac(position); - var phase = float2.zero; - var weightSum = 0.0f; - - for (var x = -1; x <= 2; x++) - for (var y = -1; y <= 2; y++) - { - var gridOffset = new float2(x, y); - var gridPoint = cell + gridOffset; - var randomOffset = Hash2(gridPoint) * 0.5f; - var delta = local - gridOffset - randomOffset; - var weight = math.max(0.0f, math.exp(-math.dot(delta, delta) * 2.0f) - 0.01111f); - weightSum += weight; - var wave = math.dot(delta, sideDirection) + offset; - phase += new float2(math.cos(wave), math.sin(wave)) * weight; - } - - var interpolated = phase / math.max(weightSum, 1.0e-20f); - var magnitude = math.max(1.0f - math.saturate(normalization), math.length(interpolated)); - var normalized = interpolated / math.max(magnitude, 1.0e-20f); - return new float4(normalized, sideDirection.x, sideDirection.y); - } - - private static float2 Hash2(float2 input) - { - var x = (int)input.x; - var y = (int)input.y; - return new float2(HashUnit(x, y, 0x68bc21ebu), HashUnit(x, y, 0x02e5be93u)) * 2.0f - float2.one; - } - - private static float HashUnit(int x, int y, uint salt) - { - var value = unchecked((uint)x * 0x8da6b343u ^ (uint)y * 0xd8163841u ^ salt); - value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; - return (value & 0x00ffffffu) * (1.0f / 16777216.0f); - } - - private static float2 SafeNormalize(float2 value) - { - var length = math.length(value); - return math.abs(length) > 1.0e-10f ? value / length : value; - } - - private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), power); - private static float EaseOut(float value) { var inverse = 1.0f - math.saturate(value); return 1.0f - inverse * inverse; } - private static float SmoothStart(float value, float smoothing) => smoothing <= 1.0e-10f || value >= smoothing - ? value - 0.5f * smoothing - : 0.5f * value * value / smoothing; -} diff --git a/src/CultMath/AdvancedErosionFilter.cs.meta b/src/CultMath/AdvancedErosionFilter.cs.meta deleted file mode 100644 index 52f7ca19..00000000 --- a/src/CultMath/AdvancedErosionFilter.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: bec15fdcbe97a7f44a24bdb3ff79e1e7 \ No newline at end of file diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index 61b57c98..01901b6b 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -19,8 +19,6 @@ - - diff --git a/src/CultMath/ErosionFrequencyBands.cs b/src/CultMath/ErosionFrequencyBands.cs deleted file mode 100644 index 3c63eb62..00000000 --- a/src/CultMath/ErosionFrequencyBands.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace CultMath; - -public readonly record struct ErosionBandSelection( - int ActiveOctaves, - float FinalOctaveWeight, - float FinestIncludedWavelength, - float UnresolvedHeightBound) -{ - public bool HasSamples => ActiveOctaves > 0; -} - -public static class ErosionFrequencyBands -{ - public static ErosionBandSelection Select( - float baseWavelength, - float sampleSpacing, - int maximumOctaves, - float lacunarity, - float baseAmplitude, - float gain, - float transitionRatio = 2.0f) - { - baseWavelength = math.max(baseWavelength, 1.0e-6f); - sampleSpacing = math.max(sampleSpacing, 0.0f); - lacunarity = math.max(lacunarity, 1.01f); - transitionRatio = math.max(transitionRatio, 1.01f); - maximumOctaves = math.min(math.max(maximumOctaves, 0), 16); - var nyquistWavelength = sampleSpacing * 2.0f; - var wavelength = baseWavelength; - var active = 0; - var finalWeight = 0.0f; - var finest = 0.0f; - - for (var octave = 0; octave < maximumOctaves; octave++) - { - var weight = nyquistWavelength <= 0.0f - ? 1.0f - : math.smoothstep(nyquistWavelength, nyquistWavelength * transitionRatio, wavelength); - if (weight <= 0.0f) break; - active++; - finalWeight = weight; - finest = wavelength; - wavelength /= lacunarity; - } - - var omittedAmplitude = 0.0f; - var amplitude = math.abs(baseAmplitude); - for (var octave = 0; octave < maximumOctaves; octave++) - { - if (octave >= active) omittedAmplitude += amplitude; - else if (octave == active - 1) omittedAmplitude += amplitude * (1.0f - finalWeight); - amplitude *= math.abs(gain); - } - - return new ErosionBandSelection(active, active > 0 ? finalWeight : 0.0f, finest, omittedAmplitude); - } -} diff --git a/src/CultMath/ErosionFrequencyBands.cs.meta b/src/CultMath/ErosionFrequencyBands.cs.meta deleted file mode 100644 index cae99643..00000000 --- a/src/CultMath/ErosionFrequencyBands.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 05e7969589dd09b43bd0fda672d25a0b \ No newline at end of file diff --git a/src/CultMath/PlanetaryField.cs b/src/CultMath/PlanetaryField.cs deleted file mode 100644 index 4d5596e5..00000000 --- a/src/CultMath/PlanetaryField.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System; - -namespace CultMath; - -public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight) -{ - public static PlanetaryQueryScale AtFootprint(float footprintMeters, float maximumUnresolvedHeight = float.PositiveInfinity) - => new PlanetaryQueryScale(footprintMeters, maximumUnresolvedHeight).Validate(); - - public PlanetaryQueryScale Validate() - { - if (!float.IsFinite(FootprintMeters) || FootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(FootprintMeters)); - if (float.IsNaN(MaximumUnresolvedHeight) || MaximumUnresolvedHeight < 0) throw new ArgumentOutOfRangeException(nameof(MaximumUnresolvedHeight)); - return this; - } -} - -public readonly record struct PlanetaryFieldDefinition( - ulong FieldVersion, - float Radius, - int Seed, - AdvancedErosionParameters Erosion) -{ - public static PlanetaryFieldDefinition Create( - ulong baseFieldVersion, - float radius, - int seed, - AdvancedErosionParameters erosion) - => new PlanetaryFieldDefinition(PlanetaryFieldIdentity.Compute(baseFieldVersion, radius, seed, erosion), radius, seed, erosion).Validate(); - - public PlanetaryFieldDefinition Validate() - { - if (FieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); - if (!float.IsFinite(Radius) || Radius <= 0) throw new ArgumentOutOfRangeException(nameof(Radius)); - if (!float.IsFinite(Erosion.Scale) || Erosion.Scale <= 0) throw new ArgumentOutOfRangeException(nameof(Erosion)); - return this; - } -} - -public static class PlanetaryFieldIdentity -{ - public static ulong Compute(ulong baseFieldVersion, float radius, int seed, in AdvancedErosionParameters erosion) - { - if (baseFieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(baseFieldVersion)); - const ulong offset = 14695981039346656037UL; - const ulong prime = 1099511628211UL; - var hash = offset; - Add(baseFieldVersion); - Add((uint)BitConverter.SingleToInt32Bits(radius)); Add((uint)seed); - Add((uint)BitConverter.SingleToInt32Bits(erosion.Scale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Strength)); - Add((uint)BitConverter.SingleToInt32Bits(erosion.GullyWeight)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Detail)); - AddFloat4(erosion.Rounding); AddFloat4(erosion.Onset); - Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.x)); Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.y)); - Add((uint)BitConverter.SingleToInt32Bits(erosion.CellScale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Normalization)); - Add((uint)erosion.Octaves); Add((uint)BitConverter.SingleToInt32Bits(erosion.Lacunarity)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Gain)); - return hash == 0 ? 1 : hash; - - void Add(ulong value) => hash = unchecked((hash ^ value) * prime); - void AddFloat4(float4 value) - { - Add((uint)BitConverter.SingleToInt32Bits(value.x)); Add((uint)BitConverter.SingleToInt32Bits(value.y)); - Add((uint)BitConverter.SingleToInt32Bits(value.z)); Add((uint)BitConverter.SingleToInt32Bits(value.w)); - } - } -} - -/// -/// Application-supplied base evidence at one unit spherical direction. -/// FieldValue and FieldGradient drive erosion. RadialDisplacement and -/// RadialGradient describe the uneroded surface in metres. -/// -public readonly record struct PlanetaryBaseFieldSample( - float RadialDisplacement, - float3 RadialGradient, - float FieldValue, - float3 FieldGradient, - float FadeTarget); - -public readonly record struct PlanetarySurfaceSample( - ulong FieldVersion, - float3 UnitDirection, - float Radius, - float RadialDisplacement, - float3 TangentGradient, - float3 SurfaceNormal, - float Slope, - float Ridge, - float Gully, - float FinestResolvedWavelength, - float UnresolvedHeightBound) -{ - public float SurfaceRadius => Radius + RadialDisplacement; - public float3 PlanetLocalPosition => UnitDirection * SurfaceRadius; - public bool Meets(PlanetaryQueryScale scale) => UnresolvedHeightBound <= scale.MaximumUnresolvedHeight; -} - -public static class PlanetaryField -{ - public static PlanetarySurfaceSample Sample( - in PlanetaryFieldDefinition definition, - float3 unitDirection, - in PlanetaryBaseFieldSample baseSample, - in PlanetaryQueryScale scale) - { - definition.Validate(); - scale.Validate(); - PlanetaryTopology.ValidateDirection(unitDirection); - PlanetaryTopology.ValidateFinite(baseSample.RadialGradient, nameof(baseSample)); - PlanetaryTopology.ValidateFinite(baseSample.FieldGradient, nameof(baseSample)); - if (!float.IsFinite(baseSample.RadialDisplacement) || !float.IsFinite(baseSample.FieldValue) || !float.IsFinite(baseSample.FadeTarget)) - throw new ArgumentOutOfRangeException(nameof(baseSample)); - - var direction = math.normalize(unitDirection); - var radialGradient = Tangent(direction, baseSample.RadialGradient); - var fieldGradient = Tangent(direction, baseSample.FieldGradient); - var p = definition.Erosion; - var band = ErosionFrequencyBands.Select( - p.Scale * p.CellScale, - scale.FootprintMeters, - p.Octaves, - p.Lacunarity, - p.Strength * p.Scale, - p.Gain); - - var powers = new float3( - Pow4(MathF.Abs(direction.x)), - Pow4(MathF.Abs(direction.y)), - Pow4(MathF.Abs(direction.z))); - var powerSum = math.max(powers.x + powers.y + powers.z, 1.0e-6f); - var weights = powers / powerSum; - var world = direction * definition.Radius; - var seed = definition.Seed; - var xy = AdvancedErosionFilter.Sample( - world.xy + new float2(713 + seed * 0.754877666f, -291 + seed * 0.569840296f), - new float3(baseSample.FieldValue, fieldGradient.x, fieldGradient.y), baseSample.FadeTarget, p, band); - var yz = AdvancedErosionFilter.Sample( - world.yz + new float2(-431 + seed * 0.438289027f, 887 + seed * 0.328438163f), - new float3(baseSample.FieldValue, fieldGradient.y, fieldGradient.z), baseSample.FadeTarget, p, band); - var zx = AdvancedErosionFilter.Sample( - new float2(world.z, world.x) + new float2(197 + seed * 0.219783071f, 557 + seed * 0.145898034f), - new float3(baseSample.FieldValue, fieldGradient.z, fieldGradient.x), baseSample.FadeTarget, p, band); - - var erosionHeight = xy.Delta.x * weights.z + yz.Delta.x * weights.x + zx.Delta.x * weights.y; - var erosionGradient = new float3( - xy.Delta.y * weights.z + zx.Delta.z * weights.y, - xy.Delta.z * weights.z + yz.Delta.y * weights.x, - yz.Delta.z * weights.x + zx.Delta.y * weights.y); - var powerGradientX = Tangent(direction, new float3(4 * direction.x * direction.x * direction.x / definition.Radius, 0, 0)); - var powerGradientY = Tangent(direction, new float3(0, 4 * direction.y * direction.y * direction.y / definition.Radius, 0)); - var powerGradientZ = Tangent(direction, new float3(0, 0, 4 * direction.z * direction.z * direction.z / definition.Radius)); - var sumGradient = powerGradientX + powerGradientY + powerGradientZ; - var weightGradientX = (powerGradientX * powerSum - sumGradient * powers.x) / (powerSum * powerSum); - var weightGradientY = (powerGradientY * powerSum - sumGradient * powers.y) / (powerSum * powerSum); - var weightGradientZ = (powerGradientZ * powerSum - sumGradient * powers.z) / (powerSum * powerSum); - erosionGradient += yz.Delta.x * weightGradientX + zx.Delta.x * weightGradientY + xy.Delta.x * weightGradientZ; - var gradient = Tangent(direction, radialGradient + erosionGradient); - var ridgeEvidence = xy.RidgeMap * weights.z + yz.RidgeMap * weights.x + zx.RidgeMap * weights.y; - var ridge = math.saturate(ridgeEvidence * 0.5f + 0.5f); - var gully = math.saturate(0.5f - ridgeEvidence * 0.5f); - var displacement = baseSample.RadialDisplacement + erosionHeight; - - return new( - definition.FieldVersion, - direction, - definition.Radius, - displacement, - gradient, - PlanetaryTopology.SurfaceNormal(direction, gradient), - math.length(gradient), - ridge, - gully, - band.FinestIncludedWavelength, - band.UnresolvedHeightBound); - } - - public static void SampleBatch( - in PlanetaryFieldDefinition definition, - ReadOnlySpan directions, - in PlanetaryQueryScale scale, - TSource source, - Span destination) - where TSource : IPlanetaryBaseField - { - if (destination.Length < directions.Length) throw new ArgumentException("Destination is shorter than directions.", nameof(destination)); - for (var i = 0; i < directions.Length; i++) - { - var direction = math.normalize(directions[i]); - var baseSample = source.Sample(direction); - destination[i] = Sample(definition, direction, baseSample, scale); - } - } - - public static PlanetarySurfaceSample SamplePosition( - in PlanetaryFieldDefinition definition, - double3 planetLocalPosition, - in PlanetaryQueryScale scale, - TSource source) - where TSource : IPlanetaryBaseField - { - if (!double.IsFinite(planetLocalPosition.x) || !double.IsFinite(planetLocalPosition.y) || !double.IsFinite(planetLocalPosition.z)) - throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); - var length = Math.Sqrt(planetLocalPosition.x * planetLocalPosition.x + planetLocalPosition.y * planetLocalPosition.y + planetLocalPosition.z * planetLocalPosition.z); - if (length < 1.0e-12) throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); - var direction = new float3((float)(planetLocalPosition.x / length), (float)(planetLocalPosition.y / length), (float)(planetLocalPosition.z / length)); - return Sample(definition, direction, source.Sample(direction), scale); - } - - private static float3 Tangent(float3 direction, float3 gradient) => gradient - direction * math.dot(gradient, direction); - private static float Pow4(float value) { var square = value * value; return square * square; } -} - -public interface IPlanetaryBaseField -{ - PlanetaryBaseFieldSample Sample(float3 unitDirection); -} diff --git a/src/CultMath/PlanetaryField.cs.meta b/src/CultMath/PlanetaryField.cs.meta deleted file mode 100644 index 3e83f637..00000000 --- a/src/CultMath/PlanetaryField.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b61d6354e3aab054e94721e77e619bfa \ No newline at end of file diff --git a/src/CultMath/PlanetaryGpuPages.cs b/src/CultMath/PlanetaryGpuPages.cs deleted file mode 100644 index 024d26b0..00000000 --- a/src/CultMath/PlanetaryGpuPages.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; - -namespace CultMath; - -public readonly record struct PlanetaryGpuPageInput(float4 DirectionRadius, float4 Sampling); - -public readonly record struct PlanetaryGpuPageMetadata(float4 Address, float4 Layout, float4 Bounds, float4 State); - -public readonly record struct PlanetaryGpuPageContent( - PlanetaryPageLayout Layout, - PlanetaryGpuPageInput[] Inputs, - float SampleSpacing, - float ParentSampleSpacing); - -public static class PlanetaryGpuPageBuilder -{ - public static PlanetaryGpuPageContent BuildContent(PlanetaryPageLayout layout, float radius) - { - layout.Validate(); - if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); - var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; - var parentSpacing = layout.Tile.Level == 0 - ? 0 - : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * radius; - var inputs = new PlanetaryGpuPageInput[layout.StorageSize * layout.StorageSize]; - for (var y = 0; y < layout.StorageSize; y++) - for (var x = 0; x < layout.StorageSize; x++) - { - var direction = PlanetaryPageSampling.Direction(layout, x, y); - inputs[y * layout.StorageSize + x] = new( - new float4(direction, radius), - new float4(spacing, parentSpacing, 0, 0)); - } - return new(layout, inputs, spacing, parentSpacing); - } - - public static PlanetaryGpuPageMetadata Metadata( - in PlanetaryGpuPageContent content, - int outputOffset, - float blend, - float4 bounds = default) - { - content.Layout.Validate(); - if (content.Inputs == null || content.Inputs.Length != content.Layout.StorageSize * content.Layout.StorageSize) - throw new ArgumentException("GPU page content does not match its layout.", nameof(content)); - if (outputOffset < 0) throw new ArgumentOutOfRangeException(nameof(outputOffset)); - if (!float.IsFinite(blend) || blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(blend)); - var tile = content.Layout.Tile; - return new( - new float4((int)tile.Face, tile.Level, tile.X, tile.Y), - new float4(outputOffset, content.Layout.StorageSize, content.Layout.InteriorSize, content.Layout.BorderSize), - bounds, - new float4(1, blend, PlanetaryPageSampling.NominalAngularTexelSize(content.Layout), content.SampleSpacing)); - } -} diff --git a/src/CultMath/PlanetaryGpuPages.cs.meta b/src/CultMath/PlanetaryGpuPages.cs.meta deleted file mode 100644 index 7afe847c..00000000 --- a/src/CultMath/PlanetaryGpuPages.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 42173c60766948a4e8c376687033532e \ No newline at end of file diff --git a/src/CultMath/PlanetaryMapTileEncoding.cs b/src/CultMath/PlanetaryMapTileEncoding.cs deleted file mode 100644 index ec556617..00000000 --- a/src/CultMath/PlanetaryMapTileEncoding.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.IO; -using System.Text; - -namespace CultMath; - -public static class PlanetaryMapTileEncoding -{ - public const uint FormatVersion = 1; - private static readonly byte[] Magic = Encoding.ASCII.GetBytes("CMPT"); - - public static void Write(Stream stream, in PlanetarySurfaceMapTile tile, bool leaveOpen = false) - { - if (stream == null || !stream.CanWrite) throw new ArgumentException("A writable stream is required.", nameof(stream)); - tile.Key.Validate(); - if (tile.Samples == null || tile.Validity == null || tile.Samples.Length != tile.Validity.Length) - throw new ArgumentException("Tile samples and validity must have equal lengths.", nameof(tile)); - var expectedCount = tile.Key.Layout.StorageSize * tile.Key.Layout.StorageSize; - if (tile.Samples.Length != expectedCount) throw new ArgumentException("Tile payload does not match its layout.", nameof(tile)); - - using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen); - writer.Write(Magic); - writer.Write(FormatVersion); - writer.Write(tile.Key.FieldVersion); - writer.Write(tile.Key.ProjectionVersion); - writer.Write(tile.Key.LayerId); - var projection = tile.Key.Layout.Projection; - writer.Write((int)projection.Kind); - writer.Write(projection.CenterLongitude); - writer.Write(projection.CenterLatitude); - writer.Write(projection.Scale); - writer.Write(tile.Key.Layout.Level); - writer.Write(tile.Key.Layout.X); - writer.Write(tile.Key.Layout.Y); - writer.Write(tile.Key.Layout.InteriorSize); - writer.Write(tile.Key.Layout.BorderSize); - writer.Write(tile.Key.QueryScale.FootprintMeters); - writer.Write(tile.Key.QueryScale.MaximumUnresolvedHeight); - writer.Write(tile.Samples.Length); - for (var i = 0; i < tile.Samples.Length; i++) - { - writer.Write(tile.Validity[i]); - if (!tile.Validity[i]) continue; - WriteSample(writer, tile.Samples[i], tile.Key.FieldVersion); - } - } - - public static PlanetarySurfaceMapTile Read(Stream stream, bool leaveOpen = false) - { - if (stream == null || !stream.CanRead) throw new ArgumentException("A readable stream is required.", nameof(stream)); - using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen); - var magic = reader.ReadBytes(Magic.Length); - if (magic.Length != Magic.Length || !magic.AsSpan().SequenceEqual(Magic)) throw new InvalidDataException("Not a CultMath planetary tile."); - var version = reader.ReadUInt32(); - if (version != FormatVersion) throw new InvalidDataException($"Unsupported planetary tile version {version}."); - var fieldVersion = reader.ReadUInt64(); - var projectionVersion = reader.ReadUInt32(); - var layerId = reader.ReadUInt32(); - var kindValue = reader.ReadInt32(); - if (!Enum.IsDefined(typeof(PlanetaryProjectionKind), kindValue)) throw new InvalidDataException("Unknown planetary projection kind."); - var projection = new PlanetaryProjectionParameters( - (PlanetaryProjectionKind)kindValue, - reader.ReadDouble(), reader.ReadDouble(), reader.ReadDouble()).Validate(); - var layout = new PlanetaryMapTileLayout( - projection, - reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), - reader.ReadInt32(), reader.ReadInt32()).Validate(); - var scale = new PlanetaryQueryScale(reader.ReadSingle(), reader.ReadSingle()).Validate(); - var key = new PlanetaryMapTileKey(fieldVersion, projectionVersion, layerId, layout, scale).Validate(); - var count = reader.ReadInt32(); - if (count != layout.StorageSize * layout.StorageSize) throw new InvalidDataException("Planetary tile sample count does not match layout."); - var samples = new PlanetarySurfaceSample[count]; - var validity = new bool[count]; - for (var i = 0; i < count; i++) - { - validity[i] = reader.ReadBoolean(); - if (validity[i]) samples[i] = ReadSample(reader, fieldVersion); - } - if (stream.CanSeek && stream.Position != stream.Length) throw new InvalidDataException("Planetary tile has trailing bytes."); - return new(key, samples, validity); - } - - private static void WriteSample(BinaryWriter writer, PlanetarySurfaceSample sample, ulong fieldVersion) - { - if (sample.FieldVersion != fieldVersion) throw new ArgumentException("Sample field version does not match tile identity."); - Write(writer, sample.UnitDirection); - writer.Write(sample.Radius); - writer.Write(sample.RadialDisplacement); - Write(writer, sample.TangentGradient); - Write(writer, sample.SurfaceNormal); - writer.Write(sample.Slope); - writer.Write(sample.Ridge); - writer.Write(sample.Gully); - writer.Write(sample.FinestResolvedWavelength); - writer.Write(sample.UnresolvedHeightBound); - } - - private static PlanetarySurfaceSample ReadSample(BinaryReader reader, ulong fieldVersion) - { - var direction = ReadFloat3(reader); - var radius = reader.ReadSingle(); - var displacement = reader.ReadSingle(); - var gradient = ReadFloat3(reader); - var normal = ReadFloat3(reader); - return new( - fieldVersion, direction, radius, displacement, gradient, normal, - reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), - reader.ReadSingle(), reader.ReadSingle()); - } - - private static void Write(BinaryWriter writer, float3 value) - { - writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); - } - - private static float3 ReadFloat3(BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); -} diff --git a/src/CultMath/PlanetaryMapTileEncoding.cs.meta b/src/CultMath/PlanetaryMapTileEncoding.cs.meta deleted file mode 100644 index 76484f65..00000000 --- a/src/CultMath/PlanetaryMapTileEncoding.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6898f6de57df20646a0b9077bb923592 \ No newline at end of file diff --git a/src/CultMath/PlanetaryPages.cs b/src/CultMath/PlanetaryPages.cs deleted file mode 100644 index 699a5c1d..00000000 --- a/src/CultMath/PlanetaryPages.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; - -namespace CultMath; - -public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize) -{ - public int StorageSize => checked(InteriorSize + BorderSize * 2); - - public PlanetaryPageLayout Validate() - { - if (InteriorSize < 2) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); - if (BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(BorderSize)); - return this; - } -} - -public readonly record struct PlanetaryPageSummary( - float MinimumHeight, - float MaximumHeight, - float MaximumSlope, - float UnresolvedHeightBound, - float AngularTexelSize); - -public readonly record struct PlanetaryPageSample(float Height, float3 TangentGradient, float Ridge, float Gully); - -public static class PlanetaryPageSampling -{ - public static float3 Direction(PlanetaryPageLayout page, int storageX, int storageY) - { - page.Validate(); - if ((uint)storageX >= (uint)page.StorageSize || (uint)storageY >= (uint)page.StorageSize) - throw new ArgumentOutOfRangeException(nameof(storageX)); - var localU = (storageX - page.BorderSize) / (double)(page.InteriorSize - 1); - var localV = (storageY - page.BorderSize) / (double)(page.InteriorSize - 1); - return DirectionAtLocal(page, localU, localV); - } - - public static float3 DirectionAtLocal(PlanetaryPageLayout page, double localU, double localV) - { - page.Validate(); - if (!double.IsFinite(localU) || !double.IsFinite(localV)) throw new ArgumentOutOfRangeException(nameof(localU)); - var count = page.Tile.AxisTileCount; - return PlanetaryTopology.Direction(new( - page.Tile.Face, - -1.0 + 2.0 * (page.Tile.X + localU) / count, - -1.0 + 2.0 * (page.Tile.Y + localV) / count)); - } - - public static float AngularTexelSize(PlanetaryPageLayout page) - { - var center = page.BorderSize + (page.InteriorSize - 1) / 2; - var a = Direction(page, center, center); - var b = Direction(page, Math.Min(center + 1, page.StorageSize - 1), center); - return MathF.Acos(math.clamp(math.dot(a, b), -1.0f, 1.0f)); - } - - public static float NominalAngularTexelSize(PlanetaryPageLayout page) - { - page.Validate(); - return MathF.PI * 0.5f / (page.Tile.AxisTileCount * (page.InteriorSize - 1)); - } - - public static PlanetaryPageSample Bilinear( - ReadOnlySpan samples, - PlanetaryPageLayout page, - float2 local) - { - page.Validate(); - if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); - var texel = local * (page.InteriorSize - 1) + page.BorderSize; - var x0 = Math.Clamp((int)MathF.Floor(texel.x), 0, page.StorageSize - 1); - var y0 = Math.Clamp((int)MathF.Floor(texel.y), 0, page.StorageSize - 1); - var x1 = Math.Min(x0 + 1, page.StorageSize - 1); - var y1 = Math.Min(y0 + 1, page.StorageSize - 1); - var tx = math.frac(texel.x); - var ty = math.frac(texel.y); - var a = Lerp(samples[y0 * page.StorageSize + x0], samples[y0 * page.StorageSize + x1], tx); - var b = Lerp(samples[y1 * page.StorageSize + x0], samples[y1 * page.StorageSize + x1], tx); - return Lerp(a, b, ty); - } - - public static PlanetaryPageSummary Summarize(ReadOnlySpan samples, PlanetaryPageLayout page, float unresolvedHeightBound) - { - page.Validate(); - if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); - var min = float.PositiveInfinity; - var max = float.NegativeInfinity; - var maxSlope = 0.0f; - foreach (var sample in samples) - { - min = MathF.Min(min, sample.Height); - max = MathF.Max(max, sample.Height); - maxSlope = MathF.Max(maxSlope, math.length(sample.TangentGradient)); - } - return new(min, max, maxSlope, MathF.Max(unresolvedHeightBound, 0), AngularTexelSize(page)); - } - - private static PlanetaryPageSample Lerp(PlanetaryPageSample a, PlanetaryPageSample b, float t) => new( - math.lerp(a.Height, b.Height, t), - math.lerp(a.TangentGradient, b.TangentGradient, t), - math.lerp(a.Ridge, b.Ridge, t), - math.lerp(a.Gully, b.Gully, t)); -} - -public readonly record struct PlanetaryBakedPage( - PlanetaryPageLayout Layout, - PlanetaryPageSample[] Samples, - PlanetaryPageSummary Summary); - -public static class PlanetaryPageBaker -{ - public static PlanetaryBakedPage Bake( - in PlanetaryFieldDefinition field, - PlanetaryPageLayout layout, - TSource source) - where TSource : IPlanetaryBaseField - { - layout.Validate(); field.Validate(); - var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * field.Radius; - var parentSpacing = layout.Tile.Level == 0 - ? 0 - : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * field.Radius; - var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; - var unresolved = 0.0f; - for (var y = 0; y < layout.StorageSize; y++) - for (var x = 0; x < layout.StorageSize; x++) - { - var direction = PlanetaryPageSampling.Direction(layout, x, y); - var baseSample = source.Sample(direction); - var child = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(spacing)); - var height = child.RadialDisplacement; - var gradient = child.TangentGradient; - if (parentSpacing > 0) - { - var parent = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(parentSpacing)); - height -= parent.RadialDisplacement; - gradient -= parent.TangentGradient; - } - samples[y * layout.StorageSize + x] = new(height, gradient, child.Ridge, child.Gully); - unresolved = MathF.Max(unresolved, child.UnresolvedHeightBound); - } - return new(layout, samples, PlanetaryPageSampling.Summarize(samples, layout, unresolved)); - } -} - -public readonly record struct PlanetaryResidentPage(PlanetaryBakedPage Page, float Blend) -{ - public PlanetaryResidentPage Validate() - { - if (!float.IsFinite(Blend) || Blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(Blend)); - Page.Layout.Validate(); - if (Page.Samples.Length != Page.Layout.StorageSize * Page.Layout.StorageSize) throw new ArgumentException("Page payload does not match layout."); - return this; - } -} - -public static class PlanetaryPageSetSampling -{ - public static bool TrySample(ReadOnlySpan pages, float3 direction, out PlanetaryPageSample sample) - { - PlanetaryTopology.ValidateDirection(direction); - var height = 0.0f; - var gradient = float3.zero; - var ridge = 0.0f; - var gully = 0.0f; - var found = false; - var deepestLevel = -1; - foreach (var resident in pages) - { - resident.Validate(); - if (!PlanetaryTopology.TryLocalCoordinate(direction, resident.Page.Layout.Tile, out var local)) continue; - var contribution = PlanetaryPageSampling.Bilinear(resident.Page.Samples, resident.Page.Layout, local); - var blend = math.saturate(resident.Blend); - height += contribution.Height * blend; - gradient += contribution.TangentGradient * blend; - if (resident.Page.Layout.Tile.Level >= deepestLevel) - { - ridge = found ? math.lerp(ridge, contribution.Ridge, blend) : contribution.Ridge; - gully = found ? math.lerp(gully, contribution.Gully, blend) : contribution.Gully; - deepestLevel = resident.Page.Layout.Tile.Level; - } - found = true; - } - sample = new(height, gradient, ridge, gully); - return found; - } -} diff --git a/src/CultMath/PlanetaryPages.cs.meta b/src/CultMath/PlanetaryPages.cs.meta deleted file mode 100644 index 91fdc27d..00000000 --- a/src/CultMath/PlanetaryPages.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7af9a0354faa56348a2e07188739a0ae \ No newline at end of file diff --git a/src/CultMath/PlanetaryPatch.cs b/src/CultMath/PlanetaryPatch.cs deleted file mode 100644 index 30c852ff..00000000 --- a/src/CultMath/PlanetaryPatch.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Linq; - -namespace CultMath; - -public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection); - -public readonly record struct PlanetaryPatchMesh( - PlanetaryCubeFace Face, - int CellsPerAxis, - PlanetaryPatchVertex[] Vertices, - int[] Indices); - -public static class PlanetaryPatch -{ - public static PlanetaryPatchMesh CreateFace(PlanetaryCubeFace face, int cellsPerAxis) - { - if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); - if (cellsPerAxis < 1 || cellsPerAxis > 4096) throw new ArgumentOutOfRangeException(nameof(cellsPerAxis)); - var axisVertices = cellsPerAxis + 1; - var vertices = new PlanetaryPatchVertex[checked(axisVertices * axisVertices)]; - for (var y = 0; y < axisVertices; y++) - for (var x = 0; x < axisVertices; x++) - { - var local = new float2(x / (float)cellsPerAxis, y / (float)cellsPerAxis); - vertices[y * axisVertices + x] = new(local, PlanetaryTopology.Direction(new(face, local.x * 2 - 1, local.y * 2 - 1))); - } - var indices = new int[checked(cellsPerAxis * cellsPerAxis * 6)]; - var write = 0; - for (var y = 0; y < cellsPerAxis; y++) - for (var x = 0; x < cellsPerAxis; x++) - { - var lowerLeft = y * axisVertices + x; - var lowerRight = lowerLeft + 1; - var upperLeft = lowerLeft + axisVertices; - var upperRight = upperLeft + 1; - indices[write++] = lowerLeft; indices[write++] = lowerRight; indices[write++] = upperRight; - indices[write++] = lowerLeft; indices[write++] = upperRight; indices[write++] = upperLeft; - } - return new(face, cellsPerAxis, vertices, indices); - } - - public static PlanetaryPatchMesh[] CreateCubeSphere(int cellsPerAxis) - => ((PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))).Select(face => CreateFace(face, cellsPerAxis)).ToArray(); -} diff --git a/src/CultMath/PlanetaryPatch.cs.meta b/src/CultMath/PlanetaryPatch.cs.meta deleted file mode 100644 index efd8156a..00000000 --- a/src/CultMath/PlanetaryPatch.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 04651571f7d240a418c1f6aafa217fa6 \ No newline at end of file diff --git a/src/CultMath/PlanetaryProjection.cs b/src/CultMath/PlanetaryProjection.cs deleted file mode 100644 index a82ebfdb..00000000 --- a/src/CultMath/PlanetaryProjection.cs +++ /dev/null @@ -1,322 +0,0 @@ -using System; - -namespace CultMath; - -public enum PlanetaryProjectionKind -{ - Equirectangular, - WebMercator, - EqualEarth, - Orthographic, - AzimuthalEquidistant, - AzimuthalEqualArea, - CubeAtlas, - LocalTangent, -} - -public readonly record struct PlanetaryProjectionParameters( - PlanetaryProjectionKind Kind, - double CenterLongitude = 0, - double CenterLatitude = 0, - double Scale = 1) -{ - public PlanetaryProjectionParameters Validate() - { - if (!double.IsFinite(CenterLongitude) || !double.IsFinite(CenterLatitude) || !double.IsFinite(Scale) || Scale <= 0) - throw new ArgumentOutOfRangeException(nameof(Scale)); - if (CenterLatitude is < -Math.PI / 2 or > Math.PI / 2) throw new ArgumentOutOfRangeException(nameof(CenterLatitude)); - return this; - } -} - -public static class PlanetaryProjection -{ - private const double EqualEarthA1 = 1.340264; - private const double EqualEarthA2 = -0.081106; - private const double EqualEarthA3 = 0.000893; - private const double EqualEarthA4 = 0.003796; - private static readonly double EqualEarthM = Math.Sqrt(3) / 2; - private static readonly double EqualEarthXMax = Math.PI / (EqualEarthM * EqualEarthA1); - private static readonly double EqualEarthYMax = EqualEarthY(Math.Asin(EqualEarthM)); - public static readonly double WebMercatorMaximumLatitude = Math.Atan(Math.Sinh(Math.PI)); - - public static bool TryForward(float3 direction, PlanetaryProjectionParameters parameters, out double2 coordinate) - { - parameters.Validate(); - PlanetaryTopology.ValidateDirection(direction); - var d = math.normalize(direction); - if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) - { - var face = PlanetaryTopology.FaceCoordinate(d); - var index = (int)face.Face; - var column = index % 3; - var row = index / 3; - coordinate = new( - (-1 + (column + (face.U + 1) * 0.5) * (2.0 / 3.0)) / parameters.Scale, - (1 - (row + (face.V + 1) * 0.5)) / parameters.Scale); - return true; - } - - var longitude = Math.Atan2(d.y, d.x); - var latitude = Math.Asin(Math.Clamp(d.z, -1, 1)); - var deltaLongitude = WrapLongitude(longitude - parameters.CenterLongitude); - var centered = CenteredForward(deltaLongitude, latitude, parameters, out coordinate); - coordinate /= parameters.Scale; - return centered; - } - - public static bool TryInverse(double2 coordinate, PlanetaryProjectionParameters parameters, out float3 direction) - { - parameters.Validate(); - if (!double.IsFinite(coordinate.x) || !double.IsFinite(coordinate.y)) - { - direction = float3.zero; - return false; - } - coordinate *= parameters.Scale; - if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) - return TryInverseCubeAtlas(coordinate, out direction); - - if (!CenteredInverse(coordinate, parameters, out var deltaLongitude, out var latitude)) - { - direction = float3.zero; - return false; - } - var longitude = WrapLongitude(deltaLongitude + parameters.CenterLongitude); - var cosLatitude = Math.Cos(latitude); - direction = math.normalize(new float3( - (float)(cosLatitude * Math.Cos(longitude)), - (float)(cosLatitude * Math.Sin(longitude)), - (float)Math.Sin(latitude))); - return true; - } - - private static bool CenteredForward(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) - { - switch (p.Kind) - { - case PlanetaryProjectionKind.Equirectangular: - coordinate = new(longitude / Math.PI, latitude / (Math.PI * 0.5)); - return true; - case PlanetaryProjectionKind.WebMercator: - if (Math.Abs(latitude) > WebMercatorMaximumLatitude) { coordinate = double2.zero; return false; } - coordinate = new(longitude / Math.PI, Math.Asinh(Math.Tan(latitude)) / Math.PI); - return true; - case PlanetaryProjectionKind.EqualEarth: - { - var theta = Math.Asin(EqualEarthM * Math.Sin(latitude)); - var theta2 = theta * theta; - var theta6 = theta2 * theta2 * theta2; - var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2)); - coordinate = new(longitude * Math.Cos(theta) / denominator / EqualEarthXMax, EqualEarthY(theta) / EqualEarthYMax); - return true; - } - case PlanetaryProjectionKind.Orthographic: - case PlanetaryProjectionKind.AzimuthalEquidistant: - case PlanetaryProjectionKind.AzimuthalEqualArea: - case PlanetaryProjectionKind.LocalTangent: - return ForwardAzimuthal(longitude, latitude, p, out coordinate); - default: - throw new ArgumentOutOfRangeException(nameof(p)); - } - } - - private static bool CenteredInverse(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) - { - longitude = latitude = 0; - switch (p.Kind) - { - case PlanetaryProjectionKind.Equirectangular: - if (Math.Abs(coordinate.y) > 1) return false; - longitude = coordinate.x * Math.PI; latitude = coordinate.y * Math.PI * 0.5; return Math.Abs(coordinate.x) <= 1; - case PlanetaryProjectionKind.WebMercator: - if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; - longitude = coordinate.x * Math.PI; latitude = Math.Atan(Math.Sinh(coordinate.y * Math.PI)); return true; - case PlanetaryProjectionKind.EqualEarth: - { - if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; - var targetY = coordinate.y * EqualEarthYMax; - var theta = targetY / EqualEarthA1; - for (var i = 0; i < 8; i++) - { - var theta2 = theta * theta; - var theta6 = theta2 * theta2 * theta2; - var f = EqualEarthY(theta) - targetY; - var derivative = EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2); - theta -= f / derivative; - } - var sinLatitude = Math.Sin(theta) / EqualEarthM; - if (Math.Abs(sinLatitude) > 1.0000000001) return false; - latitude = Math.Asin(Math.Clamp(sinLatitude, -1, 1)); - var thetaSquared = theta * theta; - var thetaSixth = thetaSquared * thetaSquared * thetaSquared; - var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * thetaSquared + thetaSixth * (7 * EqualEarthA3 + 9 * EqualEarthA4 * thetaSquared)); - longitude = coordinate.x * EqualEarthXMax * denominator / Math.Cos(theta); - return Math.Abs(longitude) <= Math.PI + 1.0e-10; - } - case PlanetaryProjectionKind.Orthographic: - case PlanetaryProjectionKind.AzimuthalEquidistant: - case PlanetaryProjectionKind.AzimuthalEqualArea: - case PlanetaryProjectionKind.LocalTangent: - return InverseAzimuthal(coordinate, p, out longitude, out latitude); - default: - throw new ArgumentOutOfRangeException(nameof(p)); - } - } - - private static bool ForwardAzimuthal(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) - { - var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); - var sin = Math.Sin(latitude); var cos = Math.Cos(latitude); var cosLon = Math.Cos(longitude); - var cosC = Math.Clamp(sin0 * sin + cos0 * cos * cosLon, -1, 1); - var rawX = cos * Math.Sin(longitude); - var rawY = cos0 * sin - sin0 * cos * cosLon; - double factor; - switch (p.Kind) - { - case PlanetaryProjectionKind.Orthographic: - if (cosC < 0) { coordinate = double2.zero; return false; } - factor = 1; break; - case PlanetaryProjectionKind.AzimuthalEquidistant: - { - var c = Math.Acos(cosC); factor = c < 1.0e-12 ? 1 : c / Math.Sin(c); factor /= Math.PI; break; - } - case PlanetaryProjectionKind.AzimuthalEqualArea: - factor = Math.Sqrt(2 / Math.Max(1 + cosC, 1.0e-20)) * 0.5; break; - case PlanetaryProjectionKind.LocalTangent: - if (cosC <= 0) { coordinate = double2.zero; return false; } - factor = 1 / cosC; break; - default: throw new ArgumentOutOfRangeException(nameof(p)); - } - coordinate = new(rawX * factor, rawY * factor); - return true; - } - - private static bool InverseAzimuthal(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) - { - longitude = latitude = 0; - var rho = Math.Sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y); - double c; - switch (p.Kind) - { - case PlanetaryProjectionKind.Orthographic: if (rho > 1) return false; c = Math.Asin(Math.Clamp(rho, 0, 1)); break; - case PlanetaryProjectionKind.AzimuthalEquidistant: if (rho > 1) return false; c = rho * Math.PI; break; - case PlanetaryProjectionKind.AzimuthalEqualArea: if (rho > 1) return false; c = 2 * Math.Asin(Math.Clamp(rho, 0, 1)); break; - case PlanetaryProjectionKind.LocalTangent: c = Math.Atan(rho); break; - default: throw new ArgumentOutOfRangeException(nameof(p)); - } - if (rho < 1.0e-12) { latitude = p.CenterLatitude; return true; } - var sinC = Math.Sin(c); var cosC = Math.Cos(c); - var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); - latitude = Math.Asin(Math.Clamp(cosC * sin0 + coordinate.y * sinC * cos0 / rho, -1, 1)); - longitude = Math.Atan2(coordinate.x * sinC, rho * cos0 * cosC - coordinate.y * sin0 * sinC); - return true; - } - - private static bool TryInverseCubeAtlas(double2 coordinate, out float3 direction) - { - direction = float3.zero; - if (coordinate.x is < -1 or > 1 || coordinate.y is < -1 or > 1) return false; - var column = Math.Min((int)((coordinate.x + 1) * 1.5), 2); - var row = Math.Min((int)((1 - coordinate.y)), 1); - var u = ((coordinate.x + 1) * 1.5 - column) * 2 - 1; - var v = ((1 - coordinate.y) - row) * 2 - 1; - direction = PlanetaryTopology.Direction(new((PlanetaryCubeFace)(row * 3 + column), u, v)); - return true; - } - - private static double EqualEarthY(double theta) - { - var theta2 = theta * theta; - var theta6 = theta2 * theta2 * theta2; - return theta * (EqualEarthA1 + EqualEarthA2 * theta2 + theta6 * (EqualEarthA3 + EqualEarthA4 * theta2)); - } - - private static double WrapLongitude(double longitude) - { - longitude %= Math.PI * 2; - if (longitude > Math.PI) longitude -= Math.PI * 2; - if (longitude < -Math.PI) longitude += Math.PI * 2; - return longitude; - } -} - -public readonly record struct PlanetaryMapTileLayout( - PlanetaryProjectionParameters Projection, - int Level, - int X, - int Y, - int InteriorSize, - int BorderSize) -{ - public int StorageSize => checked(InteriorSize + BorderSize * 2); - - public PlanetaryMapTileLayout Validate() - { - Projection.Validate(); - if (Level is < 0 or > 30) throw new ArgumentOutOfRangeException(nameof(Level)); - var count = 1 << Level; - if ((uint)X >= (uint)count || (uint)Y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(X)); - if (InteriorSize < 2 || BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); - return this; - } -} - -public static class PlanetaryMapTileSampling -{ - public static bool TryDirection(PlanetaryMapTileLayout tile, int storageX, int storageY, out float3 direction) - { - tile.Validate(); - if ((uint)storageX >= (uint)tile.StorageSize || (uint)storageY >= (uint)tile.StorageSize) throw new ArgumentOutOfRangeException(nameof(storageX)); - var count = 1 << tile.Level; - var localU = (storageX - tile.BorderSize) / (double)(tile.InteriorSize - 1); - var localV = (storageY - tile.BorderSize) / (double)(tile.InteriorSize - 1); - var map = new double2(-1 + 2 * (tile.X + localU) / count, 1 - 2 * (tile.Y + localV) / count); - return PlanetaryProjection.TryInverse(map, tile.Projection, out direction); - } -} - -public readonly record struct PlanetaryMapTileKey( - ulong FieldVersion, - uint ProjectionVersion, - uint LayerId, - PlanetaryMapTileLayout Layout, - PlanetaryQueryScale QueryScale) -{ - public PlanetaryMapTileKey Validate() - { - if (FieldVersion == 0 || ProjectionVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); - Layout.Validate(); QueryScale.Validate(); - return this; - } -} - -public readonly record struct PlanetarySurfaceMapTile( - PlanetaryMapTileKey Key, - PlanetarySurfaceSample[] Samples, - bool[] Validity); - -public static class PlanetaryMapTileBaker -{ - public static PlanetarySurfaceMapTile Bake( - in PlanetaryFieldDefinition field, - PlanetaryMapTileKey key, - TSource source) - where TSource : IPlanetaryBaseField - { - field.Validate(); key.Validate(); - if (field.FieldVersion != key.FieldVersion) throw new ArgumentException("Tile field version does not match definition.", nameof(key)); - var count = key.Layout.StorageSize * key.Layout.StorageSize; - var samples = new PlanetarySurfaceSample[count]; - var validity = new bool[count]; - for (var y = 0; y < key.Layout.StorageSize; y++) - for (var x = 0; x < key.Layout.StorageSize; x++) - { - var index = y * key.Layout.StorageSize + x; - if (!PlanetaryMapTileSampling.TryDirection(key.Layout, x, y, out var direction)) continue; - samples[index] = PlanetaryField.Sample(field, direction, source.Sample(direction), key.QueryScale); - validity[index] = true; - } - return new(key, samples, validity); - } -} diff --git a/src/CultMath/PlanetaryProjection.cs.meta b/src/CultMath/PlanetaryProjection.cs.meta deleted file mode 100644 index 7b1e6654..00000000 --- a/src/CultMath/PlanetaryProjection.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5021202cf626f7442a57961255a8a217 \ No newline at end of file diff --git a/src/CultMath/PlanetaryQueries.cs b/src/CultMath/PlanetaryQueries.cs deleted file mode 100644 index 54a9a3e9..00000000 --- a/src/CultMath/PlanetaryQueries.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; - -namespace CultMath; - -public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement) -{ - public PlanetaryRadialBounds Validate() - { - if (!float.IsFinite(MinimumDisplacement) || !float.IsFinite(MaximumDisplacement) || MinimumDisplacement > MaximumDisplacement) - throw new ArgumentOutOfRangeException(nameof(MinimumDisplacement)); - return this; - } -} - -public readonly record struct PlanetaryRayHit( - double Distance, - double3 PlanetLocalPosition, - PlanetarySurfaceSample Surface); - -public readonly record struct PlanetaryPathSample(double Distance, PlanetarySurfaceSample Surface); - -public readonly record struct PlanetaryRegionSummary( - float MinimumDisplacement, - float MaximumDisplacement, - float MaximumSlope, - float MaximumUnresolvedHeight); - -public static class PlanetaryQueries -{ - public static bool TryIntersectRay( - in PlanetaryFieldDefinition field, - double3 rayOrigin, - double3 rayDirection, - double maximumDistance, - in PlanetaryQueryScale scale, - in PlanetaryRadialBounds bounds, - TSource source, - out PlanetaryRayHit hit) - where TSource : IPlanetaryBaseField - { - field.Validate(); scale.Validate(); bounds.Validate(); - ValidateFinite(rayOrigin, nameof(rayOrigin)); ValidateFinite(rayDirection, nameof(rayDirection)); - if (!double.IsFinite(maximumDistance) || maximumDistance <= 0) throw new ArgumentOutOfRangeException(nameof(maximumDistance)); - var directionLength = Length(rayDirection); - if (directionLength < 1.0e-15) throw new ArgumentOutOfRangeException(nameof(rayDirection)); - var direction = rayDirection / directionLength; - var outerRadius = field.Radius + bounds.MaximumDisplacement + scale.MaximumUnresolvedHeight; - if (!double.IsFinite(outerRadius)) outerRadius = field.Radius + bounds.MaximumDisplacement; - if (!TrySphereInterval(rayOrigin, direction, outerRadius, out var enter, out var leave)) { hit = default; return false; } - var start = Math.Max(enter, 0); - var end = Math.Min(leave, maximumDistance); - if (start > end) { hit = default; return false; } - - var previousDistance = start; - var previous = SignedSurfaceDistance(field, rayOrigin + direction * previousDistance, scale, source, out var previousSample); - if (previous <= 0) - { - hit = new(previousDistance, rayOrigin + direction * previousDistance, previousSample); - return true; - } - var minimumStep = Math.Max(scale.FootprintMeters * 0.5, field.Radius * 1.0e-7); - for (var iteration = 0; iteration < 512 && previousDistance < end; iteration++) - { - var nextDistance = Math.Min(previousDistance + Math.Max(previous * 0.5, minimumStep), end); - var next = SignedSurfaceDistance(field, rayOrigin + direction * nextDistance, scale, source, out var nextSample); - if (next <= 0) - { - var low = previousDistance; - var high = nextDistance; - var surface = nextSample; - for (var refine = 0; refine < 32; refine++) - { - var middle = (low + high) * 0.5; - var value = SignedSurfaceDistance(field, rayOrigin + direction * middle, scale, source, out var middleSample); - if (value > 0) low = middle; - else { high = middle; surface = middleSample; } - } - var position = rayOrigin + direction * high; - hit = new(high, position, surface); - return true; - } - if (nextDistance >= end) break; - previousDistance = nextDistance; - previous = next; - previousSample = nextSample; - } - hit = default; - return false; - } - - public static double SegmentClearance( - in PlanetaryFieldDefinition field, - double3 start, - double3 end, - double sampleSpacing, - in PlanetaryQueryScale scale, - TSource source) - where TSource : IPlanetaryBaseField - { - ValidateFinite(start, nameof(start)); ValidateFinite(end, nameof(end)); - if (!double.IsFinite(sampleSpacing) || sampleSpacing <= 0) throw new ArgumentOutOfRangeException(nameof(sampleSpacing)); - var delta = end - start; - var distance = Length(delta); - var steps = Math.Max((int)Math.Ceiling(distance / sampleSpacing), 1); - var minimum = double.PositiveInfinity; - for (var i = 0; i <= steps; i++) - { - var position = start + delta * (i / (double)steps); - minimum = Math.Min(minimum, SignedSurfaceDistance(field, position, scale, source, out _)); - } - return minimum; - } - - public static PlanetaryPathSample[] SampleGreatCircle( - in PlanetaryFieldDefinition field, - float3 startDirection, - float3 endDirection, - int segmentCount, - in PlanetaryQueryScale scale, - TSource source) - where TSource : IPlanetaryBaseField - { - if (segmentCount < 1) throw new ArgumentOutOfRangeException(nameof(segmentCount)); - PlanetaryTopology.ValidateDirection(startDirection); PlanetaryTopology.ValidateDirection(endDirection); - var a = math.normalize(startDirection); var b = math.normalize(endDirection); - var angle = MathF.Acos(math.clamp(math.dot(a, b), -1, 1)); - var sinAngle = MathF.Sin(angle); - var result = new PlanetaryPathSample[segmentCount + 1]; - for (var i = 0; i <= segmentCount; i++) - { - var t = i / (float)segmentCount; - var direction = sinAngle < 1.0e-6f - ? math.normalize(math.lerp(a, b, t)) - : math.normalize(a * (MathF.Sin((1 - t) * angle) / sinAngle) + b * (MathF.Sin(t * angle) / sinAngle)); - var sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); - result[i] = new(angle * field.Radius * t, sample); - } - return result; - } - - public static PlanetaryRegionSummary Summarize( - in PlanetaryFieldDefinition field, - ReadOnlySpan directions, - in PlanetaryQueryScale scale, - TSource source) - where TSource : IPlanetaryBaseField - { - if (directions.IsEmpty) throw new ArgumentException("At least one direction is required.", nameof(directions)); - var min = float.PositiveInfinity; var max = float.NegativeInfinity; var slope = 0.0f; var unresolved = 0.0f; - foreach (var direction in directions) - { - var normalized = math.normalize(direction); - var sample = PlanetaryField.Sample(field, normalized, source.Sample(normalized), scale); - min = MathF.Min(min, sample.RadialDisplacement); - max = MathF.Max(max, sample.RadialDisplacement); - slope = MathF.Max(slope, sample.Slope); - unresolved = MathF.Max(unresolved, sample.UnresolvedHeightBound); - } - return new(min, max, slope, unresolved); - } - - private static double SignedSurfaceDistance( - in PlanetaryFieldDefinition field, - double3 position, - in PlanetaryQueryScale scale, - TSource source, - out PlanetarySurfaceSample sample) - where TSource : IPlanetaryBaseField - { - var radius = Length(position); - if (radius < 1.0e-15) { sample = default; return double.NegativeInfinity; } - var direction = new float3((float)(position.x / radius), (float)(position.y / radius), (float)(position.z / radius)); - sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); - return radius - sample.SurfaceRadius; - } - - private static bool TrySphereInterval(double3 origin, double3 direction, double radius, out double enter, out double leave) - { - var b = Dot(origin, direction); - var c = Dot(origin, origin) - radius * radius; - var discriminant = b * b - c; - if (discriminant < 0) { enter = leave = 0; return false; } - var root = Math.Sqrt(discriminant); - enter = -b - root; leave = -b + root; return leave >= 0; - } - - private static double Dot(double3 a, double3 b) => a.x * b.x + a.y * b.y + a.z * b.z; - private static double Length(double3 value) => Math.Sqrt(Dot(value, value)); - private static void ValidateFinite(double3 value, string name) - { - if (!double.IsFinite(value.x) || !double.IsFinite(value.y) || !double.IsFinite(value.z)) throw new ArgumentOutOfRangeException(name); - } -} diff --git a/src/CultMath/PlanetaryQueries.cs.meta b/src/CultMath/PlanetaryQueries.cs.meta deleted file mode 100644 index 6fd11413..00000000 --- a/src/CultMath/PlanetaryQueries.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2ec43c567214b8247bda19b5c120c200 \ No newline at end of file diff --git a/src/CultMath/PlanetaryRadialRefinement.cs b/src/CultMath/PlanetaryRadialRefinement.cs deleted file mode 100644 index 7188a513..00000000 --- a/src/CultMath/PlanetaryRadialRefinement.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace CultMath; - -public static class PlanetaryRadialRefinement -{ - public static float3 Step( - float3 position, - float3 rayDirection, - float3 center, - float targetRadius, - float minimumDerivative = 0.2f, - float maximumCorrection = 0.08f) - { - var local = position - center; - var radius = math.length(local); - var radial = local / math.max(radius, 0.0001f); - var error = radius - targetRadius; - var derivative = math.dot(rayDirection, radial); - var safeMinimum = math.max(minimumDerivative, 0.0001f); - var safeDerivative = math.abs(derivative) > safeMinimum - ? derivative - : derivative < 0.0f ? -safeMinimum : safeMinimum; - var correction = math.clamp( - -error / safeDerivative, - -math.abs(maximumCorrection), - math.abs(maximumCorrection)); - return position + rayDirection * correction; - } -} diff --git a/src/CultMath/PlanetaryRadialRefinement.cs.meta b/src/CultMath/PlanetaryRadialRefinement.cs.meta deleted file mode 100644 index 32af2655..00000000 --- a/src/CultMath/PlanetaryRadialRefinement.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 82e4db521edc253499220458b417fd1e \ No newline at end of file diff --git a/src/CultMath/PlanetaryResidency.cs b/src/CultMath/PlanetaryResidency.cs deleted file mode 100644 index 6918ac57..00000000 --- a/src/CultMath/PlanetaryResidency.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CultMath; - -public readonly record struct PlanetaryLodParameters( - int MaximumLevel, - int PageInteriorSize, - int PageBorderSize, - float ViewportHeightPixels, - float MinimumFootprintMeters) -{ - public PlanetaryLodParameters Validate() - { - if (MaximumLevel is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(MaximumLevel)); - _ = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), PageInteriorSize, PageBorderSize).Validate(); - if (!float.IsFinite(ViewportHeightPixels) || ViewportHeightPixels <= 0) throw new ArgumentOutOfRangeException(nameof(ViewportHeightPixels)); - if (!float.IsFinite(MinimumFootprintMeters) || MinimumFootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(MinimumFootprintMeters)); - return this; - } -} - -public static class PlanetaryLodSelector -{ - public static int SelectLevel( - in PlanetaryFieldDefinition field, - float cameraDistanceFromCenter, - in PlanetaryLodParameters parameters) - { - field.Validate(); parameters.Validate(); - if (!float.IsFinite(cameraDistanceFromCenter) || cameraDistanceFromCenter < 0) throw new ArgumentOutOfRangeException(nameof(cameraDistanceFromCenter)); - var altitude = MathF.Max(cameraDistanceFromCenter - field.Radius, 0.001f); - var footprint = MathF.Max(altitude / parameters.ViewportHeightPixels * 2, parameters.MinimumFootprintMeters); - var erosion = field.Erosion; - for (var level = 0; level <= parameters.MaximumLevel; level++) - { - var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, level, 0, 0), parameters.PageInteriorSize, parameters.PageBorderSize); - var spacing = PlanetaryPageSampling.NominalAngularTexelSize(page) * field.Radius; - var unresolved = ErosionFrequencyBands.Select( - erosion.Scale * erosion.CellScale, spacing, erosion.Octaves, - erosion.Lacunarity, erosion.Strength * erosion.Scale, erosion.Gain).UnresolvedHeightBound; - if (unresolved <= footprint) return level; - } - return parameters.MaximumLevel; - } - - public static PlanetaryTileAddress[] SelectAncestorChain( - in PlanetaryFieldDefinition field, - float3 cameraDirection, - float cameraDistanceFromCenter, - in PlanetaryLodParameters parameters) - { - PlanetaryTopology.ValidateDirection(cameraDirection); - var level = SelectLevel(field, cameraDistanceFromCenter, parameters); - var result = new PlanetaryTileAddress[6 + level]; - for (var face = 0; face < 6; face++) result[face] = new((PlanetaryCubeFace)face, 0, 0, 0); - var leaf = PlanetaryTopology.TileAt(cameraDirection, level); - for (var currentLevel = level; currentLevel >= 1; currentLevel--) - { - result[6 + currentLevel - 1] = leaf; - leaf = leaf.Parent(); - } - return result; - } -} - -public readonly record struct PlanetaryResidentTile(PlanetaryTileAddress Tile, float Blend, bool Departing); - -public readonly record struct PlanetaryResidencySnapshot( - ulong ContentVersion, - ulong PresentationVersion, - PlanetaryResidentTile[] Tiles); - -/// Owns presentation residency only. It never owns field content. -public sealed class PlanetaryResidualResidency -{ - private readonly Dictionary residents = new(); - private readonly Dictionary arrivals = new(); - private readonly Dictionary departures = new(); - private float lastTime = float.NegativeInfinity; - - public void Reset() - { - residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity; - } - - public PlanetaryResidencySnapshot Update(ReadOnlySpan desired, float timeSeconds, float transitionSeconds) - { - if (!float.IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); - if (!float.IsFinite(transitionSeconds) || transitionSeconds < 0) throw new ArgumentOutOfRangeException(nameof(transitionSeconds)); - if (timeSeconds < lastTime) Reset(); - lastTime = timeSeconds; - - var desiredKeys = new HashSet(); - foreach (var tile in desired) - { - var key = tile.StableKey; - desiredKeys.Add(key); - residents[key] = tile; - if (!arrivals.ContainsKey(key)) arrivals[key] = timeSeconds; - departures.Remove(key); - } - - foreach (var pair in residents.ToArray()) - { - if (pair.Value.Level == 0 || desiredKeys.Contains(pair.Key)) continue; - if (!departures.ContainsKey(pair.Key)) - { - var startingBlend = transitionSeconds == 0 - ? 1 - : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); - departures[pair.Key] = (timeSeconds, startingBlend); - } - if (transitionSeconds > 0 && timeSeconds - departures[pair.Key].Time < transitionSeconds) continue; - residents.Remove(pair.Key); arrivals.Remove(pair.Key); departures.Remove(pair.Key); - } - - var ordered = residents - .OrderBy(pair => pair.Value.Level) - .ThenBy(pair => pair.Value.Face) - .ThenBy(pair => pair.Value.X) - .ThenBy(pair => pair.Value.Y) - .ToArray(); - var tiles = new PlanetaryResidentTile[ordered.Length]; - for (var i = 0; i < ordered.Length; i++) - { - var pair = ordered[i]; - var departing = departures.TryGetValue(pair.Key, out var departure); - var blend = pair.Value.Level == 0 || transitionSeconds == 0 - ? 1 - : departing - ? departure.StartingBlend * Math.Clamp(1 - (timeSeconds - departure.Time) / transitionSeconds, 0, 1) - : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); - tiles[i] = new(pair.Value, blend, departing); - } - return new(Hash(ordered.Select(pair => pair.Key)), Hash(tiles.Select(tile => tile.Tile.StableKey ^ (uint)BitConverter.SingleToInt32Bits(tile.Blend))), tiles); - } - - private static ulong Hash(IEnumerable values) - { - const ulong offset = 14695981039346656037UL; - const ulong prime = 1099511628211UL; - var hash = offset; - foreach (var value in values) hash = unchecked((hash ^ value) * prime); - return hash == 0 ? 1 : hash; - } -} diff --git a/src/CultMath/PlanetaryResidency.cs.meta b/src/CultMath/PlanetaryResidency.cs.meta deleted file mode 100644 index 24347624..00000000 --- a/src/CultMath/PlanetaryResidency.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0585ea15edd7f1c4fa272df4911a8d6f \ No newline at end of file diff --git a/src/CultMath/PlanetaryTopology.cs b/src/CultMath/PlanetaryTopology.cs deleted file mode 100644 index 57d562cc..00000000 --- a/src/CultMath/PlanetaryTopology.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; - -namespace CultMath; - -public enum PlanetaryCubeFace -{ - PositiveX, - NegativeX, - PositiveY, - NegativeY, - PositiveZ, - NegativeZ, -} - -public readonly record struct PlanetaryFaceCoordinate(PlanetaryCubeFace Face, double U, double V) -{ - public PlanetaryFaceCoordinate Validate(bool allowOutsideFace = false) - { - if (!double.IsFinite(U) || !double.IsFinite(V)) throw new ArgumentOutOfRangeException(nameof(U)); - if (!allowOutsideFace && (U is < -1.0 or > 1.0 || V is < -1.0 or > 1.0)) - throw new ArgumentOutOfRangeException(nameof(U), "Face coordinates must be in [-1, 1]."); - if (!Enum.IsDefined(typeof(PlanetaryCubeFace), Face)) throw new ArgumentOutOfRangeException(nameof(Face)); - return this; - } -} - -public readonly record struct PlanetaryTileAddress -{ - public const int MaxLevel = 30; - - public PlanetaryTileAddress(PlanetaryCubeFace face, int level, int x, int y) - { - if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); - if (level is < 0 or > MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); - var count = 1 << level; - if ((uint)x >= (uint)count) throw new ArgumentOutOfRangeException(nameof(x)); - if ((uint)y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(y)); - Face = face; - Level = level; - X = x; - Y = y; - } - - public PlanetaryCubeFace Face { get; } - public int Level { get; } - public int X { get; } - public int Y { get; } - public int AxisTileCount => 1 << Level; - - public PlanetaryTileAddress Parent() => Level == 0 - ? throw new InvalidOperationException("Root cube tile has no parent.") - : new(Face, Level - 1, X >> 1, Y >> 1); - - public PlanetaryTileAddress Child(int childX, int childY) - { - if ((uint)childX > 1) throw new ArgumentOutOfRangeException(nameof(childX)); - if ((uint)childY > 1) throw new ArgumentOutOfRangeException(nameof(childY)); - if (Level == MaxLevel) throw new InvalidOperationException($"Level {MaxLevel} cannot be subdivided."); - return new(Face, Level + 1, (X << 1) | childX, (Y << 1) | childY); - } - - public PlanetaryFaceCoordinate PositionAt(double localU, double localV) - { - if (!double.IsFinite(localU) || localU is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localU)); - if (!double.IsFinite(localV) || localV is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localV)); - var count = AxisTileCount; - return new(Face, -1.0 + 2.0 * (X + localU) / count, -1.0 + 2.0 * (Y + localV) / count); - } - - public ulong StableKey - { - get - { - const ulong offset = 14695981039346656037UL; - const ulong prime = 1099511628211UL; - var hash = offset; - hash = unchecked((hash ^ (uint)Face) * prime); - hash = unchecked((hash ^ (uint)Level) * prime); - hash = unchecked((hash ^ (uint)X) * prime); - hash = unchecked((hash ^ (uint)Y) * prime); - return hash == 0 ? 1 : hash; - } - } - public override string ToString() => $"{Face}/L{Level:D2}/{X}/{Y}"; -} - -public static class PlanetaryTopology -{ - private const float QuarterPi = MathF.PI * 0.25f; - - public static float3 Direction(PlanetaryFaceCoordinate coordinate) - { - coordinate.Validate(allowOutsideFace: true); - var u = MathF.Tan((float)coordinate.U * QuarterPi); - var v = MathF.Tan((float)coordinate.V * QuarterPi); - var cube = coordinate.Face switch - { - PlanetaryCubeFace.PositiveX => new float3(1, v, -u), - PlanetaryCubeFace.NegativeX => new float3(-1, v, u), - PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), - PlanetaryCubeFace.NegativeY => new float3(u, -1, v), - PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), - PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), - _ => throw new ArgumentOutOfRangeException(nameof(coordinate)), - }; - return math.normalize(cube); - } - - public static float3 NormalizedCubeDirection(PlanetaryFaceCoordinate coordinate) - { - coordinate.Validate(allowOutsideFace: true); - var u = (float)coordinate.U; - var v = (float)coordinate.V; - return math.normalize(CubeVector(coordinate.Face, u, v)); - } - - public static PlanetaryFaceCoordinate FaceCoordinate(float3 direction) - { - ValidateDirection(direction); - var d = math.normalize(direction); - var a = math.abs(d); - if (a.x >= a.y && a.x >= a.z) - return d.x >= 0 - ? FromCubeUv(PlanetaryCubeFace.PositiveX, -d.z / a.x, d.y / a.x) - : FromCubeUv(PlanetaryCubeFace.NegativeX, d.z / a.x, d.y / a.x); - if (a.y >= a.z) - return d.y >= 0 - ? FromCubeUv(PlanetaryCubeFace.PositiveY, d.x / a.y, -d.z / a.y) - : FromCubeUv(PlanetaryCubeFace.NegativeY, d.x / a.y, d.z / a.y); - return d.z >= 0 - ? FromCubeUv(PlanetaryCubeFace.PositiveZ, d.x / a.z, d.y / a.z) - : FromCubeUv(PlanetaryCubeFace.NegativeZ, -d.x / a.z, d.y / a.z); - } - - public static PlanetaryTileAddress TileAt(float3 direction, int level) - { - if (level is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); - var face = FaceCoordinate(direction); - var count = 1 << level; - var x = Math.Clamp((int)((face.U * 0.5 + 0.5) * count), 0, count - 1); - var y = Math.Clamp((int)((face.V * 0.5 + 0.5) * count), 0, count - 1); - return new(face.Face, level, x, y); - } - - public static bool TryLocalCoordinate(float3 direction, PlanetaryTileAddress tile, out float2 local) - { - var face = FaceCoordinate(direction); - if (face.Face != tile.Face) { local = float2.zero; return false; } - var scaledU = (face.U * 0.5 + 0.5) * tile.AxisTileCount; - var scaledV = (face.V * 0.5 + 0.5) * tile.AxisTileCount; - local = new((float)(scaledU - tile.X), (float)(scaledV - tile.Y)); - return local.x is >= 0 and <= 1 && local.y is >= 0 and <= 1; - } - - public static float3 SurfaceNormal(float3 unitDirection, float3 worldDistanceTangentGradient) - { - ValidateDirection(unitDirection); - ValidateFinite(worldDistanceTangentGradient, nameof(worldDistanceTangentGradient)); - var direction = math.normalize(unitDirection); - var tangent = worldDistanceTangentGradient - direction * math.dot(worldDistanceTangentGradient, direction); - return math.normalize(direction - tangent); - } - - private static PlanetaryFaceCoordinate FromCubeUv(PlanetaryCubeFace face, float cubeU, float cubeV) => new( - face, - MathF.Atan(cubeU) / QuarterPi, - MathF.Atan(cubeV) / QuarterPi); - - private static float3 CubeVector(PlanetaryCubeFace face, float u, float v) => face switch - { - PlanetaryCubeFace.PositiveX => new float3(1, v, -u), - PlanetaryCubeFace.NegativeX => new float3(-1, v, u), - PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), - PlanetaryCubeFace.NegativeY => new float3(u, -1, v), - PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), - PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), - _ => throw new ArgumentOutOfRangeException(nameof(face)), - }; - - internal static void ValidateDirection(float3 direction) - { - ValidateFinite(direction, nameof(direction)); - if (math.length(direction) < 1.0e-12f) throw new ArgumentOutOfRangeException(nameof(direction)); - } - - internal static void ValidateFinite(float3 value, string name) - { - if (!float.IsFinite(value.x) || !float.IsFinite(value.y) || !float.IsFinite(value.z)) - throw new ArgumentOutOfRangeException(name); - } -} diff --git a/src/CultMath/PlanetaryTopology.cs.meta b/src/CultMath/PlanetaryTopology.cs.meta deleted file mode 100644 index 411a8bdb..00000000 --- a/src/CultMath/PlanetaryTopology.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 88f6789c4384f7846ac4be37613fe5ed \ No newline at end of file diff --git a/src/CultMath/SphericalErosion.cs b/src/CultMath/SphericalErosion.cs deleted file mode 100644 index 93ddb0ec..00000000 --- a/src/CultMath/SphericalErosion.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace CultMath; - -public readonly record struct SphericalErosionParameters( - float Frequency, - float Amplitude, - float Lacunarity, - float Gain, - float SlopeStrength, - float Detail, - float Seed, - int Octaves) -{ - public static SphericalErosionParameters Default => new(18.0f, 0.035f, 2.03f, 0.52f, 6.0f, 1.35f, 0.0f, 5); -} - -public readonly record struct SphericalErosionSample(float HeightOffset, float Ridge, float Gully, float3 Gradient); - -/// -/// Stateless, point-evaluable erosion-like synthesis on a unit sphere. -/// The matching shader body is cultmath_spherical_erosion in CultMath.hlsl. -/// Position and gradient are planet-space; the gradient is projected tangent -/// internally so cube faces and tile request order never enter the result. -/// -public static class SphericalErosion -{ - public static SphericalErosionSample Sample( - float3 unitPosition, - float baseHeight, - float3 baseGradient, - SphericalErosionParameters parameters) - { - var position = math.normalize(unitPosition); - var gradient = baseGradient - position * math.dot(baseGradient, position); - var slope = math.length(gradient); - var flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); - var frequency = math.max(parameters.Frequency, 0.001f); - var amplitude = parameters.Amplitude; - var combiMask = EaseOut(math.saturate(slope * parameters.SlopeStrength)); - var fadeTarget = math.saturate(baseHeight * 0.5f + 0.5f) * 2.0f - 1.0f; - var heightOffset = 0.0f; - var ridge = 0.0f; - var gully = 0.0f; - - var octaveCount = math.min(math.max(parameters.Octaves, 0), 8); - for (var octave = 0; octave < octaveCount; octave++) - { - var phase = DirectionalCellWave(position * frequency, flow, parameters.Seed + octave * 19.19f); - var wave = math.cos(phase); - var waveSlope = math.sin(phase); - var faded = math.lerp(fadeTarget, wave, combiMask); - heightOffset += faded * amplitude; - ridge = math.max(ridge, math.saturate(faded)); - gully = math.max(gully, math.saturate(-faded)); - - var across = math.normalize(math.cross(position, flow)); - gradient += across * (math.sign(waveSlope) * amplitude * frequency * combiMask); - gradient -= position * math.dot(gradient, position); - slope = math.length(gradient); - flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); - - var newMask = EaseOut(math.saturate(math.abs(waveSlope) * 1.35f)); - combiMask = PowInverse(combiMask, parameters.Detail) * newMask; - fadeTarget = faded; - frequency *= math.max(parameters.Lacunarity, 1.01f); - amplitude *= math.saturate(parameters.Gain); - } - - return new SphericalErosionSample(heightOffset, ridge, gully, gradient); - } - - private static float DirectionalCellWave(float3 position, float3 flow, float seed) - { - var cell = math.floor(position); - var local = math.frac(position); - var weights = local * local * (3.0f - 2.0f * local); - var x00 = CellWave(position, flow, cell + new float3(0, 0, 0), seed); - var x10 = CellWave(position, flow, cell + new float3(1, 0, 0), seed); - var x01 = CellWave(position, flow, cell + new float3(0, 1, 0), seed); - var x11 = CellWave(position, flow, cell + new float3(1, 1, 0), seed); - var y00 = math.lerp(x00, x10, weights.x); - var y10 = math.lerp(x01, x11, weights.x); - var z0 = math.lerp(y00, y10, weights.y); - var x00z = CellWave(position, flow, cell + new float3(0, 0, 1), seed); - var x10z = CellWave(position, flow, cell + new float3(1, 0, 1), seed); - var x01z = CellWave(position, flow, cell + new float3(0, 1, 1), seed); - var x11z = CellWave(position, flow, cell + new float3(1, 1, 1), seed); - var z1 = math.lerp(math.lerp(x00z, x10z, weights.x), math.lerp(x01z, x11z, weights.x), weights.y); - return math.lerp(z0, z1, weights.z); - } - - private static float CellWave(float3 position, float3 flow, float3 cell, float seed) - { - var pivot = cell + new float3( - math.hash(cell + seed + 11.7f), - math.hash(cell.yzx + seed + 37.1f), - math.hash(cell.zyx + seed + 73.9f)); - return math.dot(position - pivot, flow) * math.TAU + math.hash(cell + seed + 101.3f) * math.TAU; - } - - private static float3 StableTangent(float3 position) - { - var axis = math.abs(position.z) < 0.8f ? new float3(0, 0, 1) : new float3(0, 1, 0); - return math.normalize(math.cross(axis, position)); - } - - private static float EaseOut(float value) => 1.0f - (1.0f - value) * (1.0f - value); - private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), math.max(power, 0.001f)); -} diff --git a/src/CultMath/SphericalErosion.cs.meta b/src/CultMath/SphericalErosion.cs.meta deleted file mode 100644 index 82db7730..00000000 --- a/src/CultMath/SphericalErosion.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 59ec2db73065f6c4483ad90f5e17850e \ No newline at end of file diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index e15fbf08..d0a56e76 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -22,9 +22,6 @@ public static class math public static float4 float4(float3 xyz, float w) => new(xyz, w); public static float4 float4(float x, float3 yzw) => new(x, yzw); public static float4 float4(float value) => new(value, value, value, value); - public static rect rect(float2 min, float2 max) => new(min, max); - public static rect rect(float minX, float minY, float maxX, float maxY) => new(minX, minY, maxX, maxY); - public static float radians(float degrees) => degrees * (PI / 180.0f); public static float degrees(float radians) => radians * (180.0f / PI); public static float sin(float value) => MathF.Sin(value); diff --git a/src/CultMath/rect.cs b/src/CultMath/rect.cs deleted file mode 100644 index 67b210b9..00000000 --- a/src/CultMath/rect.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CultMath; - -public readonly record struct rect(float2 min, float2 max) -{ - public rect(float minX, float minY, float maxX, float maxY) - : this(math.float2(math.min(minX, maxX), math.min(minY, maxY)), - math.float2(math.max(minX, maxX), math.max(minY, maxY))) - { - } - - public float2 size => max - min; - public float2 center => (min + max) * 0.5f; - - public bool Contains(float2 point) - { - return point.x >= min.x && - point.x <= max.x && - point.y >= min.y && - point.y <= max.y; - } -} diff --git a/src/CultMath/rect.cs.meta b/src/CultMath/rect.cs.meta deleted file mode 100644 index 9f7598ed..00000000 --- a/src/CultMath/rect.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b00dc5f108967db47ac2f54a47033e33 \ No newline at end of file diff --git a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs deleted file mode 100644 index cccebd64..00000000 --- a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class AdvancedErosionFilterTests -{ - [Fact] - public void DefaultReferenceFixtureIsStable() - { - var result = AdvancedErosionFilter.Sample( - new float2(0.42f, 0.31f), - new float3(0.5f, 0.1f, -0.2f), - 0.0f, - AdvancedErosionParameters.Default); - - Assert.Equal(0.0639375f, result.Magnitude, precision: 7); - Assert.True(float.IsFinite(result.Delta.x)); - Assert.True(float.IsFinite(result.Delta.y)); - Assert.True(float.IsFinite(result.Delta.z)); - Assert.InRange(result.RidgeMap, -1.0f, 1.0f); - } - - [Fact] - public void FlatInputAndZeroRoundingRemainFinite() - { - var parameters = AdvancedErosionParameters.Default with { Rounding = float4.zero }; - var result = AdvancedErosionFilter.Sample(new float2(0.25f, 0.75f), float3.zero, 0.0f, parameters); - - Assert.True(float.IsFinite(result.Delta.x)); - Assert.True(float.IsFinite(result.RidgeMap)); - Assert.True(float.IsFinite(result.FadeTarget)); - } - - [Fact] - public void PhacellePartialNormalizationIsFiniteAtEndpoints() - { - foreach (var normalization in new[] { 0.0f, 0.5f, 1.0f }) - { - var sample = AdvancedErosionFilter.Phacelle(new float2(1.7f, -3.2f), new float2(0.6f, -0.8f), 0.7f, 0.25f, normalization); - Assert.True(float.IsFinite(sample.x)); - Assert.True(float.IsFinite(sample.y)); - } - } -} diff --git a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta deleted file mode 100644 index 253ee425..00000000 --- a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: a7c85309318ae3f4ba75ac28a4f661cf \ No newline at end of file diff --git a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs deleted file mode 100644 index f57213a4..00000000 --- a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class ErosionFrequencyBandsTests -{ - [Fact] - public void RadiusIndependentWorldWavelengthSelectsSameBands() - { - var small = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); - var large = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); - Assert.Equal(small, large); - Assert.Equal(3, small.ActiveOctaves); - } - - [Fact] - public void TransitionPartiallyWeightsLastResolvableOctave() - { - var selection = ErosionFrequencyBands.Select(1_000.0f, 50.0f, 8, 2.0f, 1.0f, 0.5f); - Assert.Equal(4, selection.ActiveOctaves); - Assert.InRange(selection.FinalOctaveWeight, 0.0f, 1.0f); - Assert.True(selection.UnresolvedHeightBound > 0.0f); - } - - [Fact] - public void BandedFilterPreservesLowOctaveResultExactly() - { - var p = AdvancedErosionParameters.Default; - var band = new ErosionBandSelection(2, 1.0f, 0.0f, 0.0f); - var banded = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p, band); - var truncated = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p with { Octaves = 2 }); - Assert.Equal(truncated, banded); - } -} diff --git a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta deleted file mode 100644 index ef15b7ee..00000000 --- a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 095f8ed13d2f2f74baefd23310395256 \ No newline at end of file diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs index 47f58971..b9396bdf 100644 --- a/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -5,22 +5,13 @@ namespace CultMath.Tests; public sealed class HlslMirrorTests { [Fact] - public void HlslMirrorPublishesSphericalErosionKernel() + public void HlslMirrorDoesNotPublishGeometryKernels() { var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl")); - Assert.Contains("cultmath_spherical_erosion", source); - Assert.Contains("CultMathSphericalErosionParameters", source); - } - - [Fact] - public void HlslMirrorPublishesFaithfulAdvancedErosionKernel() - { - var mirror = Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl"); - var source = File.ReadAllText(mirror); - Assert.Contains("AdvancedErosionFilter.hlsl", source); - var advanced = File.ReadAllText(Path.Combine(Path.GetDirectoryName(mirror)!, "AdvancedErosionFilter.hlsl")); - Assert.Contains("cultmath_phacelle_noise", advanced); - Assert.Contains("cultmath_advanced_erosion_filter", advanced); + Assert.DoesNotContain("AdvancedErosionFilter.hlsl", source); + Assert.DoesNotContain("Planetary.hlsl", source); + Assert.DoesNotContain("spherical_erosion", source); + Assert.DoesNotContain("planetary_radial_refinement", source); } [Fact] @@ -66,24 +57,6 @@ public sealed class HlslMirrorTests Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); } - [Fact] - public void HlslMirrorPublishesPlanetaryTopologyAndField() - { - var root = GetRepositoryRoot(); - var include = File.ReadAllText(Path.Combine(root, "shaders", "CultMath.hlsl")); - var planetary = File.ReadAllText(Path.Combine(root, "shaders", "Planetary.hlsl")); - Assert.Contains("Planetary.hlsl", include); - Assert.Contains("cultmath_planetary_face_direction", planetary); - Assert.Contains("cultmath_planetary_face_coordinate", planetary); - Assert.Contains("cultmath_planetary_page_local", planetary); - Assert.Contains("cultmath_planetary_field_sample", planetary); - Assert.Contains("cultmath_planetary_equirectangular_forward", planetary); - Assert.Contains("cultmath_planetary_equal_earth_forward", planetary); - Assert.Contains("cultmath_planetary_projection_forward", planetary); - Assert.Contains("cultmath_planetary_projection_inverse", planetary); - Assert.Contains("CultMathPlanetarySurfaceSample", planetary); - } - private static string GetRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 6979bdd6..ad268212 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -32,19 +32,6 @@ public sealed class MathTests Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); } - [Fact] - public void RectNormalizesBoundsAndContainsPoints() - { - var viewport = math.rect(10.0f, 20.0f, -5.0f, -2.0f); - - Assert.Equal(new float2(-5.0f, -2.0f), viewport.min); - Assert.Equal(new float2(10.0f, 20.0f), viewport.max); - Assert.Equal(new float2(15.0f, 22.0f), viewport.size); - Assert.Equal(new float2(2.5f, 9.0f), viewport.center); - Assert.True(viewport.Contains(new float2(0.0f, 0.0f))); - Assert.False(viewport.Contains(new float2(11.0f, 0.0f))); - } - [Fact] public void VectorsExposeCommonReadOnlySwizzles() { diff --git a/tests/CultMath.Tests/PlanetaryFieldTests.cs b/tests/CultMath.Tests/PlanetaryFieldTests.cs deleted file mode 100644 index 4dc75325..00000000 --- a/tests/CultMath.Tests/PlanetaryFieldTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryFieldTests -{ - private static readonly PlanetaryFieldDefinition Definition = new( - 0x7f59_6d11_02ab_9c31UL, - 8.4f, - 42, - AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); - - [Fact] - public void SampleIsDeterministicScaleAwareAndTangent() - { - var direction = math.normalize(new float3(0.31f, -0.72f, 0.61f)); - var source = new FixtureField(); - var coarse = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.1f)); - var fine = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); - var repeated = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); - - Assert.Equal(fine, repeated); - Assert.Equal(Definition.FieldVersion, fine.FieldVersion); - Assert.True(fine.UnresolvedHeightBound <= coarse.UnresolvedHeightBound); - Assert.True(fine.FinestResolvedWavelength <= coarse.FinestResolvedWavelength); - Assert.InRange(MathF.Abs(math.dot(fine.UnitDirection, fine.TangentGradient)), 0, 2.0e-5f); - Assert.Equal(1, math.length(fine.SurfaceNormal), 5); - } - - [Fact] - public void BatchAndDoublePositionUseThePointContract() - { - var directions = new[] - { - math.normalize(new float3(1, 2, 3)), - math.normalize(new float3(-2, 0.5f, 1)), - math.normalize(new float3(0.1f, -0.2f, -1)), - }; - var output = new PlanetarySurfaceSample[directions.Length]; - var source = new FixtureField(); - var scale = PlanetaryQueryScale.AtFootprint(0.05f); - PlanetaryField.SampleBatch(Definition, directions, scale, source, output); - - for (var i = 0; i < directions.Length; i++) - { - var normalized = math.normalize(directions[i]); - var expected = PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), scale); - Assert.InRange(MathF.Abs(expected.RadialDisplacement - output[i].RadialDisplacement), 0, 1.0e-6f); - Assert.InRange(math.distance(expected.TangentGradient, output[i].TangentGradient), 0, 1.0e-6f); - } - - var d = directions[0]; - var position = new double3(d.x * 8.0e11, d.y * 8.0e11, d.z * 8.0e11); - var positioned = PlanetaryField.SamplePosition(Definition, position, scale, source); - Assert.InRange(math.distance(positioned.UnitDirection, d), 0, 2.0e-6f); - } - - [Fact] - public void FieldIdentityChangesWithEveryLoadBearingInput() - { - var erosion = AdvancedErosionParameters.Default; - var baseline = PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion); - Assert.Equal(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion).FieldVersion); - Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(4, 8.4f, 7, erosion).FieldVersion); - Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.5f, 7, erosion).FieldVersion); - Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 8, erosion).FieldVersion); - Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion with { Gain = 0.49f }).FieldVersion); - } - - private readonly struct FixtureField : IPlanetaryBaseField - { - public PlanetaryBaseFieldSample Sample(float3 direction) - { - var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; - var fieldGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); - fieldGradient -= direction * math.dot(fieldGradient, direction); - return new(value * 0.1f, fieldGradient * 0.1f, value, fieldGradient, math.clamp(value, -1, 1)); - } - } -} diff --git a/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta b/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta deleted file mode 100644 index 1710b5ff..00000000 --- a/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c747cfda80df21d41a57412d8d07c08a \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs deleted file mode 100644 index ea17b4b9..00000000 --- a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryGpuPageTests -{ - [Fact] - public void UploadContentUsesCanonicalDirectionsSpacingAndMetadata() - { - var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.NegativeY, 3, 4, 2), 9, 2); - var content = PlanetaryGpuPageBuilder.BuildContent(layout, 8.4f); - Assert.Equal(layout.StorageSize * layout.StorageSize, content.Inputs.Length); - Assert.True(content.ParentSampleSpacing > content.SampleSpacing); - var first = content.Inputs[0]; - Assert.Equal(PlanetaryPageSampling.Direction(layout, 0, 0), first.DirectionRadius.xyz); - Assert.Equal(8.4f, first.DirectionRadius.w); - Assert.Equal(content.SampleSpacing, first.Sampling.x); - Assert.Equal(content.ParentSampleSpacing, first.Sampling.y); - - var metadata = PlanetaryGpuPageBuilder.Metadata(content, 37, 0.25f); - Assert.Equal(new float4((int)layout.Tile.Face, layout.Tile.Level, layout.Tile.X, layout.Tile.Y), metadata.Address); - Assert.Equal(new float4(37, layout.StorageSize, layout.InteriorSize, layout.BorderSize), metadata.Layout); - Assert.Equal(0.25f, metadata.State.y); - } -} diff --git a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta deleted file mode 100644 index 00f4a3a5..00000000 --- a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 87266638e8010f546ab6c430a712868b \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs deleted file mode 100644 index 112cb31f..00000000 --- a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.IO; -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryMapTileEncodingTests -{ - [Fact] - public void BinaryTileRoundTripsIdentityValidityAndSurfaceEvidence() - { - var field = PlanetaryFieldDefinition.Create(5, 10, 3, AdvancedErosionParameters.Default); - var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 1, 1, 0, 5, 1); - var key = new PlanetaryMapTileKey(field.FieldVersion, 2, 17, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.1f)); - var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); - using var bytes = new MemoryStream(); - PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); - bytes.Position = 0; - var decoded = PlanetaryMapTileEncoding.Read(bytes); - - Assert.Equal(tile.Key, decoded.Key); - Assert.Equal(tile.Validity, decoded.Validity); - for (var i = 0; i < tile.Samples.Length; i++) - if (tile.Validity[i]) Assert.Equal(tile.Samples[i], decoded.Samples[i]); - } - - [Fact] - public void DecoderRejectsForeignMagicAndVersions() - { - Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 }))); - var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 }; - Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes))); - } - - [Fact] - public void DecoderRejectsTrailingBytes() - { - var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default); - var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0); - var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1)); - var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); - using var bytes = new MemoryStream(); - PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); - bytes.WriteByte(0xff); - bytes.Position = 0; - Assert.Throws(() => PlanetaryMapTileEncoding.Read(bytes)); - } - - private readonly struct FlatField : IPlanetaryBaseField - { - public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0); - } -} diff --git a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta deleted file mode 100644 index 027754e8..00000000 --- a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e916ef83478d48b43ac99777f5e0f731 \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryPageTests.cs b/tests/CultMath.Tests/PlanetaryPageTests.cs deleted file mode 100644 index c4e4045b..00000000 --- a/tests/CultMath.Tests/PlanetaryPageTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryPageTests -{ - [Fact] - public void BilinearSamplingAndSummaryUseOnePageContract() - { - var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 3, 1); - var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; - for (var y = 0; y < layout.StorageSize; y++) - for (var x = 0; x < layout.StorageSize; x++) - samples[y * layout.StorageSize + x] = new(x + y * 10, new float3(x, y, 0), x / 10.0f, y / 10.0f); - - var sample = PlanetaryPageSampling.Bilinear(samples, layout, new float2(0.25f, 0.75f)); - Assert.Equal(26.5f, sample.Height, 4); - Assert.Equal(new float3(1.5f, 2.5f, 0), sample.TangentGradient); - var summary = PlanetaryPageSampling.Summarize(samples, layout, 0.125f); - Assert.Equal(0, summary.MinimumHeight); - Assert.Equal(44, summary.MaximumHeight); - Assert.Equal(0.125f, summary.UnresolvedHeightBound); - Assert.True(summary.MaximumSlope > 0); - } - - [Fact] - public void ChildPageStoresOnlyResidualAndComposesBackToDirectField() - { - var field = new PlanetaryFieldDefinition(7, 8.4f, 3, AdvancedErosionParameters.Default with - { - Scale = 0.63f, - Strength = 0.12f, - Octaves = 7, - }); - var source = new FixtureField(); - var root = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 17, 2), source); - var child = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2), source); - var direction = PlanetaryPageSampling.DirectionAtLocal(child.Layout, 0.5, 0.5); - var pages = new[] { new PlanetaryResidentPage(root, 1), new PlanetaryResidentPage(child, 1) }; - Assert.True(PlanetaryPageSetSampling.TrySample(pages, direction, out var composed)); - var spacing = PlanetaryPageSampling.NominalAngularTexelSize(child.Layout) * field.Radius; - var direct = PlanetaryField.Sample(field, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(spacing)); - Assert.InRange(MathF.Abs(composed.Height - direct.RadialDisplacement), 0, 2.0e-5f); - Assert.InRange(math.distance(composed.TangentGradient, direct.TangentGradient), 0, 3.0e-5f); - } - - private readonly struct FixtureField : IPlanetaryBaseField - { - public PlanetaryBaseFieldSample Sample(float3 direction) - { - var value = direction.x * 0.4f - direction.y * 0.2f + direction.z * 0.5f; - var gradient = new float3(0.4f, -0.2f, 0.5f); - gradient -= direction * math.dot(gradient, direction); - return new(value * 0.08f, gradient * 0.08f, value, gradient, value); - } - } -} diff --git a/tests/CultMath.Tests/PlanetaryPageTests.cs.meta b/tests/CultMath.Tests/PlanetaryPageTests.cs.meta deleted file mode 100644 index 70a43864..00000000 --- a/tests/CultMath.Tests/PlanetaryPageTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7c3ae5598d3b9164a9bd3a736a22ba9a \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryPatchTests.cs b/tests/CultMath.Tests/PlanetaryPatchTests.cs deleted file mode 100644 index 6564c773..00000000 --- a/tests/CultMath.Tests/PlanetaryPatchTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryPatchTests -{ - [Fact] - public void CubeSpherePatchMeshUsesCanonicalTopology() - { - var meshes = PlanetaryPatch.CreateCubeSphere(8); - Assert.Equal(6, meshes.Length); - foreach (var mesh in meshes) - { - Assert.Equal(81, mesh.Vertices.Length); - Assert.Equal(8 * 8 * 6, mesh.Indices.Length); - Assert.All(mesh.Vertices, vertex => Assert.Equal(1, math.length(vertex.UnitDirection), 5)); - foreach (var index in mesh.Indices) Assert.InRange(index, 0, mesh.Vertices.Length - 1); - } - } - - [Fact] - public void NeighborFaceCornersAreSingleValued() - { - var directions = PlanetaryPatch.CreateCubeSphere(1) - .SelectMany(mesh => mesh.Vertices.Select(vertex => vertex.UnitDirection)) - .ToArray(); - var groups = directions.GroupBy(direction => new - { - X = Math.Sign(direction.x), - Y = Math.Sign(direction.y), - Z = Math.Sign(direction.z), - }); - Assert.Equal(8, groups.Count()); - Assert.All(groups, group => - { - var first = group.First(); - Assert.All(group, direction => Assert.InRange(math.distance(first, direction), 0, 1.0e-6f)); - }); - } -} diff --git a/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta b/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta deleted file mode 100644 index 85990473..00000000 --- a/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 36840ef44326f84409f850163b00e1f7 \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryProjectionTests.cs b/tests/CultMath.Tests/PlanetaryProjectionTests.cs deleted file mode 100644 index ad811d6e..00000000 --- a/tests/CultMath.Tests/PlanetaryProjectionTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryProjectionTests -{ - [Theory] - [InlineData(PlanetaryProjectionKind.Equirectangular)] - [InlineData(PlanetaryProjectionKind.WebMercator)] - [InlineData(PlanetaryProjectionKind.EqualEarth)] - [InlineData(PlanetaryProjectionKind.Orthographic)] - [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] - [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] - [InlineData(PlanetaryProjectionKind.LocalTangent)] - public void ProjectionRoundTripsVisibleDirections(PlanetaryProjectionKind kind) - { - var parameters = new PlanetaryProjectionParameters(kind, 0.31, -0.22); - foreach (var longitude in new[] { -0.4, -0.1, 0.2, 0.65 }) - foreach (var latitude in new[] { -0.6, -0.15, 0.3, 0.7 }) - { - var direction = Direction(longitude + parameters.CenterLongitude, latitude); - if (!PlanetaryProjection.TryForward(direction, parameters, out var map)) continue; - Assert.True(PlanetaryProjection.TryInverse(map, parameters, out var roundTrip)); - Assert.InRange(math.distance(direction, roundTrip), 0, 3.0e-5f); - } - } - - [Fact] - public void CubeAtlasRoundTripsEveryFaceInterior() - { - var projection = new PlanetaryProjectionParameters(PlanetaryProjectionKind.CubeAtlas); - foreach (var face in Enum.GetValues()) - { - var direction = PlanetaryTopology.Direction(new(face, 0.23, -0.41)); - Assert.True(PlanetaryProjection.TryForward(direction, projection, out var map)); - Assert.True(PlanetaryProjection.TryInverse(map, projection, out var roundTrip)); - Assert.InRange(math.distance(direction, roundTrip), 0, 2.0e-6f); - } - } - - [Fact] - public void MercatorRejectsPolarSingularity() - { - var northPole = new float3(0, 0, 1); - Assert.False(PlanetaryProjection.TryForward(northPole, new(PlanetaryProjectionKind.WebMercator), out _)); - } - - [Fact] - public void MapTileBakerCarriesFieldIdentityAndProjectionValidity() - { - var field = new PlanetaryFieldDefinition(99, 10, 0, AdvancedErosionParameters.Default); - var layout = new PlanetaryMapTileLayout( - new(PlanetaryProjectionKind.Orthographic), 0, 0, 0, 9, 1); - var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 7, layout, PlanetaryQueryScale.AtFootprint(0.1f)); - var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); - Assert.Equal(key, tile.Key); - Assert.Equal(layout.StorageSize * layout.StorageSize, tile.Samples.Length); - Assert.Contains(tile.Validity, valid => valid); - Assert.Contains(tile.Validity, valid => !valid); - Assert.All(tile.Samples.Where((_, index) => tile.Validity[index]), sample => Assert.Equal(field.FieldVersion, sample.FieldVersion)); - } - - private static float3 Direction(double longitude, double latitude) - { - var cos = Math.Cos(latitude); - return new((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); - } - - private readonly struct FlatField : IPlanetaryBaseField - { - public PlanetaryBaseFieldSample Sample(float3 unitDirection) => new(0, float3.zero, 0, float3.zero, 0); - } -} diff --git a/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta b/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta deleted file mode 100644 index 7f37f78f..00000000 --- a/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f370036bb1663524fb4a4d47dfd50401 \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryQueryTests.cs b/tests/CultMath.Tests/PlanetaryQueryTests.cs deleted file mode 100644 index 48d865f4..00000000 --- a/tests/CultMath.Tests/PlanetaryQueryTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryQueryTests -{ - private static readonly PlanetaryFieldDefinition Field = new(12, 10, 0, AdvancedErosionParameters.Default with { Strength = 0, Octaves = 0 }); - private static readonly PlanetaryQueryScale Scale = PlanetaryQueryScale.AtFootprint(0.1f, 0); - - [Fact] - public void RayIntersectionAndClearanceUseTheCpuSurfaceContract() - { - var source = new BulgedSphere(); - Assert.True(PlanetaryQueries.TryIntersectRay( - Field, new double3(0, 0, 20), new double3(0, 0, -1), 30, - Scale, new PlanetaryRadialBounds(-1, 1), source, out var hit)); - Assert.InRange(hit.Distance, 8.99999, 9.00001); - Assert.InRange(hit.PlanetLocalPosition.z, 10.99999, 11.00001); - var clearance = PlanetaryQueries.SegmentClearance( - Field, new double3(-2, 0, 12), new double3(2, 0, 12), 0.1, Scale, source); - Assert.True(clearance > 0.8); - } - - [Fact] - public void GreatCircleAndRegionQueriesReturnVersionedSamples() - { - var source = new BulgedSphere(); - var path = PlanetaryQueries.SampleGreatCircle(Field, new float3(1, 0, 0), new float3(0, 1, 0), 8, Scale, source); - Assert.Equal(9, path.Length); - Assert.Equal(MathF.PI * 5, path[^1].Distance, 4); - Assert.All(path, point => Assert.Equal(Field.FieldVersion, point.Surface.FieldVersion)); - var summary = PlanetaryQueries.Summarize(Field, path.Select(point => point.Surface.UnitDirection).ToArray(), Scale, source); - Assert.True(summary.MaximumDisplacement >= summary.MinimumDisplacement); - Assert.True(summary.MaximumSlope >= 0); - } - - private readonly struct BulgedSphere : IPlanetaryBaseField - { - public PlanetaryBaseFieldSample Sample(float3 direction) - { - var displacement = direction.z; - var gradient = new float3(0, 0, 1) - direction * direction.z; - return new(displacement, gradient, 0, float3.zero, 0); - } - } -} diff --git a/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta b/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta deleted file mode 100644 index d72b2cb6..00000000 --- a/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 93cf8771fdd520b41a4e931a6566a80a \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs deleted file mode 100644 index d0263b67..00000000 --- a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryRadialRefinementTests -{ - [Fact] - public void FourBoundedStepsConvergeFromTheRasterCaptureShell() - { - var center=new float3(0.3f,-0.2f,0.7f); - var radial=math.normalize(new float3(0.2f,-0.4f,0.9f)); - var targetRadius=3.85f; - var target=center+radial*targetRadius; - var origin=target+math.normalize(radial+new float3(0.12f,-0.05f,0.02f))*6.0f; - var position=center+radial*(targetRadius+0.30f); - var ray=math.normalize(position-origin); - for(var step=0;step<4;step++) - position=PlanetaryRadialRefinement.Step(position,ray,center,targetRadius); - Assert.InRange(math.abs(math.length(position-center)-targetRadius),0.0f,1.0e-4f); - } - - [Fact] - public void StepClampsWeakDerivativeCorrections() - { - var position=new float3(0,0,4.1f); - var result=PlanetaryRadialRefinement.Step(position,new float3(1,0,0),float3.zero,3.8f); - Assert.InRange(math.length(result-position),0.07999f,0.08001f); - } -} diff --git a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta deleted file mode 100644 index b9f3bea9..00000000 --- a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d34c0018ee2b700448b16540eca8ebcf \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryResidencyTests.cs b/tests/CultMath.Tests/PlanetaryResidencyTests.cs deleted file mode 100644 index d2b89b7a..00000000 --- a/tests/CultMath.Tests/PlanetaryResidencyTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryResidencyTests -{ - private static readonly PlanetaryFieldDefinition Field = new( - 1, 8.4f, 0, AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); - private static readonly PlanetaryLodParameters Lod = new(5, 17, 2, 720, 8.4f / 65536); - - [Fact] - public void SelectorReturnsSixRootsAndOneAncestorChain() - { - var direction = math.normalize(new float3(-0.2f, 0.91f, 0.37f)); - var selected = PlanetaryLodSelector.SelectAncestorChain(Field, direction, 8.45f, Lod); - Assert.Equal(6, selected.Count(tile => tile.Level == 0)); - var descendants = selected.Where(tile => tile.Level > 0).OrderBy(tile => tile.Level).ToArray(); - for (var i = 1; i < descendants.Length; i++) Assert.Equal(descendants[i - 1], descendants[i].Parent()); - if (descendants.Length > 0) Assert.Equal(PlanetaryTopology.TileAt(direction, descendants[^1].Level), descendants[^1]); - } - - [Fact] - public void ResidencyFadesResidualsWithoutChangingContentDuringPresentation() - { - var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); - var child = root.Child(1, 0); - var residency = new PlanetaryResidualResidency(); - var arrival = residency.Update(new[] { root, child }, 0, 1); - var midpoint = residency.Update(new[] { root, child }, 0.5f, 1); - var settled = residency.Update(new[] { root, child }, 1, 1); - Assert.Equal(arrival.ContentVersion, midpoint.ContentVersion); - Assert.Equal(midpoint.ContentVersion, settled.ContentVersion); - Assert.Equal(0, arrival.Tiles.Single(tile => tile.Tile == child).Blend); - Assert.Equal(0.5f, midpoint.Tiles.Single(tile => tile.Tile == child).Blend); - Assert.Equal(1, settled.Tiles.Single(tile => tile.Tile == child).Blend); - - var leaving = residency.Update(new[] { root }, 1.25f, 1); - Assert.True(leaving.Tiles.Single(tile => tile.Tile == child).Departing); - Assert.Equal(1, leaving.Tiles.Single(tile => tile.Tile == child).Blend); - var gone = residency.Update(new[] { root }, 2.25f, 1); - Assert.DoesNotContain(gone.Tiles, tile => tile.Tile == child); - } - - [Fact] - public void ReversingAnArrivalDepartsFromItsCurrentWeightWithoutJumping() - { - var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); - var child = root.Child(0, 1); - var residency = new PlanetaryResidualResidency(); - _ = residency.Update(new[] { root, child }, 0, 1); - var arriving = residency.Update(new[] { root, child }, 0.25f, 1); - var departing = residency.Update(new[] { root }, 0.25f, 1); - Assert.Equal(0.25f, arriving.Tiles.Single(tile => tile.Tile == child).Blend); - Assert.Equal(0.25f, departing.Tiles.Single(tile => tile.Tile == child).Blend); - Assert.True(departing.Tiles.Single(tile => tile.Tile == child).Departing); - } -} diff --git a/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta b/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta deleted file mode 100644 index be7de2b4..00000000 --- a/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9ece3b02dbd3d7448baabee7f1fba0ab \ No newline at end of file diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs b/tests/CultMath.Tests/PlanetaryTopologyTests.cs deleted file mode 100644 index 759928d8..00000000 --- a/tests/CultMath.Tests/PlanetaryTopologyTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class PlanetaryTopologyTests -{ - [Fact] - public void FaceDirectionRoundTripsAcrossEveryFace() - { - foreach (var face in Enum.GetValues()) - foreach (var u in new[] { -0.9, -0.25, 0.0, 0.4, 0.9 }) - foreach (var v in new[] { -0.85, -0.1, 0.3, 0.88 }) - { - var direction = PlanetaryTopology.Direction(new(face, u, v)); - var roundTrip = PlanetaryTopology.FaceCoordinate(direction); - Assert.Equal(face, roundTrip.Face); - Assert.Equal(u, roundTrip.U, 5); - Assert.Equal(v, roundTrip.V, 5); - } - } - - [Fact] - public void SiblingPagesShareBitStableBoundaryDirections() - { - var left = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 17, 2); - var right = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2); - for (var y = 0; y < left.InteriorSize; y++) - { - var a = PlanetaryPageSampling.Direction(left, left.BorderSize + left.InteriorSize - 1, left.BorderSize + y); - var b = PlanetaryPageSampling.Direction(right, right.BorderSize, right.BorderSize + y); - Assert.Equal(a, b); - } - } - - [Fact] - public void DirectionSelectsContainingTileAndLocalCoordinate() - { - var expected = new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 5, 19, 7); - var direction = PlanetaryPageSampling.DirectionAtLocal(new(expected, 17, 2), 0.37, 0.61); - Assert.Equal(expected, PlanetaryTopology.TileAt(direction, expected.Level)); - Assert.True(PlanetaryTopology.TryLocalCoordinate(direction, expected, out var local)); - Assert.Equal(0.37f, local.x, 4); - Assert.Equal(0.61f, local.y, 4); - } - - [Fact] - public void BorderSamplesDoNotRequireNeighborResidency() - { - var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), 17, 3); - var edge = PlanetaryPageSampling.Direction(page, page.BorderSize + page.InteriorSize - 1, page.BorderSize + 8); - var border = PlanetaryPageSampling.Direction(page, page.StorageSize - 1, page.BorderSize + 8); - Assert.NotEqual(edge, border); - Assert.Equal(1, math.length(border), 5); - } - - [Fact] - public void NormalizedCubeBaselineSharesFaceEdges() - { - var a = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveZ, 1, -0.4)); - var b = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveX, -1, -0.4)); - Assert.InRange(math.distance(a, b), 0, 1.0e-6f); - } -} diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta b/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta deleted file mode 100644 index b6930142..00000000 --- a/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: be3b0ec07fe65344db889d1e98448113 \ No newline at end of file diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs b/tests/CultMath.Tests/SphericalErosionTests.cs deleted file mode 100644 index f634ba6d..00000000 --- a/tests/CultMath.Tests/SphericalErosionTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class SphericalErosionTests -{ - [Fact] - public void SampleIsDeterministicAndScaleInvariant() - { - var parameters = SphericalErosionParameters.Default with { Seed = 42.0f }; - var a = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); - var repeated = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); - var b = SphericalErosion.Sample(new float3(3.1f, -7.4f, 5.9f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); - - Assert.Equal(a, repeated); - Assert.InRange(math.abs(a.HeightOffset-b.HeightOffset),0.0f,1.0e-6f); - Assert.InRange(math.abs(a.Ridge-b.Ridge),0.0f,1.0e-6f); - Assert.InRange(math.abs(a.Gully-b.Gully),0.0f,5.0e-5f); - Assert.InRange(math.length(a.Gradient-b.Gradient),0.0f,5.0e-6f); - Assert.InRange(a.HeightOffset, -0.08f, 0.08f); - Assert.InRange(a.Ridge, 0.0f, 1.0f); - Assert.InRange(a.Gully, 0.0f, 1.0f); - } - - [Fact] - public void NearbySamplesRemainContinuousAcrossAChartBoundary() - { - var parameters = SphericalErosionParameters.Default; - var left = SphericalErosion.Sample(math.normalize(new float3(1.0f, 0.99999f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); - var right = SphericalErosion.Sample(math.normalize(new float3(0.99999f, 1.0f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); - - Assert.InRange(math.abs(left.HeightOffset - right.HeightOffset), 0.0f, 0.0002f); - } -} diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs.meta b/tests/CultMath.Tests/SphericalErosionTests.cs.meta deleted file mode 100644 index 00ff1de0..00000000 --- a/tests/CultMath.Tests/SphericalErosionTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 60eb9e9e9f8ae2948b13403f5080168e \ No newline at end of file diff --git a/tools/CultMath.PlanetaryTileFixture.meta b/tools/CultMath.PlanetaryTileFixture.meta deleted file mode 100644 index 7f40b6ae..00000000 --- a/tools/CultMath.PlanetaryTileFixture.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 7197a2202273a9445b974bdd69109b1a -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/unity/Runtime/PlanetaryPageUpload.cs b/unity/Runtime/PlanetaryPageUpload.cs deleted file mode 100644 index 1a61a707..00000000 --- a/unity/Runtime/PlanetaryPageUpload.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using UnityEngine; - -namespace CultMath.Unity; - -[StructLayout(LayoutKind.Sequential)] -public readonly struct PlanetaryPageInputGpu -{ - public PlanetaryPageInputGpu(Vector4 directionRadius, Vector4 sampling) - { - DirectionRadius = directionRadius; - Sampling = sampling; - } - - public readonly Vector4 DirectionRadius; - public readonly Vector4 Sampling; -} - -[StructLayout(LayoutKind.Sequential)] -public readonly struct PlanetaryPageMetadataGpu -{ - public PlanetaryPageMetadataGpu(Vector4 address, Vector4 layout, Vector4 bounds, Vector4 state) - { - Address = address; - Layout = layout; - Bounds = bounds; - State = state; - } - - public readonly Vector4 Address; - public readonly Vector4 Layout; - public readonly Vector4 Bounds; - public readonly Vector4 State; -} - -public readonly struct PlanetaryPageUpload -{ - public PlanetaryPageUpload( - PlanetaryPageInputGpu[] inputs, - PlanetaryPageMetadataGpu[] metadata, - int outputSampleCount, - ulong contentVersion, - ulong presentationVersion) - { - Inputs = inputs; - Metadata = metadata; - OutputSampleCount = outputSampleCount; - ContentVersion = contentVersion; - PresentationVersion = presentationVersion; - } - - public PlanetaryPageInputGpu[] Inputs { get; } - public PlanetaryPageMetadataGpu[] Metadata { get; } - public int OutputSampleCount { get; } - public ulong ContentVersion { get; } - public ulong PresentationVersion { get; } -} - -public static class PlanetaryPageUploadBuilder -{ - public static PlanetaryPageUpload Build( - in PlanetaryResidencySnapshot snapshot, - int interiorSize, - int borderSize, - float radius) - { - if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); - var inputs = new List(); - var metadata = new PlanetaryPageMetadataGpu[snapshot.Tiles.Length]; - foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index))) - { - var layout = new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate(); - var content = PlanetaryGpuPageBuilder.BuildContent(layout, radius); - var outputOffset = inputs.Count; - foreach (var input in content.Inputs) - { - inputs.Add(new( - ToUnity(input.DirectionRadius), - ToUnity(input.Sampling))); - } - var commonMetadata = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend); - metadata[pageIndex] = new( - ToUnity(commonMetadata.Address), - ToUnity(commonMetadata.Layout), - ToUnity(commonMetadata.Bounds), - ToUnity(commonMetadata.State)); - } - return new(inputs.ToArray(), metadata, inputs.Count, snapshot.ContentVersion, snapshot.PresentationVersion); - } - - private static Vector4 ToUnity(float4 value) => new(value.x, value.y, value.z, value.w); -} diff --git a/unity/Runtime/PlanetaryPageUpload.cs.meta b/unity/Runtime/PlanetaryPageUpload.cs.meta deleted file mode 100644 index 87bcb61a..00000000 --- a/unity/Runtime/PlanetaryPageUpload.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7edd3bb2edb2e6448a2d54fc082c27dc \ No newline at end of file diff --git a/unity/Runtime/PlanetaryPatchMeshAdapter.cs b/unity/Runtime/PlanetaryPatchMeshAdapter.cs deleted file mode 100644 index 69a72a2a..00000000 --- a/unity/Runtime/PlanetaryPatchMeshAdapter.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using UnityEngine; -using UnityEngine.Rendering; - -namespace CultMath.Unity; - -public static class PlanetaryPatchMeshAdapter -{ - public static Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) - { - if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); - var source = PlanetaryPatch.CreateFace(face, cellsPerAxis); - var vertices = new Vector3[source.Vertices.Length]; - var normals = new Vector3[source.Vertices.Length]; - var uv = new Vector2[source.Vertices.Length]; - for (var i = 0; i < source.Vertices.Length; i++) - { - var direction = source.Vertices[i].UnitDirection; - var normal = new Vector3(direction.x, direction.y, direction.z); - vertices[i] = normal * radius; - normals[i] = normal; - uv[i] = new Vector2(source.Vertices[i].LocalCoordinate.x, source.Vertices[i].LocalCoordinate.y); - } - var mesh = new Mesh - { - name = $"CultMath Planetary {face} {cellsPerAxis}x{cellsPerAxis}", - indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, - vertices = vertices, - normals = normals, - uv = uv, - triangles = source.Indices, - }; - mesh.RecalculateBounds(); - return mesh; - } -} diff --git a/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta b/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta deleted file mode 100644 index 69af0abc..00000000 --- a/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9ad7b581e14ef774d8435069bb77ca6a \ No newline at end of file diff --git a/web/planetary-tile.cross-runtime.test.mjs b/web/planetary-tile.cross-runtime.test.mjs deleted file mode 100644 index 2b457219..00000000 --- a/web/planetary-tile.cross-runtime.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { decodePlanetarySurfaceTile } from "./planetary-tile.ts"; - -if (process.argv.length !== 3) throw new Error("Expected a generated CMPT fixture path"); -const bytes = await readFile(process.argv[2]); -const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); -const tile = decodePlanetarySurfaceTile(arrayBuffer); -assert.equal(tile.header.projectionVersion, 3); -assert.equal(tile.header.layerId, 29); -assert.equal(tile.header.projectionKind, 2); -assert.equal(tile.header.interiorSize, 5); -assert.equal(tile.header.borderSize, 1); -assert.equal(tile.storageSize, 7); -assert.equal(tile.samples.length, 49); -assert.ok(tile.samples.some(sample => sample !== null)); -for (const sample of tile.samples) { - if (sample === null) continue; - assert.ok(Number.isFinite(sample.radialDisplacement)); - assert.ok(Number.isFinite(sample.unresolvedHeightBound)); -} diff --git a/web/planetary-tile.cross-runtime.test.mjs.meta b/web/planetary-tile.cross-runtime.test.mjs.meta deleted file mode 100644 index 3c38da54..00000000 --- a/web/planetary-tile.cross-runtime.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 7bc397e5660d2b6448919c41983d35e8 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/web/planetary-tile.test.mjs b/web/planetary-tile.test.mjs deleted file mode 100644 index cb054f28..00000000 --- a/web/planetary-tile.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import assert from "node:assert/strict"; -import { decodePlanetarySurfaceTile } from "./planetary-tile.ts"; - -const bytes = new ArrayBuffer(152); -const view = new DataView(bytes); -let offset = 0; -const u8 = value => view.setUint8(offset++, value); -const u32 = value => { view.setUint32(offset, value, true); offset += 4; }; -const i32 = value => { view.setInt32(offset, value, true); offset += 4; }; -const u64 = value => { view.setBigUint64(offset, value, true); offset += 8; }; -const f32 = value => { view.setFloat32(offset, value, true); offset += 4; }; -const f64 = value => { view.setFloat64(offset, value, true); offset += 8; }; - -for (const character of "CMPT") u8(character.charCodeAt(0)); -u32(1); u64(0x1234n); u32(2); u32(17); i32(0); -f64(0); f64(0); f64(1); -i32(0); i32(0); i32(0); i32(2); i32(0); -f32(10); f32(0.5); i32(4); -u8(1); -for (const value of [1, 0, 0]) f32(value); -f32(8.4); f32(0.25); -for (const value of [0, 0.1, 0]) f32(value); -for (const value of [0.99, -0.1, 0]) f32(value); -for (const value of [0.1, 0.8, 0.2, 2.5, 0.05]) f32(value); -u8(0); u8(0); u8(0); -assert.equal(offset, bytes.byteLength); - -const tile = decodePlanetarySurfaceTile(bytes); -assert.equal(tile.header.fieldVersion, 0x1234n); -assert.equal(tile.header.layerId, 17); -assert.equal(tile.storageSize, 2); -assert.equal(tile.samples.length, 4); -assert.equal(tile.samples[0]?.radialDisplacement, 0.25); -assert.equal(tile.samples[1], null); - -assert.throws(() => decodePlanetarySurfaceTile(bytes.slice(0, bytes.byteLength - 1)), /Truncated/); -const trailing = new Uint8Array(bytes.byteLength + 1); -trailing.set(new Uint8Array(bytes)); -assert.throws(() => decodePlanetarySurfaceTile(trailing.buffer), /trailing/); diff --git a/web/planetary-tile.test.mjs.meta b/web/planetary-tile.test.mjs.meta deleted file mode 100644 index d622095b..00000000 --- a/web/planetary-tile.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 04c93ecab90d465499aa3d60722e6fc3 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/web/planetary-tile.ts b/web/planetary-tile.ts deleted file mode 100644 index e68d70fa..00000000 --- a/web/planetary-tile.ts +++ /dev/null @@ -1,89 +0,0 @@ -export const CULTMATH_PLANETARY_TILE_VERSION = 1; - -export interface PlanetaryTileHeader { - fieldVersion: bigint; - projectionVersion: number; - layerId: number; - projectionKind: number; - centerLongitude: number; - centerLatitude: number; - projectionScale: number; - level: number; - x: number; - y: number; - interiorSize: number; - borderSize: number; - footprintMeters: number; - maximumUnresolvedHeight: number; -} - -export interface PlanetaryTileSample { - direction: readonly [number, number, number]; - radius: number; - radialDisplacement: number; - tangentGradient: readonly [number, number, number]; - surfaceNormal: readonly [number, number, number]; - slope: number; - ridge: number; - gully: number; - finestResolvedWavelength: number; - unresolvedHeightBound: number; -} - -export interface PlanetarySurfaceTile { - header: PlanetaryTileHeader; - storageSize: number; - samples: readonly (PlanetaryTileSample | null)[]; -} - -export function decodePlanetarySurfaceTile(bytes: ArrayBuffer): PlanetarySurfaceTile { - const view = new DataView(bytes); - let offset = 0; - const u8 = () => view.getUint8(offset++); - const u32 = () => { const value = view.getUint32(offset, true); offset += 4; return value; }; - const i32 = () => { const value = view.getInt32(offset, true); offset += 4; return value; }; - const f32 = () => { const value = view.getFloat32(offset, true); offset += 4; return value; }; - const f64 = () => { const value = view.getFloat64(offset, true); offset += 8; return value; }; - const u64 = () => { const value = view.getBigUint64(offset, true); offset += 8; return value; }; - const vector3 = (): [number, number, number] => [f32(), f32(), f32()]; - const requireRemaining = (count: number) => { - if (offset + count > view.byteLength) throw new Error("Truncated CultMath planetary tile"); - }; - - requireRemaining(8); - if (String.fromCharCode(u8(), u8(), u8(), u8()) !== "CMPT") throw new Error("Not a CultMath planetary tile"); - const version = u32(); - if (version !== CULTMATH_PLANETARY_TILE_VERSION) throw new Error(`Unsupported CultMath planetary tile version ${version}`); - requireRemaining(76); - const header: PlanetaryTileHeader = { - fieldVersion: u64(), - projectionVersion: u32(), - layerId: u32(), - projectionKind: i32(), - centerLongitude: f64(), - centerLatitude: f64(), - projectionScale: f64(), - level: i32(), x: i32(), y: i32(), interiorSize: i32(), borderSize: i32(), - footprintMeters: f32(), maximumUnresolvedHeight: f32(), - }; - const count = i32(); - const storageSize = header.interiorSize + header.borderSize * 2; - if (count !== storageSize * storageSize) throw new Error("CultMath planetary tile sample count does not match layout"); - const samples: (PlanetaryTileSample | null)[] = new Array(count); - for (let index = 0; index < count; index++) { - requireRemaining(1); - if (u8() === 0) { samples[index] = null; continue; } - requireRemaining(64); - samples[index] = { - direction: vector3(), - radius: f32(), - radialDisplacement: f32(), - tangentGradient: vector3(), - surfaceNormal: vector3(), - slope: f32(), ridge: f32(), gully: f32(), - finestResolvedWavelength: f32(), unresolvedHeightBound: f32(), - }; - } - if (offset !== view.byteLength) throw new Error("CultMath planetary tile has trailing bytes"); - return { header, storageSize, samples }; -} diff --git a/web/planetary-tile.ts.meta b/web/planetary-tile.ts.meta deleted file mode 100644 index 337da3bd..00000000 --- a/web/planetary-tile.ts.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: f284e64628320844196b7aaea76475a4 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From 69856d4f3aee6594bf5d770c43330f5b9322389c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:29:59 +0100 Subject: [PATCH 19/23] Set CultMath release version 0.1.0 --- src/CultMath/CultMath.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index 01901b6b..73b3fd51 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -8,6 +8,7 @@ CultMath CultMath CultMath + 0.1.0 Clean-room C# math shaped for HLSL-style renderer code. GameCult https://github.com/GameCult/CultMath From 9a5597d233eb6c3ae25dccf17b77b7db99dfe17a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:52:09 +0100 Subject: [PATCH 20/23] Add precompiled CultMath Unity package --- .gitignore | 1 + README.md | 21 +- scripts/build-unity-package.ps1 | 107 +++++++ unity/Runtime/CultMath.Unity.asmdef | 16 - unity/org.gamecult.cultmath/LICENSE.md | 10 + unity/org.gamecult.cultmath/README.md | 28 ++ .../{ => org.gamecult.cultmath}/Runtime.meta | 2 +- .../Runtime/CultMath.asmdef | 16 + .../Runtime/CultMath.asmdef.meta} | 2 +- .../Runtime/Plugins.meta | 8 + .../Runtime/Plugins/CultMath.dll | Bin 0 -> 44544 bytes .../Runtime/Plugins/CultMath.dll.meta | 21 ++ .../Runtime/Plugins/CultMath.pdb | Bin 0 -> 23424 bytes .../Runtime/Plugins/CultMath.pdb.meta | 4 +- unity/org.gamecult.cultmath/Shaders.meta | 8 + .../Shaders/CultMath.hlsl | 302 ++++++++++++++++++ .../Shaders/CultMath.hlsl.meta | 7 + .../THIRD-PARTY-NOTICES.md | 11 + .../org.gamecult.cultmath/package.json | 11 +- 19 files changed, 542 insertions(+), 33 deletions(-) create mode 100644 scripts/build-unity-package.ps1 delete mode 100644 unity/Runtime/CultMath.Unity.asmdef create mode 100644 unity/org.gamecult.cultmath/LICENSE.md create mode 100644 unity/org.gamecult.cultmath/README.md rename unity/{ => org.gamecult.cultmath}/Runtime.meta (77%) create mode 100644 unity/org.gamecult.cultmath/Runtime/CultMath.asmdef rename unity/{Runtime/CultMath.Unity.asmdef.meta => org.gamecult.cultmath/Runtime/CultMath.asmdef.meta} (76%) create mode 100644 unity/org.gamecult.cultmath/Runtime/Plugins.meta create mode 100644 unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll create mode 100644 unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta create mode 100644 unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb rename package.json.meta => unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta (60%) create mode 100644 unity/org.gamecult.cultmath/Shaders.meta create mode 100644 unity/org.gamecult.cultmath/Shaders/CultMath.hlsl create mode 100644 unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta create mode 100644 unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md rename package.json => unity/org.gamecult.cultmath/package.json (52%) diff --git a/.gitignore b/.gitignore index 3779002b..c9b141d0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ target/ **/target/ /native/cultmath-core/target.meta .tools/ +/artifacts/ diff --git a/README.md b/README.md index b471c7fd..02d47752 100644 --- a/README.md +++ b/README.md @@ -73,21 +73,24 @@ dotnet build CultMath.slnx dotnet test CultMath.slnx cargo test --manifest-path native/cultmath-core/Cargo.toml cargo build --release --manifest-path native/cultmath-core/Cargo.toml +.\scripts\build-unity-package.ps1 ``` -## Unity compiler +## Unity package -CultMath uses C# 10 record structs and file-scoped namespaces. Unity projects -must select the compiler's latest language mode with an `Assets/csc.rsp` file: +CultMath's Unity 2021.3 surface is the tracked precompiled package at +`unity/org.gamecult.cultmath`. Unity consumes `CultMath.dll` and the numeric +`CultMath.hlsl`; it does not compile the repository's current-language C# source +tree and does not require a consumer `csc.rsp` workaround. -```text --langversion:latest +```json +"org.gamecult.cultmath": "https://github.com/GameCult/CultMath.git?path=/unity/org.gamecult.cultmath" ``` -The package's Unity smoke project verifies this contract against Unity 6. The -setting is already present in current GameCult Unity clients; it belongs to the -consumer project because Unity only reads compiler response files from its -`Assets` root. +The repository root is not a Unity package. Build the tracked package with +`scripts/build-unity-package.ps1`; the script fresh-builds the netstandard2.1 +assembly and rejects stale binaries, missing metadata, unexpected assemblies, +or leaked C# source. ## Shader Tooling diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 new file mode 100644 index 00000000..329cedb7 --- /dev/null +++ b/scripts/build-unity-package.ps1 @@ -0,0 +1,107 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultmath" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\CultMath\CultMath.csproj" +$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultmath" +$buildRoot = Join-Path $repoRoot "artifacts\cultmath-unity-build" +$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} +$freshAssembly = Join-Path $buildRoot "CultMath.dll" +$freshSymbols = Join-Path $buildRoot "CultMath.pdb" +$trackedPluginRoot = Join-Path $templateRoot "Runtime\Plugins" +$trackedAssembly = Join-Path $trackedPluginRoot "CultMath.dll" +$trackedSymbols = Join-Path $trackedPluginRoot "CultMath.pdb" + +if (Test-Path -LiteralPath $buildRoot) { + Remove-Item -LiteralPath $buildRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot ` + --disable-build-servers -p:UseSharedCompilation=false -p:ContinuousIntegrationBuild=true -m:1 +if ($LASTEXITCODE -ne 0) { + throw "CultMath Unity assembly build failed with exit code $LASTEXITCODE" +} + +foreach ($path in @($freshAssembly, $freshSymbols)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "CultMath Unity build is missing $path" + } +} + +$requiredTemplateFiles = @( + "LICENSE.md", + "README.md", + "THIRD-PARTY-NOTICES.md", + "package.json", + "Runtime\CultMath.asmdef", + "Runtime\CultMath.asmdef.meta", + "Runtime\Plugins\CultMath.dll", + "Runtime\Plugins\CultMath.dll.meta", + "Runtime\Plugins\CultMath.pdb", + "Runtime\Plugins\CultMath.pdb.meta", + "Shaders\CultMath.hlsl", + "Shaders\CultMath.hlsl.meta" +) +foreach ($relativePath in $requiredTemplateFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) { + throw "Tracked CultMath Unity package is incomplete: missing $relativePath" + } +} + +$unityAssetFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File | Where-Object { + $_.Extension -in @(".asmdef", ".dll", ".pdb", ".hlsl") +}) +$missingAssetMetas = @($unityAssetFiles | Where-Object { -not (Test-Path -LiteralPath ($_.FullName + ".meta")) }) +$missingDirectoryMetas = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -Directory | Where-Object { + -not (Test-Path -LiteralPath ($_.FullName + ".meta")) +}) +if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { + $missing = @($missingAssetMetas.FullName) + @($missingDirectoryMetas.FullName) + throw "Tracked CultMath Unity package has assets without stable .meta files: $($missing -join ', ')" +} + +$manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json +$expectedFileVersion = "$($manifest.version).0" +$trackedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($trackedAssembly).FileVersion +$freshFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($freshAssembly).FileVersion +if ($trackedFileVersion -ne $expectedFileVersion -or $freshFileVersion -ne $expectedFileVersion) { + throw "CultMath Unity assemblies must match package version $expectedFileVersion; tracked=$trackedFileVersion fresh=$freshFileVersion" +} + +foreach ($pair in @(@($freshAssembly, $trackedAssembly), @($freshSymbols, $trackedSymbols))) { + $freshHash = (Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash + $trackedHash = (Get-FileHash -LiteralPath $pair[1] -Algorithm SHA256).Hash + if ($freshHash -ne $trackedHash) { + throw "Tracked Unity artifact is stale: $($pair[1]) differs from a fresh deterministic build" + } +} + +$trackedSourceFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.cs") +if ($trackedSourceFiles.Count -ne 0) { + throw "CultMath Unity package must not compile repository C# sources: $($trackedSourceFiles.FullName -join ', ')" +} + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +Copy-Item -Path (Join-Path $templateRoot "*") -Destination $outputRoot -Recurse -Force + +$stagedAssemblies = @(Get-ChildItem -LiteralPath (Join-Path $outputRoot "Runtime\Plugins") -Filter "*.dll") +$stagedSources = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.cs") +if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "CultMath.dll" -or $stagedSources.Count -ne 0) { + throw "CultMath package must contain one owned DLL and no C# source files" +} + +Write-Host "CultMath Unity package: $outputRoot" +Write-Host "Package: $($manifest.name)@$($manifest.version)" +Write-Host "Managed assemblies: $($stagedAssemblies.Count)" +Write-Host "C# source files: $($stagedSources.Count)" diff --git a/unity/Runtime/CultMath.Unity.asmdef b/unity/Runtime/CultMath.Unity.asmdef deleted file mode 100644 index 632c4e4c..00000000 --- a/unity/Runtime/CultMath.Unity.asmdef +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "CultMath.Unity", - "rootNamespace": "CultMath.Unity", - "references": [ - "CultMath" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} diff --git a/unity/org.gamecult.cultmath/LICENSE.md b/unity/org.gamecult.cultmath/LICENSE.md new file mode 100644 index 00000000..5e50f2d6 --- /dev/null +++ b/unity/org.gamecult.cultmath/LICENSE.md @@ -0,0 +1,10 @@ +# CultMath License + +CultMath is licensed under the Mozilla Public License 2.0 (`MPL-2.0`). + +The complete license text is available from the Mozilla Foundation at +. Source and modification history for the +precompiled assembly in this package are available in the GameCult/CultMath +repository at the corresponding package tag. + +Third-party code retains its original license. See `THIRD-PARTY-NOTICES.md`. diff --git a/unity/org.gamecult.cultmath/README.md b/unity/org.gamecult.cultmath/README.md new file mode 100644 index 00000000..1802ae9a --- /dev/null +++ b/unity/org.gamecult.cultmath/README.md @@ -0,0 +1,28 @@ +# CultMath for Unity + +This is CultMath's Unity 2021.3-compatible Git-UPM surface. It contains the +precompiled `CultMath.dll`, portable symbols, and the numeric +`Shaders/CultMath.hlsl` mirror. + +Unity does not compile CultMath's repository source tree. That source uses the +current C# language and remains owned by the normal .NET project. The package +facade references only the tracked precompiled assembly, so consumers do not +need `csc.rsp` language overrides. + +Build and inspect the package from the repository root: + +```powershell +.\scripts\build-unity-package.ps1 +``` + +The builder fresh-builds CultMath for `netstandard2.1`, verifies the tracked +DLL/PDB and stable Unity metadata, and stages an inspectable package under +`artifacts/unity/org.gamecult.cultmath`. + +Consume the repository package with: + +```json +"org.gamecult.cultmath": "https://github.com/GameCult/CultMath.git?path=/unity/org.gamecult.cultmath" +``` + +Production consumers should pin that URL to a commit or release tag. diff --git a/unity/Runtime.meta b/unity/org.gamecult.cultmath/Runtime.meta similarity index 77% rename from unity/Runtime.meta rename to unity/org.gamecult.cultmath/Runtime.meta index 8e0678f8..b0e76599 100644 --- a/unity/Runtime.meta +++ b/unity/org.gamecult.cultmath/Runtime.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8226365c03c5e1a438408b7e42b98740 +guid: a31bf2bb35e54dd1ad1c765337682769 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef b/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef new file mode 100644 index 00000000..72c968e8 --- /dev/null +++ b/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef @@ -0,0 +1,16 @@ +{ + "name": "CultMath", + "rootNamespace": "CultMath", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": true, + "precompiledReferences": [ + "CultMath.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/unity/Runtime/CultMath.Unity.asmdef.meta b/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta similarity index 76% rename from unity/Runtime/CultMath.Unity.asmdef.meta rename to unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta index 2dd3a36a..0ec09c2f 100644 --- a/unity/Runtime/CultMath.Unity.asmdef.meta +++ b/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d1906c397c420ae4eb222a3f09f36843 +guid: 4b5c49d478124399a5310d7a9d8ed166 AssemblyDefinitionImporter: externalObjects: {} userData: diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins.meta b/unity/org.gamecult.cultmath/Runtime/Plugins.meta new file mode 100644 index 00000000..fe8acafa --- /dev/null +++ b/unity/org.gamecult.cultmath/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f84156013e814c14b73f668b6610b9e0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll new file mode 100644 index 0000000000000000000000000000000000000000..8cdb6700145956da7fb303fc1d9b0a39f19c91a4 GIT binary patch literal 44544 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 literal 0 HcmV?d00001 diff --git a/package.json.meta b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta similarity index 60% rename from package.json.meta rename to unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta index 23ed71cf..e18fedbe 100644 --- a/package.json.meta +++ b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta @@ -1,6 +1,6 @@ fileFormatVersion: 2 -guid: 1abf066549a826c4ba7a30d6676735c0 -PackageManifestImporter: +guid: a8cc555f643a4d7eb4dd340025b208f6 +DefaultImporter: externalObjects: {} userData: assetBundleName: diff --git a/unity/org.gamecult.cultmath/Shaders.meta b/unity/org.gamecult.cultmath/Shaders.meta new file mode 100644 index 00000000..1e7e7110 --- /dev/null +++ b/unity/org.gamecult.cultmath/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ca5d773061ed49b3a173526048d40cb3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl new file mode 100644 index 00000000..4c00fb69 --- /dev/null +++ b/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -0,0 +1,302 @@ +#ifndef CULTMATH_HLSL +#define CULTMATH_HLSL + +// CultMath HLSL mirror. +// +// HLSL already owns the float2/float3/float4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +static const float CULTMATH_PI = 3.14159265358979323846; +static const float CULTMATH_TAU = 6.28318530717958647692; +static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +float2 cultmath_frac(float2 value) { return value - floor(value); } +float3 cultmath_frac(float3 value) { return value - floor(value); } +float4 cultmath_frac(float4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } +float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } +float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } + +float cultmath_saturate(float value) { return cultmath_clamp(value, 0.0, 1.0); } +float2 cultmath_saturate(float2 value) { return cultmath_clamp(value, float2(0.0, 0.0), float2(1.0, 1.0)); } +float3 cultmath_saturate(float3 value) { return cultmath_clamp(value, float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0)); } +float4 cultmath_saturate(float4 value) { return cultmath_clamp(value, float4(0.0, 0.0, 0.0, 0.0), float4(1.0, 1.0, 1.0, 1.0)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } +float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } +float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) +{ + float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); +} + +float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) +{ + float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); +} + +float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) +{ + float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +float2 cultmath_simplex_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_simplex_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_simplex_permute(float3 value) { return cultmath_simplex_mod289(((value * 34.0) + 1.0) * value); } + +float cultmath_simplex_noise(float2 value) +{ + const float4 c = float4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + float2 i = floor(value + dot(value, c.yy)); + float2 x0 = value - i + dot(i, c.xx); + float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0); + float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_simplex_mod289(i); + float3 p = cultmath_simplex_permute( + cultmath_simplex_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0)); + float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + float3 x = 2.0 * frac(p * c.w) - 1.0; + float3 h = abs(x) - 0.5; + float3 ox = floor(x + 0.5); + float3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + float3 g = float3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +float cultmath_lengthsq(float2 value) { return dot(value, value); } +float cultmath_lengthsq(float3 value) { return dot(value, value); } +float cultmath_lengthsq(float4 value) { return dot(value, value); } + +float cultmath_distance(float2 left, float2 right) { return length(left - right); } +float cultmath_distance(float3 left, float3 right) { return length(left - right); } +float cultmath_distance(float4 left, float4 right) { return length(left - right); } + +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + +float2 cultmath_reflect(float2 incident, float2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float3 cultmath_reflect(float3 incident, float3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float2 cultmath_rotate(float2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return float2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +float2 cultmath_rotate_degrees(float2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(float2 value) { return value.x + value.y; } +float cultmath_csum(float3 value) { return value.x + value.y + value.z; } +float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } +float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } + +float cultmath_value_noise(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + float2(1.0, 0.0)); + float c = cultmath_hash(cell + float2(0.0, 1.0)); + float d = cultmath_hash(cell + float2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) +{ + return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; +} + +float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) +{ + float2 position = uv * scale; + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 base_uv = cell / scale; + + float row0 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, + local.x); + float row1 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, + local.x); + float row2 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, + local.x); + float row3 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, + local.x); + + return cultmath_catmullrom(row0, row1, row2, row3, local.y); +} + +#endif diff --git a/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta b/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta new file mode 100644 index 00000000..d3b6d2b3 --- /dev/null +++ b/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a46136cb4acb4ff2bc47b6a4f786e4b3 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md b/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..c283df2b --- /dev/null +++ b/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## 2D Simplex Noise + +`math.simplex_noise` and `cultmath_simplex_noise` port the textureless 2D +simplex-noise function by Ian McEwan and Ashima Arts, as distributed by Stefan +Gustavson's `webgl-noise` project and Unity Mathematics. + +- Copyright 2011 Ashima Arts. +- Upstream: +- License: MIT. diff --git a/package.json b/unity/org.gamecult.cultmath/package.json similarity index 52% rename from package.json rename to unity/org.gamecult.cultmath/package.json index 6da2a17a..e45d0a35 100644 --- a/package.json +++ b/unity/org.gamecult.cultmath/package.json @@ -3,12 +3,15 @@ "displayName": "CultMath", "version": "0.1.0", "unity": "2021.3", - "description": "Clean-room C# math primitives shaped for HLSL-style renderer and simulation code.", + "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", + "license": "MPL-2.0", + "author": { + "name": "GameCult" + }, "keywords": [ "GameCult", "Math", + "HLSL", "Simulation" - ], - "license": "MPL-2.0", - "category": "Scripting" + ] } From b43cf4904f855e3d1b6da8a949326aa031bc5219 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 05:07:32 +0100 Subject: [PATCH 21/23] Fix CultMath Unity plugin references --- scripts/build-unity-package.ps1 | 20 ++++++++++++++---- unity/org.gamecult.cultmath/LICENSE.md.meta | 2 ++ unity/org.gamecult.cultmath/README.md | 4 ++-- unity/org.gamecult.cultmath/README.md.meta | 2 ++ .../Runtime/CultMath.asmdef | 16 -------------- .../Runtime/CultMath.asmdef.meta | 7 ------ .../Runtime/Plugins/CultMath.dll | Bin 44544 -> 44544 bytes .../Runtime/Plugins/CultMath.dll.meta | 2 +- .../Runtime/Plugins/CultMath.pdb | Bin 23424 -> 23420 bytes .../THIRD-PARTY-NOTICES.md.meta | 2 ++ unity/org.gamecult.cultmath/package.json.meta | 2 ++ 11 files changed, 27 insertions(+), 30 deletions(-) create mode 100644 unity/org.gamecult.cultmath/LICENSE.md.meta create mode 100644 unity/org.gamecult.cultmath/README.md.meta delete mode 100644 unity/org.gamecult.cultmath/Runtime/CultMath.asmdef delete mode 100644 unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta create mode 100644 unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta create mode 100644 unity/org.gamecult.cultmath/package.json.meta diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 329cedb7..f5fcacc0 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -41,11 +41,13 @@ foreach ($path in @($freshAssembly, $freshSymbols)) { $requiredTemplateFiles = @( "LICENSE.md", + "LICENSE.md.meta", "README.md", + "README.md.meta", "THIRD-PARTY-NOTICES.md", + "THIRD-PARTY-NOTICES.md.meta", "package.json", - "Runtime\CultMath.asmdef", - "Runtime\CultMath.asmdef.meta", + "package.json.meta", "Runtime\Plugins\CultMath.dll", "Runtime\Plugins\CultMath.dll.meta", "Runtime\Plugins\CultMath.pdb", @@ -72,6 +74,14 @@ if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { } $manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json +$pluginMeta = Get-Content -LiteralPath ($trackedAssembly + ".meta") -Raw +if ($pluginMeta -notmatch '(?m)^\s*isExplicitlyReferenced:\s*0\s*$') { + throw "CultMath.dll must be auto-referenced; explicit-only plugins do not reach dependent Unity asmdefs" +} +$facadeDefinitions = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.asmdef") +if ($facadeDefinitions.Count -ne 0) { + throw "CultMath's precompiled DLL is the Unity assembly authority; empty asmdef facades are forbidden" +} $expectedFileVersion = "$($manifest.version).0" $trackedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($trackedAssembly).FileVersion $freshFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($freshAssembly).FileVersion @@ -97,11 +107,13 @@ Copy-Item -Path (Join-Path $templateRoot "*") -Destination $outputRoot -Recurse $stagedAssemblies = @(Get-ChildItem -LiteralPath (Join-Path $outputRoot "Runtime\Plugins") -Filter "*.dll") $stagedSources = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.cs") -if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "CultMath.dll" -or $stagedSources.Count -ne 0) { - throw "CultMath package must contain one owned DLL and no C# source files" +$stagedFacades = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.asmdef") +if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "CultMath.dll" -or $stagedSources.Count -ne 0 -or $stagedFacades.Count -ne 0) { + throw "CultMath package must contain one owned auto-referenced DLL, no C# source files, and no asmdef facade" } Write-Host "CultMath Unity package: $outputRoot" Write-Host "Package: $($manifest.name)@$($manifest.version)" Write-Host "Managed assemblies: $($stagedAssemblies.Count)" Write-Host "C# source files: $($stagedSources.Count)" +Write-Host "Asmdef facades: $($stagedFacades.Count)" diff --git a/unity/org.gamecult.cultmath/LICENSE.md.meta b/unity/org.gamecult.cultmath/LICENSE.md.meta new file mode 100644 index 00000000..c234cd28 --- /dev/null +++ b/unity/org.gamecult.cultmath/LICENSE.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7172bce28532878409e57f5fbbb12719 \ No newline at end of file diff --git a/unity/org.gamecult.cultmath/README.md b/unity/org.gamecult.cultmath/README.md index 1802ae9a..223e16ce 100644 --- a/unity/org.gamecult.cultmath/README.md +++ b/unity/org.gamecult.cultmath/README.md @@ -6,8 +6,8 @@ precompiled `CultMath.dll`, portable symbols, and the numeric Unity does not compile CultMath's repository source tree. That source uses the current C# language and remains owned by the normal .NET project. The package -facade references only the tracked precompiled assembly, so consumers do not -need `csc.rsp` language overrides. +imports only the tracked auto-referenced precompiled assembly, so consumers do +not need `csc.rsp` language overrides or an asmdef propagation shim. Build and inspect the package from the repository root: diff --git a/unity/org.gamecult.cultmath/README.md.meta b/unity/org.gamecult.cultmath/README.md.meta new file mode 100644 index 00000000..0ea270c2 --- /dev/null +++ b/unity/org.gamecult.cultmath/README.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7429934026d747489a3876fd15576e5 \ No newline at end of file diff --git a/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef b/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef deleted file mode 100644 index 72c968e8..00000000 --- a/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "CultMath", - "rootNamespace": "CultMath", - "references": [], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": true, - "overrideReferences": true, - "precompiledReferences": [ - "CultMath.dll" - ], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": true -} diff --git a/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta b/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta deleted file mode 100644 index 0ec09c2f..00000000 --- a/unity/org.gamecult.cultmath/Runtime/CultMath.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 4b5c49d478124399a5310d7a9d8ed166 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll index 8cdb6700145956da7fb303fc1d9b0a39f19c91a4..91b51d8d33ed8fcbc53a66b07131e2442f17c464 100644 GIT binary patch delta 237 zcmZp;!_;txX+j6fe6!yD8+&SI3Md?&m2hM4B;O@kA8G1bU4B{@0G&^*Z;h*FX)EmP7`4b2lL=PZ4|A~n_c#^iuy0Rl^PQ$L;D z?&myz@rr5F%r?HBykVKI0#xv`JX8>X6bNim|0qwnx&Ye z87HQunweUfB&C_An423Un;07#8>E>gSsEJ|8CzH;PtIBTfaR_B{x6dQmIVlCu1PV9 zi%@ZzAR}tDy=kS#VxcJanfKBdd7u~~MxH;ca+gC&CngDHa< zLkfclLmGoILn1>e5SjtSEP*0PKrvIGoH-C0FeC#-jKR~ZW6WR#gvLNqEE$qF JZ(r@n1OOHGN4Wq1 diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta index d9935b36..917e3849 100644 --- a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta +++ b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta @@ -8,7 +8,7 @@ PluginImporter: defineConstraints: [] isPreloaded: 0 isOverridable: 0 - isExplicitlyReferenced: 1 + isExplicitlyReferenced: 0 validateReferences: 1 platformData: - first: diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb index ed00f36e044b3063d654af2d537f4821c4c253f6..711cf76c53ef3d56a5f07fa25acb9c8532c93e3d 100644 GIT binary patch delta 2168 zcmZwHdr(wm7{~GV?5cZV5vhbEO^nMfEXZX+1T7Q@uprbSico{yMP=|-Nr^+PNMeIY zAW2>tBzIjeg+Z8Dc}t5DvqYT4%1B8{i7X`@%}d|I@@HrF^ZuUMbIx;~**WvBXpwI3 zm8zEbmP8BTm#|>5SqQN$H2>LrnYLR9GKAQWP!-ldV0wV+2|lc(@CC`8}DMM`m7@1is)g-kohlk_xjv0sE3 zDjqbrj3s4(h(|Kgk%=6bQ6S&tn>ti1G01Za<7St!q|7#`oLq}K?8i~Gp&gy*LeD_6 zq+z0OpqWbCHn=E*R6?eOAA%8qiHJus(vgWAn1kf0Y${O@Bu}NLk&7}&O(V5fHpoV< z!8+Kn3!mW#I`9K}aTPb=9jq1)BLE>VAr4cKiVS37b})~vu_{K{)WRy)Lcgs#a_<>cnn*jQu!@HngJ?UFbm{`tdIm zAv&SK2zcXR1Rw+^#9=B@k%27Ch82Z)5yel`P0w)@<2cO{xj^QNE z;AdRKAGn5FkjCqT8XoY%7z802Q}NVzolRs>%)wkN!pnFK<)}n0>fpp~?8QN};#*wC zRosFuOeY?|X!s)pQJ9P*q#+ZtV8wzkn@%jHD8cLKg(6%hLNNu?FcUdg2pg(l$NSia z!}uB}VTjO)a7@G$6h_D|uDhs?aM?i0whVH5QaI6okVto7iX3<=-9=pFaFK-Rcm@`f zp#qz+6+6+0797NvXpi&|o#;Xj5=|b$hJMV5@(>pMgF6@+?IGr2DZ974ttf@Whokv< zb%D>MD^YSSiF$vKUliP$ek|}fSof~=QzVAG6IALqdw3MzJQVj(feD=;^;g zr!TZ3GlCkj-nqD!E>i+u|z zv&KKO>ap1&JC<+vO5OfsL*}DlySDDo&Th#1)LC5Gy>M~t?+$BLe4{mg?WxLnKRwjc zA9CdAwQD&w^~s-?d7oZ2-+#%yReisPraf0ZzV2eN#gYEBbLDZ{p)B-OKV#`AZo!+RzL`Y+iJ9Y>YM1WXd;Bu$c1lBcrVr z%iM??+D9Ug|EfuWQVg(9A2V0f5eW_U;&6$B@ZQ$8#K1tg4!f=hBdr3Mb` zLmF%5f@qm%H+2+aL!e1fv-DWtk%|&=E8t)ot%`fhW%DRyu=~CYe|GPA{XXAw@BMx6 z@7#0G@3#|DO}n(~rO+CS5aDU%6-7db==bf31vwFEyMhA~+c&*uyQM#Sr)^iJlok>p ziI4FaKF7sKNob-Z;jfiMCMvK~O9!D4&04n)N!drZsjXk82-PXX$izb)Lg?I-q|AR( zAQ`CR;c%ny62UsRa8ee7bS%IkeiDpVZjhm8``9q6%STGCeSb#;yK_N<_tK!&!9V8w}z$?qUoxVthmxqG3QXtjNIASb{t(i{Z8Pc14iGN0iF7P_YI# zB`K?#^h%6M$Tp%Dd(gn3t9$L|kKKv6ma2t0q22Z_8Jb;G~q?g|}dDNFH0&OZ0 zI&sZcBO<>6dHWthd*k~)k)Pq*8J)CB|0|me!gtL5U};C$1j>s z?;bv!-~Yxd$=^jxUA))1HFit+KI?Alj>BznUw7>2RE^*3=r8&1mY4eV!HAIR6)8Qs ztj2^V{53sOyW8pw%@?N}+BM#q8{C;+9Cq|whw4$|`E0RVY+SMB})OtGobmYQmD`(x9lF@Nw%wtJT+U$P?1W#=_cfcGlqx`A# zz-iXPmX^jv8y24X(PA9AR#!K2VsB^9lWPXrzpT6W)E@hgv9K!e+tq1v?msrz-|=7j zCttf_<{#{+O4a}Ul-1hZ8~fGI@9*3D`ENILvvZq9P9z8Ct!=a?w{~~=9uE5Iq^W2) zcgy|j*W1J9zOm`mgrk0!Y~PRT`!CLX%N(|PR@UV{+vSGWedDV)va#CCsBC`0oZKD8pzblxdhfVrbV;cxmL$8m#I$Vr@+3>Dxv0cq zPfj)zn@uK@p~O;@YD!ErrKA?Ss^g+U9*&TlddZn7IiHoBFG$XE$yqHqce_q4nC>y< Wcka2aWzB!!cPF_tuJqhg#s2}kT^1(* diff --git a/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta b/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta new file mode 100644 index 00000000..a53f08d8 --- /dev/null +++ b/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0c88669e4d1db604588be2a726b7d231 \ No newline at end of file diff --git a/unity/org.gamecult.cultmath/package.json.meta b/unity/org.gamecult.cultmath/package.json.meta new file mode 100644 index 00000000..337ba716 --- /dev/null +++ b/unity/org.gamecult.cultmath/package.json.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33d8d4af49bf0f946ad00a375f26b1d1 \ No newline at end of file From c4543b9443e96d848260ef00254d2d3c0fa6a9d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 7 Aug 2026 20:02:24 +0100 Subject: [PATCH 22/23] Add canonical min-max rectangle --- src/CultMath/CultMath.csproj | 2 +- src/CultMath/rect.cs | 59 ++++++++++++++++++++++++ src/CultMath/rect.cs.meta | 2 + tests/CultMath.Tests/MathTests.cs | 35 ++++++++++++++ unity/org.gamecult.cultmath/package.json | 2 +- 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/CultMath/rect.cs create mode 100644 src/CultMath/rect.cs.meta diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index 73b3fd51..dfa5475d 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -8,7 +8,7 @@ CultMath CultMath CultMath - 0.1.0 + 0.1.1 Clean-room C# math shaped for HLSL-style renderer code. GameCult https://github.com/GameCult/CultMath diff --git a/src/CultMath/rect.cs b/src/CultMath/rect.cs new file mode 100644 index 00000000..51e05c19 --- /dev/null +++ b/src/CultMath/rect.cs @@ -0,0 +1,59 @@ +namespace CultMath; + +/// +/// Axis-aligned 2D bounds stored canonically as normalized minimum and maximum +/// corners. Point containment and intersection include the boundary. +/// +public readonly record struct rect +{ + public rect(float minX, float minY, float maxX, float maxY) + : this(new float2(minX, minY), new float2(maxX, maxY)) + { + } + + public rect(float2 min, float2 max) + { + this.min = new float2(MathF.Min(min.x, max.x), MathF.Min(min.y, max.y)); + this.max = new float2(MathF.Max(min.x, max.x), MathF.Max(min.y, max.y)); + } + + public float2 min { get; } + public float2 max { get; } + + public float width => max.x - min.x; + public float height => max.y - min.y; + public float2 size => max - min; + public float2 center => (min + max) * 0.5f; + public float area => width * height; + public bool isEmpty => width == 0.0f || height == 0.0f; + + public bool Contains(float2 point) => + point.x >= min.x && point.x <= max.x && + point.y >= min.y && point.y <= max.y; + + public bool Contains(rect other) => + other.min.x >= min.x && other.max.x <= max.x && + other.min.y >= min.y && other.max.y <= max.y; + + public bool Intersects(rect other) => + min.x <= other.max.x && max.x >= other.min.x && + min.y <= other.max.y && max.y >= other.min.y; + + public rect Intersection(rect other) + { + if (!Intersects(other)) + return new rect(0.0f, 0.0f, 0.0f, 0.0f); + + return new rect( + MathF.Max(min.x, other.min.x), + MathF.Max(min.y, other.min.y), + MathF.Min(max.x, other.max.x), + MathF.Min(max.y, other.max.y)); + } + + public rect Encapsulate(float2 point) => new( + MathF.Min(min.x, point.x), + MathF.Min(min.y, point.y), + MathF.Max(max.x, point.x), + MathF.Max(max.y, point.y)); +} diff --git a/src/CultMath/rect.cs.meta b/src/CultMath/rect.cs.meta new file mode 100644 index 00000000..0b04a5ce --- /dev/null +++ b/src/CultMath/rect.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5aab0d8e1ca94d42b85bba780a7ee449 diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index ad268212..8d42a0c1 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -199,6 +199,41 @@ public sealed class MathTests Assert.Equal(new float2(2.0f, 0.0f), closest); } + [Fact] + public void RectNormalizesAndDerivesFromMinMax() + { + var bounds = new rect(8.0f, 6.0f, 2.0f, -2.0f); + + Assert.Equal(new float2(2.0f, -2.0f), bounds.min); + Assert.Equal(new float2(8.0f, 6.0f), bounds.max); + Assert.Equal(new float2(6.0f, 8.0f), bounds.size); + Assert.Equal(new float2(5.0f, 2.0f), bounds.center); + Assert.Equal(48.0f, bounds.area); + } + + [Fact] + public void RectContainmentIncludesBoundary() + { + var bounds = new rect(-2.0f, -1.0f, 4.0f, 3.0f); + + Assert.True(bounds.Contains(new float2(-2.0f, -1.0f))); + Assert.True(bounds.Contains(new float2(4.0f, 3.0f))); + Assert.True(bounds.Contains(new rect(-1.0f, 0.0f, 1.0f, 2.0f))); + Assert.False(bounds.Contains(new float2(4.001f, 3.0f))); + } + + [Fact] + public void RectIntersectionTreatsTouchingEdgesAsContact() + { + var left = new rect(0.0f, 0.0f, 4.0f, 4.0f); + var touching = new rect(4.0f, 1.0f, 8.0f, 3.0f); + var separate = new rect(4.001f, 1.0f, 8.0f, 3.0f); + + Assert.True(left.Intersects(touching)); + Assert.Equal(0.0f, left.Intersection(touching).area); + Assert.False(left.Intersects(separate)); + } + [Fact] public void FirstOrderInterceptSolvesSimpleClosingTarget() { diff --git a/unity/org.gamecult.cultmath/package.json b/unity/org.gamecult.cultmath/package.json index e45d0a35..d7039df7 100644 --- a/unity/org.gamecult.cultmath/package.json +++ b/unity/org.gamecult.cultmath/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultmath", "displayName": "CultMath", - "version": "0.1.0", + "version": "0.1.1", "unity": "2021.3", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "license": "MPL-2.0", From fcdddda72f6aa33ae6eeff3bce6a2d3e26eb16f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 7 Aug 2026 20:37:37 +0100 Subject: [PATCH 23/23] Rebuild Unity package with rectangle API --- src/CultMath/CultMath.csproj | 2 +- .../Runtime/Plugins/CultMath.dll | Bin 44544 -> 47104 bytes .../Runtime/Plugins/CultMath.pdb | Bin 23420 -> 23936 bytes unity/org.gamecult.cultmath/package.json | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index dfa5475d..4e0027db 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -8,7 +8,7 @@ CultMath CultMath CultMath - 0.1.1 + 0.1.2 Clean-room C# math shaped for HLSL-style renderer code. GameCult https://github.com/GameCult/CultMath diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll index 91b51d8d33ed8fcbc53a66b07131e2442f17c464..379dad44e209aea24d0cd34f7cb28a1a490d2ae8 100644 GIT binary patch delta 17237 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 delta 14912 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 diff --git a/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb index 711cf76c53ef3d56a5f07fa25acb9c8532c93e3d..223b4de88b508404e9dd23578205c7692bc9427e 100644 GIT binary patch delta 3993 zcmZwJ3piBi9|!RF%nW1P!jjx)3FCenv$@r}wWyZV7F+916sBd6KWhltwpfhTMysrm zT4^^^Nv%}N{x|*iqKjQ!bWxOwM`3Hb{l5ph&C|2<_?+MSecyB5^PV|4=lM}8{{&Ue zUn?>r#4L}H#QB7X7-+8$r6r}VODFhTwIGCrYWBejas%y+bo;#z0{u`!ED3QGAp0Z> zV~~CM?h!twor_x|{UT4R>4OQ2OA&_vLPQzpbU0QPb6lO zUDFdOVWJrVR9f?_R@9Tw4SjG8Mqv^Zt(Axx7=t<3LJ%y6Fo=W%NC6S#z-HJA<cIbvaj9r6XwEuwXFa#VM4$%NzFa~q51s8A! zZwRoV&o}##Br|jZI3#$wPnks6%!o&&B||Z62MN?bJ?w*T;V5)K7o4-9?~YiRoVQUW zH{mXf!4$jzmaQsL0WHu2E?9y+xPmA6LJ%y6Fo=W%NC6S#z-HJAtW!MAV}I-m>A!FlM18!!x`FbU6rX|GC@K@;YI30Qy~@WBIoAP_=eEhK^n3hY(I zWGfmKuoLQGKOBN~I1Oi^7y98Q+=Fp=3Y3E?F#>b2gGKNj1jA~GgmoZMJU;#&XCr_0My`|F-x%Y&-^hF9vKGec z+4V#`nqPROIjEE==}?k>2Es{=b!E
  • U; zNYLyK#mLTRyTTD1umzd^2vO+#8L|)Z-{aem>BZ9dD8{$X?!O+n4Y_PKTjLdi&JsMe z-^bW~3OAYVs6xkTbX4L5bl!m+g`9(Y47m=O{?PZZT`p@PAD7Er$S05+X7`Um{$6&+ zc#yeFN-qxUxGqUC%pv^Or}>WCx`^?3K%pe!WuQ(?Wozd3zV;)I9iOhq^Y)Py9_$Gb zvXfHf*EPr$cZ&6fMQ%n5qI&8yhwuE}$oF5Nqo^C+$LN{Uw|1hEs~MQ?zeyCImKvQL zn3^Q|=VY6;x&A|j?tN~x3*S@Et8E(2P(EZQNi6!|ewc~L*o#WNQFnGqv?%fQ0m0mm zj&^QS#MI=a$Qt&Zr+0~O;7zrqi9SPv+C7iETt}R?O7eNS;=`V$M#C?D-NGsG)eosE zT_5(o#fkD|BPE@A7l}Zh=JfNfHqu&KXw}5+{YO8}VAea`ZD{m1e>LE1G-3OoI<_%n$>>Xou~ndI zaN(-LkJyFLg-L~*B&M)z1jZb@`bQqu34Sznpg7Ws@+O3RBYmtF9^ z7j0XbQeeBYci-vjC;v#PzV?#x{km4#blsqiA9UfVtZcA>U#znI*08IaI7a*Y*pJ=O zSKqP7e^hfrllg!ZGL_FAxDr=BdfUJ7;9hdedy8bpA(q|L9!EvSVAVPc^N`TO>!s`7 zZMxYTrea<&>3wRYM-6}C!WCB3<(;2@v9aQ1!_aWq&=a2G;QBwNy!Ux6+GUZoGCVZq zdk+(7q!VlXk}JDk*ag^&AL^WZDE@7-+BCU`H<6ZEYNylN^Wak3S=H4$-JR8&bEF=v zMFo2c7lwo+A7A8HY%WTlI$3;6;V@6FNYUebNcNW6D-R!f1_(J{?y|5jcCgx+niFs9 zrBUL0#WuUvwldj$sG-@jEmkS<)eku>k0iO_2OsAK-ruqC=V0CD?cdn0*>(Fr_2O#l z=8eT0I=)POwzZ=yXo|UQm4NrN=*JGtCBdeio?e}5ob}qCo4mi8v)J>`-Tl7|oI0R+ z?@V}2-{P7ui=MW7V}I5dYkN)Io_zVrQ8bqDY@vShO%;RsGaHV_99Waa=!sD}yK$6F zsZgw}9Md>qY(`3aswmymJ;&4`(!tu)UBb0<<;C({`7SYTe7;M(n;>ohe}S{WF+Rc3 z(b<(B=N#t}>zEKNh<1x}mV9EDOS#GZvfFPscLgQ=kdkIlQZXgnMoDWa=>bamgRDJB zpZV6mjLC+UJyI6a4#Yp} zd-@=jk~Ljal^o_9NbZLD|LeubPDy;jy(mLTLAZ!Al1zoK)y7-K()>b>Ia$LXHuSpl RKQoaee7J|=O4w^e{|97??IHjG delta 3501 zcmZwJ2Ut_r0tfJO6EMRti=a3NG6G>Q5f>_3L7XTW?uQlv>`v(pKVA_qbYP})tIL`qPfPuIhyLW%DP4A|DwHBEq2eQMg9vX;0&C0 zph!QK@e;IRM`**K%Mqq`TAd=Tg`otT8i)iXnB=0x{a zH&ce5hNQQXjtxqhBLqSyM8RZ;2N6iS2SyM>GT(__Ob?9Ku|Y{w>gA(c1r=}v8sQvV zh8Adr$KA7qV3v;V*;wR*lMd6FMQB{G10i_9U1!xPlK1fl&|+(GUaEK>{g|2AQDD#%Vbe z!&=x38rTJU;UFA`Cb$UK;0`>5XYf0`flpxQ#v?rV2CTsroWKJH!EgwMu`m&0-FQlp zh{|k`K?Y=j3JRbUHb5oRKrQTt!*CMLLNnZiU*HkEfLHJiD0d!Vg9%tde{cpr7zN|p zm3V7VnE|sv2LFMjkPpSM4l1Aq_P_x+22F4YoVJmLl;Ap#~sJj??nl!6AngM)Aaeu8Fj^5PLs7z`sJ#f$#x8j(^j zoed~ym3rnisL*P`)!T^pcU`qzHWRi; zE>HD#7pM2|AQY1o!mlmb`qJ@r`^THN^95IAo1Px2obl0~O?rdQluq4|2HxmPeFdF}NVx-nnfW(nVP#Gch1BjPx3=eKsAT9?`~Jl&?Y zRl22b>fy#$X+^E4bVGc<+@_A-|F8NM`G&jO466fK=6|(?U5ib4F)byi>)}$t%+)so z=CY)+uh-SaSB@zkwL0Sc+$B=cvY^J$;4$l~_g{LVt~W4VH8E@?_qN0=B0Xf0A~{W# zlo}zED*m}yt_A=6liBK+LLZ+TPV9RHtMA$G?!=5aD$|C?^|&ht2)mxnnwO-Q^JPFZ z|J|+ocEc-bZ?qJ;B`3w+%qx~g8u`t6Xy8=!Jfxw2HAs==x-b(gT|s|(8v)J-lAlwW$}vH6ld53GNS-TF!7 zxZv5acH@Q_RKb%~8FgJ1?NwJtZoF!mv-dK72eoVBQ~G+D=Cv~4nyB7iDO-HAc!8B* zYl$o(ZiVeB>AUgoJY6pS##O|IYVtq6NxFBV<>-mg)&3D-t$m7p&%Jt9Slp%=w!CMg zd5uw~Qe<)EWkX@(L3xxxFW(>SLR&ah(P>u&Myojq^4B*@G@nl>)9*EXu^qWLpSa3x_T!#@%Bc!+k=6h!!COCzxc<5f+qjE zXPT0sftzP7oE$$pZV4Y7Q$XhAnltqL<6ExftIlM-&PZ%9qB@j|1TG2gQ}aelcipjgyJh(H zv9*(jd+e&-!JRIyjX6}4nbS5;9`IBv39OSOuewq^`)=R8U9Kk^U%iS~SB3tlvbvTx z$A13jypCVpBI8QkD;{NvwNX=Qa?dM|-~3SZbYAp~m!D07-t6(WDcfs(Y@eA>RpZjs zRBAQeQhXslJzBleJ=!TC$H~-RBFOBZxn$pil1GbPZ zcx(@QYjsCl$iL21-%}kPKb-2T&W~46{nYQ{6D;~;&&pPD97sHaxR7uL$u1b6PD!Yu LY}CBz3X1#>Qw59x diff --git a/unity/org.gamecult.cultmath/package.json b/unity/org.gamecult.cultmath/package.json index d7039df7..80ceddad 100644 --- a/unity/org.gamecult.cultmath/package.json +++ b/unity/org.gamecult.cultmath/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultmath", "displayName": "CultMath", - "version": "0.1.1", + "version": "0.1.2", "unity": "2021.3", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "license": "MPL-2.0",