using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Silk.NET.Core.Native; using Silk.NET.Shaderc; namespace AcDream.Tools.ShaderCompiler; /// /// Campaign V slice V6c, plan §4.6: compile acdream's GLSL to committed SPIR-V. /// /// Usage: ShaderCompiler <shaders-dir> <output-dir>. /// Every .vert/.frag pair in the source directory is compiled /// through ; successes write /// {name}.{stage}.spv and failures are recorded with the compiler's own /// message. Either way the run writes shaders.manifest.json containing /// the SHA-256 of every GLSL input, which the App test suite re-checks so a /// source edit that never got recompiled fails a test rather than shipping a /// stale binary. /// /// The exit code is 0 when every shader the manifest expects to succeed /// did. A shader that is not yet Vulkan-expressible is not a build failure — it /// is a fact about which renderer-port slices are still outstanding, recorded in /// the manifest so it is reviewable rather than folklore. /// internal static class Program { private static unsafe int Main(string[] args) { if (args.Length < 2) { Console.Error.WriteLine("usage: ShaderCompiler "); return 2; } string sourceDirectory = Path.GetFullPath(args[0]); string outputDirectory = Path.GetFullPath(args[1]); Directory.CreateDirectory(outputDirectory); string[] names = Directory .EnumerateFiles(sourceDirectory, "*.vert") .Select(Path.GetFileNameWithoutExtension) .Where(name => name is not null) .Select(name => name!) .Where(name => File.Exists(Path.Combine(sourceDirectory, $"{name}.frag"))) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); var shaderc = Shaderc.GetApi(); Compiler* compiler = shaderc.CompilerInitialize(); CompileOptions* options = shaderc.CompileOptionsInitialize(); shaderc.CompileOptionsSetTargetEnv(options, TargetEnv.Vulkan, (uint)EnvVersion.Vulkan13); shaderc.CompileOptionsSetTargetSpirv(options, SpirvVersion.Shaderc16); // Performance rather than size: these are compiled once, committed, and // then loaded by every launch forever. shaderc.CompileOptionsSetOptimizationLevel(options, OptimizationLevel.Performance); var entries = new List(); int failures = 0; try { foreach (string name in names) { var stages = new List(); // Shared across the pair so a fragment input takes the location // its vertex output was given, by name. var locations = new Dictionary(StringComparer.Ordinal); foreach (string stage in (string[])["vert", "frag"]) { string path = Path.Combine(sourceDirectory, $"{name}.{stage}"); string source = File.ReadAllText(path); string hash = Sha256(source); string transformed; try { transformed = GlslVaryingLocations.Apply( VulkanGlslPreamble.Apply(source, stage), stage, locations); } catch (Exception error) { stages.Add(new ShaderStageResult(stage, hash, false, error.Message)); continue; } if (TryCompile(shaderc, compiler, options, transformed, $"{name}.{stage}", stage, out byte[] spirv, out string message)) { string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); File.WriteAllBytes(target, spirv); stages.Add(new ShaderStageResult(stage, hash, true, null)); } else { string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); if (File.Exists(target)) File.Delete(target); stages.Add(new ShaderStageResult(stage, hash, false, Summarise(message))); } } bool ok = stages.All(stage => stage.Compiled); if (!ok) { failures++; // Half a pair is worse than none: the device would load the // surviving stage happily and nobody could account for it. foreach (string stage in (string[])["vert", "frag"]) { string orphan = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); if (File.Exists(orphan)) File.Delete(orphan); } } entries.Add(new ShaderManifestEntry(name, ok, stages)); Console.WriteLine(ok ? $"[shaders] {name}: ok" : $"[shaders] {name}: NOT VULKAN-EXPRESSIBLE YET — {FirstReason(stages)}"); } } finally { shaderc.CompileOptionsRelease(options); shaderc.CompilerRelease(compiler); shaderc.Dispose(); } var manifest = new ShaderManifest( "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.", entries.OrderBy(entry => entry.Name, StringComparer.Ordinal).ToList()); string manifestPath = Path.Combine(outputDirectory, "shaders.manifest.json"); File.WriteAllText( manifestPath, JsonSerializer.Serialize(manifest, ShaderManifestJson.Options) + Environment.NewLine); Console.WriteLine( $"[shaders] {entries.Count - failures}/{entries.Count} pair(s) compiled; manifest at {manifestPath}"); return 0; } private static unsafe bool TryCompile( Shaderc shaderc, Compiler* compiler, CompileOptions* options, string source, string name, string stage, out byte[] spirv, out string message) { ShaderKind kind = stage == "vert" ? ShaderKind.VertexShader : ShaderKind.FragmentShader; byte[] sourceBytes = Encoding.UTF8.GetBytes(source); byte[] nameBytes = Encoding.UTF8.GetBytes(name + "\0"); byte[] entryBytes = Encoding.UTF8.GetBytes("main\0"); fixed (byte* sourcePointer = sourceBytes) fixed (byte* namePointer = nameBytes) fixed (byte* entryPointer = entryBytes) { CompilationResult* result = shaderc.CompileIntoSpv( compiler, sourcePointer, (nuint)sourceBytes.Length, kind, namePointer, entryPointer, options); try { CompilationStatus status = shaderc.ResultGetCompilationStatus(result); message = SilkMarshal.PtrToString((nint)shaderc.ResultGetErrorMessage(result)) ?? string.Empty; if (status != CompilationStatus.Success) { spirv = []; return false; } nuint length = shaderc.ResultGetLength(result); spirv = new byte[(int)length]; new ReadOnlySpan(shaderc.ResultGetBytes(result), (int)length).CopyTo(spirv); return true; } finally { shaderc.ResultRelease(result); } } } /// First line of a compiler message, which is the one that names the cause. private static string Summarise(string message) { string[] lines = message .Replace("\r\n", "\n") .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return lines.Length == 0 ? "unknown compiler failure" : lines[0]; } private static string FirstReason(IEnumerable stages) => stages.FirstOrDefault(stage => !stage.Compiled)?.Message ?? "unknown"; private static string Sha256(string text) { // Line endings are normalised before hashing so a checkout with // different core.autocrlf settings does not report every shader stale. byte[] bytes = Encoding.UTF8.GetBytes(text.Replace("\r\n", "\n")); return Convert.ToHexStringLower(SHA256.HashData(bytes)); } } internal sealed record ShaderStageResult( [property: JsonPropertyName("stage")] string Stage, [property: JsonPropertyName("sourceSha256")] string SourceSha256, [property: JsonPropertyName("compiled")] bool Compiled, [property: JsonPropertyName("message")] string? Message); internal sealed record ShaderManifestEntry( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("vulkanReady")] bool VulkanReady, [property: JsonPropertyName("stages")] IReadOnlyList Stages); internal sealed record ShaderManifest( [property: JsonPropertyName("note")] string Note, [property: JsonPropertyName("shaders")] IReadOnlyList Shaders); internal static class ShaderManifestJson { internal static JsonSerializerOptions Options { get; } = new() { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; }