247 lines
10 KiB
C#
247 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: every compiled SPIR-V module's descriptor decorations
|
|
/// must match the descriptor set layouts the backend creates.
|
|
///
|
|
/// <para><b>Why this test exists.</b> Slice V6i measured that
|
|
/// <c>terrain_modern.vert</c>'s <c>TerrainClip</c> block was declared
|
|
/// <c>layout(std140, binding = 2)</c> with no <c>ACDREAM_UBO_SET</c>, so under
|
|
/// the Vulkan dialect it landed in set 0 binding 2 — which set 0's layout
|
|
/// declares as a STORAGE buffer. A terrain pipeline built against the shared
|
|
/// layout was therefore malformed. Nothing caught it: GL expands the macro to
|
|
/// nothing and keeps its UBO and SSBO namespaces separate, the shader compiled
|
|
/// cleanly for both backends, and no terrain pipeline had ever been created on
|
|
/// Vulkan. Plan §5.5.8 recorded a second instance of the same class — sky's
|
|
/// <c>UniformSkyParams</c> at binding 4 was absent from the layout — found the
|
|
/// same way, by hand, months apart.</para>
|
|
///
|
|
/// <para>A one-word omission in a shader that cannot fail to compile, cannot
|
|
/// fail on the shipping backend, and is only wrong on a backend nothing has run
|
|
/// yet, is exactly the shape of defect that needs a test rather than an audit.
|
|
/// Reading the committed <c>.spv</c> is what makes the assertion about what the
|
|
/// GPU will actually be handed, not about what the GLSL looks like.</para>
|
|
/// </summary>
|
|
public sealed class VulkanShaderDescriptorContractTests
|
|
{
|
|
private static string RepositoryRoot()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName
|
|
?? throw new InvalidOperationException("Could not locate the repository root from the test binary.");
|
|
}
|
|
|
|
private static string SpirvDirectory() =>
|
|
Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
|
|
|
|
/// <summary>One interface variable's set, binding and storage class.</summary>
|
|
private readonly record struct ShaderResource(
|
|
string Module,
|
|
uint Id,
|
|
SpirvStorageClass StorageClass,
|
|
uint? Set,
|
|
uint? Binding);
|
|
|
|
private enum SpirvStorageClass : uint
|
|
{
|
|
UniformConstant = 0,
|
|
Uniform = 2,
|
|
StorageBuffer = 12,
|
|
}
|
|
|
|
/// <summary>
|
|
/// The smallest SPIR-V reader that can answer this question: walk the
|
|
/// instruction stream collecting <c>OpDecorate</c> DescriptorSet/Binding and
|
|
/// the storage class of every <c>OpVariable</c>. No type or name recovery is
|
|
/// needed, so no dependency is either.
|
|
/// </summary>
|
|
private static IReadOnlyList<ShaderResource> ReadResources(string path)
|
|
{
|
|
byte[] bytes = File.ReadAllBytes(path);
|
|
Assert.True(bytes.Length >= 20 && bytes.Length % 4 == 0, $"{path} is not a SPIR-V module.");
|
|
var words = new uint[bytes.Length / 4];
|
|
Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
|
|
Assert.Equal(0x07230203u, words[0]);
|
|
|
|
const uint OpDecorate = 71;
|
|
const uint OpVariable = 59;
|
|
const uint DecorationBinding = 33;
|
|
const uint DecorationDescriptorSet = 34;
|
|
|
|
var sets = new Dictionary<uint, uint>();
|
|
var bindings = new Dictionary<uint, uint>();
|
|
var variables = new List<(uint Id, SpirvStorageClass StorageClass)>();
|
|
|
|
int index = 5;
|
|
while (index < words.Length)
|
|
{
|
|
uint header = words[index];
|
|
int wordCount = (int)(header >> 16);
|
|
uint opcode = header & 0xFFFF;
|
|
Assert.True(wordCount > 0, $"{path} has a zero-length instruction at word {index}.");
|
|
|
|
if (opcode == OpDecorate && wordCount >= 4)
|
|
{
|
|
uint target = words[index + 1];
|
|
uint decoration = words[index + 2];
|
|
if (decoration == DecorationDescriptorSet)
|
|
sets[target] = words[index + 3];
|
|
else if (decoration == DecorationBinding)
|
|
bindings[target] = words[index + 3];
|
|
}
|
|
else if (opcode == OpVariable && wordCount >= 4)
|
|
{
|
|
variables.Add((words[index + 2], (SpirvStorageClass)words[index + 3]));
|
|
}
|
|
|
|
index += wordCount;
|
|
}
|
|
|
|
string module = Path.GetFileName(path);
|
|
return
|
|
[
|
|
.. variables.Select(v => new ShaderResource(
|
|
module,
|
|
v.Id,
|
|
v.StorageClass,
|
|
sets.TryGetValue(v.Id, out uint s) ? s : null,
|
|
bindings.TryGetValue(v.Id, out uint b) ? b : null)),
|
|
];
|
|
}
|
|
|
|
private static IReadOnlyList<ShaderResource> AllResources() =>
|
|
[
|
|
.. Directory
|
|
.EnumerateFiles(SpirvDirectory(), "*.spv")
|
|
.OrderBy(path => path, StringComparer.Ordinal)
|
|
.SelectMany(ReadResources),
|
|
];
|
|
|
|
private static bool IsOptInPackShaderModule(string module) =>
|
|
module.StartsWith("atmospheric_", StringComparison.Ordinal)
|
|
|| module.StartsWith("directional_shadow_", StringComparison.Ordinal)
|
|
|| module.StartsWith("mesh_atmospheric.", StringComparison.Ordinal)
|
|
|| module.StartsWith("terrain_atmospheric.", StringComparison.Ordinal);
|
|
|
|
/// <summary>
|
|
/// The regression itself, named. <c>TerrainClip</c> is the only uniform block
|
|
/// <c>terrain_modern.vert</c> declares besides <c>SceneLighting</c>, so
|
|
/// asserting the module's uniform bindings as a set pins it exactly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TerrainVertexShaderDeclaresItsClipBlockInTheUniformSet()
|
|
{
|
|
uint[] uniformBindings =
|
|
[
|
|
.. ReadResources(Path.Combine(SpirvDirectory(), "terrain_modern.vert.spv"))
|
|
.Where(r => r.StorageClass == SpirvStorageClass.Uniform)
|
|
.Select(r =>
|
|
{
|
|
Assert.Equal(GpuBindingModel.UniformSet, r.Set);
|
|
return r.Binding!.Value;
|
|
})
|
|
.Order(),
|
|
];
|
|
|
|
Assert.Equal(
|
|
[GpuBindingModel.UniformSceneLighting, VulkanPipelineLayouts.UniformTerrainClip],
|
|
uniformBindings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The general rule the specific case is an instance of: a uniform block may
|
|
/// only live in retail set 1 at a retail binding, or in opt-in set 3 at one
|
|
/// of render-pack ABI v1's sparse bindings 5..8.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EveryUniformBlockLandsAtADeclaredUniformBinding()
|
|
{
|
|
string[] violations =
|
|
[
|
|
.. AllResources()
|
|
.Where(r => r.StorageClass == SpirvStorageClass.Uniform)
|
|
.Where(r =>
|
|
r.Binding is null
|
|
|| !(r.Set == GpuBindingModel.UniformSet
|
|
&& VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value))
|
|
&& !(r.Set == GpuBindingModel.RenderPackUniformSet
|
|
&& VulkanPipelineLayouts.IsDeclaredPackUniformBinding(r.Binding.Value)))
|
|
.Select(r =>
|
|
$"{r.Module}: uniform block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} "
|
|
+ $"binding {r.Binding?.ToString() ?? "(none)"}; retail set 1 declares "
|
|
+ $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}] "
|
|
+ "and opt-in set 3 declares [5, 6, 7, 8]."),
|
|
];
|
|
|
|
Assert.Empty(violations);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailShaderModulesNeverDeclareOptInSetThree()
|
|
{
|
|
string[] violations =
|
|
[
|
|
.. AllResources()
|
|
.Where(r => r.Set == GpuBindingModel.RenderPackUniformSet)
|
|
.Where(r => !IsOptInPackShaderModule(r.Module))
|
|
.Select(r =>
|
|
$"{r.Module}: resource %{r.Id} declares opt-in set 3 binding {r.Binding}."),
|
|
];
|
|
|
|
Assert.Empty(violations);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The other half of the same contract: set 0 is storage buffers only, and
|
|
/// within the range <see cref="GpuBindingModel.StorageBindingCount"/> pins. A
|
|
/// uniform block straying into set 0 fails this as well as the test above,
|
|
/// which is the point — the two layouts must partition the bindings.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EveryStorageBlockLandsInTheStorageSetWithinItsDeclaredRange()
|
|
{
|
|
string[] violations =
|
|
[
|
|
.. AllResources()
|
|
.Where(r => r.StorageClass == SpirvStorageClass.StorageBuffer)
|
|
.Where(r => r.Set != 0 || r.Binding is null || r.Binding >= GpuBindingModel.StorageBindingCount)
|
|
.Select(r =>
|
|
$"{r.Module}: storage block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} "
|
|
+ $"binding {r.Binding?.ToString() ?? "(none)"}; set 0 declares bindings "
|
|
+ $"0..{GpuBindingModel.StorageBindingCount - 1}."),
|
|
];
|
|
|
|
Assert.Empty(violations);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The sampled-texture table is one variable-count descriptor array at set 2
|
|
/// binding 0. Any other combined-image-sampler would need a layout that does
|
|
/// not exist.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EverySampledTextureIsTheSharedTable()
|
|
{
|
|
string[] violations =
|
|
[
|
|
.. AllResources()
|
|
.Where(r => r.StorageClass == SpirvStorageClass.UniformConstant && r.Set is not null)
|
|
.Where(r => r.Set != GpuBindingModel.TextureTableSet || r.Binding != GpuBindingModel.TextureTableBinding)
|
|
.Select(r =>
|
|
$"{r.Module}: sampled resource %{r.Id} is at set {r.Set} binding "
|
|
+ $"{r.Binding?.ToString() ?? "(none)"}; the only declared table is set "
|
|
+ $"{GpuBindingModel.TextureTableSet} binding {GpuBindingModel.TextureTableBinding}."),
|
|
];
|
|
|
|
Assert.Empty(violations);
|
|
}
|
|
}
|