acdream/src/AcDream.App/Rendering/RetailPViewRenderer.cs
Erik 6d4cac2418 BR-2 commit 1: exit-portal depth SEALS + retail full depth clear (the #108 machinery)
Ports the seal half of retail's invisible portal depth writes
(D3DPolyRender::DrawPortalPolyInternal, Ghidra 0x0059bc90; dispatched by
PView::DrawCells loop 1, Ghidra 0x005a4840 pc:432783-432786):

- NEW PortalDepthMaskRenderer: draws a portal polygon as a color-masked
  triangle fan, depth-test ALWAYS + depth-write ON, at the polygon's TRUE
  projected depth (retail maxZ2 seal) or forced to far-z 0.99999988
  (retail maxZ1 punch - the constant from 0x0059bc90's tail; punch wiring
  lands in BR-2 commit 2). Where retail software-clips the fan against
  the installed view (polyClipFinish), we apply the SAME slice region via
  gl_ClipDistance from the slice's <=8 clip-space half-planes. GL state
  fully self-contained (set -> draw -> restore, no early-outs).

- DrawExitPortalMasks is now WIRED in production (was a null-callback
  no-op since birth): for interior roots, every visible cell's portals
  with OtherCellId==0xFFFF get their world-space polygon sealed per view
  slice, far-to-near, after the landscape slices.

- ClearDepthSlice (per-slice scissored AABB clear - wrong shape, wrong
  scope, no seal after it) is REPLACED by ClearDepthForInterior: ONE
  full-buffer depth clear between the outside stage and the interior
  stage, gated on any outside slice having drawn (retail's
  portalsDrawnCount gate semantics staged as an open question, marked
  inline). DepthMask(true) asserted at the clear site (c4df241 lesson).
  Outdoor roots: no clear, no seals (interiors must depth-test against
  terrain until the commit-2 punch).

Closes the mechanism behind #108 (outdoor grass sweeping across the
upstairs door opening - terrain depth seen through the doorway is now
re-stamped at the door plane so farther interior geometry z-fails inside
the aperture). Visual gate: BR-2/BR-3 batched checklist (cellar doorway
+ cottage wall + tower stairs near/far).

Suites: build green, App 226 green, Core 1398 + 4 pre-existing #99-era
failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:03:10 +02:00

