feat(render): Campaign V slice V6l commit 1 - particles draw on Vulkan
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
08ffe141a0
commit
b1ad1d481b
23 changed files with 1360 additions and 64 deletions
706
src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
Normal file
706
src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
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 answers with
|
||||
/// its second <c>Shader</c>, which is only built when a shared mesh arena
|
||||
/// exists; the RHI arm answers with the pipelines the same condition builds.
|
||||
/// </summary>
|
||||
private bool MeshParticlesAvailable =>
|
||||
_glContext is null ? _meshAlphaPipeline is not null : _meshShader 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue