fix(render): restore retail per-cell alpha order

Reconstruct one combined static/dynamic object-part stream for each ordinary outdoor or interior cell, compute authored SortCenter CYpt keys, and stable-sort far to near before projecting opaque and delayed subsets. Prepare real cell-particle records at the leaf, preserve every S4-c2 router outcome, and merge object and particle delayed records by retained key before either source appends to the unchanged CLIP/ALPHA FIFO lists. Cell turns remain cell-major; equal cross-source ties are deterministically object-first.

File AP-241 and AP-242 for the remaining separate opaque/row-5 channels and unrepresented equal-key common ordinal. File AP-243 for the paired-binary correction: retail shares the cell CYpt/heading beyond 50 m while this bounded port always uses the more exact authored per-part center. Pin 162 active AP rows and correct world-alpha and AlphaFlushCounts prose.

Lead-approved scope clarification: RetailPViewPassExecutor.WalkLeaf.cs and RetailPViewPassExecutor.cs are the minimum existing production leaf adapter and thin particle-prepare forwarder omitted by the literal Walk/Wb file list. They contain no router, queue, mask, state, depth, or flush behavior; relocating them would create an artificial seam.

Gates: Release solution build 0W/0E; shader/manifest 32/32; focused production 210/210; real allocation 3/3 at 0 B; one-shot hermetic 16743/0/0 across 14 assemblies; InstalledDat 385 pass/10 documented fail/1 skip with all six AlphaFlushSites passing; git diff --check PASS. Initial no-restore solution build failed NETSDK1004 for 42 missing scratch assets; one solution restore preceded the official build.

Mutation proof, each restored before final gates:

1. Reverse comparator: authored-center order expected [202,101], actual [101,202].

2. Move ties left: multipart/subset order expected [11,12,21,22], actual [22,21,12,11].

3. Restore static/dynamic blocks: expected [2,3,1], actual [3,1,2].

4. Use entity origin: authored-center order expected [202,101], actual [101,202].

5. Restore particle tail: expected [Wb,Particle,Wb,Particle], actual [Wb,Wb,Particle,Particle].

6. Scope-global sort: first cell model X expected 5, actual 50.

7. Restore dead camera parameter: SubmitWalkAlphaInstance parameter count expected 2, actual 3.

8. Restore stale global-queue prose: exact Assert.DoesNotContain failure on distance-sorts one shared queue.

9. Remove AP-241 identity: Assert.Single found no matching row.

10. Allocate in real merge: expected 0 B, actual 3072 B.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-04 13:18:54 +02:00
parent 06b986622b
commit a86ec73ece
17 changed files with 1067 additions and 197 deletions

View file

@ -18,8 +18,10 @@ namespace AcDream.App.Rendering;
/// <summary>
/// Instanced renderer for retail particle emitters. Scene particles submit to
/// <see cref="RetailAlphaQueue"/> while a world frame is active so their
/// compositing order is shared with ordinary translucent GfxObj parts. Sky and
/// sealed off-screen passes retain their independent immediate path.
/// compositing order is merged by authored CYpt with ordinary translucent
/// GfxObj parts at each owning cell turn before either source enters the two
/// FIFO lists. Sky and sealed off-screen passes retain their independent
/// immediate path.
/// </summary>
public sealed unsafe partial class ParticleRenderer : IDisposable
{
@ -163,6 +165,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
private readonly List<ParticleSubmission> _submissionScratch = new(128);
private readonly List<PreparedParticleAlphaSubmission> _preparedCellAlphaScratch = new(128);
private readonly List<RuntimeParticleEmitter> _scopedEmitterScratch = new(64);
private readonly List<DeferredParticleDraw> _deferredAlpha = new(128);
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
@ -315,6 +318,105 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
FinishDraw(camera, renderPass);
}
/// <summary>
/// S4-c3a production cell-shadow seam. Builds the same real billboard and
/// mesh records as <see cref="DrawForCell"/>, orders particle parts by
/// their authored CYpt key, executes row-5 immediate draws at this exact
/// cell turn, and returns only delayed records without appending them.
/// The walk driver merges those retained keys with ordinary object parts
/// before calling <see cref="PreparedParticleAlphaSubmission.Append"/>.
/// </summary>
internal ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareForCellAlpha(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
uint cellId,
uint clipSlot = 0)
{
_preparedCellAlphaScratch.Clear();
if (camera is null)
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
_particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch);
if (_scopedEmitterScratch.Count == 0)
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(
cameraWorldPos,
renderPass,
cameraRight,
cameraUp,
emitterFilter: null,
_scopedEmitterScratch,
clipSlot);
if (_submissionScratch.Count == 0)
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
bool defers = renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true;
if (!defers)
{
DrawOrdered(camera);
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
}
ParticleSubmissionOrdering.Sort(_submissionScratch);
Matrix4x4 viewProjection = camera.View * camera.Projection;
RetailAlphaQueue queue = _alphaQueue!;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
TranslucencyKind translucency = submission.Kind == ParticleSubmissionKind.Mesh
? _meshDrawListScratch[submission.DrawIndex].Batch.Translucency
: default;
uint colorArgb = submission.Kind == ParticleSubmissionKind.Mesh
? _meshDrawListScratch[submission.DrawIndex].Instance.ColorArgb
: default;
RetailAlphaMeshDecision decision = RouteParticleSubmission(
submission.Kind, translucency, colorArgb);
if (decision.Action is RetailAlphaMeshAction.Append
or RetailAlphaMeshAction.AppendClipAndImmediate)
{
_dispatchDeferredParticle = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = ReserveDispatchDeferredParticle();
_preparedCellAlphaScratch.Add(new PreparedParticleAlphaSubmission(
queue,
decision.List,
_alphaSource,
token,
decision.OverrideClipmap,
submission.DistanceSq,
submission.Sequence));
}
if (decision.Action is RetailAlphaMeshAction.Immediate
or RetailAlphaMeshAction.AppendClipAndImmediate)
{
_drawImmediateParticle(
viewProjection,
submission.Kind,
submission.DrawIndex,
opaqueDepthState: decision.Action == RetailAlphaMeshAction.Immediate);
}
}
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{
if (_submissionScratch.Count == 0)
@ -738,11 +840,10 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
// S4-c2: RetailAlphaOrdering.ComputeViewerDistance is deleted (the
// alpha queue is FIFO now), but this distanceSq is still needed by
// the UNRELATED non-deferred immediate-draw path's own local sort
// (ParticleSubmissionOrdering.Sort, ParticleRenderer.Rhi.cs) —
// inlined here rather than resurrecting the deleted helper.
// The queue remains FIFO. S4-c3a retains this authored part-center
// distance for the owning cell's object/particle pre-append merge;
// the unrelated non-deferred immediate path uses the same key for its
// own local ParticleSubmissionOrdering.Sort.
Vector3 worldSortCenter = Vector3.Transform(renderData.SortCenter, model);
float distanceSq = Vector3.DistanceSquared(worldSortCenter, cameraWorldPosition);
var instance = new MeshParticleInstance(

View file

@ -16,6 +16,27 @@ internal readonly record struct ParticleSubmission(
float DistanceSq,
int Sequence);
/// <summary>
/// One scene-particle alpha record prepared at its owning cell turn but not
/// yet appended. S4-c3a lets <c>WalkFrameDriver</c> merge this retained CYpt
/// key with ordinary object parts before either source enters retail's FIFO
/// list. The source payload is reserved during preparation; <see
/// cref="RetailAlphaQueue.TryAppend"/> remains the sole visibility edge and
/// still owns capacity-drop cleanup by registering the source before a drop.
/// </summary>
internal readonly record struct PreparedParticleAlphaSubmission(
RetailAlphaQueue Queue,
RetailAlphaList List,
IRetailAlphaDrawSource Source,
int Token,
bool OverrideClipmap,
float DistanceSq,
int Sequence)
{
internal void Append() =>
Queue.TryAppend(List, Source, Token, OverrideClipmap);
}
/// <summary>
/// Shared ordering for the two retail particle geometry paths. Transparent
/// particles are submitted back-to-front; creation/enumeration order is the

View file

@ -89,9 +89,9 @@ internal sealed partial class RetailPViewPassExecutor
/// <summary>S3 chunk 3 fix round 1 (F2): the production
/// <c>IWalkFrameLeafRenderer.HasRenderableEmittersInCell</c> wiring —
/// both <see cref="DrawLandscapeStaticParticles"/> and
/// <see cref="DrawCellParticles"/> draw through
/// <c>ParticleRenderPass.Scene</c>, so this asks that SAME pass.</summary>
/// both walk particle-prepare leaves use
/// <c>ParticleRenderPass.Scene</c>, so this asks that SAME pass before
/// either leaf constructs its real delayed records.</summary>
internal bool HasWalkRenderableEmittersInCell(uint cellId) =>
_particles?.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId) ?? false;
@ -271,11 +271,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void ClearInteriorDepth() => _clearInteriorDepth();
public void DrawStaticParticles(uint cellId) =>
_passes.DrawLandscapeStaticParticles(_frame, cellId);
public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId) =>
_passes.PrepareCellParticleAlpha(_frame, cellId);
public void DrawCellParticles(uint cellId) =>
_passes.DrawCellParticles(_frame, cellId);
public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId) =>
_passes.PrepareCellParticleAlpha(_frame, cellId);
public int DrawExitSeals() => _drawExitSeals();

View file

@ -555,6 +555,21 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
}
}
internal ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticleAlpha(
RetailPViewFrameInput frame,
uint cellId)
{
if (_particles is null || _particleRenderer is null)
return ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
return _particleRenderer.PrepareForCellAlpha(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
cellId,
clipSlot: 0);
}
public void ClearInteriorDepth()
{
_surface.ClearInteriorDepth();

View file

@ -42,6 +42,15 @@ internal readonly record struct WalkFrameStaticRecords(
/// </summary>
internal interface IWalkFrameWorldData
{
/// <summary>The exact combined ordinary-object membership for one indoor
/// cell, preserving the registry's single static/dynamic insertion
/// sequence for S4-c3a's per-part stable CYpt sort.</summary>
WalkFrameStaticRecords GetCellObjects(uint cellId);
/// <summary>The exact combined ordinary-object membership for one
/// landscape cell; see <see cref="GetCellObjects"/>.</summary>
WalkFrameStaticRecords GetOutdoorObjects(uint cellId);
/// <summary>An indoor <c>PView::DrawCells</c> flood cell's (or a building
/// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary>
WalkFrameStaticRecords GetCellStatics(uint cellId);
@ -113,8 +122,8 @@ internal interface IWalkFrameLeafRenderer
/// pipeline-state citation. A <see cref="WalkFrameEventKind.StreamMark"/>
/// is NOT one of them (F10: the ordered stream it draws is opaque-only,
/// depth test AND write on, no blending); the F2 particle-turn exception
/// still holds (a genuinely empty <see cref="DrawStaticParticles"/>/<see
/// cref="DrawCellParticles"/> turn neither submits nor flushes). This is
/// still holds (a genuinely empty <see cref="PrepareStaticParticles"/>/<see
/// cref="PrepareCellParticles"/> turn neither submits nor flushes). This is
/// order-preserving by construction: the batch's GPU submission point is
/// always the SAME point the unbatched terrain draws would have occupied
/// (immediately before the next real submission), so pixel output is
@ -133,10 +142,10 @@ internal interface IWalkFrameLeafRenderer
/// name="cellId"/> has any renderable emitter right now — production
/// wires this to <c>ParticleSystem.HasRenderableEmittersInCell</c> with
/// the SAME <c>ParticleRenderPass.Scene</c> pass both <see
/// cref="DrawStaticParticles"/> and <see cref="DrawCellParticles"/>
/// already draw through. <see cref="WalkFrameDriver.Replay"/> asks this
/// BEFORE flushing the pending terrain batch (or calling either particle
/// leaf) so a genuinely empty particle turn submits nothing and does not
/// cref="PrepareStaticParticles"/> and <see cref="PrepareCellParticles"/>
/// prepare through. <see cref="WalkFrameDriver.Replay"/> asks this BEFORE
/// flushing the pending terrain batch (or preparing either particle leaf)
/// so a genuinely empty particle turn submits nothing and does not
/// force an otherwise-unneeded terrain flush.</summary>
bool HasRenderableEmittersInCell(uint cellId);
@ -157,12 +166,12 @@ internal interface IWalkFrameLeafRenderer
/// (0x00514a70) draws an emitter at its own current cell's object turn
/// regardless of its attached owner's registry membership, so this is a
/// cell lookup, not an owner union.</summary>
void DrawStaticParticles(uint cellId);
ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId);
/// <summary>Submits one indoor cell's own-cell particle emitters at that
/// cell's own object-list turn. Meshes have already entered the ordered
/// stream before this leaf event.</summary>
void DrawCellParticles(uint cellId);
ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId);
/// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear
/// (pc:432731-432732) between the outside stage and the interior root's
@ -347,7 +356,7 @@ internal enum WalkFrameEventKind : byte
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
ExitSeals,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawStaticParticles"/> —
/// <summary><see cref="IWalkFrameLeafRenderer.PrepareStaticParticles"/> —
/// ONE landscape (land) cell's (<see cref="WalkFrameEvent.CellId"/>)
/// own-cell particle emitters, emitted AT ITS OWN WALK TURN. Retail's
/// falls containment is positional: an outdoor emitter's polys join the
@ -368,7 +377,7 @@ internal enum WalkFrameEventKind : byte
/// owns one shadow in its OWN current cell, never its parent's).</summary>
StaticParticles,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellParticles"/> —
/// <summary><see cref="IWalkFrameLeafRenderer.PrepareCellParticles"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Fires at EVERY
/// visited interior cell's own object-list turn regardless of whether
/// that cell has any visible static/dynamic owner record: an emitter
@ -481,11 +490,23 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent AlphaBarrier() =>
new(WalkFrameEventKind.AlphaBarrier, 0, 0, 0f, null);
internal static WalkFrameEvent LandscapeCellParticles(uint cellId) =>
new(WalkFrameEventKind.StaticParticles, 0, cellId, 0f, null);
internal static WalkFrameEvent LandscapeCellParticles(
uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
new(
WalkFrameEventKind.StaticParticles,
alphaExclusiveEnd,
cellId,
includeParticles ? 1f : 0f,
null);
internal static WalkFrameEvent CellParticles(uint cellId) =>
new(WalkFrameEventKind.CellParticles, 0, cellId, 0f, null);
internal static WalkFrameEvent CellParticles(
uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
new(
WalkFrameEventKind.CellParticles,
alphaExclusiveEnd,
cellId,
includeParticles ? 1f : 0f,
null);
internal static WalkFrameEvent LandscapeFlush() =>
new(WalkFrameEventKind.LandscapeFlush, 0, 0, 0f, null);
@ -1138,7 +1159,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_alphaSubmissions[alphaCursor];
_dispatcher.SubmitWalkAlphaInstance(
in batch,
_cameraWorldPosition,
_viewProjection);
}
break;
@ -1205,18 +1225,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// current cell, drawn at that cell's object turn
// regardless of any owner's registry membership —
// this is a cell lookup, not an owner union.
if (_leafRenderer.HasRenderableEmittersInCell(e.CellId))
{
FlushPendingTerrainBatch();
_leafRenderer.DrawStaticParticles(e.CellId);
}
SubmitCellAlpha(
e,
ref alphaCursor,
staticParticleTurn: true);
break;
case WalkFrameEventKind.CellParticles:
if (_leafRenderer.HasRenderableEmittersInCell(e.CellId))
{
FlushPendingTerrainBatch();
_leafRenderer.DrawCellParticles(e.CellId);
}
SubmitCellAlpha(
e,
ref alphaCursor,
staticParticleTurn: false);
break;
}
}
@ -1231,13 +1249,62 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_pendingTerrainBatch.Clear();
_pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
_readyToReplay = false;
_ctx = null;
}
}
/// <summary>
/// S4-c3a: completes one retail object-cell turn. Object delayed subsets
/// are already in stable far→near part order. Real particle records are
/// prepared only now, then merged by their retained CYpt before either
/// source appends to the two untouched FIFO queue lists. Object wins an
/// exactly equal cross-source key; AP-242 records that deterministic
/// source tie because acdream has no shared physical registration
/// ordinal. The event always drains its object range, including a cell
/// with no particle emitters.
/// </summary>
private void SubmitCellAlpha(
in WalkFrameEvent e,
ref int alphaCursor,
bool staticParticleTurn)
{
ReadOnlySpan<PreparedParticleAlphaSubmission> particles =
ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
bool includeParticles = e.FloatArg != 0f;
if (includeParticles && _leafRenderer.HasRenderableEmittersInCell(e.CellId))
{
// Preserve the existing particle-turn terrain boundary: a row-5
// immediate mesh may draw during preparation.
FlushPendingTerrainBatch();
particles = staticParticleTurn
? _leafRenderer.PrepareStaticParticles(e.CellId)
: _leafRenderer.PrepareCellParticles(e.CellId);
}
int objectEnd = e.IntArg;
int particleIndex = 0;
while (alphaCursor < objectEnd || particleIndex < particles.Length)
{
bool takeParticle = particleIndex < particles.Length
&& (alphaCursor >= objectEnd
|| particles[particleIndex].DistanceSq
> _alphaSubmissions[alphaCursor].SortDistanceSq);
if (takeParticle)
{
particles[particleIndex++].Append();
}
else
{
WbDrawDispatcher.WalkClassifiedBatch batch =
_alphaSubmissions[alphaCursor++];
_dispatcher.SubmitWalkAlphaInstance(in batch, _viewProjection);
}
}
}
/// <summary>S3 chunk 3 fix round 1 (F2), round 2 (F10): submits the
/// pending terrain batch as ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
/// call, if it holds anything, and clears it. Called immediately before
@ -1446,23 +1513,18 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
+ "view set — the walk and draw driver are desynchronized.");
}
VisitedLandscapeCellIds.Add(cellId);
WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId);
WalkFrameStaticRecords dynamics = _worldData.GetOutdoorDynamics(cellId);
_populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex,
alphaSubmissions: _alphaSubmissions);
_populator.PopulateCellDynamics(
_stream, cellId, dynamics.Records, dynamics.TupleLandblockId,
_cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex,
alphaSubmissions: _alphaSubmissions);
if (_alphaSubmissions.Count != _alphaSubmitMark)
{
MarkIfGrown();
MarkAlphaIfGrown();
}
WalkFrameStaticRecords records = _worldData.GetOutdoorObjects(cellId);
_populator.PopulateCellObjects(
_stream,
WalkDrawStage.OutdoorStatic,
cellId,
records.Records,
records.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
_landscapeViewRouteIndex,
_alphaSubmissions);
// FW4 (the #132 positional invariant): this cell's emitters submit
// AT THIS TURN, so nearer buildings' pre-punch barriers drain them
// against still-true depth — see WalkFrameEventKind.StaticParticles.
@ -1473,7 +1535,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// has any visible static/dynamic owner record (Campaign OVERHAUL S2
// chunk 6 — a suspended/hidden owner's emitter must still show).
MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId));
bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
_alphaSubmitMark = _alphaSubmissions.Count;
_events.Add(WalkFrameEvent.LandscapeCellParticles(
cellId,
_alphaSubmitMark,
includeParticles));
}
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's
@ -1837,13 +1904,19 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// The shell loop captured the exact live portal_view for this cell.
// Static and dynamic object lists reuse that same route: retail's
// DrawEnvCell and DrawObjCell consume the same PortalList.
WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId);
_populator.PopulateCell(
_stream, stage, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection,
this, viewRouteIndex, _alphaSubmissions);
WalkFrameStaticRecords records = _worldData.GetCellObjects(cellId);
_populator.PopulateCellObjects(
_stream,
stage,
cellId,
records.Records,
records.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
viewRouteIndex,
_alphaSubmissions);
MarkIfGrown();
MarkAlphaIfGrown();
if (stage == WalkDrawStage.LookInStatic)
{
@ -1851,19 +1924,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
LookInCells.Add(cellId);
}
WalkFrameStaticRecords dynamics = _worldData.GetCellDynamics(cellId);
_populator.PopulateCellDynamics(
_stream,
cellId,
dynamics.Records,
dynamics.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
viewRouteIndex,
alphaSubmissions: _alphaSubmissions);
MarkIfGrown();
MarkAlphaIfGrown();
// Fires for every cell turn regardless of whether that cell has any
// visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
// a suspended/hidden owner's emitter must still show), but ONCE per
@ -1873,8 +1933,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// frame (a chamber reached through two portals). The same frame-scoped
// set that dedupes the cell SHELL dedupes the particle turn (chunk 6
// review F1).
if (_cellParticleTurnsDrawnThisFrame.Add(cellId))
_events.Add(WalkFrameEvent.CellParticles(cellId));
bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
_alphaSubmitMark = _alphaSubmissions.Count;
_events.Add(WalkFrameEvent.CellParticles(
cellId,
_alphaSubmitMark,
includeParticles));
}
private void CaptureCellViews(uint cellId)

