checkpoint: preserve cathedral look-in investigation state

This commit is contained in:
Erik 2026-08-30 23:29:28 +02:00
parent c287db8651
commit 572de1ec30
11 changed files with 757 additions and 197 deletions

View file

@ -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` |

View file

@ -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<uint> _singleCellScratch = new();
private readonly List<uint> _singleCellListScratch = new();
private readonly HashSet<uint> _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));
/// <summary>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.</summary>
internal void BindEntityFrame(in RenderFrameView view)
{
_entityFrame = view;
_entityFrameBound = true;
}
public void DrawLookInDynamics(
uint cellId,
int routeIndex,
IReadOnlySet<uint> 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) =>

View file

@ -138,6 +138,13 @@ internal sealed partial class RetailPViewPassExecutor :
private readonly Dictionary<uint, int> _singleCellClipRouting = new(1);
private readonly Dictionary<uint, int> _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; }
/// <summary>
/// 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;
}
}
/// <summary>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,

View file

@ -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
/// <see cref="DrawWalkDrivenStatics"/>); 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.</summary>
/// 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.</summary>
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();
}
/// <summary>Campaign FW3.2b-2: the DYNAMICS-only remainder of the old
/// <see cref="DrawBuildingLookIns"/> — punches, shells, and look-in cell
/// STATICS are now walk-owned (<see cref="Walk.WalkFrameDriver"/>'s
/// Building/BuildingShell/LookInStatic turns); this method keeps ONLY the
/// LookInObject route (now dynamic-classified — see
/// <c>RenderScenePViewFrameBuilder.BuildLookInRoutes</c>) 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).</summary>
private void DrawBuildingLookInDynamics(
/// <summary>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.</summary>
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(

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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);

View file

@ -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<PortalVisibilityFrame> LookInFrames,
IReadOnlyList<uint> LookInCellTurns,
IWalkLookInViewSource? WalkLookInViews,
HashSet<uint> DrawableCells,
IRetailPViewCellSource Cells,
HashSet<uint>? AnimatedEntityIds,
@ -185,7 +187,8 @@ internal sealed class RenderScenePViewFrameProductController :
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
ViewconeCuller viewcone,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
IReadOnlyList<uint> lookInCellTurns,
IWalkLookInViewSource? walkLookInViews,
HashSet<uint> drawableCells,
IRetailPViewCellSource cells,
HashSet<uint>? 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<PortalVisibilityFrame> lookInFrames)
{
var cells = new List<uint>();
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).</summary>
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);

View file

@ -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).</summary>
void DrawStaticParticles(IReadOnlySet<uint> ownerIds);
/// <summary>Draws the packed dynamic occupants of one building look-in
/// cell at that cell's OWN <c>PView::DrawCells</c> 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
/// <c>LookInObject</c> ranges.</summary>
void DrawLookInDynamics(
uint cellId,
int routeIndex,
IReadOnlySet<uint> staticParticleOwnerIds);
/// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear
/// (pc:432731-432732) between the outside stage and the interior root's
/// own flood — production maps this to <c>IWorldPassScope.ClearInteriorDepth</c>
@ -259,8 +271,37 @@ internal enum WalkFrameEventKind : byte
/// cathedral bleed; the old pipeline's user-verified #132 fix
/// `e102fb36` encoded the same invariant).</summary>
StaticParticles,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawLookInDynamics"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the look-in cell and
/// <see cref="WalkFrameEvent.IntArg"/> is its walk-ordered packed route
/// index.</summary>
LookInDynamics,
}
/// <summary>
/// The exact portal-view cones installed at the walk's building look-in
/// <c>DrawCells</c> turns. Retail <c>RenderDeviceD3D::DrawMesh</c>
/// @0x005A0860 tests each object's drawing sphere against these views before
/// drawing the mesh whole; cell membership alone is not an admission rule.
/// </summary>
internal interface IWalkLookInViewSource
{
IReadOnlyList<uint> 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);
/// <summary>See <see cref="WalkFrameEventKind"/> 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 <c>List&lt;WalkFrameEvent&gt;</c> —
@ -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 <c>part-&gt;gfxobj[deg_level]!=0</c> gate @0x0059f2d3
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b0x0059f345).</para>
/// </summary>
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<uint> VisitedCells { get; } = new();
/// <summary>Building look-in cells in the exact order the walk encountered
/// their <c>DrawCells</c> turns. Duplicates are intentional: two authored
/// portal views can independently visit the same cell and therefore own
/// distinct packed route indices.</summary>
internal List<uint> LookInCellTurns { get; } = new();
private readonly List<WalkLookInTurn> _lookInTurns = new();
private readonly List<WalkLookInSlice> _lookInSlices = new();
private readonly List<WalkPlane> _lookInPlanes = new();
private WalkPlane _lookInCyPlane;
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
/// <summary>The set form of <see cref="LookInCellTurns"/>, for drawn-once
/// exclusion and root-flood particle bookkeeping.</summary>
internal HashSet<uint> LookInCells { get; } = new();
/// <summary>FW4 slice 2: the interior root's ORDERED flood cell list,
/// exactly as retail's <c>PView::DrawCells</c> 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.
/// </summary>
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;
}
/// <summary>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;
}
/// <summary>Campaign FW3.4a: the collect-time analogue of the old

View file

@ -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<CurrentRenderDispatcherSubmission>
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<RenderFrameMeshPart> 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
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Mirrors the production dispatcher's no-VAO early return. That return
/// records an empty submission with transparent deferral disabled even

View file

@ -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<uint> 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<uint> { 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<uint> { 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()
{

View file

@ -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<uint> staticParticleOwnerIds)
{
var sorted = new List<uint>(staticParticleOwnerIds);
sorted.Sort();
log.Add(
$"LOOKIN:{cellId:x8}:{routeIndex}:"
+ string.Join(",", sorted.ConvertAll(o => o.ToString("x"))));
}
}
private sealed class RecordingTrace(List<string> 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.

View file

@ -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<uint> 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;
}
}
}