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}."), + }; +}