diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
index 605309d..98ff91e 100644
--- a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
+++ b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
@@ -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.
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 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 renders
+ /// into those pixels only.
+ ///
+ /// GL state on entry: stencil test enabled (left by prior MarkAndPunch
+ /// or EnableOutdoorPass); depth test on. DepthFunc.Lequal set here.
+ ///
+ /// Mirrors WB VisibilityManager.cs:186-189.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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 .
+ ///
+ /// GL state on entry: stencil enabled; StencilMask 0x02 from MarkBuildingBit2.
+ /// This method sets StencilMask 0x00 (read-only), DepthMask on, DepthFunc.Always.
+ ///
+ /// Mirrors WB VisibilityManager.cs:201-205.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// 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.
+ ///
+ /// Mirrors WB VisibilityManager.cs:210-212.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// GL state on entry: color/depth writes may still be on from
+ /// EnableOtherBuildingPass. This method disables them and rewrites bit 2.
+ ///
+ /// Mirrors WB VisibilityManager.cs:222-228.
+ ///
+ 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.
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Phase A8 RR6: lazily allocate a GL query object handle. The
+ /// is written on first call and reused thereafter. Callers pass
+ /// ref building.QueryId. Mirrors WB VisibilityManager.cs:173
+ /// (if (building.QueryId != 0) guard pattern — we create the id before
+ /// that guard is first needed).
+ ///
+ public uint EnsureOcclusionQueryId(ref uint slot)
+ {
+ if (slot == 0) slot = _gl.GenQuery();
+ return slot;
+ }
+
+ ///
+ /// 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 to true if at least one
+ /// sample passed.
+ ///
+ /// Mirrors WB VisibilityManager.cs:174-180 and 272-278.
+ ///
+ 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;
+ }
+
+ ///
+ /// Phase A8 RR6: begin a samples-passed occlusion query. Caller is responsible
+ /// for setting building.QueryStarted = true afterward.
+ /// Mirrors WB VisibilityManager.cs:182 and 279.
+ ///
+ public void BeginOcclusionQuery(uint queryId) =>
+ _gl.BeginQuery(QueryTarget.SamplesPassed, queryId);
+
+ ///
+ /// Phase A8 RR6: end a samples-passed occlusion query.
+ /// Mirrors WB VisibilityManager.cs:195 and 286.
+ ///
+ public void EndOcclusionQuery() =>
+ _gl.EndQuery(QueryTarget.SamplesPassed);
+
+ // -------------------------------------------------------------------------
+ // Per-building portal mesh upload (Phase A8 RR6 — S3).
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Phase A8 RR6: upload a Building's pre-computed world-space exit portal
+ /// polygons as a triangle fan. Mirrors but
+ /// operates on
+ /// 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.
+ ///
+ /// Vertex count uploaded (always a multiple of 3; 0 if no polygons).
+ 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();