diff --git a/.gitignore b/.gitignore
index 59e6736..6eae439 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 c3b1809..b693d25 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 6210273..3c87fb5 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 0000000..09598b9
--- /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 0000000..cf7819a
--- /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 0000000..5a48460
--- /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 0000000..e9a2b48
--- /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 f06b8be..83f6fb9 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 0000000..25dfffe
--- /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 0000000..52e6acb
--- /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 8576a77..2e0cea9 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);
+ }
}