feat(render): Phase A8 — indoor visibility + streaming fixes batch

Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 10:14:50 +02:00
parent e415bb3863
commit 5dc4140c11
38 changed files with 3965 additions and 277 deletions

View file

@ -23,7 +23,6 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using DatReaderWriter.Enums;
@ -70,6 +69,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private int _modernInstanceCapacity = 1024;
private uint _modernBatchBuffer;
private int _modernBatchCapacity = 1024;
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
// InstanceData below also carries CellId/Flags for filtering, so upload a
// packed transform array instead of the 80-byte CPU struct.
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
@ -193,7 +196,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.GenBuffers(1, out _modernInstanceBuffer);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
// Per-batch data SSBO (binding=1)
_gl.GenBuffers(1, out _modernBatchBuffer);
@ -464,11 +467,30 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// Call once per frame, before <see cref="Render"/>.
/// Source: WB EnvCellRenderManager.cs:247-373 (verbatim).
/// </summary>
public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? filter = null)
public void PrepareRenderBatches(
Matrix4x4 viewProjection,
Vector3 cameraPosition,
HashSet<uint>? filter = null,
int? centerLbX = null,
int? centerLbY = null,
int? renderRadius = null)
{
// WB EnvCellRenderManager.cs:249-250:
if (!_initialized || cameraPosition.Z > 4000) return;
if (filter is { Count: 0 })
{
lock (_renderLock)
{
_poolIndex = 0;
_activeSnapshot = new EnvCellVisibilitySnapshot();
_activeSnapshotGlobalGroups = new Dictionary<ulong, List<InstanceData>>();
_activeSnapshotGlobalGfxObjIds = new List<ulong>();
NeedsPrepare = false;
}
return;
}
// WB EnvCellRenderManager.cs:251-253:
lock (_renderLock) { _poolIndex = 0; }
@ -479,8 +501,19 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// Filter loaded landblocks by GpuReady + Instances non-empty.
var landblocks = new List<EnvCellLandblock>();
foreach (var lb in _landblocks.Values)
{
if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue)
{
if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value ||
Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value)
{
continue;
}
}
if (lb.GpuReady && lb.Instances.Count > 0)
landblocks.Add(lb);
}
if (landblocks.Count == 0) return;
// WB EnvCellRenderManager.cs:265-267:
@ -815,32 +848,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindVertexArray(0);
_currentVao = 0;
// Phase A8 (2026-05-28 visual-gate-#4 follow-up): NO cull-restore
// at exit. The Landblock→None override can leave cull DISABLED
// if the last batch was Landblock — and that's intentional: the
// subsequent `dispatcher.Draw(IndoorPass)` call in
// RenderInsideOutAcdream's Step 3 wants cull-off too, because
// AC's cottage-shell GfxObj parts (the wooden floor planks +
// wall slabs that the player walks on / through) have winding
// that gets back-face-culled by the dispatcher's default
// FrontFace=CCW. Letting cull stay off through IndoorPass
// renders both shell and cell mesh double-sided, so floors are
// visible from above (and inverted-front-facing wall slabs are
// visible from inside the room). Step 4's
// `gl.Enable(EnableCap.CullFace)` (line ~10768) + the cleanup
// block's enable (line ~10870) re-establish cull-back before
// LiveDynamic chars / NPCs / doors render — so those still
// look solid (no see-through head). The static `_currentVao`
// is reset because the next Render call's batch loop needs to
// re-issue BindVertexArray regardless; `_currentCullMode` is
// intentionally left at None so the cache matches actual GL
// state until the next Render call's per-batch SetCullMode
// either confirms or re-sets it.
//
// The retail-faithful long-term move is matching WB's
// glFrontFace(CW) globally (GameScene.cs:843) so cull-back
// selects the correct side for AC's polygon winding without
// double-sided rendering — deferred until a wider audit.
// No cull restore at exit, matching WB's manager pattern: the
// last SetCullMode call reflects actual GL state, and the next
// Render call invalidates `_currentCullMode` before issuing its
// own per-batch state. The Landblock->None override below can
// intentionally leave cull disabled for the following IndoorPass,
// preserving the shipped Gate #5 baseline while deeper evidence is
// gathered.
// Update frame stats for probe emission at the call site.
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
@ -932,7 +946,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
}
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
@ -977,12 +991,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
var instancesSpan = CollectionsMarshal.AsSpan(allInstances);
fixed (InstanceData* ptr = instancesSpan)
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
for (int i = 0; i < uniqueInstanceCount; i++)
_gpuInstanceTransforms[i] = allInstances[i].Transform;
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), ptr);
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
@ -1014,23 +1031,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
foreach (var group in batchesByCullMode)
{
var cullMode = (CullMode)(group.Key % 4);
// Phase A8 fix (2026-05-28 visual-gate-#3 evidence): override
// CullMode.Landblock to None for cell-mesh batches. WB sets
// glFrontFace(CW) globally (GameScene.cs:843) so its CullMode
// mapping (Landblock→Back) culls the correct side; we set
// glFrontFace(CCW) in WbDrawDispatcher (line 1056) so the
// mapping would cull the OPPOSITE side, hiding cell floors.
// Cell-mesh polys with CullMode.Landblock represent the floor +
// walls + ceiling of a single room — they face different
// directions but share one CullMode value, so a single cull
// setting can't be correct for all of them. The retail-faithful
// approach is double-sided rendering for cell polys (cull off),
// matching what the cull-disable A/B diagnostic empirically
// confirmed (floor visible with cull off in visual-gate-#3).
// CullMode.Landblock is only ever assigned in this codebase by
// PrepareCellStructMeshData (cell polys) — terrain has its own
// renderer that doesn't go through this code path — so this
// override is scoped exactly right.
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
// uniformly, but the room surfaces need to be visible from inside
// under acdream's current global winding state. Render cell polys
// double-sided while the architectural cause is isolated.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
if (_currentCullMode != cullMode)
{