384 lines
18 KiB
C#
384 lines
18 KiB
C#
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
|
|
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 BackbufferClear_ClearsDepthAndStencil()
|
|
{
|
|
GpuPassDescription pass = GpuPassDescription.BackbufferClear(
|
|
"world",
|
|
Vector4.Zero,
|
|
sampleCount: 4);
|
|
Assert.Equal(0u, pass.Depth!.Value.ClearStencil);
|
|
Assert.Equal(1f, pass.Depth.Value.ClearDepth);
|
|
Assert.Equal(GpuLoadOp.Clear, pass.Depth!.Value.Load);
|
|
}
|
|
|
|
[Fact]
|
|
public void DepthStencilTextureFormat_MapsToDepthAndStencilAspects()
|
|
{
|
|
// The punch has nowhere to mark without a stencil aspect. The V5
|
|
// capability gate prefers D32_SFLOAT_S8_UINT and falls back to
|
|
// D24_UNORM_S8_UINT rather than accepting a depth-only format.
|
|
Silk.NET.Vulkan.ImageAspectFlags aspects =
|
|
VulkanTextureFormatMapping.AspectOf(GpuTextureFormat.Depth24Stencil8);
|
|
|
|
Assert.True(aspects.HasFlag(Silk.NET.Vulkan.ImageAspectFlags.DepthBit));
|
|
Assert.True(aspects.HasFlag(Silk.NET.Vulkan.ImageAspectFlags.StencilBit));
|
|
}
|
|
|
|
[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,
|
|
};
|
|
}
|