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

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