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);
}
}
}