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); private readonly Dictionary _oneCellSlot = new(1); // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). private readonly Dictionary> _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(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); DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); 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(); _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(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); DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); 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)); } foreach (var slice in clipAssembly.OutsideViewSlices) ctx.ClearDepthSlice?.Invoke(slice); 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( IRetailPViewCellDrawCallbacks ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells) // param kept this task; removed in Task 4 { // 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). foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { uint cellId = entry.CellId; _oneCell.Clear(); _oneCell.Add(cellId); foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) { UseShellClipRouting(cellId, slice); _envCells.Render(WbRenderPass.Opaque, _oneCell); _envCells.Render(WbRenderPass.Transparent, _oneCell); } } } private void DrawCellObjectLists( IRetailPViewCellDrawContext ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet 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); UseIndoorMembershipOnlyRouting(); DrawEntityBucket(ctx, bucket, _oneCell); foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket)); } } 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() { // Retail's PView portal views decide which cells/objects are eligible, // but DrawMesh only performs portal-view visibility checks before drawing // the mesh. Feeding those 2D views into gl_ClipDistance slices characters // and cell shells at stair/door boundaries, which retail does not do. _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 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; } } 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; } public Action? ClearDepthSlice { get; init; } public Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } public Action? EmitDiagnostics { get; init; } } public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext { public required IEnumerable CandidateCells { 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 float MaxSeedDistance { 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 Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { 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);