The dense-Arwic re-measure crashed with VK_ERROR_DEVICE_LOST: between ordered ranges the walk leaf draws (terrain, shells, sky, punch fans) and RetailAlphaQueue flushes rebind the SAME set-0 storage slots to their own sections, so the bind-once latch made the next range draw against foreign buffers - out-of-bounds instance reads and a GPU fault. Sections now re-bind on every DrawOrderedRange call, exactly like the proven DrawPreparedAlphaBatchRhi; the once-per-frame ring WRITES in PrepareOrderedStream (the actual measured cost) are unchanged. The bind-once referee test flips to assert per-range rebinds with unchanged draw coverage. Suites: full Release build 0 warnings; hermetic 6,758/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
590 lines
30 KiB
C#
590 lines
30 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 (<c>_ordered*</c> fields 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>Campaign FW stage FW3.4a (2026-08-30): the FW2 submitter was ONE
|
|
/// method, <c>SubmitOrderedStream</c>, 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. <see cref="PrepareOrderedStream"/> +
|
|
/// <see cref="DrawOrderedRange"/> replace it: the WHOLE frame's stream is
|
|
/// written and bound ONCE, and each walk segment becomes a cheap
|
|
/// <see cref="DrawOrderedRange"/> call over the ALREADY-uploaded payload —
|
|
/// the same split <see cref="PrepareRhiAlphaSections"/>/
|
|
/// <see cref="DrawPreparedAlphaBatchRhi"/> already prove for the alpha path.
|
|
/// <c>SubmitOrderedStream</c> itself is deleted; every FW2 caller/test now
|
|
/// calls the pair (prepare once, draw the whole stream as one range, or as
|
|
/// several — see <c>OrderPreservingSubmitterTests</c>).</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 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.</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><paramref name="forcedBreaksAscending"/> (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. <see cref="WalkFrameDriver"/>'s Replay phase draws the frame's ONE
|
|
/// prepared stream as several <see cref="DrawOrderedRange"/> 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 <see cref="WalkDrawStage"/>/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 <see cref="DrawOrderedRange"/>'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.</para>
|
|
///
|
|
/// <para>Fails loud before building any run: <see cref="WalkDrawStage.PortalPunch"/>
|
|
/// 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.</para>
|
|
/// </summary>
|
|
internal static List<OrderedMergeRun> BuildOrderedMergeRuns(
|
|
OrderedDrawStream stream, IReadOnlyList<int>? 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<int> breaks = forcedBreaksAscending ?? Array.Empty<int>();
|
|
int breakCursor = 0;
|
|
|
|
var runs = new List<OrderedMergeRun>();
|
|
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;
|
|
}
|
|
|
|
/// <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>
|
|
/// Campaign FW3.2b-2: the production frame/encoder pair for
|
|
/// <see cref="WalkFrameDriver"/>'s own <see cref="DrawOrderedRange"/>
|
|
/// calls (contrast this stage's diagnostic-target callers, which supply
|
|
/// their own frame/encoder). Reads the SAME world-pass scope
|
|
/// <see cref="SubmitRhi"/> already requires (<see cref="RequireRhiFrame"/> /
|
|
/// <c>_scope.RequireEncoder()</c>) — fails loud rather than handing the
|
|
/// driver a null pair when the world phase is not bracketing.
|
|
/// </summary>
|
|
internal (IGpuFrame Frame, IGpuPassEncoder Encoder) RequireWalkSubmission() =>
|
|
(RequireRhiFrame(), _scope!.RequireEncoder());
|
|
|
|
/// <summary>
|
|
/// Campaign FW3.2b-2: the live colour-attachment size, for
|
|
/// <see cref="WalkProductionFrameContext"/>'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.
|
|
/// </summary>
|
|
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<OrderedMergeRun> _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;
|
|
|
|
/// <summary>
|
|
/// Writes <paramref name="stream"/>'s ENTIRE walk-order payload into the
|
|
/// frame ring exactly ONCE — the per-instance-first emission
|
|
/// (<see cref="PrepareDeferredAlphaDraws"/>'s shape) followed by one
|
|
/// section write per per-instance array (<see cref="PrepareRhiAlphaSections"/>'s
|
|
/// shape, into the <c>_ordered*</c> fields above). No draw happens here;
|
|
/// <see cref="DrawOrderedRange"/> issues the actual
|
|
/// <see cref="DrawIndirectRangeRhi"/> calls against this prepared payload,
|
|
/// as many times as the caller needs (<see cref="WalkFrameDriver"/> calls
|
|
/// it once per walk segment, interleaved with the leaf GPU calls that
|
|
/// must run between segments).
|
|
///
|
|
/// <para><paramref name="forcedBreaksAscending"/> forwards to
|
|
/// <see cref="BuildOrderedMergeRuns"/> — see that parameter's own doc
|
|
/// comment. Pass the walk segment boundaries here so a later
|
|
/// <see cref="DrawOrderedRange"/> call's range always aligns with a merge
|
|
/// run by construction.</para>
|
|
///
|
|
/// <para>A no-op (leaves <see cref="_orderedPreparedCount"/> at 0) when
|
|
/// the stream is empty or the mesh source is not yet ready — mirrors
|
|
/// <see cref="PrepareDeferredAlphaDraws"/>'s own early-outs. Fails loud
|
|
/// BEFORE any GPU work if the stream carries an unsupported stage (see
|
|
/// <see cref="BuildOrderedMergeRuns"/>).</para>
|
|
/// </summary>
|
|
internal void PrepareOrderedStream(
|
|
IGpuFrame frame,
|
|
OrderedDrawStream stream,
|
|
in Matrix4x4 viewProjection,
|
|
IReadOnlyList<int>? 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<BatchData>(frame, _batchData.AsSpan(0, count));
|
|
_orderedClipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
|
|
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
|
int uploadCount = lightCount > 0 ? lightCount : 1;
|
|
_orderedGlobalLights = WriteRingSection<float>(
|
|
frame,
|
|
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
|
|
_orderedLightSets = WriteRingSection<int>(
|
|
frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
|
|
_orderedIndoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
|
|
_orderedAlpha = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
|
|
_orderedSelectionLighting = WriteRingSection<Vector2>(
|
|
frame, _selectionLightingData.AsSpan(0, count));
|
|
_orderedDetailCategory = WriteRingSection<uint>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws commands <c>[firstCommand, firstCommand + commandCount)</c> of
|
|
/// the payload the most recent <see cref="PrepareOrderedStream"/> 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 <see cref="PrepareOrderedStream"/>
|
|
/// (what used to be ~40 full <c>SubmitOrderedStream</c> uploads per
|
|
/// frame at a town becomes one bind plus ~40 cheap
|
|
/// <see cref="DrawIndirectRangeRhi"/> calls). Section binds survive
|
|
/// pipeline switches (every mesh pipeline shares one layout — the same
|
|
/// reasoning <c>SubmitRhi</c>/the old <c>SubmitOrderedStream</c> already
|
|
/// relied on), so binding once per frame rather than once per pipeline
|
|
/// switch is safe.
|
|
///
|
|
/// <para>Fail-loud range check mirrors <see cref="DrawPreparedAlphaBatchRhi"/>'s:
|
|
/// a range outside <c>[0, _orderedPreparedCount]</c> throws
|
|
/// <see cref="ArgumentOutOfRangeException"/> 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).</para>
|
|
///
|
|
/// <para>Walks the runs <see cref="PrepareOrderedStream"/> built that
|
|
/// intersect this range. By construction (the boundaries the caller fed
|
|
/// <see cref="PrepareOrderedStream"/> as <c>forcedBreaksAscending</c>) 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.</para>
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grows <see cref="_orderedDrawCullModes"/> to at least
|
|
/// <paramref name="count"/> — the same growth shape
|
|
/// <c>EnsureDeferredAlphaCapacity</c> uses for <see cref="_drawCullModes"/>,
|
|
/// 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).
|
|
/// </summary>
|
|
private void EnsureOrderedCullModeCapacity(int count)
|
|
{
|
|
if (_orderedDrawCullModes.Length < count)
|
|
_orderedDrawCullModes = new CullMode[count + 64];
|
|
}
|
|
}
|