BR-2 commit 1: exit-portal depth SEALS + retail full depth clear (the #108 machinery)

Ports the seal half of retail's invisible portal depth writes
(D3DPolyRender::DrawPortalPolyInternal, Ghidra 0x0059bc90; dispatched by
PView::DrawCells loop 1, Ghidra 0x005a4840 pc:432783-432786):

- NEW PortalDepthMaskRenderer: draws a portal polygon as a color-masked
  triangle fan, depth-test ALWAYS + depth-write ON, at the polygon's TRUE
  projected depth (retail maxZ2 seal) or forced to far-z 0.99999988
  (retail maxZ1 punch - the constant from 0x0059bc90's tail; punch wiring
  lands in BR-2 commit 2). Where retail software-clips the fan against
  the installed view (polyClipFinish), we apply the SAME slice region via
  gl_ClipDistance from the slice's <=8 clip-space half-planes. GL state
  fully self-contained (set -> draw -> restore, no early-outs).

- DrawExitPortalMasks is now WIRED in production (was a null-callback
  no-op since birth): for interior roots, every visible cell's portals
  with OtherCellId==0xFFFF get their world-space polygon sealed per view
  slice, far-to-near, after the landscape slices.

- ClearDepthSlice (per-slice scissored AABB clear - wrong shape, wrong
  scope, no seal after it) is REPLACED by ClearDepthForInterior: ONE
  full-buffer depth clear between the outside stage and the interior
  stage, gated on any outside slice having drawn (retail's
  portalsDrawnCount gate semantics staged as an open question, marked
  inline). DepthMask(true) asserted at the clear site (c4df241 lesson).
  Outdoor roots: no clear, no seals (interiors must depth-test against
  terrain until the commit-2 punch).

Closes the mechanism behind #108 (outdoor grass sweeping across the
upstairs door opening - terrain depth seen through the doorway is now
re-stamped at the door plane so farther interior geometry z-fails inside
the aperture). Visual gate: BR-2/BR-3 batched checklist (cellar doorway
+ cottage wall + tower stairs near/far).

Suites: build green, App 226 green, Core 1398 + 4 pre-existing #99-era
failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 08:03:10 +02:00
parent 6cba95047c
commit 6d4cac2418
3 changed files with 279 additions and 17 deletions

View file

@ -171,6 +171,7 @@ public sealed class GameWindow : IDisposable
// each frame on an indoor root (null on the outdoor root).
private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer;
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
@ -1845,6 +1846,10 @@ public sealed class GameWindow : IDisposable
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
_gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!);
// BR-2: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -7632,24 +7637,28 @@ public sealed class GameWindow : IDisposable
renderSky,
kf,
environOverrideActive),
// The depth clear is a doorway "look-in" trick: clear depth inside a door/window
// region so the cell seen THROUGH it draws over the terrain drawn through that
// region (the indoor root looking out). For the OUTDOOR-node root the only
// OutsideView slice is the FULL-SCREEN base terrain, so clearing its depth wipes the
// entire depth buffer AFTER terrain/exteriors/player drew — the flooded building
// interiors (cellars) would then paint over everything (cellar in front of the
// player; building interiors through the ground). Outdoors the interiors must
// depth-test against terrain+exteriors and appear only through real door openings,
// so issue NO depth clear. Interior roots keep the doorway clear (unchanged).
ClearDepthSlice = clipRoot.IsOutdoorNode
// BR-2: retail's depth discipline between the outside stage and the
// interior stage (PView::DrawCells, Ghidra 0x005a4840): one FULL depth
// clear (no scissor — the old per-slice AABB clear was the wrong shape),
// then DrawExitPortalMasks re-stamps every outside-leading portal's TRUE
// depth so terrain seen through a doorway keeps its pixels (#108).
// For the OUTDOOR-node root the only OutsideView slice is the FULL-SCREEN
// base terrain, so a clear would wipe the entire depth buffer AFTER
// terrain/exteriors/player drew — the flooded building interiors would
// paint over everything. Outdoors the interiors must depth-test against
// terrain+exteriors and appear only through real apertures (the BR-2
// commit-2 far-Z punch), so: NO clear, NO seals.
ClearDepthForInterior = clipRoot.IsOutdoorNode
? null
: slice =>
: () =>
{
bool zc = BeginDoorwayScissor(true, slice.NdcAabb);
_gl.Disable(EnableCap.ScissorTest);
_gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
_gl.Clear(ClearBufferMask.DepthBufferBit);
if (zc)
_gl.Disable(EnableCap.ScissorTest);
},
DrawExitPortalMasks = clipRoot.IsOutdoorNode
? null
: sliceCtx => DrawRetailPViewExitPortalSeal(sliceCtx, envCellViewProj),
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
EmitDiagnostics = result =>
@ -9550,6 +9559,43 @@ public sealed class GameWindow : IDisposable
DisableClipDistances();
}
// BR-2 seal: re-stamp the TRUE depth of every outside-leading portal of this
// cell, clipped to the slice's view region — retail PView::DrawCells loop 1
// (Ghidra 0x005a4840, pc:432783-432786): after the landscape draws through
// the outside views and the depth buffer is cleared, every portal with
// other_cell_id==0xFFFF gets DrawPortalPolyInternal(poly, false) — an
// invisible depth write at the portal plane, so interior geometry FARTHER
// than the doorway z-fails inside the aperture and the terrain seen through
// it keeps its pixels (#108). Wiring only — the draw lives in
// PortalDepthMaskRenderer.
private void DrawRetailPViewExitPortalSeal(
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
System.Numerics.Matrix4x4 viewProjection)
{
if (_portalDepthMask is null)
return;
if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null)
return;
Span<System.Numerics.Vector3> world = stackalloc System.Numerics.Vector3[32];
for (int i = 0; i < cell.Portals.Count; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF)
continue; // seals apply to portals leading OUTSIDE only
if (i >= cell.PortalPolygons.Count)
break;
var localVerts = cell.PortalPolygons[i];
if (localVerts.Length < 3)
continue;
int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++)
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ: false);
}
}
private void DrawRetailPViewCellParticles(
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
ICamera camera,
@ -11773,6 +11819,7 @@ public sealed class GameWindow : IDisposable
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8
_portalDepthMask?.Dispose(); // BR-2
_clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();

View file

@ -0,0 +1,202 @@
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>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;
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)
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 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");
_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>
/// 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).
/// </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.DepthFunc(DepthFunction.Always); // retail DEPTHTEST ALWAYS
_gl.DepthMask(true); // z-write on
_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.Uniform1(_locForceFarZ, forceFarZ ? 1 : 0);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (float* v = _scratch)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
_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.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);
}
}

View file

@ -231,8 +231,16 @@ public sealed class RetailPViewRenderer
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor));
}
foreach (var slice in clipAssembly.OutsideViewSlices)
ctx.ClearDepthSlice?.Invoke(slice);
// BR-2: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; the exact gate semantics is a plan
// open question, staged here as "any outside slice drawn"), then
// re-stamps every outside-leading portal's TRUE depth (the seals,
// DrawExitPortalMasks below). The old per-slice scissored AABB clear
// was the wrong shape (AABB ⊇ aperture polygon) and had no seal after
// it — the #108 mechanism.
if (clipAssembly.OutsideViewSlices.Length > 0)
ctx.ClearDepthForInterior?.Invoke();
UseIndoorMembershipOnlyRouting();
}
@ -575,7 +583,12 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
public Action<ClipViewSlice>? ClearDepthSlice { get; init; }
/// <summary>BR-2: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
/// appear only through real apertures (the BR-2 commit-2 punch).</summary>
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }