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>
285 lines
11 KiB
C#
285 lines
11 KiB
C#
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;
|
||
}
|
||
}
|