feat(render): Phase A8 RR6 — IndoorCellStencilPipeline 3-bit + occlusion-query

Extends the dormant single-bit stencil pipeline with WB Step 5 primitives:

  MarkBuildingBit2          — mark stencil bit 2 where bit 1 set
  PunchDepthAtStencil3      — depth=1.0 at intersection (stencil==3)
  EnableOtherBuildingPass   — render state for stencil==3 EnvCell pass
  ResetBit2                 — clear bit 2 between iterations
  UploadBuildingPortalMesh  — upload a Building.ExitPortalPolygons (vs
                              cell-based UploadPortalMesh)

Plus occlusion-query helpers:
  EnsureOcclusionQueryId   — lazy GenQuery
  TryReadOcclusionResult   — asynchronous read-back (no CPU stall)
  BeginOcclusionQuery      — BeginQuery wrapper
  EndOcclusionQuery        — EndQuery wrapper

All GL state sequences mirror WB VisibilityManager.cs:73-239 line-by-line.
Comments reference the corresponding WB line numbers for verification.

Consumed by RR7's Steps 1-4 + RR9's Step 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 11:22:15 +02:00
parent 3361933ce6
commit 6a7894ac35

View file

@ -221,6 +221,249 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
_gl.StencilMask(0xFFu); // restore default write mask
}
// -------------------------------------------------------------------------
// Phase A8 RR6 (2026-05-26): Step 5 — 3-bit stencil mode + occlusion-query
// helpers. These methods are called by RR7 (Steps 1-4 wire-in) and RR9
// (Step 5 cross-building pass). They assume the VBO was already uploaded
// by UploadBuildingPortalMesh for the specific building being processed.
// -------------------------------------------------------------------------
/// <summary>
/// Phase A8 RR6: Step 5a — mark stencil bit 2 at portal silhouettes WHERE
/// bit 1 is already set. After this call, pixels at the intersection of our
/// building's portals (bit 1) and the other building's portals (bit 2) will
/// have stencil == 3. Subsequent <see cref="EnableOtherBuildingPass"/> renders
/// into those pixels only.
///
/// <para>GL state on entry: stencil test enabled (left by prior MarkAndPunch
/// or EnableOutdoorPass); depth test on. DepthFunc.Lequal set here.</para>
///
/// <para>Mirrors WB VisibilityManager.cs:186-189.</para>
/// </summary>
public void MarkBuildingBit2(Matrix4x4 viewProjection, int buildingPortalVertexCount)
{
// WB:186 StencilFunc.Equal(3, 0x01) — match where bit 1 is set
// WB:187 StencilOp(Keep, Keep, Replace)
// WB:188 StencilMask 0x02 — only write to bit 2
// WB:189 ColorMask off; DepthMask off; DepthFunc.Lequal; Disable CullFace
_gl.StencilFunc(StencilFunction.Equal, 3, 0x01u);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
_gl.StencilMask(0x02u);
_gl.ColorMask(false, false, false, false);
_gl.DepthMask(false);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Lequal);
_gl.Disable(EnableCap.CullFace);
_shader.Use();
var vp = viewProjection;
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
_gl.Uniform1(_uWriteFarDepthLoc, 0); // color/depth writes off anyway
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount);
_gl.BindVertexArray(0);
}
/// <summary>
/// Phase A8 RR6: Step 5b — punch depth=1.0 where stencil == 3 (intersection
/// of our portal silhouette and the other building's portal silhouette). Clears
/// interior-wall depth written during Step 3 so the other building's cells win
/// depth when rendered in <see cref="EnableOtherBuildingPass"/>.
///
/// <para>GL state on entry: stencil enabled; StencilMask 0x02 from MarkBuildingBit2.
/// This method sets StencilMask 0x00 (read-only), DepthMask on, DepthFunc.Always.</para>
///
/// <para>Mirrors WB VisibilityManager.cs:201-205.</para>
/// </summary>
public void PunchDepthAtStencil3(Matrix4x4 viewProjection, int buildingPortalVertexCount)
{
// WB:201 StencilFunc.Equal(3, 0x03) — match intersection pixels
// WB:202 StencilMask 0x00 — read-only stencil
// WB:203 DepthMask on; DepthFunc.Always
// WB:204-205 draw portal triangles with uWriteFarDepth=1
_gl.StencilFunc(StencilFunction.Equal, 3, 0x03u);
_gl.StencilMask(0x00u);
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Always);
_shader.Use();
var vp = viewProjection;
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
_gl.Uniform1(_uWriteFarDepthLoc, 1); // write gl_FragDepth = 1.0
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount);
_gl.BindVertexArray(0);
}
/// <summary>
/// Phase A8 RR6: Step 5c — set render state to draw the other-building's
/// EnvCells where stencil == 3. Does not issue draw calls; caller renders
/// the cells immediately after.
///
/// <para>GL state on entry: stencil func still Equal(3, 0x03) from
/// PunchDepthAtStencil3. This method re-enables color and CullFace, sets
/// DepthFunc.Less for the geometry pass.</para>
///
/// <para>Mirrors WB VisibilityManager.cs:210-212.</para>
/// </summary>
public void EnableOtherBuildingPass()
{
// WB:210 ColorMask true; WB:211 DepthFunc.Less; WB:212 Enable CullFace
// Stencil func stays Equal(3, 0x03) from PunchDepthAtStencil3.
_gl.ColorMask(true, true, true, false);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
}
/// <summary>
/// Phase A8 RR6: Step 5d — reset bit 2 to zero so the next other-building
/// iteration starts fresh. Re-draws this building's portal triangles with a
/// ref value of 1 (bit 2 = 0) to overwrite bit 2 back to 0 in stencil.
///
/// <para>GL state on entry: color/depth writes may still be on from
/// EnableOtherBuildingPass. This method disables them and rewrites bit 2.</para>
///
/// <para>Mirrors WB VisibilityManager.cs:222-228.</para>
/// </summary>
public void ResetBit2(Matrix4x4 viewProjection, int buildingPortalVertexCount)
{
// WB:222 ColorMask off; DepthMask off
// WB:223 StencilMask 0x02
// WB:224 StencilFunc.Always(1, 0x02) → ref=1 AND mask=0x02 → writes 0 to bit 2
// because ref & writeMask = 1 & 0x02 = 0 (bit 2 of 1 is 0)
// WB:225-226 StencilOp Replace
// WB:227-228 draw portal triangles
_gl.ColorMask(false, false, false, false);
_gl.DepthMask(false);
_gl.StencilMask(0x02u);
_gl.StencilFunc(StencilFunction.Always, 1, 0x02u);
_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)buildingPortalVertexCount);
_gl.BindVertexArray(0);
}
// -------------------------------------------------------------------------
// Occlusion-query helpers (Phase A8 RR6). Wrap raw GL query calls with
// the same asynchronous non-stalling pattern used by WB VisibilityManager
// (lines 173-184 and 267-287). All three Building.QueryId/QueryStarted/
// WasVisible fields are written by the caller (RR9) around these helpers.
// -------------------------------------------------------------------------
/// <summary>
/// Phase A8 RR6: lazily allocate a GL query object handle. The <paramref name="slot"/>
/// is written on first call and reused thereafter. Callers pass
/// <c>ref building.QueryId</c>. Mirrors WB VisibilityManager.cs:173
/// (<c>if (building.QueryId != 0)</c> guard pattern — we create the id before
/// that guard is first needed).
/// </summary>
public uint EnsureOcclusionQueryId(ref uint slot)
{
if (slot == 0) slot = _gl.GenQuery();
return slot;
}
/// <summary>
/// Phase A8 RR6: non-blocking read of the previous frame's occlusion query
/// result. Returns false immediately if the result is not yet available (no
/// CPU stall). Sets <paramref name="anyPassed"/> to true if at least one
/// sample passed.
///
/// <para>Mirrors WB VisibilityManager.cs:174-180 and 272-278.</para>
/// </summary>
public bool TryReadOcclusionResult(uint queryId, out bool anyPassed)
{
anyPassed = false;
if (queryId == 0) return false;
_gl.GetQueryObject(queryId, QueryObjectParameterName.ResultAvailable, out int available);
if (available == 0) return false;
_gl.GetQueryObject(queryId, QueryObjectParameterName.Result, out int samplesPassed);
anyPassed = samplesPassed > 0;
return true;
}
/// <summary>
/// Phase A8 RR6: begin a samples-passed occlusion query. Caller is responsible
/// for setting <c>building.QueryStarted = true</c> afterward.
/// Mirrors WB VisibilityManager.cs:182 and 279.
/// </summary>
public void BeginOcclusionQuery(uint queryId) =>
_gl.BeginQuery(QueryTarget.SamplesPassed, queryId);
/// <summary>
/// Phase A8 RR6: end a samples-passed occlusion query.
/// Mirrors WB VisibilityManager.cs:195 and 286.
/// </summary>
public void EndOcclusionQuery() =>
_gl.EndQuery(QueryTarget.SamplesPassed);
// -------------------------------------------------------------------------
// Per-building portal mesh upload (Phase A8 RR6 — S3).
// -------------------------------------------------------------------------
/// <summary>
/// Phase A8 RR6: upload a Building's pre-computed world-space exit portal
/// polygons as a triangle fan. Mirrors <see cref="UploadPortalMesh"/> but
/// operates on <see cref="AcDream.App.Rendering.Wb.Building.ExitPortalPolygons"/>
/// instead of per-cell portal lists. Called once per other-building per frame
/// (Step 5 loop in RR9) before the MarkBuildingBit2 / PunchDepthAtStencil3 /
/// EnableOtherBuildingPass / ResetBit2 sequence.
/// </summary>
/// <returns>Vertex count uploaded (always a multiple of 3; 0 if no polygons).</returns>
public int UploadBuildingPortalMesh(AcDream.App.Rendering.Wb.Building building)
{
// Pre-count vertices so we allocate exactly.
int triVertCount = 0;
foreach (var poly in building.ExitPortalPolygons)
{
if (poly.Length < 3) continue;
triVertCount += (poly.Length - 2) * 3;
}
if (triVertCount == 0)
{
_lastVertexCount = 0;
return 0;
}
if (triVertCount > _vboCapacityVerts)
AllocateVbo(Math.Max(triVertCount * 2, 1024));
// Triangle-fan triangulation — matches UploadPortalMesh / WB PortalRenderManager.cs:537-543.
var verts = new Vector3[triVertCount];
int idx = 0;
foreach (var poly in building.ExitPortalPolygons)
{
if (poly.Length < 3) continue;
var v0 = poly[0];
for (int i = 1; i < poly.Length - 1; i++)
{
verts[idx++] = v0;
verts[idx++] = poly[i];
verts[idx++] = poly[i + 1];
}
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (Vector3* p = verts)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0,
(nuint)(idx * sizeof(Vector3)), p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_lastVertexCount = idx;
return idx;
}
public void Dispose()
{
_shader.Dispose();