fix(render): #105 white indoor walls — restore WB's per-frame staged-texture flush dropped in the N.4/O-T4 extraction

Root cause: TextureAtlasManager.AddTexture only STAGES texture content (PBO
write + ManagedGLTextureArray._pendingUpdates); the actual TexSubImage3D
copies + mipmap regeneration happen in ProcessDirtyUpdates, which WB drives
once per frame via ObjectMeshManager.GenerateMipmaps() from its render loop
(WB GameScene.cs:975, just before the opaque pass). GameScene is the file we
replaced with GameWindow, so the call site was silently dropped — staged
updates only reached the GPU as a side effect of PBO growth (UpdateLayerInternal
flushes pending updates before orphaning the PBO). Every layer staged after an
array's LAST growth kept undefined TexStorage3D content behind a valid,
resident bindless sampler handle: white/garbage walls, zh==0, dat tripwires
silent — exactly the #105 signature. Only ObjectRenderBatch.BindlessTextureHandle
consumers are affected (EnvCellRenderer cell shells = indoor walls); entities
resolve via TextureCache (immediate TexImage2D) and terrain via TerrainAtlas
(immediate GenerateMipmap), which is why only indoor walls ever struck.

Fix: WbMeshAdapter.Tick() now calls _meshManager.GenerateMipmaps() after the
staged-upload drain — Tick runs before all draw passes (GameWindow OnRender),
the exact WB-equivalent position.

Evidence (ACDREAM_PROBE_TEXFLUSH=1 apparatus, kept env-gated):
- pre-fix (texflush-prefix.log): pending updates climb 0->48->...->142 and
  park at 126 across 34/34 atlas arrays at standstill, forever (19 heartbeats);
  brief dips only at PBO-growth crossings — the broken contract live.
- post-fix (texflush-postfix.log): every line after=0 — staged updates drain
  the same frame, all 34 arrays clean.

Intermittency explained: background decode-completion order shuffles which
textures land in the never-flushed tail; whether a visible wall samples one is
per-run luck. Also explains the #110 correlation: znear=0.1 makes close-up
geometry newly visible -> more prepare/upload pressure indoors -> bigger tail
-> higher strike probability. The near plane is mechanism-innocent (re-land
follows as its own commit).

Baseline maintained: App 223 / UI 420 / Net 294 / Core 1377 green + 4
pre-existing #99-era failures + 1 skip; CornerFloodReplayTests (5) and
CameraCornerSealReplayTests (2) gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-10 12:10:00 +02:00
parent 5d63038b61
commit c78720127a
4 changed files with 92 additions and 0 deletions

View file

@ -41,6 +41,16 @@ namespace AcDream.App.Rendering.Wb {
public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize();
/// <summary>
/// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
/// </summary>
public int PendingUpdateCount {
get { lock (_mipmapLock) { return _pendingUpdates.Count; } }
}
public ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height,
int size, ILogger logger, TextureParameters? texParams = null) {
var p = texParams ?? TextureParameters.Default;

View file

@ -301,6 +301,23 @@ namespace AcDream.App.Rendering.Wb {
}
}
/// <summary>
/// #105 diagnostic: counts staged-but-unflushed texture layer updates across all
/// shared atlases (see <see cref="ManagedGLTextureArray.PendingUpdateCount"/>).
/// Render thread only — <c>_globalAtlases</c> is render-thread-owned.
/// </summary>
public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats() {
int pending = 0, arraysWith = 0, total = 0;
foreach (var atlasList in _globalAtlases.Values) {
foreach (var atlas in atlasList) {
total++;
int p = atlas.TextureArray.PendingUpdateCount;
if (p > 0) { arraysWith++; pending += p; }
}
}
return (pending, arraysWith, total);
}
/// <summary>
/// Decrement reference count and unload GPU resources if no longer needed.
/// </summary>

View file

@ -212,6 +212,56 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{
_meshManager.UploadMeshData(meshData);
}
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe
? _meshManager.GetPendingTextureUpdateStats()
: default;
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
// texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the
// actual TexSubImage3D copies + mipmap regeneration happen in
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
// extraction replaced with GameWindow, so the driver was silently dropped:
// staged updates only ever reached the GPU as a side effect of PBO growth,
// and every layer staged after an array's LAST growth kept undefined
// TexStorage3D content behind a valid resident bindless handle — the
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
// runs before all draw passes (GameWindow OnRender), so this is the exact
// WB-equivalent position.
_meshManager.GenerateMipmaps();
if (texProbe)
EmitTexFlushProbe(pendingBefore);
}
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
private int _lastTexFlushBefore = -1;
private int _texFlushHeartbeat;
/// <summary>
/// #105 apparatus: one <c>[tex-flush]</c> line on change of the staged-texture
/// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy
/// frame ends with <c>after=0</c>; <c>before==after&gt;0</c> persisting at
/// standstill is the white-walls mechanism live (staged uploads never applied).
/// </summary>
private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before)
{
var after = _meshManager!.GetPendingTextureUpdateStats();
bool changed = before.PendingUpdates != _lastTexFlushBefore;
bool flushed = after.PendingUpdates != before.PendingUpdates;
bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600;
if (!changed && !flushed && !heartbeat) return;
_texFlushHeartbeat = 0;
_lastTexFlushBefore = before.PendingUpdates;
Console.WriteLine(
$"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" +
$" arrays={after.ArraysWithPending}/{after.TotalArrays}" +
$" (arraysBefore={before.ArraysWithPending})");
}
private void PopulateMetadata(ulong id)

View file

@ -172,6 +172,21 @@ public static class RenderingDiagnostics
public static bool ProbeClipRouteEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CLIPROUTE") == "1";
/// <summary>
/// #105 white-indoor-textures apparatus (2026-06-10). When true, <c>WbMeshAdapter.Tick</c>
/// emits one <c>[tex-flush]</c> line whenever the staged-texture-update picture changes:
/// pending layer updates across all shared atlases BEFORE and AFTER the per-frame
/// <c>ObjectMeshManager.GenerateMipmaps()</c> flush, plus arrays-with-pending / total-array
/// counts. The broken contract this pins: <c>TextureAtlasManager.AddTexture</c> only STAGES
/// pixel data (PBO + pending list); without the per-frame flush (WB GameScene.cs:975) the
/// data never reaches the GL texture and the batch samples undefined content behind a valid
/// bindless handle — the classic white walls. A healthy run shows <c>after=0</c> on every
/// line; a stuck <c>before==after&gt;0</c> at standstill is the #105 mechanism live.
/// Initial state from <c>ACDREAM_PROBE_TEXFLUSH=1</c>.
/// </summary>
public static bool ProbeTexFlushEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_TEXFLUSH") == "1";
/// <summary>
/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits
/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues,