Carry each billboard particle's resident texture handle in the instance ABI so stable retail alpha ordering can batch mixed textures without rebinding and redrawing each short texture run. Keep DAT blend transitions as the only billboard draw boundary. The connected 0xC95B stress scene improved from 6 FPS / 171 ms to about 153 FPS / 6.6 ms at the same roughly 21,000 visible entities. Release build succeeds and all 5,916 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
1081 lines
41 KiB
C#
1081 lines
41 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
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;
|
|
|
|
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. The resident texture
|
|
/// handle is carried per particle so ordered particles using different
|
|
/// textures 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 TextureHandleLow;
|
|
public uint TextureHandleHigh;
|
|
}
|
|
|
|
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 DatCollection? _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<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
|
|
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
|
|
private readonly ParticleMeshReferenceTracker? _meshReferences;
|
|
private readonly HashSet<uint> _meshLoadRequestedThisFrame = new();
|
|
private readonly int _meshTextureHandleLoc = -1;
|
|
private readonly int _meshTextureLayerLoc = -1;
|
|
|
|
private readonly uint _quadVao;
|
|
private readonly uint _quadVbo;
|
|
private readonly uint _quadEbo;
|
|
private readonly uint _instanceVbo;
|
|
private readonly uint _meshVao;
|
|
private readonly uint _meshInstanceVbo;
|
|
|
|
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<DeferredParticleDraw> _deferredAlpha = new(128);
|
|
|
|
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
|
|
{
|
|
public void DrawAlphaBatch(ReadOnlySpan<int> tokens)
|
|
=> owner.DrawDeferredAlphaBatch(tokens);
|
|
|
|
public void ResetAlphaSubmissions()
|
|
=> owner._deferredAlpha.Clear();
|
|
}
|
|
|
|
internal ParticleRenderer(
|
|
GL gl,
|
|
string shadersDir,
|
|
ParticleSystem particles,
|
|
TextureCache? textures = null,
|
|
DatCollection? dats = null,
|
|
WbMeshAdapter? meshAdapter = null,
|
|
RetailAlphaQueue? alphaQueue = 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);
|
|
if (_meshAdapter is not null)
|
|
{
|
|
_meshReferences = new ParticleMeshReferenceTracker(
|
|
gfxObjId => _meshAdapter.IncrementRefCount(gfxObjId),
|
|
gfxObjId => _meshAdapter.DecrementRefCount(gfxObjId));
|
|
}
|
|
_shader = new Shader(_gl,
|
|
System.IO.Path.Combine(shadersDir, "particle.vert"),
|
|
System.IO.Path.Combine(shadersDir, "particle.frag"));
|
|
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"));
|
|
_meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle");
|
|
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer");
|
|
}
|
|
|
|
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 };
|
|
|
|
_quadVao = _gl.GenVertexArray();
|
|
_gl.BindVertexArray(_quadVao);
|
|
|
|
_quadVbo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo);
|
|
fixed (void* p = quadVerts)
|
|
{
|
|
_gl.BufferData(
|
|
BufferTargetARB.ArrayBuffer,
|
|
(nuint)(quadVerts.Length * sizeof(float)),
|
|
p,
|
|
BufferUsageARB.StaticDraw);
|
|
}
|
|
|
|
_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)));
|
|
|
|
_quadEbo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo);
|
|
fixed (void* p = quadIdx)
|
|
{
|
|
_gl.BufferData(
|
|
BufferTargetARB.ElementArrayBuffer,
|
|
(nuint)(quadIdx.Length * sizeof(uint)),
|
|
p,
|
|
BufferUsageARB.StaticDraw);
|
|
}
|
|
|
|
_instanceVbo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
|
uint instanceStride = (uint)sizeof(BillboardGpuInstance);
|
|
_gl.BufferData(
|
|
BufferTargetARB.ArrayBuffer,
|
|
(nuint)(256 * instanceStride),
|
|
(void*)0,
|
|
BufferUsageARB.DynamicDraw);
|
|
|
|
_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);
|
|
_gl.EnableVertexAttribArray(6);
|
|
_gl.VertexAttribIPointer(
|
|
6,
|
|
2,
|
|
VertexAttribIType.UnsignedInt,
|
|
instanceStride,
|
|
(void*)(16 * sizeof(float)));
|
|
_gl.VertexAttribDivisor(6, 1);
|
|
|
|
_gl.BindVertexArray(0);
|
|
|
|
_meshVao = _gl.GenVertexArray();
|
|
_meshInstanceVbo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo);
|
|
_gl.BufferData(
|
|
BufferTargetARB.ArrayBuffer,
|
|
(nuint)(256 * 20 * sizeof(float)),
|
|
(void*)0,
|
|
BufferUsageARB.DynamicDraw);
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
|
|
|
_particles.EmitterDied += OnEmitterDied;
|
|
}
|
|
|
|
/// <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()
|
|
=> _meshLoadRequestedThisFrame.Clear();
|
|
|
|
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);
|
|
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;
|
|
|
|
_gl.Enable(EnableCap.DepthTest);
|
|
_gl.Enable(EnableCap.Blend);
|
|
_gl.DepthMask(false);
|
|
|
|
ParticleSubmissionKind? activeKind = null;
|
|
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;
|
|
if (activeKind != ParticleSubmissionKind.Billboard)
|
|
{
|
|
PrepareBillboardPipeline(camera.View * camera.Projection);
|
|
activeKind = ParticleSubmissionKind.Billboard;
|
|
}
|
|
|
|
_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);
|
|
continue;
|
|
}
|
|
|
|
if (_meshShader is null || global is null)
|
|
{
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
MeshParticleDraw meshDraw = _meshDrawListScratch[submission.DrawIndex];
|
|
MeshBatchKey meshKey = meshDraw.Key;
|
|
ObjectRenderBatch batch = meshDraw.Batch;
|
|
if (activeKind != ParticleSubmissionKind.Mesh)
|
|
{
|
|
PrepareMeshPipeline(camera.View * camera.Projection, global);
|
|
activeKind = ParticleSubmissionKind.Mesh;
|
|
}
|
|
|
|
_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.ProgramUniform2(
|
|
_meshShader.Program,
|
|
_meshTextureHandleLoc,
|
|
(uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu),
|
|
(uint)(batch.BindlessTextureHandle >> 32));
|
|
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
|
|
|
|
UploadMeshInstances(_meshRunScratch);
|
|
_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 DrawDeferredAlphaBatch(ReadOnlySpan<int> tokens)
|
|
{
|
|
if (tokens.Length == 0)
|
|
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 = 0;
|
|
while (i < tokens.Length)
|
|
{
|
|
DeferredParticleDraw deferred = _deferredAlpha[tokens[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;
|
|
}
|
|
|
|
_runScratch.Clear();
|
|
do
|
|
{
|
|
_runScratch.Add(deferred.Billboard.Instance);
|
|
i++;
|
|
if (i >= tokens.Length)
|
|
break;
|
|
deferred = _deferredAlpha[tokens[i]];
|
|
}
|
|
while (deferred.Kind == ParticleSubmissionKind.Billboard
|
|
&& deferred.Billboard.Key == key
|
|
&& deferred.ViewProjection == activeViewProjection);
|
|
|
|
_gl.BlendFunc(
|
|
BlendingFactor.SrcAlpha,
|
|
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
|
|
DrawInstances(_runScratch);
|
|
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;
|
|
}
|
|
|
|
_meshRunScratch.Clear();
|
|
do
|
|
{
|
|
_meshRunScratch.Add(deferred.Mesh.Instance);
|
|
i++;
|
|
if (i >= tokens.Length)
|
|
break;
|
|
deferred = _deferredAlpha[tokens[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.ProgramUniform2(
|
|
_meshShader.Program,
|
|
_meshTextureHandleLoc,
|
|
(uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu),
|
|
(uint)(batch.BindlessTextureHandle >> 32));
|
|
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
|
|
|
|
UploadMeshInstances(_meshRunScratch);
|
|
_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 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)
|
|
{
|
|
var draws = _drawListScratch;
|
|
draws.Clear();
|
|
_meshDrawListScratch.Clear();
|
|
_submissionScratch.Clear();
|
|
int sequence = 0;
|
|
foreach (var em in _particles.EnumerateEmitters())
|
|
{
|
|
if (!em.PresentationVisible || !em.ViewEligible)
|
|
continue;
|
|
if (em.RenderPass != renderPass)
|
|
continue;
|
|
if (emitterFilter is not null && !emitterFilter(em))
|
|
continue;
|
|
|
|
for (int idx = 0; idx < em.Particles.Length; idx++)
|
|
{
|
|
ref var 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;
|
|
}
|
|
|
|
var gfxInfo = ResolveParticleGfxInfo(em.Desc);
|
|
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)
|
|
{
|
|
if (instances.Count == 0)
|
|
return;
|
|
|
|
if (_instanceScratch.Length < instances.Count)
|
|
_instanceScratch = new BillboardGpuInstance[instances.Count + 256];
|
|
|
|
for (int i = 0; i < instances.Count; i++)
|
|
{
|
|
var p = instances[i];
|
|
_instanceScratch[i] = new BillboardGpuInstance
|
|
{
|
|
Center = new Vector4(p.Position, 0f),
|
|
AxisX = new Vector4(p.AxisX, 0f),
|
|
AxisY = new Vector4(p.AxisY, 0f),
|
|
Color = new Vector4(
|
|
((p.ColorArgb >> 16) & 0xFF) / 255f,
|
|
((p.ColorArgb >> 8) & 0xFF) / 255f,
|
|
(p.ColorArgb & 0xFF) / 255f,
|
|
((p.ColorArgb >> 24) & 0xFF) / 255f),
|
|
TextureHandleLow = (uint)(p.TextureHandle & 0xFFFFFFFFu),
|
|
TextureHandleHigh = (uint)(p.TextureHandle >> 32),
|
|
};
|
|
}
|
|
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
|
fixed (void* bp = _instanceScratch)
|
|
{
|
|
_gl.BufferData(
|
|
BufferTargetARB.ArrayBuffer,
|
|
(nuint)(instances.Count * sizeof(BillboardGpuInstance)),
|
|
bp,
|
|
BufferUsageARB.DynamicDraw);
|
|
}
|
|
|
|
_gl.BindVertexArray(_quadVao);
|
|
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
|
|
}
|
|
|
|
private void UploadMeshInstances(List<MeshParticleInstance> instances)
|
|
{
|
|
int needed = instances.Count * 20;
|
|
if (_meshInstanceScratch.Length < needed)
|
|
_meshInstanceScratch = new float[needed + 256 * 20];
|
|
|
|
for (int i = 0; i < instances.Count; i++)
|
|
{
|
|
MeshParticleInstance instance = instances[i];
|
|
Matrix4x4 model = instance.Model;
|
|
int o = i * 20;
|
|
_meshInstanceScratch[o + 0] = model.M11;
|
|
_meshInstanceScratch[o + 1] = model.M12;
|
|
_meshInstanceScratch[o + 2] = model.M13;
|
|
_meshInstanceScratch[o + 3] = model.M14;
|
|
_meshInstanceScratch[o + 4] = model.M21;
|
|
_meshInstanceScratch[o + 5] = model.M22;
|
|
_meshInstanceScratch[o + 6] = model.M23;
|
|
_meshInstanceScratch[o + 7] = model.M24;
|
|
_meshInstanceScratch[o + 8] = model.M31;
|
|
_meshInstanceScratch[o + 9] = model.M32;
|
|
_meshInstanceScratch[o + 10] = model.M33;
|
|
_meshInstanceScratch[o + 11] = model.M34;
|
|
_meshInstanceScratch[o + 12] = model.M41;
|
|
_meshInstanceScratch[o + 13] = model.M42;
|
|
_meshInstanceScratch[o + 14] = model.M43;
|
|
_meshInstanceScratch[o + 15] = model.M44;
|
|
_meshInstanceScratch[o + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
|
|
_meshInstanceScratch[o + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
|
|
_meshInstanceScratch[o + 18] = (instance.ColorArgb & 0xFF) / 255f;
|
|
_meshInstanceScratch[o + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
|
|
}
|
|
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo);
|
|
fixed (void* bp = _meshInstanceScratch)
|
|
{
|
|
_gl.BufferData(
|
|
BufferTargetARB.ArrayBuffer,
|
|
(nuint)(instances.Count * 20 * sizeof(float)),
|
|
bp,
|
|
BufferUsageARB.DynamicDraw);
|
|
}
|
|
}
|
|
|
|
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)
|
|
=> _meshReferences?.Release(handle);
|
|
|
|
private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc)
|
|
{
|
|
if (_textures is null)
|
|
return ParticleGfxInfo.Default;
|
|
|
|
if (desc.TextureSurfaceId != 0)
|
|
return ParticleGfxInfo.Billboard(
|
|
_textures.GetOrUploadBindless(desc.TextureSurfaceId),
|
|
Vector2.One,
|
|
Vector3.Zero,
|
|
additive: (desc.Flags & EmitterFlags.Additive) != 0,
|
|
hasMaterial: false);
|
|
|
|
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;
|
|
}
|
|
|
|
return info.TextureHandle != 0 ? info : ParticleGfxInfo.Default;
|
|
}
|
|
|
|
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;
|
|
ulong texture = surfaceId != 0 && _textures is not null
|
|
? _textures.GetOrUploadBindless(surfaceId)
|
|
: 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, additive, surfaceId != 0);
|
|
}
|
|
catch
|
|
{
|
|
return ParticleGfxInfo.Default;
|
|
}
|
|
}
|
|
|
|
private ParticleGfxInfo AuthoredParticleGfxInfo(GfxObj gfx, ulong texture, bool additive, bool hasMaterial)
|
|
{
|
|
if (gfx.VertexArray.Vertices.Count == 0)
|
|
return ParticleGfxInfo.Billboard(texture, Vector2.One, Vector3.Zero, additive, hasMaterial);
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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()
|
|
{
|
|
_particles.EmitterDied -= OnEmitterDied;
|
|
_meshReferences?.Dispose();
|
|
|
|
_gl.DeleteBuffer(_quadVbo);
|
|
_gl.DeleteBuffer(_quadEbo);
|
|
_gl.DeleteBuffer(_instanceVbo);
|
|
_gl.DeleteBuffer(_meshInstanceVbo);
|
|
_gl.DeleteVertexArray(_quadVao);
|
|
_gl.DeleteVertexArray(_meshVao);
|
|
_shader.Dispose();
|
|
_meshShader?.Dispose();
|
|
}
|
|
|
|
private readonly record struct ParticleGfxInfo(
|
|
ulong TextureHandle,
|
|
Vector2 Size,
|
|
Vector3 AxisX,
|
|
Vector3 AxisY,
|
|
Vector3 CenterOffset,
|
|
bool IsBillboard,
|
|
bool Additive,
|
|
bool HasMaterial)
|
|
{
|
|
public static ParticleGfxInfo Default { get; } =
|
|
Billboard(0ul, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false);
|
|
|
|
public static ParticleGfxInfo Billboard(
|
|
ulong textureHandle,
|
|
Vector2 size,
|
|
Vector3 centerOffset,
|
|
bool additive,
|
|
bool hasMaterial) =>
|
|
new(textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial);
|
|
}
|
|
}
|