acdream/src/AcDream.App/Rendering/Walk/WalkScreenClip.cs
Erik 11ca527fb9 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>
2026-08-30 09:50:03 +02:00

176 lines
6.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)(wy_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[n1]), (v[n1], v[n2]), …, (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);
}