fix(render): clip walk content to authored portal views

This commit is contained in:
Erik 2026-08-31 06:15:12 +02:00
parent 90eba0ec3c
commit 11e68aad82
10 changed files with 408 additions and 78 deletions

View file

@ -209,6 +209,7 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
private readonly Action _drawExitSeals; private readonly Action _drawExitSeals;
private readonly HashSet<uint> _singleCellScratch = new(); private readonly HashSet<uint> _singleCellScratch = new();
private readonly List<uint> _singleCellListScratch = new(); private readonly List<uint> _singleCellListScratch = new();
private readonly Dictionary<uint, int> _singleCellClipScratch = new(1);
internal WalkProductionLeafRenderer( internal WalkProductionLeafRenderer(
RetailPViewPassExecutor passes, RetailPViewPassExecutor passes,
@ -231,7 +232,12 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void DrawTerrainSlice(int sliceIndex) => public void DrawTerrainSlice(int sliceIndex) =>
_passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex); _passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex);
public void DrawCellShell(uint cellId) public void DrawCellShell(uint cellId, uint clipSlot)
{
_singleCellClipScratch.Clear();
_singleCellClipScratch.Add(cellId, checked((int)clipSlot));
_passes.SetCellShellClipRouting(_singleCellClipScratch);
try
{ {
_singleCellScratch.Clear(); _singleCellScratch.Clear();
_singleCellScratch.Add(cellId); _singleCellScratch.Add(cellId);
@ -243,6 +249,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch); _passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
} }
} }
finally
{
_passes.SetCellShellClipRouting(null);
}
}
public void ClearInteriorDepth() => _clearInteriorDepth(); public void ClearInteriorDepth() => _clearInteriorDepth();

View file

@ -240,6 +240,9 @@ internal sealed partial class RetailPViewPassExecutor :
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => public void DrawOpaqueCellShells(HashSet<uint> cellIds) =>
_envCells.Render(WbRenderPass.Opaque, cellIds); _envCells.Render(WbRenderPass.Opaque, cellIds);
public void SetCellShellClipRouting(IReadOnlyDictionary<uint, int>? routing) =>
_envCells.SetClipRouting(routing);
public bool CellHasTransparentShell(uint cellId) => public bool CellHasTransparentShell(uint cellId) =>
_envCells.CellHasTransparent(cellId); _envCells.CellHasTransparent(cellId);

View file