View file

@ -83,10 +83,12 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellObjectsCache = new();
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorObjectsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
// Campaign OVERHAUL S2 chunk 5: every distinct entity id counted into
@ -179,8 +181,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_tupleLandblockId = tupleLandblockId;
_cellCache.Clear();
_cellDynamicCache.Clear();
_cellObjectsCache.Clear();
_outdoorMaterialized.Clear();
_outdoorDynamicsMaterialized.Clear();
_outdoorObjectsMaterialized.Clear();
_shellMaterialized.Clear();
_arenaLength = 0;
UnregisteredRenderMembershipCount = 0;
@ -252,6 +256,16 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return records;
}
public WalkFrameStaticRecords GetCellObjects(uint cellId)
{
if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
EmitFacilityShadowProbe(records.Records, cellId, "combined");
_cellObjectsCache[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetCellDynamics(uint cellId)
{
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
@ -285,6 +299,20 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return records;
}
public WalkFrameStaticRecords GetOutdoorObjects(uint cellId)
{
if (_outdoorObjectsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
_outdoorObjectsMaterialized[cellId] = records;
return records;
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: one cell id's borrowed retail render
/// membership. Reads <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>
@ -303,7 +331,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
/// entity into every crossed cell under its own true id, so no
/// additional indoor/outdoor dispatch is needed here.
/// </summary>
private WalkFrameStaticRecords ResolveCellView(uint cellId, bool dynamic)
private WalkFrameStaticRecords ResolveCellView(uint cellId, bool? dynamic)
{
IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId);
@ -335,7 +363,8 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
}
if (record.EntityPayload.IsBuildingShell)
continue; // buildings draw at their own shell turn.
if (IsDynamicProjectionClass(record.ProjectionClass) != dynamic)
if (dynamic.HasValue
&& IsDynamicProjectionClass(record.ProjectionClass) != dynamic.Value)
continue;
if (written == _cellViewScratch.Length)

View file

@ -18,14 +18,11 @@ namespace AcDream.App.Rendering.Walk;
/// 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>
/// opaque batch) in the per-cell stable far→near part order reconstructed
/// from authored GfxObj SortCenter. Opaque/cutout and delayed projections
/// derive from that same order. Translucent batches then enter the SAME two
/// FIFO <see cref="RetailAlphaQueue"/> lists as particles; the queues never
/// perform a scope-global sort.</para>
///
/// <para>Reads no retained scene state itself: every method takes the target
/// <see cref="OrderedDrawStream"/> and a caller-supplied span of already
@ -40,6 +37,12 @@ internal sealed class WalkStaticStreamPopulator
private readonly WbDrawDispatcher _dispatcher;
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _batchScratch = new();
private readonly List<WbDrawDispatcher.WalkClassifiedSelectionPart> _selectionScratch = new();
private readonly List<CellBatch> _cellBatchScratch = new();
private readonly record struct CellBatch(
WbDrawDispatcher.WalkClassifiedBatch Batch,
WalkDrawStage Stage,
uint LocalEntityId);
internal WalkStaticStreamPopulator(WbDrawDispatcher dispatcher)
{
@ -147,6 +150,120 @@ internal sealed class WalkStaticStreamPopulator
}
}
/// <summary>
/// S4-c3a: classifies the cell's combined static/dynamic registry view,
/// computes every subset's CYpt from its authored part sort center,
/// performs retail's stable descending insertion sort, and projects the
/// same order to opaque commands and delayed records. Equal keys retain
/// registry/part/subset order because only a strictly farther record is
/// moved left.
///
/// <para>AP-243: this always-authored per-part key is deliberately more
/// exact than retail's greater-than-50 m branch. Retail
/// <c>UpdateObjCell</c> @0x005A06B7..0x005A0720 gives every part the
/// shared cell CYpt/heading there, preserving insertion order; acquiring
/// that cell-distance/heading LOD input is outside this bounded repair.</para>
/// </summary>
internal void PopulateCellObjects(
OrderedDrawStream stream,
WalkDrawStage staticStage,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> records,
uint tupleLandblockId,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection,
IWalkLookInViewSource? views,
int viewRouteIndex,
List<WbDrawDispatcher.WalkClassifiedBatch> alphaSubmissions)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(alphaSubmissions);
_cellBatchScratch.Clear();
for (int recordIndex = 0; recordIndex < records.Length; recordIndex++)
{
ref readonly RenderProjectionRecord record = ref records[recordIndex];
bool liveDynamic = record.ProjectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
WalkDrawStage stage = liveDynamic ? WalkDrawStage.Dynamic : staticStage;
_batchScratch.Clear();
_selectionScratch.Clear();
_dispatcher.ClassifyEntityForWalk(
in record,
tupleLandblockId,
_batchScratch,
_selectionScratch,
liveDynamic,
views,
viewRouteIndex,
cellId,
diagnosticViewProjection: viewProjection);
for (int batchIndex = 0; batchIndex < _batchScratch.Count; batchIndex++)
{
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[batchIndex];
Vector3 worldSortCenter = Vector3.Transform(batch.LocalSortCenter, batch.Transform);
batch = batch with
{
SortDistanceSq = Vector3.DistanceSquared(
worldSortCenter, cameraWorldPosition),
};
_cellBatchScratch.Add(new CellBatch(
batch,
stage,
record.Source.LocalEntityId));
}
for (int selectionIndex = 0; selectionIndex < _selectionScratch.Count; selectionIndex++)
{
WbDrawDispatcher.WalkClassifiedSelectionPart part =
_selectionScratch[selectionIndex];
_dispatcher.PublishWalkSelectionPart(in part);
}
}
StableSortCellBatches(_cellBatchScratch);
for (int i = 0; i < _cellBatchScratch.Count; i++)
{
CellBatch item = _cellBatchScratch[i];
WbDrawDispatcher.WalkClassifiedBatch batch = item.Batch;
if (batch.IsOpaque)
{
int commandIndex = stream.Count;
stream.Append(new OrderedDrawCommand(
batch.Key, batch.Transform, item.Stage, cellId, batch.ClipSlot,
batch.Lights, batch.IndoorFlag, batch.Alpha,
batch.SelectionLighting, batch.DetailCategory));
_dispatcher.ProbeFacilityStairCommandAppended(
commandIndex,
item.LocalEntityId,
cellId,
item.Stage,
in batch);
}
else
{
alphaSubmissions.Add(batch);
}
}
}
private static void StableSortCellBatches(List<CellBatch> batches)
{
for (int i = 1; i < batches.Count; i++)
{
CellBatch value = batches[i];
int insertion = i;
while (insertion > 0
&& value.Batch.SortDistanceSq > batches[insertion - 1].Batch.SortDistanceSq)
{
batches[insertion] = batches[insertion - 1];
insertion--;
}
batches[insertion] = value;
}
}
private void ClassifyAndAppend(
OrderedDrawStream stream,
WalkDrawStage stage,
@ -195,7 +312,7 @@ internal sealed class WalkStaticStreamPopulator
if (alphaSubmissions is null)
{
_dispatcher.SubmitWalkAlphaInstance(
in batch, cameraWorldPosition, viewProjection);
in batch, viewProjection);
}
else
{

View file

@ -20,7 +20,10 @@ namespace AcDream.App.Rendering.Wb;
/// key's VALUE never orders anything: groups are enumerated in the dictionary's
/// insertion order and sorted by cull mode then camera distance
/// (<c>CompareOpaqueSubmissionOrder</c> / <c>CompareTransparentSubmissionOrder</c>),
/// and the delayed-alpha path sorts by viewer distance then submission ordinal.
/// while the normal-world delayed-alpha path orders authored part centers
/// stably within each cell before appending to two FIFO lists. The grouped
/// fallback restores submission ordinal after material grouping; it is not a
/// scope-global world-alpha distance sort.
/// The key reaches only equality, hashing, and the scene-digest fingerprints.</para>
///
/// <para>Campaign VM VM6 review fix round: <see cref="FoliageFlags"/> joined

View file

@ -146,8 +146,10 @@ public sealed partial class WbDrawDispatcher
/// (the authored GfxObj sort center — the same value
/// <c>InstanceGroup.LocalSortCenters</c> carries per instance today; S4-c2
/// deleted its one alpha-ordering consumer, <c>RetailAlphaOrdering.ComputeViewerDistance</c>,
/// but the field itself stays: <c>InstanceGroup.LocalSortCenters</c> is a
/// general per-instance record this type mirrors, not an alpha-only one).
/// but S4-c3a restores its retail use: <c>WalkStaticStreamPopulator</c>
/// transforms it through the part model and retains the resulting
/// squared CYpt key in <see cref="SortDistanceSq"/> before the per-cell
/// stable sort. The key feeds two FIFO alpha lists; neither queue sorts.
/// </summary>
internal readonly record struct WalkClassifiedBatch(
GroupKey Key,
@ -159,7 +161,8 @@ public sealed partial class WbDrawDispatcher
Vector2 SelectionLighting,
uint DetailCategory,
bool IsOpaque,
Vector3 LocalSortCenter);
Vector3 LocalSortCenter,
float SortDistanceSq = 0f);
/// <summary>
/// One retail-picking part surfaced by <see cref="ClassifyEntityForWalk"/>
@ -889,10 +892,8 @@ public sealed partial class WbDrawDispatcher
/// </summary>
internal void SubmitWalkAlphaInstance(
in WalkClassifiedBatch batch,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection)
{
_ = cameraWorldPosition; // S4-c2: retail's queues are FIFO, not distance-sorted.
RetailAlphaQueue queue = _alphaQueue
?? throw new InvalidOperationException(
"SubmitWalkAlphaInstance requires an active RetailAlphaQueue.");

View file

@ -47,9 +47,12 @@ namespace AcDream.App.Rendering.Wb;
/// <c>DrawElementsIndirectCommand</c>. Per-frame ring allocations carry instance
/// matrices (binding 0), per-group batch metadata/texture-table slots (binding
/// 1), and the indirect draw commands. Opaque world groups remain MDI-batched.
/// Transparent world instances enter <see cref="RetailAlphaQueue"/> so ordinary
/// GfxObj parts and particles share retail's stable far-to-near stream; sealed
/// off-screen consumers retain the immediate transparent MDI path.
/// Transparent world instances enter <see cref="RetailAlphaQueue"/> after
/// <c>WalkFrameDriver</c> reconstructs retail's stable far-to-near order
/// independently for each object-cell turn from authored part SortCenter.
/// Object and particle records are merged before they append to retail's two
/// FIFO lists; sealed off-screen consumers retain the immediate transparent
/// MDI path.
/// </para>
///
/// <para>
@ -2419,14 +2422,11 @@ public sealed partial class WbDrawDispatcher : IDisposable
throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices.");
// Retail CShadowPart::insertion_sort (0x006B5130) is stable:
// equal-CYpt parts keep the order in which the cell submitted them.
// Material grouping is an acdream batching detail and must not become
// that tiebreak. Reconstruct the original draw-local instance order
// before handing entries to the queue — S4-c2: retail's own queue is
// FIFO (D3DPolyRender::AddMeshToAlphaList never sorts), so preserving
// this walk-order sequence IS the ordering, not a sort key for a
// later comparator.
// a tiebreak. Reconstruct original draw-local submission order before
// handing entries to retail's FIFO queue. The normal-world walk path
// does not use this grouped route: WalkStaticStreamPopulator performs
// the per-cell stable CYpt sort before its per-instance submissions.
_alphaFingerprintScratch.Clear();
foreach (InstanceGroup group in _translucentDraws)
{