using System.Numerics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Walk; using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using DatReaderWriter.Enums; namespace AcDream.App.Rendering.Wb; /// /// Campaign FW stage FW2: 's submitter. /// /// Walk-order submission through the SAME RHI machinery /// WbDrawDispatcher.Rhi.cs already owns — the ring-section writers, /// , and — is /// why this is a partial of rather than a /// standalone class. Two shapes are reused directly: /// 's per-instance-first emission /// (command i owns exactly one instance, BaseInstance = i, so /// walk order — never material bucketing — survives into the indirect array) /// and 's "write every section once" /// shape (_ordered* fields here, not the persisted _alpha* /// fields: those belong to , which can still be /// mid-flight in the same frame, and overwriting them would corrupt that /// replay). /// /// Campaign FW stage FW3.4a (2026-08-30): the FW2 submitter was ONE /// method, SubmitOrderedStream, that wrote every per-instance section /// AND drew every merge run in one call — fine for FW2/FW3.2's proof, but the /// FW3.4 perf checkpoint measured ~40 of these per frame at a town (one per /// walk segment — every cell shell, every building's alpha barrier, every /// punch fan flushes the accumulated stream so far), each one re-writing all /// nine per-instance ring sections and rebinding everything, for that /// segment's handful of instances. + /// replace it: the WHOLE frame's stream is /// written and bound ONCE, and each walk segment becomes a cheap /// call over the ALREADY-uploaded payload — /// the same split / /// already prove for the alpha path. /// SubmitOrderedStream itself is deleted; every FW2 caller/test now /// calls the pair (prepare once, draw the whole stream as one range, or as /// several — see OrderPreservingSubmitterTests). /// /// Scope: this stage proves walk-order submission through the existing /// RHI on static content (plan §FW2). It does NOT wire the retail /// building-detail overlay replay (DrawBuildingDetailRangeRhi's second /// pass through RetailDetail/RetailDetailTransparent) — a /// detail-category command still forces a solo merge run (mirroring the /// deferred-alpha detail break), but this submitter issues only the /// base-pipeline draw for it. The overlay replay is production wiring, /// deferred to whichever stage cuts the walk over for real content. /// public sealed unsafe partial class WbDrawDispatcher { /// /// One walk-order merge run: a maximal, in-order span of commands that /// share a , a resolved pipeline, and a cull /// mode, built by . /// internal readonly record struct OrderedMergeRun(int FirstCommand, int CommandCount); /// /// The four pipeline buckets a translucency kind resolves to, independent /// of any live instance. /// selects between Opaque and OpaqueAlphaToCoverage uniformly /// for the whole submission — it never varies per command — so merge-run /// legality only needs to know WHICH bucket a command falls in, not which /// concrete that resolves to. That is what lets /// stay pure CPU logic, testable /// without a live GPU device — the same separation /// already draws between layout and RHI /// glue. /// private enum PipelineBucket { Opaque, AlphaBlend, AlphaAdditive, AlphaInverse, } private static PipelineBucket BucketFor(TranslucencyKind kind) { if (IsOpaque(kind)) return PipelineBucket.Opaque; return kind switch { TranslucencyKind.Additive => PipelineBucket.AlphaAdditive, TranslucencyKind.InvAlpha => PipelineBucket.AlphaInverse, _ => PipelineBucket.AlphaBlend, }; } private IGpuPipeline PipelineForBucket(MeshPipelineSet pipelines, PipelineBucket bucket) => bucket switch { PipelineBucket.Opaque => AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque, PipelineBucket.AlphaAdditive => pipelines.AlphaAdditive, PipelineBucket.AlphaInverse => pipelines.AlphaInverse, _ => pipelines.AlphaBlend, }; /// /// Builds the maximal in-order merge runs for . /// Pure CPU: no GPU device, no live pipeline, no encoder — every legality /// decision is a comparison over the stream's own parallel arrays, which /// is what lets this be unit-tested directly. /// /// A run extends from command i to j while every /// command in [i, j) shares the same , /// the same , the same , /// and none carries a nonzero DetailCategory — a detail-category /// command always emits alone, mirroring /// DrawPreparedAlphaBatchRhi's hasDetail break. Never /// reorders or drops anything: every command in /// belongs to exactly one returned run, in stream order. /// /// (FW3.4a addition, /// default none): extra command indices, sorted ascending, at which a run /// must end even when the state comparison above would otherwise extend /// it. 's Replay phase draws the frame's ONE /// prepared stream as several calls — one /// per walk segment, each separated by a leaf GPU call (a cell shell, a /// punch fan, an alpha barrier) that MUST execute between them — and nothing /// about /bucket/cull/detail forbids two /// DIFFERENT segments from sharing all four (two consecutive indoor cells /// drawn Opaque/CounterClockwise, the overwhelmingly common case). Without /// this parameter, a whole-stream merge pass would happily fuse such /// segments into one run spanning the leaf call that must run BETWEEN /// them, silently reordering GPU commands relative to retail's walk — the /// one invariant this campaign may never trade away. Passing each /// segment's start index here makes 's /// "a range boundary always coincides with a run boundary" assumption /// true BY CONSTRUCTION instead of by hope; that method's own assert /// stays as insurance against a future bug in how boundaries are /// supplied. Every FW2 call site keeps passing none, so existing /// single-segment behavior (and its tests) is unchanged. /// /// Fails loud before building any run: /// has no submission path (see that value's own documentation), so a /// stream carrying one throws immediately rather than silently degrading /// to some other stage's handling. /// internal static List BuildOrderedMergeRuns( OrderedDrawStream stream, IReadOnlyList? forcedBreaksAscending = null) { ArgumentNullException.ThrowIfNull(stream); int count = stream.Count; for (int i = 0; i < count; i++) { if (stream.Stages[i] == WalkDrawStage.PortalPunch) { throw new NotSupportedException( $"OrderedDrawStream command {i} carries WalkDrawStage.PortalPunch, " + "which has no FW2 submission path — punch geometry emission lands " + "in FW3 with the world wiring " + "(docs/plans/2026-08-30-campaign-fw-frame-walk.md §FW2/§FW3). The " + "stage exists now purely so stage-separation gates can exercise the " + "boundary before the real emission path exists."); } } IReadOnlyList breaks = forcedBreaksAscending ?? Array.Empty(); int breakCursor = 0; var runs = new List(); int cursor = 0; while (cursor < count) { // A break AT OR BEFORE cursor already ended the previous run (or // predates the stream entirely) — only a break STRICTLY AFTER // cursor can stop the one starting here. while (breakCursor < breaks.Count && breaks[breakCursor] <= cursor) breakCursor++; WalkDrawStage stage = stream.Stages[cursor]; PipelineBucket bucket = BucketFor(stream.Keys[cursor].Translucency); CullMode cull = stream.Keys[cursor].CullMode; bool detail = stream.DetailCategories[cursor] != 0; int end = cursor + 1; if (!detail) { while (end < count && !(breakCursor < breaks.Count && breaks[breakCursor] == end) && stream.Stages[end] == stage && stream.DetailCategories[end] == 0 && BucketFor(stream.Keys[end].Translucency) == bucket && stream.Keys[end].CullMode == cull) { end++; } } runs.Add(new OrderedMergeRun(cursor, end - cursor)); cursor = end; } return runs; } /// /// The campaign's "assert it" rule (plan §FW2: "a merge across a state or /// stage boundary is forbidden by construction"). /// only ever EXTENDS a run while stage/bucket/cull/detail all match, so /// this should never fire — it exists so a future edit to that method's /// loop condition fails a test immediately instead of silently drawing /// the wrong material state for part of a run. /// private static void ValidateMergeRun(OrderedDrawStream stream, OrderedMergeRun run) { int firstCommand = run.FirstCommand; WalkDrawStage stage = stream.Stages[firstCommand]; PipelineBucket bucket = BucketFor(stream.Keys[firstCommand].Translucency); CullMode cull = stream.Keys[firstCommand].CullMode; bool detail = stream.DetailCategories[firstCommand] != 0; int end = firstCommand + run.CommandCount; if (detail && run.CommandCount != 1) { throw new InvalidOperationException( $"Merge run [{firstCommand}, {end}) carries a nonzero DetailCategory but " + $"contains {run.CommandCount} commands — a detail-category command must " + "emit alone."); } for (int i = firstCommand + 1; i < end; i++) { if (stream.Stages[i] != stage) { throw new InvalidOperationException( $"Merge run [{firstCommand}, {end}) crosses a WalkDrawStage boundary " + $"at command {i} ({stream.Stages[i]} != {stage}) — a merge across a " + "stage boundary is forbidden by construction (Campaign FW §FW2)."); } if (BucketFor(stream.Keys[i].Translucency) != bucket) { throw new InvalidOperationException( $"Merge run [{firstCommand}, {end}) crosses a pipeline boundary at " + $"command {i} — a merge across a material-state boundary is " + "forbidden by construction (Campaign FW §FW2)."); } if (stream.Keys[i].CullMode != cull) { throw new InvalidOperationException( $"Merge run [{firstCommand}, {end}) crosses a cull-mode boundary at " + $"command {i} — a merge across a material-state boundary is " + "forbidden by construction (Campaign FW §FW2)."); } if (stream.DetailCategories[i] != 0) { throw new InvalidOperationException( $"Merge run [{firstCommand}, {end}) contains a detail-category " + $"command at {i} outside a solo run — a detail-category command " + "must emit alone (Campaign FW §FW2)."); } } } /// /// Campaign FW3.2b-2: the production frame/encoder pair for /// 's own /// calls (contrast this stage's diagnostic-target callers, which supply /// their own frame/encoder). Reads the SAME world-pass scope /// already requires ( / /// _scope.RequireEncoder()) — fails loud rather than handing the /// driver a null pair when the world phase is not bracketing. /// internal (IGpuFrame Frame, IGpuPassEncoder Encoder) RequireWalkSubmission() => (RequireRhiFrame(), _scope!.RequireEncoder()); /// /// Campaign FW3.2b-2: the live colour-attachment size, for /// 's viewport (the walk's ray /// caster needs the REAL viewport, not the FW0/FW1 capture-client /// fixture constants — see that class's own doc comment). Null outside /// the world phase (no scope published yet); the caller falls back to /// the fixture constants with a comment in that case rather than /// failing loud, since a missing scope here is a startup-ordering /// timing question, not a misconfiguration. /// internal (int Width, int Height)? WalkAttachmentExtent => _scope is null ? null : (_scope.AttachmentWidth, _scope.AttachmentHeight); // ── Campaign FW3.4a: the prepared-once, drawn-in-ranges pair ─────────── // // Persisted state a PrepareOrderedStream call fills and every later // DrawOrderedRange call in the SAME frame reads. Deliberately its own set // — never _alpha* — for the same reason SubmitOrderedStream's per-frame // locals were never _alpha* (see this file's type doc comment): // RetailAlphaQueue can still be mid-flight when a walk segment flushes, // and sharing storage would corrupt whichever path writes second. private OrderedDrawStream? _orderedStream; private List _orderedRuns = new(); private int _orderedPreparedCount; // Caller-supplied, exactly like SubmitOrderedStream's own frame/encoder // parameters were (see this file's type doc comment: the walk submitter // draws into whatever pass its caller has open, never pulled from // _frames/_scope) — DrawOrderedRange's bind-once step needs the SAME // frame Prepare wrote sections into, for WorldFrameSectionBinding's // clip-region/scene-lighting binds; RequireRhiFrame() is the wrong tool // here since it demands the dispatcher's OWN BeginFrame/_dynamicFrameStarted // bookkeeping, which the walk path never participates in. private IGpuFrame? _orderedFrame; private Matrix4x4 _orderedViewProjection; private uint _orderedTransformBaseInstance; private RhiSection _orderedInstances; private RhiSection _orderedBatches; private RhiSection _orderedClipSlots; private RhiSection _orderedGlobalLights; private RhiSection _orderedLightSets; private RhiSection _orderedIndoor; private RhiSection _orderedAlpha; private RhiSection _orderedSelectionLighting; private RhiSection _orderedDetailCategory; private RhiSection _orderedCommands; /// /// Writes 's ENTIRE walk-order payload into the /// frame ring exactly ONCE — the per-instance-first emission /// ('s shape) followed by one /// section write per per-instance array ('s /// shape, into the _ordered* fields above). No draw happens here; /// issues the actual /// calls against this prepared payload, /// as many times as the caller needs ( calls /// it once per walk segment, interleaved with the leaf GPU calls that /// must run between segments). /// /// forwards to /// — see that parameter's own doc /// comment. Pass the walk segment boundaries here so a later /// call's range always aligns with a merge /// run by construction. /// /// A no-op (leaves at 0) when /// the stream is empty or the mesh source is not yet ready — mirrors /// 's own early-outs. Fails loud /// BEFORE any GPU work if the stream carries an unsupported stage (see /// ). /// internal void PrepareOrderedStream( IGpuFrame frame, OrderedDrawStream stream, in Matrix4x4 viewProjection, IReadOnlyList? forcedBreaksAscending = null) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(stream); _orderedStream = stream; _orderedFrame = frame; _orderedPreparedCount = 0; // Fail loud before any GPU work: a PortalPunch command has no // submission path. _orderedRuns = BuildOrderedMergeRuns(stream, forcedBreaksAscending); int count = stream.Count; if (count == 0) return; GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer; if (global is null || !MeshSourceReady()) return; // Per-instance-first emission — see PrepareDeferredAlphaDraws, into // the SAME shared per-instance scratch arrays that method writes // (safe: this is a single-threaded render frame, and the writer here // runs to completion — including the section uploads below — before // any other per-instance producer touches the scratch again). Cull // modes are the one exception: this stage keeps its own // _orderedDrawCullModes scratch (see DrawIndirectRangeRhi's doc // comment) precisely so a walk-ordered draw can freely interleave // with a mid-flight RetailAlphaQueue scope without corrupting — or // being corrupted by — the alpha path's _drawCullModes. EnsureDeferredAlphaCapacity(count); EnsureOrderedCullModeCapacity(count); for (int i = 0; i < count; i++) { GroupKey key = stream.Keys[i]; WriteMatrix(_instanceData, i * 16, stream.Transforms[i]); _clipSlotData[i] = stream.ClipSlots[i]; _indoorData[i] = stream.IndoorFlags[i]; _detailCategoryData[i] = stream.DetailCategories[i]; _alphaData[i] = stream.Alphas[i]; _selectionLightingData[i] = stream.SelectionLighting[i]; stream.Lights[i].CopyTo(_lightSetData, i * LightManager.MaxLightsPerObject); _batchData[i] = new BatchData { TextureIndex = key.TextureSlot.Index, TextureLayer = key.TextureLayer, Flags = 1u | key.FoliageFlags, }; _indirectCommands[i] = new DrawElementsIndirectCommand { Count = (uint)key.IndexCount, InstanceCount = 1, FirstIndex = key.FirstIndex, BaseVertex = key.BaseVertex, BaseInstance = (uint)i, }; _orderedDrawCullModes[i] = key.CullMode; } // Write every section ONCE — the PrepareRhiAlphaSections shape, into // _ordered* rather than _alpha* (see this file's type doc comment). _orderedViewProjection = viewProjection; _orderedInstances = WriteWorldTransformSection( frame, _instanceData.AsSpan(0, count * 16), out uint transformBaseInstance); _orderedTransformBaseInstance = transformBaseInstance; _orderedBatches = WriteRingSection(frame, _batchData.AsSpan(0, count)); _orderedClipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count)); int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int uploadCount = lightCount > 0 ? lightCount : 1; _orderedGlobalLights = WriteRingSection( frame, _globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight)); _orderedLightSets = WriteRingSection( frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject)); _orderedIndoor = WriteRingSection(frame, _indoorData.AsSpan(0, count)); _orderedAlpha = WriteRingSection(frame, _alphaData.AsSpan(0, count)); _orderedSelectionLighting = WriteRingSection( frame, _selectionLightingData.AsSpan(0, count)); _orderedDetailCategory = WriteRingSection(frame, _detailCategoryData.AsSpan(0, count)); GpuRingAllocation commandsAllocation = WriteIndirectCommands( frame, _indirectCommands.AsSpan(0, count), transformBaseInstance); _orderedCommands = new RhiSection( commandsAllocation.Buffer, commandsAllocation.OffsetBytes, checked((uint)(count * DrawCommandStride))); _orderedPreparedCount = count; } /// /// Draws commands [firstCommand, firstCommand + commandCount) of /// the payload the most recent call /// uploaded. Every call re-binds the pipeline, push constants, and the /// per-instance sections — leaf draws and alpha flushes between ranges /// rebind the same set-0 slots to THEIR buffers, so a latched skip draws /// against foreign sections (the dense-Arwic device-lost). The FW3.4a /// win is the once-per-frame ring WRITES in /// (what used to be ~40 full SubmitOrderedStream uploads per /// frame at a town becomes one bind plus ~40 cheap /// calls). Section binds survive /// pipeline switches (every mesh pipeline shares one layout — the same /// reasoning SubmitRhi/the old SubmitOrderedStream already /// relied on), so binding once per frame rather than once per pipeline /// switch is safe. /// /// Fail-loud range check mirrors 's: /// a range outside [0, _orderedPreparedCount] throws /// rather than silently /// clamping or drawing garbage — including when nothing was ever /// prepared this frame (a caller drawing without preparing is a real /// bug, not a valid empty draw). A zero-length range is a legal no-op /// (mirrors an empty walk segment). /// /// Walks the runs built that /// intersect this range. By construction (the boundaries the caller fed /// as forcedBreaksAscending) a /// run never starts before the range and never ends after it — this is /// asserted, not assumed: a run that straddles the range edge throws /// rather than being silently sliced, per plan §FW3.4a. /// internal void DrawOrderedRange(IGpuPassEncoder encoder, int firstCommand, int commandCount) { ArgumentNullException.ThrowIfNull(encoder); if (firstCommand < 0 || commandCount < 0 || firstCommand > _orderedPreparedCount - commandCount) { throw new ArgumentOutOfRangeException( nameof(firstCommand), "The ordered draw range exceeds the payload the most recent " + "PrepareOrderedStream call uploaded."); } if (commandCount == 0) return; if (_orderedCommands.Buffer is null || _orderedStream is null) return; GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer; if (global is null) return; MeshPipelineSet pipelines = PipelinesFor(encoder); var pushConstants = new GpuPushConstants { ViewProjection = _orderedViewProjection, DrawIdOffset = 0, LightingMode = 0, RenderPass = 0, LightDebug = RenderingDiagnostics.LightDebugMode, TextureIndexA = 0, TextureIndexB = _orderedTransformBaseInstance, ParamA = 0f, ParamB = 0f, }; { IGpuFrame frame = _orderedFrame ?? throw new InvalidOperationException( "DrawOrderedRange has no frame to bind clip-region/" + "scene-lighting sections against — PrepareOrderedStream must run first."); // Bind the SECTIONS on EVERY range call — never latch them // across calls. Between ordered ranges the walk's leaf draws run // (terrain, cell shells, sky, punch fans) and RetailAlphaQueue // flushes rebind the SAME set-0 storage bindings to THEIR // sections; a latched skip here draws the next range against the // alpha path's buffers — out-of-bounds instance reads and a // VK_ERROR_DEVICE_LOST at dense Arwic (the FW3.4a re-measure // crash). The expensive part — the ring WRITES — already happens // once per frame in PrepareOrderedStream; these are descriptor // binds only, the same per-batch rebinding the proven // DrawPreparedAlphaBatchRhi does for the same reason. BindPipelineWithMesh(encoder, pipelines.Opaque, global); encoder.SetPushConstants(in pushConstants); BindSection(encoder, GpuBindingModel.StorageInstances, _orderedInstances); BindSection(encoder, GpuBindingModel.StorageBatches, _orderedBatches); BindSection(encoder, GpuBindingModel.StorageClipSlots, _orderedClipSlots); BindSection(encoder, GpuBindingModel.StorageGlobalLights, _orderedGlobalLights); BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, _orderedLightSets); BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, _orderedIndoor); BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, _orderedAlpha); BindSection( encoder, GpuBindingModel.StorageInstanceSelectionLighting, _orderedSelectionLighting); BindSection( encoder, GpuBindingModel.StorageInstanceDetailCategory, _orderedDetailCategory); AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( encoder, _scope!.Sections, frame); AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( encoder, _scope!.Sections, frame); } IGpuBuffer commandBuffer = _orderedCommands.Buffer!; uint commandBase = _orderedCommands.OffsetBytes; int rangeEnd = firstCommand + commandCount; foreach (OrderedMergeRun run in _orderedRuns) { int runEnd = run.FirstCommand + run.CommandCount; if (runEnd <= firstCommand) continue; if (run.FirstCommand >= rangeEnd) break; if (run.FirstCommand < firstCommand || runEnd > rangeEnd) { throw new InvalidOperationException( $"DrawOrderedRange [{firstCommand}, {rangeEnd}) straddles merge run " + $"[{run.FirstCommand}, {runEnd}) — a range boundary must coincide with " + "a run boundary by construction (PrepareOrderedStream's " + "forcedBreaksAscending should have forced a break here; Campaign FW " + "§FW3.4a)."); } ValidateMergeRun(_orderedStream, run); PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency); IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket); pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1; BindPipelineWithMesh(encoder, pipeline, global); DrawIndirectRangeRhi( encoder, ref pushConstants, commandBuffer, commandBase, run.FirstCommand, run.CommandCount, _orderedDrawCullModes); } } /// /// Grows to at least /// — the same growth shape /// EnsureDeferredAlphaCapacity uses for , /// kept as its own method because this scratch array is not part of that /// method's shared per-instance group (see this file's type doc comment). /// private void EnsureOrderedCullModeCapacity(int count) { if (_orderedDrawCullModes.Length < count) _orderedDrawCullModes = new CullMode[count + 64]; } }