diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index 3250f3c2..194a8a98 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -129,8 +129,59 @@ public static class PortalProjection float w = poly[i].W; ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w); } - EnsureCcw(ndc); - return ndc; + + // T2 (BR-4): retail's post-divide vertex merge — Render::copy_view + // (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1 + // PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide. + // This is the flood's physical fixpoint floor: re-clipping a view can + // only insert sub-pixel sliver vertices, which this merge removes, so + // accumulated views converge instead of drifting (the drift is what + // forced the MaxReprocessPerCell=16 cap). Unit approximation: the + // builder has no viewport, so 1 px is expressed in NDC at a reference + // 1080p (2/1080 ≈ 0.00185); at higher resolutions the merge is merely + // slightly coarser than retail's, which only strengthens convergence. + var merged = MergeSubPixelVertices(ndc); + if (merged.Length < 3) + return System.Array.Empty(); + + EnsureCcw(merged); + return merged; + } + + // Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses + // runs of consecutive near-identical vertices, including across the + // wrap-around. A polygon that collapses below 3 distinct vertices is + // degenerate (sub-pixel sliver) and returns empty — exactly retail's + // "<3 surviving verts → output count 0". + private const float VertexMergeEpsilonNdc = 2f / 1080f; + + private static Vector2[] MergeSubPixelVertices(Vector2[] poly) + { + if (poly.Length < 3) return poly; + var kept = new List(poly.Length); + foreach (var v in poly) + { + if (kept.Count > 0) + { + var prev = kept[^1]; + if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc + && MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc) + continue; + } + kept.Add(v); + } + // Wrap-around: last ≈ first. + while (kept.Count >= 2) + { + var first = kept[0]; + var last = kept[^1]; + if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc + && MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc) + kept.RemoveAt(kept.Count - 1); + else + break; + } + return kept.Count == poly.Length ? poly : kept.ToArray(); } // One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index acc702ba..93b045bc 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -144,17 +144,33 @@ public sealed class RetailPViewRenderer } } - // 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). + // 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 (target.CellViews.ContainsKey(cellId)) + if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue; - target.CellViews[cellId] = src.CellViews[cellId]; + + 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); } }