@ -215,7 +215,8 @@ internal sealed class RetailPViewRenderer
walkDriver = new Walk.WalkFrameDriver( walkDriver = new Walk.WalkFrameDriver(
walkExecutor!.Dispatcher, walkExecutor!.Dispatcher,
walkLeafRenderer, walkLeafRenderer,
_walkWorldData); _walkWorldData,
clipFrame: clipAssembly.Frame);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
{ {

View file

@ -120,7 +120,7 @@ internal interface IWalkFrameLeafRenderer
/// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d. /// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d.
/// The ordinary interior-root flood and every building look-in flood use /// The ordinary interior-root flood and every building look-in flood use
/// this same two-pass discipline.</summary> /// this same two-pass discipline.</summary>
void DrawCellShell(uint cellId); void DrawCellShell(uint cellId, uint clipSlot);
/// <summary>One landscape cell's or building shell's static-owner /// <summary>One landscape cell's or building shell's static-owner
/// particle submission, at its own walk turn — see /// particle submission, at its own walk turn — see
@ -231,7 +231,9 @@ internal enum WalkFrameEventKind : byte
TerrainSlice, TerrainSlice,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> — /// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell.</summary> /// <see cref="WalkFrameEvent.CellId"/> is the cell and
/// <see cref="WalkFrameEvent.IntArg"/> is the exact portal-view GPU clip
/// slot for this retail <c>DrawEnvCell</c> turn.</summary>
CellShell, CellShell,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — /// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> —
@ -288,9 +290,23 @@ internal interface IWalkLookInViewSource
int routeIndex, int routeIndex,
in Vector3 center, in Vector3 center,
float radius); float radius);
/// <summary>Returns the GPU clip slots for every view slice in this
/// DrawObjCell turn which admits the part's retail drawing sphere. Retail
/// runs DrawMeshInternal once per surviving portal_view slice; a boolean
/// sphere gate alone is insufficient because the mesh polygons must remain
/// clipped to that aperture.</summary>
IReadOnlyList<uint> VisibleClipSlotsInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere);
} }
internal readonly record struct WalkLookInSlice(int PlaneStart, int PlaneCount); internal readonly record struct WalkLookInSlice(
int PlaneStart,
int PlaneCount,
uint ClipSlot);
internal readonly record struct WalkLookInTurn( internal readonly record struct WalkLookInTurn(
uint CellId, uint CellId,
@ -341,8 +357,8 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent TerrainSlice(int sliceIndex) => internal static WalkFrameEvent TerrainSlice(int sliceIndex) =>
new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null); new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null);
internal static WalkFrameEvent CellShell(uint cellId) => internal static WalkFrameEvent CellShell(uint cellId, uint clipSlot) =>
new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null); new(WalkFrameEventKind.CellShell, checked((int)clipSlot), cellId, 0f, null);
internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) => internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon); new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon);
@ -452,6 +468,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly IWalkFrameLeafRenderer _leafRenderer; private readonly IWalkFrameLeafRenderer _leafRenderer;
private readonly IWalkFrameWorldData _worldData; private readonly IWalkFrameWorldData _worldData;
private readonly IWalkFrameDriverTrace? _trace; private readonly IWalkFrameDriverTrace? _trace;
private readonly ClipFrame? _clipFrame;
private readonly OrderedDrawStream _stream = new(); private readonly OrderedDrawStream _stream = new();
private readonly List<WalkFrameEvent> _events = new(); private readonly List<WalkFrameEvent> _events = new();
private readonly List<int> _markPositions = new(); private readonly List<int> _markPositions = new();
@ -472,6 +489,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly List<WalkLookInTurn> _lookInTurns = new(); private readonly List<WalkLookInTurn> _lookInTurns = new();
private readonly List<WalkLookInSlice> _lookInSlices = new(); private readonly List<WalkLookInSlice> _lookInSlices = new();
private readonly List<WalkPlane> _lookInPlanes = new(); private readonly List<WalkPlane> _lookInPlanes = new();
private readonly List<uint> _visibleClipSlotScratch = new();
private readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane; private WalkPlane _lookInCyPlane;
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
@ -524,12 +543,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
IWalkFrameLeafRenderer leafRenderer, IWalkFrameLeafRenderer leafRenderer,
IWalkFrameWorldData worldData, IWalkFrameWorldData worldData,
IWalkFrameDriverTrace? trace = null) IWalkFrameDriverTrace? trace = null,
ClipFrame? clipFrame = null)
{ {
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer)); _leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer));
_worldData = worldData ?? throw new ArgumentNullException(nameof(worldData)); _worldData = worldData ?? throw new ArgumentNullException(nameof(worldData));
_trace = trace; _trace = trace;
_clipFrame = clipFrame;
_populator = new WalkStaticStreamPopulator(dispatcher); _populator = new WalkStaticStreamPopulator(dispatcher);
} }
@ -621,6 +642,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInTurns.Clear(); _lookInTurns.Clear();
_lookInSlices.Clear(); _lookInSlices.Clear();
_lookInPlanes.Clear(); _lookInPlanes.Clear();
_visibleClipSlotScratch.Clear();
_lookInCyPlane = ctx.CyPlane; _lookInCyPlane = ctx.CyPlane;
LookInCells.Clear(); LookInCells.Clear();
VisitedBuildings.Clear(); VisitedBuildings.Clear();
@ -699,7 +721,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_leafRenderer.DrawTerrainSlice(e.IntArg); _leafRenderer.DrawTerrainSlice(e.IntArg);
break; break;
case WalkFrameEventKind.CellShell: case WalkFrameEventKind.CellShell:
_leafRenderer.DrawCellShell(e.CellId); _leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg));
break; break;
case WalkFrameEventKind.PunchFan: case WalkFrameEventKind.PunchFan:
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
@ -985,12 +1007,23 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList<uint> cells) private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList<uint> cells)
{ {
_floodViewRouteScratch.Clear();
for (int i = 0; i < cells.Count; i++)
_floodViewRouteScratch.Add(-1);
// PView::DrawCells @0x005A4840, loop 2 (005A4A00005A4ADE): // PView::DrawCells @0x005A4840, loop 2 (005A4A00005A4ADE):
// cell_draw_list[count - 1] down to zero, DrawEnvCell only. // cell_draw_list[count - 1] down to zero. DrawEnvCell loops the
// cell's live portal_view slices, so capture those slices here and
// replay the shell once per exact GPU clip slot.
for (int i = cells.Count - 1; i >= 0; i--) for (int i = cells.Count - 1; i >= 0; i--)
{ {
int viewRouteIndex = CaptureCellViewRoute(cells[i]);
_floodViewRouteScratch[i] = viewRouteIndex;
IReadOnlyList<uint> clipSlots = VisibleClipSlotsInLookInTurn(
viewRouteIndex, default, 0f, testSphere: false);
MarkIfGrown(); MarkIfGrown();
_events.Add(WalkFrameEvent.CellShell(cells[i])); for (int slotIndex = 0; slotIndex < clipSlots.Count; slotIndex++)
_events.Add(WalkFrameEvent.CellShell(cells[i], clipSlots[slotIndex]));
} }
// Loop 3 (005A4ADE005A4B2D): restart at count - 1 and draw each // Loop 3 (005A4ADE005A4B2D): restart at count - 1 and draw each
@ -998,23 +1031,31 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// static records and its dynamic/particle tail stay at that exact // static records and its dynamic/particle tail stay at that exact
// retail turn. // retail turn.
for (int i = cells.Count - 1; i >= 0; i--) for (int i = cells.Count - 1; i >= 0; i--)
EmitCellContentsTurn(stage, cells[i]); EmitCellContentsTurn(stage, cells[i], _floodViewRouteScratch[i]);
} }
private void EmitCellContentsTurn(WalkDrawStage stage, uint cellId) private int CaptureCellViewRoute(uint cellId)
{ {
int viewRouteIndex = _cellViewRouteIndex++;
CaptureCellViews(cellId);
return viewRouteIndex;
}
private void EmitCellContentsTurn(
WalkDrawStage stage,
uint cellId,
int viewRouteIndex)
{
// The shell loop captured the exact live portal_view for this cell.
// Static and dynamic object lists reuse that same route: retail's
// DrawEnvCell and DrawObjCell consume the same PortalList.
WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId); WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId);
_populator.PopulateCell( _populator.PopulateCell(
_stream, stage, cellId, records.Records, records.TupleLandblockId, _stream, stage, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection); _cameraWorldPosition, _viewProjection,
this, viewRouteIndex);
MarkIfGrown(); MarkIfGrown();
// PView::DrawObjCellForDummies draws this cell's live objects at the
// same turn as its statics. Capture the currently installed portal
// views before the walk pops them, then apply retail's per-GfxObj
// drawing-sphere viewcone check in the walk classifier.
int viewRouteIndex = _cellViewRouteIndex++;
CaptureCellViews(cellId);
if (stage == WalkDrawStage.LookInStatic) if (stage == WalkDrawStage.LookInStatic)
{ {
LookInCellTurns.Add(cellId); LookInCellTurns.Add(cellId);
@ -1062,8 +1103,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInPlanes.Add( _lookInPlanes.Add(
portalView.View.Vertices[poly.VertexIndex + edge].Plane); portalView.View.Vertices[poly.VertexIndex + edge].Plane);
} }
uint clipSlot = AppendClipSlot(portalView, poly);
_lookInSlices.Add(new WalkLookInSlice( _lookInSlices.Add(new WalkLookInSlice(
planeStart, poly.VertexCount)); planeStart, poly.VertexCount, clipSlot));
} }
_lookInTurns.Add(new WalkLookInTurn( _lookInTurns.Add(new WalkLookInTurn(
cellId, sliceStart, _lookInSlices.Count - sliceStart)); cellId, sliceStart, _lookInSlices.Count - sliceStart));
@ -1095,6 +1137,100 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
return false; return false;
} }
public IReadOnlyList<uint> VisibleClipSlotsInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere)
{
_visibleClipSlotScratch.Clear();
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
return _visibleClipSlotScratch;
WalkLookInTurn turn = _lookInTurns[routeIndex];
for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++)
{
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
if (!testSphere
|| WalkVisibilityMath.ViewconeCheck(
center,
radius,
_lookInCyPlane,
CollectionsMarshal.AsSpan(_lookInPlanes).Slice(
slice.PlaneStart,
slice.PlaneCount)) != WalkBoundingType.Outside)
{
_visibleClipSlotScratch.Add(slice.ClipSlot);
}
}
return _visibleClipSlotScratch;
}
/// <summary>Converts one retail pixel-space portal_view polygon into the
/// clip-space half-planes consumed by mesh_modern.vert. copy_view already
/// deduplicates and removes collinear vertices, so 3..8 points map
/// directly. The rare >8-edge case uses its convex AABB as a conservative
/// four-plane gate rather than falling back to slot 0 (which would erase
/// the aperture entirely).</summary>
private uint AppendClipSlot(WalkPortalView portalView, WalkViewPoly poly)
{
if (_clipFrame is null)
return 0;
IRetailFrameWalkContext ctx = (IRetailFrameWalkContext)RequireOpenFrame();
int count = poly.VertexCount;
if (count < 3)
return 0;
Span<Vector2> ndc = stackalloc Vector2[Math.Min(count, WalkCopyView.MaxVertices)];
float minX = float.MaxValue, minY = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue;
for (int i = 0; i < ndc.Length; i++)
{
Vector2 px = portalView.View.Vertices[poly.VertexIndex + i].Point;
Vector2 point = new(
px.X / ctx.ViewportWidth * 2f - 1f,
1f - px.Y / ctx.ViewportHeight * 2f);
ndc[i] = point;
minX = MathF.Min(minX, point.X);
minY = MathF.Min(minY, point.Y);
maxX = MathF.Max(maxX, point.X);
maxY = MathF.Max(maxY, point.Y);
}
if (ndc.Length > ClipFrame.MaxPlanes)
{
Span<Vector4> aabbPlanes = stackalloc Vector4[4]
{
new(1f, 0f, 0f, -minX),
new(-1f, 0f, 0f, maxX),
new(0f, 1f, 0f, -minY),
new(0f, -1f, 0f, maxY),
};
return checked((uint)_clipFrame.AppendSlot(aabbPlanes));
}
float area2 = 0f;
for (int i = 0; i < ndc.Length; i++)
area2 += ndc[i].X * ndc[(i + 1) % ndc.Length].Y
- ndc[(i + 1) % ndc.Length].X * ndc[i].Y;
bool ccw = area2 >= 0f;
Span<Vector4> planes = stackalloc Vector4[ClipFrame.MaxPlanes];
for (int i = 0; i < ndc.Length; i++)
{
int current = ccw ? i : ndc.Length - 1 - i;
int next = ccw
? (i + 1) % ndc.Length
: (ndc.Length - 2 - i + ndc.Length) % ndc.Length;
Vector2 p = ndc[current];
Vector2 q = ndc[next];
Vector2 dir = q - p;
Vector2 normal = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
planes[i] = new Vector4(normal.X, normal.Y, 0f, -Vector2.Dot(normal, p));
}
return checked((uint)_clipFrame.AppendSlot(planes[..ndc.Length]));
}
/// <summary>Campaign FW3.4a: the collect-time analogue of the old /// <summary>Campaign FW3.4a: the collect-time analogue of the old
/// immediate driver's <c>FlushIfNonEmpty</c> — records a /// immediate driver's <c>FlushIfNonEmpty</c> — records a
/// <see cref="WalkFrameEventKind.StreamMark"/> at the stream's current /// <see cref="WalkFrameEventKind.StreamMark"/> at the stream's current

