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; }