From 11ca527fb911c4933bc2f9abf1576a0f1a7110db Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 09:50:03 +0200 Subject: [PATCH] feat(render) Campaign FW1: port the view machinery (xformStart, polyClipFinish, copy_view) WalkScreenClip ports PrimD3DRender::xformStart @0x0059b990 (homogeneous viewport coords, y-flip, no divide) and ACRender::polyClipFinish @0x006b6d00 (w>=cdstW plane then last-to-first edge passes, inside = side<=0 homogeneous 2D cross, reverse-scan passes with original-winding restore, <3 early-outs). WalkViews ports the view_type/portal_view_type data model and Render::copy_view @0x0054dfc0 exactly: in-place divide, the keep/last/stl/second pruning bookkeeping with all three closing wrap checks, <3 reject leaving dest untouched, cap 31, pool-base reset at view_count==0, retail fabs on copy, and edge planes N=normalize(cross(ray[k+1],ray[k])), d=-dot(N,eye) behind an IWalkRayCaster seam. Thirteen new tests; Walk namespace 85/85. Co-Authored-By: Claude Fable 5 --- .../Rendering/Walk/WalkScreenClip.cs | 176 +++++++++++ src/AcDream.App/Rendering/Walk/WalkViews.cs | 285 ++++++++++++++++++ .../Rendering/Walk/WalkCopyViewTests.cs | 163 ++++++++++ .../Rendering/Walk/WalkScreenClipTests.cs | 94 ++++++ 4 files changed, 718 insertions(+) create mode 100644 src/AcDream.App/Rendering/Walk/WalkScreenClip.cs create mode 100644 src/AcDream.App/Rendering/Walk/WalkViews.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkCopyViewTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkScreenClipTests.cs diff --git a/src/AcDream.App/Rendering/Walk/WalkScreenClip.cs b/src/AcDream.App/Rendering/Walk/WalkScreenClip.cs new file mode 100644 index 00000000..ca39f85c --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkScreenClip.cs @@ -0,0 +1,176 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Retail Vec2Dscreen: homogeneous viewport coordinates as produced +/// by PrimD3DRender::xformStart @0x0059b990 — X/Y are viewport-scaled +/// but NOT perspective-divided (divide by W to get pixels), Z is raw clip z, +/// W is raw clip w. copy_view performs the divide; +/// polyClipFinish clips pre-divide homogeneously. +/// +public struct WalkScreenPoint +{ + public float X, Y, Z, W; + + public WalkScreenPoint(float x, float y, float z, float w) + { + X = x; Y = y; Z = z; W = w; + } +} + +/// +/// Campaign FW1 — the screen-space projection/clip chain, ported from the +/// flood-read appendix (docs/research/2026-08-30-fw-flood-pseudocode-appendix.md, +/// report 3; Ghidra-arbitrated — BN's literal rendering inverts the edge +/// inside test and the w-clip plane). +/// +public static class WalkScreenClip +{ + /// The w-clip plane constant cdstW (= retail F_EPSILON). + public const float MinW = WalkVisibilityMath.Epsilon; + + /// + /// PrimD3DRender::xformStart @0x0059b990 (toScreen path): object + /// space → homogeneous viewport coordinates. x=(bw/2)(x_clip+w), + /// y=(bh/2)(w−y_clip) — y flipped, origin top-left — z/w raw clip. + /// is the concatenated object→clip + /// matrix (row-vector convention, v * M). + /// + public static WalkScreenPoint TransformToScreen( + Vector3 point, in Matrix4x4 objectToClip, float viewportWidth, float viewportHeight) + { + Vector4 clip = Vector4.Transform(new Vector4(point, 1f), objectToClip); + return new WalkScreenPoint( + clip.X * viewportWidth * 0.5f + clip.W * viewportWidth * 0.5f, + clip.W * viewportHeight * 0.5f - clip.Y * viewportHeight * 0.5f, + clip.Z, + clip.W); + } + + /// + /// ACRender::polyClipFinish @0x006b6d00: Sutherland-Hodgman clip + /// of a homogeneous screen polygon against the active view — first the + /// w ≥ plane (only when some w is below it), then + /// every view edge, iterated LAST-to-FIRST as vertex pairs + /// (v[0], v[n−1]), (v[n−1], v[n−2]), …, (v[1], v[0]). Edge INSIDE is + /// side ≤ 0 with the homogeneous 2D cross + /// side(p) = (p.x − a.x·p.w)·ey − (p.y − a.y·p.w)·ex. Each pass scans + /// its input in REVERSE; the output keeps the ORIGINAL winding (retail's + /// pass-parity bookkeeping collapses to reversing per pass and + /// un-reversing at the end — this port appends reversed per pass and + /// restores at the end, observably identical). Returns the surviving + /// count, or 0 the moment any stage drops below 3 vertices — in which + /// case content is unspecified (retail never + /// writes the out count on that path; callers pre-zero it). + /// + public static int ClipAgainstView( + ReadOnlySpan input, + ReadOnlySpan viewEdgeVertices, + Span output) + { + // Working buffers sized for retail's ≤32-vertex contract plus clip growth. + Span bufferA = stackalloc WalkScreenPoint[64]; + Span bufferB = stackalloc WalkScreenPoint[64]; + Span current = bufferA; + int count = input.Length; + input.CopyTo(current); + // Track how many reversing passes ran so the final copy can restore + // the original winding exactly as retail's parity dance does. + int reversals = 0; + + // Pass 0: the w-plane, only when some vertex is below cdstW. + bool anyBelow = false; + for (int i = 0; i < count; i++) + if (current[i].W < MinW) { anyBelow = true; break; } + if (anyBelow) + { + count = ClipPassW(current[..count], bufferB); + if (count < 3) return 0; + Span swap = current; + current = bufferB; + bufferB = swap; + reversals++; + } + + // Edge passes: pairs (a, b) = (v[0], v[n-1]), (v[n-1], v[n-2]) … (v[1], v[0]). + int n = viewEdgeVertices.Length; + for (int e = n - 1; e >= 0; e--) + { + Vector2 a = viewEdgeVertices[e == n - 1 ? 0 : e + 1]; + Vector2 b = viewEdgeVertices[e]; + count = ClipPassEdge(current[..count], a, b, bufferB); + if (count < 3) return 0; + Span swap = current; + current = bufferB; + bufferB = swap; + reversals++; + } + + // Restore original winding: each pass reversed the order once. + if ((reversals & 1) != 0) + { + for (int i = 0; i < count; i++) + output[i] = current[count - 1 - i]; + } + else + { + current[..count].CopyTo(output); + } + return count; + } + + private static int ClipPassW(ReadOnlySpan pts, Span outPts) + { + int outCount = 0; + // Reverse traversal starting from the wrap pair (pts[0], pts[n-1]). + WalkScreenPoint prev = pts[0]; + float sPrev = prev.W - MinW; + bool inPrev = sPrev >= 0f; + for (int i = pts.Length - 1; i >= 0; i--) + { + WalkScreenPoint cur = pts[i]; + float s = cur.W - MinW; + bool inCur = s >= 0f; + if (inPrev != inCur) + outPts[outCount++] = Lerp(prev, cur, sPrev / (sPrev - s)); + if (inCur) + outPts[outCount++] = cur; + prev = cur; sPrev = s; inPrev = inCur; + } + return outCount; + } + + private static int ClipPassEdge( + ReadOnlySpan pts, Vector2 a, Vector2 b, Span outPts) + { + float ex = b.X - a.X; + float ey = b.Y - a.Y; + float Side(in WalkScreenPoint p) => (p.X - a.X * p.W) * ey - (p.Y - a.Y * p.W) * ex; + + int outCount = 0; + WalkScreenPoint prev = pts[0]; + float s0 = Side(prev); + float sPrev = s0; + bool inPrev = s0 <= 0f; // INSIDE = side <= 0 (Ghidra-verified) + for (int i = pts.Length - 1; i >= 0; i--) + { + WalkScreenPoint cur = pts[i]; + float s = i != 0 ? Side(cur) : s0; // final pair reuses point 0's side + bool inCur = s <= 0f; + if (inPrev != inCur) + outPts[outCount++] = Lerp(prev, cur, sPrev / (sPrev - s)); + if (inCur) + outPts[outCount++] = cur; + prev = cur; sPrev = s; inPrev = inCur; + } + return outCount; + } + + private static WalkScreenPoint Lerp(in WalkScreenPoint p, in WalkScreenPoint q, float t) + => new( + p.X + (q.X - p.X) * t, + p.Y + (q.Y - p.Y) * t, + p.Z + (q.Z - p.Z) * t, + p.W + (q.W - p.W) * t); +} diff --git a/src/AcDream.App/Rendering/Walk/WalkViews.cs b/src/AcDream.App/Rendering/Walk/WalkViews.cs new file mode 100644 index 00000000..1df32d26 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkViews.cs @@ -0,0 +1,285 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Walk; + +/// Retail view_vertex (stride 0x18): a screen point plus the +/// world-space plane of the edge that STARTS at it (edge k = verts k → k+1). +public struct WalkViewVertex +{ + public Vector2 Point; + public WalkPlane Plane; +} + +/// Retail view_poly: one view polygon's slice of the shared +/// vertex pool plus its screen bounds. +public readonly record struct WalkViewPoly( + int VertexCount, int VertexIndex, float XMin, float XMax, float YMin, float YMax); + +/// Retail view_type: the poly list + shared vertex pool one +/// portal_view_type accumulates its views into. +public sealed class WalkViewSet +{ + public readonly List Polys = new(); + public readonly List Vertices = new(); + public int VertexCountTotal; +} + +/// +/// Retail portal_view_type (0x48 bytes): one view-recursion slot on a +/// cell (or the PView's outside_view). Retail recycles slots and +/// resets exactly view_count/update_count/view_timestamp on push +/// (CEnvCell::curr_view_push @0x005a5090); this port models the same +/// counters over list storage — when is 0 the next +/// append clears the pools, matching retail's vertex-pool base reset. +/// +public sealed class WalkPortalView +{ + /// Per-portal portal_info flags (seen, inflag), sized by + /// PView::InitCell for the owning cell's portal count. + public WalkPortalFlags[] PortalFlags = []; + + public readonly WalkViewSet View = new(); + + /// Max SQUARED cell-local distance to any in-view portal vertex + /// (PView::InitCell); the flood's todo-list distance key. + public float MaxInDistSquared; + + public int ViewCount; + public bool CellViewDone; + public int ViewTimestamp; + public int UpdateCount; + + /// curr_view_push's per-push counter reset. + public void ResetForPush() + { + ViewCount = 0; + UpdateCount = 0; + ViewTimestamp = 0; + } +} + +public struct WalkPortalFlags +{ + public bool Seen; + public bool InView; +} + +/// +/// Unprojects a screen point to a world-space eye ray direction — +/// retail PrimD3DRender::ScreenToViewTransform @0x0059aa40 (the live +/// newmethod==1 path of Render::copy_view's plane builder). +/// The exact matrix wiring lives with the camera module; the walk depends +/// only on this contract. +/// +public interface IWalkRayCaster +{ + Vector3 RayThrough(float screenX, float screenY); +} + +/// +/// Campaign FW1 — Render::copy_view @0x0054dfc0, ported from the +/// flood-read appendix report 3 (Ghidra-arbitrated). Appends ONE view +/// polygon to a : perspective-divides the +/// homogeneous screen points IN PLACE, prunes ~1 px duplicates and +/// collinear points (three closing wrap checks included), rejects fewer +/// than 3 survivors (returns false, dest untouched), caps at 31, stores the +/// point list plus a closing duplicate, computes bounds, and builds +/// per-edge WORLD planes N = normalize(cross(ray[k+1], ray[k])) — NEXT × +/// CURRENT — with d = −dot(N, viewpoint). +/// +public static class WalkCopyView +{ + public const int MaxVertices = 31; // retail cap 0x1f + public const float DedupThreshold = 1f; // strict > 1 px + + /// The null-source path: the full-viewport root quad + /// (0,H)(W,H)(W,0)(0,0) — used by Render::set_default_view and + /// PView::DrawInside's root view (the source count is ignored). + public static bool AppendFullViewportQuad( + WalkPortalView dest, IWalkRayCaster rays, Vector3 viewpoint, + float viewportWidth, float viewportHeight) + { + Span quad = + [ + new(0f, viewportHeight, 0f, 1f), + new(viewportWidth, viewportHeight, 0f, 1f), + new(viewportWidth, 0f, 0f, 1f), + new(0f, 0f, 0f, 1f), + ]; + return Append(dest, quad, rays, viewpoint); + } + + /// The point-source path. is mutated + /// (in-place perspective divide) exactly as retail mutates the shared + /// clip buffer; the buffer is consumed per portal, so the mutation never + /// leaks across calls. + public static bool Append( + WalkPortalView dest, Span points, + IWalkRayCaster rays, Vector3 viewpoint) + { + int npts = points.Length; + if (npts == 0) return false; + + // ---- survivor marking (keep[] / last / stl / second bookkeeping) ---- + Span keep = stackalloc bool[npts]; + keep[0] = true; + int n = 1; + int last = 0; + int secondToLast = 0; // retail 'stl': index of the second-to-last kept corner + int second = 0; // retail local_220: index of the 2nd kept point + + for (int i = 0; i < npts; i++) + { + ref WalkScreenPoint p = ref points[i]; + if (p.W != 1f) + { + p.X /= p.W; + p.Y /= p.W; + p.W = 1f; + } + if (i == 0) continue; + + bool distinct = + MathF.Abs(points[i].X - points[last].X) > DedupThreshold + || MathF.Abs(points[i].Y - points[last].Y) > DedupThreshold; + keep[i] = distinct; + if (!distinct) continue; + + if (n == 1) + { + n++; + second = i; + } + else + { + WalkScreenPoint pp = points[secondToLast]; + WalkScreenPoint prev = points[last]; + WalkScreenPoint cur = points[i]; + float span = MathF.Max(MathF.Abs(pp.X - cur.X), MathF.Abs(pp.Y - cur.Y)); + float cross = (pp.X - prev.X) * (prev.Y - cur.Y) + - (pp.Y - prev.Y) * (prev.X - cur.X); + if (MathF.Abs(cross) >= span) + { + n++; + secondToLast = last; + } + else + { + // prev was collinear: un-keep it; count unchanged (prev out, cur in). + keep[last] = false; + if (second == last) second = i; + } + } + last = i; + } + + // ---- closing wrap checks against point 0 ---- + WalkScreenPoint first = points[0]; + bool lastDistinct = + MathF.Abs(first.X - points[last].X) > DedupThreshold + || MathF.Abs(first.Y - points[last].Y) > DedupThreshold; + keep[last] = lastDistinct; + if (!lastDistinct) + { + n--; + last = secondToLast; + } + else + { + float span = MathF.Max( + MathF.Abs(points[secondToLast].X - first.X), + MathF.Abs(points[secondToLast].Y - first.Y)); + float cross = (points[secondToLast].X - points[last].X) * (points[last].Y - first.Y) + - (points[last].X - first.X) * (points[secondToLast].Y - points[last].Y); + if (MathF.Abs(cross) < span) + { + keep[last] = false; + n--; + last = secondToLast; + } + } + secondToLast = last; + if (second > 0) + { + // Is point 0 itself collinear between the last corner and the second? + float span = MathF.Max( + MathF.Abs(points[secondToLast].X - points[second].X), + MathF.Abs(points[secondToLast].Y - points[second].Y)); + float cross = (first.Y - points[second].Y) * (points[secondToLast].X - first.X) + - (first.X - points[second].X) * (points[secondToLast].Y - first.Y); + if (MathF.Abs(cross) < span) + { + n--; + keep[0] = false; + } + } + + if (n < 3) return false; // REJECT: dest completely untouched + if (n > MaxVertices) n = MaxVertices; // cap 31 (corrupt overflow path unreachable ≤32 in) + + // ---- append into the pool (view_count==0 resets the pool base) ---- + WalkViewSet view = dest.View; + if (dest.ViewCount == 0) + { + view.Polys.Clear(); + view.Vertices.Clear(); + view.VertexCountTotal = 0; + } + int vbase = view.VertexCountTotal; + view.VertexCountTotal = vbase + n + 1; + + int written = 0; + for (int i = 0; i < npts && written < n; i++) + { + if (!keep[i]) continue; + view.Vertices.Add(new WalkViewVertex + { + // Retail applies a REAL fabs on copy (harmless post-clip; preserved). + Point = new Vector2(MathF.Abs(points[i].X), MathF.Abs(points[i].Y)), + }); + written++; + } + // Closing duplicate vertex (its plane slot is never consumed). + view.Vertices.Add(new WalkViewVertex { Point = view.Vertices[vbase].Point }); + + // ---- bounds over v[0..n-1] ---- + float xmin, xmax, ymin, ymax; + Vector2 seed = view.Vertices[vbase + n - 1].Point; + xmin = xmax = seed.X; + ymin = ymax = seed.Y; + for (int k = n - 2; k >= 0; k--) + { + Vector2 pt = view.Vertices[vbase + k].Point; + if (pt.X < xmin) xmin = pt.X; else if (pt.X > xmax) xmax = pt.X; + if (pt.Y < ymin) ymin = pt.Y; else if (pt.Y > ymax) ymax = pt.Y; + } + view.Polys.Add(new WalkViewPoly(n, vbase, xmin, xmax, ymin, ymax)); + + // ---- per-edge world planes from unprojected rays ---- + Span ray = stackalloc Vector3[n + 1]; + for (int k = 0; k < n; k++) + { + Vector2 pt = view.Vertices[vbase + k].Point; + ray[k] = rays.RayThrough(pt.X, pt.Y); + } + ray[n] = ray[0]; + for (int k = n - 1; k >= 0; k--) + { + Vector3 normal = Vector3.Cross(ray[k + 1], ray[k]); // NEXT × CURRENT + if (MathF.Abs(normal.X) >= WalkVisibilityMath.Epsilon + || MathF.Abs(normal.Y) >= WalkVisibilityMath.Epsilon + || MathF.Abs(normal.Z) >= WalkVisibilityMath.Epsilon) + { + normal *= 1f / MathF.Sqrt( + normal.X * normal.X + normal.Y * normal.Y + normal.Z * normal.Z); + } + // else: degenerate edge left tiny/unnormalized (retail behavior). + WalkViewVertex v = view.Vertices[vbase + k]; + v.Plane = new WalkPlane(normal, -Vector3.Dot(normal, viewpoint)); + view.Vertices[vbase + k] = v; + } + + dest.ViewCount += 1; + return true; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkCopyViewTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkCopyViewTests.cs new file mode 100644 index 00000000..6d26600e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkCopyViewTests.cs @@ -0,0 +1,163 @@ +using System.Numerics; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class WalkCopyViewTests +{ + private sealed class LinearRayCaster : IWalkRayCaster + { + public Vector3 RayThrough(float screenX, float screenY) + => new(screenX, screenY, 100f); + } + + private static readonly LinearRayCaster Rays = new(); + private static readonly Vector3 Eye = new(1f, 2f, 3f); + + private static WalkScreenPoint Pt(float x, float y, float w = 1f) + => new(x * w, y * w, 0f, w); + + [Fact] + public void Full_viewport_quad_appends_retails_root_view() + { + var dest = new WalkPortalView(); + + bool ok = WalkCopyView.AppendFullViewportQuad(dest, Rays, Eye, 640f, 480f); + + Assert.True(ok); + Assert.Equal(1, dest.ViewCount); + WalkViewPoly poly = dest.View.Polys[0]; + Assert.Equal(4, poly.VertexCount); + Assert.Equal(0, poly.VertexIndex); + Assert.Equal((0f, 640f, 0f, 480f), (poly.XMin, poly.XMax, poly.YMin, poly.YMax)); + // Vertex order (0,H)(W,H)(W,0)(0,0) + the closing duplicate. + Assert.Equal(new Vector2(0, 480), dest.View.Vertices[0].Point); + Assert.Equal(new Vector2(640, 480), dest.View.Vertices[1].Point); + Assert.Equal(new Vector2(640, 0), dest.View.Vertices[2].Point); + Assert.Equal(new Vector2(0, 0), dest.View.Vertices[3].Point); + Assert.Equal(dest.View.Vertices[0].Point, dest.View.Vertices[4].Point); + Assert.Equal(5, dest.View.VertexCountTotal); + } + + [Fact] + public void Collinear_midpoint_on_an_edge_is_pruned() + { + var dest = new WalkPortalView(); + Span square = + [ + Pt(0, 0), Pt(50, 0), Pt(100, 0), Pt(100, 100), Pt(0, 100), + ]; + + bool ok = WalkCopyView.Append(dest, square, Rays, Eye); + + Assert.True(ok); + Assert.Equal(4, dest.View.Polys[0].VertexCount); + Assert.Equal(new Vector2(0, 0), dest.View.Vertices[0].Point); + Assert.Equal(new Vector2(100, 0), dest.View.Vertices[1].Point); + Assert.Equal(new Vector2(100, 100), dest.View.Vertices[2].Point); + Assert.Equal(new Vector2(0, 100), dest.View.Vertices[3].Point); + } + + [Fact] + public void Near_duplicate_points_within_one_pixel_are_dropped() + { + var dest = new WalkPortalView(); + Span poly = + [ + Pt(0, 0), Pt(0.5f, 0.5f), Pt(100, 0), Pt(50, 100), + ]; + + bool ok = WalkCopyView.Append(dest, poly, Rays, Eye); + + Assert.True(ok); + Assert.Equal(3, dest.View.Polys[0].VertexCount); + } + + [Fact] + public void Fewer_than_three_survivors_reject_and_leave_dest_untouched() + { + var dest = new WalkPortalView(); + Span tiny = + [ + Pt(0, 0), Pt(0.5f, 0f), Pt(0f, 0.5f), + ]; + + bool ok = WalkCopyView.Append(dest, tiny, Rays, Eye); + + Assert.False(ok); + Assert.Equal(0, dest.ViewCount); + Assert.Empty(dest.View.Polys); + Assert.Empty(dest.View.Vertices); + Assert.Equal(0, dest.View.VertexCountTotal); + } + + [Fact] + public void Homogeneous_points_are_perspective_divided_before_storage() + { + var dest = new WalkPortalView(); + Span tri = + [ + Pt(0, 0, w: 2f), Pt(100, 0, w: 2f), Pt(50, 100, w: 2f), + ]; + + bool ok = WalkCopyView.Append(dest, tri, Rays, Eye); + + Assert.True(ok); + Assert.Equal(new Vector2(0, 0), dest.View.Vertices[0].Point); + Assert.Equal(new Vector2(100, 0), dest.View.Vertices[1].Point); + Assert.Equal(new Vector2(50, 100), dest.View.Vertices[2].Point); + } + + [Fact] + public void Edge_planes_are_next_cross_current_normalized_through_the_eye() + { + var dest = new WalkPortalView(); + Span tri = [Pt(0, 0), Pt(100, 0), Pt(50, 100)]; + + Assert.True(WalkCopyView.Append(dest, tri, Rays, Eye)); + + // Edge k starts at vertex k: plane N = normalize(cross(ray[k+1], ray[k])). + Vector3 ray0 = Rays.RayThrough(0, 0); + Vector3 ray1 = Rays.RayThrough(100, 0); + Vector3 expected = Vector3.Normalize(Vector3.Cross(ray1, ray0)); + WalkPlane plane = dest.View.Vertices[0].Plane; + Assert.Equal(expected.X, plane.Normal.X, 5); + Assert.Equal(expected.Y, plane.Normal.Y, 5); + Assert.Equal(expected.Z, plane.Normal.Z, 5); + Assert.Equal(-Vector3.Dot(expected, Eye), plane.D, 3); + } + + [Fact] + public void Pool_resets_when_view_count_returns_to_zero() + { + var dest = new WalkPortalView(); + Span tri = [Pt(0, 0), Pt(100, 0), Pt(50, 100)]; + Assert.True(WalkCopyView.Append(dest, tri, Rays, Eye)); + int firstTotal = dest.View.VertexCountTotal; + + dest.ResetForPush(); // curr_view_push: ViewCount back to 0 + Span tri2 = [Pt(0, 0), Pt(200, 0), Pt(100, 200)]; + Assert.True(WalkCopyView.Append(dest, tri2, Rays, Eye)); + + Assert.Equal(1, dest.ViewCount); + Assert.Single(dest.View.Polys); + Assert.Equal(firstTotal, dest.View.VertexCountTotal); // pool restarted at 0 + Assert.Equal(0, dest.View.Polys[0].VertexIndex); + } + + [Fact] + public void Second_append_extends_the_shared_pool() + { + var dest = new WalkPortalView(); + Span tri = [Pt(0, 0), Pt(100, 0), Pt(50, 100)]; + Assert.True(WalkCopyView.Append(dest, tri, Rays, Eye)); + Span tri2 = [Pt(0, 0), Pt(200, 0), Pt(100, 200)]; + + Assert.True(WalkCopyView.Append(dest, tri2, Rays, Eye)); + + Assert.Equal(2, dest.ViewCount); + Assert.Equal(2, dest.View.Polys.Count); + Assert.Equal(4, dest.View.Polys[1].VertexIndex); // after tri's 3 + dup + Assert.Equal(8, dest.View.VertexCountTotal); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkScreenClipTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkScreenClipTests.cs new file mode 100644 index 00000000..99bbaa4e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkScreenClipTests.cs @@ -0,0 +1,94 @@ +using System.Numerics; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class WalkScreenClipTests +{ + private const float W = 640f, H = 480f; + + // The root full-viewport quad in retail's vertex order (0,H)(W,H)(W,0)(0,0). + private static readonly Vector2[] RootQuad = + [ + new(0, H), new(W, H), new(W, 0), new(0, 0), + ]; + + private static WalkScreenPoint Pt(float x, float y, float w = 1f) + => new(x * w, y * w, 0f, w); // homogeneous: screen * w + + [Fact] + public void Transform_maps_clip_center_to_screen_center_with_y_flip() + { + Matrix4x4 identity = Matrix4x4.Identity; + + WalkScreenPoint center = WalkScreenClip.TransformToScreen( + Vector3.Zero, identity, W, H); + Assert.Equal(W / 2, center.X); + Assert.Equal(H / 2, center.Y); + Assert.Equal(1f, center.W); + + // Clip y = +1 (top of clip space) lands at screen y = 0 (top-left origin). + WalkScreenPoint top = WalkScreenClip.TransformToScreen( + new Vector3(0, 1, 0), identity, W, H); + Assert.Equal(0f, top.Y); + } + + [Fact] + public void Fully_inside_polygon_survives_unchanged_with_original_winding() + { + Span tri = [Pt(100, 100), Pt(300, 120), Pt(200, 300)]; + Span outPts = stackalloc WalkScreenPoint[16]; + + int n = WalkScreenClip.ClipAgainstView(tri, RootQuad, outPts); + + Assert.Equal(3, n); + Assert.Equal(100f, outPts[0].X); + Assert.Equal(300f, outPts[1].X); + Assert.Equal(200f, outPts[2].X); + } + + [Fact] + public void Polygon_straddling_the_left_edge_is_clipped_at_x_zero() + { + Span tri = [Pt(-100, 100), Pt(100, 100), Pt(100, 300)]; + Span outPts = stackalloc WalkScreenPoint[16]; + + int n = WalkScreenClip.ClipAgainstView(tri, RootQuad, outPts); + + Assert.True(n >= 3); + for (int i = 0; i < n; i++) + Assert.True(outPts[i].X / outPts[i].W >= -0.001f, $"vertex {i} left of x=0"); + // Something was actually cut (an intersection vertex exists at x≈0). + bool touchesEdge = false; + for (int i = 0; i < n; i++) + if (MathF.Abs(outPts[i].X / outPts[i].W) < 0.001f) touchesEdge = true; + Assert.True(touchesEdge); + } + + [Fact] + public void Polygon_fully_outside_one_edge_returns_zero() + { + Span tri = [Pt(-300, 100), Pt(-100, 100), Pt(-200, 300)]; + Span outPts = stackalloc WalkScreenPoint[16]; + + Assert.Equal(0, WalkScreenClip.ClipAgainstView(tri, RootQuad, outPts)); + } + + [Fact] + public void W_plane_clips_points_behind_the_eye() + { + // One vertex behind the eye (w < cdstW); survivors get intersections + // at w == cdstW rather than dropping the polygon. + Span tri = + [ + Pt(100, 100), Pt(300, 100), new WalkScreenPoint(200, 200, 0, -0.5f), + ]; + Span outPts = stackalloc WalkScreenPoint[16]; + + int n = WalkScreenClip.ClipAgainstView(tri, RootQuad, outPts); + + Assert.True(n >= 3); + for (int i = 0; i < n; i++) + Assert.True(outPts[i].W >= WalkScreenClip.MinW - 1e-6f); + } +}