refactor(render): Phase U.1 — delete two-pipe inside-out machinery

Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (U.4a replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.

Also deleted with the partition: the two test-only walk helpers
(WbDrawDispatcher.WalkEntitiesForTest / WalkEntitiesForTestByCellIds) and their
test files (WbDrawDispatcherEntitySetTests, WbDrawDispatcherCellIdsOverloadTests),
which existed solely to exercise the removed IndoorPass/OutdoorScenery/
BuildingShells/LiveDynamic partition. EntityMatchesSet / IsShellScopedSet collapse
to the All-path constants; the set: parameter is retained as a seam for the
unified pass.

Note: the depth-clear-if-inside default-path workaround was removed per the
U.1 task list — any current indoor-wall degradation persists until a later
Phase U task lands the unified pass (expected, not a regression introduced here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-30 16:05:19 +02:00
parent 0f7b395be1
commit 3fc77be5de
8 changed files with 37 additions and 2612 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,792 +0,0 @@
// 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,
Vector3? cameraWorldPosition = null)
{
// 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 (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) 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 (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) 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;
}
private static bool ExitPortalPassesCameraSide(
LoadedCell cell,
int portalIndex,
Vector3? cameraWorldPosition)
{
if (cameraWorldPosition is not Vector3 camera)
return true;
if (portalIndex >= cell.ClipPlanes.Count)
return true;
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f)
return true;
var localCamera = Vector3.Transform(camera, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCamera) + plane.D;
return plane.InsideSide == 0
? dot >= -0.01f
: dot <= 0.01f;
}
}
/// <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 readonly int _uViewProjectionLoc;
private readonly 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,
Vector3? cameraWorldPosition = null)
{
var verts = PortalMeshBuilder.BuildTriangles(cells, cameraWorldPosition);
_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>
/// Draws the portal mesh most recently uploaded by <see cref="UploadPortalMesh"/>.
/// The caller owns stencil/depth/color/cull state, matching
/// <see cref="RenderBuildingStencilMask"/>.
/// </summary>
public void DrawUploadedPortalMesh(
Matrix4x4 viewProjection,
bool writeFarDepth,
bool enableDepthClamp = true)
{
if (_lastVertexCount == 0)
{
LastStencilVertexCount = 0;
LastStencilWasFarPunch = writeFarDepth;
LastStencilBuildingId = 0;
return;
}
if (enableDepthClamp)
_gl.Enable(EnableCap.DepthClamp);
_shader.Use();
var vp = viewProjection;
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
_gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount);
_gl.BindVertexArray(0);
if (enableDepthClamp)
_gl.Disable(EnableCap.DepthClamp);
LastStencilVertexCount = _lastVertexCount;
LastStencilWasFarPunch = writeFarDepth;
LastStencilBuildingId = 0;
}
/// <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.Enable(EnableCap.DepthTest); // idempotent if already on; makes MarkAndPunch self-contained
_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>
/// Phase A8.F: mark a pre-projected NDC clip region into stencil bit 1 and far-depth-punch it.
/// Replaces the flat world-space exit-portal path (PortalMeshBuilder.BuildTriangles) with the
/// recursively-clipped region from PortalVisibilityBuilder.OutsideView. Polygons are triangulated
/// (fan) and uploaded as NDC verts (z=0) drawn with an identity view-projection (already clip-space).
/// GL state on exit matches MarkAndPunch: stencil disabled, color+depth on, depth=Less.
/// </summary>
public void MarkAndPunchNdc(System.Collections.Generic.IReadOnlyList<ViewPolygon> region)
{
// Triangulate the region (fan per convex polygon) into NDC Vector3 (z=0).
int triVerts = 0;
foreach (var p in region) if (!p.IsEmpty) triVerts += (p.Vertices.Length - 2) * 3;
if (triVerts == 0) { _lastVertexCount = 0; LastStencilVertexCount = 0; LastStencilBuildingId = 0; return; }
var verts = new Vector3[triVerts];
int idx = 0;
foreach (var p in region)
{
if (p.IsEmpty) continue;
var v0 = new Vector3(p.Vertices[0], 0f);
for (int i = 1; i < p.Vertices.Length - 1; i++)
{
verts[idx++] = v0;
verts[idx++] = new Vector3(p.Vertices[i], 0f);
verts[idx++] = new Vector3(p.Vertices[i + 1], 0f);
}
}
if (triVerts > _vboCapacityVerts) AllocateVbo(Math.Max(triVerts * 2, 1024));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (Vector3* p = verts)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(triVerts * sizeof(Vector3)), p);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_lastVertexCount = triVerts;
// Phase A8.F: expose the clipped-mask vert count to the [stencil] probe so the Task 9
// visual-gate evidence reflects the recursively-clipped OutsideView (building id 0 = N/A).
LastStencilVertexCount = triVerts;
LastStencilWasFarPunch = true;
LastStencilBuildingId = 0;
// Same GL state machine as MarkAndPunch, but identity VP (verts are already NDC).
var identity = Matrix4x4.Identity;
_gl.Enable(EnableCap.StencilTest);
_gl.Enable(EnableCap.DepthTest);
_gl.ClearStencil(0);
_gl.Clear(ClearBufferMask.StencilBufferBit);
// Step 1: mark bit 1.
// No DepthClamp here (unlike MarkAndPunch): MarkAndPunch draws world-space portal
// polygons that can fall outside [near,far]; these verts are already NDC with z=0,
// always within the depth range, so there is nothing to clamp.
_gl.ColorMask(false, false, false, false);
_gl.DepthMask(false);
_gl.DepthFunc(DepthFunction.Always);
_gl.Disable(EnableCap.CullFace);
_gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
_gl.StencilMask(0x01u);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
_shader.Use();
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&identity);
_gl.Uniform1(_uWriteFarDepthLoc, 0);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts);
// Step 2: far-depth punch where bit 1 is set.
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Always);
_gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
_gl.StencilMask(0x00u);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
_gl.Uniform1(_uWriteFarDepthLoc, 1);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts);
_gl.BindVertexArray(0);
// Clean state for the indoor-entities pass (matches MarkAndPunch exit).
_gl.Enable(EnableCap.CullFace);
_gl.ColorMask(true, true, true, true);
_gl.DepthFunc(DepthFunction.Less);
_gl.Disable(EnableCap.StencilTest);
}
/// <summary>
/// Phase A8.F (#2): mark stencil BIT 2 (only) wherever the NDC region covers, preserving bit 1.
/// Color/depth writes off. Enables the stencil test and leaves it enabled. Used to clip a single
/// cell's translucent geometry to its portal-chain screen region without disturbing the bit-1
/// OutsideView mask. Pair with EnableBit2CellPass (render) then ResetRegionBit2 (clear).
/// </summary>
public void MarkRegionBit2(System.Collections.Generic.IReadOnlyList<ViewPolygon> region)
=> DrawRegionBit2(region, setBit: true);
/// <summary>Phase A8.F (#2): clear stencil BIT 2 (only) wherever the NDC region covers, preserving
/// bit 1. Call after rendering a cell so the next cell starts with bit 2 == 0 in this region.</summary>
public void ResetRegionBit2(System.Collections.Generic.IReadOnlyList<ViewPolygon> region)
=> DrawRegionBit2(region, setBit: false);
/// <summary>Phase A8.F (#2): set render state to draw geometry only where bit 2 is set (read-only
/// stencil). Call between MarkRegionBit2 and the cell's draw.</summary>
public void EnableBit2CellPass()
{
_gl.Enable(EnableCap.StencilTest);
_gl.StencilFunc(StencilFunction.Equal, 0x02, 0x02u); // bit 2 set
_gl.StencilMask(0x00u); // read-only — do not touch bit 1 or bit 2
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
// Make the per-cell transparent render state explicit (don't inherit DrawRegionBit2's
// DepthFunc.Always): depth-test Less, depth-write off (translucent). Self-describing so
// the clip pass can't silently regress if a sibling helper's exit state changes.
_gl.DepthFunc(DepthFunction.Less);
_gl.DepthMask(false);
}
// Triangulate the NDC region (fan, z=0) and draw it writing only bit 2 (set or clear).
// StencilMask 0x02 guarantees bit 1 is never modified. Color/depth writes off.
private void DrawRegionBit2(System.Collections.Generic.IReadOnlyList<ViewPolygon> region, bool setBit)
{
int triVerts = 0;
foreach (var p in region) if (!p.IsEmpty) triVerts += (p.Vertices.Length - 2) * 3;
if (triVerts == 0) return;
var verts = new Vector3[triVerts];
int idx = 0;
foreach (var p in region)
{
if (p.IsEmpty) continue;
var v0 = new Vector3(p.Vertices[0], 0f);
for (int i = 1; i < p.Vertices.Length - 1; i++)
{
verts[idx++] = v0;
verts[idx++] = new Vector3(p.Vertices[i], 0f);
verts[idx++] = new Vector3(p.Vertices[i + 1], 0f);
}
}
if (triVerts > _vboCapacityVerts) AllocateVbo(Math.Max(triVerts * 2, 1024));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (Vector3* p = verts)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(triVerts * sizeof(Vector3)), p);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
var identity = Matrix4x4.Identity;
_gl.Enable(EnableCap.StencilTest);
_gl.ColorMask(false, false, false, false);
_gl.DepthMask(false);
_gl.DepthFunc(DepthFunction.Always);
_gl.Disable(EnableCap.CullFace);
_gl.StencilMask(0x02u); // ONLY bit 2 — bit 1 preserved
_gl.StencilFunc(StencilFunction.Always, setBit ? 0x02 : 0x00, 0xFFu);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
_shader.Use();
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&identity);
_gl.Uniform1(_uWriteFarDepthLoc, 0);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts);
_gl.BindVertexArray(0);
_gl.Enable(EnableCap.CullFace);
_gl.ColorMask(true, true, true, false); // match the indoor pass convention (alpha-write off)
// Restore the loop's depth-test: do NOT leak DepthFunc.Always into the cell render, the
// unclipped else-branch, the IndoorPass shells, or Step 4. (Opus review C1/C2.)
_gl.DepthFunc(DepthFunction.Less);
}
/// <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
}
// -------------------------------------------------------------------------
// 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;
}
/// <summary>
/// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB
/// <c>PortalRenderManager.RenderBuildingStencilMask</c> at
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484</c>.
///
/// <para>Uploads the building's exit-portal mesh to our shared VBO and draws
/// it with the portal_stencil shader. <strong>Does NOT set or restore any
/// surrounding GL state</strong> — caller is responsible (stencil func,
/// depth mask, color mask, cull face, etc.) per WB
/// <c>VisibilityManager.RenderInsideOut</c> Steps 1/2/5a/5b/5d
/// expectations.</para>
///
/// <para>Note: enables/disables <c>GL_DEPTH_CLAMP</c> around the draw
/// because portal polygons can extend beyond the camera's near/far range.
/// This is symmetric — no state leakage.</para>
/// </summary>
/// <param name="building">The building whose exit portal polygons to draw.</param>
/// <param name="viewProjection">Camera view-projection matrix.</param>
/// <param name="writeFarDepth">When true, the fragment shader writes
/// <c>gl_FragDepth = 1.0</c> (WB Step 2 / Step 5b "punch" semantic).
/// When false, default depth is written (WB Step 1 / Step 5a "mark"
/// semantic).</param>
public void RenderBuildingStencilMask(AcDream.App.Rendering.Wb.Building building, Matrix4x4 viewProjection, bool writeFarDepth)
{
int vertexCount = UploadBuildingPortalMesh(building);
if (vertexCount == 0)
{
LastStencilVertexCount = 0;
LastStencilWasFarPunch = writeFarDepth;
LastStencilBuildingId = building.BuildingId;
return;
}
_gl.Enable(EnableCap.DepthClamp);
_shader.Use();
var vp = viewProjection;
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
_gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)vertexCount);
_gl.BindVertexArray(0);
_gl.Disable(EnableCap.DepthClamp);
LastStencilVertexCount = vertexCount;
LastStencilWasFarPunch = writeFarDepth;
LastStencilBuildingId = building.BuildingId;
}
// -------------------------------------------------------------------------
// Probe data (Phase A8 — read by the [stencil] probe emitter in GameWindow).
// -------------------------------------------------------------------------
/// <summary>Phase A8 RR9: vertex count of the most recent
/// <see cref="RenderBuildingStencilMask"/> draw. 0 if the building had no portals.</summary>
public int LastStencilVertexCount { get; private set; }
/// <summary>Phase A8 RR9: true iff the most recent
/// <see cref="RenderBuildingStencilMask"/> draw was a far-depth punch (Step 2).</summary>
public bool LastStencilWasFarPunch { get; private set; }
/// <summary>Phase A8 RR9: building id of the most recent
/// <see cref="RenderBuildingStencilMask"/> draw.</summary>
public uint LastStencilBuildingId { get; private set; }
public void Dispose()
{
_shader.Dispose();
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
}
// Safe to call mid-session after ConfigureVao — the VAO bakes the
// VBO association at VertexAttribPointer time, so reallocating the
// VBO with new size does NOT require re-running ConfigureVao.
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

