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);