From e880860291a79e0cb20454710567d86ad8731675 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 31 Aug 2026 10:49:33 +0200 Subject: [PATCH] fix(render): restore landscape objects and walk alpha order --- .../Rendering/Walk/RetailFrameWalk.cs | 5 +- src/AcDream.App/Rendering/Walk/WalkEvents.cs | 13 ++ .../Rendering/Walk/WalkFrameDriver.cs | 117 +++++++++++++++- .../Walk/WalkStaticStreamPopulator.cs | 30 ++-- .../Rendering/Walk/WalkFrameDriverTests.cs | 131 +++++++++++++++++- 5 files changed, 275 insertions(+), 21 deletions(-) diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index ed20b5ac..621db244 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -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); } } } diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index cae598ab..b57a1185 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -99,6 +99,19 @@ public interface IWalkEventSink /// void OnLandscapeCellTurn(uint cellId) { } + /// + /// The LOD-aware sibling of . + /// names one cell in the block's CURRENT + /// × + /// coarse terrain grid. Retail attaches every outdoor object's shadow to + /// that coarse CObjCell; 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. + void OnLandscapeCellTurn(uint landblockId, int sideCellCount, int cellIndex) => + OnLandscapeCellTurn( + (landblockId & 0xFFFF0000u) | checked((uint)(cellIndex + 1))); + /// /// Fires at once retail's own /// gate has passed — RenderDeviceD3D::DrawBuilding @0x0059f2a0 diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 12538873..5892c3ae 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -223,6 +223,13 @@ internal enum WalkFrameEventKind : byte /// was recorded. StreamMark, + /// Submits every retained translucent walk batch through + /// up to the + /// exclusive-end index in . 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. + AlphaSubmitMark, + /// . 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 _events = new(); private readonly List _markPositions = new(); + private readonly List _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)); } + /// 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. + 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 " diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index ef9a51ef..b1858697 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -66,7 +66,8 @@ internal sealed class WalkStaticStreamPopulator Vector3 cameraWorldPosition, Matrix4x4 viewProjection, IWalkLookInViewSource? views = null, - int viewRouteIndex = -1) + int viewRouteIndex = -1, + List? 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? drawnOnce = null) + ISet? drawnOnce = null, + List? 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? drawnOnce = null) + ISet? drawnOnce = null, + List? 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? 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); + } } } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 608a097c..4960f4b7 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -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 log) : IWalkFrameLeafRenderer + private sealed class RecordingLeafRenderer( + List log, + RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer { public readonly List Punches = new(); public readonly List<(uint CellId, uint ClipSlot)> Shells = new(); + public readonly List 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 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()), + 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()); + 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(); + 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();