feat(render): Phase A8 — IndoorCellStencilPipeline + PortalMeshBuilder

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-26 08:02:14 +02:00
parent f3d7b13664
commit 3973596468
2 changed files with 400 additions and 0 deletions

View file

@ -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;
/// <summary>
/// Pure-math triangle-fan generation from a list of cells with
/// <see cref="LoadedCell.PortalPolygons"/>. Extracted as a static class so
/// the vertex-list construction is unit-testable without a GL context.
/// </summary>
public static class PortalMeshBuilder
{
/// <summary>
/// Builds a flat triangle-fan vertex array in world space from every
/// exit portal (<see cref="CellPortalInfo.OtherCellId"/> == 0xFFFF) on
/// every cell in <paramref name="cells"/>. Inner portals are skipped
/// — they don't open to outdoors, so stencil-marking them would let
/// outdoor geometry bleed into adjacent rooms (incorrect).
/// </summary>
public static Vector3[] BuildTriangles(IReadOnlyCollection<LoadedCell> 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<Vector3>();
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;
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Builds the per-frame portal triangle array from <paramref name="cells"/>
/// and uploads it to <see cref="_vbo"/>. Returns the vertex count
/// (0 means no exit portals — caller should skip stencil setup entirely).
/// </summary>
public int UploadPortalMesh(IReadOnlyCollection<LoadedCell> 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;
}
/// <summary>
/// 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
/// <see cref="EnableOutdoorPass"/>.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Restores stencil-off state. Call after all outdoor passes complete
/// so subsequent rendering (particles, sky, UI) is unaffected.
/// </summary>
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);
}
}

View file

@ -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<LoadedCell>());
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<LoadedCell> { 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<LoadedCell> { 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<LoadedCell> { 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<Vector3>(), // empty
new[] { new Vector3(0, 0, 0), new Vector3(1, 0, 0) }, // degenerate (2 verts)
},
};
var verts = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell });
Assert.Empty(verts);
}
}