diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
index 151fab53..43f426e6 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
@@ -52,11 +52,10 @@ layout(std430, binding = 1) readonly buffer BatchBuffer {
// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every
// instance has always mapped to slot 0 (no-clip) in every shipped build; a
// shader-side clip test against a table that only ever holds the reserved
-// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot
-// index, parallel to the binding=0 instance buffer and indexed by the
-// IDENTICAL per-instance index (gl_BaseInstanceARB + gl_InstanceID) — stays
-// declared (the CPU side still writes it, always 0) but is no longer read
-// here; the instance-buffer layout it occupies is S5's to revisit.
+// no-clip slot clips nothing. binding=3 — LOCAL per-submission slot index,
+// zero-based independently of the shared binding=0 world-transform arena —
+// stays declared (the CPU side still writes it, always 0) but is no longer
+// read here; the instance-buffer layout it occupies is S5's to revisit.
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
@@ -71,10 +70,11 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
//
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
-// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
-// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
-// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
-// it once per entity (the set is constant across the entity's parts/tuples).
+// binding=5 — LOCAL per-submission light SET: MaxLightsPerObject(8) int
+// indices per instance INTO gLights[] (-1 = unused slot), addressed from
+// local instance zero even when binding=0 begins with a shared shadow prefix.
+// WbDrawDispatcher fills it once per entity (the set is constant across the
+// entity's parts/tuples).
struct GlobalLight {
vec4 posAndKind;
vec4 dirAndRange;
@@ -88,17 +88,18 @@ layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
};
-// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
-// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
-// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
+// #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
+// parented to an EnvCell (skip the sun — retail's useSunlight==0 interior
+// stage); 0 = outdoor object (gets the sun). It is indexed from local zero,
+// independently of binding=0's shared world-transform prefix.
// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
uint instanceIndoor[];
};
-// #188: per-instance opacity multiplier, 1 per instance, parallel to the
-// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
+// #188: LOCAL per-submission opacity multiplier, 1 per instance, indexed from
+// local zero independently of binding=0's shared transform arena. 1.0 = unmodified; <1.0
// while a TransparentPartHook translucency fade is in flight for the
// entity/part this instance belongs to (e.g. the "fading wall" secret-
// passage doors). Multiplied against the sampled texture alpha in
@@ -107,8 +108,8 @@ layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
-// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
-// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
+// Retail SmartBox click confirmation. One LOCAL vec2 per OBJECT instance:
+// x = CMaterial luminosity, y = CMaterial diffuse. Normal
// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
// object-only binding.
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
index 7b00613f..02833a07 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
@@ -48,11 +48,10 @@ layout(std430, binding = 1) readonly buffer BatchBuffer {
// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every
// instance has always mapped to slot 0 (no-clip) in every shipped build; a
// shader-side clip test against a table that only ever holds the reserved
-// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot
-// index, parallel to the binding=0 instance buffer and indexed by the
-// IDENTICAL per-instance index (gl_BaseInstanceARB + gl_InstanceID) — stays
-// declared (the CPU side still writes it, always 0) but is no longer read
-// here; the instance-buffer layout it occupies is S5's to revisit.
+// no-clip slot clips nothing. binding=3 — LOCAL per-submission slot index,
+// zero-based independently of the shared binding=0 world-transform arena —
+// stays declared (the CPU side still writes it, always 0) but is no longer
+// read here; the instance-buffer layout it occupies is S5's to revisit.
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
@@ -67,10 +66,11 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
//
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
-// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
-// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
-// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
-// it once per entity (the set is constant across the entity's parts/tuples).
+// binding=5 — LOCAL per-submission light SET: MaxLightsPerObject(8) int
+// indices per instance INTO gLights[] (-1 = unused slot), addressed from
+// local instance zero even when binding=0 begins with a shared shadow prefix.
+// WbDrawDispatcher fills it once per entity (the set is constant across the
+// entity's parts/tuples).
struct GlobalLight {
vec4 posAndKind;
vec4 dirAndRange;
@@ -84,17 +84,18 @@ layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
};
-// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
-// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
-// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
+// #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
+// parented to an EnvCell (skip the sun — retail's useSunlight==0 interior
+// stage); 0 = outdoor object (gets the sun). It is indexed from local zero,
+// independently of binding=0's shared world-transform prefix.
// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
uint instanceIndoor[];
};
-// #188: per-instance opacity multiplier, 1 per instance, parallel to the
-// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
+// #188: LOCAL per-submission opacity multiplier, 1 per instance, indexed from
+// local zero independently of binding=0's shared transform arena. 1.0 = unmodified; <1.0
// while a TransparentPartHook translucency fade is in flight for the
// entity/part this instance belongs to (e.g. the "fading wall" secret-
// passage doors). Multiplied against the sampled texture alpha in
@@ -103,8 +104,8 @@ layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
-// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
-// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
+// Retail SmartBox click confirmation. One LOCAL vec2 per OBJECT instance:
+// x = CMaterial luminosity, y = CMaterial diffuse. Normal
// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
// object-only binding.
@@ -118,6 +119,12 @@ layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uniform mat4 uViewProjection;
+// The shared binding=0 world-transform arena may begin with directional-shadow
+// matrices. Ordered ordinary draws carry that absolute prefix in both
+// BaseInstance and this push word. Transform lookup stays absolute; every
+// ordinary sidecar section is bound at its own local element zero.
+uniform uint uTextureIndexB;
+
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect
// call, so the transparent pass — which begins later in the indirect buffer
@@ -281,8 +288,9 @@ out flat uint vBatchFlags;
out flat uint vDetailCategory;
void main() {
- int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
- mat4 model = Instances[instanceIndex].transform;
+ int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
+ int instanceIndex = transformIndex - int(uTextureIndexB);
+ mat4 model = Instances[transformIndex].transform;
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
vSelectionLighting = (uLightingMode == 0)
? instanceSelectionLighting[instanceIndex]
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv
index c48d1399..7ec7b978 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index a5277b73..bd61c558 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -215,7 +215,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "71950823770a69d610db03e0c54c645934e3b46a686c8abac85b52f2476afcfa",
+ "sourceSha256": "bf470ee19d0ea5fd36f6ac96e307a71845e50d8872263cfa15d1c36fc6424732",
"compiled": true
},
{
@@ -231,7 +231,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "c41448edcd258313f921f3b66c0884c8f78d4d592eef1abe0804c6cb2890c58f",
+ "sourceSha256": "d5af0cbe995bcd10acea92ac7140363f0941105452a89039a7b54c5217c505a2",
"compiled": true
},
{
diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
index e31fed1b..b8dc3998 100644
--- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
+++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
@@ -5,6 +5,7 @@
enable
enable
false
+ true
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
new file mode 100644
index 00000000..4959eb55
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
@@ -0,0 +1,462 @@
+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;
+
+///
+/// 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.
+///
+public sealed unsafe class MeshModernSharedIndexOffscreenTests
+{
+ private const int Extent = 64;
+ private const uint Prefix = 2;
+ private static readonly object VulkanLock = new();
+
+ [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(),
+ 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(vertexData));
+ indices.Upload(0, MemoryMarshal.AsBytes([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(target);
+ return ReadBack(
+ vk,
+ physicalDevice,
+ logicalDevice,
+ queue,
+ queueFamily,
+ vkTarget.ColorResult.Image);
+ }
+
+ private static void BindStorage(
+ IGpuFrame frame,
+ IGpuPassEncoder encoder,
+ uint binding,
+ T[] values)
+ where T : unmanaged
+ {
+ GpuRingAllocation allocation = frame.AllocateRing(
+ checked(values.Length * Marshal.SizeOf()),
+ GpuRingUsage.Storage);
+ values.AsSpan().CopyTo(allocation.AsSpan());
+ encoder.BindStorageBuffer(binding, allocation.Buffer, allocation.OffsetBytes, (uint)allocation.Data.Length);
+ }
+
+ private static void BindUniform(
+ IGpuFrame frame,
+ IGpuPassEncoder encoder,
+ uint binding,
+ T value)
+ where T : unmanaged
+ {
+ GpuRingAllocation allocation = frame.AllocateRing(Marshal.SizeOf(), GpuRingUsage.Uniform);
+ allocation.AsSpan()[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, ©);
+ 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(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 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 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.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexSpirvTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexSpirvTests.cs
new file mode 100644
index 00000000..0f5c93e7
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexSpirvTests.cs
@@ -0,0 +1,214 @@
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// S5 issue #470: inspects the committed production module, not GLSL text, so
+/// shader compilation cannot silently fold the shared-transform/local-sidecar
+/// address split into a different lookup contract.
+///
+public sealed class MeshModernSharedIndexSpirvTests
+{
+ private const ushort OpConstant = 43;
+ private const ushort OpVariable = 59;
+ private const ushort OpLoad = 61;
+ private const ushort OpAccessChain = 65;
+ private const ushort OpDecorate = 71;
+ private const ushort OpBitcast = 124;
+ private const ushort OpIAdd = 128;
+ private const ushort OpISub = 130;
+ private const ushort OpIMul = 132;
+ private const ushort OpPhi = 245;
+
+ private const uint StorageClassInput = 1;
+ private const uint StorageClassPushConstant = 9;
+ private const uint DecorationBuiltIn = 11;
+ private const uint DecorationBinding = 33;
+ private const uint DecorationDescriptorSet = 34;
+ private const uint BuiltInInstanceIndex = 43;
+
+ [Fact]
+ public void ProductionModule_UsesAbsoluteTransformAndLocalOrdinarySidecars()
+ {
+ string path = Path.Combine(
+ RepositoryRoot(),
+ "src", "AcDream.App", "Rendering", "Shaders", "spv", "mesh_modern.vert.spv");
+ Spirv module = Spirv.Read(path);
+
+ uint instanceInput = Assert.Single(
+ module.Variables,
+ variable => variable.Value == StorageClassInput
+ && module.Decoration(variable.Key, DecorationBuiltIn) == BuiltInInstanceIndex).Key;
+ uint absoluteIndex = Assert.Single(
+ module.Instructions,
+ instruction => instruction.OpCode == OpLoad && instruction.Operands[0] == instanceInput).ResultId;
+
+ uint pushBlock = Assert.Single(
+ module.Variables,
+ variable => variable.Value == StorageClassPushConstant).Key;
+ uint prefixPointer = Assert.Single(
+ module.Instructions,
+ instruction => instruction.OpCode == OpAccessChain
+ && instruction.Operands[0] == pushBlock
+ && instruction.Operands.Skip(1).Any(id => module.Constant(id) == 6u)).ResultId;
+ uint prefixUnsigned = Assert.Single(
+ module.Instructions,
+ instruction => instruction.OpCode == OpLoad
+ && instruction.Operands[0] == prefixPointer).ResultId;
+ uint prefixSigned = Assert.Single(
+ module.Instructions,
+ instruction => instruction.OpCode == OpBitcast
+ && instruction.Operands[0] == prefixUnsigned).ResultId;
+ Instruction subtraction = Assert.Single(
+ module.Instructions,
+ instruction => instruction.OpCode == OpISub
+ && instruction.Operands.SequenceEqual([absoluteIndex, prefixSigned]));
+ uint localIndex = subtraction.ResultId;
+
+ Instruction transformLookup = Assert.Single(module.RootAccessChains(set: 0, binding: 0));
+ Assert.Contains(absoluteIndex, transformLookup.Operands.Skip(1));
+ Assert.DoesNotContain(localIndex, transformLookup.Operands.Skip(1));
+
+ foreach (uint sidecarBinding in new uint[] { 5, 6, 7, 8, 9 })
+ {
+ Instruction[] lookups = module.RootAccessChains(set: 0, binding: sidecarBinding).ToArray();
+ Assert.NotEmpty(lookups);
+ Assert.All(
+ lookups,
+ lookup => Assert.Contains(
+ lookup.Operands.Skip(1),
+ index => module.DependsOn(index, localIndex)));
+ }
+ }
+
+ private readonly record struct Instruction(ushort OpCode, uint ResultId, uint[] Operands);
+
+ private sealed class Spirv
+ {
+ private readonly Dictionary> _decorations = [];
+ private readonly Dictionary _constants = [];
+ private readonly Dictionary _results = [];
+
+ private Spirv()
+ {
+ }
+
+ internal Dictionary Variables { get; } = [];
+
+ internal List Instructions { get; } = [];
+
+ internal static Spirv Read(string path)
+ {
+ byte[] bytes = File.ReadAllBytes(path);
+ Assert.True(bytes.Length >= 20 && bytes.Length % sizeof(uint) == 0, $"{path} is not SPIR-V.");
+ uint[] words = new uint[bytes.Length / sizeof(uint)];
+ Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
+ Assert.Equal(0x07230203u, words[0]);
+
+ var module = new Spirv();
+ for (int index = 5; index < words.Length;)
+ {
+ int wordCount = checked((int)(words[index] >> 16));
+ ushort opCode = checked((ushort)(words[index] & 0xFFFFu));
+ Assert.True(wordCount > 0 && index + wordCount <= words.Length);
+
+ if (opCode == OpDecorate)
+ {
+ uint target = words[index + 1];
+ uint decoration = words[index + 2];
+ if (wordCount >= 4)
+ {
+ if (!module._decorations.TryGetValue(target, out Dictionary? values))
+ module._decorations[target] = values = [];
+ values[decoration] = words[index + 3];
+ }
+ }
+ else if (opCode == OpConstant && wordCount >= 4)
+ {
+ module._constants[words[index + 2]] = words[index + 3];
+ }
+ else if (opCode == OpVariable && wordCount >= 4)
+ {
+ module.Variables[words[index + 2]] = words[index + 3];
+ }
+
+ if (TryReadResult(opCode, words.AsSpan(index, wordCount), out Instruction instruction))
+ {
+ module.Instructions.Add(instruction);
+ module._results[instruction.ResultId] = instruction;
+ }
+
+ index += wordCount;
+ }
+ return module;
+ }
+
+ internal uint? Decoration(uint id, uint decoration) =>
+ _decorations.TryGetValue(id, out Dictionary? values)
+ && values.TryGetValue(decoration, out uint value)
+ ? value
+ : null;
+
+ internal uint? Constant(uint id) => _constants.TryGetValue(id, out uint value) ? value : null;
+
+ internal IEnumerable RootAccessChains(uint set, uint binding)
+ {
+ HashSet roots = Variables.Keys
+ .Where(id => Decoration(id, DecorationDescriptorSet) == set)
+ .Where(id => Decoration(id, DecorationBinding) == binding)
+ .ToHashSet();
+ Assert.Single(roots);
+ return Instructions.Where(instruction =>
+ instruction.OpCode == OpAccessChain && roots.Contains(instruction.Operands[0]));
+ }
+
+ internal bool DependsOn(uint value, uint dependency)
+ {
+ if (value == dependency)
+ return true;
+ var visited = new HashSet();
+ return Visit(value);
+
+ bool Visit(uint current)
+ {
+ if (!visited.Add(current) || !_results.TryGetValue(current, out Instruction instruction))
+ return false;
+ foreach (uint operand in instruction.Operands)
+ {
+ if (operand == dependency || Visit(operand))
+ return true;
+ }
+ return false;
+ }
+ }
+
+ private static bool TryReadResult(
+ ushort opCode,
+ ReadOnlySpan words,
+ out Instruction instruction)
+ {
+ switch (opCode)
+ {
+ case OpLoad:
+ case OpAccessChain:
+ case OpBitcast:
+ case OpIAdd:
+ case OpISub:
+ case OpIMul:
+ case OpPhi:
+ instruction = new Instruction(opCode, words[2], words[3..].ToArray());
+ return true;
+ default:
+ instruction = default;
+ return false;
+ }
+ }
+ }
+
+ 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.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
index 17da0f34..4e9dffe7 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
@@ -51,7 +51,7 @@ public sealed class VulkanShaderManifestTests
// S5-c4 replaces the padding word at batch offset 4 with authored
// surface opacity and forwards it plus exact detail category/flags
// flat to the fragment shader. The 16-byte ABI is unchanged.
- ["mesh_modern.vert.spv"] = "1ff1b7118153ba0fddfcdfca39bd973a4b8f6b46db6db772e9886061d15561d8",
+ ["mesh_modern.vert.spv"] = "6194193764616bc88bf967d1fdc2bea5f6f2d1ec2ef94fd492922137503f56e4",
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
// Re-pinned 2026-08-27: portal-view clip slots now travel with
// deferred billboard particles, matching retail PortalList draws.
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
index 09976756..80df2d2b 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
+using System.Runtime.InteropServices;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
@@ -717,6 +718,117 @@ public sealed class OrderPreservingSubmitterTests
// ── Decode helpers ──────────────────────────────────────────────────────
+ [Theory]
+ [InlineData(false, "mesh_modern")]
+ [InlineData(true, "mesh_atmospheric")]
+ public void SharedTransformPrefix_RecordsAbsoluteCommandsAndZeroBasedLocalSidecars(
+ bool receiverBindingAvailable,
+ string expectedShader)
+ {
+ using var fx = new DispatcherFixture(
+ atmospheric: true,
+ receiverBindingAvailable: receiverBindingAvailable);
+ using DrawScope draw = fx.BeginDraw();
+
+ Matrix4x4[] shadowPrefix =
+ [
+ Matrix4x4.CreateTranslation(101f, 102f, 103f),
+ Matrix4x4.CreateTranslation(201f, 202f, 203f),
+ Matrix4x4.CreateTranslation(301f, 302f, 303f),
+ ];
+ WorldTransformFrameSlice shared =
+ fx.Dispatcher.BeginDirectionalShadowTransformFrame(draw.Frame, shadowPrefix);
+ Assert.Equal(3u, shared.InstanceCount);
+
+ OrderedDrawCommand first = MakeCommand(0) with
+ {
+ Transform = Matrix4x4.CreateTranslation(-0.5f, 1.25f, 2.5f),
+ ClipSlot = 11u,
+ Lights = new WbDrawDispatcher.InstanceLightSet(0, 2, 4, 6, -1, -1, -1, -1),
+ IndoorFlag = 0u,
+ Alpha = 0.25f,
+ SelectionLighting = new Vector2(0.125f, 0.375f),
+ DetailCategory = 7u,
+ };
+ OrderedDrawCommand second = MakeCommand(1) with
+ {
+ Transform = Matrix4x4.CreateTranslation(0.75f, -1.5f, 3.25f),
+ ClipSlot = 22u,
+ Lights = new WbDrawDispatcher.InstanceLightSet(1, 3, 5, 7, -1, -1, -1, -1),
+ IndoorFlag = 1u,
+ Alpha = 0.75f,
+ SelectionLighting = new Vector2(0.625f, 0.875f),
+ DetailCategory = 9u,
+ };
+ OrderedDrawStream stream = StreamOf(first, second);
+
+ PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
+
+ Assert.Contains(
+ fx.Device.Calls.OfType(),
+ call => fx.Device.CreatedPipelines.Single(
+ pipeline => pipeline.Description.Name == call.PipelineName)
+ .Description.Shaders.Name == expectedShader);
+
+ GpuRecordedMultiDrawIndirect[] drawCalls =
+ fx.Device.Calls.OfType().ToArray();
+ Assert.Equal(2, drawCalls.Length);
+ Assert.All(drawCalls, call => Assert.Equal(1u, call.DrawCount));
+ Assert.Equal(drawCalls[0].OffsetBytes + drawCalls[0].StrideBytes, drawCalls[1].OffsetBytes);
+ GpuRecordedMultiDrawIndirect drawCall = drawCalls[0];
+ ReadOnlySpan commands = MemoryMarshal.Cast(
+ fx.Device.RingBytes.Slice((int)drawCall.OffsetBytes, checked((int)drawCall.StrideBytes * 2)));
+ Assert.Equal([3u, 4u], commands.ToArray().Select(command => command.BaseInstance).ToArray());
+
+ GpuRecordedPushConstants pushed = fx.Device.Calls
+ .TakeWhile(call => !ReferenceEquals(call, drawCall))
+ .OfType()
+ .Last();
+ Assert.Equal(3u, pushed.Constants.TextureIndexB);
+
+ AssertLocalSection(GpuBindingModel.StorageClipSlots, [11u, 22u]);
+ AssertLocalSection(
+ GpuBindingModel.StorageInstanceLightSets,
+ [0, 2, 4, 6, -1, -1, -1, -1, 1, 3, 5, 7, -1, -1, -1, -1]);
+ AssertLocalSection(GpuBindingModel.StorageInstanceIndoor, [0u, 1u]);
+ AssertLocalSection(GpuBindingModel.StorageInstanceAlpha, [0.25f, 0.75f]);
+ AssertLocalSection(
+ GpuBindingModel.StorageInstanceSelectionLighting,
+ [new Vector2(0.125f, 0.375f), new Vector2(0.625f, 0.875f)]);
+ AssertLocalSection(GpuBindingModel.StorageInstanceDetailCategory, [7u, 9u]);
+ Assert.Equal(
+ 6,
+ new uint[] { 3, 5, 6, 7, 8, 9 }
+ .Select(binding => LastBind(binding).OffsetBytes)
+ .Distinct()
+ .Count());
+
+ GpuRecordedStorageBind transformBind = LastBind(GpuBindingModel.StorageInstances);
+ Assert.Equal(shared.BaseOffsetBytes, transformBind.OffsetBytes);
+ ReadOnlySpan transforms = MemoryMarshal.Cast(
+ fx.Device.RingBytes.Slice(
+ (int)transformBind.OffsetBytes,
+ checked((int)(5u * WorldTransformCapacityPolicy.MatrixBytes))));
+ Assert.Equal(first.Transform, transforms[3]);
+ Assert.Equal(second.Transform, transforms[4]);
+
+ void AssertLocalSection(uint binding, T[] expected) where T : unmanaged
+ {
+ GpuRecordedStorageBind bound = LastBind(binding);
+ int byteCount = checked(expected.Length * Marshal.SizeOf());
+ Assert.Equal((uint)byteCount, bound.SizeBytes);
+ Assert.Equal(
+ expected,
+ MemoryMarshal.Cast(
+ fx.Device.RingBytes.Slice((int)bound.OffsetBytes, byteCount)).ToArray());
+ }
+
+ GpuRecordedStorageBind LastBind(uint binding) => fx.Device.Calls
+ .TakeWhile(call => !ReferenceEquals(call, drawCall))
+ .OfType()
+ .Last(call => call.Binding == binding);
+ }
+
private static List<(int Start, int Count)> DecodeDrawRanges(RecordingGpuDevice device) =>
[.. DecodeRuns(device).Select(r => (r.Start, r.Count))];
@@ -778,6 +890,7 @@ public sealed class OrderPreservingSubmitterTests
bool detailAvailable = false,
bool detailEnabled = false,
bool atmospheric = false,
+ bool receiverBindingAvailable = true,
int sampleCount = 1,
RecordingGpuDevice? device = null)
{
@@ -812,7 +925,7 @@ public sealed class OrderPreservingSubmitterTests
buildingDetailEnabled: () => detailEnabled);
if (atmospheric)
{
- var source = new BindableAtmosphericSource();
+ var source = new BindableAtmosphericSource(receiverBindingAvailable);
WbDrawDispatcher.DirectionalShadowReceiverPipelineState candidate =
Assert.IsType(
Dispatcher.PrepareDirectionalShadowReceiver(source, sampleCount));
@@ -859,7 +972,7 @@ public sealed class OrderPreservingSubmitterTests
}
}
- private sealed class BindableAtmosphericSource : IDirectionalShadowReceiverSource
+ private sealed class BindableAtmosphericSource(bool bindingAvailable) : IDirectionalShadowReceiverSource
{
public DirectionalShadowPipelineShaders PipelineShaders =>
DirectionalShadowPipelineShaders.Local;
@@ -868,6 +981,11 @@ public sealed class OrderPreservingSubmitterTests
IGpuFrame frame,
out DirectionalShadowFrameBinding binding)
{
+ if (!bindingAvailable)
+ {
+ binding = DirectionalShadowFrameBinding.Disabled;
+ return false;
+ }
GpuRingAllocation allocation = frame.AllocateRing(
checked((int)DirectionalShadowUniforms.SizeInBytes),
GpuRingUsage.Uniform);