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

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