636 lines
29 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// App-layer port of the retail indoor render orchestration:
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
/// PView::DrawInside -> ConstructView -> DrawCells.
/// </summary>
public sealed class RetailPViewRenderer
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private readonly HashSet<uint> _oneCell = new(1);
private readonly Dictionary<uint, int> _oneCellSlot = new(1);
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
private const float OutdoorBuildingSeedDistance = 48f;
public RetailPViewRenderer(
GL gl,
ClipFrame clipFrame,
EnvCellRenderer envCells,
WbDrawDispatcher entities)
{
_gl = gl;
_clipFrame = clipFrame;
_envCells = envCells;
_entities = entities;
}
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
// the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the
// land root itself has no portals (it floods nothing into buildings). Per-building seeding is
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
// oscillated as the chase eye grazed a doorway (the indoor flap).
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
// so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
// #113 fix scope (#114): GL-clip the shells only for the OUTDOOR root —
// the case the flood replay validated (tight, stable door-aperture
// regions) and the one that produced the phantom staircase. The first
// user gate (2026-06-11) showed INDOOR clip regions are not yet
// draw-quality (chopped stairs / vanishing inner walls at exits /
// see-through to neighbour rooms at the meeting hall) — indoor roots
// stay unclipped (yesterday's user-accepted state) until #114 brings
// the indoor regions to retail's pixel-exact crop.
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells,
clipShells: ctx.RootCell.IsOutdoorNode);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
return result;
}
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict is reused across frames; inner lists are cleared each frame so a building that left
// the near set simply contributes an empty (skipped) group.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
// R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit
// portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so
// dropping it (the original `continue`) left holes at building/terrain seams. Key it by its
// own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a
// cell with none contributes nothing (same as before). BuildingId/CellId key collisions are
// harmless — BuildFromExterior seeds each exit-portal cell in a group independently.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance);
MergeBuildingFrame(pvFrame, buildingFrame);
}
}
// Append a per-building flood's cells + views into the frame. Each building cell belongs to exactly
// one building, so there is no cross-building overlap; ContainsKey is a safety dedup. OutsideView is
// NOT merged — the outdoor root already seeds full-screen terrain, and ConstructViewBuilding
// (BuildFromExterior) leaves OutsideView empty (it stops at exit portals once inside the building).
private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
{
foreach (uint cellId in src.OrderedVisibleCells)
{
if (target.CellViews.ContainsKey(cellId))
continue;
target.CellViews[cellId] = src.CellViews[cellId];
target.OrderedVisibleCells.Add(cellId);
}
}
public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.BuildFromExterior(
ctx.CandidateCells,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection,
ctx.MaxSeedDistance);
if (pvFrame.OrderedVisibleCells.Count == 0)
{
RestoreNoClip(ctx.SetTerrainClipUbo);
return null;
}
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
var drawableCells = new HashSet<uint>(clipAssembly.CellIdToSlot.Keys);
UseIndoorMembershipOnlyRouting();
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
// DrawPortal is the from-outside look-in path — same validated outdoor
// regime as the outdoor root (see #114 scope note in DrawInside).
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, clipShells: true);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
RestoreNoClip(ctx.SetTerrainClipUbo);
return result;
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor));
}
// BR-2: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; the exact gate semantics is a plan
// open question, staged here as "any outside slice drawn"), then
// re-stamps every outside-leading portal's TRUE depth (the seals,
// DrawExitPortalMasks below). The old per-slice scissored AABB clear
// was the wrong shape (AABB ⊇ aperture polygon) and had no seal after
// it — the #108 mechanism.
if (clipAssembly.OutsideViewSlices.Length > 0)
ctx.ClearDepthForInterior?.Invoke();
UseIndoorMembershipOnlyRouting();
}
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
private string? _lastClipRouteSig;
private long _clipRouteSeq;
private readonly List<uint> _clipRouteCellKeys = new();
// §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects
// (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under:
// the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded
// at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head
// (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER
// SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// printed bytes are exactly what this slice's draws consume.
private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex)
{
var sb = new System.Text.StringBuilder(256);
sb.Append(System.FormattableString.Invariant(
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
sb.Append(System.FormattableString.Invariant(
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}["));
for (int i = 0; i < slice.Planes.Length; i++)
{
var p = slice.Planes[i];
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})"));
}
// CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change.
sb.Append("] cells={");
_clipRouteCellKeys.Clear();
foreach (uint key in clipAssembly.CellIdToSlot.Keys)
_clipRouteCellKeys.Add(key);
_clipRouteCellKeys.Sort();
for (int i = 0; i < _clipRouteCellKeys.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(System.FormattableString.Invariant(
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
}
sb.Append('}');
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame
// just uploaded — slot stride 144: count uint at +0, planes[8] at +16.
var rb = _clipFrame.RegionBytesForTest;
int off = slice.Slot * ClipFrame.CellClipStrideBytes;
if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length)
{
uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4));
sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}"));
int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes);
for (int i = 0; i < planeN; i++)
{
int po = off + ClipFrame.CellClipPlanesOffset + i * 16;
float px = System.BitConverter.ToSingle(rb.Slice(po, 4));
float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4));
float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4));
float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4));
sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})"));
}
}
else
{
sb.Append(System.FormattableString.Invariant(
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}"));
}
// Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16).
var tb = _clipFrame.TerrainBytesForTest;
int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4));
float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4));
float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4));
float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4));
float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4));
sb.Append(System.FormattableString.Invariant(
$" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})"));
string sig = sb.ToString();
_clipRouteSeq++;
if (sig == _lastClipRouteSig)
return;
_lastClipRouteSig = sig;
Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}");
}
private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells)
{
if (ctx.DrawExitPortalMasks is null)
return;
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
}
}
private void DrawEnvCellShells(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells, // param kept this task; removed in Task 4
bool clipShells)
{
// Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list
// (far→near), per portal_view slice. No drawableCells filter — a cell without a
// clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped
// (sealed; per-slice trim returns in Task 4).
//
// #113 (2026-06-10): the per-slice clip MUST actually clip. Retail clips drawn
// CELL geometry to the accumulated portal view — Render::set_view (:343750)
// installs the view polygon's edge planes and DrawEnvCell submits every cell
// polygon with planeMask=0xffffffff (:427922) through ACRender::polyClipFinish.
// Our equivalent (UseShellClipRouting → mesh_modern.vert gl_ClipDistance) was
// routed but INERT: gl_ClipDistance writes are ignored unless GL_CLIP_DISTANCEi
// is enabled, and no caller enabled it for this pass — so flooded interior cells
// drew WHOLE, painting interior geometry across exterior walls (the Holtburg
// meeting-hall phantom staircase, AAB3 0x100 stair cell coincident with the
// shell's west wall). Self-contained per feedback_render_self_contained_gl_state;
// no early-outs between enable and disable. Slot-0 slices (SSBO count=0) still
// pass-all — the assembler's >8-plane scissor fallback remains unimplemented
// (rare; Issue113MeetingHallFloodTests pins 0 such slices at the hall).
// Characters/statics stay unclipped (DrawCellObjectLists): retail's mesh path is
// viewcone-check + BoundingType handling, and hard-clipping slices characters at
// doorways (the original UseIndoorMembershipOnlyRouting observation).
//
// clipShells (#114 scope, 2026-06-11): true only for outdoor-eye roots.
// The first user gate showed indoor clip regions are not draw-quality
// yet (chopped stairs / vanishing walls at exits) — indoor roots draw
// unclipped until #114 lands pixel-exact indoor regions.
if (clipShells)
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
_gl.Enable(Silk.NET.OpenGL.EnableCap.ClipDistance0 + i);
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
uint cellId = entry.CellId;
_oneCell.Clear();
_oneCell.Add(cellId);
var slices = GetCellSlicesOrNoClip(clipAssembly, cellId);
// BR-2 phantom-site probe: which cells draw their shell with a
// pass-all slice (NoClipSlice fallback or assembler slot-0)?
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomShellProbe(cellId, slices, clipShells,
hadSlot: clipAssembly.CellIdToViewSlices.ContainsKey(cellId));
foreach (var slice in slices)
{
UseShellClipRouting(cellId, slice);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
if (clipShells)
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
_gl.Disable(Silk.NET.OpenGL.EnableCap.ClipDistance0 + i);
}
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition)
{
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
_oneCell.Clear();
_oneCell.Add(cellId);
// BR-2 phantom-site probe: entity buckets draw unclipped +
// un-viewcone'd by design — log the per-cell exposure.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, bucket.Count);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, bucket, _oneCell);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket));
}
}
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
// diffable while the condition persists. Throwaway apparatus — strip when
// the #113 phantom residual closes (plan §BR-2).
private readonly Dictionary<uint, string> _phantomShellSig = new();
private readonly Dictionary<uint, int> _phantomObjsSig = new();
private void EmitPhantomShellProbe(uint cellId, ClipViewSlice[] slices, bool clipShells, bool hadSlot)
{
var sb = new System.Text.StringBuilder(96);
sb.Append(clipShells ? "clip=on" : "clip=OFF");
sb.Append(hadSlot ? " slot=yes" : " slot=NONE(pass-all)");
sb.Append(" slices=[");
for (int i = 0; i < slices.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append(slices[i].Slot).Append(':').Append(slices[i].Planes.Length).Append("pl");
if (slices[i].Slot == 0) sb.Append("(PASS-ALL)");
}
sb.Append(']');
var sig = sb.ToString();
if (_phantomShellSig.TryGetValue(cellId, out var prev) && prev == sig) return;
_phantomShellSig[cellId] = sig;
Console.WriteLine($"[phantom-shell] cell=0x{cellId:X8} {sig}");
}
private void EmitPhantomObjsProbe(uint cellId, int bucketCount)
{
if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return;
_phantomObjsSig[cellId] = bucketCount;
Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)");
}
private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly,
uint cellId)
{
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
&& slices.Length > 0)
return slices;
return new[] { NoClipSlice };
}
private void UseIndoorMembershipOnlyRouting()
{
// For MESHES (characters, statics) retail's DrawMesh performs portal-view
// visibility checks (Render::viewconeCheck on the drawing sphere) rather
// than hard per-poly clipping — feeding the 2D views into gl_ClipDistance
// slices characters at stair/door boundaries, which retail does not do.
// CELL SHELL geometry is different: retail clips it to the portal view
// (planeMask=0xffffffff per cell polygon, decomp :427922 + :343750) —
// DrawEnvCellShells enables exactly that (#113).
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
private void UseShellClipRouting(uint cellId, ClipViewSlice slice)
{
_oneCellSlot.Clear();
_oneCellSlot[cellId] = slice.Slot;
_envCells.SetClipRouting(_oneCellSlot);
_entities.ClearClipRouting();
}
private void DrawEntityBucket(
IRetailPViewCellDrawContext ctx,
IReadOnlyList<WorldEntity> bucket,
HashSet<uint>? visibleCellIds)
{
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
{
_clipFrame.Reset();
UploadClipFrame(setTerrainClipUbo);
UseIndoorMembershipOnlyRouting();
}
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
{
_clipFrame.UploadShared(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo);
}
}
public interface IRetailPViewCellDrawCallbacks
{
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
{
public ICamera Camera { get; }
public FrustumPlanes? Frustum { get; }
public uint? PlayerLandblockId { get; }
public HashSet<uint>? AnimatedEntityIds { get; }
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{
public required LoadedCell RootCell { get; init; }
/// <summary>R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the
/// outdoor node. Null for interior roots. Grouped by BuildingId inside <see cref="DrawInside"/>.</summary>
public IReadOnlyList<LoadedCell>? NearbyBuildingCells { get; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>BR-2: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
/// appear only through real apertures (the BR-2 commit-2 punch).</summary>
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext
{
public required IEnumerable<LoadedCell> CandidateCells { get; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required float MaxSeedDistance { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewFrameResult
{
public required PortalVisibilityFrame PortalFrame { get; init; }
public required ClipFrameAssembly ClipAssembly { get; init; }
public required HashSet<uint> DrawableCells { get; init; }
public required InteriorEntityPartition.Result Partition { get; init; }
}
public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> CellEntities);