fix #130 (the real strip): drawn-shell lift vs draw-space portal consumers

The user's re-gate refuted the scissor fix as THE strip (6c4b6d6 was a
real but sub-pixel under-coverage): the strip survived, screenshot at a
doorway, full width of the opening, top edge only, "very subtle".

Root cause (pinned by Issue130DoorwayStripTests.UnliftedGate_*): the
+0.02 m shell render lift. Cell shells DRAW 2 cm above the dat origin
(z-fight vs coplanar terrain); f35cb8b (the #119-residual fix,
2026-06-11) deliberately reverted the VISIBILITY graph to the physics
(unlifted) transform - but the OutsideView color gate (terrain/sky/
scissor through the doorway) and the seal/punch depth fans are
DRAW-space consumers and kept projecting the unlifted polygons. The
drawn lintel therefore sits one lift-projection above the gate's top
edge - measured 6.7 px at a 2.4 m doorway - and that band never
receives terrain/sky color while the seal also stamps 2 cm low.
A regression from f35cb8b, NOT from the W=0 clip port (987313a stays
exonerated). Vertical aperture edges are immune (the lift slides them
along themselves) - top edge only, exactly as reported; explains the
"also NOW" timing precisely.

Fix - draw space draws lifted, visibility stays physics (the f35cb8b
invariant, now symmetric):
- PortalVisibilityBuilder.Build gains drawLiftZ: the exit-portal branch
  projects the OutsideView region with the lifted transform; flood
  admission, side tests, and CellViews are untouched (default 0 keeps
  every existing visibility test bit-identical).
- The seal/punch fans (DrawRetailPViewPortalDepthWrite) lift their
  world verts to the drawn shell's space.
- One shared constant PortalVisibilityBuilder.ShellDrawLiftZ feeds the
  shell registration (GameWindow:5604), the gate, and the fans.

Register: AP-32 ADDED - the +0.02 lift had NO row (a pre-register
deviation the 2026-06-12 sweep missed). The row records the split
invariant both ways: a draw-space consumer that forgets the lift
re-opens the #130 strip; a visibility consumer that picks the lifted
transform re-opens the #119-residual side-cull.

Pins: the lifted gate covers the drawn (lifted) aperture to 0.00 px
across the 147-combo sweep; the unlifted gate shows the 6.7 px strip
(sensitivity proof - if the lift is ever removed, this test says the
drawLiftZ plumbing can go too).

Suites: App 257+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user re-gate at a doorway with the lintel on screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 14:28:16 +02:00
parent 4ba714835d
commit 5135066733
6 changed files with 191 additions and 38 deletions

View file

@ -5599,9 +5599,13 @@ public sealed class GameWindow : IDisposable
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Keep the small render lift out of physics; retail BSP
// contact planes use the EnvCell origin verbatim.
// contact planes use the EnvCell origin verbatim. The lift
// constant is shared with every draw-space consumer of
// portal polygons (OutsideView gate, seal/punch fans) —
// see PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
@ -9699,9 +9703,16 @@ public sealed class GameWindow : IDisposable
if (localVerts.Length < 3)
continue;
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
// space or the stamp sits 2 cm below the drawn hole (#130 family).
int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++)
{
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
}
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
}

View file

@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
/// terrain; applied in GameWindow's cell registration). The visibility
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
/// Draw-space consumers of portal polygons (the OutsideView color gate
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
/// they meet the drawn shell's aperture edge — the unlifted gate left a
/// 2 cm background strip under the drawn lintel (#130).</summary>
public const float ShellDrawLiftZ = 0.02f;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
// shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f
? clippedRegion
: ClipPortalAgainstView(
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
activeViewPolygons,
out _);
AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
continue;
}

View file

@ -54,7 +54,9 @@ public sealed class RetailPViewRenderer
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via