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() { } } }