acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs
Erik b1ad1d481b feat(render): Campaign V slice V6l commit 1 - particles draw on Vulkan
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.

The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.

GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.

Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.

The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.

The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.

The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.

Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:20:59 +02:00

462 lines
19 KiB
C#

using Silk.NET.Core.Native;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c, plan §4.5: <see cref="IGpuPipeline"/> on Vulkan.
///
/// <para>Core 1.3 dynamic state covers viewport, scissor, cull mode, front face,
/// depth test/write/compare and topology class, which folds the GL pass matrix's
/// per-draw toggles into command-time calls. Blend and alpha-to-coverage are
/// <em>not</em> dynamic, so they are what actually define the pipeline list —
/// roughly a dozen objects, all known statically and all built at startup.</para>
///
/// <para>No render pass or framebuffer object appears anywhere: the attachment
/// formats are declared inline through <c>VK_KHR_dynamic_rendering</c>, which is
/// core in 1.3. That is what lets a pass be described by
/// <see cref="GpuPassDescription"/> alone rather than by an object that has to be
/// created, cached and matched.</para>
///
/// <para><b>Two variants, selected at bind time (slice V6g).</b> Dynamic
/// rendering bakes the depth/stencil attachment FORMAT into the pipeline, and it
/// must equal the format of the pass the pipeline draws in — <c>UNDEFINED</c>
/// when the pass has no depth attachment, the real format when it has one. V6c
/// declared the format only when the pipeline itself tested or wrote depth,
/// which made every depth-off pipeline malformed the moment it drew inside a
/// depth-carrying pass. That is not an edge case: debug lines, the retained UI
/// and the sky are all depth-off and all draw inside the main pass, and plan
/// §5.5.7 recorded it firing as
/// <c>VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08914/-08917</c>.</para>
///
/// <para>The same <see cref="GpuPipelineDescription"/> is legitimately used in
/// both kinds of pass — <c>ui-text</c> opens a depth-less pass of its own, and
/// the world pass it composites over has depth — so the description cannot
/// answer the question and the backend builds both. The contract could grow a
/// depth-format field the way it grew <see cref="GpuPipelineDescription.ColorFormat"/>
/// at V6d; until a slice is entitled to change the contract, materialising both
/// is the honest expression of the gap. Both are built at startup against the
/// persisted cache, so no frame ever compiles one.</para>
/// </summary>
internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
{
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly IGpuResourceRetirementQueue _retirement;
private readonly Pipeline _withDepthAttachment;
private readonly Pipeline _withoutDepthAttachment;
private bool _disposed;
internal VulkanGpuPipeline(
Silk.NET.Vulkan.Vk vk,
Device device,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
PipelineLayout layout,
PipelineCache cache,
ShaderModule vertexModule,
ShaderModule fragmentModule,
GpuPipelineDescription description,
Format colorFormat,
Format depthStencilFormat)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Description = description ?? throw new ArgumentNullException(nameof(description));
nint entryPoint = SilkMarshal.StringToPtr("main");
try
{
PipelineShaderStageCreateInfo* stages = stackalloc PipelineShaderStageCreateInfo[2];
stages[0] = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.VertexBit,
Module = vertexModule,
PName = (byte*)entryPoint,
};
stages[1] = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.FragmentBit,
Module = fragmentModule,
PName = (byte*)entryPoint,
};
GpuVertexLayout vertexLayout = description.VertexLayout;
// Slice V6l: one VkVertexInputBindingDescription per declared
// binding, each carrying its own stride and input rate. A per-instance
// binding is what both particle pipelines are built on, and it costs
// nothing here beyond naming it.
int bindingCount = vertexLayout.Bindings.Length;
VertexInputBindingDescription* bindings =
stackalloc VertexInputBindingDescription[Math.Max(1, bindingCount)];
for (int i = 0; i < bindingCount; i++)
{
GpuVertexBinding declared = vertexLayout.Bindings[i];
bindings[i] = new VertexInputBindingDescription
{
Binding = declared.Binding,
Stride = declared.StrideBytes,
InputRate = declared.InputRate == GpuVertexInputRate.Instance
? VertexInputRate.Instance
: VertexInputRate.Vertex,
};
}
int attributeCount = vertexLayout.Attributes.Length;
VertexInputAttributeDescription* attributes =
stackalloc VertexInputAttributeDescription[Math.Max(1, attributeCount)];
for (int i = 0; i < attributeCount; i++)
{
GpuVertexAttribute attribute = vertexLayout.Attributes[i];
attributes[i] = new VertexInputAttributeDescription
{
Location = attribute.Location,
Binding = attribute.Binding,
Format = VulkanViewportMapping.ToVulkan(attribute.Format),
Offset = attribute.OffsetBytes,
};
}
var vertexInput = new PipelineVertexInputStateCreateInfo
{
SType = StructureType.PipelineVertexInputStateCreateInfo,
VertexBindingDescriptionCount = (uint)bindingCount,
PVertexBindingDescriptions = bindingCount == 0 ? null : bindings,
VertexAttributeDescriptionCount = (uint)attributeCount,
PVertexAttributeDescriptions = attributeCount == 0 ? null : attributes,
};
var assembly = new PipelineInputAssemblyStateCreateInfo
{
SType = StructureType.PipelineInputAssemblyStateCreateInfo,
Topology = VulkanViewportMapping.ToVulkan(description.Topology),
PrimitiveRestartEnable = false,
};
var viewport = new PipelineViewportStateCreateInfo
{
SType = StructureType.PipelineViewportStateCreateInfo,
ViewportCount = 1,
ScissorCount = 1,
};
var rasterization = new PipelineRasterizationStateCreateInfo
{
SType = StructureType.PipelineRasterizationStateCreateInfo,
PolygonMode = PolygonMode.Fill,
LineWidth = 1f,
CullMode = VulkanViewportMapping.ToVulkan(description.Cull),
// The single inversion that pairs with the negative viewport
// height. See VulkanViewportMapping.
FrontFace = VulkanViewportMapping.ToVulkan(description.FrontFace),
DepthClampEnable = false,
RasterizerDiscardEnable = false,
DepthBiasEnable = false,
};
var multisample = new PipelineMultisampleStateCreateInfo
{
SType = StructureType.PipelineMultisampleStateCreateInfo,
RasterizationSamples = VulkanTextureFormatMapping.SampleCountOf(description.SampleCount),
SampleShadingEnable = false,
// Alpha-to-coverage is only meaningful multisampled; the backend
// ignores it at one sample exactly as the contract says.
AlphaToCoverageEnable = description.AlphaToCoverage && description.SampleCount > 1,
};
var depthStencil = new PipelineDepthStencilStateCreateInfo
{
SType = StructureType.PipelineDepthStencilStateCreateInfo,
DepthTestEnable = description.Depth.Test,
DepthWriteEnable = description.Depth.Write,
DepthCompareOp = VulkanViewportMapping.ToVulkan(description.Depth.Compare),
DepthBoundsTestEnable = false,
StencilTestEnable = false,
};
(BlendFactor source, BlendFactor destination) =
VulkanViewportMapping.BlendFactorsOf(description.Blend);
var attachment = new PipelineColorBlendAttachmentState
{
BlendEnable = description.Blend != GpuBlendMode.None,
SrcColorBlendFactor = source,
DstColorBlendFactor = destination,
ColorBlendOp = BlendOp.Add,
// Alpha follows colour, matching glBlendFunc's single-function
// form which is all the GL pass matrix ever sets.
SrcAlphaBlendFactor = source,
DstAlphaBlendFactor = destination,
AlphaBlendOp = BlendOp.Add,
ColorWriteMask = description.ColorWrite
? ColorComponentFlags.RBit | ColorComponentFlags.GBit
| ColorComponentFlags.BBit | ColorComponentFlags.ABit
: 0,
};
var blend = new PipelineColorBlendStateCreateInfo
{
SType = StructureType.PipelineColorBlendStateCreateInfo,
LogicOpEnable = false,
AttachmentCount = 1,
PAttachments = &attachment,
};
DynamicState* dynamicStates = stackalloc DynamicState[5];
dynamicStates[0] = DynamicState.Viewport;
dynamicStates[1] = DynamicState.Scissor;
dynamicStates[2] = DynamicState.CullMode;
dynamicStates[3] = DynamicState.FrontFace;
dynamicStates[4] = DynamicState.DepthWriteEnable;
var dynamic = new PipelineDynamicStateCreateInfo
{
SType = StructureType.PipelineDynamicStateCreateInfo,
DynamicStateCount = 5,
PDynamicStates = dynamicStates,
};
Format color = colorFormat;
var rendering = new PipelineRenderingCreateInfo
{
SType = StructureType.PipelineRenderingCreateInfo,
ColorAttachmentCount = 1,
PColorAttachmentFormats = &color,
DepthAttachmentFormat = depthStencilFormat,
StencilAttachmentFormat = depthStencilFormat,
};
var create = new GraphicsPipelineCreateInfo
{
SType = StructureType.GraphicsPipelineCreateInfo,
PNext = &rendering,
StageCount = 2,
PStages = stages,
PVertexInputState = &vertexInput,
PInputAssemblyState = &assembly,
PViewportState = &viewport,
PRasterizationState = &rasterization,
PMultisampleState = &multisample,
PDepthStencilState = &depthStencil,
PColorBlendState = &blend,
PDynamicState = &dynamic,
Layout = layout,
// No RenderPass: dynamic rendering declares the formats inline.
RenderPass = default,
Subpass = 0,
};
VulkanInterop.Check(
_vk.CreateGraphicsPipelines(_device, cache, 1, &create, null, out Pipeline withDepth),
$"vkCreateGraphicsPipelines ('{description.Name}', depth attachment)");
_withDepthAttachment = withDepth;
debugNames.NamePipeline(withDepth, description.Name);
rendering.DepthAttachmentFormat = Format.Undefined;
rendering.StencilAttachmentFormat = Format.Undefined;
try
{
VulkanInterop.Check(
_vk.CreateGraphicsPipelines(_device, cache, 1, &create, null, out Pipeline withoutDepth),
$"vkCreateGraphicsPipelines ('{description.Name}', no depth attachment)");
_withoutDepthAttachment = withoutDepth;
debugNames.NamePipeline(withoutDepth, $"{description.Name}-nodepth");
}
catch
{
_vk.DestroyPipeline(_device, withDepth, null);
throw;
}
}
finally
{
SilkMarshal.Free(entryPoint);
}
}
public GpuPipelineDescription Description { get; }
/// <summary>
/// The variant whose declared depth/stencil format matches the open pass.
/// Binding the wrong one is undefined behaviour that only a validation layer
/// reports, which is why the caller is never allowed to guess: the value
/// comes from whether <c>vkCmdBeginRendering</c> was handed a depth image
/// view, not from what the pass description asked for.
/// </summary>
internal Pipeline HandleFor(bool passHasDepthAttachment) =>
passHasDepthAttachment ? _withDepthAttachment : _withoutDepthAttachment;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Pipeline withDepth = _withDepthAttachment;
Pipeline withoutDepth = _withoutDepthAttachment;
_retirement.Retire(() =>
{
_vk.DestroyPipeline(_device, withDepth, null);
_vk.DestroyPipeline(_device, withoutDepth, null);
});
}
}
/// <summary>
/// Campaign V slice V6c, plan §4.5: the persisted <c>VkPipelineCache</c>.
///
/// <para>Every pipeline is built at startup, which on a cold cache costs a few
/// hundred milliseconds once. Persisting the cache to
/// <c>ApplicationPathSet.CacheDirectory</c> turns every later launch into
/// milliseconds — and, unlike GL, no frame ever pays a hidden first-draw driver
/// recompile.</para>
///
/// <para>The blob is validated by its header before use: a driver update, a GPU
/// change or a truncated write must be treated as a cold cache rather than fed
/// to <c>vkCreatePipelineCache</c>. Drivers are required to ignore incompatible
/// data, but "required to" is a poor foundation for something that runs before
/// anything else in the process, and checking the vendor/device/UUID ourselves
/// costs 32 bytes of comparison.</para>
/// </summary>
internal sealed unsafe class VulkanPipelineCache : IDisposable
{
private const uint HeaderLengthBytes = 32;
private const uint HeaderVersionOne = 1;
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly string? _path;
private bool _disposed;
internal VulkanPipelineCache(
Silk.NET.Vulkan.Vk vk,
PhysicalDevice physicalDevice,
Device device,
string? cacheDirectory)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
vk.GetPhysicalDeviceProperties(physicalDevice, out PhysicalDeviceProperties properties);
byte[] pipelineCacheUuid = new byte[16];
for (int i = 0; i < 16; i++)
pipelineCacheUuid[i] = properties.PipelineCacheUuid[i];
byte[]? initial = null;
if (!string.IsNullOrWhiteSpace(cacheDirectory))
{
_path = Path.Combine(cacheDirectory, "vulkan-pipeline-cache.bin");
initial = TryReadCompatible(_path, properties.VendorID, properties.DeviceID, pipelineCacheUuid);
}
LoadedFromDisk = initial is not null;
fixed (byte* data = initial)
{
var create = new PipelineCacheCreateInfo
{
SType = StructureType.PipelineCacheCreateInfo,
InitialDataSize = (nuint)(initial?.Length ?? 0),
PInitialData = initial is null ? null : data,
};
VulkanInterop.Check(
_vk.CreatePipelineCache(_device, &create, null, out PipelineCache cache),
"vkCreatePipelineCache");
Handle = cache;
}
}
internal PipelineCache Handle { get; }
/// <summary>True when a compatible cache blob was found and reused.</summary>
internal bool LoadedFromDisk { get; }
/// <summary>
/// Validates a cache blob's 32-byte header against this device. Returns null
/// for anything that is not a byte-for-byte match, which is the honest
/// answer for a driver update as much as for a corrupt file.
/// </summary>
internal static byte[]? ValidateHeader(
byte[]? blob,
uint vendorId,
uint deviceId,
ReadOnlySpan<byte> pipelineCacheUuid)
{
if (blob is null || blob.Length < HeaderLengthBytes)
return null;
uint length = BitConverter.ToUInt32(blob, 0);
uint version = BitConverter.ToUInt32(blob, 4);
uint blobVendor = BitConverter.ToUInt32(blob, 8);
uint blobDevice = BitConverter.ToUInt32(blob, 12);
if (length != HeaderLengthBytes || version != HeaderVersionOne)
return null;
if (blobVendor != vendorId || blobDevice != deviceId)
return null;
if (!blob.AsSpan(16, 16).SequenceEqual(pipelineCacheUuid))
return null;
return blob;
}
private static byte[]? TryReadCompatible(
string path,
uint vendorId,
uint deviceId,
ReadOnlySpan<byte> pipelineCacheUuid)
{
try
{
if (!File.Exists(path))
return null;
return ValidateHeader(File.ReadAllBytes(path), vendorId, deviceId, pipelineCacheUuid);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Writes the cache back. Failures are swallowed with intent: a cache that
/// cannot be saved costs a few hundred milliseconds at the next launch and
/// nothing else, so it must never take the process down.
/// </summary>
internal void Save()
{
if (_disposed || _path is null)
return;
try
{
nuint size = 0;
if (_vk.GetPipelineCacheData(_device, Handle, ref size, null) != Result.Success || size == 0)
return;
var data = new byte[(int)size];
fixed (byte* first = data)
{
if (_vk.GetPipelineCacheData(_device, Handle, ref size, first) != Result.Success)
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
string temporary = _path + ".tmp";
File.WriteAllBytes(temporary, data);
File.Move(temporary, _path, overwrite: true);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
public void Dispose()
{
if (_disposed)
return;
Save();
_disposed = true;
if (Handle.Handle != 0)
_vk.DestroyPipelineCache(_device, Handle, null);
}
}