From b1ad1d481baf4342396a661d3a3b00959054edd5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 17:20:59 +0200 Subject: [PATCH] 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 --- .../LivePresentationComposition.cs | 22 +- .../Rendering/DebugLineRenderer.cs | 10 +- .../Rendering/Gpu/Gl/GlEnumMapping.cs | 2 + .../Rendering/Gpu/Gl/GlGpuPassEncoder.cs | 14 +- .../Rendering/Gpu/Gl/GlGpuPipeline.cs | 14 +- .../Rendering/Gpu/GpuPipelineDescription.cs | 125 +++- .../Rendering/Gpu/IGpuPassEncoder.cs | 11 +- .../Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs | 4 +- .../Rendering/Gpu/Vk/VulkanGpuPipeline.cs | 30 +- .../Rendering/Gpu/Vk/VulkanRhiScene.cs | 2 +- .../Rendering/Gpu/Vk/VulkanViewportMapping.cs | 2 + .../Rendering/ParticleRenderer.Rhi.cs | 706 ++++++++++++++++++ src/AcDream.App/Rendering/ParticleRenderer.cs | 55 +- .../Rendering/Sky/SkyRenderer.Rhi.cs | 6 +- .../StandaloneBindlessTextureCache.cs | 43 +- .../Rendering/TerrainModernRenderer.Rhi.cs | 6 +- src/AcDream.App/Rendering/TextRenderer.cs | 10 +- src/AcDream.App/Rendering/TextureCache.cs | 110 ++- .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 1 + .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 1 + .../Rendering/Gpu/GpuContractTests.cs | 64 ++ .../Rendering/Gpu/RecordingGpuDevice.cs | 6 +- .../Rendering/RhiVertexLayoutStrideTests.cs | 180 +++++ 23 files changed, 1360 insertions(+), 64 deletions(-) create mode 100644 src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 97757dee..772252cf 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1070,11 +1070,15 @@ internal sealed class LivePresentationCompositionPhase content.Dats, foundation.TextureCache), static value => value.Dispose()); + // Campaign V slice V6l: particles exist on BOTH arms. The GL arm is + // unchanged; the RHI arm compiles the two particle pairs from SPIR-V, + // draws instances through the vertex binding the V6l contract amendment + // added, and records into the pass the world scene phase publishes on + // this scope. var particleLease = scope.AcquireOptional( "particle renderer", - () => gl is null - ? null - : new ParticleRenderer( + () => gl is not null + ? new ParticleRenderer( gl, foundation.ShadersDirectory, content.ParticleSystem, @@ -1082,6 +1086,18 @@ internal sealed class LivePresentationCompositionPhase content.Dats, foundation.MeshAdapter!, d.RetailAlphaQueue, + alphaScratchBudgets.ParticleBytes) + : new ParticleRenderer( + host.GpuDevice, + host.GpuFrameLifetime, + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope."), + content.ParticleSystem, + foundation.TextureCache, + content.Dats, + foundation.MeshAdapter!, + d.RetailAlphaQueue, alphaScratchBudgets.ParticleBytes), static value => value.Dispose()); Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated); diff --git a/src/AcDream.App/Rendering/DebugLineRenderer.cs b/src/AcDream.App/Rendering/DebugLineRenderer.cs index 1bce691c..ef4a7ddb 100644 --- a/src/AcDream.App/Rendering/DebugLineRenderer.cs +++ b/src/AcDream.App/Rendering/DebugLineRenderer.cs @@ -31,11 +31,13 @@ namespace AcDream.App.Rendering; /// public sealed class DebugLineRenderer : IDisposable { - private const int FloatsPerVertex = 6; + // internal: slice V6l's stride-equals-the-uploaded-record gate asserts the + // layout against the producer's own float count rather than a literal. + internal const int FloatsPerVertex = 6; private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); - private static readonly GpuVertexLayout VertexLayout = new( - StrideBytes: VertexStrideBytes, + internal static readonly GpuVertexLayout VertexLayout = GpuVertexLayout.Interleaved( + strideBytes: VertexStrideBytes, [ new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), @@ -187,7 +189,7 @@ public sealed class DebugLineRenderer : IDisposable int byteCount = _buffer.Count * sizeof(float); GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex); System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_buffer).CopyTo(allocation.AsSpan()); - encoder.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes); + encoder.BindVertexBuffer(0, allocation.Buffer, allocation.OffsetBytes); encoder.Draw((uint)_vertexCount, 1, 0, 0); } diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs index 997f513a..fe13f053 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs @@ -34,6 +34,8 @@ internal static class GlEnumMapping // Integer attributes carry Integer = true; the encoder must route them // through glVertexAttribIPointer, not the normalized float path. GpuVertexFormat.UByte4UInt => new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, false, Integer: true), + // Slice V6l: particle.vert's per-instance `in uint aTextureIndex`. + GpuVertexFormat.UInt1 => new GlVertexAttributeShape(1, VertexAttribPointerType.UnsignedInt, false, Integer: true), _ => throw new NotSupportedException($"No GL vertex attribute shape for {format}."), }; diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs index cd9fe32a..4b311cae 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs @@ -126,7 +126,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder GLHelpers.ThrowOnResourceError(_gl, $"bind uniform buffer '{buffer.Name}' at binding {binding}"); } - public unsafe void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes) + public unsafe void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes) { ThrowIfClosed(); if (_currentPipeline is not { } pipeline) @@ -135,8 +135,16 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder _gl.BindBuffer(GLEnum.ArrayBuffer, b.GlName); GpuVertexLayout layout = pipeline.Description.VertexLayout; + // Slice V6l: only the attributes this binding actually supplies. GL has + // no binding indirection of its own — glVertexAttribPointer records the + // currently bound ARRAY_BUFFER per attribute — so the binding index is + // resolved here, by filtering, rather than by the driver. + uint stride = layout.StrideOf(binding); foreach (GpuVertexAttribute attribute in layout.Attributes) { + if (attribute.Binding != binding) + continue; + GlVertexAttributeShape shape = GlEnumMapping.VertexShapeOf(attribute.Format); nint attributeOffset = (nint)(offsetBytes + attribute.OffsetBytes); if (shape.Integer) @@ -147,7 +155,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder attribute.Location, shape.ComponentCount, (VertexAttribIType)shape.Type, - layout.StrideBytes, + stride, (void*)attributeOffset); } else @@ -157,7 +165,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder shape.ComponentCount, shape.Type, shape.Normalized, - layout.StrideBytes, + stride, (void*)attributeOffset); } } diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs index 8ddb347a..60106a0e 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs @@ -39,8 +39,20 @@ internal sealed class GlGpuPipeline : IGpuPipeline _gl.GenVertexArray, _gl.DeleteVertexArray); _gl.BindVertexArray(_vertexArray); - foreach (GpuVertexAttribute attribute in description.VertexLayout.Attributes) + GpuVertexLayout layout = description.VertexLayout; + foreach (GpuVertexAttribute attribute in layout.Attributes) + { _gl.EnableVertexAttribArray(attribute.Location); + // Slice V6l: the divisor is VAO state and survives every later + // glVertexAttribPointer, so it belongs here with the enables + // rather than in the per-frame rebind. Zero is the GL default and + // is restated explicitly — a VAO name can be recycled by the + // driver, and inheriting a stale divisor draws one instance's + // data across every vertex. + _gl.VertexAttribDivisor( + attribute.Location, + layout.InputRateOf(attribute.Binding) == GpuVertexInputRate.Instance ? 1u : 0u); + } GLHelpers.ThrowOnResourceError(_gl, $"configure pipeline '{description.Name}' VAO"); _gl.BindVertexArray(0); } diff --git a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs index 818def01..7422a230 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs @@ -28,24 +28,135 @@ internal enum GpuVertexFormat /// it would be garbage. /// UByte4UInt, + + /// + /// One unsigned 32-bit integer — a shader uint input. + /// + /// Added at slice V6l with the instanced-vertex-input amendment, and + /// necessary to it: particle.vert declares + /// layout(location = 6) in uint aTextureIndex, the per-instance + /// texture-table slot, and the amendment's premise is that no shader is + /// edited. Same kind-distinction as — GL needs + /// glVertexAttribIPointer and Vulkan needs R32_UINT; the float + /// path would deliver the value's bits reinterpreted rather than a scaled + /// approximation of it. + /// + UInt1, } -/// One vertex attribute, matching a layout(location = N) in declaration. +/// +/// How often a vertex binding advances. +/// +/// Added at slice V6l. Both particle pipelines draw with PER-INSTANCE +/// vertex attributes — particle at locations 2–6 (centre, two sheet axes, +/// colour, texture slot) and particle_mesh at 3–7 (a mat4 model and +/// a colour) — and the V0 contract could express instanced DRAWING but not +/// instanced vertex INPUT, which blocked V4e (plan §5.5.16). Both backends carry +/// this natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE and +/// glVertexAttribDivisor. +/// +internal enum GpuVertexInputRate +{ + /// The binding advances once per vertex — the default for every layout written before V6l. + Vertex, + + /// The binding advances once per instance (GL divisor 1). + Instance, +} + +/// +/// One bound vertex buffer's shape: which binding index it occupies, how many +/// bytes one element occupies, and how often it advances. +/// +/// Slice V6l. Before it, a layout had exactly one stride and one implicit +/// binding 0 at vertex rate; that is now the +/// case rather than the only case. +/// +internal readonly record struct GpuVertexBinding( + uint Binding, + uint StrideBytes, + GpuVertexInputRate InputRate = GpuVertexInputRate.Vertex); + +/// +/// One vertex attribute, matching a layout(location = N) in declaration. +/// names which supplies +/// it and defaults to 0, so every layout written before slice V6l keeps its +/// meaning unchanged. +/// internal readonly record struct GpuVertexAttribute( uint Location, GpuVertexFormat Format, - uint OffsetBytes); + uint OffsetBytes, + uint Binding = 0); -/// Interleaved vertex layout for a single bound vertex buffer. -internal sealed record GpuVertexLayout(uint StrideBytes, ImmutableArray Attributes) +/// +/// Vertex input layout: the bound buffers and the attributes they feed. +/// +/// Slice V6l grew this from one stride to a list of bindings. The overwhelmingly +/// common case is still one interleaved vertex-rate buffer, which +/// spells in one line and which every layout in the +/// tree before V6l uses. +/// +internal sealed record GpuVertexLayout( + ImmutableArray Bindings, + ImmutableArray Attributes) { + /// + /// One interleaved vertex-rate buffer at binding 0 — the shape of every + /// layout the campaign wrote before slice V6l. + /// + public static GpuVertexLayout Interleaved( + uint strideBytes, + ImmutableArray attributes) => + new( + [new GpuVertexBinding(0, strideBytes, GpuVertexInputRate.Vertex)], + attributes); + + /// + /// Stride of binding 0. Kept because it is what an interleaved layout means + /// and what every single-binding consumer asks for; multi-binding consumers + /// use . + /// + public uint StrideBytes => + Bindings.IsDefaultOrEmpty ? 0u : Bindings[0].StrideBytes; + + /// Stride of , or a composition error if it is not declared. + public uint StrideOf(uint binding) + { + foreach (GpuVertexBinding candidate in Bindings) + { + if (candidate.Binding == binding) + return candidate.StrideBytes; + } + + throw new ArgumentOutOfRangeException( + nameof(binding), + binding, + "The vertex layout declares no such binding."); + } + + /// How often advances. + public GpuVertexInputRate InputRateOf(uint binding) + { + foreach (GpuVertexBinding candidate in Bindings) + { + if (candidate.Binding == binding) + return candidate.InputRate; + } + + throw new ArgumentOutOfRangeException( + nameof(binding), + binding, + "The vertex layout declares no such binding."); + } + /// /// The world mesh vertex shared by mesh_modern, EnvCells, and terrain: /// position, normal, texcoord — 32 bytes, matching the format /// ObjectMeshManager packs into GlobalMeshBuffer. /// - public static GpuVertexLayout WorldMesh { get; } = new( - StrideBytes: 32, + public static GpuVertexLayout WorldMesh { get; } = Interleaved( + strideBytes: 32, [ new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), @@ -53,7 +164,7 @@ internal sealed record GpuVertexLayout(uint StrideBytes, ImmutableArrayEmpty layout for pipelines whose vertices come entirely from storage buffers. - public static GpuVertexLayout None { get; } = new(0, []); + public static GpuVertexLayout None { get; } = new([], []); } /// diff --git a/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs index acbd9b8d..7762610d 100644 --- a/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs @@ -28,8 +28,15 @@ internal interface IGpuPassEncoder : IDisposable /// Binds a uniform buffer range — currently only the SceneLighting block. void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes); - /// Binds the interleaved vertex source matching the pipeline's vertex layout. - void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes); + /// + /// Binds one vertex source into of the pipeline's + /// . + /// + /// Slice V6l added the binding index. Layouts written before it declare + /// exactly one interleaved vertex-rate binding 0, so every existing call site + /// passes 0; the particle pipelines add a second, per-instance binding. + /// + void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes); /// Binds the index source. void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType); diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs index 9521453a..8b24015d 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs @@ -169,12 +169,12 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder _device.Api.CmdClearAttachments(_commands, attachmentCount, attachments, rectCount, rects); } - public void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes) + public void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes) { ThrowIfClosed(); Silk.NET.Vulkan.Buffer handle = RequireBuffer(buffer).Handle; ulong offset = offsetBytes; - _device.Api.CmdBindVertexBuffers(_commands, 0, 1, &handle, &offset); + _device.Api.CmdBindVertexBuffers(_commands, binding, 1, &handle, &offset); } public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs index 7eee3dce..3520ca03 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs @@ -85,12 +85,26 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline }; GpuVertexLayout vertexLayout = description.VertexLayout; - var binding = new VertexInputBindingDescription + // 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++) { - Binding = 0, - Stride = vertexLayout.StrideBytes, - InputRate = VertexInputRate.Vertex, - }; + 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)]; @@ -100,7 +114,7 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline attributes[i] = new VertexInputAttributeDescription { Location = attribute.Location, - Binding = 0, + Binding = attribute.Binding, Format = VulkanViewportMapping.ToVulkan(attribute.Format), Offset = attribute.OffsetBytes, }; @@ -109,8 +123,8 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline var vertexInput = new PipelineVertexInputStateCreateInfo { SType = StructureType.PipelineVertexInputStateCreateInfo, - VertexBindingDescriptionCount = attributeCount == 0 ? 0u : 1u, - PVertexBindingDescriptions = attributeCount == 0 ? null : &binding, + VertexBindingDescriptionCount = (uint)bindingCount, + PVertexBindingDescriptions = bindingCount == 0 ? null : bindings, VertexAttributeDescriptionCount = (uint)attributeCount, PVertexAttributeDescriptions = attributeCount == 0 ? null : attributes, }; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs index 1ee6d599..5dddfc75 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs @@ -330,7 +330,7 @@ internal sealed class VulkanRhiScene : IDisposable private void BindArena(IGpuPassEncoder encoder) { - encoder.BindVertexBuffer(_vertexArena, 0); + encoder.BindVertexBuffer(0, _vertexArena, 0); encoder.BindIndexBuffer(_indexArena, 0, GpuIndexType.UInt16); } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index f7c81759..2d1f7464 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -154,6 +154,8 @@ internal static class VulkanViewportMapping // Distinct in kind, not just scaling: an integer shader input must be // fed _UINT, and _UNORM here would deliver garbage terrain codes. GpuVertexFormat.UByte4UInt => Format.R8G8B8A8Uint, + // Slice V6l: particle.vert's per-instance `in uint aTextureIndex`. + GpuVertexFormat.UInt1 => Format.R32Uint, _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown vertex format."), }; diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs new file mode 100644 index 00000000..f7a35ef6 --- /dev/null +++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs @@ -0,0 +1,706 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; +using AcDream.Content; +using AcDream.Core.Vfx; +using DatReaderWriter.Enums; + +namespace AcDream.App.Rendering; + +/// +/// Campaign V slice V6l: the particle renderer's RHI submission arm — V4e's +/// content, landed as a SECOND arm for the reason §5.5.6 gave. +/// +/// The contract amendment this arm exists to use. Both particle +/// pipelines draw with PER-INSTANCE vertex attributes: particle at +/// locations 2–6 (centre, two sheet axes, colour, texture slot) and +/// particle_mesh at 3–7 (a mat4 model and a colour). The V0 +/// contract could express instanced DRAWING but not instanced vertex INPUT, +/// which is what stopped V4e at §5.5.16. now +/// carries a per-binding stride and input rate — a second binding at +/// — which both backends implement +/// natively and at no cost. +/// +/// What differs from the GL arm, and why. The imperative +/// glBlendFunc switch becomes five PIPELINES (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 texture table is not bound at all, because the device owns the +/// table and the encoder binds set 2; and the pass is BORROWED from +/// , because the frame's one backbuffer pass +/// resolves and a second pass could not load what it left. +/// +/// Everything above the submission seam — emitter iteration, retail +/// distance ordering, the deferred-alpha handoff to , +/// billboard axis construction, blend resolution — is the same CPU code on both +/// arms. Only where the bytes land differs. +/// +public sealed unsafe partial class ParticleRenderer +{ + private readonly IGpuDevice? _device; + private readonly ICurrentGpuFrameSource? _frames; + private readonly IWorldPassScope? _scope; + + private IGpuPipeline? _billboardAlphaPipeline; + private IGpuPipeline? _billboardAdditivePipeline; + private IGpuPipeline? _meshAlphaPipeline; + private IGpuPipeline? _meshAdditivePipeline; + private IGpuPipeline? _meshInversePipeline; + private IGpuBuffer? _quadVertexBuffer; + private IGpuBuffer? _quadIndexBuffer; + + /// + /// The unit quad both arms draw billboards from: XY in [-0.5, +0.5] with a + /// matching UV, four vertices of two floats each twice over. + /// + private static readonly float[] QuadVertices = + [ + -0.5f, -0.5f, 0f, 0f, + 0.5f, -0.5f, 1f, 0f, + 0.5f, 0.5f, 1f, 1f, + -0.5f, 0.5f, 0f, 1f, + ]; + + private static readonly uint[] QuadIndices = [0, 1, 2, 0, 2, 3]; + + private const uint QuadStrideBytes = 4 * sizeof(float); + + /// Floats per mesh-particle instance: a mat4 plus an RGBA colour. + internal const int MeshInstanceFloats = 20; + + private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float); + + /// + /// The billboard layout: the shared unit quad at vertex rate, and one + /// per particle at instance rate. + /// + /// The instance stride is sizeof(BillboardGpuInstance) rather + /// than a restated number, for the reason §5.5.16 drew from the sky's + /// 32-versus-36 defect: a layout that restates a CPU record's footprint from + /// memory is one field away from scattering the draw into noise while leaving + /// nothing else in the frame visibly wrong. + /// + internal static GpuVertexLayout BillboardVertexLayout { get; } = new( + ImmutableArray.Create( + new GpuVertexBinding(0, QuadStrideBytes, GpuVertexInputRate.Vertex), + new GpuVertexBinding( + 1, + (uint)sizeof(BillboardGpuInstance), + GpuVertexInputRate.Instance)), + ImmutableArray.Create( + new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0, Binding: 0), + new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8, Binding: 0), + new GpuVertexAttribute(2, GpuVertexFormat.Float4, 0, Binding: 1), + new GpuVertexAttribute(3, GpuVertexFormat.Float4, 16, Binding: 1), + new GpuVertexAttribute(4, GpuVertexFormat.Float4, 32, Binding: 1), + new GpuVertexAttribute(5, GpuVertexFormat.Float4, 48, Binding: 1), + // Location 6 is `in uint aTextureIndex` — an INTEGER shader input, + // so R8G8B8A8's normalized cousin would be wrong in kind. It is one + // 32-bit unsigned value; Float1 would reinterpret its bits. + new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1))); + + /// + /// The mesh-particle layout: the shared world-mesh vertex at vertex rate, + /// and a mat4 model plus a colour at instance rate. A mat4 + /// vertex input occupies four consecutive locations, one per column, which is + /// exactly what the GL arm's four glVertexAttribPointer calls set up. + /// + internal static GpuVertexLayout MeshVertexLayout { get; } = new( + ImmutableArray.Create( + new GpuVertexBinding( + 0, + GpuVertexLayout.WorldMesh.StrideBytes, + GpuVertexInputRate.Vertex), + new GpuVertexBinding(1, MeshInstanceStrideBytes, GpuVertexInputRate.Instance)), + ImmutableArray.Create( + new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0, Binding: 0), + new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12, Binding: 0), + new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24, Binding: 0), + new GpuVertexAttribute(3, GpuVertexFormat.Float4, 0, Binding: 1), + new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1), + new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1), + new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1), + new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1))); + + /// + /// The RHI arm's constructor. No GL context, no Shader, no + /// BindlessSupport: the five pipelines compile particle and + /// particle_mesh from the committed SPIR-V, and both texture sources + /// already hand out the device's own GpuTextureSlot (V4t). + /// + internal ParticleRenderer( + IGpuDevice device, + ICurrentGpuFrameSource frames, + IWorldPassScope scope, + ParticleSystem particles, + TextureCache? textures = null, + IDatReaderWriter? dats = null, + WbMeshAdapter? meshAdapter = null, + RetailAlphaQueue? alphaQueue = null, + long? alphaScratchBudgetBytes = null) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _textures = textures; + _dats = dats; + _meshAdapter = meshAdapter; + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _alphaQueue = alphaQueue; + _alphaSource = new AlphaDrawSource(this); + long scratchBudget = alphaScratchBudgetBytes + ?? AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create( + AcDream.App.Rendering.Residency.ResidencyBudgetOptions.Default.AlphaScratchBytes) + .ParticleBytes; + _alphaScratchPolicy = + new AcDream.App.Rendering.Residency.RetainedScratchCapacityPolicy(scratchBudget); + if (_meshAdapter is not null) + { + _meshReferences = new ParticleMeshReferenceTracker( + gfxObjId => _meshAdapter.IncrementRefCount(gfxObjId), + gfxObjId => _meshAdapter.DecrementRefCount(gfxObjId)); + } + _emitterRetirements = new ParticleEmitterRetirementTracker( + handle => _meshReferences?.Release(handle), + handle => _particleGfxInfoByEmitter.Remove(handle), + handle => _textures?.ReleaseParticleTextureOwner(handle), + error => Console.Error.WriteLine($"[particles] {error}")); + + try + { + CreateRhiResources(device, scope.SampleCount); + _particles.EmitterDied += OnEmitterDied; + } + catch + { + DisposeRhiResources(); + throw; + } + } + + /// + /// True when mesh particles can be submitted at all. The GL arm answers with + /// its second Shader, which is only built when a shared mesh arena + /// exists; the RHI arm answers with the pipelines the same condition builds. + /// + private bool MeshParticlesAvailable => + _glContext is null ? _meshAlphaPipeline is not null : _meshShader is not null; + + private void CreateRhiResources(IGpuDevice device, int sampleCount) + { + _billboardAlphaPipeline = CreateBillboardPipeline( + device, "particle-billboard-alpha", GpuBlendMode.StraightAlpha, sampleCount); + _billboardAdditivePipeline = CreateBillboardPipeline( + device, "particle-billboard-additive", GpuBlendMode.Additive, sampleCount); + + ReadOnlySpan quadVertexBytes = MemoryMarshal.AsBytes(QuadVertices); + _quadVertexBuffer = device.CreateBuffer(new GpuBufferDescription( + "particle-quad-vertices", + quadVertexBytes.Length, + GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + _quadVertexBuffer.Upload(0, quadVertexBytes); + + ReadOnlySpan quadIndexBytes = MemoryMarshal.AsBytes(QuadIndices); + _quadIndexBuffer = device.CreateBuffer(new GpuBufferDescription( + "particle-quad-indices", + quadIndexBytes.Length, + GpuBufferUsage.Index | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + _quadIndexBuffer.Upload(0, quadIndexBytes); + + // The mesh pipelines exist exactly when the GL arm's second shader would: + // when a shared mesh arena is published to draw instanced GfxObjs from. + if (_meshAdapter?.MeshManager?.GlobalBuffer is null) + return; + + _meshAlphaPipeline = CreateMeshParticlePipeline( + device, "particle-mesh-alpha", GpuBlendMode.StraightAlpha, sampleCount); + _meshAdditivePipeline = CreateMeshParticlePipeline( + device, "particle-mesh-additive", GpuBlendMode.Additive, sampleCount); + _meshInversePipeline = CreateMeshParticlePipeline( + device, "particle-mesh-inverse", GpuBlendMode.InverseAlpha, sampleCount); + } + + /// + /// One billboard pipeline. Depth TESTS but does not WRITE and culling is off, + /// which is the GL arm's bracket verbatim + /// (Enable(DepthTest)/DepthMask(false)/Disable(CullFace)). + /// + /// Depth compare is Less, not the contract's LessOrEqual + /// default: the world frame runs under GL_LESS and this renderer never + /// called glDepthFunc, so it inherited it. Alpha-to-coverage is off + /// for the same kind of reason and the opposite way round — the frame-global + /// state controller disables it and only WbDrawDispatcher's opaque + /// bracket turns it on, so particles have never drawn with it. + /// + private static IGpuPipeline CreateBillboardPipeline( + IGpuDevice device, + string name, + GpuBlendMode blend, + int sampleCount) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = new GpuShaderSet("particle"), + VertexLayout = BillboardVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = blend, + Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less), + Cull = GpuCullMode.None, + FrontFace = GpuFrontFace.CounterClockwise, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = sampleCount, + }); + + /// + /// One mesh-particle pipeline. Same depth bracket as the billboards; the + /// winding is CW because PrepareMeshPipeline sets + /// glFrontFace(GL_CW), and the cull mode stays DYNAMIC because it is + /// resolved per sub-batch from the DAT's own CullMode. + /// + private static IGpuPipeline CreateMeshParticlePipeline( + IGpuDevice device, + string name, + GpuBlendMode blend, + int sampleCount) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = new GpuShaderSet("particle_mesh"), + VertexLayout = MeshVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = blend, + Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less), + Cull = GpuCullMode.None, + FrontFace = GpuFrontFace.Clockwise, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = sampleCount, + }); + + /// + /// The immediate ordered path on the RHI arm: the same runs, in the same + /// retail distance order, recorded into the borrowed world pass. + /// + private void DrawOrderedRhi(ICamera camera) + { + ParticleSubmissionOrdering.Sort(_submissionScratch); + GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; + Matrix4x4 viewProjection = camera.View * camera.Projection; + IGpuPassEncoder encoder = _scope!.RequireEncoder(); + IGpuFrame frame = RequireRhiFrame(); + + for (int i = 0; i < _submissionScratch.Count;) + { + ParticleSubmission submission = _submissionScratch[i]; + if (submission.Kind == ParticleSubmissionKind.Billboard) + { + BatchKey key = _drawListScratch[submission.DrawIndex].Key; + _runScratch.Clear(); + do + { + _runScratch.Add(_drawListScratch[submission.DrawIndex].Instance); + i++; + if (i >= _submissionScratch.Count) + break; + submission = _submissionScratch[i]; + } + while (submission.Kind == ParticleSubmissionKind.Billboard + && _drawListScratch[submission.DrawIndex].Key == key); + + DrawInstancesRhi(encoder, frame, _runScratch, viewProjection, key.Additive); + continue; + } + + if (!MeshParticlesAvailable || global is null) + { + i++; + continue; + } + + MeshParticleDraw meshDraw = _meshDrawListScratch[submission.DrawIndex]; + MeshBatchKey meshKey = meshDraw.Key; + ObjectRenderBatch batch = meshDraw.Batch; + _meshRunScratch.Clear(); + do + { + _meshRunScratch.Add(_meshDrawListScratch[submission.DrawIndex].Instance); + i++; + if (i >= _submissionScratch.Count) + break; + submission = _submissionScratch[i]; + } + while (submission.Kind == ParticleSubmissionKind.Mesh + && _meshDrawListScratch[submission.DrawIndex].Key == meshKey); + + int neededFloats = _meshRunScratch.Count * MeshInstanceFloats; + if (_meshInstanceScratch.Length < neededFloats) + _meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats]; + for (int instance = 0; instance < _meshRunScratch.Count; instance++) + { + WriteMeshGpuInstance( + _meshInstanceScratch, + instance * MeshInstanceFloats, + _meshRunScratch[instance]); + } + + GpuRingAllocation instances = WriteVertexRing( + frame, + _meshInstanceScratch.AsSpan(0, neededFloats)); + DrawMeshBatchRhi( + encoder, + global, + batch, + viewProjection, + instances.Buffer, + instances.OffsetBytes, + (uint)_meshRunScratch.Count, + firstInstance: 0); + } + } + + private void DrawInstancesRhi( + IGpuPassEncoder encoder, + IGpuFrame frame, + List instances, + Matrix4x4 viewProjection, + bool additive) + { + if (instances.Count == 0) + return; + + if (_instanceScratch.Length < instances.Count) + _instanceScratch = new BillboardGpuInstance[instances.Count + 256]; + for (int i = 0; i < instances.Count; i++) + WriteBillboardGpuInstance(ref _instanceScratch[i], instances[i]); + + GpuRingAllocation ring = WriteVertexRing( + frame, + _instanceScratch.AsSpan(0, instances.Count)); + BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes); + encoder.DrawIndexed( + (uint)QuadIndices.Length, + (uint)instances.Count, + 0, + 0, + 0); + } + + /// + /// Binds a billboard pipeline and immediately re-establishes both vertex + /// sources and the index source. Every pipeline owns its own vertex array on + /// GL, and attribute pointers plus the index binding are vertex-array state, + /// so a pipeline switch silently drops them while storage bindings survive. + /// + private void BindBillboardPipeline( + IGpuPassEncoder encoder, + Matrix4x4 viewProjection, + bool additive, + IGpuBuffer instanceBuffer, + uint instanceOffsetBytes) + { + encoder.BindPipeline(additive + ? _billboardAdditivePipeline! + : _billboardAlphaPipeline!); + encoder.SetPushConstants(new GpuPushConstants + { + ViewProjection = viewProjection, + DrawIdOffset = 0, + LightingMode = 0, + RenderPass = 0, + LightDebug = 0, + // Billboards carry their texture slot per instance at location 6; + // the block's texture members are unread by particle.frag. + TextureIndexA = 0, + TextureIndexB = 0, + ParamA = 0f, + ParamB = 0f, + }); + encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0); + encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes); + encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32); + } + + private void DrawMeshBatchRhi( + IGpuPassEncoder encoder, + GlobalMeshBuffer global, + ObjectRenderBatch batch, + Matrix4x4 viewProjection, + IGpuBuffer instanceBuffer, + uint instanceOffsetBytes, + uint instanceCount, + uint firstInstance) + { + if (instanceCount == 0) + return; + + encoder.BindPipeline(PipelineForMeshBlend(ResolveMeshBlend(batch))); + encoder.SetPushConstants(new GpuPushConstants + { + ViewProjection = viewProjection, + DrawIdOffset = 0, + LightingMode = 0, + RenderPass = 0, + LightDebug = 0, + TextureIndexA = batch.TextureSlot.Index, + TextureIndexB = 0, + // uParamA is a float, so the array layer is widened here rather than + // in the shader. Layers are small integers; the sampled value is + // bit-identical to the GL arm's. + ParamA = batch.TextureIndex, + ParamB = 0f, + }); + // BindPipeline restores the pipeline's own default cull mode, so the + // per-sub-batch override has to follow it, exactly as the world + // dispatcher's does. + ApplyMeshCullModeRhi(encoder, batch.CullMode); + encoder.BindVertexBuffer( + 0, + global.VertexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no vertex store."), + 0); + encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes); + encoder.BindIndexBuffer( + global.IndexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no index store."), + 0, + GpuIndexType.UInt16); + encoder.DrawIndexed( + (uint)batch.IndexCount, + instanceCount, + (uint)batch.FirstIndex, + (int)batch.BaseVertex, + firstInstance); + } + + private IGpuPipeline PipelineForMeshBlend(TranslucencyKind blend) => blend switch + { + TranslucencyKind.Additive => _meshAdditivePipeline!, + TranslucencyKind.InvAlpha => _meshInversePipeline!, + _ => _meshAlphaPipeline!, + }; + + /// + /// The RHI form of . FrontFace is + /// re-issued with it because BindPipeline restores the pipeline's own + /// default and the two always travel together on the GL arm. + /// + private static void ApplyMeshCullModeRhi(IGpuPassEncoder encoder, CullMode mode) + { + encoder.SetFrontFace(GpuFrontFace.Clockwise); + encoder.SetCullMode(mode switch + { + CullMode.None => GpuCullMode.None, + CullMode.Clockwise => GpuCullMode.Front, + _ => GpuCullMode.Back, + }); + } + + /// + /// Writes the whole deferred-alpha payload into the frame ring once. The two + /// sections survive as ordinary values so every later + /// binds the same bytes with a + /// firstInstance offset instead of recopying — which is exactly what + /// the GL arm's baseInstance does. + /// + private void PrepareDeferredAlphaDrawsRhi(ReadOnlySpan tokens) + { + IGpuFrame frame = RequireRhiFrame(); + int count = tokens.Length; + if (_preparedAlpha.Length < count) + Array.Resize(ref _preparedAlpha, count + 256); + if (_preparedInstanceOffsets.Length < count) + Array.Resize(ref _preparedInstanceOffsets, count + 256); + if (_instanceScratch.Length < count) + Array.Resize(ref _instanceScratch, count + 256); + int neededMeshFloats = count * MeshInstanceFloats; + if (_meshInstanceScratch.Length < neededMeshFloats) + _meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats]; + + int billboardCount = 0; + int meshCount = 0; + for (int i = 0; i < count; i++) + { + DeferredParticleDraw deferred = _deferredAlpha[tokens[i]]; + _preparedAlpha[i] = deferred; + if (deferred.Kind == ParticleSubmissionKind.Billboard) + { + _preparedInstanceOffsets[i] = (uint)billboardCount; + WriteBillboardGpuInstance( + ref _instanceScratch[billboardCount++], + deferred.Billboard.Instance); + } + else + { + _preparedInstanceOffsets[i] = (uint)meshCount; + WriteMeshGpuInstance( + _meshInstanceScratch, + meshCount++ * MeshInstanceFloats, + deferred.Mesh.Instance); + } + } + + _preparedBillboardInstances = billboardCount > 0 + ? SectionOf(WriteVertexRing( + frame, + _instanceScratch.AsSpan(0, billboardCount))) + : default; + _preparedMeshInstances = meshCount > 0 + ? SectionOf(WriteVertexRing( + frame, + _meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats))) + : default; + _preparedAlphaCount = count; + } + + private void DrawPreparedAlphaBatchRhi(int firstPreparedDraw, int drawCount) + { + GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; + IGpuPassEncoder encoder = _scope!.RequireEncoder(); + + int i = firstPreparedDraw; + int preparedEnd = firstPreparedDraw + drawCount; + while (i < preparedEnd) + { + DeferredParticleDraw deferred = _preparedAlpha[i]; + if (deferred.Kind == ParticleSubmissionKind.Billboard) + { + BatchKey key = deferred.Billboard.Key; + Matrix4x4 viewProjection = deferred.ViewProjection; + uint baseInstance = _preparedInstanceOffsets[i]; + int runStart = i; + do + { + i++; + if (i >= preparedEnd) + break; + deferred = _preparedAlpha[i]; + } + while (deferred.Kind == ParticleSubmissionKind.Billboard + && deferred.Billboard.Key == key + && deferred.ViewProjection == viewProjection); + + if (_preparedBillboardInstances.Buffer is { } billboards) + { + BindBillboardPipeline( + encoder, + viewProjection, + key.Additive, + billboards, + _preparedBillboardInstances.OffsetBytes); + encoder.DrawIndexed( + (uint)QuadIndices.Length, + (uint)(i - runStart), + 0, + 0, + baseInstance); + } + + continue; + } + + if (!MeshParticlesAvailable || global is null) + { + i++; + continue; + } + + MeshBatchKey meshKey = deferred.Mesh.Key; + ObjectRenderBatch batch = deferred.Mesh.Batch; + Matrix4x4 meshViewProjection = deferred.ViewProjection; + uint meshBaseInstance = _preparedInstanceOffsets[i]; + int meshRunStart = i; + do + { + i++; + if (i >= preparedEnd) + break; + deferred = _preparedAlpha[i]; + } + while (deferred.Kind == ParticleSubmissionKind.Mesh + && deferred.Mesh.Key == meshKey + && deferred.ViewProjection == meshViewProjection); + + if (_preparedMeshInstances.Buffer is { } meshInstances) + { + DrawMeshBatchRhi( + encoder, + global, + batch, + meshViewProjection, + meshInstances, + _preparedMeshInstances.OffsetBytes, + (uint)(i - meshRunStart), + meshBaseInstance); + } + } + } + + /// A ring slice reduced to the two values a later vertex bind needs. + private readonly record struct RhiVertexSection(IGpuBuffer? Buffer, uint OffsetBytes); + + private RhiVertexSection _preparedBillboardInstances; + private RhiVertexSection _preparedMeshInstances; + + private static RhiVertexSection SectionOf(GpuRingAllocation allocation) => + new(allocation.Buffer, allocation.OffsetBytes); + + private static GpuRingAllocation WriteVertexRing(IGpuFrame frame, ReadOnlySpan data) + where T : unmanaged + { + int elementBytes = sizeof(T); + int byteCount = Math.Max(data.Length * elementBytes, elementBytes); + GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex); + if (!data.IsEmpty) + data.CopyTo(allocation.AsSpan()); + return allocation; + } + + private IGpuFrame RequireRhiFrame() + { + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing particles."); + + return _frames!.CurrentFrame + ?? throw new InvalidOperationException( + "ParticleRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + } + + private void DisposeRhiResources() + { + List? failures = null; + void Attempt(Action action) + { + try { action(); } + catch (Exception error) { (failures ??= []).Add(error); } + } + + Attempt(() => _billboardAlphaPipeline?.Dispose()); + _billboardAlphaPipeline = null; + Attempt(() => _billboardAdditivePipeline?.Dispose()); + _billboardAdditivePipeline = null; + Attempt(() => _meshAlphaPipeline?.Dispose()); + _meshAlphaPipeline = null; + Attempt(() => _meshAdditivePipeline?.Dispose()); + _meshAdditivePipeline = null; + Attempt(() => _meshInversePipeline?.Dispose()); + _meshInversePipeline = null; + Attempt(() => _quadVertexBuffer?.Dispose()); + _quadVertexBuffer = null; + Attempt(() => _quadIndexBuffer?.Dispose()); + _quadIndexBuffer = null; + _preparedBillboardInstances = default; + _preparedMeshInstances = default; + + if (failures is not null) + throw new AggregateException("The particle renderer's RHI resources did not fully release.", failures); + } +} diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index e3e6a366..28c9f38d 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -22,7 +22,7 @@ namespace AcDream.App.Rendering; /// compositing order is shared with ordinary translucent GfxObj parts. Sky and /// sealed off-screen passes retain their independent immediate path. /// -public sealed unsafe class ParticleRenderer : IDisposable +public sealed unsafe partial class ParticleRenderer : IDisposable { // The texture is per instance through GL_ARB_bindless_texture. Only blend // state remains a draw-call boundary, so stable retail distance order no @@ -97,9 +97,22 @@ public sealed unsafe class ParticleRenderer : IDisposable } } - private readonly GL _gl; - private readonly Shader _shader; + /// + /// The GL arm's context, or null on a backend that has none. + /// + /// Campaign V slice V6l: particles draw on both arms, so the context + /// became optional. below is what every GL-arm statement + /// still reads, unchanged — reaching it without a context is a composition + /// error and says so rather than dereferencing null. + /// + private readonly GL? _glContext; + private readonly Shader? _shader; private readonly Shader? _meshShader; + + private GL _gl => _glContext + ?? throw new InvalidOperationException( + "ParticleRenderer's GL arm was reached on a backend with no GL context " + + "(campaign plan §5.5.16; the RHI arm lives in ParticleRenderer.Rhi.cs)."); private readonly TextureCache? _textures; private readonly IDatReaderWriter? _dats; private readonly WbMeshAdapter? _meshAdapter; @@ -234,7 +247,7 @@ public sealed unsafe class ParticleRenderer : IDisposable RetailAlphaQueue? alphaQueue = null, long? alphaScratchBudgetBytes = null) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _glContext = gl ?? throw new ArgumentNullException(nameof(gl)); _textures = textures; _dats = dats; _meshAdapter = meshAdapter; @@ -485,6 +498,12 @@ public sealed unsafe class ParticleRenderer : IDisposable private void DrawOrdered(ICamera camera) { + if (_glContext is null) + { + DrawOrderedRhi(camera); + return; + } + ParticleSubmissionOrdering.Sort(_submissionScratch); GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; Matrix4x4 viewProjection = camera.View * camera.Projection; @@ -585,6 +604,12 @@ public sealed unsafe class ParticleRenderer : IDisposable if (tokens.Length == 0) return; + if (_glContext is null) + { + PrepareDeferredAlphaDrawsRhi(tokens); + return; + } + ActivateNextDynamicBufferSet(); int count = tokens.Length; @@ -653,6 +678,12 @@ public sealed unsafe class ParticleRenderer : IDisposable || firstPreparedDraw > _preparedAlphaCount - drawCount) throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw)); + if (_glContext is null) + { + DrawPreparedAlphaBatchRhi(firstPreparedDraw, drawCount); + return; + } + GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); @@ -805,7 +836,7 @@ public sealed unsafe class ParticleRenderer : IDisposable private void PrepareBillboardPipeline(Matrix4x4 viewProjection) { - _shader.Use(); + _shader!.Use(); _shader.SetMatrix4("uViewProjection", viewProjection); _gl.Disable(EnableCap.CullFace); _gl.BindVertexArray(_quadVao); @@ -967,7 +998,7 @@ public sealed unsafe class ParticleRenderer : IDisposable Vector3 cameraWorldPosition, ref int sequence) { - if (_meshAdapter is null || _meshShader is null) + if (_meshAdapter is null || !MeshParticlesAvailable) return true; _meshReferences!.Register(emitter.Handle, gfxObjId); @@ -1621,6 +1652,15 @@ public sealed unsafe class ParticleRenderer : IDisposable if (_meshReferences is not null) releases.Add(("mesh-references", _meshReferences.Dispose)); + // Campaign V slice V6l: the RHI arm owns pipelines and two static quad + // buffers and no GL names at all, so it releases through the same + // retryable ledger and then there is nothing else to do. + if (_glContext is null) + { + releases.Add(("rhi-resources", DisposeRhiResources)); + return; + } + AddTrackedBufferRelease( releases, _quadVbo, @@ -1640,7 +1680,8 @@ public sealed unsafe class ParticleRenderer : IDisposable AddDynamicBufferSetReleases(releases, frameSets[index], frame, index); } - releases.Add(("billboard-shader", _shader.Dispose)); + if (_shader is not null) + releases.Add(("billboard-shader", _shader.Dispose)); if (_meshShader is not null) releases.Add(("mesh-shader", _meshShader.Dispose)); } diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs index 427bb143..7741acb1 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs @@ -61,8 +61,8 @@ public sealed unsafe partial class SkyRenderer /// vertices into noise while leaving the frame otherwise plausible. The GL arm /// says sizeof(Vertex) and so does this. /// - internal static readonly GpuVertexLayout SkyVertexLayout = new( - StrideBytes: (uint)sizeof(Vertex), + internal static readonly GpuVertexLayout SkyVertexLayout = GpuVertexLayout.Interleaved( + strideBytes: (uint)sizeof(Vertex), ImmutableArray.Create( new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), @@ -237,7 +237,7 @@ public sealed unsafe partial class SkyRenderer ParamB = 0f, }; encoder.SetPushConstants(in pushConstants); - encoder.BindVertexBuffer(sub.VertexBuffer, 0); + encoder.BindVertexBuffer(0, sub.VertexBuffer, 0); encoder.BindIndexBuffer(sub.IndexBuffer, 0, GpuIndexType.UInt32); GpuRingAllocation parameters = frame.AllocateRing( diff --git a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs index d021478e..a181cd81 100644 --- a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs +++ b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs @@ -1,22 +1,44 @@ namespace AcDream.App.Rendering; /// -/// One standalone, resident bindless texture used by particle billboards. -/// Unlike entity composites, the shader always samples layer zero. +/// One standalone texture used by particle billboards. Unlike entity +/// composites, the shader always samples layer zero. +/// +/// Campaign V slice V6l: the resource identifies its texture on ONE of the +/// two arms. The GL arm names a texture object and a resident bindless handle; +/// the RHI arm names an created through the device. +/// is common to both, which is the point — a particle batch +/// has carried a backend-neutral table slot rather than a handle since V4t, so +/// only the ownership record had to grow a second shape. /// internal sealed class StandaloneBindlessTextureResource { public required uint SurfaceId { get; init; } - public required uint Name { get; init; } - public required ulong Handle { get; init; } + + /// GL texture name on the GL arm; zero on the RHI arm. + public uint Name { get; init; } + + /// Resident bindless handle on the GL arm; zero on the RHI arm. + public ulong Handle { get; init; } + + /// The device texture on the RHI arm; null on the GL arm. + public Gpu.IGpuTexture? Texture { get; init; } /// /// Campaign V slice V4t: this texture's entry in the device texture table. - /// Created with 's residency and retired with it, so a - /// particle batch carries a backend-neutral slot rather than a GL handle. + /// Created with 's residency (GL) or with + /// (RHI) and retired with it, so a particle batch + /// carries a backend-neutral slot rather than a GL handle. /// public required Gpu.GpuTextureSlot Slot { get; init; } public required long Bytes { get; init; } + + /// + /// Whether the resource names a texture at all. Exactly one arm must have + /// filled it in; a resource that names neither would retire nothing and + /// leak silently, which is what this guards. + /// + public bool IdentifiesATexture => (Name != 0 && Handle != 0) || Texture is not null; } internal interface IStandaloneBindlessTextureBackend @@ -99,8 +121,13 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable ObjectDisposedException.ThrowIf(_disposeRequested, this); ArgumentNullException.ThrowIfNull(resource); ValidateOwnerAndSurface(ownerId, resource.SurfaceId); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Name); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Handle); + if (!resource.IdentifiesATexture) + { + throw new ArgumentException( + "A standalone particle texture must name either a GL texture and its " + + "resident handle or a device texture (campaign plan slice V6l).", + nameof(resource)); + } ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Bytes); if (!_entries.TryAdd(resource.SurfaceId, resource)) diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs index ed06b37c..15cd3f7a 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs @@ -37,8 +37,8 @@ public sealed unsafe partial class TerrainModernRenderer /// terrain-type, road and split-direction codes; normalising them would not /// be an approximation, it would be garbage. /// - private static readonly GpuVertexLayout TerrainVertexLayout = new( - StrideBytes: VertexSize, + internal static readonly GpuVertexLayout TerrainVertexLayout = GpuVertexLayout.Interleaved( + strideBytes: VertexSize, ImmutableArray.Create( new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), @@ -234,7 +234,7 @@ public sealed unsafe partial class TerrainModernRenderer encoder.BindPipeline(_pipeline!); encoder.SetPushConstants(in pushConstants); - encoder.BindVertexBuffer(RequireVertexStore(), 0); + encoder.BindVertexBuffer(0, RequireVertexStore(), 0); encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32); BindTilingTable(encoder); WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame); diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 406ca5f3..0ef2a366 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -47,11 +47,13 @@ namespace AcDream.App.Rendering; /// public sealed class TextRenderer : IDisposable { - private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) + // internal: slice V6l's stride-equals-the-uploaded-record gate asserts the + // layout against the producer's own float count rather than a literal. + internal const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); - private static readonly GpuVertexLayout SpriteVertexLayout = new( - StrideBytes: VertexStrideBytes, + internal static readonly GpuVertexLayout SpriteVertexLayout = GpuVertexLayout.Interleaved( + strideBytes: VertexStrideBytes, [ new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0), new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8), @@ -461,7 +463,7 @@ public sealed class TextRenderer : IDisposable return; GpuRingAllocation allocation = frame.AllocateRing(buf.Count * sizeof(float), GpuRingUsage.Vertex); CollectionsMarshal.AsSpan(buf).CopyTo(allocation.AsSpan()); - encoder.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes); + encoder.BindVertexBuffer(0, allocation.Buffer, allocation.OffsetBytes); encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0); } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 038bf9cd..131244e9 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -187,6 +187,22 @@ public sealed unsafe class TextureCache _compositeTextures = composite; _particleTextures = particles; } + else + { + // Campaign V slice V6l: particles draw on both arms, so the standalone + // particle cache exists on both. Everything about it that matters — + // sharing equivalent surfaces between emitter owners, the bounded + // unowned LRU, and retirement behind the frame-flight fence — is + // backend-neutral; only how one entry is created and destroyed + // differs, which is what the backend interface is for. The COMPOSITE + // cache stays GL-only: it serves entity appearance, not particles, + // and its port is not this slice's. + _particleTextures = new StandaloneBindlessTextureCache( + new ParticleRhiTextureBackend(this), + retirementQueue, + budgets.StandaloneUnownedBytes, + budgets.StandaloneUnownedEntries); + } } /// @@ -546,6 +562,16 @@ public sealed unsafe class TextureCache surfaceId, origTextureOverride: null, paletteOverride: null); + + // Campaign V slice V6l: the RHI arm has no GL name to intern a bindless + // handle from, so the image is created through IGpuDevice and paired + // with a real sampler object. The decode above is the same one the GL + // arm uses, so the pixels are identical; the shader still samples layer + // zero of a one-layer array, which is what a Texture2D registered into + // the table is on Vulkan (VulkanTextureFormatMapping.SampledViewTypeOf). + if (_gl is null) + return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded); + uint name = UploadRgba8AsLayer1Array(decoded); ulong handle = 0; try @@ -604,6 +630,54 @@ public sealed unsafe class TextureCache } } + /// + /// Campaign V slice V6l: one particle surface as a device texture-table + /// slot, owned by the same emitter-scoped cache the GL arm uses. + /// + /// Linear/clamped is the filtering the GL arm's own one-layer array + /// upload sets on itself, and a particle sheet's UVs never leave [0,1] — + /// the quad's own texcoords are the unit square — so the wrap mode is not a + /// visible choice, it is just the safe one. + /// + private GpuTextureSlot AcquireParticleTextureRhi( + StandaloneBindlessTextureCache textures, + uint ownerId, + uint surfaceId, + DecodedTexture decoded) + { + IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription( + $"particle-surface-0x{surfaceId:X8}", + GpuTextureKind.Texture2D, + GpuTextureFormat.Rgba8Unorm, + Width: decoded.Width, + Height: decoded.Height, + LayerCount: 1, + MipLevelCount: 1)); + try + { + texture.Upload(0, 0, decoded.Rgba8); + uint accountingName = UploadAccountingName(texture); + TrackUploadedTexture(accountingName, decoded.Width, decoded.Height); + + IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp); + GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); + textures.AddAndAcquire(ownerId, new StandaloneBindlessTextureResource + { + SurfaceId = surfaceId, + Name = accountingName, + Texture = texture, + Slot = slot, + Bytes = checked((long)decoded.Width * decoded.Height * 4L), + }); + return slot; + } + catch + { + texture.Dispose(); + throw; + } + } + internal void ReleaseParticleTextureOwner(int emitterHandle) { if (emitterHandle <= 0 || _particleTextures is null) @@ -768,11 +842,14 @@ public sealed unsafe class TextureCache return _compositeTextures!; } - private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() - { - EnsureBindlessAvailable(); - return _particleTextures!; - } + /// + /// Campaign V slice V6l: no longer gated on bindless. The particle cache is + /// constructed on both arms, so the only failure left is a cache that was + /// never built at all. + /// + private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() => + _particleTextures ?? throw new InvalidOperationException( + "This TextureCache owns no standalone particle texture cache."); private sealed class ParticleTextureBackend(TextureCache owner) : IStandaloneBindlessTextureBackend @@ -792,6 +869,29 @@ public sealed unsafe class TextureCache => owner.DeleteUploadedTexture(resource.Name); } + /// + /// Campaign V slice V6l: the same ownership boundary on a backend with no + /// bindless handles. The table slot is released first and the image second, + /// which is the same order the GL arm uses and for the same reason — a + /// submitted-but-unretired frame may still sample the slot, and + /// is what defers its reuse. + /// + private sealed class ParticleRhiTextureBackend(TextureCache owner) + : IStandaloneBindlessTextureBackend + { + public void MakeNonResident(StandaloneBindlessTextureResource resource) + { + if (resource.Slot.IsAssigned) + owner._device.ReleaseTextureSlot(resource.Slot); + } + + public void Delete(StandaloneBindlessTextureResource resource) + { + resource.Texture?.Dispose(); + owner.UntrackUploadedTexture(resource.Name); + } + } + /// /// Advances bounded composite-cache maintenance once per render frame. /// Logical owner release is immediate; at most one over-budget layer and diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index ab9f22f9..bafea1a5 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -256,6 +256,7 @@ public sealed unsafe partial class EnvCellRenderer { encoder.BindPipeline(pipeline); encoder.BindVertexBuffer( + 0, mesh.VertexStore ?? throw new InvalidOperationException( "The shared mesh arena has no vertex store."), 0); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index 10f2a0d0..66ade0a5 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -439,6 +439,7 @@ public sealed unsafe partial class WbDrawDispatcher { encoder.BindPipeline(pipeline); encoder.BindVertexBuffer( + 0, mesh.VertexStore ?? throw new InvalidOperationException( "The shared mesh arena has no vertex store."), 0); diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index d2956b32..303c0307 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -100,6 +100,70 @@ public sealed class GpuContractTests Assert.Contains(GpuVertexFormat.UByte4UInt, Enum.GetValues()); } + [Fact] + public void AVertexLayoutCanDeclareAPerInstanceBinding() + { + // Campaign V slice V6l. Both particle pipelines draw with per-instance + // VERTEX attributes, and the V0 contract could express instanced DRAWING + // (Draw/DrawIndexed both take firstInstance) but not instanced vertex + // INPUT — one stride, no divisor, one buffer at vertex rate. Plan + // §5.5.16 recorded that as what blocked V4e and named three ways out; + // this is option (i), the second binding with a per-instance rate, which + // both backends carry natively (VK_VERTEX_INPUT_RATE_INSTANCE, + // glVertexAttribDivisor) and which needs no shader edit. + var layout = new GpuVertexLayout( + [ + new GpuVertexBinding(0, 16, GpuVertexInputRate.Vertex), + new GpuVertexBinding(1, 68, GpuVertexInputRate.Instance), + ], + [ + new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0), + new GpuVertexAttribute(2, GpuVertexFormat.Float4, 0, Binding: 1), + ]); + + Assert.Equal(16u, layout.StrideOf(0)); + Assert.Equal(68u, layout.StrideOf(1)); + Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0)); + Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1)); + + // An attribute that names no declared binding is a composition error, + // not a silent bind against binding 0. + Assert.Throws(() => layout.StrideOf(2)); + } + + [Fact] + public void ASingleBindingLayoutStillMeansOneInterleavedVertexRateBuffer() + { + // Every layout written before V6l has to keep its meaning exactly. The + // default attribute binding is 0 and the Interleaved factory's one + // binding is vertex-rate, so no existing declaration changed behaviour. + Assert.Equal(0u, new GpuVertexAttribute(3, GpuVertexFormat.Float3, 12).Binding); + + GpuVertexLayout world = GpuVertexLayout.WorldMesh; + GpuVertexBinding only = Assert.Single(world.Bindings); + Assert.Equal(0u, only.Binding); + Assert.Equal(GpuVertexInputRate.Vertex, only.InputRate); + Assert.Equal(world.StrideBytes, only.StrideBytes); + + // And a buffer-fed pipeline declares no binding at all. + Assert.Empty(GpuVertexLayout.None.Bindings); + Assert.Empty(GpuVertexLayout.None.Attributes); + Assert.Equal(0u, GpuVertexLayout.None.StrideBytes); + } + + [Fact] + public void ScalarIntegerVertexAttributesAreRepresentable() + { + // particle.vert declares `layout(location = 6) in uint aTextureIndex` — + // the per-instance texture-table slot. The amendment's premise is that no + // shader is edited, so the contract has to be able to name a scalar uint: + // GL requires glVertexAttribIPointer for it and Vulkan requires R32_UINT, + // and the float path would reinterpret its bits rather than approximate + // its value. Same kind-distinction UByte4UInt was added for at V4d. + Assert.Contains(GpuVertexFormat.UInt1, Enum.GetValues()); + Assert.NotEqual(GpuVertexFormat.Float1, GpuVertexFormat.UInt1); + } + [Fact] public void APipelineNamesTheColorFormatItRendersInto() { diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs index 06b4441c..444ae78c 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs @@ -30,7 +30,7 @@ internal sealed record GpuRecordedStorageBind(uint Binding, string BufferName, u internal sealed record GpuRecordedUniformBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes) : GpuRecordedCall; -internal sealed record GpuRecordedVertexBind(string BufferName, uint OffsetBytes) : GpuRecordedCall; +internal sealed record GpuRecordedVertexBind(uint Binding, string BufferName, uint OffsetBytes) : GpuRecordedCall; internal sealed record GpuRecordedIndexBind(string BufferName, uint OffsetBytes, GpuIndexType IndexType) : GpuRecordedCall; @@ -349,10 +349,10 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes)); } - public void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes) + public void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes) { ArgumentNullException.ThrowIfNull(buffer); - device.Record(new GpuRecordedVertexBind(buffer.Name, offsetBytes)); + device.Record(new GpuRecordedVertexBind(binding, buffer.Name, offsetBytes)); } public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) diff --git a/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs b/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs new file mode 100644 index 00000000..486239bd --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs @@ -0,0 +1,180 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Sky; +using AcDream.Content; +using AcDream.Core.Terrain; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign V slice V6l: every RHI vertex layout's declared stride must be the +/// footprint of the record the CPU actually uploads. +/// +/// V6k found the sky's arm declaring 32 bytes against a 36-byte +/// — the record carries a TerrainLayer member no sky +/// attribute names — and drew the dome as a field of noise while nothing else in +/// the frame looked wrong, no validation rule was violated, and the offline pixel +/// gate masked the band. Its report generalised the lesson rather than banking +/// it: every .Rhi.cs arm restates a CPU record's footprint from memory, +/// and only one of them had a test. This is that test for the rest. +/// +/// Each assertion names the REQUIREMENT — "the stride is the uploaded +/// record's footprint" — rather than today's number, so adding or removing a +/// member of any of these records keeps the gate honest instead of pinning an +/// answer that has drifted. +/// +public class RhiVertexLayoutStrideTests +{ + [Fact] + public void WorldMeshStrideMatchesTheVertexRecordTheMeshArenaPacks() + { + // ObjectMeshManager writes VertexPositionNormalTexture into + // GlobalMeshBuffer; WbDrawDispatcher, EnvCellRenderer and the + // mesh-particle pipeline all read it back through this layout. + Assert.Equal( + (uint)Unsafe.SizeOf(), + GpuVertexLayout.WorldMesh.StrideBytes); + Assert.Equal( + (uint)VertexPositionNormalTexture.Size, + GpuVertexLayout.WorldMesh.StrideBytes); + } + + [Fact] + public void TerrainStrideMatchesTheUploadedTerrainVertex() + { + Assert.Equal( + (uint)Unsafe.SizeOf(), + TerrainModernRenderer.TerrainVertexLayout.StrideBytes); + } + + [Fact] + public void SkyStrideMatchesTheUploadedRecord() + { + // The defect that produced this whole family of assertions. + Assert.Equal( + (uint)Unsafe.SizeOf(), + SkyRenderer.SkyVertexLayout.StrideBytes); + } + + [Fact] + public void RetainedUiSpriteStrideMatchesTheFloatsThePrducerAppends() + { + // The retained UI's producer is a List, so its "record" is the + // float count AppendQuad writes per vertex. Binding the layout to that + // constant is what makes adding a per-vertex value fail here rather than + // shear every glyph in the frame. + Assert.Equal( + (uint)(TextRenderer.FloatsPerVertex * sizeof(float)), + TextRenderer.SpriteVertexLayout.StrideBytes); + } + + [Fact] + public void DebugLineStrideMatchesTheFloatsTheProducerAppends() + { + Assert.Equal( + (uint)(DebugLineRenderer.FloatsPerVertex * sizeof(float)), + DebugLineRenderer.VertexLayout.StrideBytes); + } + + [Fact] + public void ParticleBillboardStridesMatchTheirUploadedRecords() + { + GpuVertexLayout layout = ParticleRenderer.BillboardVertexLayout; + + // Binding 0 is the shared unit quad: four floats (XY position, UV). + Assert.Equal(4u * sizeof(float), layout.StrideOf(0)); + Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0)); + + // Binding 1 is one BillboardGpuInstance per particle. Getting this + // stride wrong is the sky defect wearing a per-instance face. + Assert.Equal( + (uint)Unsafe.SizeOf(), + layout.StrideOf(1)); + Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1)); + } + + [Fact] + public void ParticleMeshStridesMatchTheirUploadedRecords() + { + GpuVertexLayout layout = ParticleRenderer.MeshVertexLayout; + + Assert.Equal( + (uint)Unsafe.SizeOf(), + layout.StrideOf(0)); + Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0)); + + // Binding 1 is a mat4 model plus an RGBA colour, written as loose floats + // by WriteMeshGpuInstance. + Assert.Equal( + (uint)(ParticleRenderer.MeshInstanceFloats * sizeof(float)), + layout.StrideOf(1)); + Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1)); + } + + /// + /// The same defect wearing its other face: an attribute that reaches past + /// the stride it is read with. Checked over every layout at once, because + /// the point of this file is that no arm should be the one without a test. + /// + [Fact] + public void EveryAttributeFitsInsideItsBindingStride() + { + foreach ((string name, GpuVertexLayout layout) in EveryRhiVertexLayout()) + { + foreach (GpuVertexAttribute attribute in layout.Attributes) + { + uint stride = layout.StrideOf(attribute.Binding); + uint size = SizeOf(attribute.Format); + Assert.True( + attribute.OffsetBytes + size <= stride, + $"{name}: attribute at location {attribute.Location} reaches past " + + $"binding {attribute.Binding}'s {stride}-byte stride."); + } + } + } + + [Fact] + public void EveryAttributeNamesADeclaredBinding() + { + foreach ((string name, GpuVertexLayout layout) in EveryRhiVertexLayout()) + { + foreach (GpuVertexAttribute attribute in layout.Attributes) + { + Assert.True( + layout.Bindings.Any(binding => binding.Binding == attribute.Binding), + $"{name}: attribute at location {attribute.Location} names undeclared " + + $"binding {attribute.Binding}."); + } + } + } + + /// + /// Every vertex layout any production RHI pipeline is built with. A new arm + /// that does not appear here is the gap V6k found; adding the row is the + /// whole cost of not repeating it. + /// + internal static IEnumerable<(string Name, GpuVertexLayout Layout)> EveryRhiVertexLayout() + { + yield return ("world mesh", GpuVertexLayout.WorldMesh); + yield return ("terrain", TerrainModernRenderer.TerrainVertexLayout); + yield return ("sky", SkyRenderer.SkyVertexLayout); + yield return ("retained UI sprite", TextRenderer.SpriteVertexLayout); + yield return ("debug line", DebugLineRenderer.VertexLayout); + yield return ("particle billboard", ParticleRenderer.BillboardVertexLayout); + yield return ("particle mesh", ParticleRenderer.MeshVertexLayout); + } + + private static uint SizeOf(GpuVertexFormat format) => format switch + { + GpuVertexFormat.Float1 => 4u, + GpuVertexFormat.Float2 => 8u, + GpuVertexFormat.Float3 => 12u, + GpuVertexFormat.Float4 => 16u, + GpuVertexFormat.UByte4Normalized => 4u, + GpuVertexFormat.UByte4UInt => 4u, + GpuVertexFormat.UInt1 => 4u, + _ => throw new NotSupportedException($"No size known for {format}."), + }; +}