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 merged by authored CYpt with ordinary translucent /// GfxObj parts at each owning cell turn before either source enters the two /// FIFO lists. 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 readonly uint ClipSlot; public ParticleInstance( Vector3 position, Vector3 axisX, Vector3 axisY, uint colorArgb, AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot, float distanceSq, uint clipSlot) { Position = position; AxisX = axisX; AxisY = axisY; ColorArgb = colorArgb; TextureSlot = textureSlot; DistanceSq = distanceSq; ClipSlot = clipSlot; } } /// /// 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; public uint ClipSlot; } /// Vertex-instance ABI shared with particle_mesh.vert. [StructLayout(LayoutKind.Sequential)] internal struct MeshParticleGpuInstance { public Matrix4x4 Model; public Vector4 Color; public uint ClipSlot; } private readonly struct MeshParticleInstance { public readonly Matrix4x4 Model; public readonly uint ColorArgb; public readonly float DistanceSq; public readonly uint ClipSlot; public MeshParticleInstance( Matrix4x4 model, uint colorArgb, float distanceSq, uint clipSlot) { Model = model; ColorArgb = colorArgb; DistanceSq = distanceSq; ClipSlot = clipSlot; } } 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 ReserveDeferredParticleDraw _reserveDeferredParticleDraw; private readonly DrawImmediateParticle _drawImmediateParticle; private DeferredParticleDraw _dispatchDeferredParticle; private readonly Dictionary _particleGfxInfoByGfxObj = new(); private readonly Dictionary _particleGfxInfoByEmitter = new(); private readonly Dictionary _geometryKindByGfxObj = new(); private readonly Dictionary _firstDegradeModeByGfxObj = 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 MeshParticleGpuInstance[] _meshInstanceScratch = new MeshParticleGpuInstance[256]; // 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 _preparedCellAlphaScratch = 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) + (long)_preparedCellAlphaScratch.Capacity * Unsafe.SizeOf()); internal (int Count, int Capacity, long RetainedBytes) PreparedCellAlphaScratchDiagnostics => ( _preparedCellAlphaScratch.Count, _preparedCellAlphaScratch.Capacity, checked((long)_preparedCellAlphaScratch.Capacity * Unsafe.SizeOf()) ); 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 delegate int ReserveDeferredParticleDraw(); internal delegate void DrawImmediateParticle( Matrix4x4 viewProjection, ParticleSubmissionKind kind, int drawIndex, bool opaqueDepthState); /// /// 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, uint clipSlot = 0) { 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, clipSlot); FinishDraw(camera, renderPass); } public void DrawForOwners( ICamera camera, Vector3 cameraWorldPos, ParticleRenderPass renderPass, IReadOnlySet attachedOwnerIds, bool includeUnattached = false, IReadOnlySet? excludedAttachedOwnerIds = null, uint clipSlot = 0, UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any) { if (camera is null) return; _particles.CopyRenderableEmittersForOwners( renderPass, attachedOwnerIds, includeUnattached, _scopedEmitterScratch, excludedAttachedOwnerIds, unattachedCellScope); 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, clipSlot); FinishDraw(camera, renderPass); } /// /// Draws exactly one cell's renderable emitters — retail /// CPhysicsObj::add_particle_shadow_to_cell (0x00514a70): an /// emitter owns one shadow in its own current cell, drawn at that cell's /// object turn like any object, independent of its attached owner's /// registry membership (a hidden/suspended owner's emitter still draws). /// No portal-view clip is applied here — retail never clips a particle to /// a view; occlusion is the depth test at the alpha flush /// (add_shadows_to_cells 0x00514aed's particle branch skips the /// CELLARRAY flood/clip-planes entirely for this state bit). /// public void DrawForCell( ICamera camera, Vector3 cameraWorldPos, ParticleRenderPass renderPass, uint cellId, uint clipSlot = 0) { if (camera is null) return; _particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch); // Retail's per-cell particle cost for an emitter-less cell is one // count check (RenderDeviceD3D::DrawPartCell 0x005a07a0, // `num_shadow_parts > 0`); every visited land cell reaches here, so // pay nothing more than that here either (chunk 6 review F2). if (_scopedEmitterScratch.Count == 0) return; 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, clipSlot); FinishDraw(camera, renderPass); } /// /// S4-c3a production cell-shadow seam. Builds the same real billboard and /// mesh records as , orders particle parts by /// their authored CYpt key, executes row-5 immediate draws at this exact /// cell turn, and returns only delayed records without appending them. /// The walk driver merges those retained keys with ordinary object parts /// before calling . /// internal ReadOnlySpan PrepareForCellAlpha( ICamera camera, Vector3 cameraWorldPos, ParticleRenderPass renderPass, uint cellId, uint clipSlot = 0) { _preparedCellAlphaScratch.Clear(); if (camera is null) return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch); _particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch); if (_scopedEmitterScratch.Count == 0) return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch); 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, clipSlot); if (_submissionScratch.Count == 0) return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch); bool defers = renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true; if (!defers) { DrawOrdered(camera); return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch); } ParticleSubmissionOrdering.Sort(_submissionScratch); Matrix4x4 viewProjection = camera.View * camera.Projection; RetailAlphaQueue queue = _alphaQueue!; int retainedClipCount = 0; int retainedAlphaCount = 0; for (int i = 0; i < _submissionScratch.Count; i++) { ParticleSubmission submission = _submissionScratch[i]; TranslucencyKind translucency = submission.Kind == ParticleSubmissionKind.Mesh ? _meshDrawListScratch[submission.DrawIndex].Batch.Translucency : default; uint colorArgb = submission.Kind == ParticleSubmissionKind.Mesh ? _meshDrawListScratch[submission.DrawIndex].Instance.ColorArgb : default; RetailAlphaMeshDecision decision = RouteParticleSubmission( submission.Kind, translucency, colorArgb); PreparedCellAlphaActions actions = ResolvePreparedCellAlphaActions( decision, ref retainedClipCount, ref retainedAlphaCount); if (actions.Retain) { _preparedCellAlphaScratch.Add(new PreparedParticleAlphaSubmission( queue, decision.List, _alphaSource, this, submission.Kind, submission.DrawIndex, viewProjection, decision.OverrideClipmap, submission.DistanceSq, submission.Sequence)); } if (actions.DrawImmediate) { _drawImmediateParticle( viewProjection, submission.Kind, submission.DrawIndex, opaqueDepthState: decision.Action == RetailAlphaMeshAction.Immediate); } } return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch); } internal readonly record struct PreparedCellAlphaActions( bool Retain, bool DrawImmediate); /// /// Applies the physical 3,000-entry bound independently to the prepared /// CLIP and ALPHA lists. Row 2's immediate duplicate is deliberately /// independent of retention: even candidate 3,001 still draws now. /// internal static PreparedCellAlphaActions ResolvePreparedCellAlphaActions( RetailAlphaMeshDecision decision, ref int retainedClipCount, ref int retainedAlphaCount) { bool requestsRetention = decision.Action is RetailAlphaMeshAction.Append or RetailAlphaMeshAction.AppendClipAndImmediate; bool retain = false; if (requestsRetention) { if (decision.List == RetailAlphaList.Clip) { if (retainedClipCount < RetailAlphaQueue.ListCapacity) { retainedClipCount++; retain = true; } } else if (retainedAlphaCount < RetailAlphaQueue.ListCapacity) { retainedAlphaCount++; retain = true; } } return new PreparedCellAlphaActions( retain, decision.Action is RetailAlphaMeshAction.Immediate or RetailAlphaMeshAction.AppendClipAndImmediate); } private void FinishDraw(ICamera camera, ParticleRenderPass renderPass) { if (_submissionScratch.Count == 0) return; bool defers = renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true; if (defers) DeferToRetailAlphaQueue(camera); else DrawOrdered(camera); } /// /// S4-c2 fix round 1 (M1): pure router-input derivation for one particle /// submission, extracted so it is directly unit-testable without a /// GPU/mesh-manager harness. A billboard's blend mode (Additive bool) is /// either the Additive or the plain Alpha surface bit — both live in /// retail's alpha-family union (0x00010300) and construct the SAME queue /// mask (0x02); billboards never carry a ClipMap bit (that is a mesh/ /// UV-texture concept a generated particle quad has no equivalent of), /// so they always route to ALPHA. A mesh-particle batch's /// CAN be /// (mask 0x00) — unlike WbDrawDispatcher's ordinary translucent- /// GfxObj path, TryAppendMeshDraws appends every batch with /// IndexCount>0 regardless of translucency classification, so /// mask 0x00 is a real, reachable input here. /// /// M1(b): retail's has_alpha (CMaterial::CheckAlphaValues /// @0x005396a0 — paired-binary verified 2026-09-04: any channel below /// 1.0 sets has_alpha; values at or above 1.0 leave it clear) /// is driven, for a particle, by CMaterial::SetTranslucencySimple /// @0x005396f0 — the only production writer reached from a particle's /// live translucency (CPhysicsPart::SetTranslucency <- /// Particle::Update @0x0051c290's own per-frame alpha-fade /// interpolation) — which sets ALL FOUR alpha channels to the SAME /// 1f - translucency value and then calls CheckAlphaValues. So /// has_alpha for a particle's material is exactly "this particle's /// CURRENT alpha isn't 1.0 (fully opaque)". acdream already carries that /// exact interpolated value — Lerp(p.StartAlpha, p.EndAlpha, tLife), /// baked into the top byte of by /// Color32 (ParticleSystem.cs:1039-1041,1453). Production clamps /// particle alpha to [0,1], so top-byte != 0xff is behaviorally identical /// to retail's below-1 predicate within this explicitly bounded domain; /// no new state is needed. Billboards never reach row 4 (their mask 0x02 always /// satisfies row 3), so only matters for /// mesh particles. /// /// This "Scene" pass never draws during the Sky leaf (sky/off- /// screen particles use the independent DrawOrdered path entirely, never /// this method) and never installs a detail surface; MultiPassAlpha /// stays false (Must Not: no environment override) — Row 2 is therefore /// provably unreachable here. Row 5 (Immediate) is reachable for an /// Opaque-classified mesh-particle batch with no material alpha; Row 1 is /// not, because this site never installs a detail surface. /// internal static RetailAlphaMeshDecision RouteParticleSubmission( ParticleSubmissionKind kind, TranslucencyKind meshTranslucency, uint meshColorArgb) { bool isMesh = kind == ParticleSubmissionKind.Mesh; byte mask = kind == ParticleSubmissionKind.Billboard ? RetailAlphaMeshRouter.MaskAlphaFamily : RetailAlphaMeshRouter.MaskFromTranslucencyKind(meshTranslucency); bool materialHasAlpha = isMesh && ((meshColorArgb >> 24) & 0xFFu) != 0xFFu; return RetailAlphaMeshRouter.Route( currentlyDrawingSky: false, delayMask: RetailAlphaMeshRouter.DefaultDelayMask, detailSurfaceActive: false, multiPassAlpha: false, subsetMask: mask, materialHasAlpha: materialHasAlpha); } 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); _dispatchDeferredParticle = deferred; TranslucencyKind translucency = submission.Kind == ParticleSubmissionKind.Mesh ? _meshDrawListScratch[submission.DrawIndex].Batch.Translucency : default; uint colorArgb = submission.Kind == ParticleSubmissionKind.Mesh ? _meshDrawListScratch[submission.DrawIndex].Instance.ColorArgb : default; DeferToRetailAlphaQueue( submission.Kind, translucency, colorArgb, queue, _alphaSource, _reserveDeferredParticleDraw, _drawImmediateParticle, viewProjection, submission.DrawIndex); } } /// /// The production one-submission dispatch used by the scene-particle /// loop. It owns both the queue append and the row-5 immediate callback, /// so tests can discriminate the former throw/deleted-call mutations /// without replacing the router with a pure surrogate. /// internal static RetailAlphaMeshDecision DeferToRetailAlphaQueue( ParticleSubmissionKind kind, TranslucencyKind meshTranslucency, uint meshColorArgb, RetailAlphaQueue queue, IRetailAlphaDrawSource source, ReserveDeferredParticleDraw reserveDeferred, DrawImmediateParticle drawImmediate, Matrix4x4 viewProjection, int drawIndex) { RetailAlphaMeshDecision decision = RouteParticleSubmission( kind, meshTranslucency, meshColorArgb); switch (decision.Action) { case RetailAlphaMeshAction.Append: { int token = reserveDeferred(); queue.TryAppend(decision.List, source, token, decision.OverrideClipmap); break; } case RetailAlphaMeshAction.Immediate: // At this hardcoded no-detail/no-multipass site Immediate is // DrawMesh row 5. Retail SetSurface uses ONE/ZERO, disables // blending, and depth-writes; select that pipeline rather // than the ordinary alpha mesh pipeline. drawImmediate(viewProjection, kind, drawIndex, opaqueDepthState: true); break; case RetailAlphaMeshAction.AppendClipAndImmediate: { int token = reserveDeferred(); queue.TryAppend(decision.List, source, token, decision.OverrideClipmap); drawImmediate(viewProjection, kind, drawIndex, opaqueDepthState: false); break; } } return decision; } private int ReserveDispatchDeferredParticle() { int token = _deferredAlpha.Count; _deferredAlpha.Add(_dispatchDeferredParticle); return token; } internal int ReservePreparedDispatchDeferredParticle( ParticleSubmissionKind kind, int drawIndex, Matrix4x4 viewProjection) { DeferredParticleDraw deferred = kind == ParticleSubmissionKind.Billboard ? new DeferredParticleDraw( kind, _drawListScratch[drawIndex], default, viewProjection) : new DeferredParticleDraw( kind, default, _meshDrawListScratch[drawIndex], viewProjection); int token = _deferredAlpha.Count; _deferredAlpha.Add(deferred); return token; } internal void RollbackPreparedDispatchDeferredParticle(int token) { int tail = _deferredAlpha.Count - 1; if (token != tail) { throw new InvalidOperationException( $"Prepared particle rollback must target tail token {tail}, not {token}."); } _deferredAlpha.RemoveAt(tail); } 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(); _preparedCellAlphaScratch.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, uint clipSlot) { 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, clipSlot, ref sequence); } return; } foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass)) { if (emitterFilter is null || emitterFilter(emitter)) AppendEmitterDraws( emitter, cameraWorldPos, cameraRight, cameraUp, clipSlot, ref sequence); } } private void AppendEmitterDraws( RuntimeParticleEmitter em, Vector3 cameraWorldPos, Vector3 cameraRight, Vector3 cameraUp, uint clipSlot, 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, clipSlot, ref sequence)) { continue; } if (!gfxInfoResolved) { gfxInfo = ResolveParticleGfxInfo(em); gfxInfoResolved = true; } Quaternion orientation = ParticleOrientation(em, p); Vector3 authoredSortPoint = p.Position + Vector3.Transform(gfxInfo.SortCenter * p.Size, orientation); float distSq = Vector3.DistanceSquared( authoredSortPoint, cameraWorldPos); bool additive = gfxInfo.HasMaterial ? gfxInfo.Additive : (em.Desc.Flags & EmitterFlags.Additive) != 0; var key = new BatchKey(additive); Vector3 axisX; Vector3 axisY; Vector3 toViewer = cameraWorldPos - pos; float toViewerLength = toViewer.Length(); if (gfxInfo.IsBillboard) { // Degrade mode 2 — face the viewer roll-free // (CPhysicsPart::calc_draw_frame @0x0050DFA0 via // Frame::set_vector_heading), not the camera plane: the two // agree at screen centre and diverge toward the edges and // overhead, where retail's sprites tilt toward the viewer. Vector3 xd; Vector3 yd; if (toViewerLength > 1e-3f) { (xd, yd) = RetailParticleFacing.OrientQuad( 2u, Quaternion.Identity, Vector3.UnitX, Vector3.UnitY, toViewer / toViewerLength, cameraRight, cameraUp); } else { (xd, yd) = (cameraRight, cameraUp); } // The sprite's authored (X, Z) plane rides the quad axes; the // out-of-plane component (authored Y, ~0 on flat sprites) is // dropped rather than pushed along the view direction. pos += (xd * gfxInfo.CenterOffset.X + yd * gfxInfo.CenterOffset.Z) * p.Size; axisX = xd * (gfxInfo.Size.X * p.Size); axisY = yd * (gfxInfo.Size.Y * p.Size); } else { if (RetailParticleFacing.Faces(gfxInfo.DegradeMode) && toViewerLength > 1e-3f) { // Modes 3/4/5 — authored geometry spun around one local // axis toward the viewer // (Frame::rotate_around_axis_to_vector). (Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad( gfxInfo.DegradeMode, orientation, gfxInfo.AxisX, gfxInfo.AxisY, toViewer / toViewerLength, cameraRight, cameraUp); Vector3 localNormal = Vector3.Cross(gfxInfo.AxisX, gfxInfo.AxisY); Vector3 spunNormal = Vector3.Cross(xd, yd); if (spunNormal.LengthSquared() > 1e-10f) spunNormal = Vector3.Normalize(spunNormal); Vector3 c = gfxInfo.CenterOffset; pos += (xd * Vector3.Dot(c, gfxInfo.AxisX) + yd * Vector3.Dot(c, gfxInfo.AxisY) + spunNormal * Vector3.Dot(c, localNormal)) * p.Size; axisX = xd * (gfxInfo.Size.X * p.Size); axisY = yd * (gfxInfo.Size.Y * p.Size); } else { 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); } } int drawIndex = draws.Count; draws.Add(new ParticleDraw( key, new ParticleInstance( pos, axisX, axisY, p.ColorArgb, gfxInfo.TextureSlot, distSq, clipSlot))); _submissionScratch.Add(new ParticleSubmission( ParticleSubmissionKind.Billboard, drawIndex, distSq, sequence++)); } } private bool TryAppendMeshDraws( AcDream.Core.Vfx.ParticleEmitter emitter, Particle particle, uint gfxObjId, Vector3 cameraWorldPosition, uint clipSlot, 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); // The queue remains FIFO. S4-c3a retains this authored part-center // distance for the owning cell's object/particle pre-append merge; // the unrelated non-deferred immediate path uses the same key for its // own local ParticleSubmissionOrdering.Sort. Vector3 worldSortCenter = Vector3.Transform(renderData.SortCenter, model); float distanceSq = Vector3.DistanceSquared(worldSortCenter, cameraWorldPosition); var instance = new MeshParticleInstance( model, particle.ColorArgb, distanceSq, clipSlot); 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, ClipSlot = particle.ClipSlot, }; } private static void WriteMeshGpuInstance( ref MeshParticleGpuInstance destination, MeshParticleInstance instance) { destination = new MeshParticleGpuInstance { Model = instance.Model, Color = new Vector4( ((instance.ColorArgb >> 16) & 0xFF) / 255f, ((instance.ColorArgb >> 8) & 0xFF) / 255f, (instance.ColorArgb & 0xFF) / 255f, ((instance.ColorArgb >> 24) & 0xFF) / 255f), ClipSlot = instance.ClipSlot, }; } 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; kind = RetailParticleGeometryClassifier.Classify( ResolveFirstDegradeMode(gfxObjId)); _geometryKindByGfxObj[gfxObjId] = kind; return kind; } /// /// The sprite's FIRST degrade entry's mode — retail's facing selector /// (GfxObjDegradeInfo::get_degrade @0x0051E4B0 feeding /// CPhysicsPart::calc_draw_frame @0x0050DFA0). Null when the /// GfxObj has no degrade table. /// private uint? ResolveFirstDegradeMode(uint gfxObjId) { if (_firstDegradeModeByGfxObj.TryGetValue(gfxObjId, out uint? mode)) return mode; try { if (_dats?.Get(gfxObjId) is { } gfx && gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) && gfx.DIDDegrade != 0 && _dats.Get(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade) { mode = 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}"); } _firstDegradeModeByGfxObj[gfxObjId] = mode; return mode; } 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, 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; if (info.SurfaceId != 0) { resolved = 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, degradeMode: ResolveFirstDegradeMode(gfxObjId) ?? 0u); } catch { return ParticleGfxInfo.Default; } } private ParticleGfxInfo AuthoredParticleGfxInfo( GfxObj gfx, AcDream.App.Rendering.Gpu.GpuTextureSlot texture, bool additive, bool hasMaterial, uint surfaceId, uint degradeMode) { if (gfx.VertexArray.Vertices.Count == 0) return ParticleGfxInfo.Billboard( texture, Vector2.One, Vector3.Zero, gfx.SortCenter, 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, gfx.SortCenter, 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, gfx.SortCenter, false, additive, hasMaterial, surfaceId, degradeMode); } private bool IsPointSprite(GfxObj gfx) => ResolveFirstDegradeMode(gfx.Id) == 2u; 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(); _firstDegradeModeByGfxObj.Clear(); _meshBlendBySurface.Clear(); _deferredAlpha.Clear(); _preparedCellAlphaScratch.Clear(); } /// /// is the sprite GfxObj's FIRST degrade /// entry's mode — retail's facing selector /// (CPhysicsPart::calc_draw_frame @0x0050DFA0, see /// ). Mode 2 sprites /// take the quad path (face viewer, /// roll-free); modes 3–5 keep authored geometry but spin around one /// local axis toward the viewer; every other mode draws authored. /// Synthetic texture-only billboards carry mode 2 by construction. /// private readonly record struct ParticleGfxInfo( AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot, Vector2 Size, Vector3 AxisX, Vector3 AxisY, Vector3 CenterOffset, Vector3 SortCenter, bool IsBillboard, bool Additive, bool HasMaterial, uint SurfaceId, uint DegradeMode) { public static ParticleGfxInfo Default { get; } = Billboard( AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned, Vector2.One, Vector3.Zero, Vector3.Zero, additive: false, hasMaterial: false, surfaceId: 0); public static ParticleGfxInfo Billboard( AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot, Vector2 size, Vector3 centerOffset, Vector3 sortCenter, bool additive, bool hasMaterial, uint surfaceId) => new( textureSlot, size, Vector3.UnitX, Vector3.UnitY, centerOffset, sortCenter, true, additive, hasMaterial, surfaceId, DegradeMode: 2u); } }