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 <noreply@anthropic.com>
This commit is contained in:
parent
368c480bc2
commit
11ca527fb9
4 changed files with 718 additions and 0 deletions
176
src/AcDream.App/Rendering/Walk/WalkScreenClip.cs
Normal file
176
src/AcDream.App/Rendering/Walk/WalkScreenClip.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Vec2Dscreen</c>: homogeneous viewport coordinates as produced
|
||||||
|
/// by <c>PrimD3DRender::xformStart</c> @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. <c>copy_view</c> performs the divide;
|
||||||
|
/// <c>polyClipFinish</c> clips pre-divide homogeneously.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static class WalkScreenClip
|
||||||
|
{
|
||||||
|
/// <summary>The w-clip plane constant <c>cdstW</c> (= retail F_EPSILON).</summary>
|
||||||
|
public const float MinW = WalkVisibilityMath.Epsilon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>PrimD3DRender::xformStart</c> @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.
|
||||||
|
/// <paramref name="objectToClip"/> is the concatenated object→clip
|
||||||
|
/// matrix (row-vector convention, v * M).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ACRender::polyClipFinish</c> @0x006b6d00: Sutherland-Hodgman clip
|
||||||
|
/// of a homogeneous screen polygon against the active view — first the
|
||||||
|
/// w ≥ <see cref="MinW"/> 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 <paramref name="output"/> content is unspecified (retail never
|
||||||
|
/// writes the out count on that path; callers pre-zero it).
|
||||||
|
/// </summary>
|
||||||
|
public static int ClipAgainstView(
|
||||||
|
ReadOnlySpan<WalkScreenPoint> input,
|
||||||
|
ReadOnlySpan<Vector2> viewEdgeVertices,
|
||||||
|
Span<WalkScreenPoint> output)
|
||||||
|
{
|
||||||
|
// Working buffers sized for retail's ≤32-vertex contract plus clip growth.
|
||||||
|
Span<WalkScreenPoint> bufferA = stackalloc WalkScreenPoint[64];
|
||||||
|
Span<WalkScreenPoint> bufferB = stackalloc WalkScreenPoint[64];
|
||||||
|
Span<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> pts, Span<WalkScreenPoint> 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<WalkScreenPoint> pts, Vector2 a, Vector2 b, Span<WalkScreenPoint> 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);
|
||||||
|
}
|
||||||
285
src/AcDream.App/Rendering/Walk/WalkViews.cs
Normal file
285
src/AcDream.App/Rendering/Walk/WalkViews.cs
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>view_vertex</c> (stride 0x18): a screen point plus the
|
||||||
|
/// world-space plane of the edge that STARTS at it (edge k = verts k → k+1).</summary>
|
||||||
|
public struct WalkViewVertex
|
||||||
|
{
|
||||||
|
public Vector2 Point;
|
||||||
|
public WalkPlane Plane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>view_poly</c>: one view polygon's slice of the shared
|
||||||
|
/// vertex pool plus its screen bounds.</summary>
|
||||||
|
public readonly record struct WalkViewPoly(
|
||||||
|
int VertexCount, int VertexIndex, float XMin, float XMax, float YMin, float YMax);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>view_type</c>: the poly list + shared vertex pool one
|
||||||
|
/// <c>portal_view_type</c> accumulates its views into.</summary>
|
||||||
|
public sealed class WalkViewSet
|
||||||
|
{
|
||||||
|
public readonly List<WalkViewPoly> Polys = new();
|
||||||
|
public readonly List<WalkViewVertex> Vertices = new();
|
||||||
|
public int VertexCountTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>portal_view_type</c> (0x48 bytes): one view-recursion slot on a
|
||||||
|
/// cell (or the PView's <c>outside_view</c>). Retail recycles slots and
|
||||||
|
/// resets exactly view_count/update_count/view_timestamp on push
|
||||||
|
/// (<c>CEnvCell::curr_view_push</c> @0x005a5090); this port models the same
|
||||||
|
/// counters over list storage — when <see cref="ViewCount"/> is 0 the next
|
||||||
|
/// append clears the pools, matching retail's vertex-pool base reset.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkPortalView
|
||||||
|
{
|
||||||
|
/// <summary>Per-portal <c>portal_info</c> flags (seen, inflag), sized by
|
||||||
|
/// <c>PView::InitCell</c> for the owning cell's portal count.</summary>
|
||||||
|
public WalkPortalFlags[] PortalFlags = [];
|
||||||
|
|
||||||
|
public readonly WalkViewSet View = new();
|
||||||
|
|
||||||
|
/// <summary>Max SQUARED cell-local distance to any in-view portal vertex
|
||||||
|
/// (<c>PView::InitCell</c>); the flood's todo-list distance key.</summary>
|
||||||
|
public float MaxInDistSquared;
|
||||||
|
|
||||||
|
public int ViewCount;
|
||||||
|
public bool CellViewDone;
|
||||||
|
public int ViewTimestamp;
|
||||||
|
public int UpdateCount;
|
||||||
|
|
||||||
|
/// <summary><c>curr_view_push</c>'s per-push counter reset.</summary>
|
||||||
|
public void ResetForPush()
|
||||||
|
{
|
||||||
|
ViewCount = 0;
|
||||||
|
UpdateCount = 0;
|
||||||
|
ViewTimestamp = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct WalkPortalFlags
|
||||||
|
{
|
||||||
|
public bool Seen;
|
||||||
|
public bool InView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unprojects a screen point to a world-space eye ray direction —
|
||||||
|
/// retail <c>PrimD3DRender::ScreenToViewTransform</c> @0x0059aa40 (the live
|
||||||
|
/// <c>newmethod==1</c> path of <c>Render::copy_view</c>'s plane builder).
|
||||||
|
/// The exact matrix wiring lives with the camera module; the walk depends
|
||||||
|
/// only on this contract.
|
||||||
|
/// </summary>
|
||||||
|
public interface IWalkRayCaster
|
||||||
|
{
|
||||||
|
Vector3 RayThrough(float screenX, float screenY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW1 — <c>Render::copy_view</c> @0x0054dfc0, ported from the
|
||||||
|
/// flood-read appendix report 3 (Ghidra-arbitrated). Appends ONE view
|
||||||
|
/// polygon to a <see cref="WalkPortalView"/>: 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).
|
||||||
|
/// </summary>
|
||||||
|
public static class WalkCopyView
|
||||||
|
{
|
||||||
|
public const int MaxVertices = 31; // retail cap 0x1f
|
||||||
|
public const float DedupThreshold = 1f; // strict > 1 px
|
||||||
|
|
||||||
|
/// <summary>The null-source path: the full-viewport root quad
|
||||||
|
/// (0,H)(W,H)(W,0)(0,0) — used by <c>Render::set_default_view</c> and
|
||||||
|
/// <c>PView::DrawInside</c>'s root view (the source count is ignored).</summary>
|
||||||
|
public static bool AppendFullViewportQuad(
|
||||||
|
WalkPortalView dest, IWalkRayCaster rays, Vector3 viewpoint,
|
||||||
|
float viewportWidth, float viewportHeight)
|
||||||
|
{
|
||||||
|
Span<WalkScreenPoint> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The point-source path. <paramref name="points"/> 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.</summary>
|
||||||
|
public static bool Append(
|
||||||
|
WalkPortalView dest, Span<WalkScreenPoint> points,
|
||||||
|
IWalkRayCaster rays, Vector3 viewpoint)
|
||||||
|
{
|
||||||
|
int npts = points.Length;
|
||||||
|
if (npts == 0) return false;
|
||||||
|
|
||||||
|
// ---- survivor marking (keep[] / last / stl / second bookkeeping) ----
|
||||||
|
Span<bool> 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<Vector3> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
tests/AcDream.App.Tests/Rendering/Walk/WalkCopyViewTests.cs
Normal file
163
tests/AcDream.App.Tests/Rendering/Walk/WalkCopyViewTests.cs
Normal file
|
|
@ -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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> 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<WalkScreenPoint> tri = [Pt(0, 0), Pt(100, 0), Pt(50, 100)];
|
||||||
|
Assert.True(WalkCopyView.Append(dest, tri, Rays, Eye));
|
||||||
|
Span<WalkScreenPoint> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<WalkScreenPoint> tri = [Pt(100, 100), Pt(300, 120), Pt(200, 300)];
|
||||||
|
Span<WalkScreenPoint> 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<WalkScreenPoint> tri = [Pt(-100, 100), Pt(100, 100), Pt(100, 300)];
|
||||||
|
Span<WalkScreenPoint> 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<WalkScreenPoint> tri = [Pt(-300, 100), Pt(-100, 100), Pt(-200, 300)];
|
||||||
|
Span<WalkScreenPoint> 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<WalkScreenPoint> tri =
|
||||||
|
[
|
||||||
|
Pt(100, 100), Pt(300, 100), new WalkScreenPoint(200, 200, 0, -0.5f),
|
||||||
|
];
|
||||||
|
Span<WalkScreenPoint> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue