feat(render) Campaign FW3.2a: the walk-to-draw population layer
The piece that turns walk-visited static content into draws, with no production frame wiring (FW3.2b roots the frame): - TryClassifyBatch: ONE shared per-batch classify core (the #426 untextured gate, #188 opacity promotion, texture resolve, foliage classification, in the exact original order) extracted from ClassifyBatches; the classic and packed classifiers now call it - behavior-identical, proven by the full hermetic + InstalledDat + Core Wb suites. - ClassifyEntityForWalk / WalkClassifiedBatch: the per-entity seam yielding per-batch keys + instance data WITHOUT InstanceGroup bucketing, plus the per-part selection data (picking stays alive on the walk path - the survey's unlisted-consumer fix). - WalkStaticStreamPopulator: per-entity walk-ordered opaque appends (under depth Less, opaque order is pixel-relevant only for coplanar surfaces, which retail resolves first-drawn-wins in ITS order - never material-grouped), translucent instances to the SAME RetailAlphaQueue via SubmitWalkAlphaInstance (identical viewer distances; walk-order submission improves retail's tie fidelity), selection parts published per entity. - SubmitOrderedStream now owns _orderedDrawCullModes, retiring the FW2-recorded alpha-scope interleaving constraint; DrawIndirectRangeRhi takes an optional cull array (all existing call sites unchanged). The referee test was verified to FAIL against the old shared-scratch behavior. - WalkDrawStage.OutdoorStatic added for the landscape turn. Suites: full Release build 0 warnings; Walk lane 195/1 skip; hermetic 6,747/0 (the two failures the implementation round reported were transient - both pass in isolation and in the full run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b10ad662b0
commit
81c6531727
9 changed files with 1264 additions and 116 deletions
|
|
@ -70,6 +70,47 @@ internal readonly record struct RenderInstanceCandidate(
|
||||||
TupleLandblockId: tupleLandblockId);
|
TupleLandblockId: tupleLandblockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2a: builds a candidate straight from a
|
||||||
|
/// <see cref="RenderProjectionRecord"/>, without the packed route's
|
||||||
|
/// <see cref="RenderFrameEntityCandidate"/>/frame-arena mesh-part
|
||||||
|
/// flattening. The walk populator reads <see cref="RenderSceneQuery"/>
|
||||||
|
/// records directly (<c>CopyCellStaticsTo</c> / <c>CopyIndexTo</c>), so it
|
||||||
|
/// has no frame arena to look the source candidate up in — every field
|
||||||
|
/// this needs already lives on the record's own <see cref="RenderSourceMetadata"/>
|
||||||
|
/// and <see cref="RenderEntityPayload"/>.
|
||||||
|
///
|
||||||
|
/// <para><paramref name="animated"/> defaults to false: the walk's static
|
||||||
|
/// routes (<c>RenderProjectionClass.OutdoorStatic</c> /
|
||||||
|
/// <c>IndoorCellStatic</c>) never carry retail's per-part
|
||||||
|
/// <c>TransparentPartHook</c> animation — that mechanic keys off a live
|
||||||
|
/// entity's ServerGuid, not a world static's. A future dynamic walk route
|
||||||
|
/// passes true explicitly.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static RenderInstanceCandidate FromProjection(
|
||||||
|
in RenderProjectionRecord projection,
|
||||||
|
uint tupleLandblockId,
|
||||||
|
bool animated = false)
|
||||||
|
{
|
||||||
|
RenderEntityPayload payload = projection.EntityPayload;
|
||||||
|
return new RenderInstanceCandidate(
|
||||||
|
ProjectionId: projection.Id,
|
||||||
|
LocalEntityId: projection.Source.LocalEntityId,
|
||||||
|
ServerGuid: projection.Source.ServerGuid,
|
||||||
|
SourceId: projection.Source.SourceId,
|
||||||
|
ParentCellId: projection.Source.ParentCellId,
|
||||||
|
RootWorld: projection.Transform.LocalToWorld,
|
||||||
|
Position: projection.Transform.Position,
|
||||||
|
Rotation: projection.Transform.Rotation,
|
||||||
|
Scale: projection.Transform.UniformScale,
|
||||||
|
Bounds: projection.Bounds,
|
||||||
|
PaletteOverride: payload.PaletteOverride,
|
||||||
|
IsBuildingShell: payload.IsBuildingShell,
|
||||||
|
Animated: animated,
|
||||||
|
MeshPartCount: payload.MeshRefs?.Count ?? 0,
|
||||||
|
TupleLandblockId: tupleLandblockId);
|
||||||
|
}
|
||||||
|
|
||||||
internal static RenderInstanceCandidate FromFrame(
|
internal static RenderInstanceCandidate FromFrame(
|
||||||
in RenderFrameEntityCandidate source,
|
in RenderFrameEntityCandidate source,
|
||||||
uint tupleLandblockId)
|
uint tupleLandblockId)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@ internal enum WalkDrawStage : byte
|
||||||
/// <c>LScape::grab_visible_cells</c> @0x00504EC0 — outdoor terrain.</summary>
|
/// <c>LScape::grab_visible_cells</c> @0x00504EC0 — outdoor terrain.</summary>
|
||||||
Terrain,
|
Terrain,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2a: the outdoor static objects (scenery,
|
||||||
|
/// buildings-as-decor, landblock statics —
|
||||||
|
/// <c>RenderProjectionClass.OutdoorStatic</c>) <c>LScape::draw</c> visits
|
||||||
|
/// for each landblock alongside its terrain mesh — retail's landscape
|
||||||
|
/// walk draws a landblock's ground and its static content in the same
|
||||||
|
/// pass, distinct from the ground mesh itself (<see cref="Terrain"/>) and
|
||||||
|
/// from a building's own exterior shell (<see cref="BuildingShell"/>).
|
||||||
|
/// The packed route's analogue is
|
||||||
|
/// <c>RenderFrameCandidateRoute.LandscapeOutdoorStatic</c>
|
||||||
|
/// (<c>WbDrawDispatcher.PackedOracle.cs</c>).
|
||||||
|
/// </summary>
|
||||||
|
OutdoorStatic,
|
||||||
|
|
||||||
/// <summary>An indoor <c>PView::DrawCells</c> @0x005A4840 flood's static
|
/// <summary>An indoor <c>PView::DrawCells</c> @0x005A4840 flood's static
|
||||||
/// geometry: EnvCell shells plus the static meshes they contain.</summary>
|
/// geometry: EnvCell shells plus the static meshes they contain.</summary>
|
||||||
CellStatic,
|
CellStatic,
|
||||||
|
|
|
||||||
137
src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs
Normal file
137
src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Scene;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2a: the walk→draw population layer. Turns one
|
||||||
|
/// cell's already-classified static content into <see cref="OrderedDrawCommand"/>
|
||||||
|
/// appends (opaque, walk order), per-instance deferred-alpha submissions
|
||||||
|
/// (translucent), and selection-scene publications (picking) — WITHOUT any
|
||||||
|
/// production frame wiring. <c>WorldSceneRenderer</c> does not call this
|
||||||
|
/// class yet; FW3.2b roots it into the real frame.
|
||||||
|
///
|
||||||
|
/// <para><b>Why per-entity append instead of the classic material grouping</b>:
|
||||||
|
/// under <see cref="WorldDepthContract"/>'s depth-compare Less, opaque draw
|
||||||
|
/// order is pixel-relevant ONLY for coplanar surfaces (a rug on a floor, a
|
||||||
|
/// shell over terrain) — and retail resolves those by first-drawn-wins in
|
||||||
|
/// ITS walk/cell-content order, not by any texture/material grouping. So
|
||||||
|
/// this populator appends one <see cref="OrderedDrawCommand"/> per (entity,
|
||||||
|
/// opaque batch) in the SAME order the caller's <c>records</c> span presents
|
||||||
|
/// them — never material-grouped, never re-sorted. Translucent batches go to
|
||||||
|
/// the SAME <see cref="RetailAlphaQueue"/> the classic/packed paths already
|
||||||
|
/// use (global far→near re-sort still applies; walk-order submission only
|
||||||
|
/// improves retail's submission-order tie-break fidelity for coincident
|
||||||
|
/// distances — <c>WbDrawDispatcher.DeferTransparentGroups</c>'s own doc
|
||||||
|
/// comment cites the same <c>CShadowPart::insertion_sort</c> stability this
|
||||||
|
/// preserves).</para>
|
||||||
|
///
|
||||||
|
/// <para>Reads no retained scene state itself: every method takes the target
|
||||||
|
/// <see cref="OrderedDrawStream"/> and a caller-supplied span of already
|
||||||
|
/// -queried <see cref="RenderProjectionRecord"/>s (from
|
||||||
|
/// <c>RenderSceneQuery.CopyCellStaticsTo</c> / <c>CopyIndexTo</c>). Owns only
|
||||||
|
/// two small per-call scratch lists — the classify seam's per-entity output
|
||||||
|
/// — reused across records to keep this hot path allocation-free after
|
||||||
|
/// warmup.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WalkStaticStreamPopulator
|
||||||
|
{
|
||||||
|
private readonly WbDrawDispatcher _dispatcher;
|
||||||
|
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _batchScratch = new();
|
||||||
|
private readonly List<WbDrawDispatcher.WalkClassifiedSelectionPart> _selectionScratch = new();
|
||||||
|
|
||||||
|
internal WalkStaticStreamPopulator(WbDrawDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populates <paramref name="stream"/> from one indoor cell's static
|
||||||
|
/// content (<c>RenderProjectionClass.IndoorCellStatic</c> —
|
||||||
|
/// <c>RenderSceneQuery.CopyCellStaticsTo</c>). <paramref name="stage"/>
|
||||||
|
/// is caller-selected per the walk turn this cell's content belongs to
|
||||||
|
/// — <see cref="WalkDrawStage.CellStatic"/> for an ordinary
|
||||||
|
/// <c>PView::DrawCells</c> flood, <see cref="WalkDrawStage.LookInStatic"/>
|
||||||
|
/// for a <c>ConstructView(CBldPortal)</c> look-in, or
|
||||||
|
/// <see cref="WalkDrawStage.BuildingShell"/> for a building's own
|
||||||
|
/// exterior shell content.
|
||||||
|
/// </summary>
|
||||||
|
internal void PopulateCell(
|
||||||
|
OrderedDrawStream stream,
|
||||||
|
WalkDrawStage stage,
|
||||||
|
uint cellId,
|
||||||
|
ReadOnlySpan<RenderProjectionRecord> records,
|
||||||
|
uint tupleLandblockId,
|
||||||
|
Vector3 cameraWorldPosition,
|
||||||
|
Matrix4x4 viewProjection)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(stream);
|
||||||
|
for (int i = 0; i < records.Length; i++)
|
||||||
|
{
|
||||||
|
ClassifyAndAppend(
|
||||||
|
stream, stage, cellId, in records[i], tupleLandblockId,
|
||||||
|
cameraWorldPosition, viewProjection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The landscape entry point: outdoor static content
|
||||||
|
/// (<c>RenderProjectionClass.OutdoorStatic</c> —
|
||||||
|
/// <c>RenderSceneQuery.CopyIndexTo(RenderSceneIndex.OutdoorStatic, ...)</c>)
|
||||||
|
/// at <see cref="WalkDrawStage.OutdoorStatic"/>, the terrain-adjacent
|
||||||
|
/// stage <c>LScape::draw</c> visits a landblock's static objects at
|
||||||
|
/// alongside its ground mesh (see that stage value's own doc comment).
|
||||||
|
/// <paramref name="cellId"/> is the OUTDOOR landblock id — outdoor
|
||||||
|
/// statics have no EnvCell of their own, so this is walk-order
|
||||||
|
/// provenance only, not a clip-slot key.
|
||||||
|
/// </summary>
|
||||||
|
internal void PopulateOutdoorStatics(
|
||||||
|
OrderedDrawStream stream,
|
||||||
|
uint cellId,
|
||||||
|
ReadOnlySpan<RenderProjectionRecord> records,
|
||||||
|
uint tupleLandblockId,
|
||||||
|
Vector3 cameraWorldPosition,
|
||||||
|
Matrix4x4 viewProjection) =>
|
||||||
|
PopulateCell(
|
||||||
|
stream, WalkDrawStage.OutdoorStatic, cellId, records,
|
||||||
|
tupleLandblockId, cameraWorldPosition, viewProjection);
|
||||||
|
|
||||||
|
private void ClassifyAndAppend(
|
||||||
|
OrderedDrawStream stream,
|
||||||
|
WalkDrawStage stage,
|
||||||
|
uint cellId,
|
||||||
|
in RenderProjectionRecord record,
|
||||||
|
uint tupleLandblockId,
|
||||||
|
Vector3 cameraWorldPosition,
|
||||||
|
Matrix4x4 viewProjection)
|
||||||
|
{
|
||||||
|
_batchScratch.Clear();
|
||||||
|
_selectionScratch.Clear();
|
||||||
|
_dispatcher.ClassifyEntityForWalk(
|
||||||
|
in record, tupleLandblockId, _batchScratch, _selectionScratch);
|
||||||
|
|
||||||
|
for (int i = 0; i < _batchScratch.Count; i++)
|
||||||
|
{
|
||||||
|
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i];
|
||||||
|
if (batch.IsOpaque)
|
||||||
|
{
|
||||||
|
stream.Append(new OrderedDrawCommand(
|
||||||
|
batch.Key, batch.Transform, stage, cellId, batch.ClipSlot,
|
||||||
|
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
||||||
|
batch.SelectionLighting, batch.DetailCategory));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dispatcher.SubmitWalkAlphaInstance(
|
||||||
|
in batch, cameraWorldPosition, viewProjection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < _selectionScratch.Count; i++)
|
||||||
|
{
|
||||||
|
WbDrawDispatcher.WalkClassifiedSelectionPart part = _selectionScratch[i];
|
||||||
|
_dispatcher.PublishWalkSelectionPart(in part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -253,14 +253,14 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
|
|
||||||
// Per-instance-first emission — the PrepareDeferredAlphaDraws shape,
|
// Per-instance-first emission — the PrepareDeferredAlphaDraws shape,
|
||||||
// into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/
|
// into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/
|
||||||
// SubmitRhi write. Most are consumed immediately by the ring uploads
|
// SubmitRhi write, EXCEPT cull modes: this stage (FW3.2a) gives the
|
||||||
// below, but _drawCullModes is NOT write-then-consume: the deferred-
|
// ordered path its own _orderedDrawCullModes scratch (see
|
||||||
// alpha path reads it at FLUSH time (DrawIndirectRangeRhi's internal
|
// DrawIndirectRangeRhi's doc comment) precisely so this loop and its
|
||||||
// cull split), so an ordered submission may never interleave between
|
// draws below can freely interleave with a mid-flight
|
||||||
// RetailAlphaQueue prepare and flush. FW2 has no production caller;
|
// RetailAlphaQueue scope without corrupting — or being corrupted by
|
||||||
// the FW3 wiring must either sequence around the alpha scope or give
|
// — the alpha path's _drawCullModes.
|
||||||
// this path its own cull scratch.
|
|
||||||
EnsureDeferredAlphaCapacity(count);
|
EnsureDeferredAlphaCapacity(count);
|
||||||
|
EnsureOrderedCullModeCapacity(count);
|
||||||
for (int i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
GroupKey key = stream.Keys[i];
|
GroupKey key = stream.Keys[i];
|
||||||
|
|
@ -286,7 +286,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
BaseVertex = key.BaseVertex,
|
BaseVertex = key.BaseVertex,
|
||||||
BaseInstance = (uint)i,
|
BaseInstance = (uint)i,
|
||||||
};
|
};
|
||||||
_drawCullModes[i] = key.CullMode;
|
_orderedDrawCullModes[i] = key.CullMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write every section ONCE — the PrepareRhiAlphaSections shape, but
|
// Write every section ONCE — the PrepareRhiAlphaSections shape, but
|
||||||
|
|
@ -350,10 +350,10 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
|
|
||||||
// One in-order pass over the pre-built merge runs: bind the run's
|
// One in-order pass over the pre-built merge runs: bind the run's
|
||||||
// pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits
|
// pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits
|
||||||
// internally on _drawCullModes (issue #52's absolute DrawIdOffset per
|
// internally on _orderedDrawCullModes (issue #52's absolute
|
||||||
// sub-call) — every run here already shares one cull mode by
|
// DrawIdOffset per sub-call) — every run here already shares one
|
||||||
// construction, so that inner split is a no-op here, never a second
|
// cull mode by construction, so that inner split is a no-op here,
|
||||||
// boundary this loop failed to expect.
|
// never a second boundary this loop failed to expect.
|
||||||
foreach (OrderedMergeRun run in runs)
|
foreach (OrderedMergeRun run in runs)
|
||||||
{
|
{
|
||||||
ValidateMergeRun(stream, run);
|
ValidateMergeRun(stream, run);
|
||||||
|
|
@ -365,7 +365,20 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
BindPipelineWithMesh(encoder, pipeline, global);
|
BindPipelineWithMesh(encoder, pipeline, global);
|
||||||
DrawIndirectRangeRhi(
|
DrawIndirectRangeRhi(
|
||||||
encoder, ref pushConstants, commandBuffer, commandBase,
|
encoder, ref pushConstants, commandBuffer, commandBase,
|
||||||
run.FirstCommand, run.CommandCount);
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -635,56 +635,22 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
batchIndex < renderData.Batches.Count;
|
batchIndex < renderData.Batches.Count;
|
||||||
batchIndex++)
|
batchIndex++)
|
||||||
{
|
{
|
||||||
ObjectRenderBatch batch =
|
// Campaign FW stage FW3.2a: mirrors the classic ClassifyBatches
|
||||||
renderData.Batches[batchIndex];
|
// gate/promotion/resolve/foliage-classify sequence exactly — see
|
||||||
|
// the one shared core (WbDrawDispatcher.WalkClassify.cs's
|
||||||
// #426: mirrors the classic ClassifyBatches gate exactly — see
|
// TryClassifyBatch) also used by ClassifyBatches and the walk
|
||||||
// RetailUntexturedSubsetPolicy for the retail citation. ONE
|
// classifier, so the classic and packed classifiers cannot drift
|
||||||
// shared predicate so the classic and packed classifiers cannot
|
// (Campaign VM VM6). `survives=false` still applies
|
||||||
// drift (Campaign VM VM6).
|
// compositePending exactly as before this extraction.
|
||||||
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
|
bool survives = TryClassifyBatch(
|
||||||
continue;
|
renderData, batchIndex, in entity, meshRef, paletteIdentity,
|
||||||
|
opacity, entityHasCutoutSubset,
|
||||||
TranslucencyKind translucency = batch.Translucency;
|
out GroupKey key, out bool compositePending);
|
||||||
if (opacity < 1f && IsOpaque(translucency))
|
|
||||||
translucency = TranslucencyKind.AlphaBlend;
|
|
||||||
|
|
||||||
ResolvedTexture texture = ResolveTexture(
|
|
||||||
in entity,
|
|
||||||
meshRef,
|
|
||||||
batch,
|
|
||||||
paletteIdentity,
|
|
||||||
out bool compositePending);
|
|
||||||
if (compositePending)
|
if (compositePending)
|
||||||
reusableAcrossFrames = false;
|
reusableAcrossFrames = false;
|
||||||
if (!texture.Slot.IsAssigned)
|
if (!survives)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Campaign VM VM6 review fix round 2 (F1 BLOCKER): the packed
|
|
||||||
// production classifier never computed FoliageFlags, so the
|
|
||||||
// production BatchData.flags word was always 0 for every
|
|
||||||
// scenery entity — the world geometry never swayed even though
|
|
||||||
// the independently-classified shadow caster did. Classify
|
|
||||||
// BEFORE constructing the key, from the RAW (pre-#188-
|
|
||||||
// promotion) batch.Translucency, exactly as the classic
|
|
||||||
// ClassifyBatches does — see that method's own comment for why
|
|
||||||
// raw translucency is used for classification but the (possibly
|
|
||||||
// promoted) local `translucency` is still what the key/group
|
|
||||||
// partitions draws by.
|
|
||||||
uint foliageFlags = FoliageWindClassification.Classify(
|
|
||||||
entity.LocalEntityId,
|
|
||||||
FoliageWindExclusions.Contains(meshRef.GfxObjId),
|
|
||||||
batch.Translucency,
|
|
||||||
entityHasCutoutSubset);
|
|
||||||
var key = new GroupKey(
|
|
||||||
batch.FirstIndex,
|
|
||||||
(int)batch.BaseVertex,
|
|
||||||
batch.IndexCount,
|
|
||||||
texture.Slot,
|
|
||||||
texture.Layer,
|
|
||||||
translucency,
|
|
||||||
FoliageFlags: foliageFlags,
|
|
||||||
CullMode: batch.CullMode);
|
|
||||||
var classified = new PackedClassifiedBatch(
|
var classified = new PackedClassifiedBatch(
|
||||||
key,
|
key,
|
||||||
restPose,
|
restPose,
|
||||||
|
|
|
||||||
|
|
@ -796,23 +796,40 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
_ => pipelines.AlphaBlend,
|
_ => pipelines.AlphaBlend,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads cull modes from <paramref name="cullModes"/> when the caller
|
||||||
|
/// supplies one, or from the shared <see cref="_drawCullModes"/> scratch
|
||||||
|
/// otherwise (every pre-FW3.2a call site). Campaign FW stage FW3.2a:
|
||||||
|
/// <see cref="SubmitOrderedStream"/> passes its OWN scratch
|
||||||
|
/// (<see cref="_orderedDrawCullModes"/>) so a walk-ordered submission can
|
||||||
|
/// interleave with a mid-flight <see cref="RetailAlphaQueue"/> scope —
|
||||||
|
/// <c>_drawCullModes</c> is written fresh by
|
||||||
|
/// <c>PrepareDeferredAlphaDraws</c> at every alpha flush and read right
|
||||||
|
/// back by this method for that draw; an ordered submission sharing the
|
||||||
|
/// same array between those two steps could silently draw the alpha
|
||||||
|
/// content's faces with the ordered content's cull mode, or vice
|
||||||
|
/// versa (the FW2 caveat this stage retires — see
|
||||||
|
/// <c>WbDrawDispatcher.OrderedStream.cs</c>).
|
||||||
|
/// </summary>
|
||||||
private void DrawIndirectRangeRhi(
|
private void DrawIndirectRangeRhi(
|
||||||
IGpuPassEncoder encoder,
|
IGpuPassEncoder encoder,
|
||||||
ref GpuPushConstants pushConstants,
|
ref GpuPushConstants pushConstants,
|
||||||
IGpuBuffer commandBuffer,
|
IGpuBuffer commandBuffer,
|
||||||
uint commandBaseOffsetBytes,
|
uint commandBaseOffsetBytes,
|
||||||
int startCommand,
|
int startCommand,
|
||||||
int commandCount)
|
int commandCount,
|
||||||
|
CullMode[]? cullModes = null)
|
||||||
{
|
{
|
||||||
|
CullMode[] modes = cullModes ?? _drawCullModes;
|
||||||
int end = startCommand + commandCount;
|
int end = startCommand + commandCount;
|
||||||
int command = startCommand;
|
int command = startCommand;
|
||||||
while (command < end)
|
while (command < end)
|
||||||
{
|
{
|
||||||
CullMode cullMode = _drawCullModes[command];
|
CullMode cullMode = modes[command];
|
||||||
ApplyCullModeRhi(encoder, cullMode);
|
ApplyCullModeRhi(encoder, cullMode);
|
||||||
|
|
||||||
int runCount = 1;
|
int runCount = 1;
|
||||||
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
|
while (command + runCount < end && modes[command + runCount] == cullMode)
|
||||||
runCount++;
|
runCount++;
|
||||||
|
|
||||||
// Each multi-draw-indirect call restarts gl_DrawID at 0, so a run
|
// Each multi-draw-indirect call restarts gl_DrawID at 0, so a run
|
||||||
|
|
|
||||||
355
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs
Normal file
355
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Scene;
|
||||||
|
using AcDream.App.Rendering.Selection;
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2a: the walk-order population layer. This partial
|
||||||
|
/// holds two things.
|
||||||
|
///
|
||||||
|
/// <para><b>The shared per-batch classify core</b> (<see cref="TryClassifyBatch"/>):
|
||||||
|
/// extracted from <c>ClassifyBatches</c> (this file's sibling
|
||||||
|
/// <c>WbDrawDispatcher.cs</c>) and <c>ClassifyPackedBatches</c>
|
||||||
|
/// (<c>WbDrawDispatcher.PackedOracle.cs</c>), which carried byte-identical
|
||||||
|
/// per-batch logic (the #426 untextured-subset gate, the #188 opacity
|
||||||
|
/// promotion, texture resolution, Campaign VM foliage classification, the
|
||||||
|
/// <see cref="GroupKey"/> construction) with only their SURROUNDING
|
||||||
|
/// bookkeeping differing (classic appends to an <see cref="InstanceGroup"/>
|
||||||
|
/// via caller-scoped instance fields; packed appends via explicit
|
||||||
|
/// parameters). Both call sites now call this one method per batch and keep
|
||||||
|
/// their own append logic — the classic and packed paths are unchanged in
|
||||||
|
/// every observable way (same hermetic + <c>Lane=InstalledDat</c> suites
|
||||||
|
/// stay the referee); this stage's OWN new walk classifier
|
||||||
|
/// (<see cref="ClassifyEntityForWalk"/>) is the third caller.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>The per-entity walk classify seam</b>
|
||||||
|
/// (<see cref="ClassifyEntityForWalk"/>): given ONE <see cref="RenderProjectionRecord"/>
|
||||||
|
/// — the same shape <c>ClassifyPackedEntity</c> consumes, but read straight
|
||||||
|
/// off the record's own <see cref="RenderEntityPayload"/> via
|
||||||
|
/// <c>RenderInstanceCandidate.FromProjection</c> rather than the packed
|
||||||
|
/// route's frame-arena mesh-part flattening — yields one
|
||||||
|
/// <see cref="WalkClassifiedBatch"/> per surviving (part, batch) pair WITHOUT
|
||||||
|
/// touching any <see cref="InstanceGroup"/>, plus the per-part selection
|
||||||
|
/// data <c>AddPackedSelectionPart</c> would have published, for
|
||||||
|
/// <c>WalkStaticStreamPopulator</c> to publish itself (deliverable
|
||||||
|
/// 1 leaves the publish decision — timing, stage/cell provenance — to the
|
||||||
|
/// caller; see <see cref="PublishWalkSelectionPart"/>, the thin internal seam
|
||||||
|
/// that keeps <c>_selectionSink</c> encapsulated).</para>
|
||||||
|
///
|
||||||
|
/// <para>Scope: this stage classifies static content only (no production
|
||||||
|
/// frame wiring — see plan §FW3.2a). Per-part translucency-fade
|
||||||
|
/// (<c>TranslucencyFadeManager</c>/<c>EntityOpacity</c>) is NOT threaded
|
||||||
|
/// through: that mechanic is <c>TransparentPartHook</c>, a retail LIVE-entity
|
||||||
|
/// behavior keyed by ServerGuid, not something world statics undergo — every
|
||||||
|
/// classified batch here carries <c>Alpha = 1f</c>. Likewise the async
|
||||||
|
/// mesh-miss self-heal request (<c>_missRequested</c>/<c>EnsureLoaded</c>,
|
||||||
|
/// frame-scoped state cleared by <c>BeginEntityDispatch</c>) is not fired
|
||||||
|
/// here — this seam has no production frame to be scoped to yet; a missing
|
||||||
|
/// mesh is simply skipped, matching every other unwired FW2/FW3.2a path.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class WbDrawDispatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One walk-classified (entity, part, batch) draw candidate — exactly
|
||||||
|
/// <c>OrderedDrawCommand</c>'s per-instance field set (minus
|
||||||
|
/// the walk-provenance <c>Stage</c>/<c>CellId</c> fields, which the
|
||||||
|
/// populator stamps on since the classifier has no notion of either)
|
||||||
|
/// plus two fields an opaque command has no use for but a translucent
|
||||||
|
/// one needs to reach the alpha queue: <see cref="IsOpaque"/> (so the
|
||||||
|
/// populator can route without re-deriving it from
|
||||||
|
/// <see cref="GroupKey.Translucency"/>) and <see cref="LocalSortCenter"/>
|
||||||
|
/// (the authored GfxObj sort center <c>RetailAlphaOrdering.ComputeViewerDistance</c>
|
||||||
|
/// transforms through <see cref="Transform"/> — the same value
|
||||||
|
/// <c>InstanceGroup.LocalSortCenters</c> carries per instance today).
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct WalkClassifiedBatch(
|
||||||
|
GroupKey Key,
|
||||||
|
Matrix4x4 Transform,
|
||||||
|
uint ClipSlot,
|
||||||
|
InstanceLightSet Lights,
|
||||||
|
uint IndoorFlag,
|
||||||
|
float Alpha,
|
||||||
|
Vector2 SelectionLighting,
|
||||||
|
uint DetailCategory,
|
||||||
|
bool IsOpaque,
|
||||||
|
Vector3 LocalSortCenter);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One retail-picking part surfaced by <see cref="ClassifyEntityForWalk"/>
|
||||||
|
/// — the exact argument tuple <c>AddPackedSelectionPart</c>'s
|
||||||
|
/// <c>publishSelection: true</c> branch passes to
|
||||||
|
/// <c>IRetailSelectionRenderSink.AddVisiblePart</c>. The classify seam
|
||||||
|
/// surfaces this; <see cref="PublishWalkSelectionPart"/> is the caller's
|
||||||
|
/// publish call.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct WalkClassifiedSelectionPart(
|
||||||
|
uint ServerGuid,
|
||||||
|
uint LocalEntityId,
|
||||||
|
int PartIndex,
|
||||||
|
uint GfxObjId,
|
||||||
|
Matrix4x4 LocalToWorld);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared per-batch classify core. Given one already-resolved
|
||||||
|
/// <paramref name="renderData"/> and the batch at <paramref name="batchIndex"/>,
|
||||||
|
/// applies — in this exact order, matching both <c>ClassifyBatches</c> and
|
||||||
|
/// <c>ClassifyPackedBatches</c> before this extraction — the #426
|
||||||
|
/// untextured-subset-on-a-shell-only gate, the #188 mid-fade-forces-
|
||||||
|
/// AlphaBlend promotion, texture resolution, and Campaign VM foliage
|
||||||
|
/// classification, then builds the surviving batch's <see cref="GroupKey"/>.
|
||||||
|
///
|
||||||
|
/// <para>Returns false when the batch does not survive — either the
|
||||||
|
/// untextured-subset gate rejected it (in which case
|
||||||
|
/// <paramref name="compositePending"/> is always false: <c>ResolveTexture</c>
|
||||||
|
/// is never called) or its resolved texture slot is unassigned (in which
|
||||||
|
/// case <paramref name="compositePending"/> still reflects whatever
|
||||||
|
/// <c>ResolveTexture</c> reported — a caller must apply it to its own
|
||||||
|
/// reusability/readiness tracking regardless of the false return, exactly
|
||||||
|
/// as both existing callers did before this extraction).</para>
|
||||||
|
/// </summary>
|
||||||
|
private bool TryClassifyBatch(
|
||||||
|
ObjectRenderData renderData,
|
||||||
|
int batchIndex,
|
||||||
|
in RenderInstanceCandidate entity,
|
||||||
|
MeshRef meshRef,
|
||||||
|
PaletteCompositeIdentity paletteIdentity,
|
||||||
|
float opacityMultiplier,
|
||||||
|
bool entityHasCutoutSubset,
|
||||||
|
out GroupKey key,
|
||||||
|
out bool compositePending)
|
||||||
|
{
|
||||||
|
key = default;
|
||||||
|
compositePending = false;
|
||||||
|
ObjectRenderBatch batch = renderData.Batches[batchIndex];
|
||||||
|
|
||||||
|
// #426 — see RetailUntexturedSubsetPolicy for the retail citation.
|
||||||
|
// ONE shared predicate for every classifier so they cannot drift.
|
||||||
|
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
TranslucencyKind translucency = batch.Translucency;
|
||||||
|
|
||||||
|
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
|
||||||
|
// must route through the alpha-blend pass so mesh_modern.frag's
|
||||||
|
// (blend-enabled) shader actually composites the reduced alpha — the
|
||||||
|
// no-blend opaque pass would ignore it.
|
||||||
|
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
|
||||||
|
translucency = TranslucencyKind.AlphaBlend;
|
||||||
|
|
||||||
|
ResolvedTexture texture = ResolveTexture(
|
||||||
|
in entity, meshRef, batch, paletteIdentity, out compositePending);
|
||||||
|
if (!texture.Slot.IsAssigned)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Classify from the RAW (pre-#188-promotion) batch.Translucency — a
|
||||||
|
// mid-fade trunk is still a trunk, it just landed in the alpha-blend
|
||||||
|
// group instead of opaque (Campaign VM VM6 review fix round).
|
||||||
|
uint foliageFlags = FoliageWindClassification.Classify(
|
||||||
|
entity.LocalEntityId,
|
||||||
|
FoliageWindExclusions.Contains(meshRef.GfxObjId),
|
||||||
|
batch.Translucency,
|
||||||
|
entityHasCutoutSubset);
|
||||||
|
key = new GroupKey(
|
||||||
|
batch.FirstIndex, (int)batch.BaseVertex,
|
||||||
|
batch.IndexCount, texture.Slot, texture.Layer, translucency,
|
||||||
|
FoliageFlags: foliageFlags,
|
||||||
|
CullMode: batch.CullMode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies one static entity for the walk populator: resolves its clip
|
||||||
|
/// slot / light set / selection lighting exactly as
|
||||||
|
/// <c>ClassifyPackedEntity</c> does (via the shared
|
||||||
|
/// <see cref="ResolveSlotForFrame"/> / <c>ResolvePackedLightSet</c>
|
||||||
|
/// helpers), walks its Setup parts or single mesh (mirroring
|
||||||
|
/// <c>ClassifyPackedEntity</c>'s own shape), and appends one
|
||||||
|
/// <see cref="WalkClassifiedBatch"/> per surviving batch to
|
||||||
|
/// <paramref name="batches"/> plus one <see cref="WalkClassifiedSelectionPart"/>
|
||||||
|
/// per part to <paramref name="selectionParts"/> — WITHOUT touching
|
||||||
|
/// <c>_groups</c>/<c>_packedGroups</c> or publishing selection itself (see
|
||||||
|
/// this file's type doc comment).
|
||||||
|
///
|
||||||
|
/// <para>A culled entity (<see cref="ResolveSlotForFrame"/> returns
|
||||||
|
/// <c>Culled: true</c> — not visible through the active clip route)
|
||||||
|
/// contributes nothing, matching every other classifier's cull gate.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal void ClassifyEntityForWalk(
|
||||||
|
in RenderProjectionRecord projection,
|
||||||
|
uint tupleLandblockId,
|
||||||
|
List<WalkClassifiedBatch> batches,
|
||||||
|
List<WalkClassifiedSelectionPart> selectionParts)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(batches);
|
||||||
|
ArgumentNullException.ThrowIfNull(selectionParts);
|
||||||
|
|
||||||
|
RenderInstanceCandidate entity =
|
||||||
|
RenderInstanceCandidate.FromProjection(in projection, tupleLandblockId);
|
||||||
|
|
||||||
|
(uint slot, bool culled) = ResolveSlotForFrame(
|
||||||
|
_clipRoutingActive, entity.ServerGuid, entity.ParentCell,
|
||||||
|
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
|
||||||
|
if (culled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ResolvePackedLightSet(in entity, out InstanceLightSet lights, out bool indoor);
|
||||||
|
Vector2 selectionLighting =
|
||||||
|
_selectionLighting?.TryGetLighting(
|
||||||
|
entity.ServerGuid, entity.LocalEntityId, out RetailSelectionLighting lighting) == true
|
||||||
|
? new Vector2(lighting.Luminosity, lighting.Diffuse)
|
||||||
|
: new Vector2(0f, 1f);
|
||||||
|
uint detailCategory = entity.IsBuildingShell ? 1u : 0u;
|
||||||
|
|
||||||
|
PaletteCompositeIdentity paletteIdentity = default;
|
||||||
|
if (entity.PaletteOverride is not null)
|
||||||
|
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
|
||||||
|
|
||||||
|
IReadOnlyList<MeshRef>? meshRefs = projection.EntityPayload.MeshRefs;
|
||||||
|
if (meshRefs is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int partIndex = 0; partIndex < meshRefs.Count; partIndex++)
|
||||||
|
{
|
||||||
|
MeshRef meshRef = meshRefs[partIndex];
|
||||||
|
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
|
||||||
|
if (renderData is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||||
|
{
|
||||||
|
// Same entity-scoped OR the classic/packed classifiers compute
|
||||||
|
// — a Setup composite's parts are separate GfxObjs with their
|
||||||
|
// own independently cached HasCutoutSubset (Campaign VM VM6
|
||||||
|
// review fix round A4/F1).
|
||||||
|
bool entityHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
|
||||||
|
renderData.SetupParts,
|
||||||
|
_meshAdapter,
|
||||||
|
static (adapter, part) => adapter.TryGetRenderData(part.GfxObjId)
|
||||||
|
is { HasCutoutSubset: true });
|
||||||
|
|
||||||
|
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
|
||||||
|
{
|
||||||
|
(ulong gfxObjId, Matrix4x4 partTransform) = renderData.SetupParts[setupPartIndex];
|
||||||
|
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||||
|
if (partData is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Matrix4x4 restPose = partTransform * meshRef.PartTransform;
|
||||||
|
Matrix4x4 model = restPose * entity.RootWorld;
|
||||||
|
int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF));
|
||||||
|
|
||||||
|
EmitClassifiedBatches(
|
||||||
|
partData, model, in entity, meshRef, paletteIdentity,
|
||||||
|
entityHasCutoutSubset, slot, lights, indoor, selectionLighting,
|
||||||
|
detailCategory, batches);
|
||||||
|
selectionParts.Add(new WalkClassifiedSelectionPart(
|
||||||
|
entity.ServerGuid, entity.LocalEntityId, selectionPartIndex,
|
||||||
|
(uint)gfxObjId, model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Matrix4x4 model = meshRef.PartTransform * entity.RootWorld;
|
||||||
|
EmitClassifiedBatches(
|
||||||
|
renderData, model, in entity, meshRef, paletteIdentity,
|
||||||
|
entityHasCutoutSubsetOverride: null, slot, lights, indoor,
|
||||||
|
selectionLighting, detailCategory, batches);
|
||||||
|
selectionParts.Add(new WalkClassifiedSelectionPart(
|
||||||
|
entity.ServerGuid, entity.LocalEntityId, partIndex,
|
||||||
|
(uint)meshRef.GfxObjId, model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks one resolved mesh's batches through <see cref="TryClassifyBatch"/>
|
||||||
|
/// and appends every surviving one to <paramref name="sink"/> at
|
||||||
|
/// <c>Alpha = 1f</c> (see this file's type doc comment for why statics
|
||||||
|
/// never carry a per-part opacity multiplier here).
|
||||||
|
/// </summary>
|
||||||
|
private void EmitClassifiedBatches(
|
||||||
|
ObjectRenderData renderData,
|
||||||
|
Matrix4x4 model,
|
||||||
|
in RenderInstanceCandidate entity,
|
||||||
|
MeshRef meshRef,
|
||||||
|
PaletteCompositeIdentity paletteIdentity,
|
||||||
|
bool? entityHasCutoutSubsetOverride,
|
||||||
|
uint slot,
|
||||||
|
InstanceLightSet lights,
|
||||||
|
bool indoor,
|
||||||
|
Vector2 selectionLighting,
|
||||||
|
uint detailCategory,
|
||||||
|
List<WalkClassifiedBatch> sink)
|
||||||
|
{
|
||||||
|
bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset;
|
||||||
|
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||||||
|
{
|
||||||
|
bool survives = TryClassifyBatch(
|
||||||
|
renderData, batchIdx, in entity, meshRef, paletteIdentity,
|
||||||
|
opacityMultiplier: 1.0f, entityHasCutoutSubset,
|
||||||
|
out GroupKey key, out _);
|
||||||
|
if (!survives)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
sink.Add(new WalkClassifiedBatch(
|
||||||
|
key, model, slot, lights, indoor ? 1u : 0u, Alpha: 1f,
|
||||||
|
selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency),
|
||||||
|
LocalSortCenter: renderData.SortCenter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The thin internal publish seam <c>WalkStaticStreamPopulator</c>
|
||||||
|
/// calls for every <see cref="WalkClassifiedSelectionPart"/>
|
||||||
|
/// <see cref="ClassifyEntityForWalk"/> surfaced — same call shape as
|
||||||
|
/// <c>AddPackedSelectionPart</c>'s <c>publishSelection: true</c> branch.
|
||||||
|
/// Keeps <c>_selectionSink</c> encapsulated: the populator lives outside
|
||||||
|
/// the dispatcher and must not reach the field directly.
|
||||||
|
/// </summary>
|
||||||
|
internal void PublishWalkSelectionPart(in WalkClassifiedSelectionPart part) =>
|
||||||
|
_selectionSink?.AddVisiblePart(
|
||||||
|
part.ServerGuid, part.LocalEntityId, part.PartIndex, part.GfxObjId, part.LocalToWorld);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The walk populator's per-instance sibling of <c>DeferTransparentGroups</c>
|
||||||
|
/// (see that method for the retail citations this mirrors): submits ONE
|
||||||
|
/// translucent <see cref="WalkClassifiedBatch"/> into the same
|
||||||
|
/// <c>_deferredAlpha</c>/<see cref="RetailAlphaQueue"/> machinery the
|
||||||
|
/// classic material-grouped path uses, so scenery, particles, and walk
|
||||||
|
/// content share retail's one stable far-to-near stream. Same
|
||||||
|
/// view-projection consistency check, same
|
||||||
|
/// <see cref="RetailAlphaOrdering.ComputeViewerDistance"/> call, same
|
||||||
|
/// <c>queue.Submit</c> contract — the walk path denormalizes to one
|
||||||
|
/// instance per call instead of flattening a material group.
|
||||||
|
/// </summary>
|
||||||
|
internal void SubmitWalkAlphaInstance(
|
||||||
|
in WalkClassifiedBatch batch,
|
||||||
|
Vector3 cameraWorldPosition,
|
||||||
|
Matrix4x4 viewProjection)
|
||||||
|
{
|
||||||
|
RetailAlphaQueue queue = _alphaQueue
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"SubmitWalkAlphaInstance requires an active RetailAlphaQueue.");
|
||||||
|
|
||||||
|
if (_deferredAlpha.Count == 0)
|
||||||
|
_deferredAlphaViewProjection = viewProjection;
|
||||||
|
else if (_deferredAlphaViewProjection != viewProjection)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"One retail alpha scope cannot combine different view-projection matrices.");
|
||||||
|
|
||||||
|
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
|
||||||
|
batch.LocalSortCenter, batch.Transform, cameraWorldPosition);
|
||||||
|
if (!float.IsFinite(viewerDistance) || viewerDistance <= 0f)
|
||||||
|
viewerDistance = 0f;
|
||||||
|
|
||||||
|
int token = _deferredAlpha.Count;
|
||||||
|
_deferredAlpha.Add(new DeferredAlphaInstance(
|
||||||
|
batch.Key, batch.Transform, batch.ClipSlot, batch.Lights,
|
||||||
|
batch.IndoorFlag, batch.DetailCategory, batch.Alpha, batch.SelectionLighting));
|
||||||
|
queue.Submit(_alphaSource, token, viewerDistance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -598,6 +598,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
private BatchData[] _batchData = new BatchData[256];
|
private BatchData[] _batchData = new BatchData[256];
|
||||||
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
|
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
|
||||||
private CullMode[] _drawCullModes = new CullMode[256];
|
private CullMode[] _drawCullModes = new CullMode[256];
|
||||||
|
|
||||||
|
// Campaign FW stage FW3.2a: SubmitOrderedStream's OWN cull-mode scratch,
|
||||||
|
// separate from _drawCullModes above. See DrawIndirectRangeRhi's doc
|
||||||
|
// comment for why sharing the array made an ordered submission unsafe to
|
||||||
|
// interleave with a mid-flight RetailAlphaQueue scope.
|
||||||
|
private CullMode[] _orderedDrawCullModes = new CullMode[256];
|
||||||
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
|
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
|
||||||
private readonly List<IndirectGroupInput> _groupInputScratch = new(256);
|
private readonly List<IndirectGroupInput> _groupInputScratch = new(256);
|
||||||
private readonly List<GroupKey> _retiredGroupKeys = new();
|
private readonly List<GroupKey> _retiredGroupKeys = new();
|
||||||
|
|
@ -3312,62 +3318,25 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
bool allTexturesReady = true;
|
bool allTexturesReady = true;
|
||||||
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||||||
{
|
{
|
||||||
var batch = renderData.Batches[batchIdx];
|
// Campaign FW stage FW3.2a: the untextured-subset gate, the #188
|
||||||
|
// opacity promotion, texture resolution, and Campaign VM foliage
|
||||||
// #426: retail's D3DPolyRender::DrawMesh skips an UNTEXTURED
|
// classification now live in the one shared core
|
||||||
// (solid-colour) subset only on a BUILDING SHELL
|
// (WbDrawDispatcher.WalkClassify.cs) also used by
|
||||||
// (RenderDeviceD3D::DrawBuilding sets ObjBuildingOrBuildingPart);
|
// ClassifyPackedBatches and the walk classifier — see
|
||||||
// ordinary statics/scenery/creatures/items draw it same as any
|
// TryClassifyBatch's doc comment. `survives=false` still applies
|
||||||
// textured subset. ONE shared predicate with ClassifyPackedBatches
|
// compositePending exactly as before this extraction (a batch
|
||||||
// and AddDirectionalShadowBatches — see RetailUntexturedSubsetPolicy.
|
// that fails the untextured-subset gate never touched
|
||||||
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
|
// ResolveTexture, so compositePending is always false there; a
|
||||||
continue;
|
// batch with an unresolved texture slot still reports it).
|
||||||
|
bool survives = TryClassifyBatch(
|
||||||
TranslucencyKind translucency = batch.Translucency;
|
renderData, batchIdx, in entity, meshRef, paletteIdentity,
|
||||||
|
opacityMultiplier, entityHasCutoutSubset,
|
||||||
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
|
out GroupKey key, out bool compositePending);
|
||||||
// must route through the alpha-blend pass so mesh_modern.frag's
|
|
||||||
// (blend-enabled) shader actually composites the reduced alpha —
|
|
||||||
// the no-blend opaque pass would ignore it.
|
|
||||||
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
|
|
||||||
translucency = TranslucencyKind.AlphaBlend;
|
|
||||||
|
|
||||||
ResolvedTexture texture = ResolveTexture(
|
|
||||||
in entity,
|
|
||||||
meshRef,
|
|
||||||
batch,
|
|
||||||
paletteIdentity,
|
|
||||||
out bool compositePending);
|
|
||||||
if (compositePending)
|
if (compositePending)
|
||||||
allTexturesReady = false;
|
allTexturesReady = false;
|
||||||
// Campaign V slice V4t: an unassigned slot is the "no texture yet"
|
if (!survives)
|
||||||
// case a zero handle used to signal. It is a real sentinel
|
continue;
|
||||||
// (GpuTextureSlot.Unassigned == ACDREAM_TEXTURE_NONE), not the
|
GpuTextureSlot texSlot = key.TextureSlot;
|
||||||
// default value, so nothing here can silently resolve to slot 0.
|
|
||||||
if (!texture.Slot.IsAssigned) continue;
|
|
||||||
GpuTextureSlot texSlot = texture.Slot;
|
|
||||||
uint texLayer = texture.Layer;
|
|
||||||
|
|
||||||
// Campaign VM VM6 review fix round: classify BEFORE constructing
|
|
||||||
// the key and fold the result INTO the key (rather than
|
|
||||||
// stamping it onto whatever group the key already resolves to).
|
|
||||||
// Classification is from the RAW (pre-#188-promotion)
|
|
||||||
// batch.Translucency — a mid-fade trunk is still a trunk, it
|
|
||||||
// just landed in the alpha-blend group instead of opaque. This
|
|
||||||
// is what keeps a scenery instance and a non-scenery instance
|
|
||||||
// of the identical mesh subset in two SEPARATE groups instead of
|
|
||||||
// coalescing into one group whose classification depends on
|
|
||||||
// whichever entity classified it last.
|
|
||||||
uint foliageFlags = FoliageWindClassification.Classify(
|
|
||||||
entity.LocalEntityId,
|
|
||||||
FoliageWindExclusions.Contains(meshRef.GfxObjId),
|
|
||||||
batch.Translucency,
|
|
||||||
entityHasCutoutSubset);
|
|
||||||
var key = new GroupKey(
|
|
||||||
batch.FirstIndex, (int)batch.BaseVertex,
|
|
||||||
batch.IndexCount, texSlot, texLayer, translucency,
|
|
||||||
FoliageFlags: foliageFlags,
|
|
||||||
CullMode: batch.CullMode);
|
|
||||||
|
|
||||||
InstanceGroup grp = GetOrCreateInstanceGroup(key);
|
InstanceGroup grp = GetOrCreateInstanceGroup(key);
|
||||||
grp.Matrices.Add(model);
|
grp.Matrices.Add(model);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,636 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Rendering.Gpu.Vk;
|
||||||
|
using AcDream.App.Rendering.Scene;
|
||||||
|
using AcDream.App.Rendering.Selection;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
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 FW3.2a: the walk→draw population layer's data-
|
||||||
|
/// equivalence referee. Covers <see cref="WbDrawDispatcher.ClassifyEntityForWalk"/>
|
||||||
|
/// (the shared per-entity classify seam), <see cref="WalkStaticStreamPopulator"/>
|
||||||
|
/// (opaque → <see cref="OrderedDrawStream"/>, translucent → the alpha queue,
|
||||||
|
/// selection publish), and the FW3.2a own-cull-scratch fix to
|
||||||
|
/// <c>SubmitOrderedStream</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkStaticStreamPopulatorTests
|
||||||
|
{
|
||||||
|
// ── Test doubles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class RecordingSelectionSink : IRetailSelectionRenderSink
|
||||||
|
{
|
||||||
|
public readonly List<(uint ServerGuid, uint LocalEntityId, int PartIndex, uint GfxObjId, Matrix4x4 LocalToWorld)>
|
||||||
|
Calls = new();
|
||||||
|
|
||||||
|
public void AddVisiblePart(
|
||||||
|
uint serverGuid, uint localEntityId, int partIndex, uint gfxObjId, Matrix4x4 partWorld) =>
|
||||||
|
Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Synthetic RenderProjectionRecord construction ──────────────────────
|
||||||
|
|
||||||
|
private static RenderProjectionRecord MakeRecord(
|
||||||
|
uint localEntityId,
|
||||||
|
uint serverGuid,
|
||||||
|
Vector3 position,
|
||||||
|
IReadOnlyList<MeshRef> meshRefs,
|
||||||
|
bool isBuildingShell = false,
|
||||||
|
uint parentCellId = 0u) =>
|
||||||
|
new(
|
||||||
|
Id: RenderProjectionId.FromRaw(localEntityId),
|
||||||
|
ProjectionClass: RenderProjectionClass.OutdoorStatic,
|
||||||
|
OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1),
|
||||||
|
Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)),
|
||||||
|
PreviousTransform: default,
|
||||||
|
MeshSet: default,
|
||||||
|
Material: default,
|
||||||
|
Residency: default,
|
||||||
|
Bounds: default,
|
||||||
|
Flags: RenderProjectionFlags.Draw,
|
||||||
|
DegradeState: default,
|
||||||
|
SortKey: new RenderSortKey(0),
|
||||||
|
DirtyMask: default,
|
||||||
|
Source: new RenderSourceMetadata(
|
||||||
|
LocalEntityId: localEntityId,
|
||||||
|
ServerGuid: serverGuid,
|
||||||
|
SourceId: 0,
|
||||||
|
ParentCellId: parentCellId,
|
||||||
|
EffectCellId: 0,
|
||||||
|
BuildingShellAnchorCellId: 0,
|
||||||
|
TransformFingerprint: default,
|
||||||
|
GeometryFingerprint: default,
|
||||||
|
AppearanceFingerprint: default),
|
||||||
|
EntityPayload: new RenderEntityPayload(
|
||||||
|
MeshRefs: meshRefs,
|
||||||
|
PaletteOverride: null,
|
||||||
|
IsBuildingShell: isBuildingShell));
|
||||||
|
|
||||||
|
private static ObjectRenderBatch MakeBatch(
|
||||||
|
uint surfaceId,
|
||||||
|
TranslucencyKind translucency,
|
||||||
|
uint firstIndex,
|
||||||
|
int baseVertex,
|
||||||
|
int indexCount,
|
||||||
|
uint textureSlotIndex,
|
||||||
|
uint textureLayer = 0,
|
||||||
|
CullMode cullMode = CullMode.CounterClockwise) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false },
|
||||||
|
Translucency = translucency,
|
||||||
|
FirstIndex = firstIndex,
|
||||||
|
BaseVertex = (uint)baseVertex,
|
||||||
|
IndexCount = indexCount,
|
||||||
|
TextureSlot = new GpuTextureSlot(textureSlotIndex),
|
||||||
|
TextureIndex = (int)textureLayer,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
|
||||||
|
new() { Batches = new List<ObjectRenderBatch>(batches) };
|
||||||
|
|
||||||
|
// ── Reflection seam: ObjectMeshManager owns no test-injection API, and
|
||||||
|
// driving real GPU/GfxObj upload for a unit test is out of this stage's
|
||||||
|
// scope — ObjectRenderData/ObjectRenderBatch are plain settable classes,
|
||||||
|
// so this seeds the manager's private cache directly. ──────────────────
|
||||||
|
|
||||||
|
private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data)
|
||||||
|
{
|
||||||
|
FieldInfo field = typeof(ObjectMeshManager).GetField(
|
||||||
|
"_renderData", BindingFlags.NonPublic | BindingFlags.Instance)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"ObjectMeshManager._renderData field not found — test relies on this exact name.");
|
||||||
|
var dict = (ConcurrentDictionary<ulong, ObjectRenderData>)field.GetValue(manager)!;
|
||||||
|
dict[id] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deliverable 1: ClassifyEntityForWalk data equivalence ─────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyEntityForWalk_OneOpaqueAndOneTranslucentPart_YieldsBatchesInRecordOrderWithCorrectIsOpaque()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
const ulong opaqueGfxObj = 0x0100_0001UL;
|
||||||
|
const ulong alphaGfxObj = 0x0100_0002UL;
|
||||||
|
InjectRenderData(fx.Manager, opaqueGfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000001u, TranslucencyKind.Opaque, firstIndex: 0, baseVertex: 0, indexCount: 3, textureSlotIndex: 1)));
|
||||||
|
InjectRenderData(fx.Manager, alphaGfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, indexCount: 6, textureSlotIndex: 2)));
|
||||||
|
|
||||||
|
var meshRefs = new[]
|
||||||
|
{
|
||||||
|
new MeshRef((uint)opaqueGfxObj, Matrix4x4.CreateTranslation(1, 0, 0)),
|
||||||
|
new MeshRef((uint)alphaGfxObj, Matrix4x4.CreateTranslation(0, 1, 0)),
|
||||||
|
};
|
||||||
|
RenderProjectionRecord record = MakeRecord(
|
||||||
|
localEntityId: 100, serverGuid: 0, position: new Vector3(5, 6, 7), meshRefs);
|
||||||
|
|
||||||
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
||||||
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
||||||
|
fx.Dispatcher.ClassifyEntityForWalk(in record, tupleLandblockId: 0x8C04u, batches, selectionParts);
|
||||||
|
|
||||||
|
Assert.Equal(2, batches.Count);
|
||||||
|
|
||||||
|
WbDrawDispatcher.WalkClassifiedBatch opaque = batches[0];
|
||||||
|
Assert.True(opaque.IsOpaque);
|
||||||
|
Assert.Equal(TranslucencyKind.Opaque, opaque.Key.Translucency);
|
||||||
|
Assert.Equal(0u, opaque.Key.FirstIndex);
|
||||||
|
Assert.Equal(3, opaque.Key.IndexCount);
|
||||||
|
Assert.Equal(1u, opaque.Key.TextureSlot.Index);
|
||||||
|
Assert.Equal(1f, opaque.Alpha);
|
||||||
|
Assert.Equal(meshRefs[0].PartTransform * record.Transform.LocalToWorld, opaque.Transform);
|
||||||
|
|
||||||
|
WbDrawDispatcher.WalkClassifiedBatch translucent = batches[1];
|
||||||
|
Assert.False(translucent.IsOpaque);
|
||||||
|
Assert.Equal(TranslucencyKind.AlphaBlend, translucent.Key.Translucency);
|
||||||
|
Assert.Equal(3u, translucent.Key.FirstIndex);
|
||||||
|
Assert.Equal(6, translucent.Key.IndexCount);
|
||||||
|
Assert.Equal(2u, translucent.Key.TextureSlot.Index);
|
||||||
|
Assert.Equal(meshRefs[1].PartTransform * record.Transform.LocalToWorld, translucent.Transform);
|
||||||
|
|
||||||
|
Assert.Equal(2, selectionParts.Count);
|
||||||
|
Assert.Equal(100u, selectionParts[0].LocalEntityId);
|
||||||
|
Assert.Equal(0, selectionParts[0].PartIndex);
|
||||||
|
Assert.Equal((uint)opaqueGfxObj, selectionParts[0].GfxObjId);
|
||||||
|
Assert.Equal(opaque.Transform, selectionParts[0].LocalToWorld);
|
||||||
|
Assert.Equal(1, selectionParts[1].PartIndex);
|
||||||
|
Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
const ulong setupGfxObj = 0x1000_0010UL;
|
||||||
|
const ulong trunkGfxObj = 0x0100_0011UL;
|
||||||
|
const ulong leavesGfxObj = 0x0100_0012UL;
|
||||||
|
|
||||||
|
InjectRenderData(fx.Manager, trunkGfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000011u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
||||||
|
InjectRenderData(fx.Manager, leavesGfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000012u, TranslucencyKind.ClipMap, 3, 4, 6, 2)));
|
||||||
|
InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData
|
||||||
|
{
|
||||||
|
IsSetup = true,
|
||||||
|
SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>
|
||||||
|
{
|
||||||
|
(trunkGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)),
|
||||||
|
(leavesGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var meshRefs = new[] { new MeshRef((uint)setupGfxObj, Matrix4x4.Identity) };
|
||||||
|
RenderProjectionRecord record = MakeRecord(200, 0, Vector3.Zero, meshRefs);
|
||||||
|
|
||||||
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
||||||
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
||||||
|
fx.Dispatcher.ClassifyEntityForWalk(in record, 0x8C04u, batches, selectionParts);
|
||||||
|
|
||||||
|
Assert.Equal(2, batches.Count);
|
||||||
|
Assert.Equal(2, selectionParts.Count);
|
||||||
|
// partIndex=0 (the entity's single top-level MeshRef) << 16 | setupPartIndex.
|
||||||
|
Assert.Equal(0, selectionParts[0].PartIndex);
|
||||||
|
Assert.Equal(1, selectionParts[1].PartIndex);
|
||||||
|
Assert.Equal((uint)trunkGfxObj, selectionParts[0].GfxObjId);
|
||||||
|
Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deliverable 2: WalkStaticStreamPopulator routing ───────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PopulateCell_OpaqueBatchAppendsOrderedDrawCommandInRecordOrderWithStageAndCellProvenance()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
const ulong gfxObj = 0x0100_0003UL;
|
||||||
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000003u, TranslucencyKind.Opaque, 10, 2, 12, 5)));
|
||||||
|
|
||||||
|
var record = MakeRecord(300, 0, new Vector3(1, 2, 3), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
||||||
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
populator.PopulateCell(
|
||||||
|
stream, WalkDrawStage.CellStatic, cellId: 0x8C040100u,
|
||||||
|
new[] { record }, tupleLandblockId: 0x8C04u,
|
||||||
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal(1, stream.Count);
|
||||||
|
Assert.Equal(WalkDrawStage.CellStatic, stream.Stages[0]);
|
||||||
|
Assert.Equal(0x8C040100u, stream.CellIds[0]);
|
||||||
|
Assert.Equal(10u, stream.Keys[0].FirstIndex);
|
||||||
|
Assert.Equal(12, stream.Keys[0].IndexCount);
|
||||||
|
Assert.Equal(1f, stream.Alphas[0]);
|
||||||
|
Assert.Equal(record.Transform.LocalToWorld, stream.Transforms[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PopulateOutdoorStatics_UsesTheOutdoorStaticStage()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
const ulong gfxObj = 0x0100_0004UL;
|
||||||
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000004u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
||||||
|
|
||||||
|
var record = MakeRecord(400, 0, Vector3.Zero, new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
||||||
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
populator.PopulateOutdoorStatics(
|
||||||
|
stream, cellId: 0x8C040000u, new[] { record }, tupleLandblockId: 0x8C04u,
|
||||||
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal(1, stream.Count);
|
||||||
|
Assert.Equal(WalkDrawStage.OutdoorStatic, stream.Stages[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PopulateCell_TranslucentBatchDoesNotAppendToTheStreamAndReachesTheAlphaQueue()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
||||||
|
const ulong gfxObj = 0x0100_0005UL;
|
||||||
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000005u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
|
||||||
|
|
||||||
|
var record = MakeRecord(500, 0, new Vector3(0, 0, 10), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
||||||
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
fx.AlphaQueue!.BeginFrame();
|
||||||
|
populator.PopulateCell(
|
||||||
|
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
|
||||||
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal(0, stream.Count);
|
||||||
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
||||||
|
fx.AlphaQueue.AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PopulateCell_PublishesSelectionPartsForEveryClassifiedEntity()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSelectionSink();
|
||||||
|
using var fx = new DispatcherFixture(selectionSink: sink);
|
||||||
|
const ulong gfxObj = 0x0100_0006UL;
|
||||||
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08000006u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
||||||
|
|
||||||
|
var record = MakeRecord(600, serverGuid: 0x8000_0060u, new Vector3(1, 1, 1),
|
||||||
|
new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
||||||
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
|
||||||
|
populator.PopulateCell(
|
||||||
|
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
|
||||||
|
Vector3.Zero, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
var call = Assert.Single(sink.Calls);
|
||||||
|
Assert.Equal(0x8000_0060u, call.ServerGuid);
|
||||||
|
Assert.Equal(600u, call.LocalEntityId);
|
||||||
|
Assert.Equal(0, call.PartIndex);
|
||||||
|
Assert.Equal((uint)gfxObj, call.GfxObjId);
|
||||||
|
Assert.Equal(record.Transform.LocalToWorld, call.LocalToWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitWalkAlphaInstance: same viewer distance + per-instance data as
|
||||||
|
// DeferTransparentGroups, through the REAL RetailAlphaQueue. ───────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitWalkAlphaInstance_SubmitsTheSameViewerDistanceComputeViewerDistanceWouldProduce()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
||||||
|
fx.AlphaQueue!.BeginFrame();
|
||||||
|
|
||||||
|
var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
|
||||||
|
Vector3 localSortCenter = new(1, 2, 3);
|
||||||
|
Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6);
|
||||||
|
var cameraWorldPosition = Vector3.Zero;
|
||||||
|
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
|
||||||
|
key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1,
|
||||||
|
Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1,
|
||||||
|
IsOpaque: false, LocalSortCenter: localSortCenter);
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
||||||
|
|
||||||
|
// Same call DeferTransparentGroups makes per instance — independently
|
||||||
|
// computed here so the assertion cannot pass by construction.
|
||||||
|
float expectedDistance = RetailAlphaOrdering.ComputeViewerDistance(
|
||||||
|
localSortCenter, model, cameraWorldPosition);
|
||||||
|
|
||||||
|
FieldInfo submissionsField = typeof(RetailAlphaQueue).GetField(
|
||||||
|
"_submissions", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||||
|
var submissions = (List<RetailAlphaSubmission>)submissionsField.GetValue(fx.AlphaQueue)!;
|
||||||
|
RetailAlphaSubmission submission = Assert.Single(submissions);
|
||||||
|
Assert.Equal(expectedDistance, submission.ViewerDistance, precision: 4);
|
||||||
|
Assert.Equal(0, submission.Token);
|
||||||
|
|
||||||
|
fx.AlphaQueue.AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
||||||
|
fx.AlphaQueue!.BeginFrame();
|
||||||
|
|
||||||
|
var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
|
||||||
|
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
|
||||||
|
key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f,
|
||||||
|
Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Vector3.Zero, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
fx.Dispatcher.SubmitWalkAlphaInstance(
|
||||||
|
in batch, Vector3.Zero, Matrix4x4.CreateTranslation(1, 0, 0)));
|
||||||
|
|
||||||
|
fx.AlphaQueue.AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deliverable 3: SubmitOrderedStream's own cull scratch ──────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
|
||||||
|
// Poison the SHARED _drawCullModes scratch the alpha path owns — the
|
||||||
|
// exact array SubmitOrderedStream used to write into before FW3.2a.
|
||||||
|
// Under the OLD shared-scratch behavior this test's second assertion
|
||||||
|
// fails: SubmitOrderedStream's own command overwrites index 0 with
|
||||||
|
// its own cull mode (Clockwise), destroying the alpha path's poison.
|
||||||
|
FieldInfo field = typeof(WbDrawDispatcher).GetField(
|
||||||
|
"_drawCullModes", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||||
|
var poisonModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
|
||||||
|
poisonModes[0] = CullMode.None;
|
||||||
|
|
||||||
|
var stream = new OrderedDrawStream();
|
||||||
|
stream.Append(new OrderedDrawCommand(
|
||||||
|
new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise),
|
||||||
|
Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0,
|
||||||
|
WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0));
|
||||||
|
|
||||||
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
// (1) The ordered submission's OWN recorded cull call reflects the
|
||||||
|
// STREAM's cull mode (Clockwise -> GpuCullMode.Front), not the
|
||||||
|
// poisoned shared array's (None).
|
||||||
|
List<GpuCullMode> cullCalls = [.. fx.Device.Calls.OfType<GpuRecordedCullMode>().Select(c => c.CullMode)];
|
||||||
|
Assert.Equal([GpuCullMode.Front], cullCalls);
|
||||||
|
|
||||||
|
// (2) The shared _drawCullModes scratch is UNTOUCHED — the ordered
|
||||||
|
// path never wrote through it.
|
||||||
|
var afterModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
|
||||||
|
Assert.Equal(CullMode.None, afterModes[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fixture ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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(
|
||||||
|
bool withAlphaQueue = false,
|
||||||
|
IRetailSelectionRenderSink? selectionSink = null)
|
||||||
|
{
|
||||||
|
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 these tests."));
|
||||||
|
AlphaQueue = withAlphaQueue ? new RetailAlphaQueue() : null;
|
||||||
|
|
||||||
|
Dispatcher = new WbDrawDispatcher(
|
||||||
|
Device,
|
||||||
|
FrameLifetime,
|
||||||
|
Scope,
|
||||||
|
_textures,
|
||||||
|
_meshAdapter,
|
||||||
|
entitySpawnAdapter,
|
||||||
|
new EntityClassificationCache(),
|
||||||
|
new AcDream.Core.Rendering.TranslucencyFadeManager(),
|
||||||
|
selectionSink: selectionSink,
|
||||||
|
alphaQueue: AlphaQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RecordingGpuDevice Device { get; }
|
||||||
|
|
||||||
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
||||||
|
|
||||||
|
public VulkanWorldPassScope Scope { get; }
|
||||||
|
|
||||||
|
public WbDrawDispatcher Dispatcher { get; }
|
||||||
|
|
||||||
|
public RetailAlphaQueue? AlphaQueue { get; }
|
||||||
|
|
||||||
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
||||||
|
|
||||||
|
public DrawScope BeginDraw()
|
||||||
|
{
|
||||||
|
FrameLifetime.BeginFrame();
|
||||||
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
||||||
|
IGpuPassEncoder pass = frame.BeginPass(
|
||||||
|
GpuPassDescription.BackbufferClear(
|
||||||
|
"fw3-2a-walk-populator-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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue