fix #108-residual (root cause): terrain drew DOUBLE-SIDED - port retail landPolysDraw eye-side gate as terrain backface cull

The cellar-ascent grass window was the UNDERSIDE of the z~94 grade
sheet. Retail terrain is single-sided: ACRender::landPolysDraw
(0x006b7040) draws each land triangle ONLY when the camera is on the
POSITIVE (upper) side of its plane (Plane::which_side2 vs
Render::FrameCurrent, zFightTerrainAdjust bias) - a below-grade eye
gets NO terrain, so retail shows sky through the cellar door.

We inherited WB's frame-global cull DISABLE (WB GameScene.cs:841 - an
editor camera goes underground by design) and TerrainModernRenderer.Draw
set no cull state of its own -> terrain rasterized both sides. From a
below-grade eye every aperture sight-ray RISES, so the only 'terrain'
it can see is the grade sheet's underside - which painted the exit-door
aperture (the landscape slice's 2D NDC clip planes (nx,ny,0,dw) have no
depth axis and cannot exclude between-eye-and-portal geometry) and slid
off the door exactly as the eye crossed grade. Membership/viewer was
exonerated by the harness in the previous commit.

Fix: TerrainModernRenderer.Draw owns its cull state (the 7th
self-contained-GL-state instance): Enable(CullFace) + CullFace(Back) +
FrontFace(Ccw), set -> draw -> restore the frame-global CW + cull-off
baseline. GL backface culling evaluates retail's per-triangle eye-side
predicate at rasterization; no shader change.

Pins:
- LandblockMeshTests.Build_AllTriangles_WindCounterClockwiseInWorldXY:
  every emitted triangle CCW in world XY across both FSplitNESW split
  directions - the winding invariant culling depends on.
- TerrainCullOrientationTests: under the production camera convention
  (LookAt up=+Z, Numerics perspective) an up-facing triangle winds CCW
  in window space from above (kept) and CW from below (culled) - guards
  FrontFace inversion, which would blank terrain from above.

Oracle note: retail's through-portal clip has NO portal-face near plane
(PView::GetClip / Render::set_view install edge planes only); nearer-
than-portal exclusion comes from the eye-side cull + cell-level
admission. No register row: this PORTS the retail mechanism, retiring
an undocumented WB-heritage deviation.

Gate pending: cellar climb (grass window gone) + outdoor sanity glance
(terrain intact from above).

Suites: App 263+1skip / Core 1443+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 22:05:31 +02:00
parent 007af1391c
commit 96a425a9a5
4 changed files with 180 additions and 19 deletions

View file

@ -3701,27 +3701,47 @@ Unverified. The likely culprits, ranked by suspected probability:
--- ---
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual] ## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [FIX SHIPPED 2026-06-12 · awaiting cellar visual gate]
**Status:** REOPENED (narrowed) — the broad symptom is GONE (T5 + **Status:** FIX SHIPPED (desk-pinned) — root cause found 2026-06-12; the
re-gate #2: "Yes, but…"), but a residual remains in ONE window: during cellar-ascent visual gate is pending.
the cellar ASCENT, while the eye is still below ground level, the
upstairs exit-door opening is covered with grass — "like the ground **ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was
level rose to the top of the door … as soon as my head pops up it falls the UNDERSIDE of the grade sheet.** Two steps:
back to ground level" (user, re-gate 2026-06-11). The original 1. The membership/viewer re-diagnosis below is **REFUTED** by the vertical
BR-2-era diagnosis stands: grass-sweep frames render through the cellar-ascent harness (`Issue108CellarAscentViewerReplayTests`, dat-backed
OUTDOOR root (membership/viewer-cell flips outdoor mid-cellar), and the A9B4 corner-building cellar 0x0174→0x0175→0x0171, production
#117 depth-gated punch then correctly refuses to punch the aperture FindCellList pick + the camera probe chain mirrored verbatim): 0
where terrain depth is NEARER than the door fan (eye below grade ⇒ the outdoor/null viewer resolutions while the eye is below grade, 0 sweep
visible front-facing terrain can sit between the eye and the door in failures, 0 fallback branches across boom distance {2.61, 5} × damping
depth). The punch must STAY depth-gated (DO-NOT-RETRY) — the fix is on lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the
the membership/viewer side (why is the root outdoor while the eye is in head pops above grade (the stairwell portal sits at grade), matching the
the cellar stairwell below grade?). Apparatus shape: a vertical user's wording. The root is INTERIOR the whole window.
cellar-ascent variant of the #118 exit-walk harness (drive the eye up 2. Retail terrain is SINGLE-SIDED: `ACRender::landPolysDraw` (0x006b7040)
the stair path; log root resolution + the punch's mark-pass outcome per draws each land triangle ONLY when the camera is on the POSITIVE (upper)
step). Prior history below. side of its plane (`Plane::which_side2` vs `Render::FrameCurrent`). A
below-grade eye gets NO terrain — through the door retail shows sky.
WB renders the world with face culling DISABLED frame-globally (WB
`GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw`
set no cull state of its own → terrain drew double-sided. From a
below-grade eye every aperture sight-ray RISES, so the only "terrain" it
can see is the underside of the z≈94 grade sheet — which painted the
whole exit-door aperture (the landscape slice's 2D NDC clip planes
`(nx,ny,0,dw)` have no depth axis and cannot exclude it) and slid down
off the door exactly as the eye crossed grade.
**Fix: port the landPolysDraw eye-side gate as terrain backface culling**
`TerrainModernRenderer.Draw` now owns Enable(CullFace) + Cull(Back) +
FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL-
state rule). Pins: `LandblockMeshTests.Build_AllTriangles_WindCounter-
ClockwiseInWorldXY` (every emitted triangle CCW in world XY — cull-safe
winding) + `TerrainCullOrientationTests` (above-eye ⇒ CCW window winding
kept / below-eye ⇒ CW culled under the production camera convention).
**Gate:** climb out of the corner-building cellar — the grass window over
the exit door must be gone (sky/world through the door instead); plus a
general outdoor sanity glance (terrain intact from above — a wrong
FrontFace would blank it).
**Severity:** MEDIUM **Severity:** MEDIUM
**Component:** ~~render / indoor PView~~**physics / membership** (cellar-transition root flip) **Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED
During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor
shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the

View file

@ -283,6 +283,27 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// when wired, else the no-clip fallback (count 0 = ungated terrain). // when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2(); BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
// evaluates the same per-triangle eye-side predicate at rasterization.
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.BindVertexArray(_globalVao); _gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit); _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect( _gl.MultiDrawElementsIndirect(
@ -292,6 +313,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
(uint)sizeof(DrawElementsIndirectCommand)); (uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0); _gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
} }
public void Dispose() public void Dispose()

View file

