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; /// /// App-layer port of the retail indoor render orchestration: /// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside -> /// PView::DrawInside -> ConstructView -> DrawCells. /// 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()); private readonly HashSet _oneCell = new(1); // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). private readonly Dictionary> _buildingGroups = new(); // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the // caller's per-building frustum pre-gate on aperture bounds (GameWindow's // gather); seeds themselves are unbounded. private const float OutdoorBuildingSeedDistance = float.PositiveInfinity; 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(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); // T1 (fused BR-2/3): retail's frame order — static world, then the // aperture depth writes, then interior cells WHOLE far→near, then // per-cell statics, then ALL dynamics last (retail draws objects after // cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0). // The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is // DELETED — retail never clips cell geometry; aperture exactness comes // from the punch/seal depth writes + the z-buffer, and the dynamics- // last order is what makes the punch safe (the first BR-2 attempt // punched after dynamics and erased the player, reverted 88be519). // T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view, // never clipped (Ghidra 0x0054c250). Built once per frame from the // assembled slices + this frame's view-projection. var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection); // #118: stage assignment for dynamics under an INTERIOR root. Retail // draws the OUTSIDE world's objects inside the landscape stage — // PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated // full depth clear (pc:432731-432732) and the exit-portal SEALS // (pc:432785-432786); DrawBlock draws every landcell's objects via // DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our // single last pass instead z-fails against the seal's true-depth stamp // the moment it stands beyond the door plane — the house-exit // clip+vanish (pinned by HouseExitWalkReplayTests). So under an // interior root: outdoor-classified dynamics draw in the outside // stage; an indoor dynamic whose sphere STRADDLES an exit portal // draws in BOTH stages (retail's per-overlapped-cell shadow-part // draw, DrawBlock pc:430056-430064) so neither body half clips at the // plane. Outdoor roots keep ALL dynamics in the last pass — our // z-buffered equivalent of retail's painter-ordered outdoor pass (the // BR-2 punch-after-dynamics lesson, reverted 88be519). _outsideStageDynamics.Clear(); if (!ctx.RootCell.IsOutdoorNode) { foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup)) _outsideStageDynamics.Add(e); } } DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawEnvCellShells(pvFrame); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); 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(); _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); } } // T2 (BR-4): merge a per-building flood's cells + views into the frame as a // UNION. Retail accumulates EVERY clipped portal polygon as a new view_poly // on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0; // a cell visible through two apertures holds two views, all consumed // downstream). The old first-wins (`ContainsKey -> continue`) dropped the // second building flood's views whenever a cell was already in the frame — // the multiview-loss-first-wins divergence (a named #109 suspect: per-frame // winner flips between apertures). CellView.Add dedups exact/collinear // re-emissions (the dac8f6a CanonicalKey), so unioning is convergent. // 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 (!src.CellViews.TryGetValue(cellId, out var srcView)) continue; if (target.CellViews.TryGetValue(cellId, out var existing)) { foreach (var p in srcView.Polygons) existing.Add(p); continue; } target.CellViews[cellId] = srcView; target.OrderedVisibleCells.Add(cellId); } } private void DrawLandscapeThroughOutsideView( RetailPViewDrawContext ctx, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition, ViewconeCuller viewcone) { if (clipAssembly.OutsideViewSlices.Length == 0) return; int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { _clipFrame.SetTerrainClip(slice.Planes); UploadClipFrame(ctx.SetTerrainClipUbo); // T3 (BR-5): entities are never hard-clipped — retail viewcone- // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // and draws it whole. The old per-slice entity clip routing // (gl_ClipDistance via SetClipRouting) is replaced by the sphere // pre-filter below; terrain/sky keep their per-slice plane clip. _entities.ClearClipRouting(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); _outdoorStaticScratch.Clear(); foreach (var e in partition.OutdoorStatic) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } // #118: outside-stage dynamics ride the landscape pass like retail's // per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn // BEFORE the depth clear + seals so the seal PROTECTS their pixels in // the aperture instead of z-killing them. Same per-slice cone test as // the statics above. Empty under outdoor roots (see DrawInside). foreach (var e in _outsideStageDynamics) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } probeSliceIndex++; ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — // Clear gated on portalsDrawnCount; exact gate semantics is a plan // open question, staged as "any outside slice drawn"), then re-stamps // every outside-leading portal's TRUE depth (the seals, // DrawExitPortalMasks). Replaces the old per-slice scissored AABB // clear (wrong shape, no seal after it). 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 _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 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())); } } private void DrawEnvCellShells(PortalVisibilityFrame pvFrame) { // T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's // shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once. // Retail NEVER clips cell geometry: the production path is the // prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the // planeMask=0xffffffff legacy submit means skip-all-edges), and // aperture exactness comes from the punch/seal depth writes + the // z-buffer + this order. The former gl_ClipDistance chop // (927fd8f/9ce335e, #114) is deleted with this rewrite. // Per-cell opaque+transparent keeps the far→near transparent // compositing the per-cell loop already provided. UseIndoorMembershipOnlyRouting(); foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { _oneCell.Clear(); _oneCell.Add(entry.CellId); _envCells.Render(WbRenderPass.Opaque, _oneCell); _envCells.Render(WbRenderPass.Transparent, _oneCell); } } // T1: the frame's single LAST entity pass — ALL server-spawned dynamics // (player, NPCs, doors, items), indoor or out, drawn after the static // world + punches + interior cells. Depth-tested, never hard-clipped // (retail draws objects per cell AFTER cells and viewcone-culls them — // PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is // T3). Drawing dynamics last is what makes the aperture punch safe. // T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its // cell's views; outdoor/unresolved vs the outside views (pass-all under // the outdoor root's full-screen outside view). A dynamic in a NON-flooded // room culls HERE — retail never reaches an object whose cell is not in // the draw list; the partition keeps routing it so the CULL (not the // visibility set) drops it, exactly retail's shape. private void DrawDynamicsLast( IRetailPViewCellDrawContext ctx, InteriorEntityPartition.Result partition, ViewconeCuller viewcone, bool rootIsOutdoor) { if (partition.Dynamics.Count == 0) return; _dynamicsScratch.Clear(); foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId); // #118: under an interior root, outdoor-classified dynamics drew in // the outside stage (pre-clear, seal-protected) — retail draws them // via LScape::draw's per-landcell DrawSortCell, never in the // post-seal cell-object epilogue (PView::DrawCells pc:432719 vs // pc:432878). Drawing them here instead z-fails them against the // seal. Indoor dynamics (incl. exit-portal straddlers, which drew // in BOTH stages) stay — this pass is retail's loop C. if (!rootIsOutdoor && !indoor) continue; bool visible = indoor ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) : viewcone.SphereVisibleOutside(c, r); if (visible) _dynamicsScratch.Add(e); } if (_dynamicsScratch.Count == 0) return; UseIndoorMembershipOnlyRouting(); DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); // #121: dynamics' attached emitters (portal swirls, creature effects) // gate through the SAME cone-surviving owner set as their meshes — // retail draws emitters with the owner object. Before this callback, // dynamics' emitters fell through EVERY particle filter under the pview // path (the landscape slice carries outdoor statics + #118 outside- // stage dynamics; the cell callback carries cell statics; T4 deleted // the old clipRoot==null global pass from normal frames) — all world // portals went invisible. Outside-stage dynamics are excluded here: // their emitters already drew in the landscape slice (alpha-blended // particles must not double-draw, unlike the depth-idempotent meshes). if (ctx.DrawDynamicsParticles is not null) { _dynamicsParticleScratch.Clear(); foreach (var e in _dynamicsScratch) if (!_outsideStageDynamics.Contains(e)) _dynamicsParticleScratch.Add(e); if (_dynamicsParticleScratch.Count > 0) ctx.DrawDynamicsParticles(_dynamicsParticleScratch); } } private void DrawCellObjectLists( IRetailPViewCellDrawContext ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, InteriorEntityPartition.Result partition, ViewconeCuller viewcone) { // T1: per-cell STATIC object lists only (dat-baked 0x40 statics) — // dynamics moved to DrawDynamicsLast. Far→near with the cells, after // the shells (retail DrawCells epilogue: PortalList = cell's views → // DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is // tested against ITS CELL's views (retail viewconeCheck) — the // statics-through-walls fix: a static whose sphere is outside every // view of its cell no longer paints through the wall (the cottage // phantom staircase's draw path). 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; _cellStaticScratch.Clear(); foreach (var e in bucket) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInCell(cellId, c, r)) _cellStaticScratch.Add(e); } // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count); if (_cellStaticScratch.Count > 0) { _oneCell.Clear(); _oneCell.Add(cellId); UseIndoorMembershipOnlyRouting(); DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); } // T3 (BR-5): particles gate through the SAME viewcone as their // owners — the callback receives the cone-surviving entity set, so // an emitter attached to a culled static no longer draws through // the wall (the candle-flames-through-walls fix). Consumed // synchronously within this iteration (scratch list reuse). foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch)); } } // T3 scratch lists (render thread only; cleared per use). private readonly List _outdoorStaticScratch = new(); private readonly List _cellStaticScratch = new(); private readonly List _dynamicsScratch = new(); // #118: dynamics assigned to the OUTSIDE stage this frame (interior roots // only) — outdoor-classified + exit-portal straddlers. Cleared per frame. private readonly List _outsideStageDynamics = new(); // #121: cone-surviving dynamics whose emitters draw in the dynamics // particle pass (survivors minus outside-stage). Cleared per use. private readonly List _dynamicsParticleScratch = new(); /// /// #118 stage assignment for a dynamic under an INTERIOR root: does it draw /// in the OUTSIDE (landscape) stage — before the gated depth clear and the /// exit-portal seals — like retail's per-landcell object draw /// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at /// the top of PView::DrawCells pc:432719)? /// /// True for outdoor-classified dynamics (their fragments lie beyond the /// door plane and would z-fail the seal in the last pass), and for INDOOR /// dynamics whose sphere straddles an exit-portal plane of their flood- /// visible cell — retail draws an object once per overlapped shadow cell /// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in /// both stages and neither half clips at the plane. Pure — also driven /// headlessly by HouseExitWalkReplayTests as the ordering contract. /// public static bool DynamicDrawsInOutsideStage( uint? parentCellId, Vector3 sphereCenter, float sphereRadius, HashSet drawableCells, Func cellLookup) { if (!InteriorEntityPartition.IsIndoorCellId(parentCellId)) return true; uint cellId = parentCellId!.Value; if (!drawableCells.Contains(cellId)) return false; // not in the flood — the last-pass cone cull owns it var cell = cellLookup(cellId); if (cell is null) return false; var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform); int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count); for (int i = 0; i < n; i++) { if (cell.Portals[i].OtherCellId != 0xFFFF) continue; var plane = cell.ClipPlanes[i]; if (plane.Normal.LengthSquared() < 1e-8f) continue; float dist = Vector3.Dot(plane.Normal, localC) + plane.D; if (MathF.Abs(dist) < sphereRadius) return true; // sphere straddles the exit-portal plane } return false; } // Conservative bounding sphere from the entity's cached AABB — the same // bounds source the dispatcher's frustum cull uses. private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius) { if (e.AabbDirty) e.RefreshAabb(); center = (e.AabbMin + e.AabbMax) * 0.5f; radius = (e.AabbMax - e.AabbMin).Length() * 0.5f; } // 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. (The [phantom-shell] half died with // the T1 chop deletion — shells draw whole, there is no slice state left // to report.) private readonly Dictionary _phantomObjsSig = new(); 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() { // T1: NOTHING in the world passes hard-clips geometry anymore — retail // viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips // cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905). // This clears any clip routing left by the landscape slices. _envCells.SetClipRouting(null); _entities.ClearClipRouting(); } private void DrawEntityBucket( IRetailPViewCellDrawContext ctx, IReadOnlyList bucket, HashSet? visibleCellIds) { uint lbId = ctx.PlayerLandblockId ?? 0u; var entry = (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)bucket, (IReadOnlyDictionary?)null); _entities.Draw( ctx.Camera, new[] { entry }, ctx.Frustum, neverCullLandblockId: ctx.PlayerLandblockId, visibleCellIds: visibleCellIds, animatedEntityIds: ctx.AnimatedEntityIds); } private void RestoreNoClip(Action setTerrainClipUbo) { _clipFrame.Reset(); UploadClipFrame(setTerrainClipUbo); UseIndoorMembershipOnlyRouting(); } private void UploadClipFrame(Action setTerrainClipUbo) { _clipFrame.UploadShared(_gl); _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); setTerrainClipUbo(_clipFrame.TerrainUbo); } } public interface IRetailPViewCellDrawCallbacks { public Action? DrawExitPortalMasks { get; } public Action? DrawCellParticles { get; } } public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks { public ICamera Camera { get; } public FrustumPlanes? Frustum { get; } public uint? PlayerLandblockId { get; } public HashSet? AnimatedEntityIds { get; } /// #121: draw the Scene-pass emitters attached to the frame's /// cone-surviving dynamics (portal swirls, creature effects). Invoked once /// per frame after the last entity pass with the survivor list. public Action>? DrawDynamicsParticles { get; } } public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext { public required LoadedCell RootCell { get; init; } /// 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 . public IReadOnlyList? NearbyBuildingCells { get; init; } public required Vector3 ViewerEyePos { get; init; } public required Matrix4x4 ViewProjection { get; init; } public required Func 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? 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 Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } /// T1: 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 punched apertures. public Action? ClearDepthForInterior { get; init; } public Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } public Action>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } } public sealed class RetailPViewFrameResult { public required PortalVisibilityFrame PortalFrame { get; init; } public required ClipFrameAssembly ClipAssembly { get; init; } public required HashSet DrawableCells { get; init; } public required InteriorEntityPartition.Result Partition { get; init; } } public readonly record struct RetailPViewLandscapeSliceContext( ClipViewSlice Slice, IReadOnlyList OutdoorEntities); public readonly record struct RetailPViewCellSliceContext( uint CellId, ClipViewSlice Slice, IReadOnlyList CellEntities);