Add Rust-backed Voronoi batch kernel

This commit is contained in:
Meta Vens 2026-06-03 02:58:56 +01:00
commit bfe356fe92
11 changed files with 461 additions and 16 deletions

9
.gitignore vendored
View file

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

View file

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

View file

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

7
native/cultmath-core/Cargo.lock generated Normal file
View file

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

View file

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

View file

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

4
src/CultMath/Color32.cs Normal file
View file

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

View file

@ -12,6 +12,7 @@
<RepositoryUrl>https://github.com/GameCult/CultMath</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<NoWarn>$(NoWarn);CS8981</NoWarn>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

View file

@ -0,0 +1,10 @@
namespace CultMath;
public enum CultMathTone : byte
{
Background = 0,
Panel = 1,
Edge = 2,
Header = 3,
Body = 4,
}

190
src/CultMath/Voronoi.cs Normal file
View file

@ -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<float> xs = stackalloc[] { x };
Span<float> ys = stackalloc[] { y };
Span<CultMathTone> tones = stackalloc[] { tone };
Span<float> spans = stackalloc[] { span };
Span<Color32> colors = stackalloc Color32[1];
SampleTones(xs, ys, tones, spans, resolutionY, frameIndex, colors);
return colors[0];
}
public static void SampleTones(
ReadOnlySpan<float> xs,
ReadOnlySpan<float> ys,
ReadOnlySpan<CultMathTone> tones,
ReadOnlySpan<float> spans,
float resolutionY,
int frameIndex,
Span<Color32> 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<float> xs,
ReadOnlySpan<float> ys,
ReadOnlySpan<CultMathTone> tones,
ReadOnlySpan<float> spans,
float resolutionY,
int frameIndex,
Span<Color32> 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);
}
}

View file

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