@ -1,25 +0,0 @@
#version 430 core
//
// Phase A8 — portal stencil mark + far-depth punch.
//
// uWriteFarDepth = 0 → pass through gl_FragCoord.z (used for the
// stencil mark pass; depth mask is off anyway).
// uWriteFarDepth != 0 → write gl_FragDepth = 1.0 (the far-depth punch
// pass; depth mask is on, color is off).
//
// Matches WorldBuilder's PortalStencil.frag at
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/PortalStencil.frag
uniform int uWriteFarDepth;
void main()
{
if (uWriteFarDepth != 0)
{
gl_FragDepth = 1.0;
}
else
{
gl_FragDepth = gl_FragCoord.z;
}
}

View file

@ -1,25 +0,0 @@
#version 430 core
//
// Phase A8 - portal stencil mark + far-depth punch.
//
// Position is in WORLD space (pipeline transforms cell-local portal
// polygon vertices through cell.WorldTransform on the CPU before
// uploading to the VBO). Output is clip space via uViewProjection.
layout(location = 0) in vec3 aPosition;
uniform mat4 uViewProjection;
void main()
{
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
// Match WorldBuilder's PortalStencil.vert: keep portal polygons stable
// when the chase camera straddles an exit portal plane. Without this,
// near-zero clip W can explode the screen-space portal mask and let the
// Step 4 terrain pass punch into indoor floor/wall pixels for a frame.
if (abs(pos.w) < 0.001)
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
gl_Position = pos;
}

