acdream/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
Erik eced67d038 feat(render): Campaign V slice V6l commit 2 - the portal mask draws on Vulkan
Contract amendment 2 of three, and V4g's remaining half behind it. Plan section
5.5.16 defect 2: PortalDepthMaskRenderer's two-pass punch (#117) is built on
glStencilFunc/glStencilOp/glStencilMask, GpuPipelineDescription carried no
stencil state at all, and nothing else can express it - so the renderer stayed
raw GL, invisible to the Vulkan arm, and V4g's "stencil/depth-mask pipelines"
row could not be written.

The amendment splits the way core Vulkan 1.3 splits. The ENABLE and the
attachment intent are baked: GpuPipelineDescription.StencilTest, false by
default so no pipeline in the tree changed. The per-draw compare, three outcome
ops, reference and both masks are a GpuStencilState that the pipeline carries as
a DEFAULT and IGpuPassEncoder.SetStencil overrides - exactly the split cull
mode, front face and depth write already have, and exactly what
VK_DYNAMIC_STATE_STENCIL_OP/_COMPARE_MASK/_WRITE_MASK/_REFERENCE make dynamic.
The four stencil dynamic states are declared ONLY by a pipeline that tests
stencil: declaring a dynamic state obliges every draw with the pipeline to have
set it, so adding them unconditionally would make every existing pipeline depend
on a call none of them make. GpuStencilOp carries three values because the punch
uses three - Replace marks, Equal gates, Zero self-cleans - and a fourth would
be a facility with no consumer.

The arm. Three pipelines, not one, because depth COMPARE is not dynamic in the
contract and the punch's two passes differ in it: mark tests LEQUAL and writes
no depth, punch tests ALWAYS and writes, seal is ALWAYS + write with no stencil.
All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan"
means. The fan is expanded to a triangle LIST on the CPU - the contract has no
fan topology and Vulkan's is not portable - which is exact: triangle i is
(v0, v[i+1], v[i+2]), the same triangles in the same order.

portal_depth.{vert,frag} is a new committed shader pair, and this is the ONE
renderer in the campaign whose two arms do not share a source. Its clip planes
have to travel in the TerrainClip uniform block at binding 2, which is already
precisely this shape and already read by terrain_modern.vert and sky.vert - but
on GL that binding is held globally by ClipFrame for terrain, so a portal draw
that rebound it would leave every later terrain draw in the frame reading the
wrong region. The GL arm therefore keeps its inline program.
PortalDepthShaderParityTests is the tripwire: retail's far-Z constant
(0.99999988, from DrawPortalPolyInternal 0x0059bc90), #129's capped mark-bias
expression and the eight-half-plane loop are asserted to appear in both. Both
are deleted at V11. 9/10 shader pairs now compile to SPIR-V.

Two GL-side gaps closed while the state was being extended, both of section 7.1
rule 1's class rather than new work. GlAmbientCapabilityState now saves and
restores the stencil test, function, ops and both masks - the portal punch draws
mid-frame among renderers that are still raw GL and assume the test is off - and
the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed
and whose absence would have blacked out every raw-GL renderer after such a
pass.

PortalTunnelPresentation was re-read and confirmed as V6k left it: it clears
depth and draws into the active viewport, binds no framebuffer of its own, and
needs no port for section 5.4's sake. It remains unported on the Vulkan arm -
the composition uses NullLocalPlayerTeleportPresentation there - which is an
absence on the V7 list, not a defect.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in two subsequent runs, solution-wide or alone - the documented
rerun-singly flake class). Strict GL offline pixel gate against 08ffe141:
2.31e-05, 13 differing pixels of 563,200, inside the documented 9-31 band. GL
connected -Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client
capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero validation errors, zero warnings, a captured world frame.

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

