acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
Erik 7506e5f14e test(vulkan): isolate the hardware witness lane
Tag the production offscreen witness as Lane=Vulkan, exclude capability-owned tests from the portable Release filter, and give lavapipe an explicit trait-only invocation after the portable Vulkan contracts.

Document the lane and pin witness, portable-filter, and workflow ownership without initializing Vulkan.

Mutations performed and restored:

1. Removed the witness trait: VulkanLaneOwnershipContractTests first failed Assert.Matches because the Lane=Vulkan/Fact/method pattern was absent.

2. Removed Lane!=Vulkan from the default filter: the contract first failed Assert.Contains, not found Lane!=Vulkan.

3. Weakened the dedicated invocation to Lane!=Vulkan: the contract first failed Assert.Contains, not found --filter Lane=Vulkan in the hardware step.

4. Narrowed the dedicated invocation with FullyQualifiedName: the contract first failed Assert.DoesNotContain because FullyQualifiedName was present at position 231.
2026-09-05 03:19:55 +02:00

463 lines
20 KiB
C#

using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.Core.Lighting;
using Silk.NET.Vulkan;
using Buffer = Silk.NET.Vulkan.Buffer;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// S5 issue #470's driver witness. The production RHI loads the checked-in
/// mesh_modern SPIR-V pair, binds a nonzero shared-transform prefix and local
/// sidecars, submits an actual offscreen draw, and the test reads its pixels.
/// </summary>
public sealed unsafe class MeshModernSharedIndexOffscreenTests
{
private const int Extent = 64;
private const uint Prefix = 2;
private static readonly object VulkanLock = new();
[Trait("Lane", "Vulkan")]
[Fact]
public void CommittedProductionOrdinaryShader_RendersLocalSidecarsAtNonzeroTransformPrefix()
{
lock (VulkanLock)
{
string shaderDirectory = Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
Assert.Equal(
Path.GetFullPath(Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv")),
Path.GetFullPath(shaderDirectory));
Assert.True(File.Exists(Path.Combine(shaderDirectory, "mesh_modern.vert.spv")));
using var host = HeadlessVulkanHost.Create(shaderDirectory);
byte[] pixels = Render(host.Device, host.Vk, host.PhysicalDevice, host.LogicalDevice, host.Queue, host.QueueFamily);
int dark = CountPixels(pixels, 51);
int bright = CountPixels(pixels, 179);
Assert.True(dark > 64, $"Expected a dark local-sidecar instance, found {dark} matching pixels.");
Assert.True(bright > 64, $"Expected a bright local-sidecar instance, found {bright} matching pixels.");
}
}
private static byte[] Render(
VulkanGpuDevice device,
Silk.NET.Vulkan.Vk vk,
PhysicalDevice physicalDevice,
Device logicalDevice,
Queue queue,
uint queueFamily)
{
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
"s5-470-vertices",
4 * Marshal.SizeOf<Vertex>(),
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
using IGpuBuffer indices = device.CreateBuffer(new GpuBufferDescription(
"s5-470-indices",
6 * sizeof(ushort),
GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
Vertex[] vertexData =
[
new(new Vector3(-0.45f, -0.45f, 0f), Vector3.UnitZ, Vector2.Zero),
new(new Vector3( 0.45f, -0.45f, 0f), Vector3.UnitZ, Vector2.UnitX),
new(new Vector3( 0.45f, 0.45f, 0f), Vector3.UnitZ, Vector2.One),
new(new Vector3(-0.45f, 0.45f, 0f), Vector3.UnitZ, Vector2.UnitY),
];
vertices.Upload(0, MemoryMarshal.AsBytes<Vertex>(vertexData));
indices.Upload(0, MemoryMarshal.AsBytes<ushort>([0, 1, 2, 2, 3, 0]));
using IGpuRenderTarget target = device.CreateRenderTarget(new GpuRenderTargetDescription(
"s5-470-offscreen",
Extent,
Extent,
GpuTextureFormat.Rgba8UnormRenderTarget,
DepthFormat: null,
SampleCount: 1));
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = "s5-470-mesh-modern",
Shaders = new GpuShaderSet("mesh_modern"),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None,
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
SampleCount = 1,
});
Assert.False(pipeline.Description.Shaders.HasEmbeddedSpirv);
Assert.Equal("mesh_modern", pipeline.Description.Shaders.Name);
using (IGpuFrame frame = device.BeginFrame())
{
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = "s5-470-shared-index-witness",
Color = new GpuColorAttachment(
target,
GpuLoadOp.Clear,
GpuStoreOp.Store,
new Vector4(0f, 0f, 0f, 1f)),
Depth = null,
SampleCount = 1,
});
encoder.BindPipeline(pipeline);
GpuPushConstants constants = GpuPushConstants.Default;
constants.LightDebug = 3;
constants.TextureIndexB = Prefix;
encoder.SetPushConstants(constants);
Matrix4x4[] transforms =
[
Matrix4x4.CreateTranslation(20f, 20f, 0f),
Matrix4x4.CreateTranslation(-20f, -20f, 0f),
Matrix4x4.CreateTranslation(-0.5f, 0f, 0f),
Matrix4x4.CreateTranslation( 0.5f, 0f, 0f),
];
BindStorage(frame, encoder, GpuBindingModel.StorageInstances, transforms);
BindStorage(frame, encoder, GpuBindingModel.StorageBatches,
[new BatchData(device.DefaultTextureSlot.Index, 1f, 0u, 1u)]);
BindStorage(frame, encoder, GpuBindingModel.StorageClipSlots, [17u, 29u]);
BindStorage(frame, encoder, GpuBindingModel.StorageGlobalLights, [GlobalLight.Zero]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceLightSets,
[-1, -1, -1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceIndoor, [0u, 1u]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceAlpha, [0.25f, 0.75f]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceSelectionLighting,
[new Vector2(0.2f, 0f), new Vector2(0.7f, 0f)]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceDetailCategory, [3u, 9u]);
SceneLightingUbo lighting = default;
BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, lighting);
encoder.BindVertexBuffer(0, vertices, 0);
encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16);
encoder.DrawIndexed(6, 2, 0, 0, Prefix);
}
device.WaitIdle();
VulkanGpuRenderTarget vkTarget = Assert.IsType<VulkanGpuRenderTarget>(target);
return ReadBack(
vk,
physicalDevice,
logicalDevice,
queue,
queueFamily,
vkTarget.ColorResult.Image);
}
private static void BindStorage<T>(
IGpuFrame frame,
IGpuPassEncoder encoder,
uint binding,
T[] values)
where T : unmanaged
{
GpuRingAllocation allocation = frame.AllocateRing(
checked(values.Length * Marshal.SizeOf<T>()),
GpuRingUsage.Storage);
values.AsSpan().CopyTo(allocation.AsSpan<T>());
encoder.BindStorageBuffer(binding, allocation.Buffer, allocation.OffsetBytes, (uint)allocation.Data.Length);
}
private static void BindUniform<T>(
IGpuFrame frame,
IGpuPassEncoder encoder,
uint binding,
T value)
where T : unmanaged
{
GpuRingAllocation allocation = frame.AllocateRing(Marshal.SizeOf<T>(), GpuRingUsage.Uniform);
allocation.AsSpan<T>()[0] = value;
encoder.BindUniformBuffer(binding, allocation.Buffer, allocation.OffsetBytes, (uint)allocation.Data.Length);
}
private static byte[] ReadBack(
Silk.NET.Vulkan.Vk vk,
PhysicalDevice physicalDevice,
Device device,
Queue queue,
uint queueFamily,
Image image)
{
uint byteCount = Extent * Extent * 4u;
Buffer readback = default;
DeviceMemory memory = default;
CommandPool pool = default;
try
{
var bufferCreate = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = byteCount,
Usage = BufferUsageFlags.TransferDstBit,
SharingMode = SharingMode.Exclusive,
};
VulkanInterop.Check(vk.CreateBuffer(device, &bufferCreate, null, out readback), "vkCreateBuffer (S5-470 readback)");
vk.GetBufferMemoryRequirements(device, readback, out MemoryRequirements requirements);
uint memoryType = VulkanActiveDeviceProbe.FindMemoryType(
vk,
physicalDevice,
requirements.MemoryTypeBits,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit)
?? throw new NotSupportedException("S5-470 requires coherent host-visible readback memory.");
var memoryAllocate = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = memoryType,
};
VulkanInterop.Check(vk.AllocateMemory(device, &memoryAllocate, null, out memory), "vkAllocateMemory (S5-470 readback)");
VulkanInterop.Check(vk.BindBufferMemory(device, readback, memory, 0), "vkBindBufferMemory (S5-470 readback)");
var poolCreate = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = queueFamily,
Flags = CommandPoolCreateFlags.TransientBit,
};
VulkanInterop.Check(vk.CreateCommandPool(device, &poolCreate, null, out pool), "vkCreateCommandPool (S5-470 readback)");
var commandAllocate = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = pool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1,
};
VulkanInterop.Check(vk.AllocateCommandBuffers(device, &commandAllocate, out CommandBuffer commands), "vkAllocateCommandBuffers (S5-470 readback)");
var begin = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer (S5-470 readback)");
var barrier = new ImageMemoryBarrier2
{
SType = StructureType.ImageMemoryBarrier2,
SrcStageMask = PipelineStageFlags2.FragmentShaderBit,
SrcAccessMask = AccessFlags2.ShaderReadBit,
DstStageMask = PipelineStageFlags2.CopyBit,
DstAccessMask = AccessFlags2.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = new ImageSubresourceRange(
ImageAspectFlags.ColorBit, 0, 1, 0, 1),
};
var dependency = new DependencyInfo
{
SType = StructureType.DependencyInfo,
ImageMemoryBarrierCount = 1,
PImageMemoryBarriers = &barrier,
};
vk.CmdPipelineBarrier2(commands, &dependency);
var copy = new BufferImageCopy
{
ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
ImageExtent = new Extent3D(Extent, Extent, 1),
};
vk.CmdCopyImageToBuffer(commands, image, ImageLayout.TransferSrcOptimal, readback, 1, &copy);
VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer (S5-470 readback)");
var commandInfo = new CommandBufferSubmitInfo
{
SType = StructureType.CommandBufferSubmitInfo,
CommandBuffer = commands,
};
var submit = new SubmitInfo2
{
SType = StructureType.SubmitInfo2,
CommandBufferInfoCount = 1,
PCommandBufferInfos = &commandInfo,
};
VulkanInterop.Check(vk.QueueSubmit2(queue, 1, &submit, default), "vkQueueSubmit2 (S5-470 readback)");
VulkanInterop.Check(vk.QueueWaitIdle(queue), "vkQueueWaitIdle (S5-470 readback)");
void* mapped = null;
VulkanInterop.Check(vk.MapMemory(device, memory, 0, byteCount, 0, &mapped), "vkMapMemory (S5-470 readback)");
try
{
var pixels = new byte[byteCount];
new ReadOnlySpan<byte>(mapped, pixels.Length).CopyTo(pixels);
return pixels;
}
finally
{
vk.UnmapMemory(device, memory);
}
}
finally
{
if (pool.Handle != 0)
vk.DestroyCommandPool(device, pool, null);
if (readback.Handle != 0)
vk.DestroyBuffer(device, readback, null);
if (memory.Handle != 0)
vk.FreeMemory(device, memory, null);
}
}
private static int CountPixels(ReadOnlySpan<byte> pixels, byte expected)
{
int count = 0;
for (int offset = 0; offset < pixels.Length; offset += 4)
{
if (Math.Abs(pixels[offset + 0] - expected) <= 2
&& Math.Abs(pixels[offset + 1] - expected) <= 2
&& Math.Abs(pixels[offset + 2] - expected) <= 2
&& pixels[offset + 3] >= 253)
{
count++;
}
}
return count;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord);
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private readonly record struct BatchData(
uint TextureIndex,
float SurfaceOpacity,
uint TextureLayer,
uint Flags);
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private readonly record struct GlobalLight(
Vector4 PositionAndKind,
Vector4 DirectionAndRange,
Vector4 ColorAndIntensity,
Vector4 ConeAngleEtc)
{
internal static GlobalLight Zero { get; } = default;
}
private sealed class HeadlessVulkanHost : IDisposable
{
private bool _disposed;
private HeadlessVulkanHost(
Silk.NET.Vulkan.Vk vk,
Instance instance,
PhysicalDevice physicalDevice,
Device logicalDevice,
Queue queue,
uint queueFamily,
VulkanGpuDevice device)
{
Vk = vk;
Instance = instance;
PhysicalDevice = physicalDevice;
LogicalDevice = logicalDevice;
Queue = queue;
QueueFamily = queueFamily;
Device = device;
}
internal Silk.NET.Vulkan.Vk Vk { get; }
internal Instance Instance { get; }
internal PhysicalDevice PhysicalDevice { get; }
internal Device LogicalDevice { get; }
internal Queue Queue { get; }
internal uint QueueFamily { get; }
internal VulkanGpuDevice Device { get; }
internal static HeadlessVulkanHost Create(string shaderDirectory)
{
Silk.NET.Vulkan.Vk vk = Silk.NET.Vulkan.Vk.GetApi();
Instance instance = default;
Device logicalDevice = default;
VulkanGpuDevice? gpuDevice = null;
try
{
instance = VulkanInstanceFactory.Create(vk, [], enableOptionalExtensions: false).Instance;
IReadOnlyList<VulkanPhysicalDeviceCandidate> candidates =
VulkanPhysicalDeviceInspector.Enumerate(vk, instance, out PhysicalDevice[] handles);
VulkanPhysicalDeviceChoice selected = VulkanPhysicalDeviceSelection.Choose(candidates, null)
?? throw new NotSupportedException("S5-470 offscreen proof found no Vulkan physical device.");
PhysicalDevice physicalDevice = handles[selected.Device.Index];
VulkanDeviceFeatureSupport features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, physicalDevice);
uint queueFamily = VulkanQueueFamilySelection.ChooseGraphicsOnly(
VulkanPhysicalDeviceInspector.ReadQueueFamilies(vk, physicalDevice, surfaceApi: null, default))
?? throw new NotSupportedException("S5-470 offscreen proof found no graphics queue.");
VulkanLogicalDeviceFactory.Created created = VulkanLogicalDeviceFactory.Create(
vk,
physicalDevice,
new VulkanQueueFamilyChoice(queueFamily, queueFamily),
requireSwapchain: false,
features);
logicalDevice = created.Device;
VulkanDeviceLimitSupport limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, physicalDevice);
VulkanFormatSupport formats = VulkanPhysicalDeviceInspector.ReadFormats(
vk, physicalDevice, surfaceOffersUnorm: true);
gpuDevice = new VulkanGpuDevice(
vk,
physicalDevice,
logicalDevice,
created.GraphicsQueue,
created.GraphicsQueue,
queueFamily,
features,
limits,
formats,
selected.Device.DeviceName,
VulkanPhysicalDeviceInspector.DescribeDriver(selected.Device),
VulkanApiVersion.Describe(selected.Device.ApiVersion),
VulkanDebugNames.Disabled,
backbuffer: null,
shaderSpirvDirectory: shaderDirectory,
pipelineCacheDirectory: null,
ringCapacityBytesPerSlot: 2 * 1024 * 1024,
framesInFlight: 1);
return new HeadlessVulkanHost(
vk,
instance,
physicalDevice,
logicalDevice,
created.GraphicsQueue,
queueFamily,
gpuDevice);
}
catch
{
gpuDevice?.Dispose();
if (logicalDevice.Handle != 0)
vk.DestroyDevice(logicalDevice, null);
if (instance.Handle != 0)
vk.DestroyInstance(instance, null);
vk.Dispose();
throw;
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Device.Dispose();
if (LogicalDevice.Handle != 0)
Vk.DestroyDevice(LogicalDevice, null);
if (Instance.Handle != 0)
Vk.DestroyInstance(Instance, null);
Vk.Dispose();
}
}
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.");
}
}