From 572de1ec30d981abfb5c88f331e1a20c6213b63b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 23:29:28 +0200 Subject: [PATCH] checkpoint: preserve cathedral look-in investigation state --- docs/launch-options.md | 2 +- .../RetailPViewPassExecutor.WalkLeaf.cs | 55 +++++ .../Rendering/RetailPViewPassExecutor.cs | 24 +- .../Rendering/RetailPViewRenderer.cs | 211 +++++++----------- .../Rendering/Scene/RenderFrameProduct.cs | 25 ++- .../Scene/RenderScenePViewFrameProduct.cs | 111 +++++---- .../Rendering/Walk/WalkFrameDriver.cs | 153 ++++++++++++- .../Wb/WbDrawDispatcher.PackedOracle.cs | 141 +++++++++++- .../RenderScenePViewFrameProductTests.cs | 140 ++++++++++++ .../Rendering/Walk/WalkFrameDriverTests.cs | 32 ++- .../Wb/WbDrawDispatcherLookInConeTests.cs | 60 +++++ 11 files changed, 757 insertions(+), 197 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs diff --git a/docs/launch-options.md b/docs/launch-options.md index 5da416f0..5dd59b88 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -315,7 +315,7 @@ issue is closed, the strip was missed; delete both. | `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` | | `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` | | `ACDREAM_PROBE_WALK_SHADOW` | Campaign FW3.2b-2 flip apparatus — dies with the flip commit | `=1` | runs the PRODUCTION retail frame walk per frame in SHADOW (no draws change) and prints one `[walk-shadow]` line per frame whose visited cell/building sets diverge from the old visibility path (the I5 dual-shadow pattern applied to the FW3 static cutover) | print-only; adds the per-frame CPU cost of the shadow walk while set | `RenderingDiagnostics.ProbeWalkShadowEnabled` | -| `ACDREAM_PROBE_WALK_ROOT` | the cathedral Phase I instrumentation (synthesis doc `docs/research/2026-08-30-cathedral-synthesis.md`) — dies with that investigation | `=1` | prints `[walk-root]` rooting lines every ~30th frame AND for 8 frames after each interior/outdoor root flip (viewer/root cell, camera-cell resolution, slice count, the walk's OWN exit-view count, the TRUE root flood separated from the visited union, eye + forward); `[walk-cam]` replayable camera dumps (eye, viewport, full view-projection) every ~300 frames and at flips; `[falls]` lines on EVERY particle submission carrying a real falls owner (0xCF418000-13) with route + phase; `[falls-adm]` admission verdicts for F418 emitters (~every 120th ApplyRetailView); plus the earlier family — `[walk-part]` per-route submissions, `[walk-alpha]` drains, `[walk-dyn]` outside-stage verdicts (~120th product frame), `[walk-portal]` per-portal side/clip decisions (~90th frame), and `[dyn-route]` per-entity ON-CHANGE DynamicLast admission traces (parent cell, look-in exclusion, viewcone verdict, the cell's lifted-plane shape — "0" marks a pass-all zero-plane slice) | print-only; chatty near particle-heavy scenes while set | `RenderingDiagnostics.ProbeWalkRootEnabled` | +| `ACDREAM_PROBE_WALK_ROOT` | the cathedral Phase I/FW4 instrumentation (synthesis doc `docs/research/2026-08-30-cathedral-synthesis.md`) — dies with that investigation | `=1` | prints `[walk-root]` rooting lines every ~30th frame AND for 8 frames after each interior/outdoor root flip (viewer/root cell, camera-cell resolution, slice count, the walk's OWN exit-view count, the TRUE root flood separated from the visited union, eye + forward); `[walk-cam]` replayable camera dumps (eye, viewport, full view-projection) every ~300 frames and at flips; `[lookin-frame]` ON-CHANGE/~30-frame comparisons of diagnostic-only legacy look-in frames against the walk's exact look-in cells, explicit 0xF4180112 membership, and walk-vs-legacy punch counts; `[lookin-draw]` identifies the walk-turn route index/cell actually submitted; `[lookin-punch]` fires if the legacy punch routine executes; `[falls]` lines on EVERY particle submission carrying a real falls owner (0xCF418000-13) with route + phase; `[falls-adm]` admission verdicts for F418 emitters (~every 120th ApplyRetailView); plus the earlier family — `[walk-part]` per-route submissions, `[walk-alpha]` drains, `[walk-dyn]` outside-stage verdicts (~120th product frame), `[walk-portal]` per-portal side/clip decisions (~90th frame), and `[dyn-route]` per-entity ON-CHANGE DynamicLast admission traces (parent cell, look-in exclusion, viewcone verdict, the cell's lifted-plane shape — "0" marks a pass-all zero-plane slice) | print-only; chatty near particle-heavy scenes while set | `RenderingDiagnostics.ProbeWalkRootEnabled` | | `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` | | `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B | | `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` | diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 6efd1f1c..385c0906 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Walk; using AcDream.Core.Vfx; @@ -151,6 +152,8 @@ internal sealed partial class RetailPViewPassExecutor int count = Math.Min(vertices.Length, world.Length); for (int vertex = 0; vertex < count; vertex++) world[vertex] = vertices[vertex]; + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) + WalkLookInPunchCountThisFrame++; _portalDepthMask.DrawDepthFan( world[..count], frame.ViewProjection, @@ -201,6 +204,9 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer private readonly Action _drawExitSeals; private readonly HashSet _singleCellScratch = new(); private readonly List _singleCellListScratch = new(); + private readonly HashSet _lookInParticleOwnerScratch = new(); + private RenderFrameView _entityFrame; + private bool _entityFrameBound; internal WalkProductionLeafRenderer( RetailPViewPassExecutor passes, @@ -242,6 +248,55 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer _passes.DrawLandscapeStaticParticles( _frame, new RetailPViewLandscapeStaticParticleContext(ownerIds)); + /// Binds the packed frame product built after the walk's Collect + /// pass and before Replay. The walk records route indices during Collect; + /// Replay consumes the matching product ranges through this exact view. + internal void BindEntityFrame(in RenderFrameView view) + { + _entityFrame = view; + _entityFrameBound = true; + } + + public void DrawLookInDynamics( + uint cellId, + int routeIndex, + IReadOnlySet staticParticleOwnerIds) + { + if (!_entityFrameBound) + { + throw new InvalidOperationException( + "Walk look-in dynamics replayed before the packed entity frame was bound."); + } + + _passes.UseIndoorMembershipOnlyRouting(); + _passes.DrawEntityRoute( + _frame.Camera, + in _entityFrame, + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId, + _frame.PlayerLandblockId ?? 0u); + + _lookInParticleOwnerScratch.Clear(); + _lookInParticleOwnerScratch.UnionWith(staticParticleOwnerIds); + RenderFrameRouteOwnerSelector.Union( + _lookInParticleOwnerScratch, + in _entityFrame, + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId); + if (_lookInParticleOwnerScratch.Count > 0) + { + _passes.DrawCellParticles( + _frame, + new RetailPViewCellSliceContext( + cellId, + default, + _lookInParticleOwnerScratch)); + } + + } + public void DrawExitSeals() => _drawExitSeals(); public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) => diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 0f3df8ca..9e25e433 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -138,6 +138,13 @@ internal sealed partial class RetailPViewPassExecutor : private readonly Dictionary _singleCellClipRouting = new(1); private readonly Dictionary _noCellClipRouting = new(0); + // ACDREAM_PROBE_WALK_ROOT / cathedral FW4: observation-only counters. + // They distinguish the walk-owned far-Z portal punches from the retired + // legacy look-in punch route. RetailPViewRenderer samples them for the + // diagnostic comparison after the walk-turn look-in routes have replayed. + internal int WalkLookInPunchCountThisFrame { get; private set; } + internal int LegacyLookInPunchCountThisFrame { get; private set; } + /// /// Borrowed until the next late landscape pass. The outdoor-root post-world /// particle pass consumes this synchronously before another PView frame. @@ -181,7 +188,11 @@ internal sealed partial class RetailPViewPassExecutor : { _particleClassifications.BeginFrame(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) + { AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase = "pre"; + WalkLookInPunchCountThisFrame = 0; + LegacyLookInPunchCountThisFrame = 0; + } } /// Campaign FW3.2b-2: the shared dispatcher, for @@ -596,8 +607,19 @@ internal sealed partial class RetailPViewPassExecutor : public void DrawLookInPortalPunch( RetailPViewFrameInput frame, RetailPViewCellSliceContext context, - int portalIndex) => + int portalIndex) + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) + { + LegacyLookInPunchCountThisFrame++; + Console.WriteLine( + $"[lookin-punch] source=legacy viewer=0x{frame.ViewerCellId:X8} " + + $"root=0x{frame.RootCell.CellId:X8} cell=0x{context.CellId:X8} " + + $"portal={portalIndex} planes={context.Slice.Planes.Length} " + + $"phase={AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase}"); + } DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex); + } public void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index a8209b6c..e43d83b2 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -110,6 +110,12 @@ public sealed class RetailPViewRenderer private bool? _probeWalkRootPrevOutdoor; private int _probeWalkRootFramesLeft; private ulong _probeWalkRootFrame; + private string? _probeLookInState; + + // The parked remote player's authoritative parent cell for the current + // cathedral FW4 investigation. This is diagnostic scope only: it changes + // no visibility or draw decision. + private const uint ProbeCathedralRemoteCellId = 0xF4180112u; // Campaign FW3.2b-2: the walk's production world-data registries // (published/retired by LandblockRenderPublisher) plus the per-frame @@ -263,6 +269,7 @@ public sealed class RetailPViewRenderer // runs at append time, not at Replay time), so the world data must // already be rebuilt for this frame before the walk starts. Walk.WalkFrameDriver? walkDriver = null; + WalkProductionLeafRenderer? walkLeafRenderer = null; if (walkActive) { Matrix4x4 view = ctx.CameraView; @@ -343,9 +350,12 @@ public sealed class RetailPViewRenderer Action drawExitSeals = () => DrawWalkExitPortalMasks(ctx, passes, clipAssembly, walkDriver!); - var leafRenderer = new WalkProductionLeafRenderer( + walkLeafRenderer = new WalkProductionLeafRenderer( walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals); - walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData); + walkDriver = new Walk.WalkFrameDriver( + walkExecutor!.Dispatcher, + walkLeafRenderer, + _walkWorldData); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) { @@ -527,7 +537,8 @@ public sealed class RetailPViewRenderer pvFrame, clipAssembly, viewcone, - _lookInFrames, + walkActive ? walkDriver!.LookInCellTurns : [], + walkActive ? walkDriver : null, outsideStageFlood, ctx.Cells, ctx.AnimatedEntityIds, @@ -539,6 +550,8 @@ public sealed class RetailPViewRenderer frameViewBorrowed = true; frameEntityPasses!.BeginEntityFrame(in frameView); entityFrameOpen = true; + if (walkActive) + walkLeafRenderer!.BindEntityFrame(in frameView); } // The retained scene product is the production object source. @@ -651,6 +664,7 @@ public sealed class RetailPViewRenderer clipAssembly, capturedPartition, capturedViewcone, + capturedDriver, capturedPasses, in capturedView); }; @@ -666,6 +680,7 @@ public sealed class RetailPViewRenderer clipAssembly, partition, viewcone, + walkDriver!, frameEntityPasses, in frameView); } @@ -1404,7 +1419,7 @@ public sealed class RetailPViewRenderer _cellParticleOwnerScratch.Clear(); foreach (uint cellId in driver.VisitedCells) { - if (_lookInCellIds.Contains(cellId)) + if (driver.LookInCells.Contains(cellId)) continue; UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch); } @@ -1431,15 +1446,16 @@ public sealed class RetailPViewRenderer /// split — the walk now draws every STATIC route (see /// ); this method keeps ONLY what /// stays on the OLD visibility pipeline per the plan's dual-compute - /// split: outdoor-cell unattached particles, LookInObject dynamics + their - /// per-cell particles, the late per-slice outside-dynamics/weather loop, - /// and the late particle union submission. + /// split: outdoor-cell unattached particles, the late per-slice outside- + /// dynamics/weather loop, and the late particle union submission. Look-in + /// dynamics and their cell particles are now walk-turn-owned. private void DrawLandscapeDynamicsPhase( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, + Walk.WalkFrameDriver walkDriver, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { @@ -1452,8 +1468,10 @@ public sealed class RetailPViewRenderer // (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn). passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); - DrawBuildingLookInDynamics( - ctx, passes, clipAssembly, partition, frameEntityPasses, in frameView); + // Trace-only now. Look-in dynamics no longer have a late production + // phase: WalkFrameDriver replays each packed route at the walk's own + // per-cell DrawCells turn, before the enclosing building shell. + ProbeBuildingLookInFrames(ctx, passes, walkDriver); // LATE phase (per slice): outside-stage dynamics' meshes + weather — // unchanged from DrawLandscapeThroughOutsideView's own late loop. @@ -1527,129 +1545,72 @@ public sealed class RetailPViewRenderer passes.UseIndoorMembershipOnlyRouting(); } - /// Campaign FW3.2b-2: the DYNAMICS-only remainder of the old - /// — punches, shells, and look-in cell - /// STATICS are now walk-owned ('s - /// Building/BuildingShell/LookInStatic turns); this method keeps ONLY the - /// LookInObject route (now dynamic-classified — see - /// RenderScenePViewFrameBuilder.BuildLookInRoutes) and the per-cell - /// particle union that route's owners feed, unioned with the walk's - /// static owners for that SAME cell (plan §FW3 item 4 — GetCellStatics - /// fills the gap the retired CellStatic-route particle submission left - /// for look-in cells specifically). - private void DrawBuildingLookInDynamics( + /// ACDREAM_PROBE_WALK_ROOT companion for FW4's surviving + /// through-wall dynamic. It compares the now-diagnostic-only legacy + /// look-in frames with the production walk's exact look-in set and reports + /// the two punch producers separately. Print-only; never participates in + /// admission. + private void ProbeBuildingLookInFrames( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, - ClipFrameAssembly clipAssembly, - InteriorEntityPartition.Result? partition, - IRenderFrameEntityPassExecutor? frameEntityPasses, - in RenderFrameView frameView) + Walk.WalkFrameDriver walkDriver) { - if (_lookInFrames.Count == 0) + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) return; - int lookInRouteIndex = 0; - for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++) + uint[] walkLookInCells = walkDriver.LookInCells + .OrderBy(cellId => cellId) + .ToArray(); + bool legacyHasTarget = _lookInFrames.Any( + frame => frame.OrderedVisibleCells.Contains(ProbeCathedralRemoteCellId)); + bool walkHasTarget = Array.IndexOf( + walkLookInCells, ProbeCathedralRemoteCellId) >= 0; + string legacyShape = string.Join( + ";", + _lookInFrames.Select((frame, index) => + $"{index}:b{frame.SourceBuildingKey:x8}/lb{frame.SourceBuildingLandblockId:x8}" + + $"/s{frame.ExteriorSeedPortals.Count}/c[" + + string.Join(",", frame.OrderedVisibleCells.Select(cell => cell.ToString("x8"))) + + "]")); + string walkShape = string.Join(",", walkLookInCells.Select(cell => cell.ToString("x8"))); + int walkPunches = passes is RetailPViewPassExecutor concrete + ? concrete.WalkLookInPunchCountThisFrame + : -1; + int legacyPunches = passes is RetailPViewPassExecutor concreteLegacy + ? concreteLegacy.LegacyLookInPunchCountThisFrame + : -1; + string state = + $"viewer={ctx.ViewerCellId:x8}|root={ctx.RootCell.CellId:x8}" + + $"|phase={AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase}" + + $"|legacy={legacyShape}|walk={walkShape}" + + $"|p={walkPunches}/{legacyPunches}"; + bool emit = state != _probeLookInState || _probeWalkRootFrame % 30 == 0; + _probeLookInState = state; + if (!emit) + return; + + Console.WriteLine( + $"[lookin-frame] f={_probeWalkRootFrame} " + + $"viewer=0x{ctx.ViewerCellId:X8} root=0x{ctx.RootCell.CellId:X8} " + + $"phase={AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase} " + + $"legacyFrames={_lookInFrames.Count} legacy112={(legacyHasTarget ? 1 : 0)} " + + $"walkLookIn={walkLookInCells.Length} walk112={(walkHasTarget ? 1 : 0)} " + + $"punches=walk:{walkPunches},legacy:{legacyPunches}"); + for (int index = 0; index < _lookInFrames.Count; index++) { - PortalVisibilityFrame frame = _lookInFrames[frameIndex]; - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) - { - uint cellId = frame.OrderedVisibleCells[i]; - var clipKey = new LookInClipCell(frameIndex, cellId); - if (!clipAssembly.LookInCellToViewSlices.TryGetValue( - clipKey, - out ClipViewSlice[]? cellSlices) - || cellSlices.Length == 0) - { - continue; - } - - _cellStaticScratch.Clear(); - if (partition is not null) - { - foreach (var e in partition.Dynamics) - if (e.ParentCellId == cellId) - _cellStaticScratch.Add(e); - } - - bool cellDrewObjects = false; - _cellParticleUnionScratch.Clear(); - foreach (ClipViewSlice slice in cellSlices) - { - int routeIndex = lookInRouteIndex++; - passes.UseCellPortalViewRouting(cellId, slice); - - if (frameEntityPasses is not null) - { - RenderFrameRouteOwnerSelector.Replace( - _cellParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LookInObject, - routeIndex, - cellId); - } - else - { - ReplaceOwnerIds( - _cellParticleOwnerScratch, - _cellStaticScratch); - } - - if (frameEntityPasses is not null - || _cellStaticScratch.Count > 0) - { - _candidateObserver?.ObservePViewBucket( - CurrentRenderPViewRoute.LookInObject, - routeIndex, - cellId, - _cellStaticScratch); - _oneCell.Clear(); - _oneCell.Add(cellId); - DrawEntityRouteOrLegacy( - ctx, - passes, - frameEntityPasses, - in frameView, - RenderFrameCandidateRoute.LookInObject, - routeIndex, - cellId, - _cellStaticScratch, - _oneCell); - - cellDrewObjects = true; - _cellParticleUnionScratch.UnionWith( - _cellParticleOwnerScratch); - } - } - - // The walk already drew this cell's STATIC content - // (WalkFrameDriver's LookInStatic turn) but never submits - // particles for it — GetCellStatics fills that gap, unioned - // with the dynamic route's own owners so ONE - // DrawCellParticles call covers both. - if (_walkWorldData is not null) - { - Walk.WalkFrameStaticRecords statics = - _walkWorldData.GetCellStatics(cellId); - foreach (RenderProjectionRecord record in statics.Records) - { - if (record.Source.LocalEntityId != 0) - { - _cellParticleUnionScratch.Add(record.Source.LocalEntityId); - cellDrewObjects = true; - } - } - } - - if (cellDrewObjects) - { - passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( - cellId, NoClipSlice, _cellParticleUnionScratch)); - } - } - - passes.UseIndoorMembershipOnlyRouting(); + PortalVisibilityFrame frame = _lookInFrames[index]; + Console.WriteLine( + $"[lookin-frame] f={_probeWalkRootFrame} index={index} " + + $"building=0x{frame.SourceBuildingKey:X8} " + + $"landblock=0x{frame.SourceBuildingLandblockId:X8} " + + $"seeds={frame.ExteriorSeedPortals.Count} " + + $"has112={(frame.OrderedVisibleCells.Contains(ProbeCathedralRemoteCellId) ? 1 : 0)} " + + $"cells=[{string.Join(",", frame.OrderedVisibleCells.Select(cell => $"0x{cell:X8}"))}]"); } + Console.WriteLine( + $"[lookin-frame] f={_probeWalkRootFrame} walkCells=[" + + string.Join(",", walkLookInCells.Select(cell => $"0x{cell:X8}")) + + "]"); } private void DrawLandscapeThroughOutsideView( diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs index f867a395..0a3d2569 100644 --- a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.Core.Selection; using AcDream.Core.World; +using AcDream.App.Rendering.Walk; namespace AcDream.App.Rendering.Scene; @@ -173,6 +174,13 @@ internal readonly struct RenderFrameView public ClipFrameAssembly? ClipAssembly => Arena.ClipAssembly; + /// + /// Exact portal-view cones captured by the retail frame walk for each + /// building look-in turn. The packed dispatcher consumes these at + /// per-GfxObj granularity, matching RenderDeviceD3D::DrawMesh. + /// + public IWalkLookInViewSource? WalkLookInViews => Arena.WalkLookInViews; + public RenderFrameDiagnosticCounts DiagnosticCounts => Arena.DiagnosticCounts; @@ -247,6 +255,8 @@ internal sealed class RenderFrameArena public ClipFrameAssembly? ClipAssembly { get; private set; } + public IWalkLookInViewSource? WalkLookInViews { get; private set; } + public RenderSceneDigest SourceDigest { get; private set; } public RenderFrameDiagnosticCounts DiagnosticCounts => @@ -328,6 +338,7 @@ internal sealed class RenderFrameArena _alphaClassificationCount = 0; PortalFrame = null; ClipAssembly = null; + WalkLookInViews = null; SourceDigest = default; _sourceDigestSet = false; Generation = generation; @@ -341,11 +352,13 @@ internal sealed class RenderFrameArena internal void SetBorrowedProducts( ulong epoch, PortalVisibilityFrame? portalFrame, - ClipFrameAssembly? clipAssembly) + ClipFrameAssembly? clipAssembly, + IWalkLookInViewSource? walkLookInViews) { EnsureBuilding(epoch); PortalFrame = portalFrame; ClipAssembly = clipAssembly; + WalkLookInViews = walkLookInViews; } internal void SetSourceDigest(ulong epoch, in RenderSceneDigest digest) @@ -549,6 +562,7 @@ internal sealed class RenderFrameArena ClearReferenceStorage(); PortalFrame = null; ClipAssembly = null; + WalkLookInViews = null; SourceDigest = default; _sourceDigestSet = false; _state = ArenaState.Available; @@ -655,8 +669,13 @@ internal readonly struct RenderFrameWriter public void SetBorrowedProducts( PortalVisibilityFrame? portalFrame, - ClipFrameAssembly? clipAssembly) => - Arena.SetBorrowedProducts(_epoch, portalFrame, clipAssembly); + ClipFrameAssembly? clipAssembly, + IWalkLookInViewSource? walkLookInViews = null) => + Arena.SetBorrowedProducts( + _epoch, + portalFrame, + clipAssembly, + walkLookInViews); public void SetSourceDigest(in RenderSceneDigest digest) => Arena.SetSourceDigest(_epoch, in digest); diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 451a8b34..0b3ea9f3 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Walk; namespace AcDream.App.Rendering.Scene; @@ -9,7 +10,8 @@ internal readonly record struct RenderScenePViewBuildInput( PortalVisibilityFrame PortalFrame, ClipFrameAssembly ClipAssembly, ViewconeCuller Viewcone, - IReadOnlyList LookInFrames, + IReadOnlyList LookInCellTurns, + IWalkLookInViewSource? WalkLookInViews, HashSet DrawableCells, IRetailPViewCellSource Cells, HashSet? AnimatedEntityIds, @@ -185,7 +187,8 @@ internal sealed class RenderScenePViewFrameProductController : PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, ViewconeCuller viewcone, - IReadOnlyList lookInFrames, + IReadOnlyList lookInCellTurns, + IWalkLookInViewSource? walkLookInViews, HashSet drawableCells, IRetailPViewCellSource cells, HashSet? animatedEntityIds, @@ -195,7 +198,7 @@ internal sealed class RenderScenePViewFrameProductController : ArgumentNullException.ThrowIfNull(portalFrame); ArgumentNullException.ThrowIfNull(clipAssembly); ArgumentNullException.ThrowIfNull(viewcone); - ArgumentNullException.ThrowIfNull(lookInFrames); + ArgumentNullException.ThrowIfNull(lookInCellTurns); ArgumentNullException.ThrowIfNull(drawableCells); ArgumentNullException.ThrowIfNull(cells); @@ -213,7 +216,8 @@ internal sealed class RenderScenePViewFrameProductController : portalFrame, clipAssembly, viewcone, - lookInFrames, + lookInCellTurns, + walkLookInViews, drawableCells, cells, animatedEntityIds, @@ -248,7 +252,8 @@ internal sealed class RenderScenePViewFrameProductController : portalFrame, clipAssembly, viewcone, - lookInFrames, + FlattenLegacyLookInCells(lookInFrames), + walkLookInViews: null, drawableCells, cells, animatedEntityIds, @@ -267,6 +272,19 @@ internal sealed class RenderScenePViewFrameProductController : } } + private static uint[] FlattenLegacyLookInCells( + IReadOnlyList lookInFrames) + { + var cells = new List(); + for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++) + { + PortalVisibilityFrame frame = lookInFrames[frameIndex]; + for (int index = frame.OrderedVisibleCells.Count - 1; index >= 0; index--) + cells.Add(frame.OrderedVisibleCells[index]); + } + return cells.ToArray(); + } + public void CompleteProduction(in RenderFrameView view) { _packedClassification = @@ -1171,7 +1189,8 @@ internal sealed class RenderScenePViewFrameBuilder { writer.SetBorrowedProducts( input.PortalFrame, - input.ClipAssembly); + input.ClipAssembly, + input.WalkLookInViews); RenderSceneDigest digest = input.SourceDigest; writer.SetSourceDigest(in digest); _projectionIds.Clear(); @@ -1182,19 +1201,11 @@ internal sealed class RenderScenePViewFrameBuilder // outdoor static, building shell, and cell static (including // look-in cell statics) directly through OrderedDrawStream (plan // §FW3 "FW3.2b-2 — the production rooting", item 3). LookInObject - // keeps emitting, but BuildLookInRoutes below is now filtered to - // DYNAMIC candidates only — the walk owns that route's statics. - int lookInRouteIndex = 0; - for (int frameIndex = 0; - frameIndex < input.LookInFrames.Count; - frameIndex++) - { - BuildLookInRoutes( - writer, - in input, - frameIndex, - ref lookInRouteIndex); - } + // keeps the packed classifier for live animation/fades, but its + // route ranges are keyed to THE WALK'S own look-in cell turns. + // The legacy PortalVisibilityFrame list no longer has a production + // admission or ordering role. + BuildLookInRoutes(writer, in input); BuildOutsideDynamicRoutes(writer, in input); BuildDynamicLastRoute(writer, in input); writer.Publish(); @@ -1284,45 +1295,37 @@ internal sealed class RenderScenePViewFrameBuilder /// item 3). private void BuildLookInRoutes( RenderFrameWriter writer, - in RenderScenePViewBuildInput input, - int frameIndex, - ref int routeIndex) + in RenderScenePViewBuildInput input) { - PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + for (int routeIndex = 0; routeIndex < input.LookInCellTurns.Count; routeIndex++) { - uint cellId = frame.OrderedVisibleCells[i]; - var clipKey = new LookInClipCell(frameIndex, cellId); - if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue( - clipKey, - out ClipViewSlice[]? slices) - || slices.Length == 0) - { - continue; - } - + uint cellId = input.LookInCellTurns[routeIndex]; int count = LoadCell( input.Scene, cellId, includeStatics: false, includeDynamics: true); - for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++) - { - int currentRouteIndex = routeIndex++; - if (count == 0) - continue; + if (count == 0) + continue; - for (int index = 0; index < count; index++) - AddProjection( - writer, - in _cell[index], - input.AnimatedEntityIds); - writer.AddRouteRange( - RenderFrameCandidateRoute.LookInObject, - currentRouteIndex, - cellId, - _cell.AsSpan(0, count)); + int survivorCount = 0; + EnsureCapacity(ref _survivors, count); + for (int index = 0; index < count; index++) + { + RenderProjectionRecord record = _cell[index]; + _survivors[survivorCount++] = record; + AddProjection( + writer, + in record, + input.AnimatedEntityIds); } + if (survivorCount == 0) + continue; + writer.AddRouteRange( + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId, + _survivors.AsSpan(0, survivorCount)); } } @@ -1384,14 +1387,8 @@ internal sealed class RenderScenePViewFrameBuilder in RenderScenePViewBuildInput input) { _lookInCellScratch.Clear(); - for (int frameIndex = 0; - frameIndex < input.LookInFrames.Count; - frameIndex++) - { - PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; - for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) - _lookInCellScratch.Add(frame.OrderedVisibleCells[i]); - } + for (int index = 0; index < input.LookInCellTurns.Count; index++) + _lookInCellScratch.Add(input.LookInCellTurns[index]); int count = 0; EnsureCapacity(ref _survivors, _dynamicCount); diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 6a6975f1..2e187dc5 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -1,4 +1,5 @@ using System.Numerics; +using System.Runtime.InteropServices; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Scene; @@ -118,6 +119,17 @@ internal interface IWalkFrameLeafRenderer /// positional invariant this carries (the #132 falls containment). void DrawStaticParticles(IReadOnlySet ownerIds); + /// Draws the packed dynamic occupants of one building look-in + /// cell at that cell's OWN PView::DrawCells turn, then submits the + /// cell's static + dynamic particle owners at the same turn. The route + /// index is assigned in the exact order Collect encountered look-in cell + /// turns and therefore matches the frame product's walk-keyed + /// LookInObject ranges. + void DrawLookInDynamics( + uint cellId, + int routeIndex, + IReadOnlySet staticParticleOwnerIds); + /// PView::DrawCells @0x005a4840's gated full depth clear /// (pc:432731-432732) between the outside stage and the interior root's /// own flood — production maps this to IWorldPassScope.ClearInteriorDepth @@ -259,8 +271,37 @@ internal enum WalkFrameEventKind : byte /// cathedral bleed; the old pipeline's user-verified #132 fix /// `e102fb36` encoded the same invariant). StaticParticles, + + /// — + /// is the look-in cell and + /// is its walk-ordered packed route + /// index. + LookInDynamics, } +/// +/// The exact portal-view cones installed at the walk's building look-in +/// DrawCells turns. Retail RenderDeviceD3D::DrawMesh +/// @0x005A0860 tests each object's drawing sphere against these views before +/// drawing the mesh whole; cell membership alone is not an admission rule. +/// +internal interface IWalkLookInViewSource +{ + IReadOnlyList LookInCellTurns { get; } + + bool SphereVisibleInLookInTurn( + int routeIndex, + in Vector3 center, + float radius); +} + +internal readonly record struct WalkLookInSlice(int PlaneStart, int PlaneCount); + +internal readonly record struct WalkLookInTurn( + uint CellId, + int SliceStart, + int SliceCount); + /// See for what each field means per /// kind. A single struct (rather than a kind hierarchy) keeps Collect's /// per-turn list a flat, allocation-cheap List<WalkFrameEvent> — @@ -320,6 +361,9 @@ internal readonly struct WalkFrameEvent internal static WalkFrameEvent BuildingShellParticles(WalkBuilding building) => new(WalkFrameEventKind.StaticParticles, 0, 0, 0f, null, building); + internal static WalkFrameEvent LookInDynamics(uint cellId, int routeIndex) => + new(WalkFrameEventKind.LookInDynamics, routeIndex, cellId, 0f, null); + internal static WalkFrameEvent ClearInteriorDepth() => new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null); @@ -404,7 +448,7 @@ internal readonly struct WalkFrameEvent /// @0x0059f2a0 (the part->gfxobj[deg_level]!=0 gate @0x0059f2d3 /// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345). /// -internal sealed class WalkFrameDriver : IWalkEventSink +internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource { private readonly WbDrawDispatcher _dispatcher; private readonly WalkStaticStreamPopulator _populator; @@ -422,6 +466,23 @@ internal sealed class WalkFrameDriver : IWalkEventSink // walk pass dedicated to nothing but set-gathering. internal HashSet VisitedCells { get; } = new(); + /// Building look-in cells in the exact order the walk encountered + /// their DrawCells turns. Duplicates are intentional: two authored + /// portal views can independently visit the same cell and therefore own + /// distinct packed route indices. + internal List LookInCellTurns { get; } = new(); + + private readonly List _lookInTurns = new(); + private readonly List _lookInSlices = new(); + private readonly List _lookInPlanes = new(); + private WalkPlane _lookInCyPlane; + + IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; + + /// The set form of , for drawn-once + /// exclusion and root-flood particle bookkeeping. + internal HashSet LookInCells { get; } = new(); + /// FW4 slice 2: the interior root's ORDERED flood cell list, /// exactly as retail's PView::DrawCells iterates it for the /// exit-portal seals (pc:432785-432786) — captured at @@ -459,6 +520,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink private bool _skyDrawnThisFrame; private WalkDrawStage? _currentDcStage; private bool _readyToReplay; + private int _lookInRouteIndex; internal WalkFrameDriver( WbDrawDispatcher dispatcher, @@ -534,7 +596,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink /// GPU work — see this type's own doc comment. /// internal void BeginFrame( - IWalkBuildingFrameContext ctx, + IRetailFrameWalkContext ctx, Matrix4x4 viewProjection, Vector3 cameraWorldPosition) { @@ -557,9 +619,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink _events.Clear(); _markPositions.Clear(); VisitedCells.Clear(); + LookInCellTurns.Clear(); + _lookInTurns.Clear(); + _lookInSlices.Clear(); + _lookInPlanes.Clear(); + _lookInCyPlane = ctx.CyPlane; + LookInCells.Clear(); VisitedBuildings.Clear(); VisitedLandscapeCellIds.Clear(); InteriorFloodCells.Clear(); + _lookInRouteIndex = 0; } /// Records the final segment mark (plan §FW3.2b-1's "at frame @@ -655,6 +724,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink if (_staticParticleOwnerScratch.Count > 0) _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); break; + case WalkFrameEventKind.LookInDynamics: + _staticParticleOwnerScratch.Clear(); + UnionOwners( + _worldData.GetCellStatics(e.CellId), + _staticParticleOwnerScratch); + _leafRenderer.DrawLookInDynamics( + e.CellId, + e.IntArg, + _staticParticleOwnerScratch); + break; } } @@ -883,6 +962,76 @@ internal sealed class WalkFrameDriver : IWalkEventSink _populator.PopulateCell( _stream, stage, cellId, records.Records, records.TupleLandblockId, _cameraWorldPosition, _viewProjection); + if (stage == WalkDrawStage.LookInStatic) + { + // Retail draws a look-in cell's complete object list at this + // re-entrant DrawCells turn. The packed dynamic route used to run + // much later at the pre-clear boundary, after nearer building + // shells, which let the cathedral's 0x112 remote player overpaint + // opaque walls. Keep animation/fade in the packed route, but replay + // it here between this cell's content and the building shell. + MarkIfGrown(); + int routeIndex = _lookInRouteIndex++; + LookInCellTurns.Add(cellId); + LookInCells.Add(cellId); + CaptureLookInViews(cellId); + _events.Add(WalkFrameEvent.LookInDynamics(cellId, routeIndex)); + } + } + + private void CaptureLookInViews(uint cellId) + { + IWalkBuildingFrameContext ctx = RequireOpenFrame(); + WalkCell? cell = ctx.GetVisible(cellId); + if (cell is null || cell.NumView <= 0) + { + _lookInTurns.Add(new WalkLookInTurn( + cellId, _lookInSlices.Count, 0)); + return; + } + + WalkPortalView portalView = cell.TopView; + int sliceStart = _lookInSlices.Count; + for (int sliceIndex = 0; sliceIndex < portalView.ViewCount; sliceIndex++) + { + WalkViewPoly poly = portalView.View.Polys[sliceIndex]; + int planeStart = _lookInPlanes.Count; + for (int edge = 0; edge < poly.VertexCount; edge++) + { + _lookInPlanes.Add( + portalView.View.Vertices[poly.VertexIndex + edge].Plane); + } + _lookInSlices.Add(new WalkLookInSlice( + planeStart, poly.VertexCount)); + } + _lookInTurns.Add(new WalkLookInTurn( + cellId, sliceStart, _lookInSlices.Count - sliceStart)); + } + + public bool SphereVisibleInLookInTurn( + int routeIndex, + in Vector3 center, + float radius) + { + if ((uint)routeIndex >= (uint)_lookInTurns.Count) + return false; + + WalkLookInTurn turn = _lookInTurns[routeIndex]; + for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++) + { + WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset]; + if (WalkVisibilityMath.ViewconeCheck( + center, + radius, + _lookInCyPlane, + CollectionsMarshal.AsSpan(_lookInPlanes).Slice( + slice.PlaneStart, + slice.PlaneCount)) != WalkBoundingType.Outside) + { + return true; + } + } + return false; } /// Campaign FW3.4a: the collect-time analogue of the old diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index f5e16ffa..61611676 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -6,6 +6,7 @@ using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Selection; using AcDream.Core.World; +using AcDream.App.Rendering.Walk; using DatReaderWriter.Enums; namespace AcDream.App.Rendering.Wb; @@ -40,6 +41,8 @@ public sealed unsafe partial class WbDrawDispatcher private RenderSceneGeneration _packedProductionGeneration; private ulong _packedProductionFrameSequence; private int _packedProductionNextRange; + private readonly Dictionary<(int RouteIndex, uint LocalEntityId, int PartIndex, uint GfxObjId), string> + _probeLookInPartStates = []; internal IReadOnlyList PackedDispatcherSubmissions => _packedSubmissions; @@ -295,7 +298,12 @@ public sealed unsafe partial class WbDrawDispatcher source.MeshPartOffset, source.MeshPartCount), ref anyVao, - publishSelection); + publishSelection, + range.Route is RenderFrameCandidateRoute.LookInObject + ? view.WalkLookInViews + : null, + range.RouteIndex, + range.CellId); } return new PackedRangeClassification( @@ -334,7 +342,10 @@ public sealed unsafe partial class WbDrawDispatcher in RenderInstanceCandidate entity, ReadOnlySpan meshParts, ref uint anyVao, - bool publishSelection) + bool publishSelection, + IWalkLookInViewSource? lookInViews, + int lookInRouteIndex, + uint lookInCellId) { (uint slot, bool culled) = ResolveSlotForFrame( _clipRoutingActive, @@ -361,7 +372,8 @@ public sealed unsafe partial class WbDrawDispatcher : new Vector2(0f, 1f); PackedProjectionClassificationEntry? cacheEntry = null; - if (!entity.Animated) + bool lookInConeActive = lookInViews is not null; + if (!entity.Animated && !lookInConeActive) { PackedClassificationIdentity identity = PackedClassificationIdentity.From(in projection); @@ -394,7 +406,7 @@ public sealed unsafe partial class WbDrawDispatcher entity.ProjectionId, in identity); } - else + else if (entity.Animated) { _packedClassificationCache.RecordAnimatedClassification(); } @@ -485,6 +497,21 @@ public sealed unsafe partial class WbDrawDispatcher partTransform * meshRef.PartTransform; Matrix4x4 model = restPose * entity.RootWorld; + int selectionPartIndex = unchecked( + (partIndex << 16) + | (setupPartIndex & 0xFFFF)); + if (!PartVisibleInLookInTurn( + lookInViews, + lookInRouteIndex, + lookInCellId, + in entity, + selectionPartIndex, + (uint)gfxObjId, + partData, + model)) + { + continue; + } if (!ClassifyPackedBatches( partData, restPose, @@ -502,9 +529,6 @@ public sealed unsafe partial class WbDrawDispatcher { reusableAcrossFrames = false; } - int selectionPartIndex = unchecked( - (partIndex << 16) - | (setupPartIndex & 0xFFFF)); cacheEntry?.SelectionParts.Add( new PackedClassifiedSelectionPart( selectionPartIndex, @@ -538,6 +562,18 @@ public sealed unsafe partial class WbDrawDispatcher Matrix4x4 restPose = meshRef.PartTransform; Matrix4x4 model = restPose * entity.RootWorld; + if (!PartVisibleInLookInTurn( + lookInViews, + lookInRouteIndex, + lookInCellId, + in entity, + partIndex, + meshRef.GfxObjId, + renderData, + model)) + { + continue; + } if (!ClassifyPackedBatches( renderData, restPose, @@ -576,6 +612,97 @@ public sealed unsafe partial class WbDrawDispatcher } } + /// + /// Retail RenderDeviceD3D::DrawMesh @0x005A0860 admits each CGfxObj + /// independently by transforming its drawing sphere into the active + /// portal view. A whole-entity sphere is not equivalent for multipart + /// creatures: the union can be roughly ten metres wide and intersect an + /// aperture while every actual body/equipment part is behind its wall. + /// + private bool PartVisibleInLookInTurn( + IWalkLookInViewSource? lookInViews, + int routeIndex, + uint cellId, + in RenderInstanceCandidate entity, + int partIndex, + uint gfxObjId, + ObjectRenderData renderData, + Matrix4x4 localToWorld) + { + if (lookInViews is null) + return true; + + // ObjectRenderData.SelectionSphere is retained per GfxObj by the + // prepared mesh payload. It is never the entity-union sphere and is + // therefore the correct granularity for DrawMesh admission. The + // current package stores a conservative vertex-derived sphere here; + // this preserves whole-mesh drawing while avoiding the invalid + // aggregate-character admission that caused the cathedral bleed. + if (renderData.SelectionSphere is not { Radius: > 0f } sphere) + return true; + + bool visible = LookInDrawingSphereVisible( + lookInViews, + routeIndex, + sphere, + localToWorld, + out Vector3 center, + out float radius); + + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled + && cellId == 0xF4180112u) + { + var key = (routeIndex, entity.LocalEntityId, partIndex, gfxObjId); + string state = + $"visible={(visible ? 1 : 0)} " + + $"center=({center.X:F2},{center.Y:F2},{center.Z:F2}) " + + $"r={radius:F2}"; + if (!_probeLookInPartStates.TryGetValue(key, out string? previous) + || previous != state) + { + _probeLookInPartStates[key] = state; + Console.WriteLine( + $"[lookin-part] route={routeIndex} cell=0x{cellId:X8} " + + $"id={entity.LocalEntityId:x} part={partIndex} " + + $"gfx=0x{gfxObjId:X8} {state}"); + } + } + + return visible; + } + + internal static bool LookInDrawingSphereVisible( + IWalkLookInViewSource lookInViews, + int routeIndex, + DatReaderWriter.Types.Sphere sphere, + Matrix4x4 localToWorld, + out Vector3 center, + out float radius) + { + ArgumentNullException.ThrowIfNull(lookInViews); + ArgumentNullException.ThrowIfNull(sphere); + + center = Vector3.Transform(sphere.Origin, localToWorld); + float scaleX = new Vector3( + localToWorld.M11, + localToWorld.M12, + localToWorld.M13).Length(); + float scaleY = new Vector3( + localToWorld.M21, + localToWorld.M22, + localToWorld.M23).Length(); + float scaleZ = new Vector3( + localToWorld.M31, + localToWorld.M32, + localToWorld.M33).Length(); + radius = sphere.Radius + * MathF.Max(scaleX, MathF.Max(scaleY, scaleZ)); + return lookInViews.SphereVisibleInLookInTurn( + routeIndex, + in center, + radius); + } + /// /// Mirrors the production dispatcher's no-VAO early return. That return /// records an empty submission with transparent deferral disabled even diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index eba4ea90..3402154a 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -3,12 +3,23 @@ using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene.Arch; using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Walk; using AcDream.Core.World; namespace AcDream.App.Tests.Rendering; public sealed class RenderScenePViewFrameProductTests { + private sealed class RejectAllLookInViews(uint cellId) : IWalkLookInViewSource + { + public IReadOnlyList LookInCellTurns { get; } = [cellId]; + + public bool SphereVisibleInLookInTurn( + int routeIndex, + in Vector3 center, + float radius) => false; + } + private const uint Landblock = 0xA9B4FFFF; private const uint Cell = 0xA9B40170; private static readonly RenderSceneGeneration Generation = @@ -159,6 +170,7 @@ public sealed class RenderScenePViewFrameProductTests clip, viewcone, [], + null, [Cell], EmptyCellSource.Instance, [], @@ -168,6 +180,134 @@ public sealed class RenderScenePViewFrameProductTests } } + [Fact] + public void Builder_KeysLookInDynamicsToWalkTurnsAndExcludesThemFromDynamicLast() + { + const uint lookInCell = 0xA9B4_0171; + using var scene = new ArchRenderScene(Generation); + RenderProjectionRecord rootDynamic = Record( + 0x0100_0000_0000_0021, + RenderProjectionClass.LiveDynamicRoot, + parentCell: Cell); + RenderProjectionRecord lookInDynamic = Record( + 0x0100_0000_0000_0022, + RenderProjectionClass.LiveDynamicRoot, + parentCell: lookInCell); + scene.Apply( + [ + RenderProjectionDelta.Register(Generation, 1, rootDynamic), + RenderProjectionDelta.Register(Generation, 2, lookInDynamic), + ]); + + PortalVisibilityFrame portal = Portal(Cell); + ClipFrameAssembly clip = FullScreenClip(Cell); + ViewconeCuller viewcone = ViewconeCuller.Build(clip, Matrix4x4.Identity); + RenderSceneQuery query = scene.OpenQuery(); + var input = new RenderScenePViewBuildInput( + query, + new RenderSceneDigest(query.Generation, query.Counts, default), + portal, + clip, + viewcone, + LookInCellTurns: [lookInCell], + WalkLookInViews: null, + DrawableCells: [Cell, lookInCell], + Cells: EmptyCellSource.Instance, + AnimatedEntityIds: [], + RootIsOutdoor: false, + RootFloodCells: new HashSet { Cell }); + var exchange = new RenderFrameExchange(); + var builder = new RenderScenePViewFrameBuilder(); + + builder.Build(exchange, frameSequence: 1, in input); + RenderFrameView frame = exchange.BorrowLatest(Generation, 1); + try + { + RenderFrameCandidateRange lookInRange = Assert.Single( + frame.RouteRanges.ToArray(), + range => range.Route == RenderFrameCandidateRoute.LookInObject); + Assert.Equal(0, lookInRange.RouteIndex); + Assert.Equal(lookInCell, lookInRange.CellId); + Assert.Equal( + [lookInDynamic.Id], + frame.RouteCandidates + .Slice(lookInRange.Offset, lookInRange.Count) + .ToArray() + .Select(static record => record.Id)); + + RenderFrameCandidateRange dynamicLast = Assert.Single( + frame.RouteRanges.ToArray(), + range => range.Route == RenderFrameCandidateRoute.DynamicLast); + RenderProjectionId[] lastIds = frame.RouteCandidates + .Slice(dynamicLast.Offset, dynamicLast.Count) + .ToArray() + .Select(static record => record.Id) + .ToArray(); + Assert.Contains(rootDynamic.Id, lastIds); + Assert.DoesNotContain(lookInDynamic.Id, lastIds); + } + finally + { + exchange.Release(in frame); + } + } + + [Fact] + public void Builder_CarriesExactWalkConeForPerPartDispatcherAdmission() + { + const uint lookInCell = 0xA9B4_0171; + using var scene = new ArchRenderScene(Generation); + RenderProjectionRecord lookInDynamic = Record( + 0x0100_0000_0000_0022, + RenderProjectionClass.LiveDynamicRoot, + parentCell: lookInCell); + scene.Apply([RenderProjectionDelta.Register(Generation, 1, lookInDynamic)]); + + PortalVisibilityFrame portal = Portal(Cell); + ClipFrameAssembly clip = FullScreenClip(Cell); + ViewconeCuller viewcone = ViewconeCuller.Build(clip, Matrix4x4.Identity); + RenderSceneQuery query = scene.OpenQuery(); + var input = new RenderScenePViewBuildInput( + query, + new RenderSceneDigest(query.Generation, query.Counts, default), + portal, + clip, + viewcone, + LookInCellTurns: [lookInCell], + WalkLookInViews: new RejectAllLookInViews(lookInCell), + DrawableCells: [Cell, lookInCell], + Cells: EmptyCellSource.Instance, + AnimatedEntityIds: [], + RootIsOutdoor: false, + RootFloodCells: new HashSet { Cell }); + var exchange = new RenderFrameExchange(); + var builder = new RenderScenePViewFrameBuilder(); + + builder.Build(exchange, frameSequence: 1, in input); + RenderFrameView frame = exchange.BorrowLatest(Generation, 1); + try + { + RenderFrameCandidateRange range = Assert.Single( + frame.RouteRanges.ToArray(), + candidate => candidate.Route == RenderFrameCandidateRoute.LookInObject); + Assert.Equal( + [lookInDynamic.Id], + frame.RouteCandidates + .Slice(range.Offset, range.Count) + .ToArray() + .Select(static record => record.Id)); + Assert.NotNull(frame.WalkLookInViews); + Assert.False(frame.WalkLookInViews!.SphereVisibleInLookInTurn( + routeIndex: 0, + center: Vector3.Zero, + radius: 1f)); + } + finally + { + exchange.Release(in frame); + } + } + [Fact] public void Controller_MatchesCurrentPViewThenNamesAWithdrawnCandidate() { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 6c88a97e..3d99ed61 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -67,6 +67,18 @@ public sealed class WalkFrameDriverTests sorted.Sort(); log.Add($"PARTICLES:{string.Join(",", sorted.ConvertAll(o => o.ToString("x")))}"); } + + public void DrawLookInDynamics( + uint cellId, + int routeIndex, + IReadOnlySet staticParticleOwnerIds) + { + var sorted = new List(staticParticleOwnerIds); + sorted.Sort(); + log.Add( + $"LOOKIN:{cellId:x8}:{routeIndex}:" + + string.Join(",", sorted.ConvertAll(o => o.ToString("x")))); + } } private sealed class RecordingTrace(List log) : IWalkFrameDriverTrace @@ -417,10 +429,28 @@ public sealed class WalkFrameDriverTests new[] { "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", - "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell", "PARTICLES:c9", + "FLUSH:1:LookInStatic", "LOOKIN:00000104:0:ca", + "FLUSH:1:BuildingShell", "PARTICLES:c9", }, log); + Assert.Equal([0x104u], driver.LookInCellTurns); + Assert.Equal([0x104u], driver.LookInCells); + WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0]; + WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys); + Vector2 capturedCenter = Vector2.Zero; + for (int edge = 0; edge < capturedPoly.VertexCount; edge++) + { + capturedCenter += capturedView.View.Vertices[ + capturedPoly.VertexIndex + edge].Point; + } + capturedCenter /= capturedPoly.VertexCount; + Vector3 insideCone = ctx.Rays.RayThrough(capturedCenter.X, capturedCenter.Y); + Assert.True(driver.SphereVisibleInLookInTurn( + 0, in insideCone, 0.1f)); + Assert.False(driver.SphereVisibleInLookInTurn( + 0, new Vector3(10_000f, 0f, 10f), 0.1f)); + // The punch polygon reached the leaf renderer in WORLD space: the // building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by // (10,0,0) under the caller-supplied building world transform. diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs new file mode 100644 index 00000000..993df47f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs @@ -0,0 +1,60 @@ +using System.Numerics; +using AcDream.App.Rendering.Walk; +using AcDream.App.Rendering.Wb; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class WbDrawDispatcherLookInConeTests +{ + [Fact] + public void DrawingSphereAdmissionUsesTheIndividualPartTransformAndLargestScale() + { + var cone = new RecordingLookInViews(); + var sphere = new Sphere + { + Origin = new Vector3(1f, 2f, 3f), + Radius = 0.5f, + }; + Matrix4x4 transform = + Matrix4x4.CreateScale(2f, 3f, 4f) + * Matrix4x4.CreateTranslation(10f, 20f, 30f); + + bool visible = WbDrawDispatcher.LookInDrawingSphereVisible( + cone, + routeIndex: 7, + sphere, + transform, + out Vector3 center, + out float radius); + + Assert.True(visible); + Assert.Equal(7, cone.RouteIndex); + Assert.Equal(new Vector3(12f, 26f, 42f), center); + Assert.Equal(center, cone.Center); + Assert.Equal(2f, radius); + Assert.Equal(radius, cone.Radius); + } + + private sealed class RecordingLookInViews : IWalkLookInViewSource + { + public IReadOnlyList LookInCellTurns { get; } = [0xF4180112u]; + + public int RouteIndex { get; private set; } + + public Vector3 Center { get; private set; } + + public float Radius { get; private set; } + + public bool SphereVisibleInLookInTurn( + int routeIndex, + in Vector3 center, + float radius) + { + RouteIndex = routeIndex; + Center = center; + Radius = radius; + return true; + } + } +}