acdream/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
Erik 7a0227c12e feat(render): Vulkan campaign V11 step 3 — drop the GL packages and shaders
Commit 2 deleted the GL rendering backend's implementations; this step
removes the package references and shader vocabulary they leave behind,
so nothing in the App project still spells Silk.NET.OpenGL.

Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from
AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its
Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are
used directly and extensively across the Wb texture/mesh pipeline,
independent of the deleted GL IUniformBuffer implementers the package
comment used to cite. The stale comment is corrected in place.

IMeshPipelineDevice.Gl is removed along with the GL? gl parameter
threaded through WbMeshAdapter's four constructors, WorldRenderComposition's
CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null
implementation — nothing read any of them once the legacy per-mesh
upload bodies were gone (confirmed by grep: the sole non-doc-comment hit
was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed
a real bug along the way: its teardown still pattern-matched the deleted
GL GpuFrameFlightController to decide whether to wait for submitted work,
which VulkanFrameFlightController replaced at slice V6a without this site
being updated — so the wait had been silently dead on every Vulkan run
since then. Retargeted to VulkanFrameFlightController, which carries the
same WaitForSubmittedWork().

The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that
WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for
upload validation is replaced by AcDream.Content's existing Silk.NET-free
UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake
tool GL-free); two new members (Rgb, Red, Float) extend that enum with
their GL ABI constants to cover the full vocabulary WorldTextureArray
needs, since MP1a's original set only covered what the extractor itself
emits. ObjectMeshManager's App-boundary cast
`(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct
pass-through now that both sides share the type.

GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of
the Vulkan texture table) is deleted and StorageBindingCount drops from
10 to 9; the descriptor-set-layout code that builds from that count
(VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just
allocates one fewer always-dummy-seeded, always-unused binding.

Several fully dead GL-only classes came along for the ride, confirmed by
zero construction sites: SilkFramebufferViewportTarget
(NullFramebufferViewportTarget is the sole production
IFramebufferViewportTarget), SilkRenderGlStateReader
(NullRenderGlStateReader.Instance is the sole IRenderGlStateReader),
RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole
IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a
pass load-op instead), and GpuFrameTimer plus FrameProfiler's
GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame
bracket (RecordGpuSample is the only GPU-timing path any backend uses
now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no
longer applies, since WbDrawDispatcher's own diagnostic GPU sampling
already moved to the device's Vulkan timer pool). GpuFrameFlightController
itself stays (never constructed with a real fence API in production, but
its retirement-ledger/serial-ring logic is backend-neutral and still
covered by its own unit tests) — only its GL-specific parts (the public
GL constructor overload, SilkGpuFenceApi) are deleted, since removing the
whole class would mean restructuring the frozen Slice-8 composition
shape's GpuFrameFlightController? threading, which is out of this
commit's scope. TextureParameters.cs and BufferUsageExtensions.cs
(zero callers each) are deleted outright.

common.glsl is deleted: nothing in the actual Vulkan .spv build reads
it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair
directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own
complete self-contained preamble per file; common.glsl's textual
concatenation was exclusively Shader.cs's GL-only mechanism, deleted at
Commit 2. The five shader files that named it in comments
(mesh_modern.vert, particle.vert, particle.frag, sky.frag,
terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs
instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the
mandatory modern path already made unreachable, with zero C# consumers
and no compiled .spv — are deleted too. Regenerated via
tools/compile-shaders.ps1: 9/9 remaining shader pairs compile
(previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests
doc comment's "nine of ten are not Vulkan-expressible" was already
stale before this commit).

Test fallout: dead-subject test methods/files are deleted rather than
patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs,
GpuResourceRetirementTransactionTests.cs's GL queue tests, one
WorldRenderDiagnosticsTests source-order test, one
RenderFrameResourceControllerTests clear-phase-order test); tests whose
subject moved or was renamed are updated in place rather than deleted
(GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests'
pinned seven-member surface now reads six, ParticleBindlessInstanceTests'
cross-dialect check now covers the one surviving dialect,
WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was
always the parameter that actually threw).

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors,
with the Silk.NET.OpenGL/.Extensions.ARB package references physically
removed from the csproj (not just unreferenced in code).
Tests: full-solution `dotnet test` green across every project.
Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:58:15 +02:00

374 lines
18 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 was the GL-only texture handle table added by slice V2;
// Campaign V slice V11 deleted it (StorageTextureTable) along with the
// rest of the raw-GL arm, so 9 is now one past the highest binding.
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.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 AVertexLayoutCanDeclareAPerInstanceBinding()
{
// Campaign V slice V6l. Both particle pipelines draw with per-instance
// VERTEX attributes, and the V0 contract could express instanced DRAWING
// (Draw/DrawIndexed both take firstInstance) but not instanced vertex
// INPUT — one stride, no divisor, one buffer at vertex rate. Plan
// §5.5.16 recorded that as what blocked V4e and named three ways out;
// this is option (i), the second binding with a per-instance rate, which
// both backends carry natively (VK_VERTEX_INPUT_RATE_INSTANCE,
// glVertexAttribDivisor) and which needs no shader edit.
var layout = new GpuVertexLayout(
[
new GpuVertexBinding(0, 16, GpuVertexInputRate.Vertex),
new GpuVertexBinding(1, 68, GpuVertexInputRate.Instance),
],
[
new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0),
new GpuVertexAttribute(2, GpuVertexFormat.Float4, 0, Binding: 1),
]);
Assert.Equal(16u, layout.StrideOf(0));
Assert.Equal(68u, layout.StrideOf(1));
Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0));
Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1));
// An attribute that names no declared binding is a composition error,
// not a silent bind against binding 0.
Assert.Throws<ArgumentOutOfRangeException>(() => layout.StrideOf(2));
}
[Fact]
public void ASingleBindingLayoutStillMeansOneInterleavedVertexRateBuffer()
{
// Every layout written before V6l has to keep its meaning exactly. The
// default attribute binding is 0 and the Interleaved factory's one
// binding is vertex-rate, so no existing declaration changed behaviour.
Assert.Equal(0u, new GpuVertexAttribute(3, GpuVertexFormat.Float3, 12).Binding);
GpuVertexLayout world = GpuVertexLayout.WorldMesh;
GpuVertexBinding only = Assert.Single(world.Bindings);
Assert.Equal(0u, only.Binding);
Assert.Equal(GpuVertexInputRate.Vertex, only.InputRate);
Assert.Equal(world.StrideBytes, only.StrideBytes);
// And a buffer-fed pipeline declares no binding at all.
Assert.Empty(GpuVertexLayout.None.Bindings);
Assert.Empty(GpuVertexLayout.None.Attributes);
Assert.Equal(0u, GpuVertexLayout.None.StrideBytes);
}
[Fact]
public void ScalarIntegerVertexAttributesAreRepresentable()
{
// particle.vert declares `layout(location = 6) in uint aTextureIndex` —
// the per-instance texture-table slot. The amendment's premise is that no
// shader is edited, so the contract has to be able to name a scalar uint:
// GL requires glVertexAttribIPointer for it and Vulkan requires R32_UINT,
// and the float path would reinterpret its bits rather than approximate
// its value. Same kind-distinction UByte4UInt was added for at V4d.
Assert.Contains(GpuVertexFormat.UInt1, Enum.GetValues<GpuVertexFormat>());
Assert.NotEqual(GpuVertexFormat.Float1, GpuVertexFormat.UInt1);
}
[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 APipelineCanDeclareThatItUsesTheStencilAspect()
{
// Campaign V slice V6l. Issue #117's portal punch is a two-pass
// stencil operation — mark where the aperture wins a depth test, then
// write the far-Z punch only on marked pixels and zero the stencil as it
// goes — and the V0 contract carried no stencil state at all, so
// PortalDepthMaskRenderer stayed raw GL and V4g's "stencil/depth-mask
// pipelines" row could not be written (plan §5.5.16 defect 2).
var description = new GpuPipelineDescription
{
Name = "contract-stencil",
Shaders = new GpuShaderSet("portal_depth"),
VertexLayout = GpuVertexLayout.None,
};
// Off by default, so no pipeline written before this slice changed.
Assert.False(description.StencilTest);
Assert.Equal(GpuStencilState.Default, description.Stencil);
Assert.Equal(GpuCompareOp.Always, GpuStencilState.Default.Compare);
Assert.Equal(GpuStencilOp.Keep, GpuStencilState.Default.Pass);
GpuPipelineDescription punch = description with
{
StencilTest = true,
Stencil = GpuStencilState.Default with
{
Compare = GpuCompareOp.Equal,
Pass = GpuStencilOp.Zero,
Reference = 1,
},
};
Assert.True(punch.StencilTest);
Assert.Equal(GpuCompareOp.Equal, punch.Stencil.Compare);
Assert.Equal(GpuStencilOp.Zero, punch.Stencil.Pass);
Assert.False(description.StencilTest);
}
[Fact]
public void EveryStencilOperationThePortalPunchNeedsIsRepresentable()
{
// Replace marks, Equal gates, Zero self-cleans. Nothing else in the tree
// touches stencil, and a fourth value would be a facility with no
// consumer rather than completeness.
Assert.Equal(3, Enum.GetValues<GpuStencilOp>().Length);
Assert.Contains(GpuStencilOp.Keep, Enum.GetValues<GpuStencilOp>());
Assert.Contains(GpuStencilOp.Zero, Enum.GetValues<GpuStencilOp>());
Assert.Contains(GpuStencilOp.Replace, Enum.GetValues<GpuStencilOp>());
}
[Fact]
public void TheDepthStencilAttachmentFormatCarriesAStencilAspect()
{
// The punch has nowhere to mark without one. The V5 capability gate
// prefers D32_SFLOAT_S8_UINT and falls back to D24_UNORM_S8_UINT rather
// than taking a depth-only format for exactly this reason, and the
// backbuffer pass clears both aspects through one ClearDepthStencil.
GpuPassDescription pass = GpuPassDescription.BackbufferClear(
"world",
Vector4.Zero,
sampleCount: 4);
Assert.Equal(0u, pass.Depth!.Value.ClearStencil);
Assert.Equal(GpuLoadOp.Clear, pass.Depth!.Value.Load);
Assert.Equal(GpuTextureFormat.Depth24Stencil8, GpuTextureFormat.Depth24Stencil8);
}
[Fact]
public void UniformBindingsDoNotCollide()
{
// Campaign V slice V6e added the sky block. Vulkan has ONE binding
// namespace per set, so two uniform buffers sharing a number is not a
// style problem — it is one of them silently reading the other's bytes.
uint[] uniformBindings =
[
GpuBindingModel.UniformSceneLighting,
GpuBindingModel.UniformTerrainTiling,
GpuBindingModel.UniformSkyParams,
];
Assert.Equal(uniformBindings.Length, uniformBindings.Distinct().Count());
// Binding 2 is the terrain clip block, which sky.vert also reads and
// which has no constant here because no CPU writer names it through the
// binding model. It is spelled as a literal on purpose: a new uniform
// buffer that took 2 would compile, link, and render the wrong thing.
Assert.DoesNotContain(2u, uniformBindings);
}
[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,
};
}