fix(render): restore landscape objects and walk alpha order

This commit is contained in:
Erik 2026-08-31 10:49:33 +02:00
parent 4683ac6f72
commit e880860291
5 changed files with 275 additions and 21 deletions

View file

@ -177,7 +177,10 @@ public sealed class RetailFrameWalk
// outdoor-static turn.
if (block.CellBuildings[cellIndex] is WalkBuilding building)
DrawBuilding(building, activeViews, ctx, sink);
sink.OnLandscapeCellTurn((block.LandblockId & 0xFFFF0000u) | (uint)(cellIndex + 1));
sink.OnLandscapeCellTurn(
block.LandblockId,
block.SideCellCount,
cellIndex);
}
}
}

View file

@ -99,6 +99,19 @@ public interface IWalkEventSink
/// </summary>
void OnLandscapeCellTurn(uint cellId) { }
/// <summary>
/// The LOD-aware sibling of <see cref="OnLandscapeCellTurn(uint)"/>.
/// <paramref name="cellIndex"/> names one cell in the block's CURRENT
/// <paramref name="sideCellCount"/>×<paramref name="sideCellCount"/>
/// coarse terrain grid. Retail attaches every outdoor object's shadow to
/// that coarse <c>CObjCell</c>; acdream retains the authoritative 8×8
/// owner buckets, so production consumers expand this turn back to the
/// covered original cells. The default preserves the FW1 vocabulary and
/// every diagnostic sink that only needs the coarse turn.</summary>
void OnLandscapeCellTurn(uint landblockId, int sideCellCount, int cellIndex) =>
OnLandscapeCellTurn(
(landblockId & 0xFFFF0000u) | checked((uint)(cellIndex + 1)));
/// <summary>
/// Fires at <see cref="RetailFrameWalk.DrawBuilding"/> once retail's own
/// gate has passed — <c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0

View file

