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).
This commit is contained in:
Erik 2026-09-05 02:36:16 +02:00
parent 56837f547f
commit 15a796c3a1
9 changed files with 843 additions and 39 deletions

View file

@ -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.

View file

@ -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]

View file

@ -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
},
{

View file

@ -5,6 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

View file

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

View file

@ -0,0 +1,214 @@
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// 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.
/// </summary>
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<uint, Dictionary<uint, uint>> _decorations = [];
private readonly Dictionary<uint, uint> _constants = [];
private readonly Dictionary<uint, Instruction> _results = [];
private Spirv()
{
}
internal Dictionary<uint, uint> Variables { get; } = [];
internal List<Instruction> 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<uint, uint>? 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<uint, uint>? values)
&& values.TryGetValue(decoration, out uint value)
? value
: null;
internal uint? Constant(uint id) => _constants.TryGetValue(id, out uint value) ? value : null;
internal IEnumerable<Instruction> RootAccessChains(uint set, uint binding)
{
HashSet<uint> 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<uint>();
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<uint> 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.");
}
}

View file

@ -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.

View file

@ -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<GpuRecordedPipelineBind>(),
call => fx.Device.CreatedPipelines.Single(
pipeline => pipeline.Description.Name == call.PipelineName)
.Description.Shaders.Name == expectedShader);
GpuRecordedMultiDrawIndirect[] drawCalls =
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().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<DrawElementsIndirectCommand> commands = MemoryMarshal.Cast<byte, DrawElementsIndirectCommand>(
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<GpuRecordedPushConstants>()
.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<Matrix4x4> transforms = MemoryMarshal.Cast<byte, Matrix4x4>(
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<T>(uint binding, T[] expected) where T : unmanaged
{
GpuRecordedStorageBind bound = LastBind(binding);
int byteCount = checked(expected.Length * Marshal.SizeOf<T>());
Assert.Equal((uint)byteCount, bound.SizeBytes);
Assert.Equal(
expected,
MemoryMarshal.Cast<byte, T>(
fx.Device.RingBytes.Slice((int)bound.OffsetBytes, byteCount)).ToArray());
}
GpuRecordedStorageBind LastBind(uint binding) => fx.Device.Calls
.TakeWhile(call => !ReferenceEquals(call, drawCall))
.OfType<GpuRecordedStorageBind>()
.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<WbDrawDispatcher.DirectionalShadowReceiverPipelineState>(
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);