acdream/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
Erik 0013819fa1 docs(render): ARCHITECTURE RESET — indoor render is a 3-gate patchwork; handoff + unified-PView target
A week on the indoor render (Phase U.4 → U.4c → 2026-05-31) fixed the flap but
produced NO shippable progress: walls/ceiling don't seal, outdoor terrain is
visible from inside (#78), the enclosure reads grey/transparent. Root cause is
ARCHITECTURAL, not a bug.

Evidence this session (direct, via the new [shell] probe + screenshots) RULED OUT
every subsystem except the gating architecture: the interior cell shells render
fine (geometry/texture/opaque/depth all correct, zh=0 tr=0); the visibility
traversal computes correct sets + non-empty portal clips; cull mode is fine; the
camera/eye thread was a detour. The residual is that OUTDOOR geometry is not gated
to portal openings when indoors, and acdream enforces visibility THREE inconsistent
ways (TerrainClipMode / per-cell shell clip / entity ParentCellId filter with an
outdoor-stab bypass) instead of retail's ONE PView gate.

This commit is the reset handoff + documentation, not a code fix:
- docs/research/2026-05-31-render-architecture-reset-handoff.md — canonical: honest
  state, evidence ledger (ruled-out / do-not-repeat), the mapped 3-gate patchwork,
  the retail PView target (one traversal → one gate for ALL geometry), the reset
  mission, and a copy-paste pickup prompt.
- docs/architecture/acdream-architecture.md — new "Render Pipeline" SSOT section
  (current divergence + unified-PView target + the one rule: compute visibility
  once, enforce it once). (Doc has pre-existing corruption below this section —
  flagged for separate cleanup.)
- Apparatus: ACDREAM_PROBE_SHELL → [shell] (EnvCellRenderer per-cell prepared/drawn
  geometry + flags) added to RenderingDiagnostics + EnvCellRenderer. Throwaway.
- docs/superpowers/specs/2026-05-31-camera-collision-indoor-engagement-design.md —
  spec for e099b4c (camera collision; now parked as orthogonal to the seam).

Next session: STOP point-fixing; do the architecture reset to a single PView gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:35:55 +02:00

1413 lines
65 KiB
C#

// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the
// production cell-rendering pipeline for indoor visibility, replacing the
// broken "cell as WorldEntity with MeshRef(envCellId)" approach that the
// four reverted RR7 variants couldn't fix.
//
// Sources ported byte-for-byte:
// GetEnvCellGeomId <- WB EnvCellRenderManager.cs:94-103
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580
// AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393
//
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
// class owns the landblock-streaming loop (Update, _pendingGeneration,
// _uploadQueue). acdream's StreamingController already does that work —
// running a parallel loop would compete for dat I/O. Instead, we expose
// RegisterCell(...) as the seam: callers populate our instance store at
// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming).
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
public sealed unsafe class EnvCellRenderer : IDisposable
{
private readonly GL _gl;
private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum;
// Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000).
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks —
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
// Active snapshot (atomic swap under _renderLock).
// WB EnvCellRenderManager.cs:71: private VisibilitySnapshot _activeSnapshot = new();
private readonly object _renderLock = new();
private EnvCellVisibilitySnapshot _activeSnapshot = new();
// Shader (set by caller via Initialize).
// Uses acdream's legacy Shader type (not WB's GLSLShader) to match the
// existing wire-in pattern in GameWindow.cs where _meshShader is loaded
// for mesh_modern.{vert,frag} and shared across multiple consumers.
// API mapping: Bind() -> Use(), SetUniform(s, int) -> SetInt(s, int),
// SetUniform(s, Vector4) -> SetVec4(s, Vector4).
private AcDream.App.Rendering.Shader? _shader;
// Phase U.4 root-cause fix: the view-projection captured in PrepareRenderBatches,
// re-uploaded by Render() so the cell-shell pass is self-contained and does NOT
// inherit WbDrawDispatcher's uViewProjection (which the opaque pass would read one
// frame stale, since it draws BEFORE the dispatcher's upload). Same matrix the
// portal clip planes are computed with (envCellViewProj).
private Matrix4x4 _lastViewProjection = Matrix4x4.Identity;
private bool _initialized;
// List pool — copied from WB ObjectRenderManagerBase.
// WB ObjectRenderManagerBase.cs:83-86: protected readonly List<List<InstanceData>> _listPool = new(); protected int _poolIndex = 0;
private readonly List<List<InstanceData>> _listPool = new();
private int _poolIndex = 0;
// Modern-MDI scratch buffers (single slot — we re-upload every frame).
// WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers
// We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws.
private uint _mdiCommandBuffer;
private int _mdiCommandCapacity = 1024;
private uint _modernInstanceBuffer;
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>();
// Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
// _modernInstanceBuffer. One uint per instance selecting its CellClip slot,
// indexed by the same BaseInstance + gl_InstanceID the shader uses for
// binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
private uint _clipSlotBuffer;
private uint[] _clipSlotData = Array.Empty<uint>();
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
private uint _sharedClipRegionSsbo;
private uint _fallbackClipRegionSsbo;
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
private DrawElementsIndirectCommand[] _commands = Array.Empty<DrawElementsIndirectCommand>();
private ModernBatchData[] _modernBatches = Array.Empty<ModernBatchData>();
// Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28.
// Shared across all manager instances on the same GL context.
private static uint _currentVao;
private static CullMode? _currentCullMode;
public bool NeedsPrepare { get; private set; } = true;
public bool IsDisposed { get; private set; }
public LastFrameStats Stats => _lastFrameStats;
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
private LastFrameStats _lastFrameStats;
/// <summary>
/// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28).
/// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water).
/// A divergence between expected and actual values would indicate a pool-
/// management regression — exactly the bug class the 2026-05-28 audit caught.
/// </summary>
public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics()
{
int poolTotal;
lock (_listPool) poolTotal = _listPool.Count;
int hwm;
lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex;
return (poolTotal, hwm);
}
/// <summary>
/// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up).
/// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes +
/// transparency flags + bindless-handle-non-zero status, so the operator
/// can read offline and identify why specific polys (e.g., floors) aren't
/// rendering. Set <c>ACDREAM_A8_AUDIT=1</c> to enable.
/// <para>Returns a deduplicated audit-line list per Render snapshot
/// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller
/// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs
/// have already been logged.</para>
/// </summary>
public IReadOnlyList<string> CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged)
{
var lines = new List<string>();
lock (_renderLock)
{
var snap = _activeSnapshot;
foreach (var (cellId, gfxDict) in snap.BatchedByCell)
{
foreach (var (gfxObjId, transforms) in gfxDict)
{
var key = (cellId, gfxObjId);
if (alreadyLogged.Contains(key)) continue;
alreadyLogged.Add(key);
var rd = _meshManager.TryGetRenderData(gfxObjId);
if (rd is null)
{
lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null");
continue;
}
int totalIdx = 0;
var cullModes = new HashSet<DatReaderWriter.Enums.CullMode>();
int translucent = 0;
int additive = 0;
int zeroHandle = 0;
foreach (var b in rd.Batches)
{
totalIdx += b.IndexCount;
cullModes.Add(b.CullMode);
if (b.IsTransparent) translucent++;
if (b.IsAdditive) additive++;
if (b.BindlessTextureHandle == 0) zeroHandle++;
}
var cullList = string.Join(",", cullModes);
lines.Add(
$"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " +
$"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " +
$"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}");
}
}
}
return lines;
}
// ---------------------------------------------------------------------------
// Constructor + Initialize
// ---------------------------------------------------------------------------
public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum)
{
_gl = gl;
_meshManager = meshManager;
_frustum = frustum;
}
public void Initialize(AcDream.App.Rendering.Shader shader)
{
_shader = shader;
AllocateMdiBuffers();
_initialized = true;
}
// ---------------------------------------------------------------------------
// AllocateMdiBuffers
// Mirrors WB BaseObjectRenderManager.cs:62-127 (slot-0 only).
// ---------------------------------------------------------------------------
private void AllocateMdiBuffers()
{
// MDI command buffer (DrawIndirectBuffer)
_gl.GenBuffers(1, out _mdiCommandBuffer);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.BufferData(GLEnum.DrawIndirectBuffer,
(nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw);
// Per-frame scratch instance SSBO (binding=0)
_gl.GenBuffers(1, out _modernInstanceBuffer);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
// Per-batch data SSBO (binding=1)
_gl.GenBuffers(1, out _modernBatchBuffer);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
// Phase U.3: per-instance clip-slot SSBO (binding=3), sized to the
// instance capacity. Uploaded all-zeros each frame in RenderModernMDIInternal.
_gl.GenBuffers(1, out _clipSlotBuffer);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
/// <summary>
/// Phase U.3: hand the renderer the SHARED per-cell clip-region SSBO
/// (binding=2) created by <see cref="ClipFrame.UploadShared"/>. The renderer
/// re-binds it to binding=2 immediately before its MDI. Pass 0 to fall back to
/// the internal one-slot no-clip region buffer.
/// </summary>
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
// Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When
// non-null, RenderModernMDIInternal writes instanceClipSlot[i] =
// _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are
// gated to that cell's portal-clip region. When null (U.3 path), every
// instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0
// (no-clip) — but the caller's Render filter already restricts the draw to the
// map's keys, so that fallback should not fire in practice.
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
/// <summary>
/// Phase U.4: install the per-frame cellId→slot map used to gate cell shells
/// to their portal-clip regions. Call once per frame BEFORE
/// <see cref="Render(WbRenderPass, HashSet{uint}?)"/>. Pass null to revert to
/// the U.3 no-clip behavior (every shell instance → slot 0).
/// </summary>
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
=> _cellIdToSlot = cellIdToSlot;
// ---------------------------------------------------------------------------
// GetEnvCellGeomId
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
// ---------------------------------------------------------------------------
/// <summary>
/// Returns a deduplicated geometry ID for an EnvCell based on its environment,
/// cell structure index, and surface IDs. Bit 33 is set to distinguish from
/// per-cell IDs (which use bit 32).
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
/// </summary>
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces)
{
// WB EnvCellRenderManager.cs:95-102 verbatim:
var hash = 17L;
hash = hash * 31 + (int)environmentId;
hash = hash * 31 + cellStructure;
foreach (var surface in surfaces) {
hash = hash * 31 + surface;
}
// Use bit 33 to indicate deduplicated EnvCell geometry (to avoid collision with bit 32 per-cell geometry)
return (ulong)hash | 0x2_0000_0000UL;
}
// ---------------------------------------------------------------------------
// RegisterCell — streaming seam
// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
// time, ONCE per EnvCell that has non-null EnvironmentId.
// ---------------------------------------------------------------------------
/// <summary>
/// Registers a single EnvCell and its static objects into the per-landblock
/// pending list. Call <see cref="FinalizeLandblock"/> after all cells for a
/// landblock have been registered.
/// </summary>
public void RegisterCell(
uint landblockId,
uint envCellId,
DatReaderWriter.DBObjs.EnvCell envCell,
DatReaderWriter.Types.CellStruct cellStruct,
Matrix4x4 cellTransform,
Vector3 cellWorldPosition,
Quaternion cellRotation,
IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects)
{
// 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId).
var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces);
// 2. Trigger mesh prep for the cell geometry on ObjectMeshManager.
// This populates ObjectMeshManager._renderData[cellGeomId] when complete.
_ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId,
envCell.CellStructure, envCell.Surfaces);
// 3. Compute local bounds from cellStruct vertex array.
var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct);
var worldBounds = TransformBoundingBox(localBounds, cellTransform);
// 4. Create the per-cell SceneryInstance.
var cellInstance = new EnvCellSceneryInstance
{
ObjectId = cellGeomId,
InstanceId = envCellId, // uint InstanceId maps to the cell id
IsBuilding = true,
IsEntryCell = false, // could be derived from entry-portal walk; default false
WorldPosition = cellWorldPosition,
LocalPosition = Vector3.Zero,
Rotation = cellRotation,
Scale = Vector3.One,
Transform = cellTransform,
LocalBoundingBox = localBounds,
BoundingBox = worldBounds,
};
var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock
{
GridX = (int)((id >> 24) & 0xFFu),
GridY = (int)((id >> 16) & 0xFFu),
});
lock (lb.Lock)
{
lb.PendingInstances ??= new List<EnvCellSceneryInstance>(capacity: 32);
lb.PendingInstances.Add(cellInstance);
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
lb.PendingEnvCellBounds[envCellId] = worldBounds;
// Add static-object instances inside the cell.
foreach (var stab in staticObjects)
{
// Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes).
_ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup);
WbBoundingBox stabBoundsLocal;
var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup);
if (rawBounds.HasValue)
stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max);
else
stabBoundsLocal = new WbBoundingBox(Vector3.Zero, Vector3.Zero);
var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform);
lb.PendingInstances.Add(new EnvCellSceneryInstance
{
ObjectId = stab.StaticObjectId,
InstanceId = 0,
IsSetup = stab.IsSetup,
IsBuilding = false,
Transform = stab.Transform,
BoundingBox = stabBoundsWorld,
LocalBoundingBox = stabBoundsLocal,
Scale = Vector3.One,
Rotation = stab.Rot,
// CurrentPreviewCellId = 0; the per-cell CellId is written during PopulatePartGroups
CurrentPreviewCellId = envCellId, // carry the parent cellId for PartGroups dispatch
});
// Union the cell bounds with the stab bounds.
var current = lb.PendingEnvCellBounds[envCellId];
lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld);
}
}
}
/// <summary>
/// Atomically promotes the pending instance list to the committed list,
/// computes total EnvCell bounds, populates PartGroups, and marks the
/// landblock ready for rendering.
/// </summary>
public void FinalizeLandblock(uint landblockId)
{
if (!_landblocks.TryGetValue(landblockId, out var lb)) return;
lock (lb.Lock)
{
if (lb.PendingInstances is not null)
{
lb.Instances = lb.PendingInstances;
lb.PendingInstances = null;
}
if (lb.PendingEnvCellBounds is not null)
{
lb.EnvCellBounds = lb.PendingEnvCellBounds;
lb.PendingEnvCellBounds = null;
}
// Compute total bounds union across all cells.
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
foreach (var b in lb.EnvCellBounds.Values)
total = WbBoundingBox.Union(total, b);
lb.TotalEnvCellBounds = total;
// Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580).
PopulatePartGroups(lb);
lb.InstancesReady = true;
lb.MeshDataReady = true;
lb.GpuReady = true;
}
NeedsPrepare = true;
}
/// <summary>
/// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
_landblocks.TryRemove(landblockId, out _);
NeedsPrepare = true;
}
// ---------------------------------------------------------------------------
// PopulatePartGroups
// Verbatim port of WB EnvCellRenderManager.cs:572-580.
// ---------------------------------------------------------------------------
/// <summary>
/// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list.
/// Must be called under lb.Lock.
/// Source: WB EnvCellRenderManager.cs:572-580 (verbatim).
/// </summary>
private void PopulatePartGroups(EnvCellLandblock lb)
{
// WB EnvCellRenderManager.cs:573-574:
lb.StaticPartGroups.Clear();
lb.BuildingPartGroups.Clear();
// WB EnvCellRenderManager.cs:575-579:
foreach (var instance in lb.Instances)
{
var targetGroup = instance.IsBuilding ? lb.BuildingPartGroups : lb.StaticPartGroups;
// WB: var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId.DataId;
// We store the parent cellId in CurrentPreviewCellId (for stabs) or InstanceId (for cell geometry).
var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId;
PopulateRecursive(targetGroup, instance.ObjectId, instance.IsSetup, instance.Transform, cellId);
}
}
/// <summary>
/// Recursively walks a Setup's parts (or handles a plain GfxObj directly),
/// adding one <see cref="InstanceData"/> per leaf GfxObj to the target group.
/// Mirrors WB ObjectRenderManagerBase.PopulateRecursive.
/// </summary>
private void PopulateRecursive(
Dictionary<ulong, List<InstanceData>> group,
ulong objectId,
bool isSetup,
Matrix4x4 transform,
uint cellId)
{
if (isSetup)
{
// For a Setup, TryGetRenderData returns a record whose SetupParts lists
// (partId, partTransform) pairs. We recursively handle each part.
var rd = _meshManager.TryGetRenderData(objectId);
if (rd is null || !rd.IsSetup) return;
foreach (var (partId, partTransform) in rd.SetupParts)
{
// 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;
bool partIsSetup = (partId >> 24) == 0x02UL;
PopulateRecursive(group, partId, partIsSetup, combined, cellId);
}
}
else
{
// Plain GfxObj — just add one InstanceData.
if (!group.TryGetValue(objectId, out var list))
{
list = new List<InstanceData>();
group[objectId] = list;
}
list.Add(new InstanceData
{
Transform = transform,
CellId = cellId,
Flags = 0,
});
}
}
// ---------------------------------------------------------------------------
// PrepareRenderBatches
// Verbatim port of WB EnvCellRenderManager.cs:247-373.
// ---------------------------------------------------------------------------
/// <summary>
/// Frustum-culls all registered landblocks and builds a new
/// <see cref="EnvCellVisibilitySnapshot"/> that the render thread consumes.
/// 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,
int? centerLbX = null,
int? centerLbY = null,
int? renderRadius = null)
{
// Phase U.4 fix: stash the view-projection so Render() can upload it itself.
_lastViewProjection = viewProjection;
// 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; }
// WB skips _cameraLbX/Y update (from LandscapeDoc.Region) here in our variant
// because we don't need camera-LB tracking for the snapshot — just frustum tests.
// WB EnvCellRenderManager.cs:262:
// 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:
// Use ThreadLocal to avoid contention on ConcurrentDictionaries during parallel grouping.
using var threadLocalBatchedByCell =
new ThreadLocal<Dictionary<uint, Dictionary<ulong, List<InstanceData>>>>(
() => new(), trackAllValues: true);
using var threadLocalGlobalGroups =
new ThreadLocal<Dictionary<ulong, List<InstanceData>>>(
() => new(), trackAllValues: true);
// WB EnvCellRenderManager.cs:269:
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
// WB EnvCellRenderManager.cs:270-325:
Parallel.ForEach(landblocks, parallelOptions, lb =>
{
lock (lb.Lock)
{
var testResult = _frustum.TestBox(lb.TotalEnvCellBounds);
if (testResult == FrustumTestResult.Outside) return;
var lbBatchedByCell = threadLocalBatchedByCell.Value!;
var lbGlobalGroups = threadLocalGlobalGroups.Value!;
// WB EnvCellRenderManager.cs:279-295: fast path — LB fully inside.
if (testResult == FrustumTestResult.Inside)
{
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
foreach (var instanceData in instances)
{
if (filter != null && !filter.Contains(instanceData.CellId)) continue;
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
}
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
foreach (var instanceData in instances)
{
if (filter != null && !filter.Contains(instanceData.CellId)) continue;
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
}
return;
}
// WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test.
var visibleCells = new HashSet<uint>();
foreach (var kvp in lb.EnvCellBounds)
{
var cellId = kvp.Key;
if (filter != null && !filter.Contains(cellId)) continue;
if (_frustum.Intersects(kvp.Value))
visibleCells.Add(cellId);
}
if (visibleCells.Count > 0)
{
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
foreach (var instanceData in instances)
{
if (visibleCells.Contains(instanceData.CellId))
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
}
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
foreach (var instanceData in instances)
{
if (visibleCells.Contains(instanceData.CellId))
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
}
}
}
});
// WB EnvCellRenderManager.cs:327-373: merge thread-locals + atomic swap.
var newBatchedByCell = new Dictionary<uint, Dictionary<ulong, List<InstanceData>>>();
var newVisibleGroups = new Dictionary<ulong, List<InstanceData>>();
var newVisibleGfxObjIds = new List<ulong>();
// WB EnvCellRenderManager.cs:333-347: merge per-cell batches.
foreach (var localBatchedByCell in threadLocalBatchedByCell.Values)
{
foreach (var cellKvp in localBatchedByCell)
{
if (!newBatchedByCell.TryGetValue(cellKvp.Key, out var gfxDict))
{
gfxDict = new Dictionary<ulong, List<InstanceData>>();
newBatchedByCell[cellKvp.Key] = gfxDict;
}
foreach (var gfxKvp in cellKvp.Value)
{
if (!gfxDict.TryGetValue(gfxKvp.Key, out var list))
{
list = GetPooledList();
gfxDict[gfxKvp.Key] = list;
}
list.AddRange(gfxKvp.Value);
}
}
}
// WB EnvCellRenderManager.cs:349-358: merge global groups.
foreach (var localGlobalGroups in threadLocalGlobalGroups.Values)
{
foreach (var kvp in localGlobalGroups)
{
if (!newVisibleGroups.TryGetValue(kvp.Key, out var list))
{
list = GetPooledList();
newVisibleGroups[kvp.Key] = list;
newVisibleGfxObjIds.Add(kvp.Key);
}
list.AddRange(kvp.Value);
}
}
// 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,
PostPreparePoolIndex = _poolIndex,
// VisibleGroups / VisibleGfxObjIds are stored as extra fields below.
};
// Stash the global groups on the snapshot for use in the unfiltered render path.
_activeSnapshotGlobalGroups = newVisibleGroups;
_activeSnapshotGlobalGfxObjIds = newVisibleGfxObjIds;
_poolIndex = 0;
NeedsPrepare = false;
}
}
// Extra fields to carry global groups out of PrepareRenderBatches into Render().
// WB stores these in VisibilitySnapshot; we keep them as sibling fields since our
// EnvCellVisibilitySnapshot only exposes BatchedByCell (per spec Task 4).
private Dictionary<ulong, List<InstanceData>> _activeSnapshotGlobalGroups = new();
private List<ulong> _activeSnapshotGlobalGfxObjIds = new();
// ---------------------------------------------------------------------------
// AddToGroups (static helper)
// Verbatim port of WB EnvCellRenderManager.cs:375-393.
// ---------------------------------------------------------------------------
private static void AddToGroups(
Dictionary<uint, Dictionary<ulong, List<InstanceData>>> batchedByCell,
Dictionary<ulong, List<InstanceData>> globalGroups,
uint cellId,
ulong gfxObjId,
InstanceData data)
{
// WB EnvCellRenderManager.cs:377-381: add to global grouping.
if (!globalGroups.TryGetValue(gfxObjId, out var globalList))
{
globalList = new List<InstanceData>();
globalGroups[gfxObjId] = globalList;
}
globalList.Add(data);
// WB EnvCellRenderManager.cs:383-392: add to per-cell grouping.
if (!batchedByCell.TryGetValue(cellId, out var gfxDict))
{
gfxDict = new Dictionary<ulong, List<InstanceData>>();
batchedByCell[cellId] = gfxDict;
}
if (!gfxDict.TryGetValue(gfxObjId, out var list))
{
list = new List<InstanceData>();
batchedByCell[cellId][gfxObjId] = list;
}
list.Add(data);
}
// ---------------------------------------------------------------------------
// Render
// Verbatim port of WB EnvCellRenderManager.cs:395-511.
// Deviations from WB (all documented):
// - Drop the _useModernRendering branch (our codebase asserts modern at startup per Phase N.5).
// - Drop SelectedInstance/HoveredInstance highlight block (lines 486-510) — no editor state.
// - Replace RenderModernMDI(base) with private RenderModernMDIInternal.
// - shader.Bind() / SetUniform API: mapped to acdream's legacy Shader
// class (Use() + SetInt/SetVec4/SetMatrix4) to match the existing
// wire-in pattern in GameWindow.cs where _meshShader is loaded once
// for mesh_modern.{vert,frag} and shared with WbDrawDispatcher.
// ---------------------------------------------------------------------------
public void Render(WbRenderPass renderPass)
{
// WB EnvCellRenderManager.cs:396:
Render(renderPass, null);
}
/// <summary>
/// Draws all visible EnvCells (and their static objects) for the given pass.
/// When <paramref name="filter"/> is non-null, only cells whose CellId is in
/// the set are drawn. As of Phase U.4 this is the portal-visibility SHELL
/// filter (the drawable visible cells from the PView traversal; each cell's
/// shell instances are clip-gated to its CellClip slot by the caller's
/// binding=3 map). NOTE: this is NOT the old two-pipe RenderInsideOut approach
/// — that flat camera-inside-building stencil pass was deleted in Phase U.1.
/// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights).
/// </summary>
public void Render(WbRenderPass renderPass, HashSet<uint>? filter)
{
// WB EnvCellRenderManager.cs:400:
if (!_initialized || _shader is null || _shader.Program == 0) return;
lock (_renderLock)
{
var snapshot = _activeSnapshot;
// WB EnvCellRenderManager.cs:403-404:
_shader.Use();
// 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:
// CurrentVAO = 0; CurrentIBO = 0; CurrentAtlas = 0;
// CurrentInstanceBuffer = 0; CurrentCullMode = null;
//
// These caches let SetCullMode / BindVertexArray skip redundant GL
// calls when the state is already correct. BUT: between two Render()
// invocations, OTHER consumers (WbDrawDispatcher, terrain, the
// RenderInsideOutAcdream stencil pipeline) change the actual GL
// state without updating these caches. The cache then lies, and
// the per-batch SetCullMode in RenderModernMDIInternal skips its
// glCullFace call — leaving stale cull state from the prior
// consumer. For a cottage with mixed CullMode batches, half the
// walls end up culled and the user sees "missing walls".
//
// Forcing the cache to null/0 at entry guarantees each Render call
// re-establishes the GL state it expects.
_currentVao = 0;
_currentCullMode = null;
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
_shader.SetInt("uRenderPass", (int)renderPass);
_shader.SetInt("uFilterByCell", 0);
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from
// WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's
// Draw (GameWindow ~7411 vs ~7418, the only other setter), so without
// this the opaque shells used the PREVIOUS frame's matrix — a stale
// gl_Position against this frame's clip planes → pose-dependent clipping,
// worst while moving. Same self-contained-GL-state precedent as the
// 2026-05-28 cull-state cache fix above.
_shader.SetMatrix4("uViewProjection", _lastViewProjection);
var allInstances = new List<InstanceData>();
var drawCalls = new List<(ObjectRenderData renderData, int count, int offset)>();
if (filter == null)
{
// WB EnvCellRenderManager.cs:418-429: optimized path — global groups.
foreach (var gfxObjId in _activeSnapshotGlobalGfxObjIds)
{
if (_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out var transforms))
{
var renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData != null && !renderData.IsSetup)
{
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
allInstances.AddRange(transforms);
}
}
}
}
else
{
// WB EnvCellRenderManager.cs:431-468: filtered path.
// Group by gfxObjId within the filtered cells to minimize draw calls.
var filteredGroups = new Dictionary<ulong, List<InstanceData>>();
var ownedLists = new HashSet<List<InstanceData>>();
foreach (var cellId in filter)
{
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) continue;
foreach (var (gfxObjId, transforms) in gfxDict)
{
if (transforms.Count == 0) continue;
if (!filteredGroups.TryGetValue(gfxObjId, out var list))
{
list = transforms; // Optimization: just use the first list
filteredGroups[gfxObjId] = list;
}
else
{
if (list == transforms) continue;
// If we don't own this list yet, we must clone it before adding to it
if (!ownedLists.Contains(list))
{
var newList = GetPooledList();
newList.AddRange(list);
list = newList;
filteredGroups[gfxObjId] = list;
ownedLists.Add(list);
}
list.AddRange(transforms);
}
}
}
// WB EnvCellRenderManager.cs:461-468:
foreach (var (gfxObjId, transforms) in filteredGroups)
{
var renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData != null && !renderData.IsSetup)
{
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
allInstances.AddRange(transforms);
}
}
}
// WB EnvCellRenderManager.cs:470-483:
if (allInstances.Count > 0)
{
// WB uses: if (_useModernRendering) { RenderModernMDI(...) } else { legacy }
// We always use modern (Phase N.5 mandatory).
RenderModernMDIInternal(_shader, drawCalls, allInstances, renderPass);
}
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
// WB EnvCellRenderManager.cs:506-509: cleanup.
_shader.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0));
_shader.SetInt("uRenderPass", (int)renderPass);
_gl.BindVertexArray(0);
_currentVao = 0;
// 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;
_lastFrameStats.TrianglesDrawn = 0;
foreach (var dc in drawCalls)
_lastFrameStats.TrianglesDrawn += (dc.renderData.Batches.Count > 0
? dc.renderData.Batches[0].IndexCount / 3
: 0) * dc.count;
// Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY.
// Per opaque-pass call: totals + per visible (filtered) cell whether it is
// present in the prepared snapshot, and its geometry/flags. Answers why the
// interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry
// prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture
// (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/
// occlusion or the geometry isn't the wall). Opaque pass only (halves noise).
if (renderPass == WbRenderPass.Opaque
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled)
{
var sb = new System.Text.StringBuilder(256);
sb.Append("[shell] filter=").Append(filter?.Count ?? -1)
.Append(" drawCalls=").Append(drawCalls.Count)
.Append(" inst=").Append(allInstances.Count)
.Append(" tris=").Append(_lastFrameStats.TrianglesDrawn);
if (filter != null)
{
foreach (var cellId in filter)
{
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict))
{
sb.Append(" [0x").Append(cellId.ToString("X8")).Append(":NOSNAP]");
continue;
}
int gfxN = 0, tf = 0, batch = 0, idx = 0, tr = 0, zh = 0;
foreach (var (gfxObjId, transforms) in gfxDict)
{
gfxN++; tf += transforms.Count;
var rd = _meshManager.TryGetRenderData(gfxObjId);
if (rd != null)
foreach (var b in rd.Batches)
{ batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (b.BindlessTextureHandle == 0) zh++; }
}
sb.Append(" [0x").Append(cellId.ToString("X8"))
.Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf)
.Append(" batch=").Append(batch).Append(" idx=").Append(idx)
.Append(" tr=").Append(tr).Append(" zh=").Append(zh).Append(']');
}
}
System.Console.WriteLine(sb.ToString());
}
}
}
// ---------------------------------------------------------------------------
// RenderModernMDIInternal
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
// Groups draw calls by CullMode (+ additive flag), uploads per-frame SSBOs,
// issues glMultiDrawElementsIndirect.
// ---------------------------------------------------------------------------
private void RenderModernMDIInternal(
AcDream.App.Rendering.Shader shader,
List<(ObjectRenderData renderData, int count, int offset)> drawCalls,
List<InstanceData> allInstances,
WbRenderPass renderPass)
{
// WB BaseObjectRenderManager.cs:710-713:
if (drawCalls.Count == 0 || allInstances.Count == 0) return;
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// WB BaseObjectRenderManager.cs:715-716:
shader.Use();
shader.SetInt("uFilterByCell", 0);
// Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish
// background, flickering when moving"): establish this pass's BLEND + DepthMask
// state OURSELVES rather than inheriting it. Render(Opaque) runs right after the
// terrain draw (which sets neither) and after particles / last frame's transparent
// pass — so whatever left GL_BLEND enabled made the OPAQUE shells composite their
// (often sub-1.0 alpha) wall textures against the bluish clear color (terrain is
// Skip'd indoors), toggling with per-frame ordering → flicker. Mirror the working
// WbDrawDispatcher passes (Disable(Blend)+DepthMask(true) opaque;
// Enable(Blend)+DepthMask(false) transparent). Restored to opaque defaults at the
// end of the draw loop so a Transparent pass can't leak into later draws.
if (renderPass == WbRenderPass.Transparent)
{
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
}
else
{
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
}
// WB BaseObjectRenderManager.cs:718-740: group batches by CullMode + additive flag.
var batchesByCullMode = new Dictionary<int, List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>>();
int totalDraws = 0;
foreach (var call in drawCalls)
{
foreach (var batch in call.renderData.Batches)
{
// WB BaseObjectRenderManager.cs:723-731: pass-filter.
if (renderPass != WbRenderPass.SinglePass)
{
if (batch.IsAdditive)
{
if (renderPass == WbRenderPass.Opaque) continue;
}
else if (!batch.IsTransparent)
{
if (renderPass == WbRenderPass.Transparent) continue;
}
}
// WB BaseObjectRenderManager.cs:732-740:
var cullMode = batch.CullMode;
var groupIdx = (int)cullMode + (batch.IsAdditive ? 4 : 0);
if (!batchesByCullMode.TryGetValue(groupIdx, out var list))
{
list = new List<(ObjectRenderBatch, int, int)>();
batchesByCullMode[groupIdx] = list;
}
list.Add((batch, call.count, call.offset));
totalDraws++;
}
}
// WB BaseObjectRenderManager.cs:743:
if (totalDraws == 0) return;
// WB BaseObjectRenderManager.cs:745-759: resize buffers if needed.
if (totalDraws > _mdiCommandCapacity)
{
_mdiCommandCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.BufferData(GLEnum.DrawIndirectBuffer,
(nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw);
_modernBatchCapacity = _mdiCommandCapacity;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
}
int uniqueInstanceCount = allInstances.Count;
if (uniqueInstanceCount > _modernInstanceCapacity)
{
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
// Phase U.3: keep the clip-slot buffer (binding=3) sized to the
// instance buffer so instanceClipSlot[BaseInstance + gl_InstanceID]
// is always in range.
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
}
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
if (_commands.Length < totalDraws)
Array.Resize(ref _commands, Math.Max(_commands.Length * 2, totalDraws));
if (_modernBatches.Length < totalDraws)
Array.Resize(ref _modernBatches, Math.Max(_modernBatches.Length * 2, totalDraws));
// WB BaseObjectRenderManager.cs:764-781: build commands array.
int cmdIndex = 0;
foreach (var group in batchesByCullMode)
{
foreach (var item in group.Value)
{
_modernBatches[cmdIndex] = new ModernBatchData
{
TextureHandle = item.batch.BindlessTextureHandle,
TextureIndex = (uint)item.batch.TextureIndex,
};
_commands[cmdIndex] = new DrawElementsIndirectCommand
{
Count = (uint)item.batch.IndexCount,
InstanceCount = (uint)item.instanceCount,
FirstIndex = item.batch.FirstIndex,
BaseVertex = (int)item.batch.BaseVertex,
BaseInstance = (uint)item.instanceOffset,
};
cmdIndex++;
}
}
// WB BaseObjectRenderManager.cs:784-805: upload (with orphaning).
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.BufferData(GLEnum.DrawIndirectBuffer,
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw);
fixed (DrawElementsIndirectCommand* ptr = _commands)
{
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(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(Matrix4x4)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(totalDraws * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
fixed (ModernBatchData* ptr = _modernBatches)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(totalDraws * sizeof(ModernBatchData)), ptr);
}
// Phase U.4: upload the per-instance clip-slot buffer (binding=3). When
// _cellIdToSlot is set (indoor routing), each cell shell instance is gated
// to its cell's CellClip slot via allInstances[i].CellId; cells absent from
// the map (shouldn't happen — the Render filter is the map's keys) and the
// U.3 path both map to slot 0 (no-clip). allInstances is laid out in the
// SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
if (_clipSlotData.Length < uniqueInstanceCount)
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
if (_cellIdToSlot is null)
{
Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
}
else
{
for (int i = 0; i < uniqueInstanceCount; i++)
_clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot)
? (uint)slot : 0u;
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * sizeof(uint)), null, GLEnum.DynamicDraw);
fixed (uint* ptr = _clipSlotData)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
}
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
if (globalVao == 0) return;
if (_currentVao != globalVao)
{
_gl.BindVertexArray(globalVao);
_currentVao = globalVao;
}
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer);
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer);
// Phase U.3: per-instance clip slots (binding=3) + shared clip regions
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
BindClipRegionBinding2();
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
// WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls.
int currentDrawOffset = 0;
foreach (var group in batchesByCullMode)
{
var cullMode = (CullMode)(group.Key % 4);
// 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)
{
SetCullMode(cullMode);
}
bool isAdditive = group.Key >= 4;
if (isAdditive)
{
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
shader.SetInt("uRenderPass", (int)renderPass | 0x100);
}
else
{
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
shader.SetInt("uRenderPass", (int)renderPass);
}
shader.SetInt("uDrawIDOffset", currentDrawOffset);
int numDraws = group.Value.Count;
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)(currentDrawOffset * sizeof(DrawElementsIndirectCommand)),
(uint)numDraws,
(uint)sizeof(DrawElementsIndirectCommand));
currentDrawOffset += numDraws;
}
// Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's
// post-transparent restore) so a Transparent pass's Blend-on / DepthMask-off does
// not leak into particles or the next frame's draws.
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
// WB BaseObjectRenderManager.cs:845-847:
shader.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
// ---------------------------------------------------------------------------
// SetCullMode
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.
// ---------------------------------------------------------------------------
private void SetCullMode(CullMode mode)
{
_currentCullMode = mode;
switch (mode)
{
case CullMode.None:
_gl.Disable(EnableCap.CullFace);
break;
case CullMode.Clockwise:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
break;
}
}
// ---------------------------------------------------------------------------
// BindClipRegionBinding2 (Phase U.3)
// ---------------------------------------------------------------------------
/// <summary>
/// Bind the per-cell clip-region SSBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> buffer (<see cref="SetClipRegionSsbo"/>); otherwise
/// lazily creates + binds a one-slot no-clip fallback (count 0 = pass-all) so
/// the shader never reads an unbound SSBO.
/// </summary>
private void BindClipRegionBinding2()
{
if (_sharedClipRegionSsbo != 0)
{
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer,
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
return;
}
if (_fallbackClipRegionSsbo == 0)
{
_gl.GenBuffers(1, out _fallbackClipRegionSsbo);
// One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
var zero = new byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes];
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _fallbackClipRegionSsbo);
fixed (byte* p = zero)
{
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, p, GLEnum.DynamicDraw);
}
}
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer,
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
}
// ---------------------------------------------------------------------------
// List pool (GetPooledList)
// Copied from WB ObjectRenderManagerBase (pattern).
// ---------------------------------------------------------------------------
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)
{
var list = _listPool[_poolIndex++];
list.Clear();
return list;
}
var fresh = new List<InstanceData>();
_listPool.Add(fresh);
_poolIndex++;
return fresh;
}
}
// ---------------------------------------------------------------------------
// Helpers: bounds computation
// ---------------------------------------------------------------------------
/// <summary>
/// Computes a local-space AABB from a CellStruct's vertex array.
/// </summary>
private static WbBoundingBox ComputeLocalBoundsFromCellStruct(DatReaderWriter.Types.CellStruct cellStruct)
{
if (cellStruct.VertexArray is null || cellStruct.VertexArray.Vertices is null || cellStruct.VertexArray.Vertices.Count == 0)
return new WbBoundingBox(Vector3.Zero, Vector3.Zero);
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var v in cellStruct.VertexArray.Vertices.Values)
{
var p = v.Origin;
min = Vector3.Min(min, p);
max = Vector3.Max(max, p);
}
return new WbBoundingBox(min, max);
}
/// <summary>
/// Transforms a local AABB to a world AABB by transforming all 8 corners.
/// </summary>
private static WbBoundingBox TransformBoundingBox(WbBoundingBox local, Matrix4x4 transform)
{
if (local.Min == local.Max && local.Min == Vector3.Zero)
return local;
var min = local.Min;
var max = local.Max;
Span<Vector3> corners = stackalloc Vector3[8]
{
new Vector3(min.X, min.Y, min.Z),
new Vector3(max.X, min.Y, min.Z),
new Vector3(min.X, max.Y, min.Z),
new Vector3(max.X, max.Y, min.Z),
new Vector3(min.X, min.Y, max.Z),
new Vector3(max.X, min.Y, max.Z),
new Vector3(min.X, max.Y, max.Z),
new Vector3(max.X, max.Y, max.Z),
};
var wMin = new Vector3(float.MaxValue);
var wMax = new Vector3(float.MinValue);
foreach (var c in corners)
{
var w = Vector3.Transform(c, transform);
wMin = Vector3.Min(wMin, w);
wMax = Vector3.Max(wMax, w);
}
return new WbBoundingBox(wMin, wMax);
}
// ---------------------------------------------------------------------------
// IDisposable
// ---------------------------------------------------------------------------
public void Dispose()
{
if (IsDisposed) return;
IsDisposed = true;
if (_mdiCommandBuffer != 0) { _gl.DeleteBuffer(_mdiCommandBuffer); _mdiCommandBuffer = 0; }
if (_modernInstanceBuffer != 0){ _gl.DeleteBuffer(_modernInstanceBuffer); _modernInstanceBuffer = 0; }
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
}
}