diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index 61351da3..0111949c 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -167,6 +167,7 @@ CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`: ```powershell .\packages\cultmath\tools\get-dxc.ps1 +.\packages\cultmath\tools\compile-hlsl.ps1 .\packages\cultmath\tools\get-spirv-cross.ps1 .\packages\cultmath\tools\compile-hlsl-spirv.ps1 ` -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` @@ -177,6 +178,8 @@ CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`: -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal ``` +`compile-hlsl.ps1` compiles both `CultMath.hlsl` copies as a `lib_6_3` library with the x64 DXC. + DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the script, but the current Windows DXC release compiled this shader successfully with DXC's default Vulkan target while rejecting the dotted target-env value. diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index 103cfa5b..d11e1518 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -112,8 +112,9 @@ local) and writes through a swizzle component (`v.xy.x = s`, which C# rejects as modifying a temporary). The HLSL side is checked too: `tools/get-dxc.ps1` fetches Microsoft's -DirectXShaderCompiler, and -`dxc -T lib_6_3 -HV 2021 shaders/CultMath.hlsl` must compile cleanly. +DirectXShaderCompiler (x64), and `tools/compile-hlsl.ps1` compiles both copies of +`CultMath.hlsl` with it (`dxc -T lib_6_3 -HV 2021`); the script throws when dxc cannot +launch or exits non-zero, so a stale exit code never reads as a pass. The mirror test compares bit patterns (any NaN equals any NaN; -0 differs from 0). It proves the text of `CultMath.hlsl` computes the same float32 results as diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl index c454773c..c480acc7 100644 --- a/packages/cultmath/shaders/CultMath.hlsl +++ b/packages/cultmath/shaders/CultMath.hlsl @@ -304,7 +304,7 @@ float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float { lo = min(lo, hi); if (b == 0.0) - return clamp(a, lo, hi) * (t1 - t0); + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; float direction = t1 < t0 ? -1.0 : 1.0; float start = min(t0, t1); diff --git a/packages/cultmath/src/CultMath/math.Span.cs b/packages/cultmath/src/CultMath/math.Span.cs index 6dcdd180..068dfd0c 100644 --- a/packages/cultmath/src/CultMath/math.Span.cs +++ b/packages/cultmath/src/CultMath/math.Span.cs @@ -112,7 +112,7 @@ public static partial class math { lo = min(lo, hi); if (b == 0.0f) - return clamp(a, lo, hi) * (t1 - t0); + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0f; var direction = t1 < t0 ? -1.0f : 1.0f; var start = min(t0, t1); diff --git a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs index 9fb45199..33665d75 100644 --- a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs @@ -45,24 +45,50 @@ public sealed class GlslMirrorTests } } + // Counting stand-ins for the mirror's clamp, one per overload, declared in the mirror's class so they + // hide math.clamp for every function in it. Each counts a call whose minimum exceeds its maximum in any + // component and forwards to math.clamp. (The int vector overloads only forward: no mirror function + // clamps integer vectors with swapped bounds, and counting them would be unreachable.) + private const string CountingClampShadows = @" +public static int ClampMinAboveMax; +static float clamp(float x, float a, float b) { if (a > b) ClampMinAboveMax++; return math.clamp(x, a, b); } +static float2 clamp(float2 x, float2 a, float2 b) { if (a.x > b.x || a.y > b.y) ClampMinAboveMax++; return math.clamp(x, a, b); } +static float3 clamp(float3 x, float3 a, float3 b) { if (a.x > b.x || a.y > b.y || a.z > b.z) ClampMinAboveMax++; return math.clamp(x, a, b); } +static float4 clamp(float4 x, float4 a, float4 b) { if (a.x > b.x || a.y > b.y || a.z > b.z || a.w > b.w) ClampMinAboveMax++; return math.clamp(x, a, b); } +static int clamp(int x, int a, int b) { if (a > b) ClampMinAboveMax++; return math.clamp(x, a, b); } +static int2 clamp(int2 x, int2 a, int2 b) { if (a.x > b.x || a.y > b.y) ClampMinAboveMax++; return math.clamp(x, a, b); } +static int3 clamp(int3 x, int3 a, int3 b) { if (a.x > b.x || a.y > b.y || a.z > b.z) ClampMinAboveMax++; return math.clamp(x, a, b); } +static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z > b.z || a.w > b.w) ClampMinAboveMax++; return math.clamp(x, a, b); } +"; + /// - /// The lowering keeps HLSL's statement order, and GLSL leaves clamp(x, minVal, maxVal) undefined for - /// minVal above maxVal, so integral_clamp_linear must normalise lo = min(lo, hi) before any clamp in - /// its body can see it: in the committed HLSL and the committed GLSL alike, the normalisation precedes - /// the first clamp. + /// The lowering keeps HLSL's statement order (CommittedGlslEqualsLowering pins the GLSL as that + /// lowering), and GLSL leaves clamp(x, minVal, maxVal) undefined for minVal above maxVal, so no clamp + /// reached by cultmath_integral_clamp_linear may see lo above hi, on any path and for any spelling of the + /// normalisation. The mirror is compiled with counting clamps and the function is run over the edge grid + /// (lo above hi, infinite bounds, zero-length spans, b = 0 and b != 0): the count of such clamp calls + /// stays 0 after every call. /// [Fact] - public void IntegralClampLinearNormalisesLoBeforeAnyClamp() + public void IntegralClampLinearNeverClampsWithMinAboveMax() { - foreach (var (file, text) in Lowered.Where(l => l.File == "CultMath.glsl").Concat(new[] { ("CultMath.hlsl", Hlsl) })) + var (assembly, errors) = HlslSourceCompatibilityTests.CompileShaderMirror(CountingClampShadows); + Assert.True(assembly is not null, string.Join(Environment.NewLine, errors)); + var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!; + var shader = Activator.CreateInstance(shaderType); + var count = shaderType.GetField("ClampMinAboveMax")!; + var function = shaderType.GetMethod("cultmath_integral_clamp_linear", BindingFlags.Instance | BindingFlags.NonPublic)!; + + var bad = new List(); + foreach (var c in HlslSourceCompatibilityTests.IntegralClampLinearEdgeGrid) { - var start = text.IndexOf("cultmath_integral_clamp_linear(float a", StringComparison.Ordinal); - Assert.True(start >= 0, $"{file} has no cultmath_integral_clamp_linear"); - var body = text[start..text.IndexOf("}", start, StringComparison.Ordinal)]; - var normalise = body.IndexOf("lo = min(lo, hi);", StringComparison.Ordinal); - var firstClamp = body.IndexOf("clamp(", StringComparison.Ordinal); - Assert.True(normalise >= 0 && normalise < firstClamp, $"{file}: lo = min(lo, hi) must come before the first clamp in cultmath_integral_clamp_linear"); + count.SetValue(null, 0); + function.Invoke(shader, c.Cast().ToArray()); + if ((int)count.GetValue(null)! > 0) + bad.Add($"({string.Join(", ", c)})"); } + + Assert.True(bad.Count == 0, $"{bad.Count} cases clamp with the minimum above the maximum:{Environment.NewLine}{string.Join(Environment.NewLine, bad.Take(12))}"); } // Identifier boundaries: cultmath_lerp is a CultMath function, lerp( is HLSL. @@ -675,6 +701,19 @@ public sealed class GlslMirrorTests _ => new object[] { a, slope, float.NegativeInfinity, float.PositiveInfinity, t0, t1 }, }; }), + // Zero-length spans (t1 = t0 three times in four) with a flat or sloped line, and infinite clamp bounds + // on either side or both: an infinite bound times a zero length must give +0, and a flat line clamped + // to an infinite bound over a nonzero span is infinite, never NaN. + new Family("float cultmath_integral_clamp_linear(float, float, float, float, float, float)", "ulp-bounded, zero-length spans and flat infinite clamps", + r => + { + float Line() => r.Next(3) switch { 0 => 0.0f, 1 => -0.0f, _ => Uniform(r, -4.0f, 4.0f) }; + float Bound() => r.Next(3) switch { 0 => float.NegativeInfinity, 1 => float.PositiveInfinity, _ => Uniform(r, -2.0f, 2.0f) }; + var (a, b, lo, hi) = (Line(), Line(), Bound(), Bound()); + var t0 = Uniform(r, -3.0f, 3.0f); + var t1 = r.Next(4) != 0 ? t0 : Uniform(r, -3.0f, 3.0f); + return new object[] { a, b, lo, hi, t0, t1 }; + }), }).ToArray(); // The fixture text, each case's arguments given by family and point index. diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs index ea2fd145..68cfce95 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -84,12 +84,13 @@ public sealed class HlslSourceCompatibilityTests } } - private static readonly float[] Specials = { 0.0f, -0.0f, 1.0f, -1.0f, 0.5f, -2.75f, 3.0f, 1.0e-7f, 1.0e4f, -1.0e4f, 1.0e7f, float.NaN }; + // Special values every mirror argument takes: zeros, ties, negatives, large values, NaN and both infinities. + private static readonly float[] Specials = { 0.0f, -0.0f, 1.0f, -1.0f, 0.5f, -2.75f, 3.0f, 1.0e-7f, 1.0e4f, -1.0e4f, 1.0e7f, float.NaN, float.PositiveInfinity, float.NegativeInfinity }; /// /// Every mirror function with a C# math counterpart returns the same bits (any NaN equals any NaN; /// -0 differs from 0) over random inputs, every special value in every argument (ties, zeros, - /// negatives, NaN, large values, integer lattice points), special pairs, and vectors whose first two + /// negatives, NaN, infinities, large values, integer lattice points), special pairs, and vectors whose first two /// components tie (x0.x == x0.y in snoise). This proves the text of CultMath.hlsl computes the same /// float32 results as C# math on the CPU. It does not prove GPU agreement: driver sin precision alone /// makes cultmath_hash and value noise differ bit for bit on hardware. The mirror's intrinsics are C# @@ -178,6 +179,56 @@ public sealed class HlslSourceCompatibilityTests Assert.Equal(120, compared.Count); } + /// + /// The 1500 arguments (a, b, lo, hi, t0, t1) at which integral_clamp_linear's zero-length rule, its lo + /// normalisation and its infinite bounds all bite: a in {-2.5, 0, 1.5}, b in {-1.5, -0, 0, 2}, lo in + /// {-inf, -1, 0.5, 3, +inf}, hi in {-inf, -0.5, 1, 4, +inf}, and (t0, t1) in {(0, 0), (1.25, 1.25), (-1, 2), + /// (2, -1), (0, 1e-30)}. One spelling, read by the mirror grid test and by the GLSL clamp shadow test. + /// + internal static IReadOnlyList IntegralClampLinearEdgeGrid { get; } = BuildIntegralClampLinearEdgeGrid(); + + private static List BuildIntegralClampLinearEdgeGrid() + { + var inf = float.PositiveInfinity; + var grid = new List(); + foreach (var a in new[] { -2.5f, 0.0f, 1.5f }) + foreach (var b in new[] { -1.5f, -0.0f, 0.0f, 2.0f }) + foreach (var lo in new[] { -inf, -1.0f, 0.5f, 3.0f, inf }) + foreach (var hi in new[] { -inf, -0.5f, 1.0f, 4.0f, inf }) + foreach (var (t0, t1) in new[] { (0.0f, 0.0f), (1.25f, 1.25f), (-1.0f, 2.0f), (2.0f, -1.0f), (0.0f, 1.0e-30f) }) + grid.Add(new[] { a, b, lo, hi, t0, t1 }); + return grid; + } + + /// + /// The compiled mirror's cultmath_integral_clamp_linear returns C#'s bits on every edge-grid case, and C#'s + /// result is never NaN there. The generic case set catches each of the function's guards (the held and + /// kept stretch guards, the flat branch's zero-length guard) only by its rotation: with the kept guard + /// removed it finds 0 mismatches, with the held guard removed 1 once infinities join the specials, and the + /// edge grid finds 246 for either every time. + /// + [Fact] + public void IntegralClampLinearMirrorMatchesCSharpOnTheEdgeGrid() + { + var (assembly, errors) = CompileShaderMirror(); + Assert.True(assembly is not null, string.Join(Environment.NewLine, errors)); + var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!; + var shader = Activator.CreateInstance(shaderType); + var mirror = ShaderFunction(shaderType, "cultmath_integral_clamp_linear", Enumerable.Repeat(typeof(float), 6).ToArray())!; + + var mismatches = new List(); + foreach (var c in IntegralClampLinearEdgeGrid) + { + var expected = integral_clamp_linear(c[0], c[1], c[2], c[3], c[4], c[5]); + var actual = (float)mirror.Invoke(shader, c.Cast().ToArray())!; + Assert.False(float.IsNaN(expected), $"C# returns NaN at ({string.Join(", ", c)})"); + if (BitConverter.SingleToInt32Bits(expected) != BitConverter.SingleToInt32Bits(actual)) + mismatches.Add($"({string.Join(", ", c)}): C# {expected:R}, mirror {actual:R}"); + } + + Assert.True(mismatches.Count == 0, $"{mismatches.Count} of {IntegralClampLinearEdgeGrid.Count} cases differ:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); + } + /// /// The generic comparison above passes about a hundred inputs through cultmath_phacelle, and /// its prune only changes an output for the rare cell whose weight is nonzero but whose lower @@ -448,8 +499,11 @@ public sealed class HlslSourceCompatibilityTests : string.Empty); } - /// The documented HLSL-to-C# transformations, and nothing else. - internal static string TransformHlslToCSharp(string hlsl) + /// + /// The documented HLSL-to-C# transformations, and nothing else. is C# source a + /// test supplies, placed first in the class body: it is not a transformation of the HLSL. + /// + internal static string TransformHlslToCSharp(string hlsl, string members = "") { // 1. Include guards (#ifndef/#define/#endif) are dropped; C# has no textual include. var source = Regex.Replace(hlsl, @"(?m)^\s*#(ifndef|define|endif)\b.*$", string.Empty); @@ -471,13 +525,13 @@ public sealed class HlslSourceCompatibilityTests m.Groups[1].Value + Regex.Replace(m.Groups[2].Value, @"(?m)^(\s*)(\w+\s+\w+;)", "$1public $2") + m.Groups[3].Value); // 6. The file body is wrapped in a class; C# has no free functions. - return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n"; + return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + members + source + "\n}\n"; } - internal static (Assembly? Assembly, IReadOnlyList Errors) CompileShaderMirror() + internal static (Assembly? Assembly, IReadOnlyList Errors) CompileShaderMirror(string members = "") { var hlsl = ReadShaderSource(FindCultMathRoot()); - var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl)); + var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl, members)); var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator) .Append(typeof(math).Assembly.Location) diff --git a/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs b/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs index 0962a357..f97d5bfe 100644 --- a/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs @@ -469,8 +469,8 @@ public sealed class NoiseBoundTests /// /// Pins SNOISE_LIPSCHITZ: no |snoise_grad| over 1e5 seeded points exceeds it, and the ascent from - /// MeasureLipschitz's witness reaches a maximum that, times 1.10, is the constant to within half a - /// percent, so a constant lowered (or raised) by 1% fails here. + /// MeasureLipschitz's witness reaches a maximum that, times 1.10, equals the constant bit for bit + /// (the expression MeasureLipschitz prints), so any moved constant fails here. /// [Fact] public void LipschitzConstantPinsSampledGradients() @@ -479,8 +479,8 @@ public sealed class NoiseBoundTests Assert.True(sampled <= SNOISE_LIPSCHITZ, $"|snoise_grad| reaches {sampled:R}, above SNOISE_LIPSCHITZ {SNOISE_LIPSCHITZ:R}"); var refined = AscendGradientNorm(LipschitzWitness); Assert.True(refined <= SNOISE_LIPSCHITZ, $"|snoise_grad| reaches {refined:R}, above SNOISE_LIPSCHITZ {SNOISE_LIPSCHITZ:R}"); - var ratio = SNOISE_LIPSCHITZ / (1.10f * refined); - Assert.True(MathF.Abs(ratio - 1.0f) <= 0.005f, $"SNOISE_LIPSCHITZ is {ratio:R} times 1.10 x the re-measured {refined:R}; re-run MeasureLipschitz and re-pin"); + var measured = 1.10f * refined; + Assert.True(SNOISE_LIPSCHITZ == measured, $"SNOISE_LIPSCHITZ is {SNOISE_LIPSCHITZ:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureLipschitz and re-pin"); } // ---- The curvature and 2D Lipschitz constants (math.Affine.cs, math.Interval.cs) ---- @@ -609,8 +609,8 @@ public sealed class NoiseBoundTests /// /// Pins SNOISE_HESSIAN: no Hessian norm over 1e5 seeded points exceeds it, and the ascent from - /// MeasureHessian's witness reaches a maximum that, times 1.10, is the constant to within half a - /// percent, so a constant moved by 1% fails here. + /// MeasureHessian's witness reaches a maximum that, times 1.10, equals the constant bit for bit + /// (the expression MeasureHessian prints), so any moved constant fails here. /// [Fact] public void HessianConstantPinsSampledCurvature() @@ -619,8 +619,8 @@ public sealed class NoiseBoundTests Assert.True(sampled <= SNOISE_HESSIAN, $"the Hessian norm reaches {sampled:R}, above SNOISE_HESSIAN {SNOISE_HESSIAN:R}"); var refined = Ascend(HessianNorm, HessianWitness); Assert.True(refined <= SNOISE_HESSIAN, $"the Hessian norm reaches {refined:R}, above SNOISE_HESSIAN {SNOISE_HESSIAN:R}"); - var ratio = SNOISE_HESSIAN / (1.10 * refined); - Assert.True(Math.Abs(ratio - 1.0) <= 0.005, $"SNOISE_HESSIAN is {ratio:R} times 1.10 x the re-measured {refined:R}; re-run MeasureHessian and re-pin"); + var measured = (float)(refined * 1.10); + Assert.True(SNOISE_HESSIAN == measured, $"SNOISE_HESSIAN is {SNOISE_HESSIAN:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureHessian and re-pin"); } // ---- The third-derivative constant (math.Span.cs) ---- @@ -947,8 +947,8 @@ public sealed class NoiseBoundTests /// /// Pins SNOISE_THIRD: no third-derivative norm over 1e5 seeded points exceeds it, and the ascent from - /// MeasureThird's witness reaches a maximum that, times 1.10, is the constant to within half a - /// percent, so a constant moved by 1% fails here. + /// MeasureThird's witness reaches a maximum that, times 1.10, equals the constant bit for bit + /// (the expression MeasureThird prints), so any moved constant fails here. /// [Fact] public void ThirdConstantPinsSampled() @@ -957,8 +957,8 @@ public sealed class NoiseBoundTests Assert.True(sampled <= SNOISE_THIRD, $"the third-derivative norm reaches {sampled:R}, above SNOISE_THIRD {SNOISE_THIRD:R}"); var refined = Ascend(ThirdNorm, ThirdWitness); Assert.True(refined <= SNOISE_THIRD, $"the third-derivative norm reaches {refined:R}, above SNOISE_THIRD {SNOISE_THIRD:R}"); - var ratio = SNOISE_THIRD / (1.10 * refined); - Assert.True(Math.Abs(ratio - 1.0) <= 0.005, $"SNOISE_THIRD is {ratio:R} times 1.10 x the re-measured {refined:R}; re-run MeasureThird and re-pin"); + var measured = (float)(refined * 1.10); + Assert.True(SNOISE_THIRD == measured, $"SNOISE_THIRD is {SNOISE_THIRD:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureThird and re-pin"); } /// @@ -1019,7 +1019,7 @@ public sealed class NoiseBoundTests /// /// Pins SNOISE2_LIPSCHITZ as LipschitzConstantPinsSampledGradients pins SNOISE_LIPSCHITZ: 1e5 seeded - /// points under it, and the ascent from MeasureLipschitz2's witness within half a percent of it / 1.10. + /// points under it, and the ascent from MeasureLipschitz2's witness, times 1.10, equals it bit for bit (the expression MeasureLipschitz2 prints). /// [Fact] public void Lipschitz2ConstantPins() @@ -1028,8 +1028,8 @@ public sealed class NoiseBoundTests Assert.True(sampled <= SNOISE2_LIPSCHITZ, $"|grad snoise2| reaches {sampled:R}, above SNOISE2_LIPSCHITZ {SNOISE2_LIPSCHITZ:R}"); var refined = Ascend2(Gradient2Norm, Lipschitz2Witness); Assert.True(refined <= SNOISE2_LIPSCHITZ, $"|grad snoise2| reaches {refined:R}, above SNOISE2_LIPSCHITZ {SNOISE2_LIPSCHITZ:R}"); - var ratio = SNOISE2_LIPSCHITZ / (1.10 * refined); - Assert.True(Math.Abs(ratio - 1.0) <= 0.005, $"SNOISE2_LIPSCHITZ is {ratio:R} times 1.10 x the re-measured {refined:R}; re-run MeasureLipschitz2 and re-pin"); + var measured = (float)(refined * 1.10); + Assert.True(SNOISE2_LIPSCHITZ == measured, $"SNOISE2_LIPSCHITZ is {SNOISE2_LIPSCHITZ:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureLipschitz2 and re-pin"); } /// diff --git a/packages/cultmath/tests/CultMath.Tests/SpanPrimitiveTests.cs b/packages/cultmath/tests/CultMath.Tests/SpanPrimitiveTests.cs index 3b871896..022a17cb 100644 --- a/packages/cultmath/tests/CultMath.Tests/SpanPrimitiveTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/SpanPrimitiveTests.cs @@ -322,7 +322,7 @@ public sealed class SpanPrimitiveTests /// /// An infinite bound never reached by the line contributes nothing, not infinity times zero: relu /// (hi = +inf) and its mirror (lo = -inf) give the finite integral, whether the line crosses the finite - /// bound inside the span or not. + /// bound inside the span or not. The flat branch (b = 0) obeys the same zero-length rule. /// [Fact] public void IntegralClampLinearIsFiniteForInfiniteBounds() @@ -343,6 +343,15 @@ public sealed class SpanPrimitiveTests Assert.Equal(-7.5f, integral_clamp_linear(0.0f, 1.0f, -inf, inf, 4.0f, 1.0f)); // A line starting on the finite bound and rising into the infinite one: all ramp, the integral of t over [2, 4]. Assert.Equal(6.0f, integral_clamp_linear(0.0f, 1.0f, 2.0f, inf, 2.0f, 4.0f)); + + // The flat branch obeys the same zero-length rule: a stretch of zero length contributes +0 however + // infinite the bound it would be clamped to, and a nonzero length of an infinite constant is infinite. + // The bit patterns are compared because Assert.Equal treats -0 as 0. + Assert.Equal(0, BitConverter.SingleToInt32Bits(integral_clamp_linear(-2.3244874f, -0.0f, 0.0f, -inf, 1.429815f, 1.429815f))); + Assert.Equal(0, BitConverter.SingleToInt32Bits(integral_clamp_linear(1.5f, 0.0f, inf, inf, 2.0f, 2.0f))); + Assert.Equal(0, BitConverter.SingleToInt32Bits(integral_clamp_linear(1.5f, -0.0f, -inf, -inf, 0.0f, 0.0f))); + Assert.Equal(inf, integral_clamp_linear(1.5f, 0.0f, inf, inf, 0.0f, 2.0f)); + Assert.Equal(inf, integral_clamp_linear(1.5f, 0.0f, -inf, -inf, 2.0f, 0.0f)); } /// diff --git a/packages/cultmath/tools/compile-hlsl.ps1 b/packages/cultmath/tools/compile-hlsl.ps1 new file mode 100644 index 00000000..6b768aa9 --- /dev/null +++ b/packages/cultmath/tools/compile-hlsl.ps1 @@ -0,0 +1,39 @@ +param( + # The HLSL files to compile, each as a lib_6_3 library with its own directory as the include path. + # Default: both copies of CultMath.hlsl (the canonical one and the Unity package's byte copy). + # tools\compile-hlsl.ps1 -ShaderPath is the negative control. + [string[]]$ShaderPath = @(), + # The dxc to run. Default: the x64 install get-dxc.ps1 makes; it is never searched for, so a dxc + # that cannot launch (the arm64 one on x64) is an error, not a skipped check. + [string]$DxcPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $DxcPath) { + $DxcPath = Join-Path $repoRoot ".tools\dxc\current\bin\x64\dxc.exe" +} +if (-not $ShaderPath) { + $ShaderPath = @( + (Join-Path $repoRoot "shaders\CultMath.hlsl"), + (Join-Path $repoRoot "unity\org.gamecult.cultmath\Shaders\CultMath.hlsl")) +} +if (-not (Test-Path -LiteralPath $DxcPath)) { + throw "dxc not found (parameter DxcPath). Run tools\get-dxc.ps1 first or pass -DxcPath." +} + +$outputDirectory = Join-Path $repoRoot ".tools\hlsl" +New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null + +$index = 0 +foreach ($shader in $ShaderPath) { + $index++ + $shaderFile = Get-Item -LiteralPath $shader + $output = Join-Path $outputDirectory "$index-$($shaderFile.BaseName).dxil" + & $DxcPath -T lib_6_3 -HV 2021 -I $shaderFile.DirectoryName $shaderFile.FullName -Fo $output + if ($LASTEXITCODE -ne 0) { + throw "dxc failed with exit code $LASTEXITCODE (parameter ShaderPath, entry $index)." + } + Write-Host "compiled shader $index of $(@($ShaderPath).Count) (lib_6_3, -HV 2021)" +} diff --git a/packages/cultmath/tools/compile-hlsl.ps1.meta b/packages/cultmath/tools/compile-hlsl.ps1.meta new file mode 100644 index 00000000..d59a4297 --- /dev/null +++ b/packages/cultmath/tools/compile-hlsl.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3f6c1e7a9b2d4c58a0e4d7b1c95f2a86 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl index c454773c..c480acc7 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -304,7 +304,7 @@ float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float { lo = min(lo, hi); if (b == 0.0) - return clamp(a, lo, hi) * (t1 - t0); + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; float direction = t1 < t0 ? -1.0 : 1.0; float start = min(t0, t1);