Vulkan is the sole, user-signed-off backend (V10 landed) and step 1 already removed ImGui/Studio/DevTools. This step deletes the GL rendering backend itself: every Gpu/Gl/** implementation, the Wb ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/ BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache, RenderBootstrap, and RenderFrameGlStateController. GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/ OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone — there is nothing left to select between. The five world-draw dual-arm renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer, SkyRenderer) and the composition roots (WorldRenderComposition, HostInputCameraComposition, LivePresentationComposition, FrameRootComposition) collapse to their RHI-only arm. GL-only diagnostic properties with a live external reader (DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op rather than disappearing, since the reader is out of this commit's scope. A few GL-flavored mechanisms turned out to be backend-neutral once isolated: GlConstructionCleanupLedger is renamed ResourceConstructionCleanupLedger (exception-chain walking has nothing to do with GL), and GlfwNativePlatformProbe moved out of the otherwise GL-only GraphicalCapabilityRecord.cs into GraphicalWindowBackendSelection.cs before the rest of that file was deleted. Test files with no surviving subject are deleted outright (GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests, PortalDepthShaderParityTests, TextureCacheBindlessTests, TextRendererFailureSafetyTests, ClipFrameUploadTests, every Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests); others get their dead GL-only members trimmed while their live assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now reads GpuBindingModel.StorageClipRegions, the same binding index under its new backend-neutral name; GpuResourceRetirementTransactionTests drops its OpenGLGraphicsDevice-subclassing test double and the two GL queue tests it existed for). EnvCellRendererTests' construction helper now builds a real ObjectMeshManager via VulkanMeshPipelineDevice instead of passing null through a null-forgiving operator, since the RHI constructor never tolerated a null mesh manager and the old GL constructor (which did) is gone. Deferred to the next two steps, deliberately not touched here: the Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl (WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale csproj comment (the package itself is still load-bearing — TextureFormat and friends are used well beyond the deleted ManagedGLUniformBuffer), and the CI/gate scripts. Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors. Tests: full-solution `dotnet test` green across every project (App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all others 100%); the 2 App.Tests names that flake under full-suite parallel execution (#250-family, documented pre-existing) pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
705 lines
29 KiB
C#
705 lines
29 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.Immutable;
|
||
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
using AcDream.App.Rendering.Gpu;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Meshing;
|
||
using AcDream.Content;
|
||
using AcDream.Core.Vfx;
|
||
using DatReaderWriter.Enums;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V6l: the particle renderer's RHI submission arm — V4e's
|
||
/// content, landed as a SECOND arm for the reason §5.5.6 gave.
|
||
///
|
||
/// <para><b>The contract amendment this arm exists to use.</b> Both particle
|
||
/// pipelines draw with PER-INSTANCE vertex attributes: <c>particle</c> at
|
||
/// locations 2–6 (centre, two sheet axes, colour, texture slot) and
|
||
/// <c>particle_mesh</c> at 3–7 (a <c>mat4</c> model and a colour). The V0
|
||
/// contract could express instanced DRAWING but not instanced vertex INPUT,
|
||
/// which is what stopped V4e at §5.5.16. <see cref="GpuVertexLayout"/> now
|
||
/// carries a per-binding stride and input rate — a second binding at
|
||
/// <see cref="GpuVertexInputRate.Instance"/> — which both backends implement
|
||
/// natively and at no cost.</para>
|
||
///
|
||
/// <para><b>What differs from the GL arm, and why.</b> The imperative
|
||
/// <c>glBlendFunc</c> switch becomes five PIPELINES (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 texture table is not bound at all, because the device owns the
|
||
/// table and the encoder binds set 2; and the pass is BORROWED from
|
||
/// <see cref="IWorldPassScope"/>, because the frame's one backbuffer pass
|
||
/// resolves and a second pass could not load what it left.</para>
|
||
///
|
||
/// <para>Everything above the submission seam — emitter iteration, retail
|
||
/// distance ordering, the deferred-alpha handoff to <see cref="RetailAlphaQueue"/>,
|
||
/// billboard axis construction, blend resolution — is the same CPU code on both
|
||
/// arms. Only where the bytes land differs.</para>
|
||
/// </summary>
|
||
public sealed unsafe partial class ParticleRenderer
|
||
{
|
||
private readonly IGpuDevice? _device;
|
||
private readonly ICurrentGpuFrameSource? _frames;
|
||
private readonly IWorldPassScope? _scope;
|
||
|
||
private IGpuPipeline? _billboardAlphaPipeline;
|
||
private IGpuPipeline? _billboardAdditivePipeline;
|
||
private IGpuPipeline? _meshAlphaPipeline;
|
||
private IGpuPipeline? _meshAdditivePipeline;
|
||
private IGpuPipeline? _meshInversePipeline;
|
||
private IGpuBuffer? _quadVertexBuffer;
|
||
private IGpuBuffer? _quadIndexBuffer;
|
||
|
||
/// <summary>
|
||
/// The unit quad both arms draw billboards from: XY in [-0.5, +0.5] with a
|
||
/// matching UV, four vertices of two floats each twice over.
|
||
/// </summary>
|
||
private static readonly float[] QuadVertices =
|
||
[
|
||
-0.5f, -0.5f, 0f, 0f,
|
||
0.5f, -0.5f, 1f, 0f,
|
||
0.5f, 0.5f, 1f, 1f,
|
||
-0.5f, 0.5f, 0f, 1f,
|
||
];
|
||
|
||
private static readonly uint[] QuadIndices = [0, 1, 2, 0, 2, 3];
|
||
|
||
private const uint QuadStrideBytes = 4 * sizeof(float);
|
||
|
||
/// <summary>Floats per mesh-particle instance: a <c>mat4</c> plus an RGBA colour.</summary>
|
||
internal const int MeshInstanceFloats = 20;
|
||
|
||
private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float);
|
||
|
||
/// <summary>
|
||
/// The billboard layout: the shared unit quad at vertex rate, and one
|
||
/// <see cref="BillboardGpuInstance"/> per particle at instance rate.
|
||
///
|
||
/// <para>The instance stride is <c>sizeof(BillboardGpuInstance)</c> rather
|
||
/// than a restated number, for the reason §5.5.16 drew from the sky's
|
||
/// 32-versus-36 defect: a layout that restates a CPU record's footprint from
|
||
/// memory is one field away from scattering the draw into noise while leaving
|
||
/// nothing else in the frame visibly wrong.</para>
|
||
/// </summary>
|
||
internal static GpuVertexLayout BillboardVertexLayout { get; } = new(
|
||
ImmutableArray.Create(
|
||
new GpuVertexBinding(0, QuadStrideBytes, GpuVertexInputRate.Vertex),
|
||
new GpuVertexBinding(
|
||
1,
|
||
(uint)sizeof(BillboardGpuInstance),
|
||
GpuVertexInputRate.Instance)),
|
||
ImmutableArray.Create(
|
||
new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0, Binding: 0),
|
||
new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8, Binding: 0),
|
||
new GpuVertexAttribute(2, GpuVertexFormat.Float4, 0, Binding: 1),
|
||
new GpuVertexAttribute(3, GpuVertexFormat.Float4, 16, Binding: 1),
|
||
new GpuVertexAttribute(4, GpuVertexFormat.Float4, 32, Binding: 1),
|
||
new GpuVertexAttribute(5, GpuVertexFormat.Float4, 48, Binding: 1),
|
||
// Location 6 is `in uint aTextureIndex` — an INTEGER shader input,
|
||
// so R8G8B8A8's normalized cousin would be wrong in kind. It is one
|
||
// 32-bit unsigned value; Float1 would reinterpret its bits.
|
||
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1)));
|
||
|
||
/// <summary>
|
||
/// The mesh-particle layout: the shared world-mesh vertex at vertex rate,
|
||
/// and a <c>mat4</c> model plus a colour at instance rate. A <c>mat4</c>
|
||
/// vertex input occupies four consecutive locations, one per column, which is
|
||
/// exactly what the GL arm's four <c>glVertexAttribPointer</c> calls set up.
|
||
/// </summary>
|
||
internal static GpuVertexLayout MeshVertexLayout { get; } = new(
|
||
ImmutableArray.Create(
|
||
new GpuVertexBinding(
|
||
0,
|
||
GpuVertexLayout.WorldMesh.StrideBytes,
|
||
GpuVertexInputRate.Vertex),
|
||
new GpuVertexBinding(1, MeshInstanceStrideBytes, GpuVertexInputRate.Instance)),
|
||
ImmutableArray.Create(
|
||
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0, Binding: 0),
|
||
new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12, Binding: 0),
|
||
new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24, Binding: 0),
|
||
new GpuVertexAttribute(3, GpuVertexFormat.Float4, 0, Binding: 1),
|
||
new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1),
|
||
new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1),
|
||
new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1),
|
||
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1)));
|
||
|
||
/// <summary>
|
||
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
|
||
/// <c>BindlessSupport</c>: the five pipelines compile <c>particle</c> and
|
||
/// <c>particle_mesh</c> from the committed SPIR-V, and both texture sources
|
||
/// already hand out the device's own <c>GpuTextureSlot</c> (V4t).
|
||
/// </summary>
|
||
internal ParticleRenderer(
|
||
IGpuDevice device,
|
||
ICurrentGpuFrameSource frames,
|
||
IWorldPassScope scope,
|
||
ParticleSystem particles,
|
||
TextureCache? textures = null,
|
||
IDatReaderWriter? dats = null,
|
||
WbMeshAdapter? meshAdapter = null,
|
||
RetailAlphaQueue? alphaQueue = null,
|
||
long? alphaScratchBudgetBytes = null)
|
||
{
|
||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
|
||
_textures = textures;
|
||
_dats = dats;
|
||
_meshAdapter = meshAdapter;
|
||
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
||
_alphaQueue = alphaQueue;
|
||
_alphaSource = new AlphaDrawSource(this);
|
||
long scratchBudget = alphaScratchBudgetBytes
|
||
?? AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
|
||
AcDream.App.Rendering.Residency.ResidencyBudgetOptions.Default.AlphaScratchBytes)
|
||
.ParticleBytes;
|
||
_alphaScratchPolicy =
|
||
new AcDream.App.Rendering.Residency.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}"));
|
||
|
||
try
|
||
{
|
||
CreateRhiResources(device, scope.SampleCount);
|
||
_particles.EmitterDied += OnEmitterDied;
|
||
}
|
||
catch
|
||
{
|
||
DisposeRhiResources();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when mesh particles can be submitted at all. The GL arm's second
|
||
/// <c>Shader</c> answer was deleted at Campaign V slice V11; only the RHI
|
||
/// pipelines remain, built exactly when a shared mesh arena exists.
|
||
/// </summary>
|
||
private bool MeshParticlesAvailable => _meshAlphaPipeline is not null;
|
||
|
||
private void CreateRhiResources(IGpuDevice device, int sampleCount)
|
||
{
|
||
_billboardAlphaPipeline = CreateBillboardPipeline(
|
||
device, "particle-billboard-alpha", GpuBlendMode.StraightAlpha, sampleCount);
|
||
_billboardAdditivePipeline = CreateBillboardPipeline(
|
||
device, "particle-billboard-additive", GpuBlendMode.Additive, sampleCount);
|
||
|
||
ReadOnlySpan<byte> quadVertexBytes = MemoryMarshal.AsBytes<float>(QuadVertices);
|
||
_quadVertexBuffer = device.CreateBuffer(new GpuBufferDescription(
|
||
"particle-quad-vertices",
|
||
quadVertexBytes.Length,
|
||
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
_quadVertexBuffer.Upload(0, quadVertexBytes);
|
||
|
||
ReadOnlySpan<byte> quadIndexBytes = MemoryMarshal.AsBytes<uint>(QuadIndices);
|
||
_quadIndexBuffer = device.CreateBuffer(new GpuBufferDescription(
|
||
"particle-quad-indices",
|
||
quadIndexBytes.Length,
|
||
GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
_quadIndexBuffer.Upload(0, quadIndexBytes);
|
||
|
||
// The mesh pipelines exist exactly when the GL arm's second shader would:
|
||
// when a shared mesh arena is published to draw instanced GfxObjs from.
|
||
if (_meshAdapter?.MeshManager?.GlobalBuffer is null)
|
||
return;
|
||
|
||
_meshAlphaPipeline = CreateMeshParticlePipeline(
|
||
device, "particle-mesh-alpha", GpuBlendMode.StraightAlpha, sampleCount);
|
||
_meshAdditivePipeline = CreateMeshParticlePipeline(
|
||
device, "particle-mesh-additive", GpuBlendMode.Additive, sampleCount);
|
||
_meshInversePipeline = CreateMeshParticlePipeline(
|
||
device, "particle-mesh-inverse", GpuBlendMode.InverseAlpha, sampleCount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// One billboard pipeline. Depth TESTS but does not WRITE and culling is off,
|
||
/// which is the GL arm's bracket verbatim
|
||
/// (<c>Enable(DepthTest)</c>/<c>DepthMask(false)</c>/<c>Disable(CullFace)</c>).
|
||
///
|
||
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c>
|
||
/// default: the world frame runs under <c>GL_LESS</c> and this renderer never
|
||
/// called <c>glDepthFunc</c>, so it inherited it. Alpha-to-coverage is off
|
||
/// for the same kind of reason and the opposite way round — the frame-global
|
||
/// state controller disables it and only <c>WbDrawDispatcher</c>'s opaque
|
||
/// bracket turns it on, so particles have never drawn with it.</para>
|
||
/// </summary>
|
||
private static IGpuPipeline CreateBillboardPipeline(
|
||
IGpuDevice device,
|
||
string name,
|
||
GpuBlendMode blend,
|
||
int sampleCount) =>
|
||
device.CreatePipeline(new GpuPipelineDescription
|
||
{
|
||
Name = name,
|
||
Shaders = new GpuShaderSet("particle"),
|
||
VertexLayout = BillboardVertexLayout,
|
||
Topology = GpuPrimitiveTopology.TriangleList,
|
||
Blend = blend,
|
||
Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less),
|
||
Cull = GpuCullMode.None,
|
||
FrontFace = GpuFrontFace.CounterClockwise,
|
||
AlphaToCoverage = false,
|
||
ColorWrite = true,
|
||
SampleCount = sampleCount,
|
||
});
|
||
|
||
/// <summary>
|
||
/// One mesh-particle pipeline. Same depth bracket as the billboards; the
|
||
/// winding is CW because <c>PrepareMeshPipeline</c> sets
|
||
/// <c>glFrontFace(GL_CW)</c>, and the cull mode stays DYNAMIC because it is
|
||
/// resolved per sub-batch from the DAT's own <c>CullMode</c>.
|
||
/// </summary>
|
||
private static IGpuPipeline CreateMeshParticlePipeline(
|
||
IGpuDevice device,
|
||
string name,
|
||
GpuBlendMode blend,
|
||
int sampleCount) =>
|
||
device.CreatePipeline(new GpuPipelineDescription
|
||
{
|
||
Name = name,
|
||
Shaders = new GpuShaderSet("particle_mesh"),
|
||
VertexLayout = MeshVertexLayout,
|
||
Topology = GpuPrimitiveTopology.TriangleList,
|
||
Blend = blend,
|
||
Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less),
|
||
Cull = GpuCullMode.None,
|
||
FrontFace = GpuFrontFace.Clockwise,
|
||
AlphaToCoverage = false,
|
||
ColorWrite = true,
|
||
SampleCount = sampleCount,
|
||
});
|
||
|
||
/// <summary>
|
||
/// The immediate ordered path on the RHI arm: the same runs, in the same
|
||
/// retail distance order, recorded into the borrowed world pass.
|
||
/// </summary>
|
||
private void DrawOrderedRhi(ICamera camera)
|
||
{
|
||
ParticleSubmissionOrdering.Sort(_submissionScratch);
|
||
GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
|
||
Matrix4x4 viewProjection = camera.View * camera.Projection;
|
||
IGpuPassEncoder encoder = _scope!.RequireEncoder();
|
||
IGpuFrame frame = RequireRhiFrame();
|
||
|
||
for (int i = 0; i < _submissionScratch.Count;)
|
||
{
|
||
ParticleSubmission submission = _submissionScratch[i];
|
||
if (submission.Kind == ParticleSubmissionKind.Billboard)
|
||
{
|
||
BatchKey key = _drawListScratch[submission.DrawIndex].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);
|
||
|
||
DrawInstancesRhi(encoder, frame, _runScratch, viewProjection, key.Additive);
|
||
continue;
|
||
}
|
||
|
||
if (!MeshParticlesAvailable || 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);
|
||
|
||
int neededFloats = _meshRunScratch.Count * MeshInstanceFloats;
|
||
if (_meshInstanceScratch.Length < neededFloats)
|
||
_meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats];
|
||
for (int instance = 0; instance < _meshRunScratch.Count; instance++)
|
||
{
|
||
WriteMeshGpuInstance(
|
||
_meshInstanceScratch,
|
||
instance * MeshInstanceFloats,
|
||
_meshRunScratch[instance]);
|
||
}
|
||
|
||
GpuRingAllocation instances = WriteVertexRing<float>(
|
||
frame,
|
||
_meshInstanceScratch.AsSpan(0, neededFloats));
|
||
DrawMeshBatchRhi(
|
||
encoder,
|
||
global,
|
||
batch,
|
||
viewProjection,
|
||
instances.Buffer,
|
||
instances.OffsetBytes,
|
||
(uint)_meshRunScratch.Count,
|
||
firstInstance: 0);
|
||
}
|
||
}
|
||
|
||
private void DrawInstancesRhi(
|
||
IGpuPassEncoder encoder,
|
||
IGpuFrame frame,
|
||
List<ParticleInstance> instances,
|
||
Matrix4x4 viewProjection,
|
||
bool additive)
|
||
{
|
||
if (instances.Count == 0)
|
||
return;
|
||
|
||
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]);
|
||
|
||
GpuRingAllocation ring = WriteVertexRing<BillboardGpuInstance>(
|
||
frame,
|
||
_instanceScratch.AsSpan(0, instances.Count));
|
||
BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes);
|
||
encoder.DrawIndexed(
|
||
(uint)QuadIndices.Length,
|
||
(uint)instances.Count,
|
||
0,
|
||
0,
|
||
0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Binds a billboard pipeline and immediately re-establishes both vertex
|
||
/// sources and the index source. Every pipeline owns its own vertex array on
|
||
/// GL, and attribute pointers plus the index binding are vertex-array state,
|
||
/// so a pipeline switch silently drops them while storage bindings survive.
|
||
/// </summary>
|
||
private void BindBillboardPipeline(
|
||
IGpuPassEncoder encoder,
|
||
Matrix4x4 viewProjection,
|
||
bool additive,
|
||
IGpuBuffer instanceBuffer,
|
||
uint instanceOffsetBytes)
|
||
{
|
||
encoder.BindPipeline(additive
|
||
? _billboardAdditivePipeline!
|
||
: _billboardAlphaPipeline!);
|
||
encoder.SetPushConstants(new GpuPushConstants
|
||
{
|
||
ViewProjection = viewProjection,
|
||
DrawIdOffset = 0,
|
||
LightingMode = 0,
|
||
RenderPass = 0,
|
||
LightDebug = 0,
|
||
// Billboards carry their texture slot per instance at location 6;
|
||
// the block's texture members are unread by particle.frag.
|
||
TextureIndexA = 0,
|
||
TextureIndexB = 0,
|
||
ParamA = 0f,
|
||
ParamB = 0f,
|
||
});
|
||
encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0);
|
||
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
|
||
encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32);
|
||
}
|
||
|
||
private void DrawMeshBatchRhi(
|
||
IGpuPassEncoder encoder,
|
||
GlobalMeshBuffer global,
|
||
ObjectRenderBatch batch,
|
||
Matrix4x4 viewProjection,
|
||
IGpuBuffer instanceBuffer,
|
||
uint instanceOffsetBytes,
|
||
uint instanceCount,
|
||
uint firstInstance)
|
||
{
|
||
if (instanceCount == 0)
|
||
return;
|
||
|
||
encoder.BindPipeline(PipelineForMeshBlend(ResolveMeshBlend(batch)));
|
||
encoder.SetPushConstants(new GpuPushConstants
|
||
{
|
||
ViewProjection = viewProjection,
|
||
DrawIdOffset = 0,
|
||
LightingMode = 0,
|
||
RenderPass = 0,
|
||
LightDebug = 0,
|
||
TextureIndexA = batch.TextureSlot.Index,
|
||
TextureIndexB = 0,
|
||
// uParamA is a float, so the array layer is widened here rather than
|
||
// in the shader. Layers are small integers; the sampled value is
|
||
// bit-identical to the GL arm's.
|
||
ParamA = batch.TextureIndex,
|
||
ParamB = 0f,
|
||
});
|
||
// BindPipeline restores the pipeline's own default cull mode, so the
|
||
// per-sub-batch override has to follow it, exactly as the world
|
||
// dispatcher's does.
|
||
ApplyMeshCullModeRhi(encoder, batch.CullMode);
|
||
encoder.BindVertexBuffer(
|
||
0,
|
||
global.VertexStore ?? throw new InvalidOperationException(
|
||
"The shared mesh arena has no vertex store."),
|
||
0);
|
||
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
|
||
encoder.BindIndexBuffer(
|
||
global.IndexStore ?? throw new InvalidOperationException(
|
||
"The shared mesh arena has no index store."),
|
||
0,
|
||
GpuIndexType.UInt16);
|
||
encoder.DrawIndexed(
|
||
(uint)batch.IndexCount,
|
||
instanceCount,
|
||
(uint)batch.FirstIndex,
|
||
(int)batch.BaseVertex,
|
||
firstInstance);
|
||
}
|
||
|
||
private IGpuPipeline PipelineForMeshBlend(TranslucencyKind blend) => blend switch
|
||
{
|
||
TranslucencyKind.Additive => _meshAdditivePipeline!,
|
||
TranslucencyKind.InvAlpha => _meshInversePipeline!,
|
||
_ => _meshAlphaPipeline!,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The RHI form of <see cref="ApplyMeshCullMode"/>. <c>FrontFace</c> is
|
||
/// re-issued with it because <c>BindPipeline</c> restores the pipeline's own
|
||
/// default and the two always travel together on the GL arm.
|
||
/// </summary>
|
||
private static void ApplyMeshCullModeRhi(IGpuPassEncoder encoder, CullMode mode)
|
||
{
|
||
encoder.SetFrontFace(GpuFrontFace.Clockwise);
|
||
encoder.SetCullMode(mode switch
|
||
{
|
||
CullMode.None => GpuCullMode.None,
|
||
CullMode.Clockwise => GpuCullMode.Front,
|
||
_ => GpuCullMode.Back,
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes the whole deferred-alpha payload into the frame ring once. The two
|
||
/// sections survive as ordinary values so every later
|
||
/// <see cref="DrawPreparedAlphaBatchRhi"/> binds the same bytes with a
|
||
/// <c>firstInstance</c> offset instead of recopying — which is exactly what
|
||
/// the GL arm's <c>baseInstance</c> does.
|
||
/// </summary>
|
||
private void PrepareDeferredAlphaDrawsRhi(ReadOnlySpan<int> tokens)
|
||
{
|
||
IGpuFrame frame = RequireRhiFrame();
|
||
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 * MeshInstanceFloats;
|
||
if (_meshInstanceScratch.Length < neededMeshFloats)
|
||
_meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats];
|
||
|
||
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++ * MeshInstanceFloats,
|
||
deferred.Mesh.Instance);
|
||
}
|
||
}
|
||
|
||
_preparedBillboardInstances = billboardCount > 0
|
||
? SectionOf(WriteVertexRing<BillboardGpuInstance>(
|
||
frame,
|
||
_instanceScratch.AsSpan(0, billboardCount)))
|
||
: default;
|
||
_preparedMeshInstances = meshCount > 0
|
||
? SectionOf(WriteVertexRing<float>(
|
||
frame,
|
||
_meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats)))
|
||
: default;
|
||
_preparedAlphaCount = count;
|
||
}
|
||
|
||
private void DrawPreparedAlphaBatchRhi(int firstPreparedDraw, int drawCount)
|
||
{
|
||
GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
|
||
IGpuPassEncoder encoder = _scope!.RequireEncoder();
|
||
|
||
int i = firstPreparedDraw;
|
||
int preparedEnd = firstPreparedDraw + drawCount;
|
||
while (i < preparedEnd)
|
||
{
|
||
DeferredParticleDraw deferred = _preparedAlpha[i];
|
||
if (deferred.Kind == ParticleSubmissionKind.Billboard)
|
||
{
|
||
BatchKey key = deferred.Billboard.Key;
|
||
Matrix4x4 viewProjection = 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 == viewProjection);
|
||
|
||
if (_preparedBillboardInstances.Buffer is { } billboards)
|
||
{
|
||
BindBillboardPipeline(
|
||
encoder,
|
||
viewProjection,
|
||
key.Additive,
|
||
billboards,
|
||
_preparedBillboardInstances.OffsetBytes);
|
||
encoder.DrawIndexed(
|
||
(uint)QuadIndices.Length,
|
||
(uint)(i - runStart),
|
||
0,
|
||
0,
|
||
baseInstance);
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (!MeshParticlesAvailable || global is null)
|
||
{
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
MeshBatchKey meshKey = deferred.Mesh.Key;
|
||
ObjectRenderBatch batch = deferred.Mesh.Batch;
|
||
Matrix4x4 meshViewProjection = 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 == meshViewProjection);
|
||
|
||
if (_preparedMeshInstances.Buffer is { } meshInstances)
|
||
{
|
||
DrawMeshBatchRhi(
|
||
encoder,
|
||
global,
|
||
batch,
|
||
meshViewProjection,
|
||
meshInstances,
|
||
_preparedMeshInstances.OffsetBytes,
|
||
(uint)(i - meshRunStart),
|
||
meshBaseInstance);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>A ring slice reduced to the two values a later vertex bind needs.</summary>
|
||
private readonly record struct RhiVertexSection(IGpuBuffer? Buffer, uint OffsetBytes);
|
||
|
||
private RhiVertexSection _preparedBillboardInstances;
|
||
private RhiVertexSection _preparedMeshInstances;
|
||
|
||
private static RhiVertexSection SectionOf(GpuRingAllocation allocation) =>
|
||
new(allocation.Buffer, allocation.OffsetBytes);
|
||
|
||
private static GpuRingAllocation WriteVertexRing<T>(IGpuFrame frame, ReadOnlySpan<T> data)
|
||
where T : unmanaged
|
||
{
|
||
int elementBytes = sizeof(T);
|
||
int byteCount = Math.Max(data.Length * elementBytes, elementBytes);
|
||
GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex);
|
||
if (!data.IsEmpty)
|
||
data.CopyTo(allocation.AsSpan<T>());
|
||
return allocation;
|
||
}
|
||
|
||
private IGpuFrame RequireRhiFrame()
|
||
{
|
||
if (!_dynamicFrameStarted)
|
||
throw new InvalidOperationException("BeginFrame must be called before drawing particles.");
|
||
|
||
return _frames!.CurrentFrame
|
||
?? throw new InvalidOperationException(
|
||
"ParticleRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
||
}
|
||
|
||
private void DisposeRhiResources()
|
||
{
|
||
List<Exception>? failures = null;
|
||
void Attempt(Action action)
|
||
{
|
||
try { action(); }
|
||
catch (Exception error) { (failures ??= []).Add(error); }
|
||
}
|
||
|
||
Attempt(() => _billboardAlphaPipeline?.Dispose());
|
||
_billboardAlphaPipeline = null;
|
||
Attempt(() => _billboardAdditivePipeline?.Dispose());
|
||
_billboardAdditivePipeline = null;
|
||
Attempt(() => _meshAlphaPipeline?.Dispose());
|
||
_meshAlphaPipeline = null;
|
||
Attempt(() => _meshAdditivePipeline?.Dispose());
|
||
_meshAdditivePipeline = null;
|
||
Attempt(() => _meshInversePipeline?.Dispose());
|
||
_meshInversePipeline = null;
|
||
Attempt(() => _quadVertexBuffer?.Dispose());
|
||
_quadVertexBuffer = null;
|
||
Attempt(() => _quadIndexBuffer?.Dispose());
|
||
_quadIndexBuffer = null;
|
||
_preparedBillboardInstances = default;
|
||
_preparedMeshInstances = default;
|
||
|
||
if (failures is not null)
|
||
throw new AggregateException("The particle renderer's RHI resources did not fully release.", failures);
|
||
}
|
||
}
|