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