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>
1007 lines
38 KiB
C#
1007 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 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 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);
|
||
}
|
||
}
|