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