acdream/tools/ShaderCompiler/Program.cs
Erik 234fe91d3b feat(render): Campaign V slice V6c - SPIR-V, pipelines, passes, and a Vulkan frame that draws
The last of V6's three commits, and the one that makes the backend render.
Plan sections: 4.5 (pipelines and the persisted cache), 4.6 (shaders and the
committed .spv), 4.7 and 3.3 (clip space, the Y flip and winding), 4.9 and 4.10
(swapchain format and the scissor convention), 4.11 (the probe shader V5
deferred), 5.4 (Target: null means the swapchain image, literally).

WHAT RUNS. ACDREAM_RENDER_BACKEND=vulkan now renders a real scene through the
whole RHI on the RX 9070 XT: 60,000-plus frames per twelve-second run, 4x MSAA
resolving into a B8G8R8A8_UNORM swapchain, GPU timer scopes resolving, a
screenshot taken through IGpuDevice.CaptureBackbuffer, and a clean
CloseMainWindow exit with the allocator reporting three device-memory objects.

WHAT IT DRAWS, AND WHY IT IS NOT THE GAME. V6's milestone is "a full game frame
on Vulkan" and on this branch that cannot be the game's own frame. V4c and V4d
are parked by 5.5.5 so the world renderers are still raw GL; and the two
renderers that DO speak the RHI - TextRenderer and DebugLineRenderer, ported at
V4a - both throw for any device that is not a GlGpuDevice, because their loose
uniforms and their classic texture-unit sprite binding have no home in the
pinned contract yet. Converting them is a V4-class change with its own GL pixel
gate, outside this slice's file list.

So the backend is exercised through the contract by a scene of our own, and it
is not a toy. It uses a device-local mesh arena filled through the staging ring,
instance and batch data written straight into mapped ring memory, an offscreen
render target whose colour is registered into the global texture table and
sampled by a later pass, a BC1 texture with a CPU-built mip chain beside an
uncompressed one with a vkCmdBlitImage chain, one multi-draw-indirect covering
five quads with gl_DrawID selecting per-draw batch data, a second pipeline with
line-list topology bound mid-pass, dynamic cull/front-face/depth-write, push
constants, timer scopes, and an MSAA colour attachment resolving into the
swapchain image.

ORIENTATION, BY INSPECTION. Slice V5's screenshot was a uniform clear and its
orientation was right "by construction" - which a uniform clear cannot show. The
scene is therefore deliberately asymmetric in both axes: a quadrant card that is
red top-left, green top-right, blue bottom-left and white bottom-right, four
differently tinted markers at four different corners, and an open L of lines
whose short stub rises at its right end. The captured PNG reads correctly in
every one of those, including a miniature of the same card in the bottom-right
whose own quadrants are also the right way up. The negative viewport height, the
front-face inversion and the capture path agree.

THE SHADER TOOLCHAIN, AND WHAT IT FOUND. tools/compile-shaders.ps1 drives
tools/ShaderCompiler, a small out-of-solution .NET tool over Silk.NET.Shaderc -
the same shaderc glslc is built on, through the already-pinned Silk 2.23.0
family. glslc is preferred when a Vulkan SDK is present and reported when it is;
neither this machine nor CI has one, and requiring a 500 MB manual install
between a contributor and a working checkout is not a reasonable price for a
build step. The GLSL sources stay the single source of truth: the Vulkan dialect
arrives as a preamble injected after the #version line - ACDREAM_UBO_SET becomes
"set = 1,", the texture table becomes a set-2 descriptor array with a required
nonuniformEXT accessor, and the shared 96-byte push block is declared with each
loose uniform name defined onto its member. The only edits to a shader BODY are
mechanical and dialect-level: dropping default-block uniform declarations, which
Vulkan GLSL has no such thing as, and assigning explicit varying locations BY
NAME across a pair, because ordinal assignment would look identical today and
silently swap varyings the first time an author reordered a line.

Run over the eight production pairs, exactly one thing happened: none of them
compiled, and every failure is a specific source-level fact belonging to a
renderer-port slice that has not landed. debug_line needs uView/uProjection
converged into one uViewProjection - two matrices are 128 bytes and the shared
block is 96. mesh_modern and particle still pass a uvec2 bindless handle as a
varying, which is V4t's GpuTextureSlot retype. sky has ten loose uniforms and
wants a UBO. ui_text needs uScreenSize/uUseTexture/uTex. particle_mesh needs
uTextureIndex to become uTextureIndexA. terrain_modern needs V4d-1's matrix
convergence. mesh is the legacy pair with no RHI consumer at all. That inventory
is committed as shaders.manifest.json, with each source's SHA-256 and the
compiler's own message, and a test re-hashes it so an edited shader that never
got recompiled fails a build rather than shipping a stale binary.

vk_probe is the pair that does compile, and it is the shader 4.11 already asked
for: V5 recorded "build one real pipeline from the committed .spv" as its single
deliberate deviation because no toolchain existed. It is Vulkan-dialect only and
no GL renderer draws with it, so it forks nothing; it retires when the ported
world renderers become the backend's own proof.