View file

@ -64,14 +64,17 @@ internal sealed class WalkStaticStreamPopulator
ReadOnlySpan<RenderProjectionRecord> records, ReadOnlySpan<RenderProjectionRecord> records,
uint tupleLandblockId, uint tupleLandblockId,
Vector3 cameraWorldPosition, Vector3 cameraWorldPosition,
Matrix4x4 viewProjection) Matrix4x4 viewProjection,
IWalkLookInViewSource? views = null,
int viewRouteIndex = -1)
{ {
ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++) for (int i = 0; i < records.Length; i++)
{ {
ClassifyAndAppend( ClassifyAndAppend(
stream, stage, cellId, in records[i], tupleLandblockId, stream, stage, cellId, in records[i], tupleLandblockId,
cameraWorldPosition, viewProjection); cameraWorldPosition, viewProjection,
liveDynamic: false, views, viewRouteIndex);
} }
} }

View file

@ -274,23 +274,15 @@ public sealed partial class WbDrawDispatcher
Matrix4x4 model = restPose * entity.RootWorld; Matrix4x4 model = restPose * entity.RootWorld;
int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF)); int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF));
if (!PartVisibleInLookInTurn( IReadOnlyList<uint>? partClipSlots = ResolvePartClipSlots(
lookInViews, lookInViews, lookInRouteIndex, partData, model);
lookInRouteIndex, if (partClipSlots is { Count: 0 })
lookInCellId,
in entity,
selectionPartIndex,
(uint)gfxObjId,
partData,
model))
{
continue; continue;
}
EmitClassifiedBatches( EmitClassifiedBatches(
partData, model, in entity, meshRef, paletteIdentity, partData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubset, slot, lights, indoor, selectionLighting, entityHasCutoutSubset, slot, lights, indoor, selectionLighting,
detailCategory, opacity, batches); detailCategory, opacity, partClipSlots, batches);
selectionParts.Add(new WalkClassifiedSelectionPart( selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, selectionPartIndex, entity.ServerGuid, entity.LocalEntityId, selectionPartIndex,
(uint)gfxObjId, model)); (uint)gfxObjId, model));
@ -308,22 +300,14 @@ public sealed partial class WbDrawDispatcher
continue; continue;
Matrix4x4 model = meshRef.PartTransform * entity.RootWorld; Matrix4x4 model = meshRef.PartTransform * entity.RootWorld;
if (!PartVisibleInLookInTurn( IReadOnlyList<uint>? partClipSlots = ResolvePartClipSlots(
lookInViews, lookInViews, lookInRouteIndex, renderData, model);
lookInRouteIndex, if (partClipSlots is { Count: 0 })
lookInCellId,
in entity,
partIndex,
(uint)meshRef.GfxObjId,
renderData,
model))
{
continue; continue;
}
EmitClassifiedBatches( EmitClassifiedBatches(
renderData, model, in entity, meshRef, paletteIdentity, renderData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubsetOverride: null, slot, lights, indoor, entityHasCutoutSubsetOverride: null, slot, lights, indoor,
selectionLighting, detailCategory, opacity, batches); selectionLighting, detailCategory, opacity, partClipSlots, batches);
selectionParts.Add(new WalkClassifiedSelectionPart( selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, partIndex, entity.ServerGuid, entity.LocalEntityId, partIndex,
(uint)meshRef.GfxObjId, model)); (uint)meshRef.GfxObjId, model));
@ -379,28 +363,29 @@ public sealed partial class WbDrawDispatcher
lights = InstanceLightSet.From(selected); lights = InstanceLightSet.From(selected);
} }
private static bool PartVisibleInLookInTurn( private static IReadOnlyList<uint>? ResolvePartClipSlots(
IWalkLookInViewSource? lookInViews, IWalkLookInViewSource? lookInViews,
int routeIndex, int routeIndex,
uint cellId,
in RenderInstanceCandidate entity,
int partIndex,
uint gfxObjId,
ObjectRenderData renderData, ObjectRenderData renderData,
Matrix4x4 localToWorld) Matrix4x4 localToWorld)
{ {
if (lookInViews is null) if (lookInViews is null)
return true; return null;
if (renderData.SelectionSphere is not { Radius: > 0f } sphere) if (renderData.SelectionSphere is not { Radius: > 0f } sphere)
return true; {
return lookInViews.VisibleClipSlotsInLookInTurn(
return LookInDrawingSphereVisible(
lookInViews,
routeIndex, routeIndex,
sphere, Vector3.Zero,
localToWorld, radius: 0f,
out _, testSphere: false);
out _); }
TransformDrawingSphere(sphere, localToWorld, out Vector3 center, out float radius);
return lookInViews.VisibleClipSlotsInLookInTurn(
routeIndex,
in center,
radius,
testSphere: true);
} }
internal static bool LookInDrawingSphereVisible( internal static bool LookInDrawingSphereVisible(
@ -414,6 +399,19 @@ public sealed partial class WbDrawDispatcher
ArgumentNullException.ThrowIfNull(lookInViews); ArgumentNullException.ThrowIfNull(lookInViews);
ArgumentNullException.ThrowIfNull(sphere); ArgumentNullException.ThrowIfNull(sphere);
TransformDrawingSphere(sphere, localToWorld, out center, out radius);
return lookInViews.SphereVisibleInLookInTurn(
routeIndex,
in center,
radius);
}
private static void TransformDrawingSphere(
DatReaderWriter.Types.Sphere sphere,
Matrix4x4 localToWorld,
out Vector3 center,
out float radius)
{
center = Vector3.Transform(sphere.Origin, localToWorld); center = Vector3.Transform(sphere.Origin, localToWorld);
float scaleX = new Vector3( float scaleX = new Vector3(
localToWorld.M11, localToWorld.M11,
@ -429,10 +427,6 @@ public sealed partial class WbDrawDispatcher
localToWorld.M33).Length(); localToWorld.M33).Length();
radius = sphere.Radius radius = sphere.Radius
* MathF.Max(scaleX, MathF.Max(scaleY, scaleZ)); * MathF.Max(scaleX, MathF.Max(scaleY, scaleZ));
return lookInViews.SphereVisibleInLookInTurn(
routeIndex,
in center,
radius);
} }
/// <summary> /// <summary>
@ -454,6 +448,7 @@ public sealed partial class WbDrawDispatcher
Vector2 selectionLighting, Vector2 selectionLighting,
uint detailCategory, uint detailCategory,
float opacity, float opacity,
IReadOnlyList<uint>? clipSlots,
List<WalkClassifiedBatch> sink) List<WalkClassifiedBatch> sink)
{ {
bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset; bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset;
@ -466,10 +461,23 @@ public sealed partial class WbDrawDispatcher
if (!survives) if (!survives)
continue; continue;
if (clipSlots is null)
{
sink.Add(new WalkClassifiedBatch( sink.Add(new WalkClassifiedBatch(
key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity, key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity,
selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency), selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency),
LocalSortCenter: renderData.SortCenter)); LocalSortCenter: renderData.SortCenter));
continue;
}
for (int clipIndex = 0; clipIndex < clipSlots.Count; clipIndex++)
{
sink.Add(new WalkClassifiedBatch(
key, model, clipSlots[clipIndex], lights, indoor ? 1u : 0u,
Alpha: opacity, selectionLighting, detailCategory,
IsOpaque: IsOpaque(key.Translucency),
LocalSortCenter: renderData.SortCenter));
}
} }
} }

