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