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>
This commit is contained in:
parent
9eae496301
commit
234fe91d3b
24 changed files with 3937 additions and 319 deletions
100
tools/ShaderCompiler/GlslVaryingLocations.cs
Normal file
100
tools/ShaderCompiler/GlslVaryingLocations.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AcDream.Tools.ShaderCompiler;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6c: give every stage-to-stage varying an explicit
|
||||
/// <c>layout(location = N)</c>, which SPIR-V requires and desktop GL does not.
|
||||
///
|
||||
/// <para>This is the one place the compiler edits a shader body rather than
|
||||
/// prepending to it, and it is limited to inserting a qualifier in front of a
|
||||
/// declaration it already matched exactly. Locations are keyed BY NAME, taken
|
||||
/// from the vertex stage's outputs and looked up by the fragment stage's inputs,
|
||||
/// so the two stages cannot drift apart if someone reorders a declaration in one
|
||||
/// of them. Assigning by ordinal would look identical today and produce silently
|
||||
/// swapped varyings the first time an author moved a line.</para>
|
||||
///
|
||||
/// <para>Anything already carrying a <c>layout(</c> qualifier is left alone, as
|
||||
/// are interface blocks (which open a brace) and the redeclared
|
||||
/// <c>gl_PerVertex</c> block that lets a core-profile shader write
|
||||
/// <c>gl_ClipDistance</c>.</para>
|
||||
/// </summary>
|
||||
internal static partial class GlslVaryingLocations
|
||||
{
|
||||
// GLSL allows the interpolation qualifier on either side of the direction —
|
||||
// both `out flat uvec2 v;` and `flat out uvec2 v;` appear in acdream's
|
||||
// shaders — so the pattern accepts either and neither.
|
||||
[GeneratedRegex(
|
||||
@"^(?<indent>\s*)(?<leading>(flat|noperspective|smooth|centroid)\s+)*(?<direction>in|out)\s+(?<interp>(flat|noperspective|smooth|centroid)\s+)*(?<type>[A-Za-z_][A-Za-z0-9_]*)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?<array>\[[^\]]*\])?\s*;\s*(?<trailing>//.*)?$",
|
||||
RegexOptions.ExplicitCapture)]
|
||||
private static partial Regex VaryingDeclaration();
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites <paramref name="source"/> so every user varying carries a
|
||||
/// location. <paramref name="locationsByName"/> is shared across the two
|
||||
/// stages of a pair and is populated by whichever stage declares a name
|
||||
/// first.
|
||||
/// </summary>
|
||||
internal static string Apply(string source, string stage, Dictionary<string, int> locationsByName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(locationsByName);
|
||||
|
||||
string[] lines = source.Replace("\r\n", "\n").Split('\n');
|
||||
var output = new StringBuilder();
|
||||
int nextFragmentOutput = 0;
|
||||
|
||||
foreach (string line in lines)
|
||||
{
|
||||
if (line.Contains("layout(", StringComparison.Ordinal) || line.Contains('{'))
|
||||
{
|
||||
output.AppendLine(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match = VaryingDeclaration().Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
output.AppendLine(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
string direction = match.Groups["direction"].Value;
|
||||
string name = match.Groups["name"].Value;
|
||||
|
||||
// Vertex inputs are attributes, not varyings: their locations are
|
||||
// the vertex layout the pipeline declares, and every acdream vertex
|
||||
// shader already states them. Leaving an unqualified one alone makes
|
||||
// the compiler say so rather than inventing a binding.
|
||||
if (stage == "vert" && direction == "in")
|
||||
{
|
||||
output.AppendLine(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
int location;
|
||||
if (stage == "frag" && direction == "out")
|
||||
{
|
||||
// Fragment outputs live in their own location space; acdream has
|
||||
// exactly one colour attachment everywhere.
|
||||
location = nextFragmentOutput++;
|
||||
}
|
||||
else if (locationsByName.TryGetValue(name, out int existing))
|
||||
{
|
||||
location = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
location = locationsByName.Count == 0 ? 0 : locationsByName.Values.Max() + 1;
|
||||
locationsByName[name] = location;
|
||||
}
|
||||
|
||||
string indent = match.Groups["indent"].Value;
|
||||
string rest = line[indent.Length..];
|
||||
output.AppendLine($"{indent}layout(location = {location}) {rest}");
|
||||
}
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
}
|
||||
235
tools/ShaderCompiler/Program.cs
Normal file
235
tools/ShaderCompiler/Program.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
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 <shaders-dir> <output-dir></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,
|
||||
};
|
||||
}
|
||||
29
tools/ShaderCompiler/ShaderCompiler.csproj
Normal file
29
tools/ShaderCompiler/ShaderCompiler.csproj
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!--
|
||||
Campaign V slice V6c: the GLSL -> SPIR-V compiler behind
|
||||
tools/compile-shaders.ps1.
|
||||
|
||||
Deliberately OUTSIDE AcDream.slnx and never referenced by AcDream.App. Plan
|
||||
§4.6 rules out runtime shader compilation - it would add a native dependency
|
||||
and a startup cost for shaders that never change at runtime - so this is a
|
||||
build-time tool whose only output is committed .spv artifacts.
|
||||
|
||||
It uses Silk.NET.Shaderc rather than shelling out to glslc because CI
|
||||
runners and this development machine have no Vulkan SDK installed, and
|
||||
Silk.NET is already the pinned graphics binding family at 2.23.0.
|
||||
tools/compile-shaders.ps1 still prefers a real glslc when one is present.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<RootNamespace>AcDream.Tools.ShaderCompiler</RootNamespace>
|
||||
<AssemblyName>AcDream.Tools.ShaderCompiler</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.Shaderc" Version="2.23.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
225
tools/ShaderCompiler/VulkanGlslPreamble.cs
Normal file
225
tools/ShaderCompiler/VulkanGlslPreamble.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
using System.Text;
|
||||
|
||||
namespace AcDream.Tools.ShaderCompiler;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6c, plan §3.4 and §4.6: the Vulkan half of acdream's
|
||||
/// dual-dialect GLSL.
|
||||
///
|
||||
/// <para>The GLSL sources under <c>Rendering/Shaders</c> are the single source of
|
||||
/// truth for both backends, and they are written in the dialect GL accepts. The
|
||||
/// three things Vulkan needs on top of that are not source edits — they are
|
||||
/// definitions the compiler injects immediately after the <c>#version</c> line:
|
||||
/// </para>
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><c>ACDREAM_UBO_SET</c> becomes <c>set = 1,</c>. Under GL it expands to
|
||||
/// nothing, because GL keeps the SSBO and UBO binding namespaces separate and
|
||||
/// <c>BatchBuffer</c> (SSBO binding 1) and <c>SceneLighting</c> (UBO binding 1)
|
||||
/// can share a number. Vulkan has one namespace per set, so moving uniform
|
||||
/// buffers to set 1 preserves both numbers. <c>common.glsl</c> has carried this
|
||||
/// macro since V2 for exactly this moment.</item>
|
||||
/// <item>The texture table becomes a real descriptor array at set 2 binding 0,
|
||||
/// and <c>ACDREAM_TEXTURE_HANDLE</c> becomes an index rather than a packed
|
||||
/// bindless handle. <c>nonuniformEXT</c> is required, not optional: within one
|
||||
/// multi-draw dispatch different draws read different <c>Batches[]</c> entries,
|
||||
/// and "dynamically uniform" is defined over the whole dispatch on some
|
||||
/// implementations.</item>
|
||||
/// <item>The shared 96-byte push-constant block is declared, and each loose
|
||||
/// uniform name is <c>#define</c>d onto its member. Vulkan GLSL has no default
|
||||
/// uniform block, so this is the only way the same source can declare
|
||||
/// <c>uniform mat4 uViewProjection;</c> for GL and read a push constant for
|
||||
/// Vulkan.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Injection rather than source rewriting is the whole design. A textual
|
||||
/// transform over the real shader bodies would be a small compiler with its own
|
||||
/// failure modes, and it would fail silently — a shader that compiles but reads
|
||||
/// the wrong storage buffer looks exactly like a shader that works until
|
||||
/// someone renders with it. A preamble either defines what the body needs or the
|
||||
/// compile fails loudly, which is a property worth more than convenience.</para>
|
||||
/// </summary>
|
||||
internal static class VulkanGlslPreamble
|
||||
{
|
||||
/// <summary>
|
||||
/// The loose uniform names the shared push-constant block carries, in the
|
||||
/// order <c>GpuPushConstants</c> declares them. Any shader whose uniforms are
|
||||
/// all in this list needs nothing but the preamble; any shader with a
|
||||
/// uniform outside it cannot be expressed against the pinned contract and is
|
||||
/// reported rather than guessed at.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> PushConstantFields { get; } =
|
||||
[
|
||||
"uViewProjection",
|
||||
"uDrawIDOffset",
|
||||
"uLightingMode",
|
||||
"uRenderPass",
|
||||
"uLightDebug",
|
||||
"uTextureIndexA",
|
||||
"uTextureIndexB",
|
||||
"uParamA",
|
||||
"uParamB",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Builds the text inserted after the <c>#version</c> directive.
|
||||
/// <paramref name="stage"/> only affects which stage-specific rewrites are
|
||||
/// emitted.
|
||||
/// </summary>
|
||||
internal static string Build(string stage)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine("// ---- injected by tools/compile-shaders.ps1 (Campaign V slice V6c) ----");
|
||||
text.AppendLine("// Vulkan dialect only. The GL backend compiles the same source with none");
|
||||
text.AppendLine("// of this, which is what keeps one GLSL file the single source of truth.");
|
||||
text.AppendLine("#extension GL_EXT_nonuniform_qualifier : require");
|
||||
if (stage == "vert")
|
||||
{
|
||||
// gl_DrawID is Vulkan's shaderDrawParameters feature, and glslang
|
||||
// still gates the identifier behind the ARB extension name even when
|
||||
// targeting Vulkan. Declared here so a source that does not name it
|
||||
// still gets it.
|
||||
text.AppendLine("#extension GL_ARB_shader_draw_parameters : require");
|
||||
}
|
||||
|
||||
text.AppendLine();
|
||||
text.AppendLine("// §3.4 set 1: every uniform buffer. Under GL this macro expands to nothing.");
|
||||
text.AppendLine("#undef ACDREAM_UBO_SET");
|
||||
text.AppendLine("#define ACDREAM_UBO_SET set = 1,");
|
||||
text.AppendLine();
|
||||
text.AppendLine("// §4.4 set 2: the global sampled-texture table that replaces");
|
||||
text.AppendLine("// GL_ARB_bindless_texture. Variable count, partially bound,");
|
||||
text.AppendLine("// update-after-bind; the CPU never writes it per frame.");
|
||||
text.AppendLine("layout(set = 2, binding = 0) uniform sampler2DArray uTextures[];");
|
||||
text.AppendLine("#undef ACDREAM_TEXTURE_HANDLE");
|
||||
text.AppendLine("#define ACDREAM_TEXTURE_HANDLE(idx) (idx)");
|
||||
text.AppendLine("#define ACDREAM_TEXTURE(idx) uTextures[nonuniformEXT(uint(idx))]");
|
||||
text.AppendLine();
|
||||
text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines");
|
||||
text.AppendLine("// mid-pass invalidates neither descriptors nor constants.");
|
||||
text.AppendLine("layout(push_constant) uniform AcdreamPushBlock {");
|
||||
text.AppendLine(" mat4 viewProjection;");
|
||||
text.AppendLine(" int drawIdOffset;");
|
||||
text.AppendLine(" int lightingMode;");
|
||||
text.AppendLine(" int renderPass;");
|
||||
text.AppendLine(" int lightDebug;");
|
||||
text.AppendLine(" uint textureIndexA;");
|
||||
text.AppendLine(" uint textureIndexB;");
|
||||
text.AppendLine(" float paramA;");
|
||||
text.AppendLine(" float paramB;");
|
||||
text.AppendLine("} acdreamPush;");
|
||||
text.AppendLine();
|
||||
text.AppendLine("#define uViewProjection acdreamPush.viewProjection");
|
||||
text.AppendLine("#define uDrawIDOffset acdreamPush.drawIdOffset");
|
||||
text.AppendLine("#define uLightingMode acdreamPush.lightingMode");
|
||||
text.AppendLine("#define uRenderPass acdreamPush.renderPass");
|
||||
text.AppendLine("#define uLightDebug acdreamPush.lightDebug");
|
||||
text.AppendLine("#define uTextureIndexA acdreamPush.textureIndexA");
|
||||
text.AppendLine("#define uTextureIndexB acdreamPush.textureIndexB");
|
||||
text.AppendLine("#define uParamA acdreamPush.paramA");
|
||||
text.AppendLine("#define uParamB acdreamPush.paramB");
|
||||
text.AppendLine();
|
||||
text.AppendLine("// §4.6: gl_DrawIDARB stays as written — glslang exposes it for Vulkan");
|
||||
text.AppendLine("// under the same ARB extension name. gl_InstanceIndex already includes");
|
||||
text.AppendLine("// firstInstance, so the GL idiom gl_BaseInstanceARB + gl_InstanceID");
|
||||
text.AppendLine("// collapses to it exactly.");
|
||||
text.AppendLine(stage == "vert"
|
||||
? "#define gl_BaseInstanceARB 0\n"
|
||||
+ "#define gl_InstanceID gl_InstanceIndex\n"
|
||||
+ "#define gl_VertexID gl_VertexIndex"
|
||||
: "// (the vertex/instance-index rewrites apply to the vertex stage only)");
|
||||
text.AppendLine("// ---- end injected preamble ----");
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="source"/> with the preamble inserted after its
|
||||
/// <c>#version</c> line and the version raised to 450, which is the floor for
|
||||
/// Vulkan GLSL. Everything else is untouched: this never edits a shader body.
|
||||
/// </summary>
|
||||
internal static string Apply(string source, string stage)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
string[] lines = source.Replace("\r\n", "\n").Split('\n');
|
||||
var output = new StringBuilder();
|
||||
bool injected = false;
|
||||
|
||||
foreach (string line in lines)
|
||||
{
|
||||
string trimmed = line.TrimStart();
|
||||
if (!injected && trimmed.StartsWith("#version", StringComparison.Ordinal))
|
||||
{
|
||||
// 450 is the floor for Vulkan GLSL; a source already asking for
|
||||
// more keeps it, because a shader that opted into 460 did so for
|
||||
// a feature and quietly downgrading it would be a silent change.
|
||||
output.AppendLine(HighestVersion(trimmed) >= 460 ? "#version 460 core" : "#version 450 core");
|
||||
output.Append(Build(stage));
|
||||
injected = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bindless textures are what the set-2 descriptor array replaces;
|
||||
// requiring the extension under Vulkan is an error rather than a
|
||||
// no-op. (GL_ARB_shader_draw_parameters is kept — glslang still gates
|
||||
// gl_DrawID behind that name when targeting Vulkan.)
|
||||
if (trimmed.StartsWith("#extension GL_ARB_bindless_texture", StringComparison.Ordinal))
|
||||
{
|
||||
output.AppendLine($"// (dropped for Vulkan: {trimmed})");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vulkan GLSL has no default uniform block, so a loose
|
||||
// `uniform mat4 uViewProjection;` is illegal however it is spelled.
|
||||
// Dropping the DECLARATION is what lets the preamble's #define
|
||||
// redirect the name onto a push-constant member; a #define alone
|
||||
// would only rewrite the declaration into a worse one. Uniform BLOCK
|
||||
// declarations (which carry a `{`) are untouched, and an opaque
|
||||
// sampler or a name with no push-constant home simply becomes an
|
||||
// undeclared identifier — a loud, specific compiler error naming the
|
||||
// shader that still needs its port slice.
|
||||
if (IsDefaultBlockUniformDeclaration(trimmed))
|
||||
{
|
||||
output.AppendLine($"// (declaration dropped for Vulkan: {trimmed})");
|
||||
continue;
|
||||
}
|
||||
|
||||
output.AppendLine(line);
|
||||
}
|
||||
|
||||
if (!injected)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The shader has no #version directive, so there is nowhere to inject the Vulkan preamble.");
|
||||
}
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
/// <summary>The numeric version a <c>#version</c> directive asks for, or 450 when it cannot be read.</summary>
|
||||
internal static int HighestVersion(string versionDirective)
|
||||
{
|
||||
string[] parts = versionDirective.Split(
|
||||
[' ', '\t'],
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length >= 2 && int.TryParse(parts[1], out int version) ? version : 450;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for a loose (default-block) uniform declaration —
|
||||
/// <c>uniform mat4 uViewProjection;</c> or <c>uniform float uTexTiling[36];</c> —
|
||||
/// and false for a uniform BLOCK, which opens a brace and is legal in both
|
||||
/// dialects.
|
||||
/// </summary>
|
||||
internal static bool IsDefaultBlockUniformDeclaration(string trimmedLine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trimmedLine);
|
||||
if (!trimmedLine.StartsWith("uniform ", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
// A block declaration is `uniform Name {` — possibly with the brace on
|
||||
// the next line, in which case there is no semicolon here either.
|
||||
int comment = trimmedLine.IndexOf("//", StringComparison.Ordinal);
|
||||
string code = comment >= 0 ? trimmedLine[..comment] : trimmedLine;
|
||||
return !code.Contains('{') && code.TrimEnd().EndsWith(';');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue