feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan

The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.

What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.

The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.

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,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.

That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.

Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.

Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.

Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.

The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.

Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.

What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 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, and a graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 15:46:54 +02:00
parent 81fe5e1b63
commit f84eef3256
22 changed files with 2566 additions and 264 deletions

View file

@ -0,0 +1,570 @@
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;
private IGpuPipeline? _opaquePipeline;
private IGpuPipeline? _opaqueAlphaToCoveragePipeline;
private IGpuPipeline? _alphaBlendPipeline;
private IGpuPipeline? _alphaAdditivePipeline;
private IGpuPipeline? _alphaInversePipeline;
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;
_opaquePipeline = CreateMeshPipeline(
device, "wb-mesh-opaque", GpuBlendMode.None, true, false, samples);
_opaqueAlphaToCoveragePipeline = CreateMeshPipeline(
device, "wb-mesh-opaque-a2c", GpuBlendMode.None, true, true, samples);
_alphaBlendPipeline = CreateMeshPipeline(
device, "wb-mesh-alpha", GpuBlendMode.StraightAlpha, false, false, samples);
_alphaAdditivePipeline = CreateMeshPipeline(
device, "wb-mesh-additive", GpuBlendMode.Additive, false, false, samples);
_alphaInversePipeline = CreateMeshPipeline(
device, "wb-mesh-inverse", GpuBlendMode.InverseAlpha, false, false, samples);
}
/// <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.
BindPipelineWithMesh(
encoder,
AlphaToCoverage ? _opaqueAlphaToCoveragePipeline! : _opaquePipeline!,
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, _alphaBlendPipeline!, 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,
};
BindPipelineWithMesh(encoder, _alphaBlendPipeline!, 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(blend), mesh);
encoder.SetPushConstants(in pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
runStart,
runEnd - runStart);
runStart = runEnd;
}
}
private IGpuPipeline PipelineForBlend(TranslucencyKind blend) => blend switch
{
TranslucencyKind.Additive => _alphaAdditivePipeline!,
TranslucencyKind.InvAlpha => _alphaInversePipeline!,
_ => _alphaBlendPipeline!,
};
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(
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()
{
_opaquePipeline?.Dispose();
_opaquePipeline = null;
_opaqueAlphaToCoveragePipeline?.Dispose();
_opaqueAlphaToCoveragePipeline = null;
_alphaBlendPipeline?.Dispose();
_alphaBlendPipeline = null;
_alphaAdditivePipeline?.Dispose();
_alphaAdditivePipeline = null;
_alphaInversePipeline?.Dispose();
_alphaInversePipeline = null;
}
private sealed class NullRhiTimerScope : IDisposable
{
internal static NullRhiTimerScope Instance { get; } = new();
public void Dispose()
{
}
}
}