acdream/src/AcDream.App/Rendering/Walk/WalkViews.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

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