Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1818 lines
68 KiB
C#
1818 lines
68 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 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The GL arm's context, or null on a backend that has none.
|
|
///
|
|
/// <para>Campaign V slice V6l: particles draw on both arms, so the context
|
|
/// became optional. <see cref="_gl"/> below is what every GL-arm statement
|
|
/// still reads, unchanged — reaching it without a context is a composition
|
|
/// error and says so rather than dereferencing null.</para>
|
|
/// </summary>
|
|
private readonly GL? _glContext;
|
|
private readonly Shader? _shader;
|
|
private readonly Shader? _meshShader;
|
|
|
|
private GL _gl => _glContext
|
|
?? throw new InvalidOperationException(
|
|
"ParticleRenderer's GL arm was reached on a backend with no GL context "
|
|
+ "(campaign plan §5.5.16; the RHI arm lives in ParticleRenderer.Rhi.cs).");
|
|
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();
|
|
// Campaign V slice V6e: particle_mesh.frag's two loose uniforms were renamed
|
|
// onto members of the shared push-constant block (uTextureIndex →
|
|
// uTextureIndexA, uTextureLayer → uParamA), because Vulkan GLSL has no
|
|
// default uniform block to declare them in. Under GL they are still plain
|
|
// program uniforms set exactly as before; only the names moved.
|
|
private readonly int _meshTextureIndexLoc = -1;
|
|
private readonly int _meshTextureLayerLoc = -1;
|
|
|
|
// Campaign V slice V4t (2026-07-28): the interim per-renderer
|
|
// GlBindlessHandleTable is retired. Both particle texture sources now hand
|
|
// out the device's own GpuTextureSlot — TextureCache.AcquireParticleTexture
|
|
// for billboards, ObjectRenderBatch.TextureSlot for mesh particles — so all
|
|
// that is left here is flushing and binding that one table before each
|
|
// raw-GL draw. There is still no automated pixel-gate coverage for
|
|
// particles (the offline gate's fixed outdoor view has none in frame), so
|
|
// this change is kept strictly mechanical, exactly as V2c's was.
|
|
private AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable =>
|
|
(_meshAdapter
|
|
?? throw new InvalidOperationException(
|
|
"ParticleRenderer was constructed without a mesh adapter: its texture " +
|
|
"slots come from that adapter's GL device table (Campaign V slice V4t)."))
|
|
.WorldTextureTable;
|
|
|
|
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)
|
|
{
|
|
_glContext = 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, "uTextureIndexA");
|
|
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uParamA");
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (_glContext is null)
|
|
{
|
|
DrawOrderedRhi(camera);
|
|
return;
|
|
}
|
|
|
|
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,
|
|
batch.TextureSlot.Index);
|
|
// Slice V6e: uParamA is a float, so the layer is widened here rather
|
|
// than in the shader's float(uTextureLayer). Layers are small
|
|
// integers; the sampled value is bit-identical.
|
|
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)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;
|
|
|
|
if (_glContext is null)
|
|
{
|
|
PrepareDeferredAlphaDrawsRhi(tokens);
|
|
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));
|
|
|
|
if (_glContext is null)
|
|
{
|
|
DrawPreparedAlphaBatchRhi(firstPreparedDraw, drawCount);
|
|
return;
|
|
}
|
|
|
|
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,
|
|
batch.TextureSlot.Index);
|
|
// Slice V6e: uParamA is a float, so the layer is widened here rather
|
|
// than in the shader's float(uTextureLayer). Layers are small
|
|
// integers; the sampled value is bit-identical.
|
|
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)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.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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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 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 V4t: drains the device texture table's dirty runs and
|
|
/// (re)binds it at
|
|
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
|
|
/// Still called immediately before every draw call rather than once per
|
|
/// pipeline switch, for the reason V2c gave: a run of consecutive
|
|
/// mesh-particle sub-batches can pull in a texture whose slot was registered
|
|
/// this frame, and the table must be current for each one.
|
|
/// </summary>
|
|
private void FlushAndBindTextureTable()
|
|
{
|
|
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable;
|
|
device.FlushTextureTable();
|
|
_gl.BindBufferBase(
|
|
BufferTargetARB.ShaderStorageBuffer,
|
|
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
|
|
device.TextureTableGlName);
|
|
}
|
|
|
|
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
|
|
{
|
|
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);
|
|
}
|
|
catch
|
|
{
|
|
return ParticleGfxInfo.Default;
|
|
}
|
|
}
|
|
|
|
private ParticleGfxInfo AuthoredParticleGfxInfo(
|
|
GfxObj gfx,
|
|
AcDream.App.Rendering.Gpu.GpuTextureSlot 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));
|
|
|
|
// Campaign V slice V6l: 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.
|
|
if (_glContext is null)
|
|
{
|
|
releases.Add(("rhi-resources", DisposeRhiResources));
|
|
return;
|
|
}
|
|
|
|
AddTrackedBufferRelease(
|
|
releases,
|
|
_quadVbo,
|
|
16L * sizeof(float),
|
|
"quad-vbo",
|
|
"deleting particle quad VBO");
|
|
AddTrackedBufferRelease(
|
|
releases,
|
|
_quadEbo,
|
|
6L * sizeof(uint),
|
|
"quad-ebo",
|
|
"deleting particle quad EBO");
|
|
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);
|
|
}
|
|
|
|
if (_shader is not null)
|
|
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;
|
|
_particleGfxInfoByEmitter.Clear();
|
|
_particleGfxInfoByGfxObj.Clear();
|
|
_geometryKindByGfxObj.Clear();
|
|
_meshBlendBySurface.Clear();
|
|
_deferredAlpha.Clear();
|
|
}
|
|
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
}
|