Seed HLSL-shaped math library

This commit is contained in:
Meta Vens 2026-05-08 01:49:44 +01:00
commit 38b79a8d89
12 changed files with 366 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
**/bin/
**/obj/
artifacts/
*.user
*.suo
.vs/
.idea/

8
CultMath.slnx Normal file
View file

@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/CultMath/CultMath.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/CultMath.Tests/CultMath.Tests.csproj" />
</Folder>
</Solution>

43
README.md Normal file
View file

@ -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.

21
docs/design.md Normal file
View file

@ -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.

6
global.json Normal file
View file

@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.202",
"rollForward": "latestFeature"
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>CultMath</RootNamespace>
<AssemblyName>CultMath</AssemblyName>
<PackageId>CultMath</PackageId>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors>
<RepositoryUrl>https://github.com/GameCult/CultMath</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<NoWarn>$(NoWarn);CS8981</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

26
src/CultMath/float2.cs Normal file
View file

@ -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);
}

31
src/CultMath/float3.cs Normal file
View file

@ -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);
}

31
src/CultMath/float4.cs Normal file
View file

@ -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);
}

114
src/CultMath/math.cs Normal file
View file

@ -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;
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CultMath\CultMath.csproj" />
</ItemGroup>
</Project>

View file

@ -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));
}
}