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:
Erik 2026-07-28 08:17:24 +02:00
parent 9eae496301
commit 234fe91d3b
24 changed files with 3937 additions and 319 deletions

View file

@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c, plan §4.6: the committed SPIR-V must match the GLSL it
/// was compiled from.
///
/// <para>Plan §4.6 rules out runtime shader compilation — CI has no Vulkan SDK,
/// and a native shaderc dependency plus a startup cost would be paid for shaders
/// that never change at runtime — so the <c>.spv</c> artifacts are committed.
/// The obvious hazard follows immediately: someone edits a shader, the GL
/// backend picks it up because it compiles GLSL at startup, and the Vulkan
/// backend silently keeps rendering the old one. This test is what turns that
/// into a red build.</para>
///
/// <para>It also pins which production shaders are Vulkan-expressible TODAY. Nine
/// of the ten pairs are not, and each failure is a specific source-level fact
/// belonging to a renderer-port slice that has not landed — not a toolchain gap.
/// Recording them here means the next slice inherits an inventory rather than a
/// rediscovery.</para>
/// </summary>
public sealed class VulkanShaderManifestTests
{
private sealed record StageEntry(string Stage, string SourceSha256, bool Compiled, string? Message);
private sealed record ShaderEntry(string Name, bool VulkanReady, IReadOnlyList<StageEntry> Stages);
private sealed record Manifest(string Note, IReadOnlyList<ShaderEntry> Shaders);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
directory = directory.Parent;
return directory?.FullName
?? throw new InvalidOperationException("Could not locate the repository root from the test binary.");
}
private static string ShadersDirectory() =>
Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders");
private static string SpirvDirectory() => Path.Combine(ShadersDirectory(), "spv");
private static Manifest ReadManifest()
{
string path = Path.Combine(SpirvDirectory(), "shaders.manifest.json");
Assert.True(File.Exists(path), $"The shader manifest is missing at {path}. Run tools/compile-shaders.ps1.");
return JsonSerializer.Deserialize<Manifest>(File.ReadAllText(path), JsonOptions)
?? throw new InvalidOperationException("The shader manifest could not be parsed.");
}
private static string Sha256OfSource(string path)
{
// Line endings are normalised before hashing so a checkout with a
// different core.autocrlf setting does not report every shader stale.
string text = File.ReadAllText(path).Replace("\r\n", "\n");
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
}
[Fact]
public void EveryGlslPairIsRecordedInTheManifest()
{
Manifest manifest = ReadManifest();
string[] pairs = Directory
.EnumerateFiles(ShadersDirectory(), "*.vert")
.Select(Path.GetFileNameWithoutExtension)
.Where(name => name is not null && File.Exists(Path.Combine(ShadersDirectory(), $"{name}.frag")))
.Select(name => name!)
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
Assert.Equal(pairs, manifest.Shaders.Select(shader => shader.Name).OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void CommittedSpirvIsNotStaleAgainstItsGlslSource()
{
Manifest manifest = ReadManifest();
var stale = new List<string>();
foreach (ShaderEntry shader in manifest.Shaders)
{
foreach (StageEntry stage in shader.Stages)
{
string source = Path.Combine(ShadersDirectory(), $"{shader.Name}.{stage.Stage}");
if (!File.Exists(source))
{
stale.Add($"{shader.Name}.{stage.Stage}: the GLSL source no longer exists");
continue;
}
string actual = Sha256OfSource(source);
if (!string.Equals(actual, stage.SourceSha256, StringComparison.Ordinal))
stale.Add($"{shader.Name}.{stage.Stage}: source changed since the .spv was built");
}
}
Assert.True(
stale.Count == 0,
"Committed SPIR-V is out of date. Run tools/compile-shaders.ps1 and commit the result.\n "
+ string.Join("\n ", stale));
}
[Fact]
public void EveryShaderTheManifestCallsReadyHasBothSpirvArtifacts()
{
Manifest manifest = ReadManifest();
foreach (ShaderEntry shader in manifest.Shaders.Where(entry => entry.VulkanReady))
{
foreach (string stage in (string[])["vert", "frag"])
{
string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv");
Assert.True(File.Exists(path), $"{shader.Name} is marked Vulkan-ready but {path} is missing.");
long length = new FileInfo(path).Length;
Assert.True(length > 0 && length % 4 == 0, $"{path} is not a whole number of SPIR-V words.");
}
}
}
[Fact]
public void ShadersTheManifestCallsUnreadyHaveNoStaleSpirvLeftBehind()
{
Manifest manifest = ReadManifest();
foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady))
{
foreach (string stage in (string[])["vert", "frag"])
{
string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv");
// A leftover .spv from an earlier attempt would be loaded
// happily by the device and would be a shader nobody can account
// for.
Assert.False(File.Exists(path), $"{shader.Name} is not Vulkan-ready but {path} exists.");
}
}
}
[Fact]
public void EveryUnreadyShaderRecordsWhyItCannotBeCompiledYet()
{
Manifest manifest = ReadManifest();
foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady))
{
Assert.Contains(shader.Stages, stage => !stage.Compiled && !string.IsNullOrWhiteSpace(stage.Message));
}
}
[Fact]
public void TheRhiVerificationShaderIsCompiled()
{
Manifest manifest = ReadManifest();
ShaderEntry probe = Assert.Single(
manifest.Shaders,
shader => string.Equals(shader.Name, "vk_probe", StringComparison.Ordinal));
// Plan §4.11 wants the capability probe to build a real pipeline from
// committed .spv; slice V5 deferred that to V6c because no toolchain
// existed. If this pair ever stops compiling, the Vulkan backend has no
// pipeline it can build at all.
Assert.True(probe.VulkanReady, "vk_probe must compile — the whole Vulkan backend draws with it.");
}
}

