using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Wb;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using RuntimeParticleEmitter = AcDream.Core.Vfx.ParticleEmitter;
namespace AcDream.App.Rendering;
///
/// Instanced renderer for retail particle emitters. Scene particles submit to
/// while a world frame is active so their
/// compositing order is shared with ordinary translucent GfxObj parts. Sky and
/// sealed off-screen passes retain their independent immediate path.
///
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
// longer degenerates into one draw per alternating particle texture.
private readonly record struct BatchKey(bool Additive);
private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance);
private readonly record struct MeshBatchKey(uint GfxObjId, int BatchIndex);
private readonly record struct MeshParticleDraw(
MeshBatchKey Key,
ObjectRenderBatch Batch,
MeshParticleInstance Instance);
private readonly record struct DeferredParticleDraw(
ParticleSubmissionKind Kind,
ParticleDraw Billboard,
MeshParticleDraw Mesh,
Matrix4x4 ViewProjection);
private readonly struct ParticleInstance
{
public readonly Vector3 Position;
public readonly Vector3 AxisX;
public readonly Vector3 AxisY;
public readonly uint ColorArgb;
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
public readonly float DistanceSq;
public ParticleInstance(
Vector3 position,
Vector3 axisX,
Vector3 axisY,
uint colorArgb,
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
float distanceSq)
{
Position = position;
AxisX = axisX;
AxisY = axisY;
ColorArgb = colorArgb;
TextureSlot = textureSlot;
DistanceSq = distanceSq;
}
}
///
/// Vertex-instance ABI shared with particle.vert. Campaign V slice V2c
/// (2026-07-27): TextureHandleLow/High (the split halves of a raw 64-bit
/// ARB_bindless_texture handle) became one TextureIndex — a slot into the
/// binding=9 handle table — so ordered particles using different textures
/// still remain one instanced draw when their blend mode matches.
///
[StructLayout(LayoutKind.Sequential)]
internal struct BillboardGpuInstance
{
public Vector4 Center;
public Vector4 AxisX;
public Vector4 AxisY;
public Vector4 Color;
public uint TextureIndex;
}
private readonly struct MeshParticleInstance
{
public readonly Matrix4x4 Model;
public readonly uint ColorArgb;
public readonly float DistanceSq;
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
{
Model = model;
ColorArgb = colorArgb;
DistanceSq = distanceSq;
}
}
private readonly TextureCache? _textures;
private readonly IDatReaderWriter? _dats;
private readonly WbMeshAdapter? _meshAdapter;
private readonly ParticleSystem _particles;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly Dictionary _particleGfxInfoByGfxObj = new();
private readonly Dictionary _particleGfxInfoByEmitter = new();
private readonly Dictionary _geometryKindByGfxObj = new();
private readonly Dictionary _meshBlendBySurface = new();
private readonly ParticleMeshReferenceTracker? _meshReferences;
private readonly ParticleEmitterRetirementTracker _emitterRetirements;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
private readonly HashSet _meshLoadRequestedThisFrame = new();
private bool _dynamicFrameStarted;
///
/// The GL arm's per-flight VAO/VBO pool this used to report on was deleted
/// at Campaign V slice V11: the RHI arm draws every particle instance from
/// a ring allocation that lives until its frame retires, so there is no
/// persistent dynamic-buffer pool left to size.
///
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
private float[] _meshInstanceScratch = new float[256 * 20];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
// each previously `new`ing a List (BuildDrawList) and a
// List (the per-batch `run` list) that became garbage
// as soon as the call returned. All Draw() calls happen sequentially on
// the render thread (verified: every call site in GameWindow.cs is a
// plain synchronous invocation from the single-threaded OnRender chain,
// none dispatched via Task.Run/Parallel) and each call fully drains its
// lists before returning, so a single pair of reused fields is safe -
// no call overlaps another's use of these buffers.
private readonly List _drawListScratch = new(64);
private readonly List _runScratch = new(64);
private readonly List _meshDrawListScratch = new(64);
private readonly List _meshRunScratch = new(64);
private readonly List _submissionScratch = new(128);
private readonly List _scopedEmitterScratch = new(64);
private readonly List _deferredAlpha = new(128);
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
private uint[] _preparedInstanceOffsets = new uint[256];
private int _preparedAlphaCount;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
internal long RetainedAlphaScratchBytes => checked(
(long)_deferredAlpha.Capacity * Unsafe.SizeOf()
+ (long)_preparedAlpha.Length * Unsafe.SizeOf()
+ (long)_preparedInstanceOffsets.Length * sizeof(uint));
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
{
public void PrepareAlphaDraws(ReadOnlySpan tokens)
=> owner.PrepareDeferredAlphaDraws(tokens);
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
public void ResetAlphaSubmissions()
=> owner.ResetDeferredAlpha();
}
///
/// Starts one render frame. Wb point-of-use recovery is limited to one
/// request per missing GfxObj even though portal slicing may invoke Draw
/// many times during the frame.
///
public void BeginFrame(int frameSlot)
{
// The GL arm's per-flight VAO/VBO pool this used to index into was
// deleted at Campaign V slice V11; the RHI arm takes every instance
// from a ring allocation, so frameSlot is validated but otherwise
// unused here.
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_dynamicFrameStarted = true;
_meshLoadRequestedThisFrame.Clear();
_emitterRetirements.RetryPending();
_textures?.TickParticleTextureCache();
}
public void Draw(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
Func? emitterFilter = null)
{
if (camera is null)
return;
Matrix4x4.Invert(camera.View, out var invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(
cameraWorldPos,
renderPass,
cameraRight,
cameraUp,
emitterFilter,
scopedEmitters: null);
FinishDraw(camera, renderPass);
}
public void DrawForOwners(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
IReadOnlySet attachedOwnerIds,
bool includeUnattached = false,
IReadOnlySet? excludedAttachedOwnerIds = null)
{
if (camera is null)
return;
_particles.CopyRenderableEmittersForOwners(
renderPass,
attachedOwnerIds,
includeUnattached,
_scopedEmitterScratch,
excludedAttachedOwnerIds);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(
cameraWorldPos,
renderPass,
cameraRight,
cameraUp,
emitterFilter: null,
_scopedEmitterScratch);
FinishDraw(camera, renderPass);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{
if (_submissionScratch.Count == 0)
return;
if (renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true)
DeferToRetailAlphaQueue(camera);
else
DrawOrdered(camera);
}
private void DeferToRetailAlphaQueue(ICamera camera)
{
RetailAlphaQueue queue = _alphaQueue!;
Matrix4x4 viewProjection = camera.View * camera.Projection;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
DeferredParticleDraw deferred = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = _deferredAlpha.Count;
_deferredAlpha.Add(deferred);
queue.Submit(
_alphaSource,
token,
MathF.Sqrt(MathF.Max(0f, submission.DistanceSq)));
}
}
private void DrawOrdered(ICamera camera)
{
DrawOrderedRhi(camera);
}
private void PrepareDeferredAlphaDraws(ReadOnlySpan tokens)
{
if (tokens.Length == 0)
return;
PrepareDeferredAlphaDrawsRhi(tokens);
}
private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
if (drawCount <= 0)
return;
if (firstPreparedDraw < 0
|| firstPreparedDraw > _preparedAlphaCount - drawCount)
throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw));
DrawPreparedAlphaBatchRhi(firstPreparedDraw, drawCount);
}
private void ResetDeferredAlpha()
{
int observedCount = Math.Max(
_deferredAlpha.Count,
_preparedAlphaCount);
_deferredAlpha.Clear();
_preparedAlphaCount = 0;
int currentCapacity = Math.Max(
_deferredAlpha.Capacity,
Math.Max(
_preparedAlpha.Length,
_preparedInstanceOffsets.Length));
int bytesPerDraw = checked(
2 * Unsafe.SizeOf() + sizeof(uint));
int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedCount,
bytesPerDraw,
minimumCapacity: 256,
growthQuantum: 256);
if (targetCapacity >= currentCapacity)
return;
_deferredAlpha.Capacity = targetCapacity;
Array.Resize(ref _preparedAlpha, targetCapacity);
Array.Resize(ref _preparedInstanceOffsets, targetCapacity);
}
private void BuildDrawLists(
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
Vector3 cameraRight,
Vector3 cameraUp,
Func? emitterFilter,
IReadOnlyList? scopedEmitters)
{
var draws = _drawListScratch;
draws.Clear();
_meshDrawListScratch.Clear();
_submissionScratch.Clear();
int sequence = 0;
if (scopedEmitters is not null)
{
for (int i = 0; i < scopedEmitters.Count; i++)
{
AppendEmitterDraws(
scopedEmitters[i],
cameraWorldPos,
cameraRight,
cameraUp,
ref sequence);
}
return;
}
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
{
if (emitterFilter is null || emitterFilter(emitter))
AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence);
}
}
private void AppendEmitterDraws(
RuntimeParticleEmitter em,
Vector3 cameraWorldPos,
Vector3 cameraRight,
Vector3 cameraUp,
ref int sequence)
{
List draws = _drawListScratch;
ParticleGfxInfo gfxInfo = default;
bool gfxInfoResolved = false;
for (int idx = 0; idx < em.Particles.Length; idx++)
{
ref Particle p = ref em.Particles[idx];
if (!p.Alive)
continue;
// `p.Position` is already in world coordinates: AttachLocal
// emitters get their AnchorPos refreshed each frame by the
// owning subsystem (sky-PES driver, animation tick, etc.) which
// mirrors retail's live-parent-frame read at
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
Vector3 pos = p.Position;
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
&& TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
{
continue;
}
if (!gfxInfoResolved)
{
gfxInfo = ResolveParticleGfxInfo(em);
gfxInfoResolved = true;
}
bool additive = gfxInfo.HasMaterial
? gfxInfo.Additive
: (em.Desc.Flags & EmitterFlags.Additive) != 0;
var key = new BatchKey(additive);
Vector3 axisX;
Vector3 axisY;
if (gfxInfo.IsBillboard)
{
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
axisX = cameraRight * (gfxInfo.Size.X * p.Size);
axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
}
else
{
Quaternion orientation = ParticleOrientation(em, p);
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(
key,
new ParticleInstance(
pos,
axisX,
axisY,
p.ColorArgb,
gfxInfo.TextureSlot,
distSq)));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard,
drawIndex,
distSq,
sequence++));
}
}
private bool TryAppendMeshDraws(
AcDream.Core.Vfx.ParticleEmitter emitter,
Particle particle,
uint gfxObjId,
Vector3 cameraWorldPosition,
ref int sequence)
{
if (_meshAdapter is null || !MeshParticlesAvailable)
return true;
_meshReferences!.Register(emitter.Handle, gfxObjId);
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (_meshLoadRequestedThisFrame.Add(gfxObjId))
_meshAdapter.EnsureLoaded(gfxObjId);
return true;
}
Quaternion orientation = ParticleOrientation(emitter, particle);
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
renderData.SortCenter,
model,
cameraWorldPosition);
float distanceSq = viewerDistance * viewerDistance;
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
ObjectRenderBatch batch = renderData.Batches[batchIndex];
if (batch.IndexCount <= 0 || !batch.TextureSlot.IsAssigned)
continue;
int drawIndex = _meshDrawListScratch.Count;
_meshDrawListScratch.Add(new MeshParticleDraw(
new MeshBatchKey(gfxObjId, batchIndex),
batch,
instance));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Mesh,
drawIndex,
distanceSq,
sequence++));
}
return true;
}
///
/// Campaign V slice V6e: the shader-side spelling of "this particle has no
/// texture, draw the procedural blob". It must agree with
/// ACDREAM_TEXTURE_NONE in Shaders/common.glsl and in the
/// Vulkan preamble.
///
/// V2c encoded the same fact as "a table slot whose handle is zero",
/// which particle.frag could test because GL's emulated table stores the
/// handles themselves. Vulkan's table is an opaque descriptor array with
/// nothing to compare — reading an unwritten element of a partially-bound
/// array is undefined, not zero — so the fact moves into the index, where
/// both dialects test it the same way. GL renders identically: the same
/// particles take the same branch.
///
private const uint NoTextureSlot = 0xFFFFFFFFu;
// Campaign V slice V4t: static again — the particle already carries the
// device's table slot, so there is no per-renderer interning left to do.
// GpuTextureSlot.Unassigned and NoTextureSlot are the same 0xFFFFFFFF by
// construction (the contract's sentinel IS ACDREAM_TEXTURE_NONE), so the
// untextured branch collapses into the assignment rather than disappearing.
private static void WriteBillboardGpuInstance(
ref BillboardGpuInstance destination,
ParticleInstance particle)
{
destination = new BillboardGpuInstance
{
Center = new Vector4(particle.Position, 0f),
AxisX = new Vector4(particle.AxisX, 0f),
AxisY = new Vector4(particle.AxisY, 0f),
Color = new Vector4(
((particle.ColorArgb >> 16) & 0xFF) / 255f,
((particle.ColorArgb >> 8) & 0xFF) / 255f,
(particle.ColorArgb & 0xFF) / 255f,
((particle.ColorArgb >> 24) & 0xFF) / 255f),
TextureIndex = particle.TextureSlot.IsAssigned
? particle.TextureSlot.Index
: NoTextureSlot,
};
}
private static void WriteMeshGpuInstance(
float[] destination,
int offset,
MeshParticleInstance instance)
{
Matrix4x4 model = instance.Model;
destination[offset + 0] = model.M11;
destination[offset + 1] = model.M12;
destination[offset + 2] = model.M13;
destination[offset + 3] = model.M14;
destination[offset + 4] = model.M21;
destination[offset + 5] = model.M22;
destination[offset + 6] = model.M23;
destination[offset + 7] = model.M24;
destination[offset + 8] = model.M31;
destination[offset + 9] = model.M32;
destination[offset + 10] = model.M33;
destination[offset + 11] = model.M34;
destination[offset + 12] = model.M41;
destination[offset + 13] = model.M42;
destination[offset + 14] = model.M43;
destination[offset + 15] = model.M44;
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
}
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
{
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || _dats is null)
return batch.IsAdditive ? TranslucencyKind.Additive : TranslucencyKind.AlphaBlend;
if (_meshBlendBySurface.TryGetValue(surfaceId, out TranslucencyKind blend))
return blend;
blend = RetailParticleBlendResolver.Resolve(
surfaceId,
batch.IsAdditive,
id => _dats.Get(id),
Console.Error.WriteLine);
_meshBlendBySurface[surfaceId] = blend;
return blend;
}
private RetailParticleGeometryKind ResolveGeometryKind(uint gfxObjId)
{
if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind))
return kind;
uint? firstDegradeMode = null;
try
{
if (_dats?.Get(gfxObjId) is { } gfx
&& gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)
&& gfx.DIDDegrade != 0
&& _dats.Get(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade)
{
firstDegradeMode = degrade.Degrades[0].DegradeMode;
}
}
catch (Exception ex)
{
// Missing/corrupt content must not invent a billboard. Cache the
// retail full-mesh choice and let WbMeshAdapter's normal missing-
// asset diagnostics decide whether geometry can be presented.
Console.Error.WriteLine(
$"[particle-geometry] Failed to decode GfxObj 0x{gfxObjId:X8} degrade metadata: {ex.Message}");
}
kind = RetailParticleGeometryClassifier.Classify(firstDegradeMode);
_geometryKindByGfxObj[gfxObjId] = kind;
return kind;
}
private void OnEmitterDied(int handle)
{
_emitterRetirements.BeginRetirement(handle);
}
private ParticleGfxInfo ResolveParticleGfxInfo(RuntimeParticleEmitter emitter)
{
if (_textures is null)
return ParticleGfxInfo.Default;
if (_particleGfxInfoByEmitter.TryGetValue(emitter.Handle, out ParticleGfxInfo resolved))
return resolved;
EmitterDesc desc = emitter.Desc;
if (desc.TextureSurfaceId != 0)
{
resolved = ParticleGfxInfo.Billboard(
_textures.AcquireParticleTexture(emitter.Handle, desc.TextureSurfaceId),
Vector2.One,
Vector3.Zero,
additive: (desc.Flags & EmitterFlags.Additive) != 0,
hasMaterial: false,
surfaceId: desc.TextureSurfaceId);
_particleGfxInfoByEmitter.Add(emitter.Handle, resolved);
return resolved;
}
uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId;
if (gfxObjId == 0 || _dats is null)
return ParticleGfxInfo.Default;
if (!_particleGfxInfoByGfxObj.TryGetValue(gfxObjId, out var info))
{
info = ReadParticleGfxInfo(gfxObjId);
_particleGfxInfoByGfxObj[gfxObjId] = info;
}
resolved = info.SurfaceId == 0
? ParticleGfxInfo.Default
: info with
{
TextureSlot = _textures.AcquireParticleTexture(
emitter.Handle,
info.SurfaceId),
};
_particleGfxInfoByEmitter.Add(emitter.Handle, resolved);
return resolved;
}
private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId)
{
try
{
var gfx = _dats?.Get(gfxObjId);
if (gfx is null)
return ParticleGfxInfo.Default;
uint surfaceId = gfx.Surfaces.Count > 0 ? gfx.Surfaces[0].DataId : 0u;
bool additive = false;
if (surfaceId != 0)
{
var surface = _dats?.Get(surfaceId);
additive = surface is not null && surface.Type.HasFlag(SurfaceType.Additive);
}
return AuthoredParticleGfxInfo(
gfx,
// Shape only: the caller re-resolves the slot per emitter, so
// this record is cached with no texture rather than slot 0.
texture: AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned,
additive,
hasMaterial: surfaceId != 0,
surfaceId: surfaceId);
}
catch
{
return ParticleGfxInfo.Default;
}
}
private ParticleGfxInfo AuthoredParticleGfxInfo(
GfxObj gfx,
AcDream.App.Rendering.Gpu.GpuTextureSlot texture,
bool additive,
bool hasMaterial,
uint surfaceId)
{
if (gfx.VertexArray.Vertices.Count == 0)
return ParticleGfxInfo.Billboard(
texture,
Vector2.One,
Vector3.Zero,
additive,
hasMaterial,
surfaceId);
var min = new Vector3(float.PositiveInfinity);
var max = new Vector3(float.NegativeInfinity);
foreach (var (_, v) in gfx.VertexArray.Vertices)
{
min = Vector3.Min(min, v.Origin);
max = Vector3.Max(max, v.Origin);
}
var size = max - min;
var center = (min + max) * 0.5f;
if (IsPointSprite(gfx))
{
float sx = FallbackParticleExtent(size.X) * 0.9f;
float sy = FallbackParticleExtent(size.Z) * 0.9f;
return ParticleGfxInfo.Billboard(
texture,
new Vector2(sx, sy),
center,
additive,
hasMaterial,
surfaceId);
}
Vector3 axisX;
Vector3 axisY;
Vector2 planeSize;
if (size.Y > size.X && size.Y > size.Z)
{
if (size.X > size.Z)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitY;
planeSize = new Vector2(size.X, size.Y);
}
else
{
axisX = Vector3.UnitY;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.Y, size.Z);
}
}
else if (size.X > size.Y && size.X > size.Z)
{
if (size.Z > size.Y)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.X, size.Z);
}
else
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitY;
planeSize = new Vector2(size.X, size.Y);
}
}
else
{
if (size.X > size.Y)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.X, size.Z);
}
else
{
axisX = Vector3.UnitY;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.Y, size.Z);
}
}
planeSize.X = FallbackParticleExtent(planeSize.X);
planeSize.Y = FallbackParticleExtent(planeSize.Y);
return new ParticleGfxInfo(
texture,
planeSize,
axisX,
axisY,
center,
false,
additive,
hasMaterial,
surfaceId);
}
private bool IsPointSprite(GfxObj gfx)
{
if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null)
return false;
try
{
var degrade = _dats.Get(gfx.DIDDegrade);
return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2;
}
catch
{
return false;
}
}
private static float FallbackParticleExtent(float value)
=> value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f;
private static Quaternion ParticleOrientation(AcDream.Core.Vfx.ParticleEmitter em, Particle p)
{
Quaternion orientation = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0
? em.AnchorRot
: p.SpawnRotation;
if (em.Desc.Type is AcDream.Core.Vfx.ParticleType.ParabolicLVGAGR
or AcDream.Core.Vfx.ParticleType.ParabolicLVLALR
or AcDream.Core.Vfx.ParticleType.ParabolicGVGAGR)
{
Vector3 angular = p.C * p.Age;
float radians = angular.Length();
if (radians > 1e-6f)
orientation = Quaternion.Normalize(orientation * Quaternion.CreateFromAxisAngle(angular / radians, radians));
}
return orientation;
}
public void Dispose()
{
if (_disposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
BuildDisposeReleases(releases);
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more particle renderer resources could not be released.");
}
CompleteDispose();
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Particle renderer resources released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
private void BuildDisposeReleases(List<(string Name, Action Release)> releases)
{
releases.Add(("emitter-death-subscription", () =>
_particles.EmitterDied -= OnEmitterDied));
releases.Add(("emitter-resources", RetireEveryResolvedEmitter));
if (_meshReferences is not null)
releases.Add(("mesh-references", _meshReferences.Dispose));
// 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. The raw-GL release path (tracked
// quad/dynamic-buffer/shader deletions) was deleted at Campaign V
// slice V11.
releases.Add(("rhi-resources", DisposeRhiResources));
}
private void RetireEveryResolvedEmitter()
{
int[] handles = [.. _particleGfxInfoByEmitter.Keys];
for (int i = 0; i < handles.Length; i++)
_emitterRetirements.BeginRetirement(handles[i]);
_emitterRetirements.CompleteOrThrow();
}
private void CompleteDispose()
{
_dynamicFrameStarted = false;
_particleGfxInfoByEmitter.Clear();
_particleGfxInfoByGfxObj.Clear();
_geometryKindByGfxObj.Clear();
_meshBlendBySurface.Clear();
_deferredAlpha.Clear();
}
private readonly record struct ParticleGfxInfo(
AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot,
Vector2 Size,
Vector3 AxisX,
Vector3 AxisY,
Vector3 CenterOffset,
bool IsBillboard,
bool Additive,
bool HasMaterial,
uint SurfaceId)
{
public static ParticleGfxInfo Default { get; } =
Billboard(
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned,
Vector2.One,
Vector3.Zero,
additive: false,
hasMaterial: false,
surfaceId: 0);
public static ParticleGfxInfo Billboard(
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
Vector2 size,
Vector3 centerOffset,
bool additive,
bool hasMaterial,
uint surfaceId) =>
new(
textureSlot,
size,
Vector3.UnitX,
Vector3.UnitY,
centerOffset,
true,
additive,
hasMaterial,
surfaceId);
}
}