fix(render): Phase A8 — cell-mesh Landblock CullMode → None + cull state restore

The cull A/B diagnostic (prior commit's ACDREAM_A8_DISABLE_CULL=1) in
visual-gate-#3 confirmed: cell-mesh polys are being culled by back-face
culling, which is why floors disappear when looking down from inside a
room. Per-cell audit data showed every cell-mesh batch has
CullMode.Landblock — assigned because AC's CellStruct polys carry
SidesType=Landblock in the dat. Our SetCullMode maps Landblock to
glCullFace(Back), matching WB.

Root cause:
WB sets `glFrontFace(GLEnum.CW)` globally at GameScene.cs:843. Our
WbDrawDispatcher.cs:1056 sets `glFrontFace(CCW)` — the GL default,
opposite of WB. With our flipped-from-natural fan triangulation in
BuildCellStructPolygonIndices (which emits (i, i-1, 0) for each fan
triangle, reversing the input vertex order), the resulting effective
winding from the camera's perspective is OPPOSITE WB's. Cull-back then
removes the OPPOSITE face from what WB does — hiding the floor side
that should be visible from inside the room.

Within a single cell-mesh batch, the polys face every direction (walls
outward, floor up, ceiling down) but all share CullMode.Landblock. No
single cull setting can be correct for all three orientations
simultaneously — the retail-faithful approach is to render cell polys
double-sided (cull off).

Two changes scoped to EnvCellRenderer.RenderModernMDIInternal so other
renderers aren't affected:
  1. Remap CullMode.Landblock → None when iterating per-cull-mode
     batch groups. Cell polys render with cull disabled, all faces
     visible. CullMode.Landblock is only assigned by
     PrepareCellStructMeshData (cell polys) in this codebase — terrain
     uses a different render path. Scope is exactly right.
  2. Explicitly Enable(CullFace) + CullFace(Back) at Render exit so the
     dispatcher's subsequent IndoorPass + LiveDynamic Draws don't
     inherit the cull-disabled state. The see-through-head symptom in
     visual-gate-#3 was caused by exactly this state leak from the
     ACDREAM_A8_DISABLE_CULL=1 diagnostic; the proper fix needs the
     explicit restore. Also updates the static `_currentCullMode` cache
     so the next Render call's first SetCullMode comparison is correct.

Removed the ACDREAM_A8_DISABLE_CULL diagnostic env var — its role as
A/B test is complete. 14/14 EnvCellRenderer tests pass. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 20:12:20 +02:00
parent b19f3c14a9
commit 0940d7961a

View file

@ -81,16 +81,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private static uint _currentVao;
private static CullMode? _currentCullMode;
// Phase A8 A/B diagnostic (2026-05-28 visual-gate-#2 follow-up):
// ACDREAM_A8_DISABLE_CULL=1 forces every batch's effective CullMode to
// None (no face culling). Used to isolate whether the missing-floor
// symptom is caused by polygon-winding+CullMode interaction or by
// something else (lighting, depth, alpha). If the floor appears with
// this set, cull/winding is the bug. If not, look elsewhere. Static
// because it's read once at startup; no need to re-query per draw.
private static readonly bool _forceCullModeNone =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_DISABLE_CULL"), "1", StringComparison.Ordinal);
public bool NeedsPrepare { get; private set; } = true;
public bool IsDisposed { get; private set; }
@ -825,6 +815,22 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindVertexArray(0);
_currentVao = 0;
// Phase A8 fix (2026-05-28 visual-gate-#3 follow-up): explicitly
// restore cull-back at exit. The Landblock→None override above
// can leave cull DISABLED if the last batch's CullMode was
// Landblock — which would leak into the subsequent dispatcher
// draws (IndoorPass building shells, then LiveDynamic chars +
// NPCs + doors), making them all render see-through (no
// back-face cull). The see-through-head symptom in the
// ACDREAM_A8_DISABLE_CULL=1 A/B test was caused exactly by
// this state leak. Re-enabling cull here restores the
// dispatcher's expected default and updates our static cache
// so the next Render call's first SetCullMode comparison is
// correct.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_currentCullMode = CullMode.CounterClockwise;
// Update frame stats for probe emission at the call site.
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
_lastFrameStats.TrianglesDrawn = 0;
@ -997,10 +1003,24 @@ public sealed unsafe class EnvCellRenderer : IDisposable
foreach (var group in batchesByCullMode)
{
var cullMode = (CullMode)(group.Key % 4);
// A/B diagnostic: ACDREAM_A8_DISABLE_CULL=1 overrides every batch
// to no-culling. Reveals whether floor-missing is a cull/winding
// bug or something else.
if (_forceCullModeNone) cullMode = CullMode.None;
// Phase A8 fix (2026-05-28 visual-gate-#3 evidence): override
// CullMode.Landblock to None for cell-mesh batches. WB sets
// glFrontFace(CW) globally (GameScene.cs:843) so its CullMode
// mapping (Landblock→Back) culls the correct side; we set
// glFrontFace(CCW) in WbDrawDispatcher (line 1056) so the
// mapping would cull the OPPOSITE side, hiding cell floors.
// Cell-mesh polys with CullMode.Landblock represent the floor +
// walls + ceiling of a single room — they face different
// directions but share one CullMode value, so a single cull
// setting can't be correct for all of them. The retail-faithful
// approach is double-sided rendering for cell polys (cull off),
// matching what the cull-disable A/B diagnostic empirically
// confirmed (floor visible with cull off in visual-gate-#3).
// CullMode.Landblock is only ever assigned in this codebase by
// PrepareCellStructMeshData (cell polys) — terrain has its own
// renderer that doesn't go through this code path — so this
// override is scoped exactly right.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
if (_currentCullMode != cullMode)
{
SetCullMode(cullMode);