commit 38b79a8d897ac5e2f3b3b24313a051b8b9a47a30 Author: Meta Vens Date: Fri May 8 01:49:44 2026 +0100 Seed HLSL-shaped math library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59e6736 --- /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 0000000..9583457 --- /dev/null +++ b/CultMath.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3b1809 --- /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 0000000..6210273 --- /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 0000000..99b1511 --- /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 0000000..f06b8be --- /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 0000000..1f1d66b --- /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 0000000..037e883 --- /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 0000000..d9ec098 --- /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 0000000..6b0cf45 --- /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 0000000..1e77eba --- /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 0000000..80b0ed9 --- /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)); + } +}