View file

@ -62,42 +62,21 @@ namespace AcDream.App.Rendering.Wb;
public sealed unsafe class WbDrawDispatcher : IDisposable
{
/// <summary>
/// Phase A8 — which subset of entities to walk in a single Draw call.
/// Used to split the indoor-cell visibility pipeline into three passes
/// when the camera is inside an EnvCell.
/// Which subset of entities to walk in a single Draw call.
///
/// Taxonomy reference: docs/research/2026-05-26-a8-entity-taxonomy.md.
/// Phase U.1 (2026-05-30): the indoor/outdoor two-pipe split (IndoorPass /
/// OutdoorScenery / BuildingShells / LiveDynamic) was deleted along with the
/// inside-out render machinery. <see cref="All"/> is the sole remaining
/// member; the unified retail-faithful pass (Phase U) draws every entity in
/// one path. The <c>set:</c> parameter is retained on the Draw overloads so
/// the unified pass can re-introduce partitioning later without re-threading
/// the call sites.
/// </summary>
public enum EntitySet
{
/// <summary>Pre-A8 behavior: every entity walked, gated only by
/// the existing <c>ParentCellId ∈ visibleCellIds</c> filter.
/// Used when the camera is OUTSIDE any EnvCell.</summary>
/// <summary>Every entity walked, gated only by the existing
/// <c>ParentCellId ∈ visibleCellIds</c> filter.</summary>
All,
/// <summary>Cell mesh + cell statics (<see cref="WorldEntity.ParentCellId"/>
/// non-null) PLUS building shell stabs (<see cref="WorldEntity.IsBuildingShell"/>
/// true) whose <see cref="WorldEntity.BuildingShellAnchorCellId"/>
/// belongs to the active building cell set. Live-dynamic
/// (<c>ServerGuid != 0</c>) is excluded; it flows through
/// <see cref="LiveDynamic"/>.</summary>
IndoorPass,
/// <summary>Outdoor/top-level stabs (<c>ParentCellId == null</c>),
/// including building shells. Drawn stencil-gated to portal
/// silhouettes when the camera is inside. Live-dynamic excluded.</summary>
OutdoorScenery,
/// <summary>Top-level building shell stabs only, optionally scoped by
/// <see cref="WorldEntity.BuildingShellAnchorCellId"/>. Used for
/// portal depth repair without walking the full outdoor scenery set.</summary>
BuildingShells,
/// <summary>Server-spawned dynamic entities (<c>ServerGuid != 0</c>):
/// player, NPCs, monsters, dropped items, animated and idle doors.
/// Drawn last with stencil disabled so they're depth-tested against
/// everything else but not stencil-clipped.</summary>
LiveDynamic,
}
private readonly GL _gl;
@ -576,13 +555,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
probeState,
set);
if (set == EntitySet.IndoorPass && RenderingDiagnostics.ProbeVisibilityEnabled)
{
Console.WriteLine(
$"[indoor-shells] anchorPass={walkResult.BuildingShellAnchorPass} " +
$"anchorReject={walkResult.BuildingShellAnchorReject} walked={walkResult.EntitiesWalked}");
}
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
// a given entity are contiguous. We accumulate ALL of an entity's
@ -1507,82 +1479,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
/// <summary>
/// Phase A8 — entity-taxonomy-aware membership test for the three-way
/// EntitySet partition. See <see cref="EntitySet"/> for the doctrine.
/// Entity-set membership test. Phase U.1 (2026-05-30): with the
/// two-pipe partition deleted, the sole <see cref="EntitySet.All"/>
/// member matches every entity. Retained as a seam for the unified
/// pass to re-introduce partitioning.
/// </summary>
private static bool EntityMatchesSet(WorldEntity entity, EntitySet set)
{
if (set == EntitySet.All) return true;
bool isLiveDynamic = entity.ServerGuid != 0;
if (set == EntitySet.LiveDynamic) return isLiveDynamic;
if (isLiveDynamic) return false; // IndoorPass/OutdoorScenery exclude live-dynamic
bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
if (set == EntitySet.IndoorPass) return isIndoor;
if (set == EntitySet.OutdoorScenery) return !entity.ParentCellId.HasValue;
if (set == EntitySet.BuildingShells) return entity.IsBuildingShell;
throw new InvalidOperationException($"Unhandled EntitySet value: {set}");
}
/// <summary>
/// Phase A8 test helper: runs the EntitySet partition + visibleCellIds
/// gate against an in-memory entity list, returning the IDs that
/// survive both filters. Exists so the partition logic is unit-testable
/// without requiring a GL context or landblock-entries machinery.
/// </summary>
public static List<uint> WalkEntitiesForTest(
IReadOnlyList<AcDream.Core.World.WorldEntity> entities,
HashSet<uint>? visibleCellIds,
EntitySet set)
{
var output = new List<uint>();
foreach (var entity in entities)
{
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set)) continue;
output.Add(entity.Id);
}
return output;
}
/// <summary>
/// Phase A8 RR5 (2026-05-26): pure-data walk for the explicit cellIds
/// overload. Used by RR7's IndoorPass to render only the camera-buildings'
/// cells (instead of the visibility-derived set).
///
/// <para>Indoor entities (ParentCellId set) gated by membership in
/// <paramref name="cellIds"/>. Building shells are gated by
/// BuildingShellAnchorCellId membership in the same cell set. Outdoor
/// scenery is excluded by the EntitySet partition (no cell-list gate
/// needed — EntityMatchesSet handles it).</para>
/// </summary>
public static List<uint> WalkEntitiesForTestByCellIds(
IEnumerable<AcDream.Core.World.WorldEntity> entities,
IReadOnlyCollection<uint> cellIds,
EntitySet set)
{
var result = new List<uint>();
foreach (var entity in entities)
{
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value))
continue;
if (IsShellScopedSet(set) && entity.IsBuildingShell)
{
if (entity.BuildingShellAnchorCellId is not uint anchorCellId ||
!cellIds.Contains(anchorCellId))
continue;
}
result.Add(entity.Id);
}
return result;
}
private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true;
private static bool EntityPassesVisibleCellGate(
WorldEntity entity,
@ -1604,8 +1506,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
return true;
}
private static bool IsShellScopedSet(EntitySet set) =>
set == EntitySet.IndoorPass || set == EntitySet.BuildingShells;
// Phase U.1 (2026-05-30): the shell-scoped sets (IndoorPass / BuildingShells)
// were deleted with the two-pipe machinery. EntitySet.All is never shell-scoped.
private static bool IsShellScopedSet(EntitySet set) => false;
public void Dispose()
{

View file

@ -1,232 +0,0 @@
// 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_OnlyIncludesProvidedVisibleCells()
{
// The render path now feeds BuildTriangles from the portal traversal's
// visible cells, not every cell in the building. A hidden room's exit
// portal must not punch outdoor terrain into the current view.
var visibleInnerCell = new LoadedCell
{
WorldTransform = Matrix4x4.Identity,
Portals = new() { new CellPortalInfo(0x0102, 100, 0) },
ClipPlanes = new() { default },
PortalPolygons = new()
{
new[]
{
new Vector3(0, 0, 0),
new Vector3(1, 0, 0),
new Vector3(1, 1, 0),
},
},
};
var hiddenExitCell = new LoadedCell
{
WorldTransform = Matrix4x4.Identity,
Portals = new() { new CellPortalInfo(0xFFFF, 101, 0) },
ClipPlanes = new() { default },
PortalPolygons = new()
{
new[]
{
new Vector3(10, 0, 0),
new Vector3(11, 0, 0),
new Vector3(11, 1, 0),
},
},
};
var visibleOnly = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { visibleInnerCell });
var allBuildingCells = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { visibleInnerCell, hiddenExitCell });
Assert.Empty(visibleOnly);
Assert.Equal(3, allBuildingCells.Length);
Assert.Equal(new Vector3(10, 0, 0), allBuildingCells[0]);
}
[Fact]
public void BuildTriangles_CameraSideFilterSkipsExitPortalsBehindCamera()
{
var cell = new LoadedCell
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) },
ClipPlanes = new()
{
new PortalClipPlane
{
Normal = Vector3.UnitX,
D = 0f,
InsideSide = 0,
},
},
PortalPolygons = new()
{
new[]
{
new Vector3(0, 0, 0),
new Vector3(0, 1, 0),
new Vector3(0, 0, 1),
},
},
};
var visible = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell }, new Vector3(1, 0, 0));
var rejected = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell }, new Vector3(-1, 0, 0));
Assert.Equal(3, visible.Length);
Assert.Empty(rejected);
}
[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);
}
}