329 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Immutable;
namespace AcDream.App.Rendering.Gpu;
/// <summary>Vertex attribute component layout. Only the formats acdream's meshes actually use.</summary>
internal enum GpuVertexFormat
{
Float1,
Float2,
Float3,
Float4,
/// <summary>Four unsigned bytes scaled to [0,1] floats — a shader <c>vec4</c> input.</summary>
UByte4Normalized,
/// <summary>
/// Four unsigned bytes delivered as INTEGERS — a shader <c>uvec4</c> input.
///
/// Distinct from <see cref="UByte4Normalized"/> in kind, not just in scaling:
/// GL requires <c>glVertexAttribIPointer</c> for an integer shader input and
/// leaves the value undefined if it arrives through the float path, and Vulkan
/// needs the format named as <c>R8G8B8A8_UINT</c> rather than <c>_UNORM</c>.
///
/// Added at slice V4d, which found `terrain_modern.vert` declares locations 25
/// as <c>uvec4</c> and feeds them with <c>glVertexAttribIPointer</c>. Those
/// packed bytes carry terrain-type, road and split-direction codes that drive
/// every blend decision, so normalising them would not be an approximation —
/// it would be garbage.
/// </summary>
UByte4UInt,
/// <summary>
/// One unsigned 32-bit integer — a shader <c>uint</c> input.
///
/// <para>Added at slice V6l with the instanced-vertex-input amendment, and
/// necessary to it: <c>particle.vert</c> declares
/// <c>layout(location = 6) in uint aTextureIndex</c>, the per-instance
/// texture-table slot, and the amendment's premise is that no shader is
/// edited. Same kind-distinction as <see cref="UByte4UInt"/> — GL needs
/// <c>glVertexAttribIPointer</c> and Vulkan needs <c>R32_UINT</c>; the float
/// path would deliver the value's bits reinterpreted rather than a scaled
/// approximation of it.</para>
/// </summary>
UInt1,
}
/// <summary>
/// How often a vertex binding advances.
///
/// <para>Added at slice V6l. Both particle pipelines draw with PER-INSTANCE
/// vertex attributes — <c>particle</c> at locations 26 (centre, two sheet axes,
/// colour, texture slot) and <c>particle_mesh</c> at 37 (a <c>mat4</c> model and
/// a colour) — and the V0 contract could express instanced DRAWING but not
/// instanced vertex INPUT, which blocked V4e (plan §5.5.16). Both backends carry
/// this natively and at no cost: <c>VK_VERTEX_INPUT_RATE_INSTANCE</c> and
/// <c>glVertexAttribDivisor</c>.</para>
/// </summary>
internal enum GpuVertexInputRate
{
/// <summary>The binding advances once per vertex — the default for every layout written before V6l.</summary>
Vertex,
/// <summary>The binding advances once per instance (GL divisor 1).</summary>
Instance,
}
/// <summary>
/// One bound vertex buffer's shape: which binding index it occupies, how many
/// bytes one element occupies, and how often it advances.
///
/// <para>Slice V6l. Before it, a layout had exactly one stride and one implicit
/// binding 0 at vertex rate; that is now the <see cref="GpuVertexLayout.Interleaved"/>
/// case rather than the only case.</para>
/// </summary>
internal readonly record struct GpuVertexBinding(
uint Binding,
uint StrideBytes,
GpuVertexInputRate InputRate = GpuVertexInputRate.Vertex);
/// <summary>
/// One vertex attribute, matching a <c>layout(location = N) in</c> declaration.
/// <paramref name="Binding"/> names which <see cref="GpuVertexBinding"/> supplies
/// it and defaults to 0, so every layout written before slice V6l keeps its
/// meaning unchanged.
/// </summary>
internal readonly record struct GpuVertexAttribute(
uint Location,
GpuVertexFormat Format,
uint OffsetBytes,
uint Binding = 0);
/// <summary>
/// Vertex input layout: the bound buffers and the attributes they feed.
///
/// <para>Slice V6l grew this from one stride to a list of bindings. The overwhelmingly
/// common case is still one interleaved vertex-rate buffer, which
/// <see cref="Interleaved"/> spells in one line and which every layout in the
/// tree before V6l uses.</para>
/// </summary>
internal sealed record GpuVertexLayout(
ImmutableArray<GpuVertexBinding> Bindings,
ImmutableArray<GpuVertexAttribute> Attributes)
{
/// <summary>
/// One interleaved vertex-rate buffer at binding 0 — the shape of every
/// layout the campaign wrote before slice V6l.
/// </summary>
public static GpuVertexLayout Interleaved(
uint strideBytes,
ImmutableArray<GpuVertexAttribute> attributes) =>
new(
[new GpuVertexBinding(0, strideBytes, GpuVertexInputRate.Vertex)],
attributes);
/// <summary>
/// Stride of binding 0. Kept because it is what an interleaved layout means
/// and what every single-binding consumer asks for; multi-binding consumers
/// use <see cref="StrideOf"/>.
/// </summary>
public uint StrideBytes =>
Bindings.IsDefaultOrEmpty ? 0u : Bindings[0].StrideBytes;
/// <summary>Stride of <paramref name="binding"/>, or a composition error if it is not declared.</summary>
public uint StrideOf(uint binding)
{
foreach (GpuVertexBinding candidate in Bindings)
{
if (candidate.Binding == binding)
return candidate.StrideBytes;
}
throw new ArgumentOutOfRangeException(
nameof(binding),
binding,
"The vertex layout declares no such binding.");
}
/// <summary>How often <paramref name="binding"/> advances.</summary>
public GpuVertexInputRate InputRateOf(uint binding)
{
foreach (GpuVertexBinding candidate in Bindings)
{
if (candidate.Binding == binding)
return candidate.InputRate;
}
throw new ArgumentOutOfRangeException(
nameof(binding),
binding,
"The vertex layout declares no such binding.");
}
/// <summary>
/// The world mesh vertex shared by <c>mesh_modern</c>, EnvCells, and terrain:
/// position, normal, texcoord — 32 bytes, matching the format
/// <c>ObjectMeshManager</c> packs into <c>GlobalMeshBuffer</c>.
/// </summary>
public static GpuVertexLayout WorldMesh { get; } = Interleaved(
strideBytes: 32,
[
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0),
new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12),
new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24),
]);
/// <summary>Empty layout for pipelines whose vertices come entirely from storage buffers.</summary>
public static GpuVertexLayout None { get; } = new([], []);
}
/// <summary>
/// Names one GLSL shader pair. The backend resolves it: the GL backend loads
/// <c>Rendering/Shaders/{Name}.vert</c> and <c>.frag</c> and compiles at startup;
/// the Vulkan backend loads the committed <c>Rendering/Shaders/spv/{Name}.vert.spv</c>
/// and <c>.frag.spv</c> produced by <c>tools/compile-shaders.ps1</c>. One source
/// of truth (the GLSL), two consumption paths.
/// </summary>
internal readonly record struct GpuShaderSet(string Name);
/// <summary>Depth-buffer behaviour baked into a pipeline.</summary>
/// <param name="Test">Whether depth testing is enabled at all.</param>
/// <param name="Write">Default depth-write state; overridable per draw via dynamic state.</param>
/// <param name="Compare">Comparison function when testing is enabled.</param>
internal readonly record struct GpuDepthState(bool Test, bool Write, GpuCompareOp Compare)
{
/// <summary>Standard opaque geometry: test and write, nearer wins.</summary>
public static GpuDepthState OpaqueDefault { get; } = new(Test: true, Write: true, GpuCompareOp.LessOrEqual);
/// <summary>Translucent geometry: test against existing depth but do not occlude later draws.</summary>
public static GpuDepthState TranslucentDefault { get; } = new(Test: true, Write: false, GpuCompareOp.LessOrEqual);
/// <summary>Sky and 2-D overlays: depth is irrelevant.</summary>
public static GpuDepthState Disabled { get; } = new(Test: false, Write: false, GpuCompareOp.Always);
}
/// <summary>
/// The per-draw half of stencil state: the comparison, what happens at each of
/// its three outcomes, and the reference and masks it uses.
///
/// <para>Added at slice V6l. Core Vulkan 1.3 makes ALL of these dynamic
/// (<c>VK_DYNAMIC_STATE_STENCIL_OP</c>, <c>_COMPARE_MASK</c>, <c>_WRITE_MASK</c>,
/// <c>_REFERENCE</c>), and #117's portal punch changes every one of them between
/// its mark pass and its punch pass, so they live here as a pipeline DEFAULT and
/// on <see cref="IGpuPassEncoder.SetStencil"/> as the per-draw override —
/// exactly the split cull mode, front face and depth write already have.
/// Whether the pipeline uses the stencil aspect at all is
/// <see cref="GpuPipelineDescription.StencilTest"/>, because that is also the
/// attachment intent.</para>
///
/// <para>The front and back faces always carry the same state. Retail's portal
/// fans are drawn with culling off and face either way, so a two-sided
/// distinction would be a facility with no consumer.</para>
/// </summary>
internal readonly record struct GpuStencilState(
GpuCompareOp Compare,
GpuStencilOp Fail,
GpuStencilOp DepthFail,
GpuStencilOp Pass,
uint Reference,
uint CompareMask,
uint WriteMask)
{
/// <summary>GL's and Vulkan's own defaults: always pass, never write.</summary>
public static GpuStencilState Default { get; } = new(
GpuCompareOp.Always,
GpuStencilOp.Keep,
GpuStencilOp.Keep,
GpuStencilOp.Keep,
Reference: 0,
CompareMask: 0xFF,
WriteMask: 0xFF);
}
/// <summary>
/// Everything a draw needs beyond its buffers: the shader pair and all fixed
/// state. Vulkan bakes this into one <c>VkPipeline</c> at startup, which is why
/// runtime shader compilation and driver state revalidation both disappear.
///
/// Equality is NOT part of this contract — pipelines are created explicitly and
/// held by their renderer. <see cref="Name"/> is the identity used by debug
/// tooling and by the backend's own pipeline cache key.
/// </summary>
internal sealed record GpuPipelineDescription
{
/// <summary>Stable identifier, e.g. <c>"mesh-opaque"</c>. Surfaced to RenderDoc and validation layers.</summary>
public required string Name { get; init; }
/// <summary>The GLSL pair this pipeline draws with.</summary>
public required GpuShaderSet Shaders { get; init; }
/// <summary>Vertex input layout; <see cref="GpuVertexLayout.None"/> for buffer-fed geometry.</summary>
public required GpuVertexLayout VertexLayout { get; init; }
public GpuPrimitiveTopology Topology { get; init; } = GpuPrimitiveTopology.TriangleList;
public GpuBlendMode Blend { get; init; } = GpuBlendMode.None;
public GpuDepthState Depth { get; init; } = GpuDepthState.OpaqueDefault;
/// <summary>Default cull mode; overridable per draw via <see cref="IGpuPassEncoder.SetCullMode"/>.</summary>
public GpuCullMode Cull { get; init; } = GpuCullMode.Back;
/// <summary>
/// Winding treated as front-facing, expressed in GL convention. The Vulkan
/// backend inverts it internally to compensate for its negative viewport
/// height; no renderer performs that flip itself.
/// </summary>
public GpuFrontFace FrontFace { get; init; } = GpuFrontFace.CounterClockwise;
/// <summary>
/// Alpha-to-coverage for foliage. Only meaningful when the pass is
/// multisampled; the backend ignores it at one sample.
/// </summary>
public bool AlphaToCoverage { get; init; }
/// <summary>Whether the pipeline writes colour at all. False for depth/stencil-only prepasses.</summary>
public bool ColorWrite { get; init; } = true;
/// <summary>
/// Whether this pipeline uses the stencil aspect at all.
///
/// <para>Added at slice V6l for #117's portal punch, which is the only
/// consumer in the tree and which nothing else could express: the V0 contract
/// carried no stencil state, so <c>PortalDepthMaskRenderer</c> stayed raw GL
/// and V4g's "stencil/depth-mask pipelines" row could not be written (plan
/// §5.5.16 defect 2).</para>
///
/// <para>This is the ENABLE and the attachment intent together, which is why
/// it is baked while everything in <see cref="Stencil"/> is dynamic. Vulkan
/// makes <c>stencilTestEnable</c> dynamic too, but a pipeline that declares
/// the stencil dynamic states obliges every draw with it to have set them,
/// so a pipeline that will never test stencil is better off saying so once.
/// </para>
/// </summary>
public bool StencilTest { get; init; }
/// <summary>
/// Default stencil compare/op/reference/mask, re-established by
/// <c>BindPipeline</c> and overridable per draw through
/// <see cref="IGpuPassEncoder.SetStencil"/>. Ignored entirely when
/// <see cref="StencilTest"/> is false.
/// </summary>
public GpuStencilState Stencil { get; init; } = GpuStencilState.Default;
/// <summary>
/// Format of the colour attachment this pipeline renders into.
///
/// Vulkan's dynamic rendering bakes the attachment format into the pipeline:
/// <c>VkPipelineRenderingCreateInfo</c> has to name it at creation, and a
/// pipeline whose declared format differs from the attachment it is used
/// with is undefined. GL has no equivalent — a framebuffer carries its own
/// attachment formats and a program is bound to whatever is attached — so
/// the GL backend ignores this field entirely.
///
/// The default is the offscreen render-target format, which the Vulkan
/// backend maps to the swapchain's <c>B8G8R8A8_UNORM</c>
/// (<c>VulkanTextureFormatMapping.CanonicalColorAttachmentFormat</c>) so
/// that backbuffer and offscreen pipelines really do agree. That mapping is
/// what made this field necessary and is why the default preserves it: slice
/// V6c had to hard-code one format for every pipeline because the contract
/// could not express the question, and recorded the gap rather than hiding
/// it. Naming the format here is the reviewed fix, in the same shape as
/// <see cref="GpuBlendMode.InverseAlpha"/> (V4c) and
/// <see cref="GpuVertexFormat.UByte4UInt"/> (V4d).
/// </summary>
public GpuTextureFormat ColorFormat { get; init; } = GpuTextureFormat.Rgba8UnormRenderTarget;
/// <summary>Sample count of the passes this pipeline is used in. Must match the pass.</summary>
public int SampleCount { get; init; } = 1;
}