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 HashSet<uint> _singleCellScratch = new();
private readonly List<uint> _singleCellListScratch = new();
private readonly Dictionary<uint, int> _singleCellClipScratch = new(1);
internal WalkProductionLeafRenderer(
RetailPViewPassExecutor passes,
@ -231,16 +232,26 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void DrawTerrainSlice(int sliceIndex) =>
_passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex);
public void DrawCellShell(uint cellId)
public void DrawCellShell(uint cellId, uint clipSlot)
{
_singleCellScratch.Clear();
_singleCellScratch.Add(cellId);
_passes.DrawOpaqueCellShells(_singleCellScratch);
if (_passes.CellHasTransparentShell(cellId))
_singleCellClipScratch.Clear();
_singleCellClipScratch.Add(cellId, checked((int)clipSlot));
_passes.SetCellShellClipRouting(_singleCellClipScratch);
try
{
_singleCellListScratch.Clear();
_singleCellListScratch.Add(cellId);
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
_singleCellScratch.Clear();
_singleCellScratch.Add(cellId);
_passes.DrawOpaqueCellShells(_singleCellScratch);
if (_passes.CellHasTransparentShell(cellId))
{
_singleCellListScratch.Clear();
_singleCellListScratch.Add(cellId);
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
}
}
finally
{
_passes.SetCellShellClipRouting(null);
}
}

View file

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

View file

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

View file

@ -120,7 +120,7 @@ internal interface IWalkFrameLeafRenderer
/// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d.
/// The ordinary interior-root flood and every building look-in flood use
/// 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
/// particle submission, at its own walk turn — see
@ -231,7 +231,9 @@ internal enum WalkFrameEventKind : byte
TerrainSlice,
/// <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,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> —
@ -288,9 +290,23 @@ internal interface IWalkLookInViewSource
int routeIndex,
in Vector3 center,
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(
uint CellId,
@ -341,8 +357,8 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent TerrainSlice(int sliceIndex) =>
new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null);
internal static WalkFrameEvent CellShell(uint cellId) =>
new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null);
internal static WalkFrameEvent CellShell(uint cellId, uint clipSlot) =>
new(WalkFrameEventKind.CellShell, checked((int)clipSlot), cellId, 0f, null);
internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon);
@ -452,6 +468,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly IWalkFrameLeafRenderer _leafRenderer;
private readonly IWalkFrameWorldData _worldData;
private readonly IWalkFrameDriverTrace? _trace;
private readonly ClipFrame? _clipFrame;
private readonly OrderedDrawStream _stream = new();
private readonly List<WalkFrameEvent> _events = 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<WalkLookInSlice> _lookInSlices = new();
private readonly List<WalkPlane> _lookInPlanes = new();
private readonly List<uint> _visibleClipSlotScratch = new();
private readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane;
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
@ -524,12 +543,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
WbDrawDispatcher dispatcher,
IWalkFrameLeafRenderer leafRenderer,
IWalkFrameWorldData worldData,
IWalkFrameDriverTrace? trace = null)
IWalkFrameDriverTrace? trace = null,
ClipFrame? clipFrame = null)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer));
_worldData = worldData ?? throw new ArgumentNullException(nameof(worldData));
_trace = trace;
_clipFrame = clipFrame;
_populator = new WalkStaticStreamPopulator(dispatcher);
}
@ -621,6 +642,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInTurns.Clear();
_lookInSlices.Clear();
_lookInPlanes.Clear();
_visibleClipSlotScratch.Clear();
_lookInCyPlane = ctx.CyPlane;
LookInCells.Clear();
VisitedBuildings.Clear();
@ -699,7 +721,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_leafRenderer.DrawTerrainSlice(e.IntArg);
break;
case WalkFrameEventKind.CellShell:
_leafRenderer.DrawCellShell(e.CellId);
_leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg));
break;
case WalkFrameEventKind.PunchFan:
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
@ -985,12 +1007,23 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
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):
// 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--)
{
int viewRouteIndex = CaptureCellViewRoute(cells[i]);
_floodViewRouteScratch[i] = viewRouteIndex;
IReadOnlyList<uint> clipSlots = VisibleClipSlotsInLookInTurn(
viewRouteIndex, default, 0f, testSphere: false);
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
@ -998,23 +1031,31 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// static records and its dynamic/particle tail stay at that exact
// retail turn.
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);
_populator.PopulateCell(
_stream, stage, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection);
_cameraWorldPosition, _viewProjection,
this, viewRouteIndex);
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)
{
LookInCellTurns.Add(cellId);
@ -1062,8 +1103,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInPlanes.Add(
portalView.View.Vertices[poly.VertexIndex + edge].Plane);
}
uint clipSlot = AppendClipSlot(portalView, poly);
_lookInSlices.Add(new WalkLookInSlice(
planeStart, poly.VertexCount));
planeStart, poly.VertexCount, clipSlot));
}
_lookInTurns.Add(new WalkLookInTurn(
cellId, sliceStart, _lookInSlices.Count - sliceStart));
@ -1095,6 +1137,100 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
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
/// immediate driver's <c>FlushIfNonEmpty</c> — records a
/// <see cref="WalkFrameEventKind.StreamMark"/> at the stream's current

View file

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