acdream/src/AcDream.App/Rendering/ParticleRenderer.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

1787 lines
67 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 Silk.NET.OpenGL;
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 class ParticleRenderer : IDisposable
{
// The texture is per instance through GL_ARB_bindless_texture. Only blend
// state remains a draw-call boundary, so stable retail distance order no
// longer degenerates into one draw per alternating particle texture.
private readonly record struct BatchKey(bool Additive);
private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance);
private readonly record struct MeshBatchKey(uint GfxObjId, int BatchIndex);
private readonly record struct MeshParticleDraw(
MeshBatchKey Key,
ObjectRenderBatch Batch,
MeshParticleInstance Instance);
private readonly record struct DeferredParticleDraw(
ParticleSubmissionKind Kind,
ParticleDraw Billboard,
MeshParticleDraw Mesh,
Matrix4x4 ViewProjection);
private readonly struct ParticleInstance
{
public readonly Vector3 Position;
public readonly Vector3 AxisX;
public readonly Vector3 AxisY;
public readonly uint ColorArgb;
public readonly ulong TextureHandle;
public readonly float DistanceSq;
public ParticleInstance(
Vector3 position,
Vector3 axisX,
Vector3 axisY,
uint colorArgb,
ulong textureHandle,
float distanceSq)
{
Position = position;
AxisX = axisX;
AxisY = axisY;
ColorArgb = colorArgb;
TextureHandle = textureHandle;
DistanceSq = distanceSq;
}
}
/// <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 GL _gl;
private readonly Shader _shader;
private readonly Shader? _meshShader;
private readonly TextureCache? _textures;
private readonly IDatReaderWriter? _dats;
private readonly WbMeshAdapter? _meshAdapter;
private readonly ParticleSystem _particles;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
private readonly Dictionary<int, ParticleGfxInfo> _particleGfxInfoByEmitter = new();
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = 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 readonly int _meshTextureIndexLoc = -1;
private readonly int _meshTextureLayerLoc = -1;
// Campaign V slice V2c (2026-07-27): GL-only emulation of the eventual
// Vulkan global texture descriptor array (binding=9,
// GpuBindingModel.StorageTextureTable). Owns its own table — see
// GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why
// particles don't share WbDrawDispatcher's/EnvCellRenderer's/
// TerrainModernRenderer's tables. There is no automated pixel-gate
// coverage for particles (the offline gate's fixed outdoor view has none
// in frame), so this indirection is kept strictly mechanical.
private readonly GlBindlessHandleTable _textureTable = new();
private uint _textureTableSsbo;
private int _textureTableSsboCapacityBytes;
private uint _quadVao;
private readonly uint _quadVbo;
private readonly uint _quadEbo;
private uint _instanceVbo;
private uint _meshVao;
private uint _meshInstanceVbo;
private int _instanceVboCapacityBytes;
private int _meshInstanceVboCapacityBytes;
private sealed class DynamicBufferSet
{
public uint BillboardVao;
public uint BillboardInstanceVbo;
public int BillboardCapacityBytes;
public uint MeshVao;
public uint MeshInstanceVbo;
public int MeshCapacityBytes;
}
private readonly List<DynamicBufferSet>[] _dynamicBufferSetsByFrame =
[[], [], []];
private int _dynamicFrameSlot;
private int _dynamicBufferSetCursor;
private bool _dynamicFrameStarted;
private DynamicBufferSet? _activeDynamicBufferSet;
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics
{
get
{
int count = 0;
long bytes = 0;
foreach (List<DynamicBufferSet> frameSets in _dynamicBufferSetsByFrame)
{
count += frameSets.Count;
foreach (DynamicBufferSet set in frameSets)
bytes += set.BillboardCapacityBytes + set.MeshCapacityBytes;
}
return (count, bytes);
}
}
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
private float[] _meshInstanceScratch = new float[256 * 20];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
// each previously `new`ing a List<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();
}
internal ParticleRenderer(
GL gl,
string shadersDir,
ParticleSystem particles,
TextureCache? textures = null,
IDatReaderWriter? dats = null,
WbMeshAdapter? meshAdapter = null,
RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures;
_dats = dats;
_meshAdapter = meshAdapter;
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes).ParticleBytes;
_alphaScratchPolicy =
new RetainedScratchCapacityPolicy(scratchBudget);
if (_meshAdapter is not null)
{
_meshReferences = new ParticleMeshReferenceTracker(
gfxObjId => _meshAdapter.IncrementRefCount(gfxObjId),
gfxObjId => _meshAdapter.DecrementRefCount(gfxObjId));
}
_emitterRetirements = new ParticleEmitterRetirementTracker(
handle => _meshReferences?.Release(handle),
handle => _particleGfxInfoByEmitter.Remove(handle),
handle => _textures?.ReleaseParticleTextureOwner(handle),
error => Console.Error.WriteLine($"[particles] {error}"));
var constructionResources = new ResourceCleanupGroup();
try
{
_shader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle.vert"),
System.IO.Path.Combine(shadersDir, "particle.frag"),
includeCommonPreamble: true);
constructionResources.Add("particle shader", _shader.Dispose);
if (_meshAdapter?.MeshManager?.GlobalBuffer is not null)
{
_meshShader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle_mesh.vert"),
System.IO.Path.Combine(shadersDir, "particle_mesh.frag"),
includeCommonPreamble: true);
constructionResources.Add(
"particle mesh shader",
_meshShader.Dispose);
_meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndex");
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer");
}
// Campaign V slice V2c: binding=9 texture-table SSBO (GL-only
// emulation of the eventual Vulkan descriptor array).
_textureTableSsbo = TrackedGlResource.CreateBuffer(
_gl,
"creating particle texture-table SSBO");
RetryableGpuResourceRelease textureTableRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_textureTableSsbo,
() => _textureTableSsboCapacityBytes,
"rolling back particle texture-table SSBO");
constructionResources.Add(
"particle texture-table SSBO",
textureTableRelease.Run);
float[] quadVerts =
{
-0.5f, -0.5f, 0f, 0f,
0.5f, -0.5f, 1f, 0f,
0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0f, 1f,
};
uint[] quadIdx = { 0, 1, 2, 0, 2, 3 };
bool vboAllocated = false;
bool eboAllocated = false;
uint quadVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating particle quad VBO");
RetryableGpuResourceRelease quadVboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
quadVbo,
() => vboAllocated ? quadVerts.Length * sizeof(float) : 0,
"rolling back particle quad VBO");
constructionResources.Add("particle quad VBO", quadVboRelease.Run);
fixed (void* p = quadVerts)
{
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
quadVbo,
0,
quadVerts.Length * sizeof(float),
GLEnum.StaticDraw,
p,
"uploading particle quad VBO");
}
vboAllocated = true;
uint quadEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating particle quad EBO");
RetryableGpuResourceRelease quadEboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
quadEbo,
() => eboAllocated ? quadIdx.Length * sizeof(uint) : 0,
"rolling back particle quad EBO");
constructionResources.Add("particle quad EBO", quadEboRelease.Run);
uint uploadVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating particle upload VAO");
RetryableGpuResourceRelease uploadVaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
uploadVao,
"rolling back particle upload VAO");
constructionResources.Add("particle upload VAO", uploadVaoRelease.Run);
GlResourceCommand.Execute(
_gl,
"bind particle upload VAO",
() => _gl.BindVertexArray(uploadVao));
fixed (void* p = quadIdx)
{
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ElementArrayBuffer,
quadEbo,
0,
quadIdx.Length * sizeof(uint),
GLEnum.StaticDraw,
p,
"uploading particle quad EBO");
}
eboAllocated = true;
GlResourceCommand.Execute(_gl, "finish particle static-buffer upload", () =>
{
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
});
uploadVaoRelease.Run();
_quadVbo = quadVbo;
_quadEbo = quadEbo;
_particles.EmitterDied += OnEmitterDied;
constructionResources.TransferAll();
}
catch (Exception constructionFailure)
{
constructionResources.RollbackConstructionAndThrow(
"ParticleRenderer construction failed and its shader prefix did not cleanly roll back.",
constructionFailure);
throw new System.Diagnostics.UnreachableException();
}
}
/// <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)
{
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_dynamicFrameSlot = frameSlot;
_dynamicBufferSetCursor = 0;
_dynamicFrameStarted = true;
_activeDynamicBufferSet = null;
_meshLoadRequestedThisFrame.Clear();
_emitterRetirements.RetryPending();
_textures?.TickParticleTextureCache();
}
public void Draw(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
Func<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)
{
ParticleSubmissionOrdering.Sort(_submissionScratch);
GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
Matrix4x4 viewProjection = camera.View * camera.Projection;
_gl.Enable(EnableCap.DepthTest);
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
for (int i = 0; i < _submissionScratch.Count;)
{
ParticleSubmission submission = _submissionScratch[i];
if (submission.Kind == ParticleSubmissionKind.Billboard)
{
ParticleDraw draw = _drawListScratch[submission.DrawIndex];
BatchKey key = draw.Key;
_runScratch.Clear();
do
{
_runScratch.Add(_drawListScratch[submission.DrawIndex].Instance);
i++;
if (i >= _submissionScratch.Count)
break;
submission = _submissionScratch[i];
}
while (submission.Kind == ParticleSubmissionKind.Billboard
&& _drawListScratch[submission.DrawIndex].Key == key);
_gl.BlendFunc(
BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
DrawInstances(_runScratch, viewProjection);
continue;
}
if (_meshShader is null || global is null)
{
i++;
continue;
}
MeshParticleDraw meshDraw = _meshDrawListScratch[submission.DrawIndex];
MeshBatchKey meshKey = meshDraw.Key;
ObjectRenderBatch batch = meshDraw.Batch;
_meshRunScratch.Clear();
do
{
_meshRunScratch.Add(_meshDrawListScratch[submission.DrawIndex].Instance);
i++;
if (i >= _submissionScratch.Count)
break;
submission = _submissionScratch[i];
}
while (submission.Kind == ParticleSubmissionKind.Mesh
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
ApplyMeshCullMode(batch.CullMode);
TranslucencyKind blend = ResolveMeshBlend(batch);
_gl.BlendFunc(
blend == TranslucencyKind.InvAlpha
? BlendingFactor.OneMinusSrcAlpha
: BlendingFactor.SrcAlpha,
blend switch
{
TranslucencyKind.Additive => BlendingFactor.One,
TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
_ => BlendingFactor.OneMinusSrcAlpha,
});
_gl.ProgramUniform1(
_meshShader.Program,
_meshTextureIndexLoc,
_textureTable.GetOrAdd(batch.BindlessTextureHandle));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
UploadMeshInstances(_meshRunScratch);
PrepareMeshPipeline(viewProjection, global);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstancedBaseVertex(
PrimitiveType.Triangles,
(uint)batch.IndexCount,
DrawElementsType.UnsignedShort,
(void*)(batch.FirstIndex * sizeof(ushort)),
(uint)_meshRunScratch.Count,
(int)batch.BaseVertex);
}
_gl.BindVertexArray(0);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
}
private void PrepareDeferredAlphaDraws(ReadOnlySpan<int> tokens)
{
if (tokens.Length == 0)
return;
ActivateNextDynamicBufferSet();
int count = tokens.Length;
if (_preparedAlpha.Length < count)
Array.Resize(ref _preparedAlpha, count + 256);
if (_preparedInstanceOffsets.Length < count)
Array.Resize(ref _preparedInstanceOffsets, count + 256);
if (_instanceScratch.Length < count)
Array.Resize(ref _instanceScratch, count + 256);
int neededMeshFloats = count * 20;
if (_meshInstanceScratch.Length < neededMeshFloats)
Array.Resize(ref _meshInstanceScratch, neededMeshFloats + 256 * 20);
int billboardCount = 0;
int meshCount = 0;
for (int i = 0; i < count; i++)
{
DeferredParticleDraw deferred = _deferredAlpha[tokens[i]];
_preparedAlpha[i] = deferred;
if (deferred.Kind == ParticleSubmissionKind.Billboard)
{
_preparedInstanceOffsets[i] = (uint)billboardCount;
WriteBillboardGpuInstance(
ref _instanceScratch[billboardCount++],
deferred.Billboard.Instance);
}
else
{
_preparedInstanceOffsets[i] = (uint)meshCount;
WriteMeshGpuInstance(
_meshInstanceScratch,
meshCount++ * 20,
deferred.Mesh.Instance);
}
}
if (billboardCount > 0)
{
fixed (void* bp = _instanceScratch)
UploadDynamicArrayBuffer(
_instanceVbo,
ref _instanceVboCapacityBytes,
bp,
billboardCount * sizeof(BillboardGpuInstance));
}
if (meshCount > 0)
{
fixed (void* mp = _meshInstanceScratch)
UploadDynamicArrayBuffer(
_meshInstanceVbo,
ref _meshInstanceVboCapacityBytes,
mp,
meshCount * 20 * sizeof(float));
}
PersistActiveDynamicBufferCapacities();
_preparedAlphaCount = count;
}
private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
if (drawCount <= 0)
return;
if (firstPreparedDraw < 0
|| firstPreparedDraw > _preparedAlphaCount - drawCount)
throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw));
GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
_gl.Enable(EnableCap.DepthTest);
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
ParticleSubmissionKind? activeKind = null;
Matrix4x4 activeViewProjection = default;
int i = firstPreparedDraw;
int preparedEnd = firstPreparedDraw + drawCount;
while (i < preparedEnd)
{
DeferredParticleDraw deferred = _preparedAlpha[i];
if (deferred.Kind == ParticleSubmissionKind.Billboard)
{
ParticleDraw draw = deferred.Billboard;
BatchKey key = draw.Key;
if (activeKind != ParticleSubmissionKind.Billboard
|| activeViewProjection != deferred.ViewProjection)
{
PrepareBillboardPipeline(deferred.ViewProjection);
activeKind = ParticleSubmissionKind.Billboard;
activeViewProjection = deferred.ViewProjection;
}
uint baseInstance = _preparedInstanceOffsets[i];
int runStart = i;
do
{
i++;
if (i >= preparedEnd)
break;
deferred = _preparedAlpha[i];
}
while (deferred.Kind == ParticleSubmissionKind.Billboard
&& deferred.Billboard.Key == key
&& deferred.ViewProjection == activeViewProjection);
_gl.BlendFunc(
BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
_gl.BindVertexArray(_quadVao);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstancedBaseInstance(
PrimitiveType.Triangles,
6,
DrawElementsType.UnsignedInt,
(void*)0,
(uint)(i - runStart),
baseInstance);
continue;
}
if (_meshShader is null || global is null)
{
i++;
continue;
}
MeshParticleDraw meshDraw = deferred.Mesh;
MeshBatchKey meshKey = meshDraw.Key;
ObjectRenderBatch batch = meshDraw.Batch;
if (activeKind != ParticleSubmissionKind.Mesh
|| activeViewProjection != deferred.ViewProjection)
{
PrepareMeshPipeline(deferred.ViewProjection, global);
activeKind = ParticleSubmissionKind.Mesh;
activeViewProjection = deferred.ViewProjection;
}
uint meshBaseInstance = _preparedInstanceOffsets[i];
int meshRunStart = i;
do
{
i++;
if (i >= preparedEnd)
break;
deferred = _preparedAlpha[i];
}
while (deferred.Kind == ParticleSubmissionKind.Mesh
&& deferred.Mesh.Key == meshKey
&& deferred.ViewProjection == activeViewProjection);
ApplyMeshCullMode(batch.CullMode);
TranslucencyKind blend = ResolveMeshBlend(batch);
_gl.BlendFunc(
blend == TranslucencyKind.InvAlpha
? BlendingFactor.OneMinusSrcAlpha
: BlendingFactor.SrcAlpha,
blend switch
{
TranslucencyKind.Additive => BlendingFactor.One,
TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
_ => BlendingFactor.OneMinusSrcAlpha,
});
_gl.ProgramUniform1(
_meshShader.Program,
_meshTextureIndexLoc,
_textureTable.GetOrAdd(batch.BindlessTextureHandle));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
_gl.BindVertexArray(_meshVao);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstancedBaseVertexBaseInstance(
PrimitiveType.Triangles,
(uint)batch.IndexCount,
DrawElementsType.UnsignedShort,
(void*)(batch.FirstIndex * sizeof(ushort)),
(uint)(i - meshRunStart),
(int)batch.BaseVertex,
meshBaseInstance);
}
_gl.BindVertexArray(0);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
}
private void ResetDeferredAlpha()
{
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 PrepareBillboardPipeline(Matrix4x4 viewProjection)
{
_shader.Use();
_shader.SetMatrix4("uViewProjection", viewProjection);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(_quadVao);
}
private void PrepareMeshPipeline(Matrix4x4 viewProjection, GlobalMeshBuffer global)
{
_meshShader!.Use();
_meshShader.SetMatrix4("uViewProjection", viewProjection);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.BindVertexArray(_meshVao);
// GlobalMeshBuffer may grow and replace either backing buffer. Bind
// today's buffer names on every pipeline switch instead of caching
// them in this VAO at construction time.
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, global.VBO);
int vertexStride = VertexPositionNormalTexture.Size;
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(6 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, global.IBO);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo);
const int instanceStride = 20 * sizeof(float);
for (uint column = 0; column < 4; column++)
{
uint location = 3 + column;
_gl.EnableVertexAttribArray(location);
_gl.VertexAttribPointer(
location,
4,
VertexAttribPointerType.Float,
false,
instanceStride,
(void*)(column * 4 * sizeof(float)));
_gl.VertexAttribDivisor(location, 1);
}
_gl.EnableVertexAttribArray(7);
_gl.VertexAttribPointer(7, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(16 * sizeof(float)));
_gl.VertexAttribDivisor(7, 1);
}
private void BuildDrawLists(
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
Vector3 cameraRight,
Vector3 cameraUp,
Func<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;
if (gfxInfo.IsBillboard)
{
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
axisX = cameraRight * (gfxInfo.Size.X * p.Size);
axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
}
else
{
Quaternion orientation = ParticleOrientation(em, p);
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(
key,
new ParticleInstance(
pos,
axisX,
axisY,
p.ColorArgb,
gfxInfo.TextureHandle,
distSq)));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard,
drawIndex,
distSq,
sequence++));
}
}
private bool TryAppendMeshDraws(
AcDream.Core.Vfx.ParticleEmitter emitter,
Particle particle,
uint gfxObjId,
Vector3 cameraWorldPosition,
ref int sequence)
{
if (_meshAdapter is null || _meshShader is null)
return true;
_meshReferences!.Register(emitter.Handle, gfxObjId);
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (_meshLoadRequestedThisFrame.Add(gfxObjId))
_meshAdapter.EnsureLoaded(gfxObjId);
return true;
}
Quaternion orientation = ParticleOrientation(emitter, particle);
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
renderData.SortCenter,
model,
cameraWorldPosition);
float distanceSq = viewerDistance * viewerDistance;
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
ObjectRenderBatch batch = renderData.Batches[batchIndex];
if (batch.IndexCount <= 0 || batch.BindlessTextureHandle == 0)
continue;
int drawIndex = _meshDrawListScratch.Count;
_meshDrawListScratch.Add(new MeshParticleDraw(
new MeshBatchKey(gfxObjId, batchIndex),
batch,
instance));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Mesh,
drawIndex,
distanceSq,
sequence++));
}
return true;
}
private void DrawInstances(List<ParticleInstance> instances, Matrix4x4 viewProjection)
{
if (instances.Count == 0)
return;
ActivateNextDynamicBufferSet();
if (_instanceScratch.Length < instances.Count)
_instanceScratch = new BillboardGpuInstance[instances.Count + 256];
for (int i = 0; i < instances.Count; i++)
WriteBillboardGpuInstance(ref _instanceScratch[i], instances[i]);
fixed (void* bp = _instanceScratch)
UploadDynamicArrayBuffer(
_instanceVbo,
ref _instanceVboCapacityBytes,
bp,
instances.Count * sizeof(BillboardGpuInstance));
PersistActiveDynamicBufferCapacities();
PrepareBillboardPipeline(viewProjection);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
}
private void UploadMeshInstances(List<MeshParticleInstance> instances)
{
ActivateNextDynamicBufferSet();
int needed = instances.Count * 20;
if (_meshInstanceScratch.Length < needed)
_meshInstanceScratch = new float[needed + 256 * 20];
for (int i = 0; i < instances.Count; i++)
WriteMeshGpuInstance(_meshInstanceScratch, i * 20, instances[i]);
fixed (void* bp = _meshInstanceScratch)
UploadDynamicArrayBuffer(
_meshInstanceVbo,
ref _meshInstanceVboCapacityBytes,
bp,
instances.Count * 20 * sizeof(float));
PersistActiveDynamicBufferCapacities();
}
// Campaign V slice V2c: instance method (not static) because it converts
// the particle's raw bindless handle to a _textureTable slot.
private 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 = _textureTable.GetOrAdd(particle.TextureHandle),
};
}
private static void WriteMeshGpuInstance(
float[] destination,
int offset,
MeshParticleInstance instance)
{
Matrix4x4 model = instance.Model;
destination[offset + 0] = model.M11;
destination[offset + 1] = model.M12;
destination[offset + 2] = model.M13;
destination[offset + 3] = model.M14;
destination[offset + 4] = model.M21;
destination[offset + 5] = model.M22;
destination[offset + 6] = model.M23;
destination[offset + 7] = model.M24;
destination[offset + 8] = model.M31;
destination[offset + 9] = model.M32;
destination[offset + 10] = model.M33;
destination[offset + 11] = model.M34;
destination[offset + 12] = model.M41;
destination[offset + 13] = model.M42;
destination[offset + 14] = model.M43;
destination[offset + 15] = model.M44;
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
}
private void ActivateNextDynamicBufferSet()
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing particles.");
List<DynamicBufferSet> slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot];
if (_dynamicBufferSetCursor == slotSets.Count)
slotSets.Add(CreateDynamicBufferSet());
DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++];
_activeDynamicBufferSet = set;
_quadVao = set.BillboardVao;
_instanceVbo = set.BillboardInstanceVbo;
_instanceVboCapacityBytes = set.BillboardCapacityBytes;
_meshVao = set.MeshVao;
_meshInstanceVbo = set.MeshInstanceVbo;
_meshInstanceVboCapacityBytes = set.MeshCapacityBytes;
}
private DynamicBufferSet CreateDynamicBufferSet()
{
var set = new DynamicBufferSet();
try
{
set.BillboardVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle billboard VAO");
set.BillboardInstanceVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle billboard instance VBO");
set.MeshVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle mesh VAO");
set.MeshInstanceVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle mesh instance VBO");
_gl.BindVertexArray(set.BillboardVao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.BillboardInstanceVbo);
uint instanceStride = (uint)sizeof(BillboardGpuInstance);
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)0);
_gl.VertexAttribDivisor(2, 1);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(4 * sizeof(float)));
_gl.VertexAttribDivisor(3, 1);
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(8 * sizeof(float)));
_gl.VertexAttribDivisor(4, 1);
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float)));
_gl.VertexAttribDivisor(5, 1);
// Campaign V slice V2c: one uint table slot (was uvec2 low/high
// handle halves) — BillboardGpuInstance shrank by 4 bytes.
_gl.EnableVertexAttribArray(6);
_gl.VertexAttribIPointer(
6,
1,
VertexAttribIType.UnsignedInt,
instanceStride,
(void*)(16 * sizeof(float)));
_gl.VertexAttribDivisor(6, 1);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
GLHelpers.ThrowOnResourceError(_gl, "configuring particle dynamic VAOs");
return set;
}
catch (Exception creationFailure)
{
try { DeleteDynamicBufferSet(set); }
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Particle dynamic-buffer creation and rollback failed.",
creationFailure,
cleanupFailure);
}
throw;
}
}
private void DeleteDynamicBufferSet(DynamicBufferSet set)
{
List<Exception>? failures = null;
void Attempt(Action action)
{
try { action(); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
Attempt(() => TrackedGlResource.DeleteBuffer(
_gl,
set.BillboardInstanceVbo,
set.BillboardCapacityBytes,
"deleting particle billboard instance VBO"));
Attempt(() => TrackedGlResource.DeleteBuffer(
_gl,
set.MeshInstanceVbo,
set.MeshCapacityBytes,
"deleting particle mesh instance VBO"));
Attempt(() => TrackedGlResource.DeleteVertexArray(
_gl,
set.BillboardVao,
"deleting particle billboard VAO"));
Attempt(() => TrackedGlResource.DeleteVertexArray(
_gl,
set.MeshVao,
"deleting particle mesh VAO"));
if (failures is not null)
throw new AggregateException("One or more particle dynamic resources failed to delete.", failures);
}
private void PersistActiveDynamicBufferCapacities()
{
DynamicBufferSet set = _activeDynamicBufferSet
?? throw new InvalidOperationException("No dynamic particle buffer set is active.");
set.BillboardCapacityBytes = _instanceVboCapacityBytes;
set.MeshCapacityBytes = _meshInstanceVboCapacityBytes;
}
private void UploadDynamicArrayBuffer(
uint buffer,
ref int capacityBytes,
void* data,
int byteCount)
{
if (byteCount <= 0)
throw new ArgumentOutOfRangeException(nameof(byteCount));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, buffer);
if (capacityBytes < byteCount)
{
int grownCapacity = DynamicBufferCapacity.Grow(capacityBytes, byteCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
buffer,
capacityBytes,
grownCapacity,
GLEnum.DynamicDraw,
$"growing particle dynamic buffer {buffer} to {grownCapacity} bytes");
capacityBytes = grownCapacity;
}
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)byteCount, data);
}
/// <summary>
/// Campaign V slice V2c: uploads <see cref="_textureTable"/>'s handles to
/// <see cref="_textureTableSsbo"/> when a new one was registered since the
/// last flush (by <see cref="WriteBillboardGpuInstance"/> or either mesh
/// draw site's <c>_textureTable.GetOrAdd</c>), then (re)binds it at
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// Called immediately before every draw call rather than once per pipeline
/// switch: a run of consecutive mesh-particle sub-batches can register a
/// new handle partway through, and the table must be current for each one.
/// </summary>
private void FlushAndBindTextureTable()
{
if (_textureTable.Dirty)
{
ReadOnlySpan<ulong> handles = _textureTable.Handles;
int byteCount = handles.Length * sizeof(ulong);
fixed (ulong* p = handles)
{
_gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _textureTableSsbo);
if (_textureTableSsboCapacityBytes < byteCount)
{
int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_textureTableSsbo,
_textureTableSsboCapacityBytes,
grown,
GLEnum.DynamicDraw,
"growing particle texture-table SSBO");
_textureTableSsboCapacityBytes = grown;
}
_gl.BufferSubData(BufferTargetARB.ShaderStorageBuffer, 0, (nuint)byteCount, p);
}
_textureTable.MarkFlushed();
}
_gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
_textureTableSsbo);
}
private void ApplyMeshCullMode(CullMode mode)
{
_gl.FrontFace(FrontFaceDirection.CW);
switch (mode)
{
case CullMode.None:
_gl.Disable(EnableCap.CullFace);
break;
case CullMode.Clockwise:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
break;
}
}
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
{
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || _dats is null)
return batch.IsAdditive ? TranslucencyKind.Additive : TranslucencyKind.AlphaBlend;
if (_meshBlendBySurface.TryGetValue(surfaceId, out TranslucencyKind blend))
return blend;
blend = RetailParticleBlendResolver.Resolve(
surfaceId,
batch.IsAdditive,
id => _dats.Get<Surface>(id),
Console.Error.WriteLine);
_meshBlendBySurface[surfaceId] = blend;
return blend;
}
private RetailParticleGeometryKind ResolveGeometryKind(uint gfxObjId)
{
if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind))
return kind;
uint? firstDegradeMode = null;
try
{
if (_dats?.Get<GfxObj>(gfxObjId) is { } gfx
&& gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)
&& gfx.DIDDegrade != 0
&& _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade)
{
firstDegradeMode = degrade.Degrades[0].DegradeMode;
}
}
catch (Exception ex)
{
// Missing/corrupt content must not invent a billboard. Cache the
// retail full-mesh choice and let WbMeshAdapter's normal missing-
// asset diagnostics decide whether geometry can be presented.
Console.Error.WriteLine(
$"[particle-geometry] Failed to decode GfxObj 0x{gfxObjId:X8} degrade metadata: {ex.Message}");
}
kind = RetailParticleGeometryClassifier.Classify(firstDegradeMode);
_geometryKindByGfxObj[gfxObjId] = kind;
return kind;
}
private void OnEmitterDied(int handle)
{
_emitterRetirements.BeginRetirement(handle);
}
private ParticleGfxInfo ResolveParticleGfxInfo(RuntimeParticleEmitter emitter)
{
if (_textures is null)
return ParticleGfxInfo.Default;
if (_particleGfxInfoByEmitter.TryGetValue(emitter.Handle, out ParticleGfxInfo resolved))
return resolved;
EmitterDesc desc = emitter.Desc;
if (desc.TextureSurfaceId != 0)
{
resolved = ParticleGfxInfo.Billboard(
_textures.AcquireParticleTexture(emitter.Handle, desc.TextureSurfaceId),
Vector2.One,
Vector3.Zero,
additive: (desc.Flags & EmitterFlags.Additive) != 0,
hasMaterial: false,
surfaceId: desc.TextureSurfaceId);
_particleGfxInfoByEmitter.Add(emitter.Handle, resolved);
return resolved;
}
uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId;
if (gfxObjId == 0 || _dats is null)
return ParticleGfxInfo.Default;
if (!_particleGfxInfoByGfxObj.TryGetValue(gfxObjId, out var info))
{
info = ReadParticleGfxInfo(gfxObjId);
_particleGfxInfoByGfxObj[gfxObjId] = info;
}
resolved = info.SurfaceId == 0
? ParticleGfxInfo.Default
: info with
{
TextureHandle = _textures.AcquireParticleTexture(
emitter.Handle,
info.SurfaceId),
};
_particleGfxInfoByEmitter.Add(emitter.Handle, resolved);
return resolved;
}
private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId)
{
try
{
var gfx = _dats?.Get<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,
texture: 0,
additive,
hasMaterial: surfaceId != 0,
surfaceId: surfaceId);
}
catch
{
return ParticleGfxInfo.Default;
}
}
private ParticleGfxInfo AuthoredParticleGfxInfo(
GfxObj gfx,
ulong texture,
bool additive,
bool hasMaterial,
uint surfaceId)
{
if (gfx.VertexArray.Vertices.Count == 0)
return ParticleGfxInfo.Billboard(
texture,
Vector2.One,
Vector3.Zero,
additive,
hasMaterial,
surfaceId);
var min = new Vector3(float.PositiveInfinity);
var max = new Vector3(float.NegativeInfinity);
foreach (var (_, v) in gfx.VertexArray.Vertices)
{
min = Vector3.Min(min, v.Origin);
max = Vector3.Max(max, v.Origin);
}
var size = max - min;
var center = (min + max) * 0.5f;
if (IsPointSprite(gfx))
{
float sx = FallbackParticleExtent(size.X) * 0.9f;
float sy = FallbackParticleExtent(size.Z) * 0.9f;
return ParticleGfxInfo.Billboard(
texture,
new Vector2(sx, sy),
center,
additive,
hasMaterial,
surfaceId);
}
Vector3 axisX;
Vector3 axisY;
Vector2 planeSize;
if (size.Y > size.X && size.Y > size.Z)
{
if (size.X > size.Z)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitY;
planeSize = new Vector2(size.X, size.Y);
}
else
{
axisX = Vector3.UnitY;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.Y, size.Z);
}
}
else if (size.X > size.Y && size.X > size.Z)
{
if (size.Z > size.Y)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.X, size.Z);
}
else
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitY;
planeSize = new Vector2(size.X, size.Y);
}
}
else
{
if (size.X > size.Y)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.X, size.Z);
}
else
{
axisX = Vector3.UnitY;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.Y, size.Z);
}
}
planeSize.X = FallbackParticleExtent(planeSize.X);
planeSize.Y = FallbackParticleExtent(planeSize.Y);
return new ParticleGfxInfo(
texture,
planeSize,
axisX,
axisY,
center,
false,
additive,
hasMaterial,
surfaceId);
}
private bool IsPointSprite(GfxObj gfx)
{
if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null)
return false;
try
{
var degrade = _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade);
return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2;
}
catch
{
return false;
}
}
private static float FallbackParticleExtent(float value)
=> value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f;
private static Quaternion ParticleOrientation(AcDream.Core.Vfx.ParticleEmitter em, Particle p)
{
Quaternion orientation = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0
? em.AnchorRot
: p.SpawnRotation;
if (em.Desc.Type is AcDream.Core.Vfx.ParticleType.ParabolicLVGAGR
or AcDream.Core.Vfx.ParticleType.ParabolicLVLALR
or AcDream.Core.Vfx.ParticleType.ParabolicGVGAGR)
{
Vector3 angular = p.C * p.Age;
float radians = angular.Length();
if (radians > 1e-6f)
orientation = Quaternion.Normalize(orientation * Quaternion.CreateFromAxisAngle(angular / radians, radians));
}
return orientation;
}
public void Dispose()
{
if (_disposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
BuildDisposeReleases(releases);
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more particle renderer resources could not be released.");
}
CompleteDispose();
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Particle renderer resources released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
private void BuildDisposeReleases(List<(string Name, Action Release)> releases)
{
releases.Add(("emitter-death-subscription", () =>
_particles.EmitterDied -= OnEmitterDied));
releases.Add(("emitter-resources", RetireEveryResolvedEmitter));
if (_meshReferences is not null)
releases.Add(("mesh-references", _meshReferences.Dispose));
AddTrackedBufferRelease(
releases,
_quadVbo,
16L * sizeof(float),
"quad-vbo",
"deleting particle quad VBO");
AddTrackedBufferRelease(
releases,
_quadEbo,
6L * sizeof(uint),
"quad-ebo",
"deleting particle quad EBO");
AddTrackedBufferRelease(
releases,
_textureTableSsbo,
_textureTableSsboCapacityBytes,
"texture-table",
"deleting particle texture-table SSBO");
for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++)
{
List<DynamicBufferSet> frameSets = _dynamicBufferSetsByFrame[frame];
for (int index = 0; index < frameSets.Count; index++)
AddDynamicBufferSetReleases(releases, frameSets[index], frame, index);
}
releases.Add(("billboard-shader", _shader.Dispose));
if (_meshShader is not null)
releases.Add(("mesh-shader", _meshShader.Dispose));
}
private void RetireEveryResolvedEmitter()
{
int[] handles = [.. _particleGfxInfoByEmitter.Keys];
for (int i = 0; i < handles.Length; i++)
_emitterRetirements.BeginRetirement(handles[i]);
_emitterRetirements.CompleteOrThrow();
}
private void AddDynamicBufferSetReleases(
List<(string Name, Action Release)> releases,
DynamicBufferSet set,
int frame,
int index)
{
AddTrackedBufferRelease(
releases,
set.BillboardInstanceVbo,
set.BillboardCapacityBytes,
$"dynamic-{frame}-{index}-billboard-vbo",
"deleting particle billboard instance VBO");
AddTrackedBufferRelease(
releases,
set.MeshInstanceVbo,
set.MeshCapacityBytes,
$"dynamic-{frame}-{index}-mesh-vbo",
"deleting particle mesh instance VBO");
AddTrackedVertexArrayRelease(
releases,
set.BillboardVao,
$"dynamic-{frame}-{index}-billboard-vao",
"deleting particle billboard VAO");
AddTrackedVertexArrayRelease(
releases,
set.MeshVao,
$"dynamic-{frame}-{index}-mesh-vao",
"deleting particle mesh VAO");
}
private void AddTrackedBufferRelease(
List<(string Name, Action Release)> releases,
uint buffer,
long capacityBytes,
string name,
string context)
{
if (buffer == 0)
return;
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
buffer,
capacityBytes,
context);
releases.Add((name, release.Run));
}
private void AddTrackedVertexArrayRelease(
List<(string Name, Action Release)> releases,
uint vertexArray,
string name,
string context)
{
if (vertexArray == 0)
return;
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
vertexArray,
context);
releases.Add((name, release.Run));
}
private void CompleteDispose()
{
foreach (List<DynamicBufferSet> frameSets in _dynamicBufferSetsByFrame)
frameSets.Clear();
_activeDynamicBufferSet = null;
_dynamicFrameStarted = false;
_quadVao = 0;
_instanceVbo = 0;
_meshVao = 0;
_meshInstanceVbo = 0;
_instanceVboCapacityBytes = 0;
_meshInstanceVboCapacityBytes = 0;
_textureTableSsbo = 0;
_textureTableSsboCapacityBytes = 0;
_particleGfxInfoByEmitter.Clear();
_particleGfxInfoByGfxObj.Clear();
_geometryKindByGfxObj.Clear();
_meshBlendBySurface.Clear();
_deferredAlpha.Clear();
}
private readonly record struct ParticleGfxInfo(
ulong TextureHandle,
Vector2 Size,
Vector3 AxisX,
Vector3 AxisY,
Vector3 CenterOffset,
bool IsBillboard,
bool Additive,
bool HasMaterial,
uint SurfaceId)
{
public static ParticleGfxInfo Default { get; } =
Billboard(
0ul,
Vector2.One,
Vector3.Zero,
additive: false,
hasMaterial: false,
surfaceId: 0);
public static ParticleGfxInfo Billboard(
ulong textureHandle,
Vector2 size,
Vector3 centerOffset,
bool additive,
bool hasMaterial,
uint surfaceId) =>
new(
textureHandle,
size,
Vector3.UnitX,
Vector3.UnitY,
centerOffset,
true,
additive,
hasMaterial,
surfaceId);
}
}