View file

@ -1,92 +0,0 @@
// Phase A8 RR5 — verify WbDrawDispatcher.WalkEntitiesForTestByCellIds,
// the pure-data companion to the new Draw(cellIds:) production overload.
//
// Semantics: indoor entities (ParentCellId.HasValue) are gated by explicit
// membership in cellIds. Building shells (IsBuildingShell) pass only when their
// BuildingShellAnchorCellId belongs to the same cell set.
// Outdoor scenery (no ParentCellId, not a shell) is excluded by EntitySet.IndoorPass.
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
public class WbDrawDispatcherCellIdsOverloadTests
{
private static WorldEntity CellEnt(uint id, uint cellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = cellId,
MeshRefs = new List<MeshRef> { new(0x01000001u, Matrix4x4.Identity) },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity OutdoorScenery(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = null,
IsBuildingShell = false,
MeshRefs = new List<MeshRef> { new(0x01000001u, Matrix4x4.Identity) },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity BuildingShell(uint id, uint? anchorCellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ParentCellId = null,
IsBuildingShell = true,
BuildingShellAnchorCellId = anchorCellId,
MeshRefs = new List<MeshRef> { new(0x01000001u, Matrix4x4.Identity) },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
[Fact]
public void WalkEntitiesByCellIds_IncludesOnlyEntitiesInListedCells()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001u, 0xA9B40150u), // in listed cells
CellEnt(0x40000002u, 0xA9B40151u), // in listed cells
CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list
BuildingShell(0xC0000001u, 0xA9B40150u), // in listed building cells
BuildingShell(0xC0000003u, 0xA9B40999u), // OUT — another building shell
OutdoorScenery(0xC0000002u), // OUT — not a shell, not in cell list
};
var cellIds = new HashSet<uint> { 0xA9B40150u, 0xA9B40151u };
var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
entities, cellIds, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(3, result.Count);
Assert.Contains(0x40000001u, result);
Assert.Contains(0x40000002u, result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0x40000003u, result);
Assert.DoesNotContain(0xC0000003u, result);
Assert.DoesNotContain(0xC0000002u, result);
}
[Fact]
public void WalkEntitiesByCellIds_EmptyCellList_ExcludesBuildingShells()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001u, 0xA9B40150u),
BuildingShell(0xC0000001u, 0xA9B40150u),
};
var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
entities, new HashSet<uint>(), set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Empty(result);
}
}

