acdream/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
Erik 2e8b8b91ad feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan
Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.

Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.

Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.

Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.

Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.

The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 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.

And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:05:24 +02:00

635 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Selection;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Campaign V slice V6j: the world entity dispatcher's RHI submission arm.
///
/// <para>V4c's content, re-landed as a second arm rather than a replacement —
/// §5.5.6 selected that shape after NVIDIA rendered the V4c binary 10/10 and
/// AMD's GL stack did not. Every GL statement in the sibling file is untouched;
/// everything here runs only when there is no GL context.</para>
///
/// <para>Three differences from V4c, each because the tree moved under it. There
/// is no binding-9 texture table — V4t put the slot on the device and Vulkan
/// binds set 2. The pass is BORROWED from <see cref="IWorldPassScope"/> rather
/// than opened, because the frame's one backbuffer pass resolves. And the
/// pipelines carry the device's sample count, because Vulkan requires a
/// pipeline's <c>rasterizationSamples</c> to match the pass and
/// alpha-to-coverage does nothing at one sample.</para>
/// </summary>
public sealed unsafe partial class WbDrawDispatcher
{
private readonly IGpuDevice? _device;
private readonly ICurrentGpuFrameSource? _frames;
private readonly IWorldPassScope? _scope;
/// <summary>
/// The five mesh pipelines at ONE sample count.
///
/// <para>Campaign V slice V6l: there are two of these. Vulkan requires a
/// pipeline's <c>rasterizationSamples</c> to equal the pass it draws in, and
/// this dispatcher draws in two passes with different counts — the
/// multisampled backbuffer world pass, and the single-sampled offscreen
/// paperdoll/appraisal target, which the contract fixes at one sample. Plan
/// §5.5.16 defect 3 named exactly this as the reason those viewports could
/// not exist on the Vulkan arm, and the answer is the same shape §5.5.8 gave
/// the depth-format problem: materialise both, select at bind time from what
/// the live pass actually is. Both are built at startup against the persisted
/// cache, so no frame ever compiles one.</para>
/// </summary>
private sealed record MeshPipelineSet(
int SampleCount,
IGpuPipeline Opaque,
IGpuPipeline OpaqueAlphaToCoverage,
IGpuPipeline AlphaBlend,
IGpuPipeline AlphaAdditive,
IGpuPipeline AlphaInverse);
private MeshPipelineSet? _backbufferPipelines;
private MeshPipelineSet? _offscreenPipelines;
private const string OpaqueTimerScope = "wb-entities-opaque";
private const string TransparentTimerScope = "wb-entities-transparent";
/// <summary>
/// A ring slice reduced to the three values a later bind needs. The prepared
/// alpha payload is written once and bound many times, so the allocation's
/// <c>ref struct</c> lifetime is escaped through these ordinary values.
/// </summary>
private readonly record struct RhiSection(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes);
private RhiSection _alphaInstances;
private RhiSection _alphaBatches;
private RhiSection _alphaClipSlots;
private RhiSection _alphaGlobalLights;
private RhiSection _alphaLightSets;
private RhiSection _alphaIndoor;
private RhiSection _alphaOpacity;
private RhiSection _alphaSelectionLighting;
private RhiSection _alphaCommands;
/// <summary>
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
/// <c>BindlessSupport</c>: the five pipelines compile <c>mesh_modern</c> from
/// the committed SPIR-V, and batch data already carries the device's own
/// <c>GpuTextureSlot</c> (V4t) rather than a bindless handle.
/// </summary>
internal WbDrawDispatcher(
IGpuDevice device,
ICurrentGpuFrameSource frames,
IWorldPassScope scope,
TextureCache textures,
WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter,
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = 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 ?? throw new ArgumentNullException(nameof(textures));
_meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter));
_entitySpawnAdapter = entitySpawnAdapter
?? throw new ArgumentNullException(nameof(entitySpawnAdapter));
_cache = classificationCache
?? throw new ArgumentNullException(nameof(classificationCache));
_translucencyFades = translucencyFades
?? throw new ArgumentNullException(nameof(translucencyFades));
_selectionSink = selectionSink;
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes)
.DispatcherBytes;
_alphaScratchPolicy = new RetainedScratchCapacityPolicy(scratchBudget);
int samples = scope.SampleCount;
try
{
_backbufferPipelines = CreateMeshPipelineSet(device, samples);
// One sample is what an IGpuRenderTarget is by contract, so a second
// set only exists when the backbuffer is multisampled.
_offscreenPipelines = samples == 1
? _backbufferPipelines
: CreateMeshPipelineSet(device, 1);
}
catch
{
DisposeRhiResources();
throw;
}
}
private static MeshPipelineSet CreateMeshPipelineSet(IGpuDevice device, int samples)
{
string suffix = samples > 1 ? string.Empty : "-1x";
return new MeshPipelineSet(
samples,
CreateMeshPipeline(
device, $"wb-mesh-opaque{suffix}", GpuBlendMode.None, true, false, samples),
CreateMeshPipeline(
device, $"wb-mesh-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples),
CreateMeshPipeline(
device, $"wb-mesh-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples),
CreateMeshPipeline(
device, $"wb-mesh-additive{suffix}", GpuBlendMode.Additive, false, false, samples),
CreateMeshPipeline(
device, $"wb-mesh-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples));
}
/// <summary>
/// The pipeline set whose sample count matches the pass being recorded into.
/// Taken from the live pass rather than from the scope, because the offscreen
/// viewport borrows the scope with a pass of its own.
/// </summary>
private MeshPipelineSet PipelinesFor(IGpuPassEncoder encoder) =>
encoder.Pass.SampleCount > 1
? _backbufferPipelines!
: _offscreenPipelines!;
/// <summary>
/// The imperative <c>Enable/Disable/BlendFunc/DepthMask</c> brackets became
/// pipeline variants: opaque, opaque with alpha-to-coverage, and the three
/// retail blends. Cull mode and front face stay dynamic per MDI run, exactly
/// where <c>ApplyCullMode</c> sets them, because core Vulkan 1.3 makes those
/// dynamic and blend and alpha-to-coverage not.
///
/// <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. Baking
/// <c>LessOrEqual</c> would change which of two coplanar retail surfaces
/// wins.</para>
/// </summary>
private static IGpuPipeline CreateMeshPipeline(
IGpuDevice device,
string name,
GpuBlendMode blend,
bool depthWrite,
bool alphaToCoverage,
int sampleCount) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = new GpuShaderSet("mesh_modern"),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less),
Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = alphaToCoverage,
ColorWrite = true,
SampleCount = sampleCount,
});
/// <summary>
/// Records the opaque and transparent multi-draws into the borrowed world
/// pass. Phases 14 above are untouched — the bucketing, the sorts, the
/// indirect-command array and every retail fidelity decision are the same
/// CPU code on both arms; only where the bytes land differs.
/// </summary>
private void SubmitRhi(
Matrix4x4 viewProjection,
int immediateInstances,
int totalDraws,
bool diag)
{
IWorldPassScope scope = _scope!;
IGpuPassEncoder encoder = scope.RequireEncoder();
IGpuFrame frame = RequireRhiFrame();
GlobalMeshBuffer mesh = _meshAdapter.MeshManager?.GlobalBuffer
?? throw new InvalidOperationException("The shared mesh arena is not published.");
var pushConstants = new GpuPushConstants
{
ViewProjection = viewProjection,
DrawIdOffset = 0,
LightingMode = 0,
RenderPass = 0,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
TextureIndexB = 0,
ParamA = 0f,
ParamB = 0f,
};
// Bind the opaque variant first so the ring binds land on a live program;
// the transparent bracket rebinds its own variant, and push constants
// survive that switch per the encoder contract.
MeshPipelineSet pipelines = PipelinesFor(encoder);
BindPipelineWithMesh(
encoder,
AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque,
mesh);
encoder.SetPushConstants(in pushConstants);
BindRingSection<float>(
encoder, frame, GpuBindingModel.StorageInstances,
_instanceData.AsSpan(0, immediateInstances * 16));
BindRingSection<BatchData>(
encoder, frame, GpuBindingModel.StorageBatches,
_batchData.AsSpan(0, totalDraws));
BindRingSection<uint>(
encoder, frame, GpuBindingModel.StorageClipSlots,
_clipSlotData.AsSpan(0, immediateInstances));
BindGlobalLightsRhi(encoder, frame);
BindRingSection<int>(
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, immediateInstances * LightManager.MaxLightsPerObject));
BindRingSection<uint>(
encoder, frame, GpuBindingModel.StorageInstanceIndoor,
_indoorData.AsSpan(0, immediateInstances));
BindRingSection<float>(
encoder, frame, GpuBindingModel.StorageInstanceAlpha,
_alphaData.AsSpan(0, immediateInstances));
BindRingSection<Vector2>(
encoder, frame, GpuBindingModel.StorageInstanceSelectionLighting,
_selectionLightingData.AsSpan(0, immediateInstances));
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, scope.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, scope.Sections, frame);
GpuRingAllocation commands = frame.AllocateRing(
totalDraws * DrawCommandStride,
GpuRingUsage.Indirect);
MemoryMarshal.AsBytes(_indirectCommands.AsSpan(0, totalDraws))
.CopyTo(commands.Data);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
// ── Phase 7: opaque pass ─────────────────────────────────────────────
if (_opaqueDrawCount > 0)
{
// Blend-off, depth-write-on and A.5 T20's alpha-to-coverage all come
// from the pipeline rather than an imperative bracket. Issue #52's
// per-pass batch offset is unchanged: the opaque section of Batches[]
// starts at index 0, and Vulkan's gl_DrawID resets per
// vkCmdDrawIndexedIndirect exactly as GL's does.
pushConstants.RenderPass = 0;
pushConstants.DrawIdOffset = 0;
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, OpaqueTimerScope))
{
DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase,
0, _opaqueDrawCount);
}
}
// ── Phase 8: transparent pass ────────────────────────────────────────
if (_transparentDrawCount > 0)
{
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
// Issue #52 again: the transparent section starts at _opaqueDrawCount.
// Without the offset each transparent draw reads the OPAQUE section
// and the lifestone crystal's texture flickers.
pushConstants.RenderPass = 1;
pushConstants.DrawIdOffset = _opaqueDrawCount;
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, TransparentTimerScope))
{
DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase,
_opaqueDrawCount, _transparentDrawCount);
}
}
SampleRhiTimers(diag);
}
/// <summary>
/// Writes the prepared deferred-alpha payload into the frame ring once. The
/// sections survive as ordinary values so every later
/// <c>DrawPreparedAlphaBatch</c> binds the same bytes without recopying.
/// </summary>
private void PrepareRhiAlphaSections(int count)
{
IGpuFrame frame = RequireRhiFrame();
_alphaInstances = WriteRingSection<float>(frame, _instanceData.AsSpan(0, count * 16));
_alphaBatches = WriteRingSection<BatchData>(frame, _batchData.AsSpan(0, count));
_alphaClipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
_alphaGlobalLights = WriteRingSection<float>(
frame,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
_alphaLightSets = WriteRingSection<int>(
frame,
_lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
_alphaIndoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
_alphaOpacity = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
_alphaSelectionLighting = WriteRingSection<Vector2>(
frame,
_selectionLightingData.AsSpan(0, count));
_alphaCommands = WriteRingSection<DrawElementsIndirectCommand>(
frame,
_indirectCommands.AsSpan(0, count),
GpuRingUsage.Indirect);
}
private void DrawPreparedAlphaBatchRhi(
GlobalMeshBuffer mesh,
int firstPreparedDraw,
int drawCount)
{
if (_alphaCommands.Buffer is null)
return;
IWorldPassScope scope = _scope!;
IGpuPassEncoder encoder = scope.RequireEncoder();
IGpuFrame frame = RequireRhiFrame();
var pushConstants = new GpuPushConstants
{
ViewProjection = _deferredAlphaViewProjection,
DrawIdOffset = 0,
LightingMode = 0,
RenderPass = 1,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
TextureIndexB = 0,
ParamA = 0f,
ParamB = 0f,
};
MeshPipelineSet pipelines = PipelinesFor(encoder);
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
encoder.SetPushConstants(in pushConstants);
BindSection(encoder, GpuBindingModel.StorageInstances, _alphaInstances);
BindSection(encoder, GpuBindingModel.StorageBatches, _alphaBatches);
BindSection(encoder, GpuBindingModel.StorageClipSlots, _alphaClipSlots);
BindSection(encoder, GpuBindingModel.StorageGlobalLights, _alphaGlobalLights);
BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, _alphaLightSets);
BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, _alphaIndoor);
BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, _alphaOpacity);
BindSection(
encoder,
GpuBindingModel.StorageInstanceSelectionLighting,
_alphaSelectionLighting);
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, scope.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, scope.Sections, frame);
int runStart = firstPreparedDraw;
int preparedEnd = firstPreparedDraw + drawCount;
while (runStart < preparedEnd)
{
TranslucencyKind blend = _deferredAlphaKinds[runStart];
int runEnd = runStart + 1;
while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend)
runEnd++;
// ApplyRetailBlend's three cases are three pipelines, including the
// inverse-alpha one GpuBlendMode.InverseAlpha was added for.
BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh);
encoder.SetPushConstants(in pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
runStart,
runEnd - runStart);
runStart = runEnd;
}
}
private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) =>
blend switch
{
TranslucencyKind.Additive => pipelines.AlphaAdditive,
TranslucencyKind.InvAlpha => pipelines.AlphaInverse,
_ => pipelines.AlphaBlend,
};
private void DrawIndirectRangeRhi(
IGpuPassEncoder encoder,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBaseOffsetBytes,
int startCommand,
int commandCount)
{
int end = startCommand + commandCount;
int command = startCommand;
while (command < end)
{
CullMode cullMode = _drawCullModes[command];
ApplyCullModeRhi(encoder, cullMode);
int runCount = 1;
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
runCount++;
// Each multi-draw-indirect call restarts gl_DrawID at 0, so a run
// that begins partway into the batch array must carry its absolute
// command index or it reads BatchData[0] again (issue #52).
pushConstants.DrawIdOffset = command;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBaseOffsetBytes + (uint)(command * DrawCommandStride),
(uint)runCount,
(uint)DrawCommandStride);
command += runCount;
}
}
/// <summary>
/// WB <c>BaseObjectRenderManager.cs:850-866</c> applies CullMode per MDI
/// group and WB <c>GameScene.cs:843</c> sets FrontFace(CW) globally. Both are
/// dynamic state in core Vulkan 1.3, and both must be re-issued after every
/// <c>BindPipeline</c>, which restores the pipeline's own defaults.
/// </summary>
private static void ApplyCullModeRhi(IGpuPassEncoder encoder, CullMode mode)
{
encoder.SetFrontFace(GpuFrontFace.Clockwise);
switch (mode)
{
case CullMode.None:
encoder.SetCullMode(GpuCullMode.None);
break;
case CullMode.Clockwise:
encoder.SetCullMode(GpuCullMode.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
encoder.SetCullMode(GpuCullMode.Back);
break;
}
}
/// <summary>
/// Binds a pipeline and immediately re-establishes the mesh source. Every
/// pipeline owns its own vertex array, and vertex attribute pointers plus the
/// index binding are vertex-array state, so a pipeline switch inside a pass
/// silently drops them while storage bindings survive.
/// </summary>
private static void BindPipelineWithMesh(
IGpuPassEncoder encoder,
IGpuPipeline pipeline,
GlobalMeshBuffer mesh)
{
encoder.BindPipeline(pipeline);
encoder.BindVertexBuffer(
0,
mesh.VertexStore ?? throw new InvalidOperationException(
"The shared mesh arena has no vertex store."),
0);
encoder.BindIndexBuffer(
mesh.IndexStore ?? throw new InvalidOperationException(
"The shared mesh arena has no index store."),
0,
GpuIndexType.UInt16);
}
private void BindGlobalLightsRhi(IGpuPassEncoder encoder, IGpuFrame frame)
{
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
BindRingSection<float>(
encoder,
frame,
GpuBindingModel.StorageGlobalLights,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
}
private static void BindRingSection<T>(
IGpuPassEncoder encoder,
IGpuFrame frame,
uint binding,
ReadOnlySpan<T> data)
where T : unmanaged =>
BindSection(encoder, binding, WriteRingSection(frame, data));
private static void BindSection(
IGpuPassEncoder encoder,
uint binding,
in RhiSection section)
{
if (section.Buffer is null)
return;
encoder.BindStorageBuffer(
binding,
section.Buffer,
section.OffsetBytes,
section.SizeBytes);
}
private static RhiSection WriteRingSection<T>(
IGpuFrame frame,
ReadOnlySpan<T> data,
GpuRingUsage usage = GpuRingUsage.Storage)
where T : unmanaged
{
int elementBytes = sizeof(T);
int byteCount = Math.Max(data.Length * elementBytes, elementBytes);
GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage);
if (!data.IsEmpty)
data.CopyTo(allocation.AsSpan<T>());
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
}
private IGpuFrame RequireRhiFrame()
{
// The same precondition ActivateNextDynamicBufferSet enforces on GL: a
// draw that has not been bracketed by BeginFrame has no slot to write to.
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing world entities.");
return _frames!.CurrentFrame
?? throw new InvalidOperationException(
"WbDrawDispatcher requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
}
private static IDisposable BeginRhiTimer(
IGpuPassEncoder encoder,
bool diag,
string scopeName) =>
diag ? encoder.BeginTimerScope(scopeName) : NullRhiTimerScope.Instance;
/// <summary>
/// The [WB-DIAG] median/p95 window still measures opaque + transparent GPU
/// time; the sample now comes from the device's timer pool — the most recent
/// retired result — rather than a hand-rolled 3-deep query ring read at N-3.
/// A sample can therefore repeat when the GPU has not finished a newer query,
/// where the old code dropped it. Diagnostic-only.
/// </summary>
private void SampleRhiTimers(bool diag)
{
if (!diag || _device is null)
return;
double totalMs = 0;
bool any = false;
if (_device.Timers.TryResolve(OpaqueTimerScope, out double opaqueMs))
{
totalMs += opaqueMs;
any = true;
}
if (_device.Timers.TryResolve(TransparentTimerScope, out double transparentMs))
{
totalMs += transparentMs;
any = true;
}
if (!any)
return;
_gpuSamples[_gpuSampleCursor] = (long)(totalMs * 1000.0);
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
private void DisposeRhiResources()
{
MeshPipelineSet? backbuffer = _backbufferPipelines;
MeshPipelineSet? offscreen = _offscreenPipelines;
_backbufferPipelines = null;
_offscreenPipelines = null;
DisposeMeshPipelineSet(backbuffer);
// Reference-equal when the backbuffer is single-sampled, in which case
// there is one set and disposing it twice would be a double free.
if (!ReferenceEquals(offscreen, backbuffer))
DisposeMeshPipelineSet(offscreen);
}
private static void DisposeMeshPipelineSet(MeshPipelineSet? pipelines)
{
if (pipelines is null)
return;
pipelines.Opaque.Dispose();
pipelines.OpaqueAlphaToCoverage.Dispose();
pipelines.AlphaBlend.Dispose();
pipelines.AlphaAdditive.Dispose();
pipelines.AlphaInverse.Dispose();
}
private sealed class NullRhiTimerScope : IDisposable
{
internal static NullRhiTimerScope Instance { get; } = new();
public void Dispose()
{
}
}
}