View file

@ -43,12 +43,17 @@ public sealed class WalkFrameDriverTests
private sealed class RecordingLeafRenderer(List<string> log) : IWalkFrameLeafRenderer private sealed class RecordingLeafRenderer(List<string> log) : IWalkFrameLeafRenderer
{ {
public readonly List<WalkPolygon> Punches = new(); public readonly List<WalkPolygon> Punches = new();
public readonly List<(uint CellId, uint ClipSlot)> Shells = new();
public void DrawSky() => log.Add("SKY"); public void DrawSky() => log.Add("SKY");
public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}");
public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}"); public void DrawCellShell(uint cellId, uint clipSlot)
{
Shells.Add((cellId, clipSlot));
log.Add($"SHELL:{cellId:x8}");
}
public void ClearInteriorDepth() => log.Add("CLEAR"); public void ClearInteriorDepth() => log.Add("CLEAR");
@ -427,7 +432,9 @@ public sealed class WalkFrameDriverTests
var leaf = new RecordingLeafRenderer(log); var leaf = new RecordingLeafRenderer(log);
var trace = new RecordingTrace(log); var trace = new RecordingTrace(log);
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace); using ClipFrame clipFrame = ClipFrame.NoClip();
var driver = new WalkFrameDriver(
fx.Dispatcher, leaf, worldData, trace, clipFrame);
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk();
var activeView = new WalkPortalView(); var activeView = new WalkPortalView();
@ -456,6 +463,13 @@ public sealed class WalkFrameDriverTests
Assert.Equal([0x104u], driver.LookInCellTurns); Assert.Equal([0x104u], driver.LookInCellTurns);
Assert.Equal([0x104u], driver.LookInCells); Assert.Equal([0x104u], driver.LookInCells);
Assert.Collection(
leaf.Shells,
shell =>
{
Assert.Equal(0x104u, shell.CellId);
Assert.NotEqual(0u, shell.ClipSlot);
});
WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0]; WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0];
WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys); WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys);
Vector2 capturedCenter = Vector2.Zero; Vector2 capturedCenter = Vector2.Zero;
@ -470,6 +484,10 @@ public sealed class WalkFrameDriverTests
0, in insideCone, 0.1f)); 0, in insideCone, 0.1f));
Assert.False(driver.SphereVisibleInLookInTurn( Assert.False(driver.SphereVisibleInLookInTurn(
0, new Vector3(10_000f, 0f, 10f), 0.1f)); 0, new Vector3(10_000f, 0f, 10f), 0.1f));
uint clipSlot = Assert.Single(driver.VisibleClipSlotsInLookInTurn(
0, in insideCone, 0.1f, testSphere: true));
Assert.NotEqual(0u, clipSlot);
Assert.Equal(2, clipFrame.SlotCount);
// The punch polygon reached the leaf renderer in WORLD space: the // The punch polygon reached the leaf renderer in WORLD space: the
// building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by // building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by