View file

@ -0,0 +1,191 @@
using System;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c — the coordinate reconciliation (plan §3.3, §4.7,
/// §4.10).
///
/// <para>Renderers speak GL: viewport origin bottom-left, front faces
/// counter-clockwise. The Vulkan backend renders with a NEGATIVE viewport
/// height, which mirrors framebuffer space vertically and therefore also
/// reverses winding, so the front face is inverted to compensate. The two flips
/// are exact inverses and must always travel together — which is why they live
/// in one file, and why these tests assert them together.</para>
///
/// <para>The scissor is the trap. It does NOT flip with the viewport:
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
/// while <c>NdcScissorRect.ToPixels</c> emits GL bottom-left rectangles. The V3
/// audit flagged this explicitly as a V6 acceptance item, and getting it wrong
/// shows up as a doorway aperture clipped from the wrong edge — which only a
/// scene containing one would reveal.</para>
/// </summary>
public sealed class VulkanViewportMappingTests
{
[Fact]
public void FullViewportBecomesANegativeHeightRectangleAnchoredAtTheBottom()
{
Viewport viewport = VulkanViewportMapping.ToVulkan(0, 0, 1280, 720, attachmentHeight: 720);
Assert.Equal(0f, viewport.X);
// Y is the BOTTOM edge in Vulkan's top-left space, and the height runs
// upward from it. Together they mirror clip space.
Assert.Equal(720f, viewport.Y);
Assert.Equal(1280f, viewport.Width);
Assert.Equal(-720f, viewport.Height);
Assert.Equal(0f, viewport.MinDepth);
Assert.Equal(1f, viewport.MaxDepth);
}
[Fact]
public void AnOffsetViewportKeepsItsGlBottomLeftMeaning()
{
// A 100x50 viewport whose bottom edge sits 30 px above the bottom of a
// 720 px attachment.
Viewport viewport = VulkanViewportMapping.ToVulkan(10, 30, 100, 50, attachmentHeight: 720);
Assert.Equal(10f, viewport.X);
Assert.Equal(690f, viewport.Y);
Assert.Equal(-50f, viewport.Height);
}
[Fact]
public void ScissorFlipsAgainstTheAttachmentBecauseTheViewportSignDoesNotDoItForUs()
{
// GL rectangle: 100 px wide, 50 px tall, bottom edge 30 px up.
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(10, 30, 100, 50, attachmentHeight: 720);
Assert.Equal(10, scissor.Offset.X);
// Top edge measured from the top: 720 - (30 + 50).
Assert.Equal(640, scissor.Offset.Y);
Assert.Equal(100u, scissor.Extent.Width);
Assert.Equal(50u, scissor.Extent.Height);
}
[Fact]
public void AFullAttachmentScissorIsUnchangedByTheFlip()
{
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 0, 1280, 720, attachmentHeight: 720);
Assert.Equal(0, scissor.Offset.X);
Assert.Equal(0, scissor.Offset.Y);
Assert.Equal(1280u, scissor.Extent.Width);
Assert.Equal(720u, scissor.Extent.Height);
}
[Fact]
public void AScissorStraddlingTheTopEdgeIsClampedRatherThanRejected()
{
// Bottom edge 700 px up in a 720 px attachment, 50 px tall: 30 px of it
// is off the top. GL silently clips this; a driver error here would turn
// a harmless off-screen aperture into a crash.
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 700, 100, 50, attachmentHeight: 720);
Assert.Equal(0, scissor.Offset.Y);
Assert.Equal(20u, scissor.Extent.Height);
}
[Fact]
public void FrontFaceIsInvertedBecauseTheViewportMirrorsFramebufferSpace()
{
Assert.Equal(FrontFace.Clockwise, VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise));
Assert.Equal(FrontFace.CounterClockwise, VulkanViewportMapping.ToVulkan(GpuFrontFace.Clockwise));
}
[Fact]
public void TheFlipAndTheWindingInversionAreExactInverses()
{
// Mirroring twice is identity, and inverting the winding twice is too.
// If a later change ever flipped one without the other, this is the
// shape of the assertion that catches it.
Viewport once = VulkanViewportMapping.ToVulkan(0, 0, 640, 480, attachmentHeight: 480);
Assert.Equal(-480f, once.Height);
Assert.Equal(480f, once.Y);
FrontFace inverted = VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise);
Assert.NotEqual(FrontFace.CounterClockwise, inverted);
}
[Fact]
public void CullModesMapStraightAcross()
{
Assert.Equal(CullModeFlags.None, VulkanViewportMapping.ToVulkan(GpuCullMode.None));
Assert.Equal(CullModeFlags.BackBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Back));
Assert.Equal(CullModeFlags.FrontBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Front));
}
[Fact]
public void AllThreeRetailBlendModesAreRepresentable()
{
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha));
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.One),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive));
// Retail's third mode, found at slice V4c. Mapping it onto straight
// alpha would have silently changed how every inverse-alpha surface
// composites.
Assert.Equal(
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
}
[Fact]
public void IntegerVertexAttributesTakeAUintFormatNotANormalisedOne()
{
Assert.Equal(Format.R8G8B8A8Unorm, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4Normalized));
// terrain_modern.vert reads locations 2-5 as uvec4; those packed bytes
// carry terrain-type, road and split-direction codes, so normalising
// them would not be an approximation - it would be garbage.
Assert.Equal(Format.R8G8B8A8Uint, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4UInt));
}
[Fact]
public void ResolveIsExpressedByAResolveTargetRatherThanAStoreOp()
{
// There is no VK_ATTACHMENT_STORE_OP_RESOLVE; a resolving attachment
// discards its multisampled contents and names a resolve image instead.
Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.Resolve));
Assert.Equal(AttachmentStoreOp.Store, VulkanViewportMapping.ToVulkan(GpuStoreOp.Store));
Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.DontCare));
}
[Fact]
public void PipelineCacheHeaderValidationRejectsAnotherDevicesBlob()
{
byte[] uuid = new byte[16];
for (int i = 0; i < 16; i++)
uuid[i] = (byte)(i + 1);
byte[] blob = new byte[64];
BitConverter.GetBytes(32u).CopyTo(blob, 0);
BitConverter.GetBytes(1u).CopyTo(blob, 4);
BitConverter.GetBytes(0x1002u).CopyTo(blob, 8);
BitConverter.GetBytes(0x7550u).CopyTo(blob, 12);
uuid.CopyTo(blob, 16);
Assert.NotNull(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid));
// A driver update changes the cache UUID, and feeding the old blob back
// is exactly the case the header exists to catch.
uuid[0] = 0xFF;
Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid));
}
[Fact]
public void PipelineCacheHeaderValidationRejectsTruncatedAndForeignBlobs()
{
byte[] uuid = new byte[16];
Assert.Null(VulkanPipelineCache.ValidateHeader(null, 1, 1, uuid));
Assert.Null(VulkanPipelineCache.ValidateHeader(new byte[8], 1, 1, uuid));
byte[] blob = new byte[32];
BitConverter.GetBytes(32u).CopyTo(blob, 0);
BitConverter.GetBytes(1u).CopyTo(blob, 4);
BitConverter.GetBytes(0x8086u).CopyTo(blob, 8);
Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0, uuid));
}
}