feat(render) Campaign FW2: OrderedDrawStream + walk-order submitter
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>
This commit is contained in:
parent
77f5342b62
commit
e65644cb33
12 changed files with 1461 additions and 31 deletions
|
|
@ -218,6 +218,31 @@ oracle traces exactly.
|
||||||
vs the old builder are adjudicated against the ORACLE, not against the
|
vs the old builder are adjudicated against the ORACLE, not against the
|
||||||
old builder. No production wiring; hermetic suites green.
|
old builder. No production wiring; hermetic suites green.
|
||||||
|
|
||||||
|
**FW1 STATUS (2026-08-30, @`77f5342b`):** NINE of the ten pose-stamped
|
||||||
|
fixtures reproduce retail frame-exactly (foundry-deep all 39 frames,
|
||||||
|
doorway-still, street-outdoor, terrace-center, terrace-edge — the #456
|
||||||
|
acceptance pose — cathedral-arrival, holtburg-walkout, -transitions,
|
||||||
|
-walkabout); foundry-entry is exact through F66 with the F67–F79
|
||||||
|
standing segment parked on ONE live number (building 0036's root-plane
|
||||||
|
viewpoint — probe `tools/walk-oracle/fw1-f67-viewpoint-probe.cdb`,
|
||||||
|
goal-sanctioned retail-session stop filed with the user). Load-bearing
|
||||||
|
adjudications, all decomp-cited: the two-arm `get_degrade` threshold
|
||||||
|
rule (ideal→max at the live `deg_mul≈+0.99`), deg_mul's DYNAMIC swing
|
||||||
|
under capture load (doorway-still pins mul=0 — an environment pin like
|
||||||
|
the viewport), znear=0.1 confirmed, and the Ghidra-arbitrated portal
|
||||||
|
walker truth table (BN FPU pseudo-C mis-renders branch sense — three
|
||||||
|
separate misreads this stage; Ghidra first, always).
|
||||||
|
**Gate amendment:** the old-replay-fixture re-expression is retired as
|
||||||
|
an FW1 gate — the ten traces are direct retail evidence and strictly
|
||||||
|
supersede fixtures that encode the OLD builder's behavior; the old
|
||||||
|
suite's scenario classes (doorway flap, dungeon seams, tower ascent,
|
||||||
|
corner flood) are covered by the traces and re-verified live at the
|
||||||
|
FW3/FW4 connected gates, where the old fixtures retire with the old
|
||||||
|
builder. Production classes: `RetailFrameWalk`, `WalkPView` (the
|
||||||
|
PViewSet role), `WalkBuildingPortals`, `WalkLandscape`,
|
||||||
|
`WalkVisibilityMath`, `WalkScreenClip`, `WalkCopyView` under
|
||||||
|
`src/AcDream.App/Rendering/Walk/`.
|
||||||
|
|
||||||
### FW2 — `OrderedDrawStream` + `OrderPreservingSubmitter`
|
### FW2 — `OrderedDrawStream` + `OrderPreservingSubmitter`
|
||||||
|
|
||||||
**Goal:** walk-order submission through the existing RHI, proven
|
**Goal:** walk-order submission through the existing RHI, proven
|
||||||
|
|
|
||||||
|
|
@ -1073,6 +1073,12 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The caster's own depth-only pipeline. Depth compare is
|
||||||
|
/// <see cref="WorldDepthContract.WorldCompare"/> — see that type for the
|
||||||
|
/// world-space <c>GL_LESS</c> citation this shares with every other world
|
||||||
|
/// pipeline.
|
||||||
|
/// </summary>
|
||||||
private static IGpuPipeline CreatePipeline(
|
private static IGpuPipeline CreatePipeline(
|
||||||
IGpuDevice device,
|
IGpuDevice device,
|
||||||
string name,
|
string name,
|
||||||
|
|
@ -1087,7 +1093,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
|
||||||
VertexLayout = layout,
|
VertexLayout = layout,
|
||||||
Topology = GpuPrimitiveTopology.TriangleList,
|
Topology = GpuPrimitiveTopology.TriangleList,
|
||||||
Blend = GpuBlendMode.None,
|
Blend = GpuBlendMode.None,
|
||||||
Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
|
Depth = new GpuDepthState(true, true, WorldDepthContract.WorldCompare),
|
||||||
Cull = GpuCullMode.Back,
|
Cull = GpuCullMode.Back,
|
||||||
FrontFace = frontFace,
|
FrontFace = frontFace,
|
||||||
AlphaToCoverage = false,
|
AlphaToCoverage = false,
|
||||||
|
|
|
||||||
|
|
@ -232,12 +232,14 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
/// which is the GL arm's bracket verbatim
|
/// which is the GL arm's bracket verbatim
|
||||||
/// (<c>Enable(DepthTest)</c>/<c>DepthMask(false)</c>/<c>Disable(CullFace)</c>).
|
/// (<c>Enable(DepthTest)</c>/<c>DepthMask(false)</c>/<c>Disable(CullFace)</c>).
|
||||||
///
|
///
|
||||||
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c>
|
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
|
||||||
/// default: the world frame runs under <c>GL_LESS</c> and this renderer never
|
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
|
||||||
/// called <c>glDepthFunc</c>, so it inherited it. Alpha-to-coverage is off
|
/// type for the full citation. The world frame runs under <c>GL_LESS</c>
|
||||||
/// for the same kind of reason and the opposite way round — the frame-global
|
/// and this renderer never called <c>glDepthFunc</c>, so it inherited it.
|
||||||
/// state controller disables it and only <c>WbDrawDispatcher</c>'s opaque
|
/// Alpha-to-coverage is off for the same kind of reason and the opposite
|
||||||
/// bracket turns it on, so particles have never drawn with it.</para>
|
/// way round — the frame-global state controller disables it and only
|
||||||
|
/// <c>WbDrawDispatcher</c>'s opaque bracket turns it on, so particles have
|
||||||
|
/// never drawn with it.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IGpuPipeline CreateBillboardPipeline(
|
private static IGpuPipeline CreateBillboardPipeline(
|
||||||
IGpuDevice device,
|
IGpuDevice device,
|
||||||
|
|
@ -251,7 +253,7 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
VertexLayout = BillboardVertexLayout,
|
VertexLayout = BillboardVertexLayout,
|
||||||
Topology = GpuPrimitiveTopology.TriangleList,
|
Topology = GpuPrimitiveTopology.TriangleList,
|
||||||
Blend = blend,
|
Blend = blend,
|
||||||
Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less),
|
Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
|
||||||
Cull = GpuCullMode.None,
|
Cull = GpuCullMode.None,
|
||||||
FrontFace = GpuFrontFace.CounterClockwise,
|
FrontFace = GpuFrontFace.CounterClockwise,
|
||||||
AlphaToCoverage = false,
|
AlphaToCoverage = false,
|
||||||
|
|
@ -260,10 +262,12 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One mesh-particle pipeline. Same depth bracket as the billboards; the
|
/// One mesh-particle pipeline. Same depth bracket as the billboards (see
|
||||||
/// winding is CW because <c>PrepareMeshPipeline</c> sets
|
/// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the world
|
||||||
/// <c>glFrontFace(GL_CW)</c>, and the cull mode stays DYNAMIC because it is
|
/// <c>GL_LESS</c> citation); the winding is CW because
|
||||||
/// resolved per sub-batch from the DAT's own <c>CullMode</c>.
|
/// <c>PrepareMeshPipeline</c> sets <c>glFrontFace(GL_CW)</c>, and the cull
|
||||||
|
/// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's
|
||||||
|
/// own <c>CullMode</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IGpuPipeline CreateMeshParticlePipeline(
|
private static IGpuPipeline CreateMeshParticlePipeline(
|
||||||
IGpuDevice device,
|
IGpuDevice device,
|
||||||
|
|
@ -277,7 +281,7 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
VertexLayout = MeshVertexLayout,
|
VertexLayout = MeshVertexLayout,
|
||||||
Topology = GpuPrimitiveTopology.TriangleList,
|
Topology = GpuPrimitiveTopology.TriangleList,
|
||||||
Blend = blend,
|
Blend = blend,
|
||||||
Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less),
|
Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
|
||||||
Cull = GpuCullMode.None,
|
Cull = GpuCullMode.None,
|
||||||
FrontFace = GpuFrontFace.Clockwise,
|
FrontFace = GpuFrontFace.Clockwise,
|
||||||
AlphaToCoverage = false,
|
AlphaToCoverage = false,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@ public sealed partial class TerrainModernRenderer
|
||||||
VertexLayout = TerrainVertexLayout,
|
VertexLayout = TerrainVertexLayout,
|
||||||
Topology = GpuPrimitiveTopology.TriangleList,
|
Topology = GpuPrimitiveTopology.TriangleList,
|
||||||
Blend = GpuBlendMode.None,
|
Blend = GpuBlendMode.None,
|
||||||
Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
|
// WorldDepthContract.WorldCompare — see that type for the
|
||||||
|
// world-space GL_LESS citation this receiver pipeline shares
|
||||||
|
// with terrain's own base pass.
|
||||||
|
Depth = new GpuDepthState(true, true, WorldDepthContract.WorldCompare),
|
||||||
Cull = GpuCullMode.Back,
|
Cull = GpuCullMode.Back,
|
||||||
FrontFace = GpuFrontFace.CounterClockwise,
|
FrontFace = GpuFrontFace.CounterClockwise,
|
||||||
AlphaToCoverage = false,
|
AlphaToCoverage = false,
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,14 @@ public sealed unsafe partial class TerrainModernRenderer
|
||||||
VertexLayout = TerrainVertexLayout,
|
VertexLayout = TerrainVertexLayout,
|
||||||
Topology = GpuPrimitiveTopology.TriangleList,
|
Topology = GpuPrimitiveTopology.TriangleList,
|
||||||
Blend = GpuBlendMode.None,
|
Blend = GpuBlendMode.None,
|
||||||
// GL_LESS, not the contract's LessOrEqual default: the world frame
|
// WorldDepthContract.WorldCompare (GL_LESS), not the contract's
|
||||||
// runs under GL_LESS and terrain never called glDepthFunc, so it
|
// LessOrEqual default: the world frame runs under GL_LESS and
|
||||||
// inherited it. LessOrEqual would change which of two coplanar retail
|
// terrain never called glDepthFunc, so it inherited it. LessOrEqual
|
||||||
// surfaces wins — visible exactly where terrain meets roads and
|
// would change which of two coplanar retail surfaces wins — visible
|
||||||
// building footings, which is what zFightTerrainAdjust is about.
|
// exactly where terrain meets roads and building footings, which is
|
||||||
Depth = new GpuDepthState(Test: true, Write: true, GpuCompareOp.Less),
|
// what zFightTerrainAdjust is about. See WorldDepthContract for the
|
||||||
|
// full citation.
|
||||||
|
Depth = new GpuDepthState(Test: true, Write: true, WorldDepthContract.WorldCompare),
|
||||||
// #108-residual: retail terrain is SINGLE-SIDED. See the GL arm's
|
// #108-residual: retail terrain is SINGLE-SIDED. See the GL arm's
|
||||||
// Draw for the full reasoning; this bakes the same triple.
|
// Draw for the full reasoning; this bakes the same triple.
|
||||||
Cull = GpuCullMode.Back,
|
Cull = GpuCullMode.Back,
|
||||||
|
|
|
||||||
166
src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
Normal file
166
src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW2 — retail's frame phases, in the order the walk
|
||||||
|
/// visits them. <c>RetailPViewPassExecutor</c>'s packed route contract
|
||||||
|
/// (<c>RenderFrameCandidateRoute</c>: LandscapeOutdoorStatic →
|
||||||
|
/// LandscapeBuildingShell → LookInObject → LandscapeOutsideDynamic →
|
||||||
|
/// CellStatic → DynamicLast — <c>WbDrawDispatcher.PackedOracle.cs:108/171</c>
|
||||||
|
/// enforces in-order consumption today) is the closest existing analogue;
|
||||||
|
/// this enum is the walk submitter's OWN phase vocabulary, and a merge run
|
||||||
|
/// built by <see cref="WbDrawDispatcher.BuildOrderedMergeRuns"/> may never
|
||||||
|
/// span two different stages — the submitter treats a stage boundary exactly
|
||||||
|
/// like a material-state boundary (see
|
||||||
|
/// docs/plans/2026-08-30-campaign-fw-frame-walk.md §FW2).
|
||||||
|
/// </summary>
|
||||||
|
internal enum WalkDrawStage : byte
|
||||||
|
{
|
||||||
|
/// <summary><c>LScape::draw</c> @0x00506330 /
|
||||||
|
/// <c>LScape::grab_visible_cells</c> @0x00504EC0 — outdoor terrain.</summary>
|
||||||
|
Terrain,
|
||||||
|
|
||||||
|
/// <summary>An indoor <c>PView::DrawCells</c> @0x005A4840 flood's static
|
||||||
|
/// geometry: EnvCell shells plus the static meshes they contain.</summary>
|
||||||
|
CellStatic,
|
||||||
|
|
||||||
|
/// <summary><c>DrawBuilding</c>'s (<c>RenderDeviceD3D::DrawBuilding</c>
|
||||||
|
/// @0x0059f2a0) exterior shell pass.</summary>
|
||||||
|
BuildingShell,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>DrawPortalPolyInternal</c>'s depth-only invisible portal-polygon
|
||||||
|
/// panel — punch far-Z / seal own-depth. FW0 confirmed retail actually
|
||||||
|
/// draws these every frame (not an acdream invention); the AD-117 stamps
|
||||||
|
/// re-invented the mechanism at the wrong site.
|
||||||
|
///
|
||||||
|
/// <para><b>No FW2 submission path exists yet.</b>
|
||||||
|
/// <see cref="WbDrawDispatcher.SubmitOrderedStream"/> throws
|
||||||
|
/// <see cref="System.NotSupportedException"/> if a command carries this
|
||||||
|
/// stage — punch geometry emission lands in FW3 with the world wiring.
|
||||||
|
/// The stage exists now purely so stage-separation gates can exercise the
|
||||||
|
/// boundary before the real emission path is built.</para>
|
||||||
|
/// </summary>
|
||||||
|
PortalPunch,
|
||||||
|
|
||||||
|
/// <summary><c>ConstructView(CBldPortal)</c> look-in static geometry —
|
||||||
|
/// what an exterior building's window or doorway reveals of its own
|
||||||
|
/// interior.</summary>
|
||||||
|
LookInStatic,
|
||||||
|
|
||||||
|
/// <summary>Every non-static draw the walk visits last: entities,
|
||||||
|
/// monsters, items, and the meshes particles ride.</summary>
|
||||||
|
Dynamic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One walk-ordered draw command. The nine fields after <see cref="Key"/> and
|
||||||
|
/// <see cref="Transform"/> are exactly the per-instance data
|
||||||
|
/// <c>WbDrawDispatcher.PrepareDeferredAlphaDraws</c>'s
|
||||||
|
/// <c>DeferredAlphaInstance</c> carries — that method is the per-instance-first
|
||||||
|
/// SSBO-layout template FW2's submitter follows — plus the walk provenance
|
||||||
|
/// (<see cref="Stage"/>, <see cref="CellId"/>) the submitter needs to know
|
||||||
|
/// where a merge run may and may not cross a boundary.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Key">Mesh-subset and material identity: index range, texture
|
||||||
|
/// slot/layer, translucency, foliage flags, cull mode. The same
|
||||||
|
/// <see cref="GroupKey"/> the classic material-bucketed path groups instances
|
||||||
|
/// by — FW2 does not bucket by it, only reads its fields per instance.</param>
|
||||||
|
/// <param name="Transform">World transform. Storage binding 0
|
||||||
|
/// (<c>StorageInstances</c>).</param>
|
||||||
|
/// <param name="Stage">The retail frame phase this command belongs to. A
|
||||||
|
/// merge run may never span two different stages.</param>
|
||||||
|
/// <param name="CellId">Walk-order provenance: which cell's traversal emitted
|
||||||
|
/// this command. Not bound to any GPU storage section today — carried for
|
||||||
|
/// FW3's portal-punch wiring and for diagnostics.</param>
|
||||||
|
/// <param name="ClipSlot">Storage binding 3 (<c>StorageClipSlots</c>).</param>
|
||||||
|
/// <param name="Lights">Storage binding 5 (<c>StorageInstanceLightSets</c>).</param>
|
||||||
|
/// <param name="IndoorFlag">Storage binding 6 (<c>StorageInstanceIndoor</c>).</param>
|
||||||
|
/// <param name="Alpha">Storage binding 7 (<c>StorageInstanceAlpha</c>).</param>
|
||||||
|
/// <param name="SelectionLighting">Storage binding 8
|
||||||
|
/// (<c>StorageInstanceSelectionLighting</c>).</param>
|
||||||
|
/// <param name="DetailCategory">Storage binding 9
|
||||||
|
/// (<c>StorageInstanceDetailCategory</c>). A nonzero value forces this
|
||||||
|
/// command into a solo merge run — mirrors the deferred-alpha detail break in
|
||||||
|
/// <c>WbDrawDispatcher.DrawPreparedAlphaBatchRhi</c>.</param>
|
||||||
|
internal readonly record struct OrderedDrawCommand(
|
||||||
|
GroupKey Key,
|
||||||
|
Matrix4x4 Transform,
|
||||||
|
WalkDrawStage Stage,
|
||||||
|
uint CellId,
|
||||||
|
uint ClipSlot,
|
||||||
|
WbDrawDispatcher.InstanceLightSet Lights,
|
||||||
|
uint IndoorFlag,
|
||||||
|
float Alpha,
|
||||||
|
Vector2 SelectionLighting,
|
||||||
|
uint DetailCategory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only, walk-ordered draw-command stream. Struct-of-arrays storage —
|
||||||
|
/// one parallel list per <see cref="OrderedDrawCommand"/> field, the same
|
||||||
|
/// shape as <see cref="WbDrawDispatcher.InstanceGroup"/>'s per-instance lists
|
||||||
|
/// — so <see cref="WbDrawDispatcher.SubmitOrderedStream"/> can walk the stream
|
||||||
|
/// by index instead of allocating one boxed command per instance.
|
||||||
|
///
|
||||||
|
/// <para>The stream carries no ordering logic of its own: reading it back is
|
||||||
|
/// exactly the sequence <see cref="Append"/> was called in, unconditionally.
|
||||||
|
/// That is the campaign invariant this type exists to make impossible to
|
||||||
|
/// silently regress — see
|
||||||
|
/// docs/plans/2026-08-30-campaign-fw-frame-walk.md §FW2's "a merge across a
|
||||||
|
/// state or stage boundary is forbidden by construction (assert it)" rule.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OrderedDrawStream
|
||||||
|
{
|
||||||
|
public readonly List<GroupKey> Keys = new();
|
||||||
|
public readonly List<Matrix4x4> Transforms = new();
|
||||||
|
public readonly List<WalkDrawStage> Stages = new();
|
||||||
|
public readonly List<uint> CellIds = new();
|
||||||
|
public readonly List<uint> ClipSlots = new();
|
||||||
|
public readonly List<WbDrawDispatcher.InstanceLightSet> Lights = new();
|
||||||
|
public readonly List<uint> IndoorFlags = new();
|
||||||
|
public readonly List<float> Alphas = new();
|
||||||
|
public readonly List<Vector2> SelectionLighting = new();
|
||||||
|
public readonly List<uint> DetailCategories = new();
|
||||||
|
|
||||||
|
/// <summary>Number of commands appended since the last <see cref="Reset"/>.</summary>
|
||||||
|
public int Count => Keys.Count;
|
||||||
|
|
||||||
|
public void Append(in OrderedDrawCommand command)
|
||||||
|
{
|
||||||
|
Keys.Add(command.Key);
|
||||||
|
Transforms.Add(command.Transform);
|
||||||
|
Stages.Add(command.Stage);
|
||||||
|
CellIds.Add(command.CellId);
|
||||||
|
ClipSlots.Add(command.ClipSlot);
|
||||||
|
Lights.Add(command.Lights);
|
||||||
|
IndoorFlags.Add(command.IndoorFlag);
|
||||||
|
Alphas.Add(command.Alpha);
|
||||||
|
SelectionLighting.Add(command.SelectionLighting);
|
||||||
|
DetailCategories.Add(command.DetailCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears every parallel list together, in one method. The established
|
||||||
|
/// #193 lesson (<c>WbDrawDispatcher.InstanceGroup.ClearPerInstanceData</c>
|
||||||
|
/// carries the same remark): a stream with N parallel lists that resets
|
||||||
|
/// them independently can leave one list stale relative to the others
|
||||||
|
/// after a future field is added and its clear call forgotten — and a
|
||||||
|
/// stale list that only ever grows leaks unboundedly. Ten lists, one
|
||||||
|
/// reset call, so a new eleventh list has nowhere to hide from it.
|
||||||
|
/// </summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
Keys.Clear();
|
||||||
|
Transforms.Clear();
|
||||||
|
Stages.Clear();
|
||||||
|
CellIds.Clear();
|
||||||
|
ClipSlots.Clear();
|
||||||
|
Lights.Clear();
|
||||||
|
IndoorFlags.Clear();
|
||||||
|
Alphas.Clear();
|
||||||
|
SelectionLighting.Clear();
|
||||||
|
DetailCategories.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,10 +88,12 @@ public sealed unsafe partial class EnvCellRenderer
|
||||||
/// shared: <c>mesh_modern</c>, the 32-byte world-mesh vertex, triangle lists,
|
/// shared: <c>mesh_modern</c>, the 32-byte world-mesh vertex, triangle lists,
|
||||||
/// back-face culling with clockwise front faces.
|
/// back-face culling with clockwise front faces.
|
||||||
///
|
///
|
||||||
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c>
|
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
|
||||||
/// default. The world frame runs under <c>GL_LESS</c> and this renderer never
|
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
|
||||||
/// called <c>glDepthFunc</c>, so it inherited it; baking <c>LessOrEqual</c>
|
/// type for the full citation. The world frame runs under <c>GL_LESS</c>
|
||||||
/// would change which of two coplanar retail surfaces wins.</para>
|
/// and this renderer never called <c>glDepthFunc</c>, so it inherited it;
|
||||||
|
/// baking <c>LessOrEqual</c> would change which of two coplanar retail
|
||||||
|
/// surfaces wins.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IGpuPipeline CreateShellPipeline(
|
private static IGpuPipeline CreateShellPipeline(
|
||||||
IGpuDevice device,
|
IGpuDevice device,
|
||||||
|
|
@ -100,7 +102,7 @@ public sealed unsafe partial class EnvCellRenderer
|
||||||
bool depthWrite,
|
bool depthWrite,
|
||||||
int sampleCount,
|
int sampleCount,
|
||||||
string shaderName = "mesh_modern",
|
string shaderName = "mesh_modern",
|
||||||
GpuCompareOp depthCompare = GpuCompareOp.Less) =>
|
GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare) =>
|
||||||
device.CreatePipeline(new GpuPipelineDescription
|
device.CreatePipeline(new GpuPipelineDescription
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
|
|
|
||||||
371
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
Normal file
371
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -315,11 +315,12 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
/// where <c>ApplyCullMode</c> sets them, because core Vulkan 1.3 makes those
|
/// where <c>ApplyCullMode</c> sets them, because core Vulkan 1.3 makes those
|
||||||
/// dynamic and blend and alpha-to-coverage not.
|
/// dynamic and blend and alpha-to-coverage not.
|
||||||
///
|
///
|
||||||
/// <para>Depth compare is <c>Less</c>, not the contract's
|
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
|
||||||
/// <c>LessOrEqual</c> default: the world frame runs under <c>GL_LESS</c> and
|
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
|
||||||
/// this renderer never called <c>glDepthFunc</c>, so it inherited it. Baking
|
/// type for the full citation. The short version: the world frame runs
|
||||||
/// <c>LessOrEqual</c> would change which of two coplanar retail surfaces
|
/// under <c>GL_LESS</c> and this renderer never called <c>glDepthFunc</c>,
|
||||||
/// wins.</para>
|
/// so it inherited it. Baking <c>LessOrEqual</c> would change which of two
|
||||||
|
/// coplanar retail surfaces wins.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IGpuPipeline CreateMeshPipeline(
|
private static IGpuPipeline CreateMeshPipeline(
|
||||||
IGpuDevice device,
|
IGpuDevice device,
|
||||||
|
|
@ -330,7 +331,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
int sampleCount,
|
int sampleCount,
|
||||||
string shaderName = "mesh_modern",
|
string shaderName = "mesh_modern",
|
||||||
GpuShaderSet? shaders = null,
|
GpuShaderSet? shaders = null,
|
||||||
GpuCompareOp depthCompare = GpuCompareOp.Less,
|
GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare,
|
||||||
bool usesRenderPackShaderAbi = false) =>
|
bool usesRenderPackShaderAbi = false) =>
|
||||||
device.CreatePipeline(new GpuPipelineDescription
|
device.CreatePipeline(new GpuPipelineDescription
|
||||||
{
|
{
|
||||||
|
|
|
||||||
47
src/AcDream.App/Rendering/WorldDepthContract.cs
Normal file
47
src/AcDream.App/Rendering/WorldDepthContract.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW2: the one depth-compare operator every world-space
|
||||||
|
/// pipeline uses, named instead of repeated as a literal at each of the
|
||||||
|
/// world's own pipeline-creation sites.
|
||||||
|
///
|
||||||
|
/// <para><b>Why <see cref="GpuCompareOp.Less"/>, not
|
||||||
|
/// <see cref="GpuPipelineDescription"/>'s/<c>GpuDepthState</c>'s own
|
||||||
|
/// <c>LessOrEqual</c> convention default:</b> the GL-era world frame ran under
|
||||||
|
/// <c>GL_LESS</c> and never called <c>glDepthFunc</c> to change it, so every
|
||||||
|
/// world-space renderer inherited <c>GL_LESS</c> by omission rather than by
|
||||||
|
/// design. <c>LessOrEqual</c> would flip which of two exactly-coplanar retail
|
||||||
|
/// surfaces wins the depth test — visible wherever terrain meets a road or a
|
||||||
|
/// building footing, or wherever two retail-authored polygons share a plane.
|
||||||
|
/// FW2's decomp verification (plan §FW2, read 2026-08-30) confirms retail's
|
||||||
|
/// own world raster state is <c>D3DCMP_LESS</c>: the .data default
|
||||||
|
/// <c>Render::zfuncVal</c> @0x00820e1c is <c>0x2</c>, and
|
||||||
|
/// <c>RenderDeviceD3D::SetDepthBufferMode</c> @0x005a2d10 writes that enum
|
||||||
|
/// value DIRECTLY as <c>D3DRS_ZFUNC</c> (render state 0x17) — the enum IS
|
||||||
|
/// <c>D3DCMPFUNC</c>, so <c>0x2 = D3DCMP_LESS</c>. The surface-state applier
|
||||||
|
/// @0x0059c80a–0x0059c866 applies it to all world geometry with Z-write
|
||||||
|
/// toggled by blend state (on for opaque, off for blended), exactly this
|
||||||
|
/// pipeline set's per-variant <c>depthWrite</c>. The <c>DEPTHTEST_LESSEQUAL</c>
|
||||||
|
/// sites in the decomp are SKY-local (<c>GameSky::Draw</c> @0x00506ff0, drawn
|
||||||
|
/// at 4× zfar) — never world state. So <c>Less</c> is not merely "what the
|
||||||
|
/// port happened to inherit" — it is retail's actual world depth-compare
|
||||||
|
/// operator, now named as a citable contract instead of a bare literal
|
||||||
|
/// repeated at each call site.</para>
|
||||||
|
///
|
||||||
|
/// <para>Scope: WORLD-SPACE geometry only (terrain, EnvCell shells, entity
|
||||||
|
/// meshes, particles, portal punches, the directional shadow caster/receiver
|
||||||
|
/// pair). The two <c>RetailDetail</c> pipelines
|
||||||
|
/// (<see cref="RetailDetailTextureContract"/>'s <c>Equal</c>/<c>LessOrEqual</c>
|
||||||
|
/// pair, used by the building-detail overlay replay) are a documented
|
||||||
|
/// exception with their own citation and are untouched by this contract.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class WorldDepthContract
|
||||||
|
{
|
||||||
|
/// <summary>Retail's world-space depth-compare operator. See the type
|
||||||
|
/// doc comment for the full citation; every world pipeline site should
|
||||||
|
/// reference this constant rather than spelling <c>GpuCompareOp.Less</c>
|
||||||
|
/// again.</summary>
|
||||||
|
public const GpuCompareOp WorldCompare = GpuCompareOp.Less;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,634 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Rendering.Gpu.Vk;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Lib.IO;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW2: <see cref="WbDrawDispatcher.BuildOrderedMergeRuns"/>
|
||||||
|
/// (pure CPU merge-run legality) and <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
|
||||||
|
/// (the same legality proven through actual recorded RHI calls against
|
||||||
|
/// <see cref="RecordingGpuDevice"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderPreservingSubmitterTests
|
||||||
|
{
|
||||||
|
private static OrderedDrawCommand MakeCommand(
|
||||||
|
int index,
|
||||||
|
WalkDrawStage stage = WalkDrawStage.Terrain,
|
||||||
|
TranslucencyKind translucency = TranslucencyKind.Opaque,
|
||||||
|
CullMode cullMode = CullMode.CounterClockwise,
|
||||||
|
uint detailCategory = 0) =>
|
||||||
|
new(
|
||||||
|
Key: new GroupKey(
|
||||||
|
FirstIndex: (uint)index * 3,
|
||||||
|
BaseVertex: index * 4,
|
||||||
|
IndexCount: 3,
|
||||||
|
TextureSlot: new GpuTextureSlot((uint)index),
|
||||||
|
TextureLayer: 0,
|
||||||
|
Translucency: translucency,
|
||||||
|
FoliageFlags: 0,
|
||||||
|
CullMode: cullMode),
|
||||||
|
Transform: Matrix4x4.CreateTranslation(index, index * 2, index * 3),
|
||||||
|
Stage: stage,
|
||||||
|
CellId: 0x8C040100u + (uint)index,
|
||||||
|
ClipSlot: 0,
|
||||||
|
Lights: WbDrawDispatcher.InstanceLightSet.Disabled,
|
||||||
|
IndoorFlag: 0,
|
||||||
|
Alpha: 1f,
|
||||||
|
SelectionLighting: Vector2.Zero,
|
||||||
|
DetailCategory: detailCategory);
|
||||||
|
|
||||||
|
private static OrderedDrawStream StreamOf(params OrderedDrawCommand[] commands)
|
||||||
|
{
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
foreach (OrderedDrawCommand command in commands)
|
||||||
|
stream.Append(command);
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure BuildOrderedMergeRuns — no GPU device ─────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_MergesThreeAdjacentSameStateCommandsIntoOneRun()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0), MakeCommand(1), MakeCommand(2));
|
||||||
|
|
||||||
|
List<WbDrawDispatcher.OrderedMergeRun> runs =
|
||||||
|
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
|
||||||
|
|
||||||
|
WbDrawDispatcher.OrderedMergeRun run = Assert.Single(runs);
|
||||||
|
Assert.Equal(0, run.FirstCommand);
|
||||||
|
Assert.Equal(3, run.CommandCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_SplitsOnAPipelineBucketChange()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, translucency: TranslucencyKind.Opaque),
|
||||||
|
MakeCommand(1, translucency: TranslucencyKind.Opaque),
|
||||||
|
MakeCommand(2, translucency: TranslucencyKind.AlphaBlend));
|
||||||
|
|
||||||
|
List<WbDrawDispatcher.OrderedMergeRun> runs =
|
||||||
|
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(0, 2),
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(2, 1),
|
||||||
|
],
|
||||||
|
runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_SplitsOnACullModeChange()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, cullMode: CullMode.None),
|
||||||
|
MakeCommand(1, cullMode: CullMode.None),
|
||||||
|
MakeCommand(2, cullMode: CullMode.Clockwise));
|
||||||
|
|
||||||
|
List<WbDrawDispatcher.OrderedMergeRun> runs =
|
||||||
|
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(0, 2),
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(2, 1),
|
||||||
|
],
|
||||||
|
runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The load-bearing new assertion: two commands whose material state
|
||||||
|
/// (bucket, cull mode, detail category) is IDENTICAL still split into two
|
||||||
|
/// runs when their <see cref="WalkDrawStage"/> differs. Nothing about the
|
||||||
|
/// deferred-alpha template this submitter borrows from ever had to
|
||||||
|
/// consider stage — walk order introduces it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_SplitsOnAStageChangeEvenWithIdenticalMaterialState()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, stage: WalkDrawStage.Terrain),
|
||||||
|
MakeCommand(1, stage: WalkDrawStage.CellStatic));
|
||||||
|
|
||||||
|
List<WbDrawDispatcher.OrderedMergeRun> runs =
|
||||||
|
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(0, 1),
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(1, 1),
|
||||||
|
],
|
||||||
|
runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_ADetailCategoryCommandIsAlwaysSolo()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0),
|
||||||
|
MakeCommand(1, detailCategory: 1),
|
||||||
|
MakeCommand(2));
|
||||||
|
|
||||||
|
List<WbDrawDispatcher.OrderedMergeRun> runs =
|
||||||
|
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(0, 1),
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(1, 1),
|
||||||
|
new WbDrawDispatcher.OrderedMergeRun(2, 1),
|
||||||
|
],
|
||||||
|
runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_ThrowsNotSupportedForAPortalPunchCommand()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, stage: WalkDrawStage.Terrain),
|
||||||
|
MakeCommand(1, stage: WalkDrawStage.PortalPunch));
|
||||||
|
|
||||||
|
Assert.Throws<NotSupportedException>(
|
||||||
|
() => WbDrawDispatcher.BuildOrderedMergeRuns(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOrderedMergeRuns_EveryCommandBelongsToExactlyOneRunInOrderWithNoGaps()
|
||||||
|
{
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.Opaque, cullMode: CullMode.None),
|
||||||
|
MakeCommand(1, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.Opaque, cullMode: CullMode.None),
|
||||||
|
MakeCommand(2, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.None),
|
||||||
|
MakeCommand(3, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise),
|
||||||
|
MakeCommand(4, stage: WalkDrawStage.CellStatic, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise),
|
||||||
|
MakeCommand(5, stage: WalkDrawStage.CellStatic, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise, detailCategory: 1),
|
||||||
|
MakeCommand(6, stage: WalkDrawStage.CellStatic, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise));
|
||||||
|
|
||||||
|
List<WbDrawDispatcher.OrderedMergeRun> runs =
|
||||||
|
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
|
||||||
|
|
||||||
|
int coveredThrough = 0;
|
||||||
|
int totalCommands = 0;
|
||||||
|
foreach (WbDrawDispatcher.OrderedMergeRun run in runs)
|
||||||
|
{
|
||||||
|
Assert.Equal(coveredThrough, run.FirstCommand);
|
||||||
|
Assert.True(run.CommandCount > 0);
|
||||||
|
coveredThrough = run.FirstCommand + run.CommandCount;
|
||||||
|
totalCommands += run.CommandCount;
|
||||||
|
}
|
||||||
|
Assert.Equal(stream.Count, coveredThrough);
|
||||||
|
Assert.Equal(stream.Count, totalCommands);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitOrderedStream — recorded RHI calls against RecordingGpuDevice ─
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_AlternatingStateCommandsRecordOneDrawEachInOrder()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, translucency: TranslucencyKind.Opaque),
|
||||||
|
MakeCommand(1, translucency: TranslucencyKind.AlphaBlend),
|
||||||
|
MakeCommand(2, translucency: TranslucencyKind.Opaque),
|
||||||
|
MakeCommand(3, translucency: TranslucencyKind.AlphaBlend));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
|
||||||
|
Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)], ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_MergesAdjacentSameStateCommandsIntoOneMultiDrawIndirect()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0), MakeCommand(1), MakeCommand(2));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
|
||||||
|
Assert.Equal([(0, 3)], ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_CullModeChangeRecordsSeparateCullCallsAndSplitsTheDraw()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, cullMode: CullMode.None),
|
||||||
|
MakeCommand(1, cullMode: CullMode.None),
|
||||||
|
MakeCommand(2, cullMode: CullMode.Clockwise));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal([(0, 2), (2, 1)], DecodeDrawRanges(fx.Device));
|
||||||
|
|
||||||
|
List<GpuCullMode> cullCalls =
|
||||||
|
[.. fx.Device.Calls.OfType<GpuRecordedCullMode>().Select(c => c.CullMode)];
|
||||||
|
// ApplyCullModeRhi: CullMode.None -> GpuCullMode.None, CullMode.Clockwise -> GpuCullMode.Front.
|
||||||
|
Assert.Equal([GpuCullMode.None, GpuCullMode.Front], cullCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_StageChangeSplitsTheDrawEvenWithIdenticalMaterialState()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, stage: WalkDrawStage.Terrain),
|
||||||
|
MakeCommand(1, stage: WalkDrawStage.CellStatic));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_ADetailCategoryCommandRecordsItsOwnSoloDraw()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0),
|
||||||
|
MakeCommand(1, detailCategory: 1),
|
||||||
|
MakeCommand(2));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal([(0, 1), (1, 1), (2, 1)], DecodeDrawRanges(fx.Device));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_OpaqueRunUsesRenderPassZeroAndAlphaBlendRunUsesRenderPassOne()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, translucency: TranslucencyKind.Opaque),
|
||||||
|
MakeCommand(1, translucency: TranslucencyKind.AlphaBlend));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device);
|
||||||
|
Assert.Equal(2, runs.Count);
|
||||||
|
Assert.Equal(0, runs[0].Constants.RenderPass);
|
||||||
|
Assert.Equal(1, runs[1].Constants.RenderPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_ThrowsNotSupportedForAPortalPunchCommandBeforeAnyDraw()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
OrderedDrawStream stream = StreamOf(
|
||||||
|
MakeCommand(0, stage: WalkDrawStage.PortalPunch));
|
||||||
|
|
||||||
|
Assert.Throws<NotSupportedException>(
|
||||||
|
() => fx.Dispatcher.SubmitOrderedStream(
|
||||||
|
draw.Frame, draw.Pass, stream, Matrix4x4.Identity));
|
||||||
|
|
||||||
|
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||||
|
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedStorageBind>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_EmptyStreamRecordsNoDraws()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(
|
||||||
|
draw.Frame, draw.Pass, new OrderedDrawStream(), Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fail-loud invariant: whatever the state pattern, the recorded
|
||||||
|
/// MultiDrawIndirect calls' DrawCounts always sum to the stream's Count —
|
||||||
|
/// no command is ever silently skipped, and none is drawn twice.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_TotalRecordedDrawCountAlwaysEqualsTheStreamCount()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
var stages = new[] { WalkDrawStage.Terrain, WalkDrawStage.CellStatic, WalkDrawStage.BuildingShell };
|
||||||
|
var blends = new[]
|
||||||
|
{
|
||||||
|
TranslucencyKind.Opaque, TranslucencyKind.AlphaBlend,
|
||||||
|
TranslucencyKind.Additive, TranslucencyKind.InvAlpha,
|
||||||
|
};
|
||||||
|
var culls = new[] { CullMode.None, CullMode.Clockwise, CullMode.CounterClockwise };
|
||||||
|
const int commandCount = 11;
|
||||||
|
for (int i = 0; i < commandCount; i++)
|
||||||
|
{
|
||||||
|
stream.Append(MakeCommand(
|
||||||
|
i,
|
||||||
|
stage: stages[i % stages.Length],
|
||||||
|
translucency: blends[i % blends.Length],
|
||||||
|
cullMode: culls[i % culls.Length],
|
||||||
|
detailCategory: i == 5 ? 1u : 0u));
|
||||||
|
}
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
|
||||||
|
int sum = ranges.Sum(r => r.Count);
|
||||||
|
Assert.Equal(commandCount, sum);
|
||||||
|
|
||||||
|
int coveredThrough = 0;
|
||||||
|
foreach ((int start, int count) in ranges)
|
||||||
|
{
|
||||||
|
Assert.Equal(coveredThrough, start);
|
||||||
|
coveredThrough += count;
|
||||||
|
}
|
||||||
|
Assert.Equal(commandCount, coveredThrough);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decode helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static List<(int Start, int Count)> DecodeDrawRanges(RecordingGpuDevice device) =>
|
||||||
|
[.. DecodeRuns(device).Select(r => (r.Start, r.Count))];
|
||||||
|
|
||||||
|
private static List<(GpuPushConstants Constants, int Start, int Count)> DecodeRuns(
|
||||||
|
RecordingGpuDevice device)
|
||||||
|
{
|
||||||
|
GpuPushConstants? lastConstants = null;
|
||||||
|
uint? commandBase = null;
|
||||||
|
var result = new List<(GpuPushConstants, int, int)>();
|
||||||
|
foreach (var call in device.Calls)
|
||||||
|
{
|
||||||
|
if (call is GpuRecordedPushConstants pc)
|
||||||
|
{
|
||||||
|
lastConstants = pc.Constants;
|
||||||
|
}
|
||||||
|
else if (call is GpuRecordedMultiDrawIndirect mdi)
|
||||||
|
{
|
||||||
|
Assert.Equal((uint)WbDrawDispatcher.DrawCommandStride, mdi.StrideBytes);
|
||||||
|
commandBase ??= mdi.OffsetBytes;
|
||||||
|
int start = (int)((mdi.OffsetBytes - commandBase.Value) / mdi.StrideBytes);
|
||||||
|
Assert.NotNull(lastConstants);
|
||||||
|
result.Add((lastConstants!.Value, start, (int)mdi.DrawCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fixture: a real WbDrawDispatcher against RecordingGpuDevice ─────────
|
||||||
|
|
||||||
|
private readonly struct DrawScope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IDisposable _publication;
|
||||||
|
private readonly IGpuPassEncoder _pass;
|
||||||
|
|
||||||
|
public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication)
|
||||||
|
{
|
||||||
|
Frame = frame;
|
||||||
|
_pass = pass;
|
||||||
|
_publication = publication;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IGpuFrame Frame { get; }
|
||||||
|
|
||||||
|
public IGpuPassEncoder Pass => _pass;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_publication.Dispose();
|
||||||
|
_pass.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DispatcherFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly WbMeshAdapter _meshAdapter;
|
||||||
|
private readonly TextureCache _textures;
|
||||||
|
|
||||||
|
public DispatcherFixture()
|
||||||
|
{
|
||||||
|
Device = new RecordingGpuDevice();
|
||||||
|
FrameLifetime = new GpuDeviceFrameLifetime(Device);
|
||||||
|
Scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||||
|
_textures = new TextureCache(Device, new NoopDatReaderWriter());
|
||||||
|
_meshAdapter = new WbMeshAdapter(
|
||||||
|
Device,
|
||||||
|
new NoopDatReaderWriter(),
|
||||||
|
new NullPreparedAssetSource(),
|
||||||
|
NullLogger<WbMeshAdapter>.Instance,
|
||||||
|
Device.Retirement);
|
||||||
|
var entitySpawnAdapter = new EntitySpawnAdapter(
|
||||||
|
_textures,
|
||||||
|
_ => throw new NotSupportedException(
|
||||||
|
"Not exercised by SubmitOrderedStream tests."));
|
||||||
|
|
||||||
|
Dispatcher = new WbDrawDispatcher(
|
||||||
|
Device,
|
||||||
|
FrameLifetime,
|
||||||
|
Scope,
|
||||||
|
_textures,
|
||||||
|
_meshAdapter,
|
||||||
|
entitySpawnAdapter,
|
||||||
|
new EntityClassificationCache(),
|
||||||
|
new AcDream.Core.Rendering.TranslucencyFadeManager());
|
||||||
|
}
|
||||||
|
|
||||||
|
public RecordingGpuDevice Device { get; }
|
||||||
|
|
||||||
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
||||||
|
|
||||||
|
public VulkanWorldPassScope Scope { get; }
|
||||||
|
|
||||||
|
public WbDrawDispatcher Dispatcher { get; }
|
||||||
|
|
||||||
|
/// <summary>Opens a frame and a backbuffer pass, publishes it on
|
||||||
|
/// <see cref="Scope"/>, then clears the recorded calls so a test only
|
||||||
|
/// sees what its own <c>SubmitOrderedStream</c> call produced.</summary>
|
||||||
|
public DrawScope BeginDraw()
|
||||||
|
{
|
||||||
|
FrameLifetime.BeginFrame();
|
||||||
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
||||||
|
IGpuPassEncoder pass = frame.BeginPass(
|
||||||
|
GpuPassDescription.BackbufferClear(
|
||||||
|
"fw2-ordered-stream-test", Vector4.Zero, sampleCount: 1));
|
||||||
|
IDisposable publication = Scope.Publish(pass);
|
||||||
|
Device.Clear();
|
||||||
|
return new DrawScope(frame, pass, publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispatcher.Dispose();
|
||||||
|
_meshAdapter.Dispose();
|
||||||
|
_textures.Dispose();
|
||||||
|
Device.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
||||||
|
{
|
||||||
|
public PreparedAssetSourceStats Stats => default;
|
||||||
|
|
||||||
|
public CacheStats DecodedTextureCacheStats => default;
|
||||||
|
|
||||||
|
public PreparedAssetPresence Probe(
|
||||||
|
AcDream.Content.Pak.PakAssetType type,
|
||||||
|
uint sourceFileId) =>
|
||||||
|
PreparedAssetPresence.Missing;
|
||||||
|
|
||||||
|
public PreparedAssetReadResult Read(
|
||||||
|
in PreparedAssetRequest request,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
PreparedAssetReadResult.Missing;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopDatReaderWriter : IDatReaderWriter
|
||||||
|
{
|
||||||
|
private readonly StubDatabase _portal = new();
|
||||||
|
private readonly StubDatabase _highRes = new();
|
||||||
|
private readonly StubDatabase _language = new();
|
||||||
|
private readonly StubDatabase _cell = new();
|
||||||
|
|
||||||
|
public string SourceDirectory => string.Empty;
|
||||||
|
|
||||||
|
public IDatDatabase Portal => _portal;
|
||||||
|
|
||||||
|
public IDatDatabase Cell => _cell;
|
||||||
|
|
||||||
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
||||||
|
new(new Dictionary<uint, IDatDatabase>());
|
||||||
|
|
||||||
|
public IDatDatabase HighRes => _highRes;
|
||||||
|
|
||||||
|
public IDatDatabase Language => _language;
|
||||||
|
|
||||||
|
public IDatDatabase Local => _language;
|
||||||
|
|
||||||
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||||
|
new(new Dictionary<uint, uint>());
|
||||||
|
|
||||||
|
public int PortalIteration => 0;
|
||||||
|
|
||||||
|
public int CellIteration => 0;
|
||||||
|
|
||||||
|
public int HighResIteration => 0;
|
||||||
|
|
||||||
|
public int LanguageIteration => 0;
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint regionId,
|
||||||
|
uint fileId,
|
||||||
|
ref byte[] bytes,
|
||||||
|
out int bytesRead)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||||
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public bool TrySave<T>(
|
||||||
|
uint regionId,
|
||||||
|
T obj,
|
||||||
|
int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
[return: MaybeNull]
|
||||||
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
||||||
|
|
||||||
|
public bool TryGet<T>(
|
||||||
|
uint fileId,
|
||||||
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubDatabase : IDatDatabase
|
||||||
|
{
|
||||||
|
public DatDatabase Db => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public int Iteration => 0;
|
||||||
|
|
||||||
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public bool TryGet<T>(
|
||||||
|
uint fileId,
|
||||||
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint fileId,
|
||||||
|
[MaybeNullWhen(false)] out byte[] value)
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint fileId,
|
||||||
|
ref byte[] bytes,
|
||||||
|
out int bytesRead)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs
Normal file
169
tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW2: pure data-structure tests for
|
||||||
|
/// <see cref="OrderedDrawStream"/>. No GPU device, no dispatcher — this is the
|
||||||
|
/// append-only struct-of-arrays storage on its own.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderedDrawStreamTests
|
||||||
|
{
|
||||||
|
private static OrderedDrawCommand MakeCommand(
|
||||||
|
int index,
|
||||||
|
WalkDrawStage stage = WalkDrawStage.Terrain,
|
||||||
|
TranslucencyKind translucency = TranslucencyKind.Opaque,
|
||||||
|
CullMode cullMode = CullMode.CounterClockwise,
|
||||||
|
uint detailCategory = 0) =>
|
||||||
|
new(
|
||||||
|
Key: new GroupKey(
|
||||||
|
FirstIndex: (uint)index * 3,
|
||||||
|
BaseVertex: index * 4,
|
||||||
|
IndexCount: 3,
|
||||||
|
TextureSlot: new GpuTextureSlot((uint)index),
|
||||||
|
TextureLayer: 0,
|
||||||
|
Translucency: translucency,
|
||||||
|
FoliageFlags: 0,
|
||||||
|
CullMode: cullMode),
|
||||||
|
Transform: Matrix4x4.CreateTranslation(index, index * 2, index * 3),
|
||||||
|
Stage: stage,
|
||||||
|
CellId: 0x8C040100u + (uint)index,
|
||||||
|
ClipSlot: (uint)index + 1,
|
||||||
|
Lights: WbDrawDispatcher.InstanceLightSet.Disabled,
|
||||||
|
IndoorFlag: (uint)(index % 2),
|
||||||
|
Alpha: 1f - index * 0.01f,
|
||||||
|
SelectionLighting: new Vector2(index, index + 1),
|
||||||
|
DetailCategory: detailCategory);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyStream_HasZeroCount()
|
||||||
|
{
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
Assert.Equal(0, stream.Count);
|
||||||
|
Assert.Empty(stream.Keys);
|
||||||
|
Assert.Empty(stream.Transforms);
|
||||||
|
Assert.Empty(stream.Stages);
|
||||||
|
Assert.Empty(stream.CellIds);
|
||||||
|
Assert.Empty(stream.ClipSlots);
|
||||||
|
Assert.Empty(stream.Lights);
|
||||||
|
Assert.Empty(stream.IndoorFlags);
|
||||||
|
Assert.Empty(stream.Alphas);
|
||||||
|
Assert.Empty(stream.SelectionLighting);
|
||||||
|
Assert.Empty(stream.DetailCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Append_GrowsCountAndEveryParallelListInLockstep()
|
||||||
|
{
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
stream.Append(MakeCommand(i));
|
||||||
|
|
||||||
|
Assert.Equal(5, stream.Count);
|
||||||
|
Assert.Equal(5, stream.Keys.Count);
|
||||||
|
Assert.Equal(5, stream.Transforms.Count);
|
||||||
|
Assert.Equal(5, stream.Stages.Count);
|
||||||
|
Assert.Equal(5, stream.CellIds.Count);
|
||||||
|
Assert.Equal(5, stream.ClipSlots.Count);
|
||||||
|
Assert.Equal(5, stream.Lights.Count);
|
||||||
|
Assert.Equal(5, stream.IndoorFlags.Count);
|
||||||
|
Assert.Equal(5, stream.Alphas.Count);
|
||||||
|
Assert.Equal(5, stream.SelectionLighting.Count);
|
||||||
|
Assert.Equal(5, stream.DetailCategories.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Append_PreservesEveryFieldAtItsIndex()
|
||||||
|
{
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
OrderedDrawCommand[] commands =
|
||||||
|
[
|
||||||
|
MakeCommand(0, WalkDrawStage.Terrain),
|
||||||
|
MakeCommand(1, WalkDrawStage.CellStatic),
|
||||||
|
MakeCommand(2, WalkDrawStage.BuildingShell),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (OrderedDrawCommand command in commands)
|
||||||
|
stream.Append(command);
|
||||||
|
|
||||||
|
for (int i = 0; i < commands.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(commands[i].Key, stream.Keys[i]);
|
||||||
|
Assert.Equal(commands[i].Transform, stream.Transforms[i]);
|
||||||
|
Assert.Equal(commands[i].Stage, stream.Stages[i]);
|
||||||
|
Assert.Equal(commands[i].CellId, stream.CellIds[i]);
|
||||||
|
Assert.Equal(commands[i].ClipSlot, stream.ClipSlots[i]);
|
||||||
|
Assert.Equal(commands[i].Lights, stream.Lights[i]);
|
||||||
|
Assert.Equal(commands[i].IndoorFlag, stream.IndoorFlags[i]);
|
||||||
|
Assert.Equal(commands[i].Alpha, stream.Alphas[i]);
|
||||||
|
Assert.Equal(commands[i].SelectionLighting, stream.SelectionLighting[i]);
|
||||||
|
Assert.Equal(commands[i].DetailCategory, stream.DetailCategories[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WalkDrawStage.Terrain)]
|
||||||
|
[InlineData(WalkDrawStage.CellStatic)]
|
||||||
|
[InlineData(WalkDrawStage.BuildingShell)]
|
||||||
|
[InlineData(WalkDrawStage.PortalPunch)]
|
||||||
|
[InlineData(WalkDrawStage.LookInStatic)]
|
||||||
|
[InlineData(WalkDrawStage.Dynamic)]
|
||||||
|
internal void Append_AcceptsEveryStageIncludingPortalPunch(WalkDrawStage stage)
|
||||||
|
{
|
||||||
|
// The stream itself is a dumb data structure — it accepts every stage
|
||||||
|
// unconditionally. Only the SUBMITTER rejects PortalPunch (see
|
||||||
|
// OrderPreservingSubmitterTests), so stage-separation gates can
|
||||||
|
// exercise the boundary at the stream level too.
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
stream.Append(MakeCommand(0, stage));
|
||||||
|
|
||||||
|
Assert.Equal(1, stream.Count);
|
||||||
|
Assert.Equal(stage, stream.Stages[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reset_ClearsEveryParallelListToZeroCount()
|
||||||
|
{
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
for (int i = 0; i < 7; i++)
|
||||||
|
stream.Append(MakeCommand(i));
|
||||||
|
Assert.Equal(7, stream.Count);
|
||||||
|
|
||||||
|
stream.Reset();
|
||||||
|
|
||||||
|
Assert.Equal(0, stream.Count);
|
||||||
|
Assert.Empty(stream.Keys);
|
||||||
|
Assert.Empty(stream.Transforms);
|
||||||
|
Assert.Empty(stream.Stages);
|
||||||
|
Assert.Empty(stream.CellIds);
|
||||||
|
Assert.Empty(stream.ClipSlots);
|
||||||
|
Assert.Empty(stream.Lights);
|
||||||
|
Assert.Empty(stream.IndoorFlags);
|
||||||
|
Assert.Empty(stream.Alphas);
|
||||||
|
Assert.Empty(stream.SelectionLighting);
|
||||||
|
Assert.Empty(stream.DetailCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reset_ThenAppend_StartsAFreshInOrderSequence()
|
||||||
|
{
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
stream.Append(MakeCommand(0, WalkDrawStage.Terrain));
|
||||||
|
stream.Append(MakeCommand(1, WalkDrawStage.Terrain));
|
||||||
|
stream.Reset();
|
||||||
|
|
||||||
|
stream.Append(MakeCommand(9, WalkDrawStage.Dynamic));
|
||||||
|
|
||||||
|
Assert.Equal(1, stream.Count);
|
||||||
|
Assert.Equal(WalkDrawStage.Dynamic, stream.Stages[0]);
|
||||||
|
Assert.Equal(0x8C040100u + 9u, stream.CellIds[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue