acdream/src/AcDream.App/Rendering/ParticleRenderer.cs
Erik 18fce7bb5a fix #28: port retail's sky default-script playback (aurora) and the particle facing law
The aurora was never missing data — it was a missing mechanism plus a
misread. New decompile evidence closes the April-2026 contradiction:
retail plays the sky carriers' PES through the Setup's own DefaultScript
(GameSky::MakeObject @0x00506EE0 -> CPhysicsObj::makeObject @0x00513970
sets state|=0x80000; animate_static_object @0x00513DF0 ticks
ScriptManager + ParticleManager). The pes_id column stays dead — that
half of the April finding stands; the ids are byte-equal mirrors.

- SkyPesFrameController is now the production owner (ACDREAM_ENABLE_SKY_PES
  deleted): script ids resolve from the Setup DefaultScript
  (SkyObjectData.DefaultScriptId; the pes_id column is a one-time-logged
  cross-check), slots persist by (index, gfx id, properties) per
  CreateDeletePhysicsObjects @0x005073C0 — a day-group swap keeping the
  carrier no longer restarts its emitters — and stale slots stop before
  replacements claim the slot-derived owner id.
- RetailParticleFacing ports calc_draw_frame @0x0050DFA0: degrade mode 2
  faces the viewer roll-free (set_vector_heading) instead of the camera
  plane; modes 3/4/5 spin the authored frame around one local axis
  (rotate_around_axis_to_vector) — Dereth authors 54 mode-5 emitters that
  previously got no facing at all; 1,583 mode-2 emitters get the exact
  law; authored/mode-1 paths are unchanged.
- The 2026-08-23 'whole-sky tint' was the Rainy-group lightning/thunder
  PES playing at the debug anchor inside their 0.03-0.19 window, not the
  aurora: the aurora is nine faint viewer-facing glows pulsing on
  6.7/15/55-minute rebirth cycles, in every day group, all day.

Research: docs/research/2026-08-23-sky-default-script-port.md.
Register: AD-112 filed (camera-anchored synthetic owners vs sky-cell
physics objects). ISSUES #2 corrected (the playback ban is lifted by the
new evidence); #28 fix landed pending the connected night gate.
Tests: RetailParticleFacingTests (16), SkyPesFrameControllerTests (6);
hermetic suites App 6,076/0, Core 4,905/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 15:28:59 +02:00

1007 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ParticleInstance(
Vector3 position,
Vector3 axisX,
Vector3 axisY,
uint colorArgb,
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
float distanceSq)
{
Position = position;
AxisX = axisX;
AxisY = axisY;
ColorArgb = colorArgb;
TextureSlot = textureSlot;
DistanceSq = distanceSq;
}
}
/// <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;
}
private readonly struct MeshParticleInstance
{
public readonly Matrix4x4 Model;
public readonly uint ColorArgb;
public readonly float DistanceSq;
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
{
Model = model;
ColorArgb = colorArgb;
DistanceSq = distanceSq;
}
}
private readonly TextureCache? _textures;
private readonly IDatReaderWriter? _dats;
private readonly WbMeshAdapter? _meshAdapter;
private readonly ParticleSystem _particles;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly Dictionary<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 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<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);
FinishDraw(camera, renderPass);
}
public void DrawForOwners(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached = false,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
{
if (camera is null)
return;
_particles.CopyRenderableEmittersForOwners(
renderPass,
attachedOwnerIds,
includeUnattached,
_scopedEmitterScratch,
excludedAttachedOwnerIds);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(
cameraWorldPos,
renderPass,
cameraRight,
cameraUp,
emitterFilter: null,
_scopedEmitterScratch);
FinishDraw(camera, renderPass);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{
if (_submissionScratch.Count == 0)
return;
if (renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true)
DeferToRetailAlphaQueue(camera);
else
DrawOrdered(camera);
}
private void DeferToRetailAlphaQueue(ICamera camera)
{
RetailAlphaQueue queue = _alphaQueue!;
Matrix4x4 viewProjection = camera.View * camera.Projection;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
DeferredParticleDraw deferred = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = _deferredAlpha.Count;
_deferredAlpha.Add(deferred);
queue.Submit(
_alphaSource,
token,
MathF.Sqrt(MathF.Max(0f, submission.DistanceSq)));
}
}
private void DrawOrdered(ICamera camera)
{
DrawOrderedRhi(camera);
}
private void PrepareDeferredAlphaDraws(ReadOnlySpan<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)
{
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<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, 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)));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard,
drawIndex,
distSq,
sequence++));
}
}
private bool TryAppendMeshDraws(
AcDream.Core.Vfx.ParticleEmitter emitter,
Particle particle,
uint gfxObjId,
Vector3 cameraWorldPosition,
ref int sequence)
{
if (_meshAdapter is null || !MeshParticlesAvailable)
return true;
_meshReferences!.Register(emitter.Handle, gfxObjId);
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (_meshLoadRequestedThisFrame.Add(gfxObjId))
_meshAdapter.EnsureLoaded(gfxObjId);
return true;
}
Quaternion orientation = ParticleOrientation(emitter, particle);
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
renderData.SortCenter,
model,
cameraWorldPosition);
float distanceSq = viewerDistance * viewerDistance;
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
ObjectRenderBatch batch = renderData.Batches[batchIndex];
if (batch.IndexCount <= 0 || !batch.TextureSlot.IsAssigned)
continue;
int drawIndex = _meshDrawListScratch.Count;
_meshDrawListScratch.Add(new MeshParticleDraw(
new MeshBatchKey(gfxObjId, batchIndex),
batch,
instance));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Mesh,
drawIndex,
distanceSq,
sequence++));
}
return true;
}
/// <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,
};
}
private static void WriteMeshGpuInstance(
float[] destination,
int offset,
MeshParticleInstance instance)
{
Matrix4x4 model = instance.Model;
destination[offset + 0] = model.M11;
destination[offset + 1] = model.M12;
destination[offset + 2] = model.M13;
destination[offset + 3] = model.M14;
destination[offset + 4] = model.M21;
destination[offset + 5] = model.M22;
destination[offset + 6] = model.M23;
destination[offset + 7] = model.M24;
destination[offset + 8] = model.M31;
destination[offset + 9] = model.M32;
destination[offset + 10] = model.M33;
destination[offset + 11] = model.M34;
destination[offset + 12] = model.M41;
destination[offset + 13] = model.M42;
destination[offset + 14] = model.M43;
destination[offset + 15] = model.M44;
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
}
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
{
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || _dats is null)
return batch.IsAdditive ? TranslucencyKind.Additive : TranslucencyKind.AlphaBlend;
if (_meshBlendBySurface.TryGetValue(surfaceId, out TranslucencyKind blend))
return blend;
blend = RetailParticleBlendResolver.Resolve(
surfaceId,
batch.IsAdditive,
id => _dats.Get<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 35 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);
}
}