@ -0,0 +1,82 @@
using System;
using System.Numerics;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #108-residual orientation pin: TerrainModernRenderer culls terrain back
/// faces with FrontFace(Ccw) — the GL port of retail's single-sided terrain
/// (ACRender::landPolysDraw 0x006b7040: a land triangle draws ONLY when the
/// camera is on the POSITIVE side of its plane via Plane::which_side2).
///
/// The FrontFace choice rests on one mapping fact: under the production
/// camera convention (Matrix4x4.CreateLookAt with up = world +Z, Numerics
/// CreatePerspectiveFieldOfView — RetailChaseCamera.cs:203 / :52), an
/// UP-FACING terrain triangle that LandblockMesh emits CCW in world XY
/// rasterizes
/// · CCW in NDC/window space when the eye is ABOVE its plane (kept), and
/// · CW when the eye is BELOW (culled — retail draws nothing there: from
/// a below-grade cellar eye the door aperture shows sky, never grass).
/// This test pins that mapping in pure CPU math so a projection-convention
/// change (handedness, Y-flip) can't silently invert the cull and either
/// resurrect the #108 grass window or cull terrain from above.
/// </summary>
public class TerrainCullOrientationTests
{
// An up-facing triangle, CCW in world XY viewed from above — the exact
// emission convention pinned by LandblockMeshTests (crossZ > 0).
private static readonly Vector3[] Triangle =
{
new(-1f, 10f, 94f),
new( 1f, 10f, 94f),
new( 1f, 12f, 94f),
};
private static float NdcSignedArea2(Vector3 eye, Vector3 forward)
{
// The production camera shape: look-at with world-Z up
// (RetailChaseCamera.cs:203), Numerics perspective with the retail
// znear 0.1 (RetailChaseCamera.cs:52).
var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
var viewProj = view * proj;
Span<Vector2> ndc = stackalloc Vector2[3];
for (int i = 0; i < 3; i++)
{
var c = Vector4.Transform(new Vector4(Triangle[i], 1f), viewProj);
Assert.True(c.W > 1e-3f, "test triangle must be in front of the eye");
ndc[i] = new Vector2(c.X / c.W, c.Y / c.W);
}
// Twice the signed area: > 0 = CCW in NDC (GL window space keeps the
// orientation — NDC y up maps to window y up, no flip).
return (ndc[1].X - ndc[0].X) * (ndc[2].Y - ndc[0].Y)
- (ndc[1].Y - ndc[0].Y) * (ndc[2].X - ndc[0].X);
}
[Fact]
public void EyeAboveTerrainPlane_WindsCcw_FrontFaceKept()
{
// Eye above grade looking forward-down at the triangle (the normal
// outdoor view). Retail: which_side2 = POSITIVE → drawn.
float area = NdcSignedArea2(new Vector3(0f, 5f, 96.5f), new Vector3(0f, 1f, -0.3f));
Assert.True(area > 0f,
$"above-plane eye must see the terrain triangle CCW (area2={area}) — " +
"FrontFace(Ccw)+Cull(Back) would otherwise cull terrain from above");
}
[Fact]
public void EyeBelowTerrainPlane_WindsCw_BackfaceCulled()
{
// Eye below grade (the cellar-stairwell window) looking up-forward at
// the underside. Retail: which_side2 = NEGATIVE → not drawn at all —
// the #108 grass that covered the exit door was exactly this
// underside rasterizing when culling was left disabled.
float area = NdcSignedArea2(new Vector3(0f, 5f, 92.5f), new Vector3(0f, 1f, 0.2f));
Assert.True(area < 0f,
$"below-plane eye must see the terrain triangle CW (area2={area}) — " +
"it must backface-cull like retail's which_side2 eye-side gate");
}
}

View file

@ -169,6 +169,41 @@ public class LandblockMeshTests
Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}"); Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}");
} }
[Fact]
public void Build_AllTriangles_WindCounterClockwiseInWorldXY()
{
// #108-residual winding pin: TerrainModernRenderer enables backface
// culling with FrontFace(Ccw) — the GL port of retail's single-sided
// terrain (ACRender::landPolysDraw 0x006b7040 draws a land triangle
// only when the eye is on the POSITIVE side of its plane). That cull
// is only correct if EVERY emitted triangle winds the same way:
// counter-clockwise in world XY viewed from above (+Z toward the
// viewer), i.e. cross2D(v1-v0, v2-v0) > 0. Varied heights + several
// landblock coords exercise both FSplitNESW split directions across
// the 64 cells. A future emission-order change that flips any
// triangle would silently punch terrain holes under culling.
var block = BuildFlatLandBlock();
for (int i = 0; i < 81; i++)
block.Height[i] = (byte)((i * 37) % 64); // varied, deterministic slopes
foreach (var (lbx, lby) in new[] { (0u, 0u), (0xA9u, 0xB4u), (3u, 7u) })
{
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, lbx, lby, IdentityHeightTable, MakeContext(), cache);
for (int t = 0; t < mesh.Indices.Length; t += 3)
{
var p0 = mesh.Vertices[mesh.Indices[t + 0]].Position;
var p1 = mesh.Vertices[mesh.Indices[t + 1]].Position;
var p2 = mesh.Vertices[mesh.Indices[t + 2]].Position;
float crossZ = (p1.X - p0.X) * (p2.Y - p0.Y) - (p1.Y - p0.Y) * (p2.X - p0.X);
Assert.True(crossZ > 0f,
$"lb=({lbx},{lby}) triangle {t / 3} winds CW in world XY (crossZ={crossZ}) — " +
"backface culling in TerrainModernRenderer would cull its TOP side");
}
}
}
[Fact] [Fact]
public void Build_HeightmapPackedAsXMajor_NotYMajor() public void Build_HeightmapPackedAsXMajor_NotYMajor()
{ {