acdream/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
Erik 871c406b99 feat(render): let a pipeline name the colour format it renders into
Campaign V slice V6d, commit 1 of 3. The third contract amendment of the campaign, in the same shape as GpuBlendMode.InverseAlpha (V4c) and GpuVertexFormat.UByte4UInt (V4d): a slice met a wall the pinned contract could not express, and the fix is a reviewed field rather than a backend working around it.

Vulkan's dynamic rendering bakes the colour-attachment format into the pipeline. VkPipelineRenderingCreateInfo has to name it at creation, and a pipeline whose declared format disagrees with the attachment it is used with is undefined. GpuPipelineDescription named SampleCount and nothing else about the target, so slice V6c had no way to ask the question and hard-coded VulkanTextureFormatMapping.CanonicalColorAttachmentFormat for every pipeline it built. It recorded that as a real expressiveness gap rather than hiding it, and named this commit as the honest fix.

GpuPipelineDescription.ColorFormat defaults to Rgba8UnormRenderTarget, which the Vulkan backend already maps to the swapchain's B8G8R8A8_UNORM, so every pipeline written before the field existed keeps exactly the format it was getting. GL ignores the field entirely: a GL framebuffer carries its own attachment formats and a program binds to whatever is attached, so there is nothing for the GL backend to declare. The substitution that makes an offscreen Rgba8UnormRenderTarget resolve to the swapchain's byte order stays — it is what lets a backbuffer pipeline and an offscreen pipeline share one description, and it is invisible above the API because an image is sampled through its format's component mapping.

The contract test asserts both halves that matter: the default is the render-target format (so nothing moves), and the field is really settable (so naming it is not decoration).

App tests 4,057 passed / 3 skipped, up one from the 4,056 baseline. Offline pixel gate against 234fe91d: differing fraction 2.84e-05, 16 pixels of 563,200 compared, inside the documented 15-23 pixel same-commit noise band and about 35x under the 0.001 threshold.

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

154 lines
7.4 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 vertex attribute, matching a <c>layout(location = N) in</c> declaration.</summary>
internal readonly record struct GpuVertexAttribute(
uint Location,
GpuVertexFormat Format,
uint OffsetBytes);
/// <summary>Interleaved vertex layout for a single bound vertex buffer.</summary>
internal sealed record GpuVertexLayout(uint StrideBytes, ImmutableArray<GpuVertexAttribute> Attributes)
{
/// <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; } = new(
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(0, []);
}
/// <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>
/// 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>
/// 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;
}