View file

@ -48,7 +48,13 @@ public sealed class WalkProductionWorldConformanceTests
private sealed class Recorder : IWalkEventSink private sealed class Recorder : IWalkEventSink
{ {
public readonly List<WalkEvent> Events = new(); public readonly List<WalkEvent> Events = new();
public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent); public Action<WalkEvent>? OnEmit { get; init; }
public void Emit(in WalkEvent walkEvent)
{
Events.Add(walkEvent);
OnEmit?.Invoke(walkEvent);
}
} }
private static DatCollection OpenDats() private static DatCollection OpenDats()
@ -227,6 +233,93 @@ public sealed class WalkProductionWorldConformanceTests
$"production walk diverged from the FW1 test adapter ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}"); $"production walk diverged from the FW1 test adapter ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}");
} }
[Theory]
[InlineData(0xF4180100u, 36.166267f, 79.828407f)]
[InlineData(0xF4180101u, 36.391270f, 72.167931f)]
public void Cathedral_transition_keeps_south_hall_draws_inside_the_authored_aperture(
uint cameraCellId,
float x,
float y)
{
// Owner's exact 2026-08-31 repro: the remote player and special NPC
// are parented in 0xF4180112, behind opaque cathedral walls. Retail
// hides them on BOTH sides of the 0x100 <-> 0x101 transition. The walk
// legitimately reaches 0x112 through one authored building aperture;
// the regression was submitting each admitted mesh with slot 0, so the
// whole player/NPC escaped that aperture. Preserve the installed-DAT
// fact this fix depends on: every admitted route is a real, bounded
// portal polygon, never a pass-all zero-plane route.
var pose = new WalkOraclePose(
cameraCellId,
new Vector3(x, y, 169.804993f),
Q0: -0.004591f,
Q1: 0f,
Q2: 0f,
Q3: 0.999989f);
using DatCollection dats = OpenDats();
using var adapter = new DatCollectionAdapter(dats);
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
BuildProductionWorld(adapter, pose.CellId, pose.Origin);
var ctx = new WalkTraceReplayContext(pose, cells) { Buildings = buildings };
WalkCell camera = Assert.Contains(cameraCellId, cells);
var remotePlayer = new Vector3(36.299465f, 18.594580f, 169.804993f);
var southHallAdmission = new List<string>();
var recorder = new Recorder
{
OnEmit = e =>
{
if (e.Kind != WalkEventKind.DrawCells
|| !e.Cells.Contains(0xF4180112u))
{
return;
}
WalkPortalView views = cells[0xF4180112u].TopView;
for (int viewIndex = 0; viewIndex < views.ViewCount; viewIndex++)
{
WalkViewPoly poly = views.View.Polys[viewIndex];
var planes = new WalkPlane[poly.VertexCount];
for (int edge = 0; edge < poly.VertexCount; edge++)
{
planes[edge] = views.View.Vertices[
poly.VertexIndex + edge].Plane;
}
WalkBoundingType verdict = WalkVisibilityMath.ViewconeCheck(
remotePlayer,
radius: 1.5f,
ctx.CyPlane,
planes);
float minDistance = planes
.Select(plane => Vector3.Dot(plane.Normal, remotePlayer) + plane.D)
.Append(Vector3.Dot(ctx.CyPlane.Normal, remotePlayer) + ctx.CyPlane.D)
.Min();
southHallAdmission.Add(
$"view={viewIndex} planes={poly.VertexCount} verdict={verdict} min={minDistance:F4}");
}
},
};
new RetailFrameWalk().WalkFrame(
cameraCellId,
camera,
assembler.Landscape,
ctx,
recorder);
WalkEvent[] drawCells = recorder.Events
.Where(static e => e.Kind == WalkEventKind.DrawCells)
.ToArray();
Assert.NotEmpty(drawCells);
Assert.Contains(cameraCellId, drawCells[0].Cells);
Assert.NotEmpty(southHallAdmission);
Assert.Contains(
southHallAdmission,
static verdict => verdict.Contains("verdict=PartiallyInside")
&& !verdict.Contains("planes=0"));
}
[Fact] [Fact]
public void Foundry_entry_reproduces_every_frame_before_the_f67_order_segment() public void Foundry_entry_reproduces_every_frame_before_the_f67_order_segment()
{ {

View file

@ -45,6 +45,22 @@ public sealed class WalkStaticStreamPopulatorTests
Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld)); Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld));
} }
private sealed class FixedWalkViews(params uint[] slots) : IWalkLookInViewSource
{
public IReadOnlyList<uint> LookInCellTurns { get; } = [0x8C040112u];
public bool SphereVisibleInLookInTurn(
int routeIndex,
in Vector3 center,
float radius) => slots.Length != 0;
public IReadOnlyList<uint> VisibleClipSlotsInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere) => slots;
}
// ── Synthetic RenderProjectionRecord construction ────────────────────── // ── Synthetic RenderProjectionRecord construction ──────────────────────
private static RenderProjectionRecord MakeRecord( private static RenderProjectionRecord MakeRecord(
@ -212,6 +228,35 @@ public sealed class WalkStaticStreamPopulatorTests
Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId); Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId);
} }
[Fact]
public void ClassifyEntityForWalk_EmitsOneGpuClippedInstancePerPortalViewSlice()
{
using var fx = new DispatcherFixture();
const ulong gfxObj = 0x0100_0013UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08000013u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
RenderProjectionRecord record = MakeRecord(
201, 0, Vector3.Zero,
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)],
parentCellId: 0x8C040112u);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
var views = new FixedWalkViews(7u, 9u);
fx.Dispatcher.ClassifyEntityForWalk(
in record,
0x8C04u,
batches,
selectionParts,
liveDynamic: true,
views,
lookInRouteIndex: 0,
lookInCellId: 0x8C040112u);
Assert.Equal([7u, 9u], batches.Select(static batch => batch.ClipSlot));
Assert.Single(selectionParts);
}
// ── Deliverable 2: WalkStaticStreamPopulator routing ─────────────────── // ── Deliverable 2: WalkStaticStreamPopulator routing ───────────────────
[Fact] [Fact]

View file

@ -56,5 +56,17 @@ public sealed class WbDrawDispatcherLookInConeTests
Radius = radius; Radius = radius;
return true; return true;
} }
public IReadOnlyList<uint> VisibleClipSlotsInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere)
{
RouteIndex = routeIndex;
Center = center;
Radius = radius;
return [7u];
}
} }
} }