fix(render): Phase A8.F — empty OutsideView draws no outdoor terrain (cellar flood fix)
First-fix from the visual-gate-failure handoff: an empty OutsideView means
"no outdoors visible from here," not "all outdoors." When inside a building
with an empty clipped mask, Step 4 now draws NO terrain/scenery instead of
disabling the stencil and flooding ungated terrain over the cell interior
(the Step-3 walls already occupy the framebuffer). Visual-confirmed: Holtburg
cottage cellar walls are solid now, no terrain bleed-through.
Also adds portal diagnostics that root-caused so-called "Bug B":
- PortalVisibilityBuilder: per-camera-cell CAMPORTAL census (polyLen +
side-test result) emitted BEFORE the BFS guards, so an empty OUTSIDEVIEW
can be traced to the exact gate.
- A8CellAudit `portals`: replicate BuildLoadedCell's polygon-vertex
resolution so PortalPolygons[i] validity is checkable offline.
Finding: the builder is largely CORRECT — it produces narrowed clipped
OutsideView regions for most cells (0172/0173/0162/015E/0165/016F). The
empty cases are mostly legitimate (windowless cellar can't see out; the
3rd-person camera eye on the outdoor side of a front-door plane culls that
exit). The handoff's Finding 2 ("under-produces, never narrows") is
substantially not real. Remaining wall-missing regressions in OTHER
buildings live in the cross-building Step-5 enforcement, escalated separately.
All gated behind ACDREAM_A8_INDOOR_BRANCH=1; default play unaffected.
App tests 108/108.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
cf3d49cbd7
commit
9417d3c4ce
3 changed files with 104 additions and 31 deletions
|
|
@ -11138,46 +11138,60 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count);
|
||||
|
||||
// Step 4 (WB VisibilityManager.cs:130-154): stencil STATE gated; exterior DRAWS unconditional.
|
||||
// WB draws terrain + scenery OUTSIDE the `if`; only the stencil read-only state setup is
|
||||
// gated on whether an indoor mask was marked. When no exit portal was visible (sealed view,
|
||||
// e.g. a cellar that reaches no exit), depth written in Step 3 occludes terrain on its own.
|
||||
// Step 4 (WB VisibilityManager.cs:130-154): terrain + scenery, stencil-gated to the
|
||||
// recursively-clipped OutsideView (bit 1).
|
||||
//
|
||||
// A8.F first-fix (2026-05-29): an EMPTY OutsideView means "no outdoors is visible from
|
||||
// here," NOT "all outdoors is visible." WB never hits the empty-while-inside case — its
|
||||
// mask marks the whole building's exit-portal set and is always non-empty when inside —
|
||||
// so WB safely draws terrain OUTSIDE the `if`. Our recursive-clip builder yields an empty
|
||||
// mask whenever it finds no visible exit portal (the builder under-production bug, issue
|
||||
// #102 family). Drawing terrain ungated in that case FLOODS the interior: indoor cell
|
||||
// geometry is not a reliable full-screen depth-occluder of the terrain heightfield from
|
||||
// an underground vantage (the prior "depth alone occludes outdoor geometry" assumption is
|
||||
// false for cellars). So when the mask is empty we draw NO outdoor terrain/scenery — the
|
||||
// Step-3 walls stay solid. Cost: terrain-through-portal is suppressed until the builder
|
||||
// yields a non-empty OutsideView. This decouples the flood (Bug A) from the builder (Bug B)
|
||||
// so each can be fixed and verified independently.
|
||||
if (didInsideStencil)
|
||||
{
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||||
gl.StencilMask(0x00u);
|
||||
gl.ColorMask(true, true, true, false);
|
||||
gl.DepthMask(true);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
|
||||
EmitDrawOrderProbe(step: 4, sub: ' ');
|
||||
// Terrain (WB line 143).
|
||||
// acdream's retail/ACME terrain mesh is CCW from the visible top side
|
||||
// (see terrain_modern.vert's LandblockMesh order comment), while WB's
|
||||
// editor terrain uses the opposite vertex order under its global CW
|
||||
// convention. Step 4 enables culling before terrain, so temporarily
|
||||
// use terrain's own front-face convention or ground disappears through
|
||||
// indoor portal silhouettes.
|
||||
gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
|
||||
_meshShader!.Use();
|
||||
// Scenery + static objects via dispatcher (WB lines 148-154).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
}
|
||||
else
|
||||
{
|
||||
gl.Disable(EnableCap.StencilTest); // sealed view — depth alone occludes outdoor geometry
|
||||
// Empty OutsideView while inside → no outdoors visible → draw no terrain/scenery.
|
||||
// The Step-3 walls already occupy the framebuffer with correct depth.
|
||||
gl.Disable(EnableCap.StencilTest);
|
||||
EmitDrawOrderProbe(step: 4, sub: 'x'); // 'x' = Step 4 skipped (empty OutsideView)
|
||||
}
|
||||
gl.ColorMask(true, true, true, false);
|
||||
gl.DepthMask(true);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
|
||||
EmitDrawOrderProbe(step: 4, sub: ' ');
|
||||
// Terrain (WB line 143).
|
||||
// acdream's retail/ACME terrain mesh is CCW from the visible top side
|
||||
// (see terrain_modern.vert's LandblockMesh order comment), while WB's
|
||||
// editor terrain uses the opposite vertex order under its global CW
|
||||
// convention. Step 4 enables culling before terrain, so temporarily
|
||||
// use terrain's own front-face convention or ground disappears through
|
||||
// indoor portal silhouettes.
|
||||
gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
|
||||
_meshShader!.Use();
|
||||
// Scenery + static objects via dispatcher (WB lines 148-154).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
|
||||
// Step 5: per-other-building 3-bit stencil pipeline (cross-building
|
||||
// visibility — wire-in #3). WB VisibilityManager.cs:157-232.
|
||||
|
|
|
|||
|
|
@ -79,7 +79,25 @@ public static class PortalVisibilityBuilder
|
|||
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
|
||||
}
|
||||
if (pvDump)
|
||||
{
|
||||
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
|
||||
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
|
||||
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
|
||||
// a portal the loop silently `continue`s past is still visible here. An
|
||||
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
|
||||
// polygon from BuildLoadedCell), interiorSide=false (camera back-facing the
|
||||
// portal — a legitimately-empty result, not a bug), or (if both OK) a
|
||||
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
|
||||
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
|
||||
{
|
||||
int plen = ci < cameraCell.PortalPolygons.Count
|
||||
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
|
||||
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
|
||||
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
|
||||
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
|
||||
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
|
|
|
|||
|
|
@ -137,6 +137,14 @@ static void DumpCellPortals(DatCollection dats, uint envCellId)
|
|||
return;
|
||||
}
|
||||
|
||||
// Resolve cellStruct so we can replicate BuildLoadedCell's portal-polygon
|
||||
// vertex resolution and report what LoadedCell.PortalPolygons[i] would hold.
|
||||
var envId = 0x0D000000u | envCell.EnvironmentId;
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||
DatReaderWriter.Types.CellStruct? cellStruct = null;
|
||||
if (environment is not null && environment.Cells.TryGetValue(envCell.CellStructure, out var cs))
|
||||
cellStruct = cs;
|
||||
|
||||
uint lbPrefix = envCellId & 0xFFFF0000u;
|
||||
int exitCount = 0;
|
||||
int interiorCount = 0;
|
||||
|
|
@ -148,9 +156,42 @@ static void DumpCellPortals(DatCollection dats, uint envCellId)
|
|||
string dest = isExit
|
||||
? "EXIT(outdoor)"
|
||||
: $"0x{(lbPrefix | (uint)portal.OtherCellId):X8}";
|
||||
|
||||
// Replicate BuildLoadedCell (GameWindow.cs:5816-5838): look up the portal
|
||||
// polygon by portal.PolygonId, require >= 3 verts, resolve every VertexId.
|
||||
// If any vertex is missing the loader stores an EMPTY array, which the
|
||||
// PortalVisibilityBuilder's guard (poly.Length < 3) silently skips.
|
||||
string resolveText;
|
||||
if (cellStruct is null)
|
||||
{
|
||||
resolveText = "no-cellStruct";
|
||||
}
|
||||
else if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly))
|
||||
{
|
||||
resolveText = $"polygon {portal.PolygonId} NOT in cellStruct.Polygons (keys=[{string.Join(",", cellStruct.Polygons.Keys.OrderBy(k => k))}])";
|
||||
}
|
||||
else
|
||||
{
|
||||
int vc = poly.VertexIds.Count;
|
||||
int resolved = 0;
|
||||
var missing = new List<int>();
|
||||
for (int vi = 0; vi < vc; vi++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.ContainsKey((ushort)poly.VertexIds[vi]))
|
||||
resolved++;
|
||||
else
|
||||
missing.Add(poly.VertexIds[vi]);
|
||||
}
|
||||
bool wouldResolve = vc >= 3 && resolved == vc;
|
||||
resolveText =
|
||||
$"polyVerts={vc} resolved={resolved} BUILDER_SEES={(wouldResolve ? "OK" : "EMPTY/SKIPPED")} " +
|
||||
$"vids=[{string.Join(",", poly.VertexIds)}]" +
|
||||
(missing.Count > 0 ? $" MISSING=[{string.Join(",", missing)}]" : "");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$" portal[{i}] other=0x{portal.OtherCellId:X4} -> {dest} " +
|
||||
$"flags={portal.Flags} polyId={portal.PolygonId}");
|
||||
$"flags={portal.Flags} polyId={portal.PolygonId} | {resolveText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue