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 (locals 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). /// /// 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 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. /// /// Fails loud before building any run: /// has no FW2 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) { 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."); } } var runs = new List(); int cursor = 0; while (cursor < count) { 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 && 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)."); } } } /// /// Submits in walk order through the existing /// RHI: per-instance-first emission (see the type doc comment), one /// section write per per-instance array, then one /// call per maximal merge run from /// . N commands in yield indirect /// commands [0, N) in stream order, each covered by exactly one /// emitted run — nothing is reordered, sorted, or dropped. /// /// and are /// caller-supplied rather than pulled from _frames/_scope /// (contrast 's RequireRhiFrame/ /// scope.RequireEncoder()): the walk submitter draws into whatever /// pass its caller has open, including an offscreen diagnostic target /// that never touches the dispatcher's own world-pass scope. The frame's /// shared clip-region/scene-lighting sections still come from /// _scope.Sections — those are canonical per-frame published /// state, not something this submitter owns. /// internal void SubmitOrderedStream( IGpuFrame frame, IGpuPassEncoder encoder, OrderedDrawStream stream, in Matrix4x4 viewProjection) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(encoder); ArgumentNullException.ThrowIfNull(stream); int count = stream.Count; if (count == 0) return; // Fail loud before any GPU work: a PortalPunch command has no FW2 // submission path. List runs = BuildOrderedMergeRuns(stream); GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer; if (global is null || !MeshSourceReady()) return; // Per-instance-first emission — the PrepareDeferredAlphaDraws shape, // into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/ // SubmitRhi write. Most are consumed immediately by the ring uploads // below, but _drawCullModes is NOT write-then-consume: the deferred- // alpha path reads it at FLUSH time (DrawIndirectRangeRhi's internal // cull split), so an ordered submission may never interleave between // RetailAlphaQueue prepare and flush. FW2 has no production caller; // the FW3 wiring must either sequence around the alpha scope or give // this path its own cull scratch. EnsureDeferredAlphaCapacity(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, }; _drawCullModes[i] = key.CullMode; } // Write every section ONCE — the PrepareRhiAlphaSections shape, but // into locals rather than the persisted _alpha* fields (see the type // doc comment for why those must stay untouched here). RhiSection instances = WriteWorldTransformSection( frame, _instanceData.AsSpan(0, count * 16), out uint transformBaseInstance); RhiSection batches = WriteRingSection(frame, _batchData.AsSpan(0, count)); RhiSection clipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count)); int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int uploadCount = lightCount > 0 ? lightCount : 1; RhiSection globalLights = WriteRingSection( frame, _globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight)); RhiSection lightSets = WriteRingSection( frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject)); RhiSection indoor = WriteRingSection(frame, _indoorData.AsSpan(0, count)); RhiSection alpha = WriteRingSection(frame, _alphaData.AsSpan(0, count)); RhiSection selectionLighting = WriteRingSection( frame, _selectionLightingData.AsSpan(0, count)); RhiSection detailCategory = WriteRingSection(frame, _detailCategoryData.AsSpan(0, count)); GpuRingAllocation commandsAllocation = WriteIndirectCommands( frame, _indirectCommands.AsSpan(0, count), transformBaseInstance); IGpuBuffer commandBuffer = commandsAllocation.Buffer; uint commandBase = commandsAllocation.OffsetBytes; MeshPipelineSet pipelines = PipelinesFor(encoder); var pushConstants = new GpuPushConstants { ViewProjection = viewProjection, DrawIdOffset = 0, LightingMode = 0, RenderPass = 0, LightDebug = RenderingDiagnostics.LightDebugMode, TextureIndexA = 0, TextureIndexB = transformBaseInstance, ParamA = 0f, ParamB = 0f, }; // Bind the opaque variant first so the storage/uniform binds below // land on a live program (SubmitRhi's own rationale) — every mesh // pipeline shares one layout, so these bindings survive the per-run // pipeline switches in the loop below. BindPipelineWithMesh(encoder, pipelines.Opaque, global); encoder.SetPushConstants(in pushConstants); BindSection(encoder, GpuBindingModel.StorageInstances, instances); BindSection(encoder, GpuBindingModel.StorageBatches, batches); BindSection(encoder, GpuBindingModel.StorageClipSlots, clipSlots); BindSection(encoder, GpuBindingModel.StorageGlobalLights, globalLights); BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, lightSets); BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, indoor); BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, alpha); BindSection(encoder, GpuBindingModel.StorageInstanceSelectionLighting, selectionLighting); BindSection(encoder, GpuBindingModel.StorageInstanceDetailCategory, detailCategory); AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( encoder, _scope!.Sections, frame); AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( encoder, _scope!.Sections, frame); // One in-order pass over the pre-built merge runs: bind the run's // pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits // internally on _drawCullModes (issue #52's absolute DrawIdOffset per // sub-call) — every run here already shares one cull mode by // construction, so that inner split is a no-op here, never a second // boundary this loop failed to expect. foreach (OrderedMergeRun run in runs) { ValidateMergeRun(stream, run); PipelineBucket bucket = BucketFor(stream.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); } } }