DESCRIPTORS. Sets 0 and 1 are DYNAMIC buffer descriptors bound per flight slot,
so a per-draw range change costs a dynamic offset in vkCmdBindDescriptorSets
rather than a vkUpdateDescriptorSets in the hot path - which is what keeps 4.4's
zero-writes-per-frame property true for buffers as well as for textures. Ten
dynamic storage descriptors is above Vulkan's guaranteed minimum of four, so it
is a real requirement rather than a free choice, it fails loudly at layout
creation on a device that cannot serve it, and V9's lavapipe row must confirm
it. Unused bindings point at a shared dummy range so there is ONE set layout and
one pipeline layout; that is why binding a second pipeline mid-pass costs
nothing and disturbs neither the descriptors nor the push constants.

THE ONE MAPPING FUNCTION. VulkanViewportMapping holds the whole coordinate
reconciliation: negative viewport height, the front-face inversion that pairs
with it, and - separately - the scissor flip, which the viewport sign does NOT
perform. The V3 audit flagged that as a concrete V6 acceptance item and it is
the subtle one: vkCmdSetScissor is always top-left-origin, NdcScissorRect emits
GL bottom-left rectangles, and getting it wrong clips a doorway aperture from
the wrong edge in a scene that has one. Clip space needs nothing, as 4.7
concluded: the cameras already build [0,1]-convention projections.

CONTRACT GAP, RECORDED NOT PAPERED OVER. GpuPipelineDescription cannot name its
colour-attachment format, and Vulkan bakes that into a pipeline. Offscreen
targets therefore adopt the swapchain's B8G8R8A8_UNORM rather than a literal
RGBA order - invisible above the API, because an image is sampled through its
format's component mapping and the one CPU readback swizzles explicitly. The
honest fix is a colour-format field added in a reviewed contract commit, exactly
as GpuBlendMode.InverseAlpha and GpuVertexFormat.UByte4UInt were added when V4c
and V4d met the same wall. It is documented at
VulkanTextureFormatMapping.CanonicalColorAttachmentFormat.

The pipeline cache is persisted to the cache directory and validated by its
32-byte header against this device's vendor, device and cache UUID before use.
Drivers are required to ignore incompatible blobs, but "required to" is a poor
foundation for something that runs before anything else in the process, and the
check costs 32 bytes of comparison. Two consecutive launches report "cold" then
"reused".

Gates: Release build clean; App suite 4056 passed / 3 skipped (4037 at V6b plus
19 new); offline pixel gate PASS at a differing fraction of 5.15e-05 with a
same-commit control immediately after it at 2.84e-05 - 29 and 16 pixels of
563,200, the same class of ambient variation the campaign's 15-23 band records,
and roughly 19x under the 0.001 threshold on a commit that changes no GL code
path.

Validation layers could not be run: this machine has no Vulkan SDK, no
HKLM\SOFTWARE\Khronos\Vulkan\ExplicitLayers key, no VK_LAYER_PATH and no
VkLayer_khronos_validation.json anywhere on disk. Plan 7 already requires one
validation-clean run at V7; it needs the SDK installed first and is reported
rather than assumed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 08:17:24 +02:00

235 lines
9.7 KiB
C#

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;
/// <summary>
/// Campaign V slice V6c, plan §4.6: compile acdream's GLSL to committed SPIR-V.
///
/// <para>Usage: <c>ShaderCompiler &lt;shaders-dir&gt; &lt;output-dir&gt;</c>.
/// Every <c>.vert</c>/<c>.frag</c> pair in the source directory is compiled
/// through <see cref="VulkanGlslPreamble"/>; successes write
/// <c>{name}.{stage}.spv</c> and failures are recorded with the compiler's own
/// message. Either way the run writes <c>shaders.manifest.json</c> 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.</para>
///
/// <para>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.</para>
/// </summary>
internal static class Program
{
private static unsafe int Main(string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("usage: ShaderCompiler <shaders-dir> <output-dir>");
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<ShaderManifestEntry>();
int failures = 0;
try
{
foreach (string name in names)
{
var stages = new List<ShaderStageResult>();
// Shared across the pair so a fragment input takes the location
// its vertex output was given, by name.
var locations = new Dictionary<string, int>(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<byte>(shaderc.ResultGetBytes(result), (int)length).CopyTo(spirv);
return true;
}
finally
{
shaderc.ResultRelease(result);
}
}
}
/// <summary>First line of a compiler message, which is the one that names the cause.</summary>
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<ShaderStageResult> 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<ShaderStageResult> Stages);
internal sealed record ShaderManifest(
[property: JsonPropertyName("note")] string Note,
[property: JsonPropertyName("shaders")] IReadOnlyList<ShaderManifestEntry> Shaders);
internal static class ShaderManifestJson
{
internal static JsonSerializerOptions Options { get; } = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}