The walk-order submission layer over the existing RHI (plan section FW2): - OrderedDrawStream: append-only walk-ordered draw commands (GroupKey + transform + per-instance data + WalkDrawStage + cell provenance), struct-of-arrays with one lockstep Reset (#193 shape). The PortalPunch stage exists but has no FW2 submission path - the submitter throws on it; punch emission lands with FW3 wiring. - WbDrawDispatcher.OrderedStream partial: per-instance-first emission (the deferred-alpha shape - command i owns instance i, walk order survives into the indirect array), each SSBO section written once, then one DrawIndirectRangeRhi call per maximal merge run. Runs are built by pure-CPU BuildOrderedMergeRuns and may never span a stage, pipeline-bucket, or cull boundary; ValidateMergeRun re-checks every emitted run and throws (the campaign fail-loud rule). Nothing is sorted, reordered, or dropped: N commands in, N indirect commands out, covered exactly once. - WorldDepthContract: retail world depth verified verbatim from the decomp - Render::zfuncVal @0x00820e1c = 0x2, SetDepthBufferMode @0x005a2d10 writes the enum directly as D3DRS_ZFUNC so the value IS D3DCMP_LESS, applied by the surface-state applier @0x0059c80a with Z-write toggled by blend; the LESSEQUAL sites are GameSky::Draw-local. Seven world pipeline sites now cite the named constant (no value changes). - Plan updated: FW1 status block + gate amendment (the ten pose-stamped retail traces supersede re-expressing the old-builder replay fixtures; those retire with the old builder at FW4 and their scenario classes re-verify at the FW3/FW4 connected gates). Known FW2 scope notes recorded in the code: the building-detail overlay replay is production wiring (FW3); the _drawCullModes scratch may not interleave with a mid-flight RetailAlphaQueue scope (FW3 sequencing constraint). The pixel A/B equivalence proof rides FW3's cutover toggle where a walk-driven scene first exists. Suites: full Release build 0 warnings; Walk lane 154/1 skip; hermetic 6,714/0 (+27 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
371 lines
18 KiB
C#
371 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign FW stage FW2: <see cref="OrderedDrawStream"/>'s submitter.
|
|
///
|
|
/// <para>Walk-order submission through the SAME RHI machinery
|
|
/// <c>WbDrawDispatcher.Rhi.cs</c> already owns — the ring-section writers,
|
|
/// <see cref="MeshPipelineSet"/>, and <see cref="DrawIndirectRangeRhi"/> — is
|
|
/// why this is a partial of <see cref="WbDrawDispatcher"/> rather than a
|
|
/// standalone class. Two shapes are reused directly:
|
|
/// <see cref="PrepareDeferredAlphaDraws"/>'s per-instance-first emission
|
|
/// (command <c>i</c> owns exactly one instance, <c>BaseInstance = i</c>, so
|
|
/// walk order — never material bucketing — survives into the indirect array)
|
|
/// and <see cref="PrepareRhiAlphaSections"/>'s "write every section once"
|
|
/// shape (locals here, not the persisted <c>_alpha*</c> fields: those belong
|
|
/// to <see cref="RetailAlphaQueue"/>, which can still be mid-flight in the
|
|
/// same frame, and overwriting them would corrupt that replay).</para>
|
|
///
|
|
/// <para>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 (<c>DrawBuildingDetailRangeRhi</c>'s second
|
|
/// pass through <c>RetailDetail</c>/<c>RetailDetailTransparent</c>) — a
|
|
/// detail-category command still forces a solo merge run (mirroring the
|
|
/// deferred-alpha detail break), but <see cref="SubmitOrderedStream"/> 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.</para>
|
|
/// </summary>
|
|
public sealed unsafe partial class WbDrawDispatcher
|
|
{
|
|
/// <summary>
|
|
/// One walk-order merge run: a maximal, in-order span of commands that
|
|
/// share a <see cref="WalkDrawStage"/>, a resolved pipeline, and a cull
|
|
/// mode, built by <see cref="BuildOrderedMergeRuns"/>.
|
|
/// </summary>
|
|
internal readonly record struct OrderedMergeRun(int FirstCommand, int CommandCount);
|
|
|
|
/// <summary>
|
|
/// The four pipeline buckets a translucency kind resolves to, independent
|
|
/// of any live <see cref="MeshPipelineSet"/> instance. <see cref="AlphaToCoverage"/>
|
|
/// selects between <c>Opaque</c> and <c>OpaqueAlphaToCoverage</c> 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 <see cref="IGpuPipeline"/> that resolves to. That is what lets
|
|
/// <see cref="BuildOrderedMergeRuns"/> stay pure CPU logic, testable
|
|
/// without a live GPU device — the same separation
|
|
/// <see cref="BuildIndirectArrays"/> already draws between layout and RHI
|
|
/// glue.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Builds the maximal in-order merge runs for <paramref name="stream"/>.
|
|
/// 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.
|
|
///
|
|
/// <para>A run extends from command <c>i</c> to <c>j</c> while every
|
|
/// command in <c>[i, j)</c> shares the same <see cref="WalkDrawStage"/>,
|
|
/// the same <see cref="PipelineBucket"/>, the same <see cref="CullMode"/>,
|
|
/// and none carries a nonzero <c>DetailCategory</c> — a detail-category
|
|
/// command always emits alone, mirroring
|
|
/// <c>DrawPreparedAlphaBatchRhi</c>'s <c>hasDetail</c> break. Never
|
|
/// reorders or drops anything: every command in <paramref name="stream"/>
|
|
/// belongs to exactly one returned run, in stream order.</para>
|
|
///
|
|
/// <para>Fails loud before building any run: <see cref="WalkDrawStage.PortalPunch"/>
|
|
/// 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.</para>
|
|
/// </summary>
|
|
internal static List<OrderedMergeRun> 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<OrderedMergeRun>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The campaign's "assert it" rule (plan §FW2: "a merge across a state or
|
|
/// stage boundary is forbidden by construction"). <see cref="BuildOrderedMergeRuns"/>
|
|
/// 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.
|
|
/// </summary>
|
|
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).");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Submits <paramref name="stream"/> 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
|
|
/// <see cref="DrawIndirectRangeRhi"/> call per maximal merge run from
|
|
/// <see cref="BuildOrderedMergeRuns"/>. N commands in yield indirect
|
|
/// commands <c>[0, N)</c> in stream order, each covered by exactly one
|
|
/// emitted run — nothing is reordered, sorted, or dropped.
|
|
///
|
|
/// <para><paramref name="frame"/> and <paramref name="encoder"/> are
|
|
/// caller-supplied rather than pulled from <c>_frames</c>/<c>_scope</c>
|
|
/// (contrast <see cref="SubmitRhi"/>'s <c>RequireRhiFrame</c>/
|
|
/// <c>scope.RequireEncoder()</c>): 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
|
|
/// <c>_scope.Sections</c> — those are canonical per-frame published
|
|
/// state, not something this submitter owns.</para>
|
|
/// </summary>
|
|
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<OrderedMergeRun> 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<BatchData>(frame, _batchData.AsSpan(0, count));
|
|
RhiSection clipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
|
|
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
|
int uploadCount = lightCount > 0 ? lightCount : 1;
|
|
RhiSection globalLights = WriteRingSection<float>(
|
|
frame,
|
|
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
|
|
RhiSection lightSets = WriteRingSection<int>(
|
|
frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
|
|
RhiSection indoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
|
|
RhiSection alpha = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
|
|
RhiSection selectionLighting = WriteRingSection<Vector2>(
|
|
frame, _selectionLightingData.AsSpan(0, count));
|
|
RhiSection detailCategory = WriteRingSection<uint>(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);
|
|
}
|
|
}
|
|
}
|