@ -223,6 +223,13 @@ internal enum WalkFrameEventKind : byte
/// was recorded.</summary>
StreamMark,
/// <summary>Submits every retained translucent walk batch through
/// <see cref="WbDrawDispatcher.SubmitWalkAlphaInstance"/> up to the
/// exclusive-end index in <see cref="WalkFrameEvent.IntArg"/>. This is a
/// replay-time event: collecting the complete frame must not make alpha
/// from a later/near cell visible to an earlier building barrier.</summary>
AlphaSubmitMark,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawSky"/>.</summary>
Sky,
@ -351,6 +358,9 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent Mark(int exclusiveEnd) =>
new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null);
internal static WalkFrameEvent AlphaSubmitMark(int exclusiveEnd) =>
new(WalkFrameEventKind.AlphaSubmitMark, exclusiveEnd, 0, 0f, null);
internal static WalkFrameEvent Sky() =>
new(WalkFrameEventKind.Sky, 0, 0, 0f, null);
@ -472,6 +482,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly OrderedDrawStream _stream = new();
private readonly List<WalkFrameEvent> _events = new();
private readonly List<int> _markPositions = new();
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _alphaSubmissions = new();
private int _alphaSubmitMark;
// Campaign FW3.4a: visited-set collection, absorbed from the renderer's
// former dedicated set-collecting sink (RetailPViewRenderer's old
@ -603,6 +615,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_alphaSubmitMark = 0;
VisitedCells.Clear();
LookInCellTurns.Clear();
_lookInTurns.Clear();
@ -736,6 +750,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_alphaSubmitMark = 0;
VisitedCells.Clear();
LookInCellTurns.Clear();
_lookInTurns.Clear();
@ -764,6 +780,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
try
{
MarkIfGrown();
MarkAlphaIfGrown();
}
finally
{
@ -804,6 +821,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
int cursor = 0;
int alphaCursor = 0;
for (int i = 0; i < _events.Count; i++)
{
WalkFrameEvent e = _events[i];
@ -817,6 +835,18 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_dispatcher.DrawOrderedRange(encoder, cursor, count);
cursor = end;
break;
case WalkFrameEventKind.AlphaSubmitMark:
int alphaEnd = e.IntArg;
for (; alphaCursor < alphaEnd; alphaCursor++)
{
WbDrawDispatcher.WalkClassifiedBatch batch =
_alphaSubmissions[alphaCursor];
_dispatcher.SubmitWalkAlphaInstance(
in batch,
_cameraWorldPosition,
_viewProjection);
}
break;
case WalkFrameEventKind.Sky:
_leafRenderer.DrawSky();
break;
@ -883,6 +913,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_alphaSubmitMark = 0;
_readyToReplay = false;
_ctx = null;
}
@ -916,6 +948,50 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
}
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
=> HandleLandscapeCellTurn(cellId);
void IWalkEventSink.OnLandscapeCellTurn(
uint landblockId,
int sideCellCount,
int cellIndex)
{
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
uint blockPrefix = landblockId & 0xFFFF0000u;
if (sideCellCount == 8)
{
HandleLandscapeCellTurn(blockPrefix | checked((uint)(cellIndex + 1)));
return;
}
// CLandBlock's coarse DrawSortCell owns the shadows from every
// original 24 m land cell it covers. Our render journal deliberately
// retains those authoritative 8x8 owner buckets, so turn all covered
// buckets here while preserving the coarse cell's single walk turn.
int span = 8 / sideCellCount;
int coarseX = cellIndex / sideCellCount;
int coarseY = cellIndex % sideCellCount;
int firstX = coarseX * span;
int firstY = coarseY * span;
for (int x = firstX; x < firstX + span; x++)
{
for (int y = firstY; y < firstY + span; y++)
{
HandleLandscapeCellTurn(
blockPrefix | checked((uint)(x * 8 + y + 1)));
}
}
}
private void HandleLandscapeCellTurn(uint cellId)
{
RequireOpenFrame();
if (_landscapeViewRouteIndex < 0)
@ -930,11 +1006,18 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame);
this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame,
_alphaSubmissions);
_populator.PopulateCellDynamics(
_stream, cellId, dynamics.Records, dynamics.TupleLandblockId,
_cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame);
this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame,
_alphaSubmissions);
if (_alphaSubmissions.Count != _alphaSubmitMark)
{
MarkIfGrown();
MarkAlphaIfGrown();
}
// FW4 (the #132 positional invariant): this cell's emitter owners
// submit AT THIS TURN, so nearer buildings' pre-punch barriers
// drain them against still-true depth — see
@ -977,6 +1060,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// look-ins) follows this call; the building's own shell content is
// appended only once that pass completes (OnBuildingShellTurn).
MarkIfGrown();
MarkAlphaIfGrown();
_events.Add(WalkFrameEvent.AlphaBarrier());
_currentDcStage = WalkDrawStage.LookInStatic;
@ -996,7 +1080,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
_populator.PopulateCell(
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection);
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection,
alphaSubmissions: _alphaSubmissions);
if (_alphaSubmissions.Count != _alphaSubmitMark)
{
MarkIfGrown();
MarkAlphaIfGrown();
}
// FW4 (the #132 positional invariant): the building's own shell
// emitters submit at the shell turn, after the shell content
// flushes — see WalkFrameEventKind.StaticParticles.
@ -1166,8 +1256,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_populator.PopulateCell(
_stream, stage, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection,
this, viewRouteIndex);
this, viewRouteIndex, _alphaSubmissions);
MarkIfGrown();
MarkAlphaIfGrown();
if (stage == WalkDrawStage.LookInStatic)
{
@ -1184,8 +1275,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_cameraWorldPosition,
_viewProjection,
this,
viewRouteIndex);
viewRouteIndex,
alphaSubmissions: _alphaSubmissions);
MarkIfGrown();
MarkAlphaIfGrown();
if (HasAnyOwner(records) || HasAnyOwner(dynamics))
_events.Add(WalkFrameEvent.CellParticles(cellId));
}
@ -1364,6 +1457,20 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Add(WalkFrameEvent.Mark(count));
}
/// <summary>Records translucent walk batches at the same temporal point
/// as their cell turn. Replay submits them only when it reaches this
/// marker, so an earlier building's full alpha barrier cannot see content
/// that the far-to-near walk has not reached yet.</summary>
private void MarkAlphaIfGrown()
{
int count = _alphaSubmissions.Count;
if (count == _alphaSubmitMark)
return;
_alphaSubmitMark = count;
_events.Add(WalkFrameEvent.AlphaSubmitMark(count));
}
private IWalkBuildingFrameContext RequireOpenFrame() =>
_ctx ?? throw new InvalidOperationException(
"WalkFrameDriver received a walk turn outside BeginFrame/EndFrame — call "

View file

@ -66,7 +66,8 @@ internal sealed class WalkStaticStreamPopulator
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection,
IWalkLookInViewSource? views = null,
int viewRouteIndex = -1)
int viewRouteIndex = -1,
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null)
{
ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++)
@ -74,7 +75,7 @@ internal sealed class WalkStaticStreamPopulator
ClassifyAndAppend(
stream, stage, cellId, in records[i], tupleLandblockId,
cameraWorldPosition, viewProjection,
liveDynamic: false, views, viewRouteIndex);
liveDynamic: false, views, viewRouteIndex, alphaSubmissions);
}
}
@ -98,7 +99,8 @@ internal sealed class WalkStaticStreamPopulator
Matrix4x4 viewProjection,
IWalkLookInViewSource? views = null,
int viewRouteIndex = -1,
ISet<RenderProjectionId>? drawnOnce = null)
ISet<RenderProjectionId>? drawnOnce = null,
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null)
{
ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++)
@ -108,7 +110,7 @@ internal sealed class WalkStaticStreamPopulator
ClassifyAndAppend(
stream, WalkDrawStage.OutdoorStatic, cellId, in records[i],
tupleLandblockId, cameraWorldPosition, viewProjection,
liveDynamic: false, views, viewRouteIndex);
liveDynamic: false, views, viewRouteIndex, alphaSubmissions);
}
}
@ -121,7 +123,8 @@ internal sealed class WalkStaticStreamPopulator
Matrix4x4 viewProjection,
IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1,
ISet<RenderProjectionId>? drawnOnce = null)
ISet<RenderProjectionId>? drawnOnce = null,
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null)
{
ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++)
@ -138,7 +141,8 @@ internal sealed class WalkStaticStreamPopulator
viewProjection,
liveDynamic: true,
lookInViews,
lookInRouteIndex);
lookInRouteIndex,
alphaSubmissions);
}
}
@ -152,7 +156,8 @@ internal sealed class WalkStaticStreamPopulator
Matrix4x4 viewProjection,
bool liveDynamic = false,
IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1)
int lookInRouteIndex = -1,
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null)
{
_batchScratch.Clear();
_selectionScratch.Clear();
@ -178,8 +183,15 @@ internal sealed class WalkStaticStreamPopulator
}
else
{
_dispatcher.SubmitWalkAlphaInstance(
in batch, cameraWorldPosition, viewProjection);
if (alphaSubmissions is null)
{
_dispatcher.SubmitWalkAlphaInstance(
in batch, cameraWorldPosition, viewProjection);
}
else
{
alphaSubmissions.Add(batch);
}
}
}

