From 6d4cac241835e770e0e5c0b18f0598edf7371788 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 08:03:10 +0200 Subject: [PATCH] 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 --- src/AcDream.App/Rendering/GameWindow.cs | 75 +++++-- .../Rendering/PortalDepthMaskRenderer.cs | 202 ++++++++++++++++++ .../Rendering/RetailPViewRenderer.cs | 19 +- 3 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 324c7fe6..5ed1677c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 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(); diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs new file mode 100644 index 00000000..088f2b7a --- /dev/null +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -0,0 +1,202 @@ +using System; +using System.Numerics; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// BR-2 (holistic building-render port): retail's invisible portal depth +/// writes — the port of D3DPolyRender::DrawPortalPolyInternal +/// (Ghidra 0x0059bc90, pc:424490). +/// +/// Retail projects a portal polygon, software-clips it against the +/// installed portal view (polyClipFinish), and draws the survivor as a +/// COLOR-INVISIBLE triangle fan with depth-test ALWAYS + depth-write ON: +/// +/// Seal (retail maxZ2=6, bit0 clear, data 0x00820e14): +/// z = the polygon's true projected depth. Drawn on portals leading OUTSIDE +/// (other_cell_id==0xFFFF) 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). +/// Punch (retail maxZ1=7, 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. +/// +/// +/// Where retail clips the polygon on the CPU against the view, we apply +/// the SAME view region via gl_ClipDistance from the slice's clip-space +/// half-planes (≤8, the validated output) — the +/// depth write lands only inside the slice region, matching retail's clipped +/// fan. +/// +/// 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. +/// +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; + } + + /// + /// Draw one portal polygon as an invisible depth write, clipped to the + /// slice's clip-space half-planes. selects + /// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth). + /// + public void DrawDepthFan( + ReadOnlySpan worldVerts, + in Matrix4x4 viewProjection, + ReadOnlySpan 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 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); + } +} diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index be309602..a7fb7bae 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -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? AnimatedById)> LandblockEntries { get; init; } public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } - public Action? ClearDepthSlice { get; init; } + + /// 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). + public Action? ClearDepthForInterior { get; init; } public Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } public Action? EmitDiagnostics { get; init; }