Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 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 that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
307 lines
15 KiB
C#
307 lines
15 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 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 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,
|
|
};
|
|
}
|