feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS

Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 10:14:43 +02:00
parent bff1955066
commit 1405dd8e90
27 changed files with 3635 additions and 814 deletions

View file

@ -1291,21 +1291,24 @@ namespace AcDream.App.Rendering.Wb {
ct.ThrowIfCancellationRequested();
if (poly.VertexIds.Count < 3) continue;
// Handle Positive Surface
if (!poly.Stippling.HasFlag(StipplingType.NoPos)) {
AddSurfaceToBatch(poly, poly.PosSurface, false);
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
// 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still
// suppress hidden portal/cap faces before they reach our mesh.
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg);
if (hasPos)
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false);
if (hasPos && poly.SidesType == CullMode.None) {
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true);
}
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
}
// Handle Negative Surface
bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) ||
poly.Stippling.HasFlag(StipplingType.Both) ||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise);
if (hasNeg) {
AddSurfaceToBatch(poly, poly.NegSurface, true);
}
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) {
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
if (surfaceIdx < 0) return;
uint surfaceId;
@ -1499,7 +1502,17 @@ namespace AcDream.App.Rendering.Wb {
// Helper for CellStruct vertices
bool batchHasWrappingUVs = batch.HasWrappingUVs;
BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs);
BuildCellStructPolygonIndices(
poly,
cellStruct,
UVLookup,
vertices,
batch.Indices,
useNegUv,
invertNormal,
reverseWinding,
transform,
ref batchHasWrappingUVs);
batch.HasWrappingUVs = batchHasWrappingUVs;
}
}
@ -1516,8 +1529,10 @@ namespace AcDream.App.Rendering.Wb {
}
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) {
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
bool useNegUv, bool invertNormal, bool reverseWinding,
Matrix4x4 transform, ref bool hasWrappingUVs) {
var polyIndices = new List<ushort>();
@ -1525,9 +1540,9 @@ namespace AcDream.App.Rendering.Wb {
ushort vertId = (ushort)poly.VertexIds[i];
ushort uvIdx = 0;
if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
uvIdx = poly.NegUVIndices[i];
else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
uvIdx = poly.PosUVIndices[i];
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
@ -1536,7 +1551,7 @@ namespace AcDream.App.Rendering.Wb {
uvIdx = 0;
}
var key = (vertId, uvIdx, useNegSurface);
var key = (vertId, uvIdx, invertNormal);
if (!hasWrappingUVs) {
var uvCheck = vertex.UVs.Count > 0
@ -1553,7 +1568,7 @@ namespace AcDream.App.Rendering.Wb {
: Vector2.Zero;
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
if (useNegSurface) {
if (invertNormal) {
normal = -normal;
}
@ -1568,18 +1583,18 @@ namespace AcDream.App.Rendering.Wb {
polyIndices.Add(idx);
}
if (useNegSurface) {
if (reverseWinding) {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
}
}
else {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
}
}
}

View file

@ -144,8 +144,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root /
// no portal frame), every instance maps to slot 0 (no-clip) and no instance is
// culled — identical to U.3. When active, each instance's slot is resolved by
// ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to
// their cell slot; outdoor scenery to the OutsideView slot; non-visible culled).
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
private bool _clipRoutingActive;
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
private int _outdoorSlot;
@ -310,8 +310,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
/// non-null; the next <see cref="Draw"/> resolves each instance's binding=3
/// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their
/// cell slot, outdoor scenery to the OutsideView slot, non-visible culled).
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
/// dispatcher reverts to the U.3 no-clip-everything behavior.
/// </summary>
@ -354,12 +354,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
/// <list type="bullet">
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
/// cell isn't in <paramref name="cellIdToSlot"/> (not visible / nothing-visible).</item>
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
/// slot when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
/// </list>
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
/// path every instance is slot 0 and nothing is culled — see
@ -385,20 +383,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int outdoorSlot,
bool outdoorVisible)
{
// Live-dynamic entities render unclipped regardless of cell — retail draws
// the player / NPCs / dropped items through the depth buffer without portal
// clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated).
if (serverGuid != 0)
return 0;
// Live-dynamic entities are not a global indoor overlay. When they
// have current cell ownership, route them through the same visible
// cell/OutsideView graph as every other object. Parentless live objects
// are unresolved indoors, so cull them while clip routing is active.
if (parentCellId is uint parentCell)
return cellIdToSlot.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull;
{
if (IsIndoorCellId(parentCell))
{
if (!cellIdToSlot.ContainsKey(parentCell))
return ClipSlotCull;
return cellIdToSlot[parentCell];
}
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
if (serverGuid != 0)
return ClipSlotCull;
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
// the OutsideView slot, or cull when nothing outdoors is visible.
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
/// <summary>
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps