using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; 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 Silk.NET.OpenGL; 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 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 ulong TextureHandle; public readonly float DistanceSq; public ParticleInstance( Vector3 position, Vector3 axisX, Vector3 axisY, uint colorArgb, ulong textureHandle, float distanceSq) { Position = position; AxisX = axisX; AxisY = axisY; ColorArgb = colorArgb; TextureHandle = textureHandle; DistanceSq = distanceSq; } } /// /// Vertex-instance ABI shared with particle.vert. The resident texture /// handle is carried per particle so ordered particles using different /// textures 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 TextureHandleLow; public uint TextureHandleHigh; } 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 GL _gl; private readonly Shader _shader; private readonly Shader? _meshShader; 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 readonly int _meshTextureHandleLoc = -1; private readonly int _meshTextureLayerLoc = -1; private uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; private uint _instanceVbo; private uint _meshVao; private uint _meshInstanceVbo; private int _instanceVboCapacityBytes; private int _meshInstanceVboCapacityBytes; private sealed class DynamicBufferSet { public uint BillboardVao; public uint BillboardInstanceVbo; public int BillboardCapacityBytes; public uint MeshVao; public uint MeshInstanceVbo; public int MeshCapacityBytes; } private readonly List[] _dynamicBufferSetsByFrame = [[], [], []]; private int _dynamicFrameSlot; private int _dynamicBufferSetCursor; private bool _dynamicFrameStarted; private DynamicBufferSet? _activeDynamicBufferSet; internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics { get { int count = 0; long bytes = 0; foreach (List frameSets in _dynamicBufferSetsByFrame) { count += frameSets.Count; foreach (DynamicBufferSet set in frameSets) bytes += set.BillboardCapacityBytes + set.MeshCapacityBytes; } return (count, bytes); } } 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 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(); } internal ParticleRenderer( GL gl, string shadersDir, ParticleSystem particles, TextureCache? textures = null, IDatReaderWriter? dats = null, WbMeshAdapter? meshAdapter = null, RetailAlphaQueue? alphaQueue = null) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _textures = textures; _dats = dats; _meshAdapter = meshAdapter; _particles = particles ?? throw new ArgumentNullException(nameof(particles)); _alphaQueue = alphaQueue; _alphaSource = new AlphaDrawSource(this); 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}")); _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), System.IO.Path.Combine(shadersDir, "particle.frag")); if (_meshAdapter?.MeshManager?.GlobalBuffer is not null) { _meshShader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle_mesh.vert"), System.IO.Path.Combine(shadersDir, "particle_mesh.frag")); _meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle"); _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); } float[] quadVerts = { -0.5f, -0.5f, 0f, 0f, 0.5f, -0.5f, 1f, 0f, 0.5f, 0.5f, 1f, 1f, -0.5f, 0.5f, 0f, 1f, }; uint[] quadIdx = { 0, 1, 2, 0, 2, 3 }; uint quadVbo = 0; uint quadEbo = 0; uint uploadVao = 0; bool vboAllocated = false; bool eboAllocated = false; try { quadVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle quad VBO"); fixed (void* p = quadVerts) { TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ArrayBuffer, quadVbo, 0, quadVerts.Length * sizeof(float), GLEnum.StaticDraw, p, "uploading particle quad VBO"); } vboAllocated = true; quadEbo = TrackedGlResource.CreateBuffer(_gl, "creating particle quad EBO"); uploadVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle upload VAO"); _gl.BindVertexArray(uploadVao); fixed (void* p = quadIdx) { TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ElementArrayBuffer, quadEbo, 0, quadIdx.Length * sizeof(uint), GLEnum.StaticDraw, p, "uploading particle quad EBO"); } eboAllocated = true; _gl.BindVertexArray(0); TrackedGlResource.DeleteVertexArray(_gl, uploadVao, "deleting particle upload VAO"); uploadVao = 0; _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _quadVbo = quadVbo; _quadEbo = quadEbo; } catch (Exception creationFailure) { List? cleanupFailures = null; void Attempt(Action action) { try { action(); } catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } } if (uploadVao != 0) Attempt(() => TrackedGlResource.DeleteVertexArray(_gl, uploadVao, "rolling back particle upload VAO")); if (quadEbo != 0) Attempt(() => TrackedGlResource.DeleteBuffer( _gl, quadEbo, eboAllocated ? quadIdx.Length * sizeof(uint) : 0, "rolling back particle quad EBO")); if (quadVbo != 0) Attempt(() => TrackedGlResource.DeleteBuffer( _gl, quadVbo, vboAllocated ? quadVerts.Length * sizeof(float) : 0, "rolling back particle quad VBO")); if (cleanupFailures is not null) { cleanupFailures.Insert(0, creationFailure); throw new AggregateException("Particle static-buffer creation and rollback failed.", cleanupFailures); } throw; } _particles.EmitterDied += OnEmitterDied; } /// /// 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) { if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); _dynamicFrameSlot = frameSlot; _dynamicBufferSetCursor = 0; _dynamicFrameStarted = true; _activeDynamicBufferSet = null; _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) { ParticleSubmissionOrdering.Sort(_submissionScratch); GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; Matrix4x4 viewProjection = camera.View * camera.Projection; _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); for (int i = 0; i < _submissionScratch.Count;) { ParticleSubmission submission = _submissionScratch[i]; if (submission.Kind == ParticleSubmissionKind.Billboard) { ParticleDraw draw = _drawListScratch[submission.DrawIndex]; BatchKey key = draw.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); _gl.BlendFunc( BlendingFactor.SrcAlpha, key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); DrawInstances(_runScratch, viewProjection); continue; } if (_meshShader is null || 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); ApplyMeshCullMode(batch.CullMode); TranslucencyKind blend = ResolveMeshBlend(batch); _gl.BlendFunc( blend == TranslucencyKind.InvAlpha ? BlendingFactor.OneMinusSrcAlpha : BlendingFactor.SrcAlpha, blend switch { TranslucencyKind.Additive => BlendingFactor.One, TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha, _ => BlendingFactor.OneMinusSrcAlpha, }); _gl.ProgramUniform2( _meshShader.Program, _meshTextureHandleLoc, (uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), (uint)(batch.BindlessTextureHandle >> 32)); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); UploadMeshInstances(_meshRunScratch); PrepareMeshPipeline(viewProjection, global); _gl.DrawElementsInstancedBaseVertex( PrimitiveType.Triangles, (uint)batch.IndexCount, DrawElementsType.UnsignedShort, (void*)(batch.FirstIndex * sizeof(ushort)), (uint)_meshRunScratch.Count, (int)batch.BaseVertex); } _gl.BindVertexArray(0); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); _gl.Disable(EnableCap.CullFace); } private void PrepareDeferredAlphaDraws(ReadOnlySpan tokens) { if (tokens.Length == 0) return; ActivateNextDynamicBufferSet(); 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 * 20; if (_meshInstanceScratch.Length < neededMeshFloats) Array.Resize(ref _meshInstanceScratch, neededMeshFloats + 256 * 20); 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++ * 20, deferred.Mesh.Instance); } } if (billboardCount > 0) { fixed (void* bp = _instanceScratch) UploadDynamicArrayBuffer( _instanceVbo, ref _instanceVboCapacityBytes, bp, billboardCount * sizeof(BillboardGpuInstance)); } if (meshCount > 0) { fixed (void* mp = _meshInstanceScratch) UploadDynamicArrayBuffer( _meshInstanceVbo, ref _meshInstanceVboCapacityBytes, mp, meshCount * 20 * sizeof(float)); } PersistActiveDynamicBufferCapacities(); _preparedAlphaCount = count; } private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) { if (drawCount <= 0) return; if (firstPreparedDraw < 0 || firstPreparedDraw > _preparedAlphaCount - drawCount) throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw)); GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); ParticleSubmissionKind? activeKind = null; Matrix4x4 activeViewProjection = default; int i = firstPreparedDraw; int preparedEnd = firstPreparedDraw + drawCount; while (i < preparedEnd) { DeferredParticleDraw deferred = _preparedAlpha[i]; if (deferred.Kind == ParticleSubmissionKind.Billboard) { ParticleDraw draw = deferred.Billboard; BatchKey key = draw.Key; if (activeKind != ParticleSubmissionKind.Billboard || activeViewProjection != deferred.ViewProjection) { PrepareBillboardPipeline(deferred.ViewProjection); activeKind = ParticleSubmissionKind.Billboard; activeViewProjection = 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 == activeViewProjection); _gl.BlendFunc( BlendingFactor.SrcAlpha, key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); _gl.BindVertexArray(_quadVao); _gl.DrawElementsInstancedBaseInstance( PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)(i - runStart), baseInstance); continue; } if (_meshShader is null || global is null) { i++; continue; } MeshParticleDraw meshDraw = deferred.Mesh; MeshBatchKey meshKey = meshDraw.Key; ObjectRenderBatch batch = meshDraw.Batch; if (activeKind != ParticleSubmissionKind.Mesh || activeViewProjection != deferred.ViewProjection) { PrepareMeshPipeline(deferred.ViewProjection, global); activeKind = ParticleSubmissionKind.Mesh; activeViewProjection = 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 == activeViewProjection); ApplyMeshCullMode(batch.CullMode); TranslucencyKind blend = ResolveMeshBlend(batch); _gl.BlendFunc( blend == TranslucencyKind.InvAlpha ? BlendingFactor.OneMinusSrcAlpha : BlendingFactor.SrcAlpha, blend switch { TranslucencyKind.Additive => BlendingFactor.One, TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha, _ => BlendingFactor.OneMinusSrcAlpha, }); _gl.ProgramUniform2( _meshShader.Program, _meshTextureHandleLoc, (uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), (uint)(batch.BindlessTextureHandle >> 32)); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); _gl.BindVertexArray(_meshVao); _gl.DrawElementsInstancedBaseVertexBaseInstance( PrimitiveType.Triangles, (uint)batch.IndexCount, DrawElementsType.UnsignedShort, (void*)(batch.FirstIndex * sizeof(ushort)), (uint)(i - meshRunStart), (int)batch.BaseVertex, meshBaseInstance); } _gl.BindVertexArray(0); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); _gl.Disable(EnableCap.CullFace); } private void ResetDeferredAlpha() { _deferredAlpha.Clear(); _preparedAlphaCount = 0; } private void PrepareBillboardPipeline(Matrix4x4 viewProjection) { _shader.Use(); _shader.SetMatrix4("uViewProjection", viewProjection); _gl.Disable(EnableCap.CullFace); _gl.BindVertexArray(_quadVao); } private void PrepareMeshPipeline(Matrix4x4 viewProjection, GlobalMeshBuffer global) { _meshShader!.Use(); _meshShader.SetMatrix4("uViewProjection", viewProjection); _gl.FrontFace(FrontFaceDirection.CW); _gl.BindVertexArray(_meshVao); // GlobalMeshBuffer may grow and replace either backing buffer. Bind // today's buffer names on every pipeline switch instead of caching // them in this VAO at construction time. _gl.BindBuffer(BufferTargetARB.ArrayBuffer, global.VBO); int vertexStride = VertexPositionNormalTexture.Size; _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)0); _gl.EnableVertexAttribArray(1); _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(3 * sizeof(float))); _gl.EnableVertexAttribArray(2); _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(6 * sizeof(float))); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, global.IBO); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo); const int instanceStride = 20 * sizeof(float); for (uint column = 0; column < 4; column++) { uint location = 3 + column; _gl.EnableVertexAttribArray(location); _gl.VertexAttribPointer( location, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(column * 4 * sizeof(float))); _gl.VertexAttribDivisor(location, 1); } _gl.EnableVertexAttribArray(7); _gl.VertexAttribPointer(7, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(16 * sizeof(float))); _gl.VertexAttribDivisor(7, 1); } 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.TextureHandle, 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 || _meshShader is null) 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.BindlessTextureHandle == 0) 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; } private void DrawInstances(List instances, Matrix4x4 viewProjection) { if (instances.Count == 0) return; ActivateNextDynamicBufferSet(); 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]); fixed (void* bp = _instanceScratch) UploadDynamicArrayBuffer( _instanceVbo, ref _instanceVboCapacityBytes, bp, instances.Count * sizeof(BillboardGpuInstance)); PersistActiveDynamicBufferCapacities(); PrepareBillboardPipeline(viewProjection); _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); } private void UploadMeshInstances(List instances) { ActivateNextDynamicBufferSet(); int needed = instances.Count * 20; if (_meshInstanceScratch.Length < needed) _meshInstanceScratch = new float[needed + 256 * 20]; for (int i = 0; i < instances.Count; i++) WriteMeshGpuInstance(_meshInstanceScratch, i * 20, instances[i]); fixed (void* bp = _meshInstanceScratch) UploadDynamicArrayBuffer( _meshInstanceVbo, ref _meshInstanceVboCapacityBytes, bp, instances.Count * 20 * sizeof(float)); PersistActiveDynamicBufferCapacities(); } 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), TextureHandleLow = (uint)(particle.TextureHandle & 0xFFFFFFFFu), TextureHandleHigh = (uint)(particle.TextureHandle >> 32), }; } 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 void ActivateNextDynamicBufferSet() { if (!_dynamicFrameStarted) throw new InvalidOperationException("BeginFrame must be called before drawing particles."); List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; if (_dynamicBufferSetCursor == slotSets.Count) slotSets.Add(CreateDynamicBufferSet()); DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; _activeDynamicBufferSet = set; _quadVao = set.BillboardVao; _instanceVbo = set.BillboardInstanceVbo; _instanceVboCapacityBytes = set.BillboardCapacityBytes; _meshVao = set.MeshVao; _meshInstanceVbo = set.MeshInstanceVbo; _meshInstanceVboCapacityBytes = set.MeshCapacityBytes; } private DynamicBufferSet CreateDynamicBufferSet() { var set = new DynamicBufferSet(); try { set.BillboardVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle billboard VAO"); set.BillboardInstanceVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle billboard instance VBO"); set.MeshVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle mesh VAO"); set.MeshInstanceVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle mesh instance VBO"); _gl.BindVertexArray(set.BillboardVao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo); _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0); _gl.EnableVertexAttribArray(1); _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float))); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.BillboardInstanceVbo); uint instanceStride = (uint)sizeof(BillboardGpuInstance); _gl.EnableVertexAttribArray(2); _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)0); _gl.VertexAttribDivisor(2, 1); _gl.EnableVertexAttribArray(3); _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(4 * sizeof(float))); _gl.VertexAttribDivisor(3, 1); _gl.EnableVertexAttribArray(4); _gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(8 * sizeof(float))); _gl.VertexAttribDivisor(4, 1); _gl.EnableVertexAttribArray(5); _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float))); _gl.VertexAttribDivisor(5, 1); _gl.EnableVertexAttribArray(6); _gl.VertexAttribIPointer( 6, 2, VertexAttribIType.UnsignedInt, instanceStride, (void*)(16 * sizeof(float))); _gl.VertexAttribDivisor(6, 1); _gl.BindVertexArray(0); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); GLHelpers.ThrowOnResourceError(_gl, "configuring particle dynamic VAOs"); return set; } catch (Exception creationFailure) { try { DeleteDynamicBufferSet(set); } catch (Exception cleanupFailure) { throw new AggregateException( "Particle dynamic-buffer creation and rollback failed.", creationFailure, cleanupFailure); } throw; } } private void DeleteDynamicBufferSet(DynamicBufferSet set) { List? failures = null; void Attempt(Action action) { try { action(); } catch (Exception ex) { (failures ??= []).Add(ex); } } Attempt(() => TrackedGlResource.DeleteBuffer( _gl, set.BillboardInstanceVbo, set.BillboardCapacityBytes, "deleting particle billboard instance VBO")); Attempt(() => TrackedGlResource.DeleteBuffer( _gl, set.MeshInstanceVbo, set.MeshCapacityBytes, "deleting particle mesh instance VBO")); Attempt(() => TrackedGlResource.DeleteVertexArray( _gl, set.BillboardVao, "deleting particle billboard VAO")); Attempt(() => TrackedGlResource.DeleteVertexArray( _gl, set.MeshVao, "deleting particle mesh VAO")); if (failures is not null) throw new AggregateException("One or more particle dynamic resources failed to delete.", failures); } private void PersistActiveDynamicBufferCapacities() { DynamicBufferSet set = _activeDynamicBufferSet ?? throw new InvalidOperationException("No dynamic particle buffer set is active."); set.BillboardCapacityBytes = _instanceVboCapacityBytes; set.MeshCapacityBytes = _meshInstanceVboCapacityBytes; } private void UploadDynamicArrayBuffer( uint buffer, ref int capacityBytes, void* data, int byteCount) { if (byteCount <= 0) throw new ArgumentOutOfRangeException(nameof(byteCount)); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, buffer); if (capacityBytes < byteCount) { int grownCapacity = DynamicBufferCapacity.Grow(capacityBytes, byteCount); TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ArrayBuffer, buffer, capacityBytes, grownCapacity, GLEnum.DynamicDraw, $"growing particle dynamic buffer {buffer} to {grownCapacity} bytes"); capacityBytes = grownCapacity; } _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)byteCount, data); } private void ApplyMeshCullMode(CullMode mode) { _gl.FrontFace(FrontFaceDirection.CW); switch (mode) { case CullMode.None: _gl.Disable(EnableCap.CullFace); break; case CullMode.Clockwise: _gl.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: _gl.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Back); break; } } 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 { TextureHandle = _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, texture: 0, additive, hasMaterial: surfaceId != 0, surfaceId: surfaceId); } catch { return ParticleGfxInfo.Default; } } private ParticleGfxInfo AuthoredParticleGfxInfo( GfxObj gfx, ulong 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)); AddTrackedBufferRelease( releases, _quadVbo, 16L * sizeof(float), "quad-vbo", "deleting particle quad VBO"); AddTrackedBufferRelease( releases, _quadEbo, 6L * sizeof(uint), "quad-ebo", "deleting particle quad EBO"); for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) { List frameSets = _dynamicBufferSetsByFrame[frame]; for (int index = 0; index < frameSets.Count; index++) AddDynamicBufferSetReleases(releases, frameSets[index], frame, index); } releases.Add(("billboard-shader", _shader.Dispose)); if (_meshShader is not null) releases.Add(("mesh-shader", _meshShader.Dispose)); } private void RetireEveryResolvedEmitter() { int[] handles = [.. _particleGfxInfoByEmitter.Keys]; for (int i = 0; i < handles.Length; i++) _emitterRetirements.BeginRetirement(handles[i]); _emitterRetirements.CompleteOrThrow(); } private void AddDynamicBufferSetReleases( List<(string Name, Action Release)> releases, DynamicBufferSet set, int frame, int index) { AddTrackedBufferRelease( releases, set.BillboardInstanceVbo, set.BillboardCapacityBytes, $"dynamic-{frame}-{index}-billboard-vbo", "deleting particle billboard instance VBO"); AddTrackedBufferRelease( releases, set.MeshInstanceVbo, set.MeshCapacityBytes, $"dynamic-{frame}-{index}-mesh-vbo", "deleting particle mesh instance VBO"); AddTrackedVertexArrayRelease( releases, set.BillboardVao, $"dynamic-{frame}-{index}-billboard-vao", "deleting particle billboard VAO"); AddTrackedVertexArrayRelease( releases, set.MeshVao, $"dynamic-{frame}-{index}-mesh-vao", "deleting particle mesh VAO"); } private void AddTrackedBufferRelease( List<(string Name, Action Release)> releases, uint buffer, long capacityBytes, string name, string context) { if (buffer == 0) return; RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableBufferDeletion( _gl, buffer, capacityBytes, context); releases.Add((name, release.Run)); } private void AddTrackedVertexArrayRelease( List<(string Name, Action Release)> releases, uint vertexArray, string name, string context) { if (vertexArray == 0) return; RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableVertexArrayDeletion( _gl, vertexArray, context); releases.Add((name, release.Run)); } private void CompleteDispose() { foreach (List frameSets in _dynamicBufferSetsByFrame) frameSets.Clear(); _activeDynamicBufferSet = null; _dynamicFrameStarted = false; _quadVao = 0; _instanceVbo = 0; _meshVao = 0; _meshInstanceVbo = 0; _instanceVboCapacityBytes = 0; _meshInstanceVboCapacityBytes = 0; _particleGfxInfoByEmitter.Clear(); _particleGfxInfoByGfxObj.Clear(); _geometryKindByGfxObj.Clear(); _meshBlendBySurface.Clear(); _deferredAlpha.Clear(); } private readonly record struct ParticleGfxInfo( ulong TextureHandle, Vector2 Size, Vector3 AxisX, Vector3 AxisY, Vector3 CenterOffset, bool IsBillboard, bool Additive, bool HasMaterial, uint SurfaceId) { public static ParticleGfxInfo Default { get; } = Billboard( 0ul, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false, surfaceId: 0); public static ParticleGfxInfo Billboard( ulong textureHandle, Vector2 size, Vector3 centerOffset, bool additive, bool hasMaterial, uint surfaceId) => new( textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial, surfaceId); } }