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;
///
/// Campaign V slice V6j: the world entity dispatcher's RHI submission arm.
///
/// 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.
///
/// 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 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 rasterizationSamples to match the pass and
/// alpha-to-coverage does nothing at one sample.
///
public sealed unsafe partial class WbDrawDispatcher
{
private readonly IGpuDevice? _device;
private readonly ICurrentGpuFrameSource? _frames;
private readonly IWorldPassScope? _scope;
///
/// The five mesh pipelines at ONE sample count.
///
/// Campaign V slice V6l: there are two of these. 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
/// 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.
///
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";
///
/// 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
/// ref struct lifetime is escaped through these ordinary values.
///
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;
///
/// The RHI arm's constructor. No GL context, no Shader, no
/// BindlessSupport: the five pipelines compile mesh_modern from
/// the committed SPIR-V, and batch data already carries the device's own
/// GpuTextureSlot (V4t) rather than a bindless handle.
///
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));
}
///
/// 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.
///
private MeshPipelineSet PipelinesFor(IGpuPassEncoder encoder) =>
encoder.Pass.SampleCount > 1
? _backbufferPipelines!
: _offscreenPipelines!;
///
/// The imperative Enable/Disable/BlendFunc/DepthMask 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 ApplyCullMode sets them, because core Vulkan 1.3 makes those
/// dynamic and blend and alpha-to-coverage not.
///
/// Depth compare is Less, not the contract's
/// LessOrEqual default: the world frame runs under GL_LESS and
/// this renderer never called glDepthFunc, so it inherited it. Baking
/// LessOrEqual would change which of two coplanar retail surfaces
/// wins.
///
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,
});
///
/// Records the opaque and transparent multi-draws into the borrowed world
/// pass. Phases 1–4 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.
///
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(
encoder, frame, GpuBindingModel.StorageInstances,
_instanceData.AsSpan(0, immediateInstances * 16));
BindRingSection(
encoder, frame, GpuBindingModel.StorageBatches,
_batchData.AsSpan(0, totalDraws));
BindRingSection(
encoder, frame, GpuBindingModel.StorageClipSlots,
_clipSlotData.AsSpan(0, immediateInstances));
BindGlobalLightsRhi(encoder, frame);
BindRingSection(
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, immediateInstances * LightManager.MaxLightsPerObject));
BindRingSection(
encoder, frame, GpuBindingModel.StorageInstanceIndoor,
_indoorData.AsSpan(0, immediateInstances));
BindRingSection(
encoder, frame, GpuBindingModel.StorageInstanceAlpha,
_alphaData.AsSpan(0, immediateInstances));
BindRingSection(
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);
}
///
/// Writes the prepared deferred-alpha payload into the frame ring once. The
/// sections survive as ordinary values so every later
/// DrawPreparedAlphaBatch binds the same bytes without recopying.
///
private void PrepareRhiAlphaSections(int count)
{
IGpuFrame frame = RequireRhiFrame();
_alphaInstances = WriteRingSection(frame, _instanceData.AsSpan(0, count * 16));
_alphaBatches = WriteRingSection(frame, _batchData.AsSpan(0, count));
_alphaClipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count));
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
_alphaGlobalLights = WriteRingSection(
frame,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
_alphaLightSets = WriteRingSection(
frame,
_lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
_alphaIndoor = WriteRingSection(frame, _indoorData.AsSpan(0, count));
_alphaOpacity = WriteRingSection(frame, _alphaData.AsSpan(0, count));
_alphaSelectionLighting = WriteRingSection(
frame,
_selectionLightingData.AsSpan(0, count));
_alphaCommands = WriteRingSection(
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;
}
}
///
/// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI
/// group and WB GameScene.cs:843 sets FrontFace(CW) globally. Both are
/// dynamic state in core Vulkan 1.3, and both must be re-issued after every
/// BindPipeline, which restores the pipeline's own defaults.
///
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;
}
}
///
/// 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.
///
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(
encoder,
frame,
GpuBindingModel.StorageGlobalLights,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
}
private static void BindRingSection(
IGpuPassEncoder encoder,
IGpuFrame frame,
uint binding,
ReadOnlySpan 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(
IGpuFrame frame,
ReadOnlySpan 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());
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;
///
/// 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.
///
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()
{
}
}
}