Retail never clips a particle to a portal view: each emitter's polys
join the ONE alpha list during its owner cell's far-to-near walk turn
(LScape::draw @0x00506330 iterates block_draw_list reversed; DrawBlock
@0x005A17C0 walks cells; ShouldDrawParticles @0x0050FE60 gates by cell
and distance), and occlusion is the depth test at FlushAlphaList
@0x0059D2E0 (its float is a COUNT threshold - 0f = flush all). The
1d2f2f73 architecture instead re-submitted particles once per
OutsideView slice under that slice's hardware clip slot, which cut
effects at aperture boundaries and drew nothing when no outside slice
was in view (the cathedral look-north disappearance).
Now: unattached emitters submit once per frame by owner-cell kind
(outdoor landcells in the landscape stage, interior EnvCells in the
final world scope - new UnattachedEmitterCellScope filter); cell,
shell-route, barrier-static, and late-stage owners submit their
per-slice cone-cull UNION once with clipSlot 0; and particles emit in
the stage matching their PARENT CELL - an interior dynamic whose
sphere straddles an exit-portal plane keeps its mesh in both stages
(#118) but its particles move to the final pass, so the interior
stage can no longer repaint over them (the aperture-band star cut).
Also lands the inert Change-2 primitives for the AP-236 retirement
(candle-behind-door): RetailAlphaQueue.FlushFartherThan drains only
the far prefix without resetting sources, plus the executor
passthrough and the conservative look-in threshold helper - nothing
calls them yet.
User-gated 2026-08-29 round 2 at the Sanctuary Cathedral: spell and
recall stars cover the whole room at every camera direction including
north; waterfall containment holds on retail's depth/seal mechanism;
adjacent-room particles/lights, walls, Holtburg, recall unregressed
(paperdoll remains pre-existing intermittent #443). Register: AP-236
filed for the remaining barrier-order divergence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1041 lines
38 KiB
C#
1041 lines
38 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Instanced renderer for retail particle emitters. Scene particles submit to
|
||
/// <see cref="RetailAlphaQueue"/> 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.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[StructLayout(LayoutKind.Sequential)]
|
||
internal struct BillboardGpuInstance
|
||
{
|
||
public Vector4 Center;
|
||
public Vector4 AxisX;
|
||
public Vector4 AxisY;
|
||
public Vector4 Color;
|
||
public uint TextureIndex;
|
||
public uint ClipSlot;
|
||
}
|
||
|
||
/// <summary>Vertex-instance ABI shared with particle_mesh.vert.</summary>
|
||
[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 Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
|
||
private readonly Dictionary<int, ParticleGfxInfo> _particleGfxInfoByEmitter = new();
|
||
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
|
||
private readonly Dictionary<uint, uint?> _firstDegradeModeByGfxObj = new();
|
||
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
|
||
private readonly ParticleMeshReferenceTracker? _meshReferences;
|
||
private readonly ParticleEmitterRetirementTracker _emitterRetirements;
|
||
private RetryableResourceReleaseLedger? _disposeResources;
|
||
private bool _disposing;
|
||
private bool _disposed;
|
||
private readonly HashSet<uint> _meshLoadRequestedThisFrame = new();
|
||
private bool _dynamicFrameStarted;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<ParticleDraw> (BuildDrawList) and a
|
||
// List<ParticleInstance> (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<ParticleDraw> _drawListScratch = new(64);
|
||
private readonly List<ParticleInstance> _runScratch = new(64);
|
||
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
|
||
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
|
||
private readonly List<ParticleSubmission> _submissionScratch = new(128);
|
||
private readonly List<RuntimeParticleEmitter> _scopedEmitterScratch = new(64);
|
||
private readonly List<DeferredParticleDraw> _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<DeferredParticleDraw>()
|
||
+ (long)_preparedAlpha.Length * Unsafe.SizeOf<DeferredParticleDraw>()
|
||
+ (long)_preparedInstanceOffsets.Length * sizeof(uint));
|
||
|
||
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
|
||
{
|
||
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
|
||
=> owner.PrepareDeferredAlphaDraws(tokens);
|
||
|
||
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
|
||
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
|
||
|
||
public void ResetAlphaSubmissions()
|
||
=> owner.ResetDeferredAlpha();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<AcDream.Core.Vfx.ParticleEmitter, bool>? 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,
|
||
clipSlot: 0);
|
||
FinishDraw(camera, renderPass);
|
||
}
|
||
|
||
public void DrawForOwners(
|
||
ICamera camera,
|
||
Vector3 cameraWorldPos,
|
||
ParticleRenderPass renderPass,
|
||
IReadOnlySet<uint> attachedOwnerIds,
|
||
bool includeUnattached = false,
|
||
IReadOnlySet<uint>? 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);
|
||
}
|
||
|
||
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<int> 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<DeferredParticleDraw>() + 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<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter,
|
||
IReadOnlyList<RuntimeParticleEmitter>? 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<ParticleDraw> 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;
|
||
}
|
||
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
|
||
{
|
||
Quaternion orientation = ParticleOrientation(em, p);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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,
|
||
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);
|
||
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
|
||
renderData.SortCenter,
|
||
model,
|
||
cameraWorldPosition);
|
||
float distanceSq = viewerDistance * viewerDistance;
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V6e: the shader-side spelling of "this particle has no
|
||
/// texture, draw the procedural blob". It must agree with
|
||
/// <c>ACDREAM_TEXTURE_NONE</c> in <c>Shaders/common.glsl</c> and in the
|
||
/// Vulkan preamble.
|
||
///
|
||
/// <para>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.</para>
|
||
/// </summary>
|
||
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<Surface>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The sprite's FIRST degrade entry's mode — retail's facing selector
|
||
/// (<c>GfxObjDegradeInfo::get_degrade @0x0051E4B0</c> feeding
|
||
/// <c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>). Null when the
|
||
/// GfxObj has no degrade table.
|
||
/// </summary>
|
||
private uint? ResolveFirstDegradeMode(uint gfxObjId)
|
||
{
|
||
if (_firstDegradeModeByGfxObj.TryGetValue(gfxObjId, out uint? mode))
|
||
return mode;
|
||
|
||
try
|
||
{
|
||
if (_dats?.Get<GfxObj>(gfxObjId) is { } gfx
|
||
&& gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)
|
||
&& gfx.DIDDegrade != 0
|
||
&& _dats.Get<GfxObjDegradeInfo>(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,
|
||
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<GfxObj>(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<Surface>(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,
|
||
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,
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// <paramref name="DegradeMode"/> is the sprite GfxObj's FIRST degrade
|
||
/// entry's mode — retail's facing selector
|
||
/// (<c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>, see
|
||
/// <see cref="AcDream.Core.Vfx.RetailParticleFacing"/>). Mode 2 sprites
|
||
/// take the <paramref name="IsBillboard"/> 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.
|
||
/// </summary>
|
||
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,
|
||
uint DegradeMode)
|
||
{
|
||
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,
|
||
DegradeMode: 2u);
|
||
}
|
||
}
|