The FPS deep-dive landed (dense Arwic 75 -> ~165 fps via the cell-object batching + cell-particle consolidation, both already committed). Remove the throwaway diagnostic apparatus now that it has served its purpose: - delete FrameProfiler.cs (whole-frame TimeElapsed + [PASS-GPU] glFinish + [CPU-PHASE]/[GPU-PHASE] timers + the =1/=2 ACDREAM_FPS_PROF modes) - GameWindow: _fpsProf/_frameProfiler/_msaaSamples fields, the BeginFrame/ EndFrame/MarkUpdateStart hooks, the terrain glFinish, and the landscape sub-phase LsMark instrumentation - RetailPViewRenderer: the DrawInside per-phase Phase()/MarkGpu markers - ParticleRenderer / PortalDepthMaskRenderer / EnvCellRenderer: the per-pass glFinish brackets - delete DegradeCoverageProbeTests.cs (the dead distance-degrade probe) KEPT (the real fixes): RetailPViewRenderer cell-object batching + consolidated cell-particle pass; EnvCellRenderer.CellHasTransparent. Build + full test suite green (468 App incl. pview replay tests; 1566 Core; 317 Net; 425 UI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
316 lines
14 KiB
C#
316 lines
14 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// BR-2 (holistic building-render port): retail's invisible portal depth
|
||
/// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c>
|
||
/// (Ghidra 0x0059bc90, pc:424490).
|
||
///
|
||
/// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch
|
||
/// on outdoor / look-in roots, via <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>
|
||
/// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the
|
||
/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
|
||
/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)
|
||
/// added the two-pass stencil depth gate on the punch side — see
|
||
/// <see cref="DrawDepthFan"/>.</para>
|
||
///
|
||
/// <para>Retail projects a portal polygon, software-clips it against the
|
||
/// installed portal view (<c>polyClipFinish</c>), and draws the survivor as a
|
||
/// COLOR-INVISIBLE triangle fan with depth-test ALWAYS + depth-write ON:</para>
|
||
/// <list type="bullet">
|
||
/// <item><b>Seal</b> (retail <c>maxZ2=6</c>, bit0 clear, data 0x00820e14):
|
||
/// z = the polygon's true projected depth. Drawn on portals leading OUTSIDE
|
||
/// (<c>other_cell_id==0xFFFF</c>) after the landscape pass — terrain seen
|
||
/// through a doorway keeps its pixels because farther interior geometry
|
||
/// z-fails inside the aperture (PView::DrawCells loop 1, Ghidra 0x005a4840,
|
||
/// pc:432783-432786).</item>
|
||
/// <item><b>Punch</b> (retail <c>maxZ1=7</c>, bit0 set, data 0x00820e18):
|
||
/// z forced to the far plane (0.99999988) — erases depth inside a building
|
||
/// aperture so the interior cells drawn next land cleanly
|
||
/// (ConstructView(CBldPortal) mode-1, pc:433827). BR-2 commit 2 wires this
|
||
/// side.</item>
|
||
/// </list>
|
||
///
|
||
/// <para>Where retail clips the polygon on the CPU against the view, we apply
|
||
/// the SAME view region via <c>gl_ClipDistance</c> from the slice's clip-space
|
||
/// half-planes (≤8, the validated <see cref="ClipPlaneSet"/> output) — the
|
||
/// depth write lands only inside the slice region, matching retail's clipped
|
||
/// fan.</para>
|
||
///
|
||
/// <para>Self-contained GL state (feedback_render_self_contained_gl_state):
|
||
/// sets everything it depends on, restores the frame-global convention on
|
||
/// exit, no early-outs between set and restore.</para>
|
||
/// </summary>
|
||
public sealed class PortalDepthMaskRenderer : IDisposable
|
||
{
|
||
private const string VertSrc = @"#version 430 core
|
||
layout(location = 0) in vec3 aPos;
|
||
uniform mat4 uViewProjection;
|
||
uniform int uPlaneCount;
|
||
uniform vec4 uPlanes[8];
|
||
uniform int uForceFarZ;
|
||
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
|
||
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
|
||
out float gl_ClipDistance[8];
|
||
void main()
|
||
{
|
||
vec4 clipPos = uViewProjection * vec4(aPos, 1.0);
|
||
for (int i = 0; i < 8; i++)
|
||
gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0;
|
||
if (uForceFarZ == 1)
|
||
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
|
||
else if (uDepthBias > 0.0)
|
||
{
|
||
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
|
||
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
|
||
// constant-NDC form alone reached METERS at distance (door-shaped
|
||
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
|
||
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
|
||
clipPos.z -= biasNdc * clipPos.w;
|
||
}
|
||
gl_Position = clipPos;
|
||
}";
|
||
|
||
private const string FragSrc = @"#version 430 core
|
||
void main() { } // depth-only: color writes are masked off by the caller state
|
||
";
|
||
|
||
private readonly GL _gl;
|
||
private readonly uint _program;
|
||
private readonly uint _vao;
|
||
private readonly uint _vbo;
|
||
private readonly int _locViewProjection;
|
||
private readonly int _locPlaneCount;
|
||
private readonly int _locPlanes;
|
||
private readonly int _locForceFarZ;
|
||
private readonly int _locDepthBias;
|
||
private readonly int _locDepthBiasEyeCapN;
|
||
|
||
private const int MaxFanVerts = 32;
|
||
private readonly float[] _scratch = new float[MaxFanVerts * 3];
|
||
|
||
public PortalDepthMaskRenderer(GL gl)
|
||
{
|
||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||
|
||
uint vs = Compile(ShaderType.VertexShader, VertSrc);
|
||
uint fs = Compile(ShaderType.FragmentShader, FragSrc);
|
||
_program = _gl.CreateProgram();
|
||
_gl.AttachShader(_program, vs);
|
||
_gl.AttachShader(_program, fs);
|
||
_gl.LinkProgram(_program);
|
||
_gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked);
|
||
if (linked == 0)
|
||
throw new InvalidOperationException($"PortalDepthMask link failed: {_gl.GetProgramInfoLog(_program)}");
|
||
_gl.DeleteShader(vs);
|
||
_gl.DeleteShader(fs);
|
||
|
||
_locViewProjection = _gl.GetUniformLocation(_program, "uViewProjection");
|
||
_locPlaneCount = _gl.GetUniformLocation(_program, "uPlaneCount");
|
||
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
|
||
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
|
||
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
|
||
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
|
||
|
||
_vao = _gl.GenVertexArray();
|
||
_vbo = _gl.GenBuffer();
|
||
_gl.BindVertexArray(_vao);
|
||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||
unsafe
|
||
{
|
||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||
(nuint)(MaxFanVerts * 3 * sizeof(float)), null, BufferUsageARB.DynamicDraw);
|
||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
|
||
}
|
||
_gl.EnableVertexAttribArray(0);
|
||
_gl.BindVertexArray(0);
|
||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||
}
|
||
|
||
private uint Compile(ShaderType type, string src)
|
||
{
|
||
uint s = _gl.CreateShader(type);
|
||
_gl.ShaderSource(s, src);
|
||
_gl.CompileShader(s);
|
||
_gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok);
|
||
if (ok == 0)
|
||
throw new InvalidOperationException($"PortalDepthMask {type} compile failed: {_gl.GetShaderInfoLog(s)}");
|
||
return s;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #117 (2026-06-11): the mark-pass depth bias, in NDC, toward the
|
||
/// viewer. Retail's punch is DEPTHTEST_ALWAYS and is safe only because
|
||
/// retail's outdoor pass is painter's-ordered far→near (anything nearer
|
||
/// redraws AFTER the punch and re-covers it). Our z-buffered MDI frame
|
||
/// has no such order, so an unconditional far-Z punch erased the depth
|
||
/// of NEARER occluders (terrain hills, closer buildings) at aperture
|
||
/// pixels — doors/interiors painted through them (the T5 #117 report).
|
||
/// The z-buffer-correct equivalent: punch ONLY where the aperture
|
||
/// polygon itself wins a depth test at its true depth (two-pass
|
||
/// stencil below). The bias keeps the #108 case covered — terrain
|
||
/// hugging the door plane (centimeters in front of the aperture) must
|
||
/// still be punched; a hill or another house meters nearer must not.
|
||
/// </summary>
|
||
private const float PunchMarkDepthBias = 0.0005f;
|
||
|
||
/// <summary>
|
||
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
|
||
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
|
||
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
|
||
/// ~190 m at a landblock away: every hill/house in front of a distant
|
||
/// aperture passed the mark and got far-Z punched — door-shaped leaks
|
||
/// through occluders. Fix: cap the bias's EYE-SPACE span at
|
||
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
|
||
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
|
||
/// bit-identical to the T5-validated close-range behavior (#108 grass
|
||
/// coverage untouched); beyond it the punch can never reach an occluder
|
||
/// more than the cap in front of the aperture plane.
|
||
/// </summary>
|
||
public const float PunchMarkBiasEyeCapMeters = 0.5f;
|
||
|
||
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
|
||
/// d4b5c71). The cap conversion below assumes the production camera near
|
||
/// plane; the small f/(f−n) factor (~1.00002 at far 5000) is ignored.</summary>
|
||
public const float CameraNearPlaneMeters = 0.1f;
|
||
|
||
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
|
||
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
|
||
/// <paramref name="eyeDepthMeters"/>.</summary>
|
||
public static float MarkBiasNdc(float eyeDepthMeters) =>
|
||
MathF.Min(PunchMarkDepthBias,
|
||
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
|
||
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
|
||
|
||
/// <summary>
|
||
/// Draw one portal polygon as an invisible depth write, clipped to the
|
||
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
|
||
/// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth).
|
||
///
|
||
/// <para><b>Seal</b> (interior root): one pass, retail-verbatim —
|
||
/// depth ALWAYS + true projected depth. It runs immediately after the
|
||
/// gated full depth clear, so there is no nearer content to stomp.</para>
|
||
///
|
||
/// <para><b>Punch</b> (outdoor root / look-in): two passes (#117).
|
||
/// Pass A marks stencil where the aperture fan passes a LEQUAL depth
|
||
/// test at its (biased) true depth — i.e. where the aperture is
|
||
/// actually visible against everything drawn so far. Pass B writes the
|
||
/// far-Z punch with depth ALWAYS but stencil-gated to the marked
|
||
/// pixels, and zeroes the stencil as it goes (self-cleaning). This is
|
||
/// the z-buffered equivalent of retail's painter's-order safety.</para>
|
||
/// </summary>
|
||
public void DrawDepthFan(
|
||
ReadOnlySpan<Vector3> worldVerts,
|
||
in Matrix4x4 viewProjection,
|
||
ReadOnlySpan<Vector4> planes,
|
||
bool forceFarZ)
|
||
{
|
||
if (worldVerts.Length < 3)
|
||
return;
|
||
int n = Math.Min(worldVerts.Length, MaxFanVerts);
|
||
int planeCount = Math.Min(planes.Length, 8);
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
_scratch[i * 3 + 0] = worldVerts[i].X;
|
||
_scratch[i * 3 + 1] = worldVerts[i].Y;
|
||
_scratch[i * 3 + 2] = worldVerts[i].Z;
|
||
}
|
||
|
||
// ---- set state (everything this draw depends on) ----
|
||
_gl.UseProgram(_program);
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.Disable(EnableCap.CullFace); // portal fans face either way
|
||
_gl.Disable(EnableCap.ScissorTest);
|
||
_gl.Enable(EnableCap.DepthTest);
|
||
_gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color
|
||
for (int i = 0; i < planeCount; i++)
|
||
_gl.Enable(EnableCap.ClipDistance0 + i);
|
||
|
||
unsafe
|
||
{
|
||
var m = viewProjection;
|
||
_gl.UniformMatrix4(_locViewProjection, 1, false, (float*)&m);
|
||
_gl.Uniform1(_locPlaneCount, planeCount);
|
||
if (planeCount > 0)
|
||
{
|
||
Span<float> p = stackalloc float[planeCount * 4];
|
||
for (int i = 0; i < planeCount; i++)
|
||
{
|
||
p[i * 4 + 0] = planes[i].X;
|
||
p[i * 4 + 1] = planes[i].Y;
|
||
p[i * 4 + 2] = planes[i].Z;
|
||
p[i * 4 + 3] = planes[i].W;
|
||
}
|
||
fixed (float* pp = p)
|
||
_gl.Uniform4(_locPlanes, (uint)planeCount, pp);
|
||
}
|
||
|
||
_gl.BindVertexArray(_vao);
|
||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||
fixed (float* v = _scratch)
|
||
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v);
|
||
|
||
if (!forceFarZ)
|
||
{
|
||
// ── SEAL: retail-verbatim single pass ──
|
||
_gl.DepthFunc(DepthFunction.Always);
|
||
_gl.DepthMask(true);
|
||
_gl.Uniform1(_locForceFarZ, 0);
|
||
_gl.Uniform1(_locDepthBias, 0f);
|
||
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
|
||
}
|
||
else
|
||
{
|
||
// ── PUNCH pass A: stencil-mark visible aperture pixels ──
|
||
_gl.Enable(EnableCap.StencilTest);
|
||
_gl.StencilFunc(StencilFunction.Always, 1, 0xFF);
|
||
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
||
_gl.StencilMask(0xFF);
|
||
_gl.DepthFunc(DepthFunction.Lequal);
|
||
_gl.DepthMask(false);
|
||
_gl.Uniform1(_locForceFarZ, 0);
|
||
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
|
||
_gl.Uniform1(_locDepthBiasEyeCapN,
|
||
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
|
||
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
|
||
|
||
// ── PUNCH pass B: far-Z write on marked pixels only;
|
||
// zero the stencil as we go (self-cleaning) ──
|
||
_gl.StencilFunc(StencilFunction.Equal, 1, 0xFF);
|
||
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Zero);
|
||
_gl.DepthFunc(DepthFunction.Always);
|
||
_gl.DepthMask(true);
|
||
_gl.Uniform1(_locForceFarZ, 1);
|
||
_gl.Uniform1(_locDepthBias, 0f);
|
||
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
|
||
|
||
_gl.Disable(EnableCap.StencilTest);
|
||
}
|
||
|
||
_gl.BindVertexArray(0);
|
||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||
}
|
||
|
||
// ---- restore the frame-global convention ----
|
||
for (int i = 0; i < planeCount; i++)
|
||
_gl.Disable(EnableCap.ClipDistance0 + i);
|
||
_gl.ColorMask(true, true, true, true);
|
||
_gl.DepthMask(true);
|
||
_gl.DepthFunc(DepthFunction.Less);
|
||
_gl.Enable(EnableCap.CullFace);
|
||
_gl.CullFace(TriangleFace.Back);
|
||
_gl.FrontFace(FrontFaceDirection.CW);
|
||
_gl.UseProgram(0);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_gl.DeleteProgram(_program);
|
||
_gl.DeleteVertexArray(_vao);
|
||
_gl.DeleteBuffer(_vbo);
|
||
}
|
||
}
|