using Silk.NET.Core.Native;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
///
/// Campaign V slice V6c, plan §4.5: on Vulkan.
///
/// 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
/// not dynamic, so they are what actually define the pipeline list —
/// roughly a dozen objects, all known statically and all built at startup.
///
/// No render pass or framebuffer object appears anywhere: the attachment
/// formats are declared inline through VK_KHR_dynamic_rendering, which is
/// core in 1.3. That is what lets a pass be described by
/// alone rather than by an object that has to be
/// created, cached and matched.
///
/// Two variants, selected at bind time (slice V6g). Dynamic
/// rendering bakes the depth/stencil attachment FORMAT into the pipeline, and it
/// must equal the format of the pass the pipeline draws in — UNDEFINED
/// 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
/// VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08914/-08917.
///
/// The same is legitimately used in
/// both kinds of pass — ui-text 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
/// 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.
///
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; }
///
/// 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 vkCmdBeginRendering was handed a depth image
/// view, not from what the pass description asked for.
///
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);
});
}
}
///
/// Campaign V slice V6c, plan §4.5: the persisted VkPipelineCache.
///
/// Every pipeline is built at startup, which on a cold cache costs a few
/// hundred milliseconds once. Persisting the cache to
/// ApplicationPathSet.CacheDirectory turns every later launch into
/// milliseconds — and, unlike GL, no frame ever pays a hidden first-draw driver
/// recompile.
///
/// 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 vkCreatePipelineCache. 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.
///
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; }
/// True when a compatible cache blob was found and reused.
internal bool LoadedFromDisk { get; }
///
/// 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.
///
internal static byte[]? ValidateHeader(
byte[]? blob,
uint vendorId,
uint deviceId,
ReadOnlySpan 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 pipelineCacheUuid)
{
try
{
if (!File.Exists(path))
return null;
return ValidateHeader(File.ReadAllBytes(path), vendorId, deviceId, pipelineCacheUuid);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
///
/// 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.
///
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);
}
}