View file

@ -40,10 +40,13 @@ public sealed class WalkFrameDriverTests
// interleave (stream flushes interleaved with sky/terrain/shell/punch/
// alpha-barrier), not just each half in isolation. ─────────────────────
private sealed class RecordingLeafRenderer(List<string> log) : IWalkFrameLeafRenderer
private sealed class RecordingLeafRenderer(
List<string> log,
RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer
{
public readonly List<WalkPolygon> Punches = new();
public readonly List<(uint CellId, uint ClipSlot)> Shells = new();
public readonly List<int> AlphaPendingAtBarrier = new();
public void DrawSky() => log.Add("SKY");
@ -65,7 +68,18 @@ public sealed class WalkFrameDriverTests
log.Add($"PUNCH:{worldPolygon.Vertices.Length}@v{activeViewIndex}");
}
public void AlphaBarrier() => log.Add("ALPHA");
public void AlphaBarrier()
{
if (alpha is null)
{
log.Add("ALPHA");
return;
}
AlphaPendingAtBarrier.Add(alpha.PendingCount);
log.Add($"ALPHA:{alpha.PendingCount}");
alpha.Flush();
}
public void DrawStaticParticles(IReadOnlySet<uint> ownerIds)
{
@ -762,6 +776,89 @@ public sealed class WalkFrameDriverTests
Assert.Equal(1u, mdi[0].DrawCount);
}
[Fact]
public void CoarseLandscapeCellTurn_ExpandsToEveryCoveredAuthoritativeOwnerCell()
{
using var fx = new DispatcherFixture();
const ulong gfxObj = 0x0200_0022UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08100022u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[0xE43D0040u] = new WalkFrameStaticRecords(
new[] { MakeRecord(304, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
0xE43Du);
var driver = new WalkFrameDriver(
fx.Dispatcher,
new RecordingLeafRenderer(new List<string>()),
worldData);
var ctx = new TestContext();
using DrawScope draw = fx.BeginDraw();
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
var activeViews = new WalkPortalView();
WalkCopyView.AppendFullViewportQuad(
activeViews,
ctx.Rays,
ctx.WorldViewpoint,
ctx.ViewportWidth,
ctx.ViewportHeight);
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
// A 2x2 coarse grid's cell 3 covers authoritative x/y cells 4..7,
// including original cell 64. The old cellIndex+1 lookup only asked
// for ids 1..4 and silently lost this record.
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43DFFFFu, 2, 3);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
Assert.Contains(0xE43D0040u, driver.VisitedLandscapeCellIds);
GpuRecordedMultiDrawIndirect call = Assert.Single(
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Equal(1u, call.DrawCount);
}
[Fact]
public void Collect_DoesNotExposeNearAlphaToEarlierBuildingBarrier()
{
using var fx = new DispatcherFixture();
const ulong gfxObj = 0x0200_0023UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08100023u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[0xE43D0001u] = new WalkFrameStaticRecords(
new[] { MakeRecord(305, 0, new Vector3(0, 0, -20), [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
0xE43Du);
worldData.OutdoorStaticsByCell[0xE43D0002u] = new WalkFrameStaticRecords(
new[] { MakeRecord(306, 0, new Vector3(0, 0, -2), [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
0xE43Du);
var log = new List<string>();
var leaf = new RecordingLeafRenderer(log, fx.AlphaQueue);
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
var ctx = new TestContext();
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
var activeViews = new WalkPortalView();
WalkCopyView.AppendFullViewportQuad(
activeViews,
ctx.Rays,
ctx.WorldViewpoint,
ctx.ViewportWidth,
ctx.ViewportHeight);
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43D0001u);
((IWalkEventSink)driver).OnBuildingTurn(new WalkBuilding());
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43D0002u);
driver.EndFrame();
Assert.Equal(0, fx.AlphaQueue.PendingCount);
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(new[] { 1 }, leaf.AlphaPendingAtBarrier);
Assert.Equal(1, fx.AlphaQueue.PendingCount);
}
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
// FW3.2a's own referee) ─────────────────────────────────────────────────
@ -838,12 +935,18 @@ public sealed class WalkFrameDriverTests
{
private readonly IDisposable _publication;
private readonly IGpuPassEncoder _pass;
private readonly RetailAlphaQueue? _alpha;
public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication)
public DrawScope(
IGpuFrame frame,
IGpuPassEncoder pass,
IDisposable publication,
RetailAlphaQueue? alpha = null)
{
Frame = frame;
_pass = pass;
_publication = publication;
_alpha = alpha;
}
public IGpuFrame Frame { get; }
@ -852,6 +955,8 @@ public sealed class WalkFrameDriverTests
public void Dispose()
{
if (_alpha?.IsCollecting == true)
_alpha.EndFrame();
_publication.Dispose();
_pass.Dispose();
}
@ -886,7 +991,8 @@ public sealed class WalkFrameDriverTests
_meshAdapter,
entitySpawnAdapter,
new EntityClassificationCache(),
new AcDream.Core.Rendering.TranslucencyFadeManager());
new AcDream.Core.Rendering.TranslucencyFadeManager(),
alphaQueue: AlphaQueue);
}
public RecordingGpuDevice Device { get; }
@ -897,10 +1003,17 @@ public sealed class WalkFrameDriverTests
public WbDrawDispatcher Dispatcher { get; }
public RetailAlphaQueue AlphaQueue { get; } = new();
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public DrawScope BeginDraw()
public DrawScope BeginDraw(bool beginAlpha = false)
{
if (beginAlpha)
{
Dispatcher.BeginFrame(frameSlot: 0);
AlphaQueue.BeginFrame();
}
FrameLifetime.BeginFrame();
IGpuFrame frame = FrameLifetime.CurrentFrame!;
IGpuPassEncoder pass = frame.BeginPass(
@ -908,11 +1021,17 @@ public sealed class WalkFrameDriverTests
"fw3-2b-1-walk-frame-driver-test", Vector4.Zero, sampleCount: 1));
IDisposable publication = Scope.Publish(pass);
Device.Clear();
return new DrawScope(frame, pass, publication);
return new DrawScope(
frame,
pass,
publication,
beginAlpha ? AlphaQueue : null);
}
public void Dispose()
{
if (AlphaQueue.IsCollecting)
AlphaQueue.AbortFrame();
Dispatcher.Dispose();
_meshAdapter.Dispose();
_textures.Dispose();