diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 15b76bb..d395772 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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. diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index fdd0f59..d64c097 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -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) diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index e6f1810..9720a7a 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -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(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(); + 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(