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:
parent
56837f547f
commit
15a796c3a1
9 changed files with 843 additions and 39 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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, ©);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue