acdream/src/AcDream.App/Rendering/ViewconeCuller.cs
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

207 lines
7.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;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// T3 (BR-5): the port of retail's <c>Render::viewconeCheck</c> (Ghidra
/// 0x0054c250) — meshes (characters, statics, emitters) are CULLED per portal
/// view by a bounding-sphere test against the view's edge planes, never
/// clipped. Retail stores each view vertex with its 3D eye-edge plane
/// (<c>view_vertex { Vec2D pt; Plane plane }</c>, acclient.h:32483) and tests
/// the object's drawing sphere against the installed view's plane set;
/// OUTSIDE → skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310,
/// and the DrawCells per-cell object epilogue, Ghidra 0x005a4840).
///
/// <para>Our views are clip-space half-planes (≤8 per slice,
/// <see cref="ClipPlaneSet"/> output: (nx,ny,0,d) satisfied when
/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space —
/// the view_vertex.plane analog, a plane through the EYE and the view edge —
/// is one matrix fold: with row-vector convention (System.Numerics),
/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P)
/// is the world-space homogeneous half-plane. Sphere-vs-half-plane keeps the
/// sphere when L.xyz·c + L.w ≥ r·|L.xyz| (not entirely outside).</para>
///
/// <para>A sphere is visible through a SLICE when it is not entirely outside
/// any of the slice's planes (convex region); visible for a CELL when any of
/// the cell's slices passes. A slice with zero planes is pass-all (the
/// NoClipSlice / full-screen outdoor case). A cell with no views culls — in
/// retail an object whose cell is not in the draw list is simply never
/// reached.</para>
/// </summary>
public sealed class ViewconeCuller
{
private const int MaxRetainedCellPlaneSets = 512;
private const int MaxRetainedPlanesPerCell = 256;
private const int MaxRetainedSlicesPerCell = 64;
private readonly Dictionary<uint, PlaneSet> _cellPlanes = new();
private readonly Stack<PlaneSet> _planeSetPool = new();
private PlaneSet _outsidePlanes = new();
private readonly record struct SliceRange(int Start, int Count);
private readonly record struct LiftedPlane(Vector4 Equation, float NormalLength);
/// <summary>
/// Contiguous per-cell plane storage. Reusing two Lists per visible cell
/// avoids rebuilding a jagged array graph on every render frame while
/// retaining the exact slice boundaries used by retail's any-view test.
/// </summary>
private sealed class PlaneSet
{
public List<LiftedPlane> Planes { get; } = new();
public List<SliceRange> Slices { get; } = new();
public bool IsRetainable =>
Planes.Capacity <= MaxRetainedPlanesPerCell
&& Slices.Capacity <= MaxRetainedSlicesPerCell;
public void Reset()
{
Planes.Clear();
Slices.Clear();
}
}
/// <summary>True when the outside view is a full-screen pass-all (the
/// synthetic outdoor root) — every outside-test passes.</summary>
public bool OutsideIsFullScreen { get; private set; }
public static ViewconeCuller Build(
ClipFrameAssembly assembly,
in Matrix4x4 viewProjection,
ViewconeCuller? reuse = null)
{
ArgumentNullException.ThrowIfNull(assembly);
var culler = reuse ?? new ViewconeCuller();
culler.Reset();
foreach (var (cellId, slices) in assembly.CellIdToViewSlices)
{
PlaneSet lifted = culler.RentPlaneSet();
for (int s = 0; s < slices.Length; s++)
AppendLiftedSlice(lifted, slices[s].Planes, viewProjection);
culler._cellPlanes[cellId] = lifted;
}
var outside = assembly.OutsideViewSlices;
bool fullScreen = false;
for (int s = 0; s < outside.Length; s++)
{
AppendLiftedSlice(culler._outsidePlanes, outside[s].Planes, viewProjection);
if (outside[s].Planes.Length == 0)
fullScreen = true;
}
culler.OutsideIsFullScreen = fullScreen;
return culler;
}
private void Reset()
{
foreach (PlaneSet set in _cellPlanes.Values)
{
set.Reset();
if (set.IsRetainable && _planeSetPool.Count < MaxRetainedCellPlaneSets)
_planeSetPool.Push(set);
}
_cellPlanes.Clear();
if (_outsidePlanes.IsRetainable)
_outsidePlanes.Reset();
else
_outsidePlanes = new PlaneSet();
OutsideIsFullScreen = false;
}
private PlaneSet RentPlaneSet()
{
PlaneSet result = _planeSetPool.Count != 0
? _planeSetPool.Pop()
: new PlaneSet();
result.Reset();
return result;
}
private static void AppendLiftedSlice(
PlaneSet destination,
Vector4[] clipPlanes,
in Matrix4x4 m)
{
int start = destination.Planes.Count;
for (int i = 0; i < clipPlanes.Length; i++)
{
var p = clipPlanes[i];
var equation = new Vector4(
m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W,
m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W,
m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W,
m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W);
float normalLength = MathF.Sqrt(
equation.X * equation.X
+ equation.Y * equation.Y
+ equation.Z * equation.Z);
destination.Planes.Add(new LiftedPlane(equation, normalLength));
}
destination.Slices.Add(new SliceRange(start, clipPlanes.Length));
}
private static bool SphereInsidePlanes(
PlaneSet set,
SliceRange slice,
in Vector3 center,
float radius)
{
int end = slice.Start + slice.Count;
for (int i = slice.Start; i < end; i++)
{
LiftedPlane plane = set.Planes[i];
Vector4 l = plane.Equation;
float nLen = plane.NormalLength;
if (nLen < 1e-12f)
continue; // degenerate plane — no constraint
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
if (dist < -radius * nLen)
return false; // entirely outside this edge plane
}
return true;
}
/// <summary>Sphere-vs-the-cell's-views: visible when any slice passes.
/// A cell with no views culls (not in the draw list ⇒ never reached in
/// retail). A zero-plane slice is pass-all.</summary>
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
{
if (!_cellPlanes.TryGetValue(cellId, out PlaneSet? set))
return false;
for (int s = 0; s < set.Slices.Count; s++)
if (SphereInsidePlanes(set, set.Slices[s], center, radius))
return true;
return false;
}
/// <summary>Sphere-vs-the-outside-views (objects in outdoor space seen
/// from an interior root through doorways; pass-all under the outdoor
/// root's full-screen outside view).</summary>
public bool SphereVisibleOutside(in Vector3 center, float radius)
{
if (OutsideIsFullScreen)
return true;
for (int s = 0; s < _outsidePlanes.Slices.Count; s++)
if (SphereInsidePlanes(_outsidePlanes, _outsidePlanes.Slices[s], center, radius))
return true;
return false;
}
/// <summary>Sphere vs ONE outside slice (the landscape pass draws per
/// slice; its statics pre-filter tests against exactly that slice).</summary>
public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius)
{
if ((uint)sliceIndex >= (uint)_outsidePlanes.Slices.Count)
return false;
return SphereInsidePlanes(
_outsidePlanes,
_outsidePlanes.Slices[sliceIndex],
center,
radius);
}
}