diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs
new file mode 100644
index 0000000..80fa721
--- /dev/null
+++ b/src/AcDream.App/Rendering/PortalView.cs
@@ -0,0 +1,71 @@
+// PortalView.cs
+//
+// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
+// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
+// a cell's clip region is a SET of convex polygons in normalized device coords.
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.App.Rendering;
+
+/// One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.
+public readonly struct ViewPolygon
+{
+ public readonly Vector2[] Vertices;
+ public readonly float MinX, MinY, MaxX, MaxY;
+
+ public ViewPolygon(Vector2[] vertices)
+ {
+ Vertices = vertices;
+ if (vertices is null || vertices.Length < 3)
+ {
+ MinX = MinY = MaxX = MaxY = 0f;
+ return;
+ }
+ float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
+ foreach (var v in vertices)
+ {
+ if (v.X < minX) minX = v.X;
+ if (v.X > maxX) maxX = v.X;
+ if (v.Y < minY) minY = v.Y;
+ if (v.Y > maxY) maxY = v.Y;
+ }
+ MinX = minX; MinY = minY; MaxX = maxX; MaxY = maxY;
+ }
+
+ public bool IsEmpty => Vertices is null || Vertices.Length < 3;
+}
+
+/// A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.
+public sealed class CellView
+{
+ public readonly List Polygons = new();
+ public float MinX { get; private set; } = float.MaxValue;
+ public float MinY { get; private set; } = float.MaxValue;
+ public float MaxX { get; private set; } = float.MinValue;
+ public float MaxY { get; private set; } = float.MinValue;
+
+ public bool IsEmpty => Polygons.Count == 0;
+
+ /// A region covering the entire NDC viewport — the camera cell's seed region
+ /// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).
+ public static CellView FullScreen()
+ {
+ var v = new CellView();
+ v.Add(new ViewPolygon(new[]
+ {
+ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
+ }));
+ return v;
+ }
+
+ public void Add(ViewPolygon p)
+ {
+ if (p.IsEmpty) return;
+ Polygons.Add(p);
+ if (p.MinX < MinX) MinX = p.MinX;
+ if (p.MinY < MinY) MinY = p.MinY;
+ if (p.MaxX > MaxX) MaxX = p.MaxX;
+ if (p.MaxY > MaxY) MaxY = p.MaxY;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/PortalViewTests.cs b/tests/AcDream.App.Tests/Rendering/PortalViewTests.cs
new file mode 100644
index 0000000..3fd7721
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/PortalViewTests.cs
@@ -0,0 +1,52 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class PortalViewTests
+{
+ [Fact]
+ public void ViewPolygon_ComputesBoundingRect()
+ {
+ var p = new ViewPolygon(new[]
+ {
+ new Vector2(-0.5f, -0.25f), new Vector2(0.5f, -0.25f), new Vector2(0.0f, 0.75f),
+ });
+ Assert.Equal(-0.5f, p.MinX, 5);
+ Assert.Equal(0.5f, p.MaxX, 5);
+ Assert.Equal(-0.25f, p.MinY, 5);
+ Assert.Equal(0.75f, p.MaxY, 5);
+ Assert.False(p.IsEmpty);
+ }
+
+ [Fact]
+ public void ViewPolygon_FewerThanThreeVerts_IsEmpty()
+ {
+ Assert.True(new ViewPolygon(new[] { new Vector2(0, 0), new Vector2(1, 0) }).IsEmpty);
+ Assert.True(new ViewPolygon(System.Array.Empty()).IsEmpty);
+ }
+
+ [Fact]
+ public void CellView_FullScreen_CoversNdc()
+ {
+ var v = CellView.FullScreen();
+ Assert.False(v.IsEmpty);
+ Assert.Equal(-1f, v.MinX, 5);
+ Assert.Equal(1f, v.MaxX, 5);
+ Assert.Equal(-1f, v.MinY, 5);
+ Assert.Equal(1f, v.MaxY, 5);
+ }
+
+ [Fact]
+ public void CellView_Add_GrowsUnionBoundsAndIsEmptyTracks()
+ {
+ var v = new CellView();
+ Assert.True(v.IsEmpty);
+ v.Add(new ViewPolygon(new[] { new Vector2(0, 0), new Vector2(0.2f, 0), new Vector2(0, 0.2f) }));
+ v.Add(new ViewPolygon(new[] { new Vector2(-0.3f, -0.3f), new Vector2(-0.1f, -0.3f), new Vector2(-0.1f, -0.1f) }));
+ Assert.False(v.IsEmpty);
+ Assert.Equal(-0.3f, v.MinX, 5);
+ Assert.Equal(0.2f, v.MaxX, 5);
+ }
+}