fix(render): Phase A8 — pool aliasing in EnvCellRenderer (visual chaos root cause)
The post-Wave-5 indoor branch chaos (flickering, missing walls, GPU 100%, ~10 FPS) is caused by two interconnected pool-management bugs in EnvCellRenderer that line-by-line WB comparison surfaced in 30 minutes. Neither was found by the five post-Wave-5 speculative fixes because none of them inspected the pool path. Bug #1 — GetPooledList missing list.Clear(): The reuse branch returned pool lists with prior-frame data still inside. PrepareRenderBatches' merge phase pattern `gfxDict[k] = list; list.AddRange(...)` assumes empty lists. Without Clear(), lists grow unbounded each frame, GPU draws cumulative instance counts, and per-instance transforms become a stew of past + present data. Mirrors WB ObjectRenderManagerBase.cs:1221-1233. Bug #2 — Render uses snapshot.BatchedByCell.Count instead of PostPreparePoolIndex: The snapshot author dropped WB's PostPreparePoolIndex field calling it "scenery-only," then "compensated" in Render by setting _poolIndex to the cell count. The cell count has no relation to the pool — Prepare may have used 50+ pool lists for an 18-cell scene. Render's filter-path GetPooledList then returns lists that ARE in snapshot.BatchedByCell, corrupting the snapshot mid-Render. Restoring PostPreparePoolIndex (WB VisibilitySnapshot.cs:31) correctly places Render's pool cursor past the snapshot's owned region. Bug #3 (minor) — PopulateRecursive hardcoded isSetup:false for nested parts: Setup IDs use high-byte 0x02 (per retail). WB ObjectRenderManagerBase.cs:813 checks `(partId >> 24) == 0x02` to detect nested Setups. Our port always passed isSetup:false, silently dropping any nested Setup (its TryGetRenderData returns IsSetup=true, Render's `!IsSetup` guard skips the draw). Probably rare in EnvCells but fixed for completeness. Regression coverage: - GetPooledList_ReusedList_IsClearedBeforeReturn — would have failed pre-fix - GetPooledList_FreshList_IsAlwaysEmpty — sanity check - Snapshot_PostPreparePoolIndex_IsInitSettable — compile-time guarantee - Snapshot_PostPreparePoolIndex_DefaultsToZero — defensive default 86/86 App tests pass. Build green. The fix is the audit's primary deliverable; the GL state probe option-1 apparatus follows in a separate commit as defense-in-depth for any unidentified residual issue. Full audit + WB cross-reference in docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d0ffaa794
commit
9559726960
4 changed files with 408 additions and 9 deletions
|
|
@ -352,10 +352,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
|
||||
foreach (var (partId, partTransform) in rd.SetupParts)
|
||||
{
|
||||
// Each Setup part is a GfxObj — not a nested Setup.
|
||||
// Compose: world = partTransform (relative to setup) * setup world transform.
|
||||
//
|
||||
// FIX 2026-05-28: detect nested Setups via the high-byte
|
||||
// 0x02 convention (Setup IDs start with 0x02; plain GfxObj
|
||||
// IDs start with 0x01). Mirrors WB
|
||||
// ObjectRenderManagerBase.cs:813. Original port hardcoded
|
||||
// isSetup:false which silently dropped any nested Setup —
|
||||
// its TryGetRenderData returns IsSetup=true, and Render's
|
||||
// `if (!renderData.IsSetup)` guard then skips the draw.
|
||||
var combined = partTransform * transform;
|
||||
PopulateRecursive(group, partId, isSetup: false, combined, cellId);
|
||||
bool partIsSetup = (partId >> 24) == 0x02UL;
|
||||
PopulateRecursive(group, partId, partIsSetup, combined, cellId);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -518,12 +526,21 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
}
|
||||
|
||||
// WB EnvCellRenderManager.cs:361-372: atomic swap under _renderLock.
|
||||
//
|
||||
// FIX 2026-05-28 (pool aliasing root cause): capture _poolIndex's
|
||||
// high-water mark from the merge phase into the snapshot's
|
||||
// PostPreparePoolIndex BEFORE the reset to 0. Render reads it back
|
||||
// to set its pool cursor past the snapshot's owned lists. Without
|
||||
// this capture, Render's filter-path GetPooledList returns lists
|
||||
// the snapshot is still referencing, corrupting per-frame instance
|
||||
// data. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||||
lock (_renderLock)
|
||||
{
|
||||
_activeSnapshot = new EnvCellVisibilitySnapshot
|
||||
{
|
||||
BatchedByCell = newBatchedByCell,
|
||||
VisibleLandblocks = landblocks,
|
||||
BatchedByCell = newBatchedByCell,
|
||||
VisibleLandblocks = landblocks,
|
||||
PostPreparePoolIndex = _poolIndex,
|
||||
// VisibleGroups / VisibleGfxObjIds are stored as extra fields below.
|
||||
};
|
||||
// Stash the global groups on the snapshot for use in the unfiltered render path.
|
||||
|
|
@ -611,7 +628,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
var snapshot = _activeSnapshot;
|
||||
// WB EnvCellRenderManager.cs:403-404:
|
||||
_shader.Use();
|
||||
_poolIndex = snapshot.BatchedByCell.Count; // reset point (mirrors WB line 405)
|
||||
// FIX 2026-05-28 (pool aliasing root cause): mirror WB
|
||||
// EnvCellRenderManager.cs:405 — restore the pool cursor to the
|
||||
// high-water mark Prepare's merge phase reached, so any
|
||||
// GetPooledList calls below return lists past the snapshot's
|
||||
// owned region. Original code used `snapshot.BatchedByCell.Count`
|
||||
// (number of cells, e.g. 18) which has no relation to the pool
|
||||
// index and pointed back into snapshot data, corrupting it
|
||||
// mid-Render. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||||
_poolIndex = snapshot.PostPreparePoolIndex;
|
||||
|
||||
// FIX 2026-05-28: invalidate static GL-state caches at start of Render.
|
||||
// Mirrors WB EnvCellRenderManager.cs:404-410:
|
||||
|
|
@ -958,9 +983,22 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
|
||||
private List<InstanceData> GetPooledList()
|
||||
{
|
||||
// Mirrors WB ObjectRenderManagerBase.cs:1221-1233 — the reuse
|
||||
// branch MUST clear the list before returning. PrepareRenderBatches'
|
||||
// merge phase pattern is `gfxDict[k] = list; list.AddRange(...)`,
|
||||
// which assumes the list is empty. Without the clear, lists grow
|
||||
// unbounded across frames and each frame's draw includes all prior
|
||||
// frames' stale data. Original port omitted the Clear() call — root
|
||||
// cause of post-Wave-5 visual chaos (FIX 2026-05-28). See
|
||||
// docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||||
lock (_listPool)
|
||||
{
|
||||
if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++];
|
||||
if (_poolIndex < _listPool.Count)
|
||||
{
|
||||
var list = _listPool[_poolIndex++];
|
||||
list.Clear();
|
||||
return list;
|
||||
}
|
||||
var fresh = new List<InstanceData>();
|
||||
_listPool.Add(fresh);
|
||||
_poolIndex++;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// WB's <c>VisibilitySnapshot</c> at
|
||||
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:1-36</c>,
|
||||
/// narrowed to the fields <see cref="EnvCellRenderer"/> actually consumes
|
||||
/// (<c>BatchedByCell</c> + <c>VisibleLandblocks</c>). The scenery-side
|
||||
/// <c>VisibleGroups</c> / <c>VisibleGfxObjIds</c> / <c>IntersectingLandblocks</c>
|
||||
/// / <c>PostPreparePoolIndex</c> are dropped — we render scenery through
|
||||
/// (<c>BatchedByCell</c> + <c>VisibleLandblocks</c> + <c>PostPreparePoolIndex</c>).
|
||||
/// The scenery-side <c>VisibleGroups</c> / <c>VisibleGfxObjIds</c> /
|
||||
/// <c>IntersectingLandblocks</c> are dropped — we render scenery through
|
||||
/// <see cref="WbDrawDispatcher"/>, not through this snapshot.
|
||||
///
|
||||
/// <para>Used as an immutable snapshot atomically swapped under the
|
||||
|
|
@ -30,6 +30,19 @@ public sealed class EnvCellVisibilitySnapshot
|
|||
/// </summary>
|
||||
public Dictionary<uint, Dictionary<ulong, List<InstanceData>>> BatchedByCell { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pool-index high-water mark after PrepareRenderBatches' merge phase.
|
||||
/// Mirrors WB <c>VisibilitySnapshot.PostPreparePoolIndex</c> at
|
||||
/// <c>references/WorldBuilder/.../VisibilitySnapshot.cs:31</c>.
|
||||
/// <para>Read by <see cref="EnvCellRenderer.Render"/> to set the pool
|
||||
/// cursor to a safe region past the snapshot's owned lists, so any
|
||||
/// <c>GetPooledList</c> calls inside Render don't trample data the
|
||||
/// snapshot still references. Dropping this field caused the post-Wave-5
|
||||
/// visual chaos — see
|
||||
/// <c>docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md</c>.</para>
|
||||
/// </summary>
|
||||
public int PostPreparePoolIndex { get; init; }
|
||||
|
||||
/// <summary>True when no visible cells were produced this prepare cycle.</summary>
|
||||
public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue