From 397359646894778b05bbf096753f896ab814d177 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 26 May 2026 08:02:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20=E2=80=94=20Indoor?= =?UTF-8?q?CellStencilPipeline=20+=20PortalMeshBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline class owns the portal_stencil shader + a dynamic VBO/VAO for per-frame portal triangle uploads. MarkAndPunch runs WB's two-step stencil setup (mark portals = 1, then write gl_FragDepth=1.0 into stencil=1 regions). EnableOutdoorPass switches to read-only stencil for the subsequent terrain + outdoor-entity passes. PortalMeshBuilder.BuildTriangles is the pure-math triangle-fan extractor — unit-testable without a GL context. Only exit portals (OtherCellId == 0xFFFF) are emitted; inner portals are skipped to prevent outdoor geometry from bleeding into adjacent rooms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/IndoorCellStencilPipeline.cs | 249 ++++++++++++++++++ .../IndoorCellStencilPipelineTests.cs | 151 +++++++++++ 2 files changed, 400 insertions(+) create mode 100644 src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs create mode 100644 tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs new file mode 100644 index 0000000..0f113d8 --- /dev/null +++ b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs @@ -0,0 +1,249 @@ +// IndoorCellStencilPipeline.cs — Phase A8 indoor-cell visibility culling. +// +// Ports WorldBuilder's RenderInsideOut stencil mechanism for acdream's +// modern GL pipeline. Closes issue #78 (outdoor stabs visible through +// indoor walls) and the cellar-stairs artifact (outdoor terrain visible +// inside cottage cellars). +// +// Algorithm (per WB VisibilityManager.cs:73-239): +// Step 1: stencil ref=1 marked everywhere portal polygons cover +// (color/depth writes off, depth=Always, CullFace off). +// Step 2: gl_FragDepth=1.0 punched into stencil=1 regions +// (color off, depth on, depth=Always). +// Step 3: (caller) draw indoor entities with stencil OFF. +// Step 4: (caller) draw terrain + outdoor entities with +// glStencilFunc(Equal, 1, 0x01), stencil read-only. +// +// Retail equivalent: PView::DrawCells at +// docs/research/named-retail/acclient_2013_pseudo_c.txt:432709 uses +// screen-space polygon-clip scissor instead of stencil. Same observable +// behavior; the stencil approach matches modern GL pipeline conventions. + +using System; +using System.Collections.Generic; +using System.Numerics; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Pure-math triangle-fan generation from a list of cells with +/// . Extracted as a static class so +/// the vertex-list construction is unit-testable without a GL context. +/// +public static class PortalMeshBuilder +{ + /// + /// Builds a flat triangle-fan vertex array in world space from every + /// exit portal ( == 0xFFFF) on + /// every cell in . Inner portals are skipped + /// — they don't open to outdoors, so stencil-marking them would let + /// outdoor geometry bleed into adjacent rooms (incorrect). + /// + public static Vector3[] BuildTriangles(IReadOnlyCollection cells) + { + // Pre-count to size the output exactly. + int triCount = 0; + foreach (var cell in cells) + { + for (int p = 0; p < cell.Portals.Count; p++) + { + if (cell.Portals[p].OtherCellId != 0xFFFF) continue; + if (p >= cell.PortalPolygons.Count) continue; + var poly = cell.PortalPolygons[p]; + if (poly.Length < 3) continue; + triCount += (poly.Length - 2) * 3; + } + } + + if (triCount == 0) return Array.Empty(); + + var output = new Vector3[triCount]; + int outIdx = 0; + foreach (var cell in cells) + { + var xform = cell.WorldTransform; + for (int p = 0; p < cell.Portals.Count; p++) + { + if (cell.Portals[p].OtherCellId != 0xFFFF) continue; + if (p >= cell.PortalPolygons.Count) continue; + var poly = cell.PortalPolygons[p]; + if (poly.Length < 3) continue; + + // Triangle-fan from vertex 0. + var v0 = Vector3.Transform(poly[0], xform); + for (int i = 1; i < poly.Length - 1; i++) + { + output[outIdx++] = v0; + output[outIdx++] = Vector3.Transform(poly[i], xform); + output[outIdx++] = Vector3.Transform(poly[i + 1], xform); + } + } + } + + return output; + } +} + +/// +/// GL pipeline owner. Holds the portal stencil shader and a dynamic +/// VBO/VAO for per-frame portal triangle uploads. Public methods are +/// called in the per-frame render path when the camera is inside an +/// EnvCell. When outside, this object is dormant — no GL work. +/// +public sealed unsafe class IndoorCellStencilPipeline : IDisposable +{ + private readonly GL _gl; + private readonly Shader _shader; + private readonly uint _vao; + private readonly uint _vbo; + private int _vboCapacityVerts; + private int _lastVertexCount; + private int _uViewProjectionLoc; + private int _uWriteFarDepthLoc; + + public IndoorCellStencilPipeline(GL gl, string vertPath, string fragPath) + { + _gl = gl; + _shader = new Shader(gl, vertPath, fragPath); + _uViewProjectionLoc = _gl.GetUniformLocation(_shader.Program, "uViewProjection"); + _uWriteFarDepthLoc = _gl.GetUniformLocation(_shader.Program, "uWriteFarDepth"); + + _vao = _gl.GenVertexArray(); + _vbo = _gl.GenBuffer(); + AllocateVbo(1024); + ConfigureVao(); + } + + /// + /// Builds the per-frame portal triangle array from + /// and uploads it to . Returns the vertex count + /// (0 means no exit portals — caller should skip stencil setup entirely). + /// + public int UploadPortalMesh(IReadOnlyCollection cells) + { + var verts = PortalMeshBuilder.BuildTriangles(cells); + _lastVertexCount = verts.Length; + if (_lastVertexCount == 0) return 0; + + if (_lastVertexCount > _vboCapacityVerts) + { + _vboCapacityVerts = Math.Max(_lastVertexCount * 2, 1024); + AllocateVbo(_vboCapacityVerts); + } + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + fixed (Vector3* p = verts) + { + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, + (nuint)(_lastVertexCount * sizeof(Vector3)), p); + } + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + return _lastVertexCount; + } + + /// + /// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever + /// portal polygons cover, then write gl_FragDepth=1.0 into those + /// regions. Leaves the GL state set up for the caller to invoke + /// . + /// + public void MarkAndPunch(Matrix4x4 viewProjection) + { + if (_lastVertexCount == 0) return; + + _gl.Enable(EnableCap.StencilTest); + _gl.ClearStencil(0); + _gl.Clear(ClearBufferMask.StencilBufferBit); + + // Step 1: stencil mark. + _gl.ColorMask(false, false, false, false); + _gl.DepthMask(false); + _gl.DepthFunc(DepthFunction.Always); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.DepthClamp); // portal polys can be at any Z + _gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); + _gl.StencilMask(0x01u); + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + + _shader.Use(); + var vp = viewProjection; + _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); + _gl.Uniform1(_uWriteFarDepthLoc, 0); + + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount); + + // Step 2: far-depth punch. + _gl.ColorMask(false, false, false, false); + _gl.DepthMask(true); + _gl.DepthFunc(DepthFunction.Always); + _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); + _gl.StencilMask(0x00u); // read-only + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); + + _gl.Uniform1(_uWriteFarDepthLoc, 1); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount); + + _gl.BindVertexArray(0); + _gl.Disable(EnableCap.DepthClamp); + _gl.Enable(EnableCap.CullFace); + + // Leave a clean state for the indoor-entities pass: stencil + // disabled, color+depth on, depth=Less, no cull change. + _gl.ColorMask(true, true, true, true); + _gl.DepthFunc(DepthFunction.Less); + _gl.Disable(EnableCap.StencilTest); + } + + /// + /// Step 4 of WB's RenderInsideOut: enable stencil read-only with + /// ref=1, so subsequent terrain + outdoor entity draws are gated + /// to portal silhouette regions only. + /// + public void EnableOutdoorPass() + { + if (_lastVertexCount == 0) return; + _gl.Enable(EnableCap.StencilTest); + _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); + _gl.StencilMask(0x00u); + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); + } + + /// + /// Restores stencil-off state. Call after all outdoor passes complete + /// so subsequent rendering (particles, sky, UI) is unaffected. + /// + public void DisableStencil() + { + _gl.Disable(EnableCap.StencilTest); + _gl.StencilMask(0xFFu); // restore default write mask + } + + public void Dispose() + { + _shader.Dispose(); + _gl.DeleteVertexArray(_vao); + _gl.DeleteBuffer(_vbo); + } + + private void AllocateVbo(int capacityVerts) + { + _vboCapacityVerts = capacityVerts; + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(capacityVerts * sizeof(Vector3)), + null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + } + + private void ConfigureVao() + { + _gl.BindVertexArray(_vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, + (uint)sizeof(Vector3), (void*)0); + _gl.BindVertexArray(0); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs new file mode 100644 index 0000000..2f5d2d5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs @@ -0,0 +1,151 @@ +// Phase A8 — portal mesh triangle-fan generation tests. +// +// Pure-math coverage of PortalMeshBuilder.BuildTriangles — the part of +// IndoorCellStencilPipeline that converts a list of LoadedCell with +// PortalPolygons + WorldTransform into a flat Vector3[] of triangles +// in world space. The GL/upload portion is exercised at runtime only. + +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class IndoorCellStencilPipelineTests +{ + [Fact] + public void BuildTriangles_NoCells_ReturnsEmpty() + { + var verts = PortalMeshBuilder.BuildTriangles(new List()); + Assert.Empty(verts); + } + + [Fact] + public void BuildTriangles_SkipsInnerPortals() + { + // Two portals on one cell: one exit (OtherCellId=0xFFFF, should be + // included), one inner (OtherCellId=0x0102, should be skipped). + var cell = new LoadedCell + { + WorldTransform = Matrix4x4.Identity, + Portals = new() + { + new CellPortalInfo(0xFFFF, 100, 0), // exit — included + new CellPortalInfo(0x0102, 101, 0), // inner — skipped + }, + ClipPlanes = new() { default, default }, + PortalPolygons = new() + { + new[] + { + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(1, 1, 0), + }, + new[] + { + new Vector3(10, 0, 0), + new Vector3(11, 0, 0), + new Vector3(11, 1, 0), + }, + }, + }; + + var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); + + // Only the exit polygon (3 verts → 1 triangle → 3 vertices). + Assert.Equal(3, verts.Length); + Assert.Equal(new Vector3(0, 0, 0), verts[0]); + Assert.Equal(new Vector3(1, 0, 0), verts[1]); + Assert.Equal(new Vector3(1, 1, 0), verts[2]); + } + + [Fact] + public void BuildTriangles_TriangulatesAsFan() + { + // 4-vertex quad → fan = 2 triangles → 6 vertices. + // Quad: (0,0,0), (1,0,0), (1,1,0), (0,1,0). + // Fan from vertex 0: (0,1,2), (0,2,3). + var cell = new LoadedCell + { + WorldTransform = Matrix4x4.Identity, + Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) }, + ClipPlanes = new() { default }, + PortalPolygons = new() + { + new[] + { + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(1, 1, 0), + new Vector3(0, 1, 0), + }, + }, + }; + + var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); + + Assert.Equal(6, verts.Length); + // Triangle 1: (0,1,2) + Assert.Equal(new Vector3(0, 0, 0), verts[0]); + Assert.Equal(new Vector3(1, 0, 0), verts[1]); + Assert.Equal(new Vector3(1, 1, 0), verts[2]); + // Triangle 2: (0,2,3) + Assert.Equal(new Vector3(0, 0, 0), verts[3]); + Assert.Equal(new Vector3(1, 1, 0), verts[4]); + Assert.Equal(new Vector3(0, 1, 0), verts[5]); + } + + [Fact] + public void BuildTriangles_AppliesWorldTransform() + { + // Identity cell-local triangle, translated by WorldTransform. + var translate = Matrix4x4.CreateTranslation(new Vector3(100, 200, 300)); + var cell = new LoadedCell + { + WorldTransform = translate, + Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) }, + ClipPlanes = new() { default }, + PortalPolygons = new() + { + new[] + { + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(0, 1, 0), + }, + }, + }; + + var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); + + Assert.Equal(3, verts.Length); + Assert.Equal(new Vector3(100, 200, 300), verts[0]); + Assert.Equal(new Vector3(101, 200, 300), verts[1]); + Assert.Equal(new Vector3(100, 201, 300), verts[2]); + } + + [Fact] + public void BuildTriangles_SkipsEmptyOrDegeneratePolygons() + { + var cell = new LoadedCell + { + WorldTransform = Matrix4x4.Identity, + Portals = new() + { + new CellPortalInfo(0xFFFF, 100, 0), + new CellPortalInfo(0xFFFF, 101, 0), + }, + ClipPlanes = new() { default, default }, + PortalPolygons = new() + { + System.Array.Empty(), // empty + new[] { new Vector3(0, 0, 0), new Vector3(1, 0, 0) }, // degenerate (2 verts) + }, + }; + + var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); + Assert.Empty(verts); + } +}