acdream/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.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

235 lines
11 KiB
C#

using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu;
/// <summary>
/// Campaign V slice V0 — the pinned RHI contract.
///
/// These are not behaviour tests; they are the tripwires that stop the contract
/// drifting out from under the shaders and the two backends. Every constant
/// asserted here also appears in a GLSL source file or in a backend's binding
/// setup, so a change that lands in only one place fails here rather than as a
/// corrupted frame.
/// </summary>
public sealed class GpuContractTests
{
[Fact]
public void PushConstantBlockMatchesThePinnedLayout()
{
Assert.Equal(GpuBindingModel.PushConstantBytes, Unsafe.SizeOf<GpuPushConstants>());
Assert.True(GpuBindingModel.PushConstantBytes <= GpuBindingModel.MaxPushConstantBytes);
Assert.Equal(0, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ViewProjection)));
Assert.Equal(64, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.DrawIdOffset)));
Assert.Equal(68, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightingMode)));
Assert.Equal(72, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.RenderPass)));
Assert.Equal(76, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightDebug)));
Assert.Equal(80, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexA)));
Assert.Equal(84, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexB)));
Assert.Equal(88, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamA)));
Assert.Equal(92, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamB)));
}
[Fact]
public void StorageBindingsMatchTheShaderSources()
{
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order;
// binding 9 is the GL-only texture handle table added by slice V2.
Assert.Equal(0u, GpuBindingModel.StorageInstances);
Assert.Equal(1u, GpuBindingModel.StorageBatches);
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(3u, GpuBindingModel.StorageClipSlots);
Assert.Equal(4u, GpuBindingModel.StorageGlobalLights);
Assert.Equal(5u, GpuBindingModel.StorageInstanceLightSets);
Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor);
Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha);
Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting);
Assert.Equal(9u, GpuBindingModel.StorageTextureTable);
Assert.Equal(10u, GpuBindingModel.StorageBindingCount);
}
[Fact]
public void UniformAndTextureTableLiveInSeparateSets()
{
// The SceneLighting UBO keeps binding=1 even though the BatchBuffer SSBO
// also uses binding=1. GL tolerates that because its SSBO and UBO binding
// tables are separate; Vulkan does not, so the set index disambiguates.
Assert.Equal(GpuBindingModel.StorageBatches, GpuBindingModel.UniformSceneLighting);
Assert.NotEqual(0u, GpuBindingModel.UniformSet);
Assert.NotEqual(GpuBindingModel.UniformSet, GpuBindingModel.TextureTableSet);
}
[Fact]
public void ClipRegionStrideMatchesTheUploadedLayout()
{
// ClipFrame lays these bytes out on the CPU; ClipFrameLayoutTests pins the
// producer side, this pins the contract side. 16 B header + 8 x vec4.
Assert.Equal(8, GpuBindingModel.ClipPlanesPerSlot);
Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes);
}
[Fact]
public void BlendModesCoverEveryRetailTranslucencyKind()
{
// WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
// surface's TranslucencyKind. Retail has three, and the V0 contract shipped
// with only two — slice V4c found the gap. Mapping InvAlpha onto
// StraightAlpha would silently change how every inverse-alpha surface
// composites, so the contract has to carry all three.
Assert.Equal(4, Enum.GetValues<GpuBlendMode>().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
}
[Fact]
public void IntegerVertexAttributesAreRepresentableDistinctlyFromNormalizedOnes()
{
// terrain_modern.vert declares locations 2-5 as uvec4 and the CPU feeds
// them with glVertexAttribIPointer. GL leaves an integer shader input
// undefined if it arrives through the float path, and Vulkan needs the
// format named as R8G8B8A8_UINT rather than _UNORM — so the two cannot be
// the same contract value. Those packed bytes carry terrain-type, road and
// split-direction codes, so normalising them would produce garbage, not an
// approximation.
Assert.NotEqual(GpuVertexFormat.UByte4Normalized, GpuVertexFormat.UByte4UInt);
Assert.Contains(GpuVertexFormat.UByte4UInt, Enum.GetValues<GpuVertexFormat>());
}
[Fact]
public void APipelineNamesTheColorFormatItRendersInto()
{
// Vulkan's dynamic rendering bakes the colour-attachment format into the
// pipeline, so a pipeline that cannot name it either forces one format on
// every pass or is undefined against the ones it does not match. Slice V6c
// hit that wall and hard-coded the swapchain format for every pipeline,
// recording the gap in VulkanTextureFormatMapping rather than hiding it.
var description = new GpuPipelineDescription
{
Name = "contract-default",
Shaders = new GpuShaderSet("ui_text"),
VertexLayout = GpuVertexLayout.None,
};
// The default has to be the render-target format, because that is what
// the Vulkan backend already maps to the B8G8R8A8_UNORM swapchain — so
// every pipeline written before this field existed keeps its behaviour.
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
// And it has to be settable, or naming it would be decoration.
GpuPipelineDescription single = description with { ColorFormat = GpuTextureFormat.R8Unorm };
Assert.Equal(GpuTextureFormat.R8Unorm, single.ColorFormat);
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
}
[Fact]
public void UniformBindingsDoNotCollide()
{
uint[] uniformBindings =
[
GpuBindingModel.UniformSceneLighting,
GpuBindingModel.UniformTerrainTiling,
];
Assert.Equal(uniformBindings.Length, uniformBindings.Distinct().Count());
// Binding 2 is the terrain clip block; the tiling array must not take it.
Assert.NotEqual(2u, GpuBindingModel.UniformTerrainTiling);
}
[Fact]
public void UnassignedTextureSlotIsNeverAValidIndex()
{
Assert.False(GpuTextureSlot.Unassigned.IsAssigned);
Assert.True(new GpuTextureSlot(0).IsAssigned);
Assert.Equal("slot#unassigned", GpuTextureSlot.Unassigned.ToString());
Assert.Equal("slot#7", new GpuTextureSlot(7).ToString());
}
[Fact]
public void WorldMeshVertexLayoutMatchesTheMeshShaderInputs()
{
GpuVertexLayout layout = GpuVertexLayout.WorldMesh;
Assert.Equal(32u, layout.StrideBytes);
Assert.Equal(3, layout.Attributes.Length);
Assert.Equal(new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), layout.Attributes[0]);
Assert.Equal(new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), layout.Attributes[1]);
Assert.Equal(new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24), layout.Attributes[2]);
}
[Fact]
public void MultisampledBackbufferPassResolvesWhileDepthIsDiscarded()
{
GpuPassDescription multisampled = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 4);
Assert.Equal(GpuStoreOp.Resolve, multisampled.Color.Store);
Assert.Null(multisampled.Color.Target);
Assert.Equal(GpuStoreOp.DontCare, multisampled.Depth!.Value.Store);
Assert.Equal(1f, multisampled.Depth!.Value.ClearDepth);
GpuPassDescription single = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1);
Assert.Equal(GpuStoreOp.Store, single.Color.Store);
}
[Fact]
public void CapabilityRecordAcceptsADeviceThatMeetsEveryRequirement()
{
GpuCapabilityRecord record = SupportedRecord();
Assert.Empty(record.SupportFailures);
Assert.True(record.IsSupported);
}
[Fact]
public void CapabilityRecordNamesEveryMissingRequirement()
{
GpuCapabilityRecord record = SupportedRecord() with
{
SupportsMultiDrawIndirect = false,
SupportsDrawParameters = false,
SupportsTextureCompressionBc = false,
MaxTextureTableSlots = 16,
MaxStorageBufferBindings = 4,
MaxPushConstantBytes = 32,
MaxClipDistances = 0,
};
Assert.False(record.IsSupported);
Assert.Equal(7, record.SupportFailures.Count);
Assert.Contains(record.SupportFailures, failure => failure.Contains("Multi-draw-indirect", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("gl_DrawID", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("BC (DXT)", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("clip distances", StringComparison.Ordinal));
}
[Fact]
public void TimestampSupportIsOptional()
{
// Losing GPU timing degrades profiling; it must never refuse to start.
GpuCapabilityRecord record = SupportedRecord() with { SupportsTimestampQueries = false };
Assert.True(record.IsSupported);
}
private static GpuCapabilityRecord SupportedRecord() => new()
{
Backend = GpuBackendKind.Vulkan,
DeviceName = "test-adapter",
DriverInfo = "test-driver",
ApiVersion = "Vulkan 1.3.0",
MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity,
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
MinStorageBufferOffsetAlignment = 64,
MinUniformBufferOffsetAlignment = 256,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxSampleCount = 8,
SupportsMultiDrawIndirect = true,
SupportsDrawParameters = true,
SupportsTextureCompressionBc = true,
SupportsTimestampQueries = true,
SupportsPersistentlyMappedRings = true,
};
}