View file

@ -1,282 +0,0 @@
// Phase A8 — verify the WbDrawDispatcher EntitySet partition (taxonomy-aware).
//
// The pure-data WalkEntitiesForTest helper iterates a flat entity list and
// returns the IDs that survive the EntitySet filter + visibleCellIds gate.
//
// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell,
// and NOT live-dynamic (ServerGuid == 0).
// Building shells are gated by their dat anchor;
// live-dynamic flows through LiveDynamic instead.
// EntitySet.OutdoorScenery — ParentCellId == null AND not live-dynamic.
// Includes building shells for exterior/depth repair passes.
// EntitySet.BuildingShells — IsBuildingShell only, gated by dat anchor when
// visibleCellIds are supplied.
// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items,
// idle doors after animation). Drawn last with
// stencil disabled.
// EntitySet.All — pre-A8 behavior (visibleCellIds gates indoor;
// outdoor entities pass through).
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
public class WbDrawDispatcherEntitySetTests
{
private static WorldEntity CellEnt(uint id, uint cellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = cellId,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity OutdoorScenery(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = null,
IsBuildingShell = false,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity BuildingShell(uint id, uint? anchorCellId = null) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ParentCellId = null,
IsBuildingShell = true,
BuildingShellAnchorCellId = anchorCellId,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity LiveDynamic(uint id, uint serverGuid) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ServerGuid = serverGuid,
ParentCellId = null,
IsBuildingShell = false,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
[Fact]
public void IndoorPass_IncludesCellEntities()
{
var entities = new List<WorldEntity>
{
CellEnt(0x10000001, 0xA9B40143),
OutdoorScenery(0x10000002),
CellEnt(0x10000003, 0xA9B40144),
};
var visible = new HashSet<uint> { 0xA9B40143u, 0xA9B40144u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(2, result.Count);
Assert.Contains(0x10000001u, result);
Assert.Contains(0x10000003u, result);
Assert.DoesNotContain(0x10000002u, result);
}
[Fact]
public void IndoorPass_IncludesBuildingShells_WhenAnchorCellIsVisible()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40143u), // cottage wall
OutdoorScenery(0xC0000002), // tree
CellEnt(0x40000001, 0xA9B40143),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(2, result.Count);
Assert.Contains(0xC0000001u, result); // building shell included
Assert.Contains(0x40000001u, result); // cell entity included
Assert.DoesNotContain(0xC0000002u, result); // tree excluded
}
[Fact]
public void IndoorPass_WithNullCellFilter_UsesEntitySetOnly()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40143u),
CellEnt(0x40000001, 0xA9B40143),
CellEnt(0x40000002, 0xA9B40199),
OutdoorScenery(0xC0000002),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(3, result.Count);
Assert.Contains(0xC0000001u, result);
Assert.Contains(0x40000001u, result);
Assert.Contains(0x40000002u, result);
Assert.DoesNotContain(0xC0000002u, result);
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void IndoorPass_ExcludesBuildingShells_WhenAnchorCellIsNotVisible()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40150u),
CellEnt(0x40000001, 0xA9B40143),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Single(result);
Assert.Contains(0x40000001u, result);
Assert.DoesNotContain(0xC0000001u, result);
}
[Fact]
public void IndoorPass_ExcludesLiveDynamic()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001, 0xA9B40143),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Single(result);
Assert.Contains(0x40000001u, result);
Assert.DoesNotContain(0x10000001u, result); // live-dynamic excluded
}
[Fact]
public void OutdoorScenery_IncludesBuildingShells()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001), // cottage wall — included
OutdoorScenery(0xC0000002), // tree — included
CellEnt(0x40000001, 0xA9B40143), // cell — excluded
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
Assert.Equal(2, result.Count);
Assert.Contains(0xC0000002u, result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0x40000001u, result);
}
[Fact]
public void OutdoorScenery_ExcludesLiveDynamic()
{
var entities = new List<WorldEntity>
{
OutdoorScenery(0xC0000001),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
Assert.Single(result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void BuildingShells_IncludesOnlyAnchoredShells()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40143u),
BuildingShell(0xC0000002, 0xA9B40999u),
OutdoorScenery(0xC0000003),
CellEnt(0x40000001, 0xA9B40143),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.BuildingShells);
Assert.Single(result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0xC0000002u, result);
Assert.DoesNotContain(0xC0000003u, result);
Assert.DoesNotContain(0x40000001u, result);
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void LiveDynamic_IncludesOnlyServerSpawned()
{
var entities = new List<WorldEntity>
{
OutdoorScenery(0xC0000001),
BuildingShell(0xC0000002),
CellEnt(0x40000001, 0xA9B40143),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
LiveDynamic(0x10000002, serverGuid: 0x50000456u),
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.LiveDynamic);
Assert.Equal(2, result.Count);
Assert.Contains(0x10000001u, result);
Assert.Contains(0x10000002u, result);
Assert.DoesNotContain(0xC0000001u, result);
Assert.DoesNotContain(0xC0000002u, result);
Assert.DoesNotContain(0x40000001u, result);
}
[Fact]
public void All_MatchesPreA8Behavior()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001, 0xA9B40143),
OutdoorScenery(0xC0000001),
BuildingShell(0xC0000002),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
CellEnt(0x40000002, 0xA9B40999), // not in visibleCellIds
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.All);
// Pre-A8: visibleCellIds gates indoor entities only; outdoor entities
// (regardless of building/scenery/live-dynamic) pass through.
Assert.Equal(4, result.Count);
Assert.Contains(0x40000001u, result);
Assert.Contains(0xC0000001u, result);
Assert.Contains(0xC0000002u, result);
Assert.Contains(0x10000001u, result);
Assert.DoesNotContain(0x40000002u, result);
}
}