feat(render): Phase A8.F — ViewPolygon + CellView clip-region data model

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 11:30:28 +02:00
parent bb903bc157
commit 406307e8ee
2 changed files with 123 additions and 0 deletions

View file

@ -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;
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
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;
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
public sealed class CellView
{
public readonly List<ViewPolygon> 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;
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
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;
}
}

View file

@ -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<Vector2>()).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);
}
}