CULTMATH_MAX_SITES defaults to 128 under #ifndef. The consumer-written ring-loop and border-loop comment recipes are deleted. The C# mirror takes the array as T[]; the span twins live in SiteArrays; the GLSL lowering keeps the capacity macro; gen.py learns the array leaf; the smooth-step family gains its cap-point draw; CULTMATH_WRITE_GLSL refuses non-Linux. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
187 lines
9.3 KiB
C#
187 lines
9.3 KiB
C#
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace CultMath.Tests;
|
|
|
|
/// <summary>
|
|
/// The HLSL to GLSL ES 3.00 transformation that produces <c>shaders/CultMath.glsl</c>, and one file per
|
|
/// <see cref="SeparateFiles"/> entry, from <c>shaders/CultMath.hlsl</c> and its includes. The steps below,
|
|
/// in order, are the complete list documented in docs/design.md (GLSL Target: Source Transformations).
|
|
/// A dialect gap outside the list is fixed in the HLSL, in the subset both languages share, never by a
|
|
/// special case here; GlslMirrorTests fails on any HLSL-only token that survives.
|
|
/// </summary>
|
|
internal static class GlslLowering
|
|
{
|
|
/// <summary>
|
|
/// Includes under a file-level licence of their own: each is lowered into its own output file instead
|
|
/// of being inlined, so CultMath.glsl stays MIT. This table is the lowering's only rule keyed by name.
|
|
/// </summary>
|
|
internal static readonly IReadOnlyDictionary<string, (string Glsl, string Licence)> SeparateFiles =
|
|
new Dictionary<string, (string, string)>
|
|
{
|
|
// Phacelle is a port of MPL-2.0 code, and MPL-2.0 covers the files that carry it.
|
|
["CultMath.Phacelle.hlsl"] = ("CultMath.Phacelle.glsl", "MPL-2.0"),
|
|
// The Cycles node semantics are a port of Apache-2.0 code, and the licence travels with the file.
|
|
["CultMath.Cycles.hlsl"] = ("CultMath.Cycles.glsl", "Apache-2.0"),
|
|
};
|
|
|
|
private const string Regenerate =
|
|
"by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with " +
|
|
"CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests.";
|
|
|
|
/// <summary>
|
|
/// The lowered files as (name under shaders/, text): CultMath.glsl first, then one per
|
|
/// <see cref="SeparateFiles"/> entry, which requires CultMath.glsl before it.
|
|
/// </summary>
|
|
internal static IReadOnlyList<(string File, string Text)> Lower(string cultMathRoot)
|
|
{
|
|
// 1. Each #include is inlined in place by the C# mirror's own resolver, except a SeparateFiles
|
|
// include, which leaves nothing behind and is lowered into its own file.
|
|
var inlined = new List<string>();
|
|
var library = HlslSourceCompatibilityTests.ReadShaderSource(cultMathRoot, name =>
|
|
{
|
|
if (SeparateFiles.ContainsKey(name))
|
|
return false;
|
|
inlined.Add(name);
|
|
return true;
|
|
});
|
|
|
|
var outputs = new List<(string File, string Text)>();
|
|
var pointers = new List<string>();
|
|
foreach (var (hlsl, (glsl, licence)) in SeparateFiles)
|
|
{
|
|
var text = LowerText(
|
|
File.ReadAllText(Path.Combine(cultMathRoot, "shaders", hlsl)),
|
|
$"// Generated from shaders/{hlsl} {Regenerate} {licence}; requires CultMath.glsl before it.",
|
|
glsl);
|
|
var functions = Regex.Matches(text, @"(?m)^\w+\s+(cultmath_\w+)\s*\(").Select(m => m.Groups[1].Value);
|
|
pointers.Add($" {string.Join(", ", functions)} is in {glsl}, to be concatenated after this file by consumers that call it.");
|
|
outputs.Add((glsl, text));
|
|
}
|
|
|
|
var includes = string.Concat(inlined.Select(name => " and " + name));
|
|
outputs.Insert(0, ("CultMath.glsl", LowerText(
|
|
library, $"// Generated from shaders/CultMath.hlsl{includes} {Regenerate} MIT.{string.Concat(pointers)}", "CultMath.glsl")));
|
|
return outputs;
|
|
}
|
|
|
|
private static string LowerText(string hlsl, string provenance, string file)
|
|
{
|
|
var source = hlsl.ReplaceLineEndings("\n");
|
|
|
|
// 2. Include guards are dropped, and so are the functions taking Texture2D/SamplerState, exactly
|
|
// as the C# mirror drops them; the host shader samples its own textures. The capacity macro
|
|
// (#ifndef CULTMATH_MAX_SITES ... #endif) stays: GLSL has the preprocessor, and a consumer overrides it.
|
|
source = HlslSourceCompatibilityTests.DropGuards(source, (name, value) => $"#ifndef {name}\n#define {name} {value}\n#endif");
|
|
source = Regex.Replace(source, @"(?ms)^\w+ \w+\([^)]*\b(Texture2D|SamplerState)\b[^)]*\)\s*\{.*?^\}[ \t]*$", string.Empty);
|
|
|
|
// 3. Type names: floatN, intN, uintN and boolN become vecN, ivecN, uvecN and bvecN, and float3x3
|
|
// becomes mat3 (the one matrix type the library returns: a float3x3 is built from nine scalars,
|
|
// which HLSL reads as rows and GLSL as columns, so only symmetric matrices are exported).
|
|
source = LowerTypeNames(source);
|
|
|
|
// 4. File-scope `static const` becomes `const`.
|
|
source = Regex.Replace(source, @"(?m)^static const ", "const ");
|
|
|
|
// 5. C-style casts (float)x, (int)x and (uint)x become constructor calls float(x), int(x) and
|
|
// uint(x). The operand is found by a balanced scanner, not a regex: (int)((state >> 28) + 4u)
|
|
// nests parentheses.
|
|
source = LowerCasts(source);
|
|
|
|
// 6. Intrinsic renames: lerp -> mix, frac -> fract, rsqrt -> inversesqrt, asuint ->
|
|
// floatBitsToUint, asfloat -> uintBitsToFloat, atan2 -> atan (GLSL's two-argument arctangent),
|
|
// and saturate(x) -> clamp(x, 0.0, 1.0).
|
|
// Identifier boundaries keep cultmath_lerp and the rest of the cultmath_ names intact.
|
|
source = Regex.Replace(source, @"\blerp\(", "mix(");
|
|
source = Regex.Replace(source, @"\bfrac\(", "fract(");
|
|
source = Regex.Replace(source, @"\brsqrt\(", "inversesqrt(");
|
|
source = Regex.Replace(source, @"\basuint\(", "floatBitsToUint(");
|
|
source = Regex.Replace(source, @"\basfloat\(", "uintBitsToFloat(");
|
|
source = Regex.Replace(source, @"\batan2\(", "atan(");
|
|
source = LowerSaturate(source);
|
|
|
|
// 7. Float literals pass through: HLSL's unsuffixed 0.5 and 1.0e30 are GLSL literals already.
|
|
|
|
// 8. The guard named for the file (CultMath.glsl -> CULTMATH_GLSL), and 9. the provenance line.
|
|
var guard = file.ToUpperInvariant().Replace('.', '_');
|
|
return provenance + "\n#ifndef " + guard + "\n#define " + guard + "\n" + source.Trim('\n') + "\n\n#endif\n";
|
|
}
|
|
|
|
/// <summary>Step 3 alone; GlslMirrorTests maps the HLSL signatures with it to compare overloads.</summary>
|
|
internal static string LowerTypeNames(string source)
|
|
{
|
|
source = Regex.Replace(source, @"\bfloat([234])\b", "vec$1");
|
|
source = Regex.Replace(source, @"\bint([234])\b", "ivec$1");
|
|
source = Regex.Replace(source, @"\buint([234])\b", "uvec$1");
|
|
source = Regex.Replace(source, @"\bbool([234])\b", "bvec$1");
|
|
return Regex.Replace(source, @"\bfloat3x3\b", "mat3");
|
|
}
|
|
|
|
private static readonly Regex Cast = new(@"\((float|int|uint)\)");
|
|
|
|
private static string LowerCasts(string source)
|
|
{
|
|
for (var match = Cast.Match(source); match.Success; match = Cast.Match(source))
|
|
{
|
|
var start = match.Index + match.Length;
|
|
var end = OperandEnd(source, start);
|
|
// A parenthesized operand lends its own parentheses to the constructor call.
|
|
var operand = source[start] == '(' && Balanced(source, start) == end ? source[(start + 1)..(end - 1)] : source[start..end];
|
|
source = source[..match.Index] + match.Groups[1].Value + "(" + operand + ")" + source[end..];
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
// The cast operand: a parenthesized group, or an identifier or number followed by any member
|
|
// accesses, calls and indexing. Anything else is outside the list and throws.
|
|
private static int OperandEnd(string source, int index)
|
|
{
|
|
if (index < source.Length && source[index] == '(')
|
|
return Balanced(source, index);
|
|
var token = Regex.Match(source[index..], @"^[A-Za-z_0-9]+");
|
|
if (!token.Success)
|
|
throw new InvalidOperationException($"cast operand outside the lowering list at: {source.Substring(index, Math.Min(40, source.Length - index))}");
|
|
index += token.Length;
|
|
while (index < source.Length)
|
|
{
|
|
if (source[index] == '(' || source[index] == '[')
|
|
index = Balanced(source, index);
|
|
else if (source[index] == '.' && index + 1 < source.Length && (char.IsLetter(source[index + 1]) || source[index + 1] == '_'))
|
|
index += 1 + Regex.Match(source[(index + 1)..], @"^[A-Za-z_0-9]+").Length;
|
|
else
|
|
break;
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
// The index one past the bracket that closes the one at `open`.
|
|
private static int Balanced(string source, int open)
|
|
{
|
|
var depth = 0;
|
|
for (var i = open; i < source.Length; i++)
|
|
{
|
|
if (source[i] is '(' or '[')
|
|
depth++;
|
|
else if (source[i] is ')' or ']' && --depth == 0)
|
|
return i + 1;
|
|
}
|
|
|
|
throw new InvalidOperationException($"unbalanced bracket at {open}");
|
|
}
|
|
|
|
private static string LowerSaturate(string source)
|
|
{
|
|
var saturate = new Regex(@"\bsaturate\(");
|
|
for (var match = saturate.Match(source); match.Success; match = saturate.Match(source))
|
|
{
|
|
var open = match.Index + match.Length - 1;
|
|
var close = Balanced(source, open);
|
|
var argument = source[(open + 1)..(close - 1)];
|
|
source = source[..match.Index] + "clamp(" + argument + ", 0.0, 1.0)" + source[close..];
|
|
}
|
|
|
|
return source;
|
|
}
|
|
}
|