Commit 2 deleted the GL rendering backend's implementations; this step removes the package references and shader vocabulary they leave behind, so nothing in the App project still spells Silk.NET.OpenGL. Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are used directly and extensively across the Wb texture/mesh pipeline, independent of the deleted GL IUniformBuffer implementers the package comment used to cite. The stale comment is corrected in place. IMeshPipelineDevice.Gl is removed along with the GL? gl parameter threaded through WbMeshAdapter's four constructors, WorldRenderComposition's CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null implementation — nothing read any of them once the legacy per-mesh upload bodies were gone (confirmed by grep: the sole non-doc-comment hit was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed a real bug along the way: its teardown still pattern-matched the deleted GL GpuFrameFlightController to decide whether to wait for submitted work, which VulkanFrameFlightController replaced at slice V6a without this site being updated — so the wait had been silently dead on every Vulkan run since then. Retargeted to VulkanFrameFlightController, which carries the same WaitForSubmittedWork(). The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for upload validation is replaced by AcDream.Content's existing Silk.NET-free UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake tool GL-free); two new members (Rgb, Red, Float) extend that enum with their GL ABI constants to cover the full vocabulary WorldTextureArray needs, since MP1a's original set only covered what the extractor itself emits. ObjectMeshManager's App-boundary cast `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct pass-through now that both sides share the type. GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of the Vulkan texture table) is deleted and StorageBindingCount drops from 10 to 9; the descriptor-set-layout code that builds from that count (VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just allocates one fewer always-dummy-seeded, always-unused binding. Several fully dead GL-only classes came along for the ride, confirmed by zero construction sites: SilkFramebufferViewportTarget (NullFramebufferViewportTarget is the sole production IFramebufferViewportTarget), SilkRenderGlStateReader (NullRenderGlStateReader.Instance is the sole IRenderGlStateReader), RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a pass load-op instead), and GpuFrameTimer plus FrameProfiler's GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame bracket (RecordGpuSample is the only GPU-timing path any backend uses now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no longer applies, since WbDrawDispatcher's own diagnostic GPU sampling already moved to the device's Vulkan timer pool). GpuFrameFlightController itself stays (never constructed with a real fence API in production, but its retirement-ledger/serial-ring logic is backend-neutral and still covered by its own unit tests) — only its GL-specific parts (the public GL constructor overload, SilkGpuFenceApi) are deleted, since removing the whole class would mean restructuring the frozen Slice-8 composition shape's GpuFrameFlightController? threading, which is out of this commit's scope. TextureParameters.cs and BufferUsageExtensions.cs (zero callers each) are deleted outright. common.glsl is deleted: nothing in the actual Vulkan .spv build reads it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own complete self-contained preamble per file; common.glsl's textual concatenation was exclusively Shader.cs's GL-only mechanism, deleted at Commit 2. The five shader files that named it in comments (mesh_modern.vert, particle.vert, particle.frag, sky.frag, terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the mandatory modern path already made unreachable, with zero C# consumers and no compiled .spv — are deleted too. Regenerated via tools/compile-shaders.ps1: 9/9 remaining shader pairs compile (previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests doc comment's "nine of ten are not Vulkan-expressible" was already stale before this commit). Test fallout: dead-subject test methods/files are deleted rather than patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs, GpuResourceRetirementTransactionTests.cs's GL queue tests, one WorldRenderDiagnosticsTests source-order test, one RenderFrameResourceControllerTests clear-phase-order test); tests whose subject moved or was renamed are updated in place rather than deleted (GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests' pinned seven-member surface now reads six, ParticleBindlessInstanceTests' cross-dialect check now covers the one surviving dialect, WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was always the parameter that actually threw). Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors, with the Silk.NET.OpenGL/.Extensions.ARB package references physically removed from the csproj (not just unreferenced in code). Tests: full-solution `dotnet test` green across every project. Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
931 lines
34 KiB
C#
931 lines
34 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering.Residency;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.Vfx;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using RuntimeParticleEmitter = AcDream.Core.Vfx.ParticleEmitter;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Instanced renderer for retail particle emitters. Scene particles submit to
|
|
/// <see cref="RetailAlphaQueue"/> while a world frame is active so their
|
|
/// compositing order is shared with ordinary translucent GfxObj parts. Sky and
|
|
/// sealed off-screen passes retain their independent immediate path.
|
|
/// </summary>
|
|
public sealed unsafe partial class ParticleRenderer : IDisposable
|
|
{
|
|
// The texture is per instance through GL_ARB_bindless_texture. Only blend
|
|
// state remains a draw-call boundary, so stable retail distance order no
|
|
// longer degenerates into one draw per alternating particle texture.
|
|
private readonly record struct BatchKey(bool Additive);
|
|
private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance);
|
|
private readonly record struct MeshBatchKey(uint GfxObjId, int BatchIndex);
|
|
private readonly record struct MeshParticleDraw(
|
|
MeshBatchKey Key,
|
|
ObjectRenderBatch Batch,
|
|
MeshParticleInstance Instance);
|
|
private readonly record struct DeferredParticleDraw(
|
|
ParticleSubmissionKind Kind,
|
|
ParticleDraw Billboard,
|
|
MeshParticleDraw Mesh,
|
|
Matrix4x4 ViewProjection);
|
|
|
|
private readonly struct ParticleInstance
|
|
{
|
|
public readonly Vector3 Position;
|
|
public readonly Vector3 AxisX;
|
|
public readonly Vector3 AxisY;
|
|
public readonly uint ColorArgb;
|
|
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
|
|
public readonly float DistanceSq;
|
|
|
|
public ParticleInstance(
|
|
Vector3 position,
|
|
Vector3 axisX,
|
|
Vector3 axisY,
|
|
uint colorArgb,
|
|
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
|
|
float distanceSq)
|
|
{
|
|
Position = position;
|
|
AxisX = axisX;
|
|
AxisY = axisY;
|
|
ColorArgb = colorArgb;
|
|
TextureSlot = textureSlot;
|
|
DistanceSq = distanceSq;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vertex-instance ABI shared with particle.vert. Campaign V slice V2c
|
|
/// (2026-07-27): TextureHandleLow/High (the split halves of a raw 64-bit
|
|
/// ARB_bindless_texture handle) became one TextureIndex — a slot into the
|
|
/// binding=9 handle table — so ordered particles using different textures
|
|
/// still remain one instanced draw when their blend mode matches.
|
|
/// </summary>
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
internal struct BillboardGpuInstance
|
|
{
|
|
public Vector4 Center;
|
|
public Vector4 AxisX;
|
|
public Vector4 AxisY;
|
|
public Vector4 Color;
|
|
public uint TextureIndex;
|
|
}
|
|
|
|
private readonly struct MeshParticleInstance
|
|
{
|
|
public readonly Matrix4x4 Model;
|
|
public readonly uint ColorArgb;
|
|
public readonly float DistanceSq;
|
|
|
|
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
|
|
{
|
|
Model = model;
|
|
ColorArgb = colorArgb;
|
|
DistanceSq = distanceSq;
|
|
}
|
|
}
|
|
|
|
private readonly TextureCache? _textures;
|
|
private readonly IDatReaderWriter? _dats;
|
|
private readonly WbMeshAdapter? _meshAdapter;
|
|
private readonly ParticleSystem _particles;
|
|
private readonly RetailAlphaQueue? _alphaQueue;
|
|
private readonly AlphaDrawSource _alphaSource;
|
|
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
|
|
private readonly Dictionary<int, ParticleGfxInfo> _particleGfxInfoByEmitter = new();
|
|
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
|
|
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
|
|
private readonly ParticleMeshReferenceTracker? _meshReferences;
|
|
private readonly ParticleEmitterRetirementTracker _emitterRetirements;
|
|
private RetryableResourceReleaseLedger? _disposeResources;
|
|
private bool _disposing;
|
|
private bool _disposed;
|
|
private readonly HashSet<uint> _meshLoadRequestedThisFrame = new();
|
|
private bool _dynamicFrameStarted;
|
|
|
|
/// <summary>
|
|
/// The GL arm's per-flight VAO/VBO pool this used to report on was deleted
|
|
/// at Campaign V slice V11: the RHI arm draws every particle instance from
|
|
/// a ring allocation that lives until its frame retires, so there is no
|
|
/// persistent dynamic-buffer pool left to size.
|
|
/// </summary>
|
|
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
|
|
|
|
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
|
|
private float[] _meshInstanceScratch = new float[256 * 20];
|
|
|
|
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
|
|
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
|
|
// each previously `new`ing a List<ParticleDraw> (BuildDrawList) and a
|
|
// List<ParticleInstance> (the per-batch `run` list) that became garbage
|
|
// as soon as the call returned. All Draw() calls happen sequentially on
|
|
// the render thread (verified: every call site in GameWindow.cs is a
|
|
// plain synchronous invocation from the single-threaded OnRender chain,
|
|
// none dispatched via Task.Run/Parallel) and each call fully drains its
|
|
// lists before returning, so a single pair of reused fields is safe -
|
|
// no call overlaps another's use of these buffers.
|
|
private readonly List<ParticleDraw> _drawListScratch = new(64);
|
|
private readonly List<ParticleInstance> _runScratch = new(64);
|
|
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
|
|
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
|
|
private readonly List<ParticleSubmission> _submissionScratch = new(128);
|
|
private readonly List<RuntimeParticleEmitter> _scopedEmitterScratch = new(64);
|
|
private readonly List<DeferredParticleDraw> _deferredAlpha = new(128);
|
|
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
|
|
private uint[] _preparedInstanceOffsets = new uint[256];
|
|
private int _preparedAlphaCount;
|
|
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
|
|
|
|
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
|
|
internal long RetainedAlphaScratchBytes => checked(
|
|
(long)_deferredAlpha.Capacity * Unsafe.SizeOf<DeferredParticleDraw>()
|
|
+ (long)_preparedAlpha.Length * Unsafe.SizeOf<DeferredParticleDraw>()
|
|
+ (long)_preparedInstanceOffsets.Length * sizeof(uint));
|
|
|
|
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
|
|
{
|
|
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
|
|
=> owner.PrepareDeferredAlphaDraws(tokens);
|
|
|
|
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
|
|
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
|
|
|
|
public void ResetAlphaSubmissions()
|
|
=> owner.ResetDeferredAlpha();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts one render frame. Wb point-of-use recovery is limited to one
|
|
/// request per missing GfxObj even though portal slicing may invoke Draw
|
|
/// many times during the frame.
|
|
/// </summary>
|
|
public void BeginFrame(int frameSlot)
|
|
{
|
|
// The GL arm's per-flight VAO/VBO pool this used to index into was
|
|
// deleted at Campaign V slice V11; the RHI arm takes every instance
|
|
// from a ring allocation, so frameSlot is validated but otherwise
|
|
// unused here.
|
|
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
|
|
|
|
_dynamicFrameStarted = true;
|
|
_meshLoadRequestedThisFrame.Clear();
|
|
_emitterRetirements.RetryPending();
|
|
_textures?.TickParticleTextureCache();
|
|
}
|
|
|
|
public void Draw(
|
|
ICamera camera,
|
|
Vector3 cameraWorldPos,
|
|
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
|
|
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter = null)
|
|
{
|
|
if (camera is null)
|
|
return;
|
|
|
|
Matrix4x4.Invert(camera.View, out var invView);
|
|
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
|
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
|
BuildDrawLists(
|
|
cameraWorldPos,
|
|
renderPass,
|
|
cameraRight,
|
|
cameraUp,
|
|
emitterFilter,
|
|
scopedEmitters: null);
|
|
FinishDraw(camera, renderPass);
|
|
}
|
|
|
|
public void DrawForOwners(
|
|
ICamera camera,
|
|
Vector3 cameraWorldPos,
|
|
ParticleRenderPass renderPass,
|
|
IReadOnlySet<uint> attachedOwnerIds,
|
|
bool includeUnattached = false,
|
|
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
|
|
{
|
|
if (camera is null)
|
|
return;
|
|
|
|
_particles.CopyRenderableEmittersForOwners(
|
|
renderPass,
|
|
attachedOwnerIds,
|
|
includeUnattached,
|
|
_scopedEmitterScratch,
|
|
excludedAttachedOwnerIds);
|
|
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
|
|
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
|
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
|
BuildDrawLists(
|
|
cameraWorldPos,
|
|
renderPass,
|
|
cameraRight,
|
|
cameraUp,
|
|
emitterFilter: null,
|
|
_scopedEmitterScratch);
|
|
FinishDraw(camera, renderPass);
|
|
}
|
|
|
|
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
|
|
{
|
|
if (_submissionScratch.Count == 0)
|
|
return;
|
|
|
|
if (renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true)
|
|
DeferToRetailAlphaQueue(camera);
|
|
else
|
|
DrawOrdered(camera);
|
|
}
|
|
|
|
private void DeferToRetailAlphaQueue(ICamera camera)
|
|
{
|
|
RetailAlphaQueue queue = _alphaQueue!;
|
|
Matrix4x4 viewProjection = camera.View * camera.Projection;
|
|
for (int i = 0; i < _submissionScratch.Count; i++)
|
|
{
|
|
ParticleSubmission submission = _submissionScratch[i];
|
|
DeferredParticleDraw deferred = submission.Kind == ParticleSubmissionKind.Billboard
|
|
? new DeferredParticleDraw(
|
|
submission.Kind,
|
|
_drawListScratch[submission.DrawIndex],
|
|
default,
|
|
viewProjection)
|
|
: new DeferredParticleDraw(
|
|
submission.Kind,
|
|
default,
|
|
_meshDrawListScratch[submission.DrawIndex],
|
|
viewProjection);
|
|
|
|
int token = _deferredAlpha.Count;
|
|
_deferredAlpha.Add(deferred);
|
|
queue.Submit(
|
|
_alphaSource,
|
|
token,
|
|
MathF.Sqrt(MathF.Max(0f, submission.DistanceSq)));
|
|
}
|
|
}
|
|
|
|
private void DrawOrdered(ICamera camera)
|
|
{
|
|
DrawOrderedRhi(camera);
|
|
}
|
|
|
|
private void PrepareDeferredAlphaDraws(ReadOnlySpan<int> tokens)
|
|
{
|
|
if (tokens.Length == 0)
|
|
return;
|
|
PrepareDeferredAlphaDrawsRhi(tokens);
|
|
}
|
|
|
|
private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
|
|
{
|
|
if (drawCount <= 0)
|
|
return;
|
|
if (firstPreparedDraw < 0
|
|
|| firstPreparedDraw > _preparedAlphaCount - drawCount)
|
|
throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw));
|
|
DrawPreparedAlphaBatchRhi(firstPreparedDraw, drawCount);
|
|
}
|
|
|
|
private void ResetDeferredAlpha()
|
|
{
|
|
int observedCount = Math.Max(
|
|
_deferredAlpha.Count,
|
|
_preparedAlphaCount);
|
|
_deferredAlpha.Clear();
|
|
_preparedAlphaCount = 0;
|
|
int currentCapacity = Math.Max(
|
|
_deferredAlpha.Capacity,
|
|
Math.Max(
|
|
_preparedAlpha.Length,
|
|
_preparedInstanceOffsets.Length));
|
|
int bytesPerDraw = checked(
|
|
2 * Unsafe.SizeOf<DeferredParticleDraw>() + sizeof(uint));
|
|
int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity(
|
|
currentCapacity,
|
|
observedCount,
|
|
bytesPerDraw,
|
|
minimumCapacity: 256,
|
|
growthQuantum: 256);
|
|
if (targetCapacity >= currentCapacity)
|
|
return;
|
|
|
|
_deferredAlpha.Capacity = targetCapacity;
|
|
Array.Resize(ref _preparedAlpha, targetCapacity);
|
|
Array.Resize(ref _preparedInstanceOffsets, targetCapacity);
|
|
}
|
|
|
|
private void BuildDrawLists(
|
|
Vector3 cameraWorldPos,
|
|
ParticleRenderPass renderPass,
|
|
Vector3 cameraRight,
|
|
Vector3 cameraUp,
|
|
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter,
|
|
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters)
|
|
{
|
|
var draws = _drawListScratch;
|
|
draws.Clear();
|
|
_meshDrawListScratch.Clear();
|
|
_submissionScratch.Clear();
|
|
int sequence = 0;
|
|
if (scopedEmitters is not null)
|
|
{
|
|
for (int i = 0; i < scopedEmitters.Count; i++)
|
|
{
|
|
AppendEmitterDraws(
|
|
scopedEmitters[i],
|
|
cameraWorldPos,
|
|
cameraRight,
|
|
cameraUp,
|
|
ref sequence);
|
|
}
|
|
return;
|
|
}
|
|
|
|
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
|
|
{
|
|
if (emitterFilter is null || emitterFilter(emitter))
|
|
AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence);
|
|
}
|
|
}
|
|
|
|
private void AppendEmitterDraws(
|
|
RuntimeParticleEmitter em,
|
|
Vector3 cameraWorldPos,
|
|
Vector3 cameraRight,
|
|
Vector3 cameraUp,
|
|
ref int sequence)
|
|
{
|
|
List<ParticleDraw> draws = _drawListScratch;
|
|
ParticleGfxInfo gfxInfo = default;
|
|
bool gfxInfoResolved = false;
|
|
|
|
for (int idx = 0; idx < em.Particles.Length; idx++)
|
|
{
|
|
ref Particle p = ref em.Particles[idx];
|
|
if (!p.Alive)
|
|
continue;
|
|
// `p.Position` is already in world coordinates: AttachLocal
|
|
// emitters get their AnchorPos refreshed each frame by the
|
|
// owning subsystem (sky-PES driver, animation tick, etc.) which
|
|
// mirrors retail's live-parent-frame read at
|
|
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
|
|
Vector3 pos = p.Position;
|
|
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
|
|
if (gfxObjId != 0
|
|
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
|
|
&& TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!gfxInfoResolved)
|
|
{
|
|
gfxInfo = ResolveParticleGfxInfo(em);
|
|
gfxInfoResolved = true;
|
|
}
|
|
bool additive = gfxInfo.HasMaterial
|
|
? gfxInfo.Additive
|
|
: (em.Desc.Flags & EmitterFlags.Additive) != 0;
|
|
var key = new BatchKey(additive);
|
|
Vector3 axisX;
|
|
Vector3 axisY;
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6e: the shader-side spelling of "this particle has no
|
|
/// texture, draw the procedural blob". It must agree with
|
|
/// <c>ACDREAM_TEXTURE_NONE</c> in <c>Shaders/common.glsl</c> and in the
|
|
/// Vulkan preamble.
|
|
///
|
|
/// <para>V2c encoded the same fact as "a table slot whose handle is zero",
|
|
/// which particle.frag could test because GL's emulated table stores the
|
|
/// handles themselves. Vulkan's table is an opaque descriptor array with
|
|
/// nothing to compare — reading an unwritten element of a partially-bound
|
|
/// array is undefined, not zero — so the fact moves into the index, where
|
|
/// both dialects test it the same way. GL renders identically: the same
|
|
/// particles take the same branch.</para>
|
|
/// </summary>
|
|
private const uint NoTextureSlot = 0xFFFFFFFFu;
|
|
|
|
// Campaign V slice V4t: static again — the particle already carries the
|
|
// device's table slot, so there is no per-renderer interning left to do.
|
|
// GpuTextureSlot.Unassigned and NoTextureSlot are the same 0xFFFFFFFF by
|
|
// construction (the contract's sentinel IS ACDREAM_TEXTURE_NONE), so the
|
|
// untextured branch collapses into the assignment rather than disappearing.
|
|
private static void WriteBillboardGpuInstance(
|
|
ref BillboardGpuInstance destination,
|
|
ParticleInstance particle)
|
|
{
|
|
destination = new BillboardGpuInstance
|
|
{
|
|
Center = new Vector4(particle.Position, 0f),
|
|
AxisX = new Vector4(particle.AxisX, 0f),
|
|
AxisY = new Vector4(particle.AxisY, 0f),
|
|
Color = new Vector4(
|
|
((particle.ColorArgb >> 16) & 0xFF) / 255f,
|
|
((particle.ColorArgb >> 8) & 0xFF) / 255f,
|
|
(particle.ColorArgb & 0xFF) / 255f,
|
|
((particle.ColorArgb >> 24) & 0xFF) / 255f),
|
|
TextureIndex = particle.TextureSlot.IsAssigned
|
|
? particle.TextureSlot.Index
|
|
: NoTextureSlot,
|
|
};
|
|
}
|
|
|
|
private static void WriteMeshGpuInstance(
|
|
float[] destination,
|
|
int offset,
|
|
MeshParticleInstance instance)
|
|
{
|
|
Matrix4x4 model = instance.Model;
|
|
destination[offset + 0] = model.M11;
|
|
destination[offset + 1] = model.M12;
|
|
destination[offset + 2] = model.M13;
|
|
destination[offset + 3] = model.M14;
|
|
destination[offset + 4] = model.M21;
|
|
destination[offset + 5] = model.M22;
|
|
destination[offset + 6] = model.M23;
|
|
destination[offset + 7] = model.M24;
|
|
destination[offset + 8] = model.M31;
|
|
destination[offset + 9] = model.M32;
|
|
destination[offset + 10] = model.M33;
|
|
destination[offset + 11] = model.M34;
|
|
destination[offset + 12] = model.M41;
|
|
destination[offset + 13] = model.M42;
|
|
destination[offset + 14] = model.M43;
|
|
destination[offset + 15] = model.M44;
|
|
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
|
|
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
|
|
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
|
|
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
|
|
}
|
|
|
|
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
|
|
{
|
|
uint surfaceId = batch.Key.SurfaceId;
|
|
if (surfaceId == 0 || _dats is null)
|
|
return batch.IsAdditive ? TranslucencyKind.Additive : TranslucencyKind.AlphaBlend;
|
|
if (_meshBlendBySurface.TryGetValue(surfaceId, out TranslucencyKind blend))
|
|
return blend;
|
|
|
|
blend = RetailParticleBlendResolver.Resolve(
|
|
surfaceId,
|
|
batch.IsAdditive,
|
|
id => _dats.Get<Surface>(id),
|
|
Console.Error.WriteLine);
|
|
_meshBlendBySurface[surfaceId] = blend;
|
|
return blend;
|
|
}
|
|
|
|
private RetailParticleGeometryKind ResolveGeometryKind(uint gfxObjId)
|
|
{
|
|
if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind))
|
|
return kind;
|
|
|
|
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));
|
|
|
|
// The RHI arm owns pipelines and two static quad buffers and no GL
|
|
// names at all, so it releases through the same retryable ledger and
|
|
// then there is nothing else to do. The raw-GL release path (tracked
|
|
// quad/dynamic-buffer/shader deletions) was deleted at Campaign V
|
|
// slice V11.
|
|
releases.Add(("rhi-resources", DisposeRhiResources));
|
|
}
|
|
|
|
private void RetireEveryResolvedEmitter()
|
|
{
|
|
int[] handles = [.. _particleGfxInfoByEmitter.Keys];
|
|
for (int i = 0; i < handles.Length; i++)
|
|
_emitterRetirements.BeginRetirement(handles[i]);
|
|
_emitterRetirements.CompleteOrThrow();
|
|
}
|
|
|
|
private void CompleteDispose()
|
|
{
|
|
_dynamicFrameStarted = false;
|
|
_particleGfxInfoByEmitter.Clear();
|
|
_particleGfxInfoByGfxObj.Clear();
|
|
_geometryKindByGfxObj.Clear();
|
|
_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);
|
|
}
|
|
}
|