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's second /// Shader answer was deleted at Campaign V slice V11; only the RHI /// pipelines remain, built exactly when a shared mesh arena exists. /// private bool MeshParticlesAvailable => _meshAlphaPipeline 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); } }