From 15a796c3a128d96d21cd8c80b59994461c161194 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 5 Sep 2026 02:36:16 +0200 Subject: [PATCH] fix(rendering): split ordinary transform and sidecar indices Use the shared absolute base-instance domain only for mesh transforms and subtract the published transform prefix for every live ordinary sidecar. Regenerate the production module and pin its hash. Add real ordered-recording, committed-SPIR-V structure, and headless production-Vulkan pixel witnesses at nonzero prefixes, including receiver active/inactive invariance. Mutations performed and restored: 1. Transform lookup -> local instanceIndex: MeshModernSharedIndexSpirvTests first failed Assert.Contains, item 368 not found in [27,377,27] (and the pixel witness found 0 dark pixels). 2. Selection sidecar -> absolute transformIndex: MeshModernSharedIndexOffscreenTests first failed: Expected a dark local-sidecar instance, found 0 matching pixels. 3. Published TextureIndexB -> 0: SharedTransformPrefix recording first failed Assert.Equal, expected 3, actual 0. 4. Receiver choice inverted: inactive first failed because expected mesh_modern was absent and only mesh_atmospheric was recorded; active failed conversely. 5. Offscreen shader directory -> copied test output: witness first failed the exact-path Assert.Equal (expected repo src/AcDream.App/Rendering/Shaders/spv, actual tests/AcDream.App.Tests/bin/Release/net10.0/Rendering/Shaders/spv). --- .../Rendering/Shaders/mesh_atmospheric.vert | 33 +- .../Rendering/Shaders/mesh_modern.vert | 44 +- .../Shaders/spv/mesh_modern.vert.spv | Bin 9232 -> 9336 bytes .../Shaders/spv/shaders.manifest.json | 4 +- .../AcDream.App.Tests.csproj | 1 + .../Vk/MeshModernSharedIndexOffscreenTests.cs | 462 ++++++++++++++++++ .../Gpu/Vk/MeshModernSharedIndexSpirvTests.cs | 214 ++++++++ .../Gpu/Vk/VulkanShaderManifestTests.cs | 2 +- .../Walk/OrderPreservingSubmitterTests.cs | 122 ++++- 9 files changed, 843 insertions(+), 39 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexSpirvTests.cs 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 c48d139946b43ef835c4e3e5ad0594f8d3b224c7..7ec7b978da1c65ac537f093f61f1429536e38e0a 100644 GIT binary patch literal 9336 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 literal 9232 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 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);