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:
parent
bff1955066
commit
1405dd8e90
27 changed files with 3635 additions and 814 deletions
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue