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:
parent
f3d7b13664
commit
3973596468
2 changed files with 400 additions and 0 deletions
249
src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
Normal file
249
src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue