acdream/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
Erik a5debaca2b fix(overhaul): integrate reviewed room-light selection repair
Exact26 code/test/architecture/register blobs from621b41fa3; campaign ledger and lead verification included. Independent retail and production/lifetime/ABI reviews PASS. Lead69Core/176App/2actualshader pixels, viewer/clear/NaN negative controls fail as intended, exact restoration69PASS. AP68retired; AP16/35/85 residuals honest. Fresh campaign Release and graphical lighting proof still owed; temporary observer cleanup contract conditional. FPS deferred; no G4 or main merge.
2026-09-05 14:57:52 +02:00

1467 lines
65 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 <- collision-resistant successor to WB's content key
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
// 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, streaming builds
// a private EnvCellLandblockBuild and CommitLandblock publishes the completed
// snapshot on the render thread.
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 AcDream.Core.Meshing;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
[Flags]
internal enum EnvCellTransparentRoute : byte
{
None = 0,
Immediate = 1 << 0,
Clip = 1 << 1,
Alpha = 1 << 2,
All = Immediate | Clip | Alpha,
}
public sealed partial class EnvCellRenderer :
IDisposable,
IEnvCellLandblockPublisher
{
private readonly object _publicationOwner = new();
private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum;
// Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF).
// 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();
// 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;
// PrepareRenderBatches used to construct and dispose two ThreadLocal dictionary trees every
// frame. At outdoor view distances those trees grow thousands of InstanceData entries, so the
// temporary List backing arrays alone accounted for ~11 MB of allocations in a five-second
// Caul trace. Keep one scratch arena per participating worker thread: Reset clears counts and
// dictionaries while retaining their capacity. The published visibility snapshot still owns
// separate pooled output lists, preserving the existing atomic-swap lifetime.
private readonly ThreadLocal<PrepareScratch> _prepareScratch =
new(() => new PrepareScratch(), trackAllValues: true);
// Modern-MDI scratch buffers (single slot — we re-upload every frame).
// WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers
// 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 data, parallel to _gpuInstanceTransforms.
// One uint per instance selecting its CellClip slot, indexed by the same
// BaseInstance + gl_InstanceID the shader uses for binding=0. ALL ZEROS ⇒
// slot 0 ⇒ no-clip.
private uint[] _clipSlotData = Array.Empty<uint>();
// Campaign VM VM1 follow-up: per-instance opacity multiplier, parallel to
// _gpuInstanceTransforms, feeding both world vertex families'
// InstanceAlphaBuf (binding 7, GpuBindingModel.StorageInstanceAlpha).
// Before this field the shell pass never bound that storage buffer at
// all, so the shader read whatever section
// WbDrawDispatcher's own SubmitRhi last bound in the same pass — an
// unrelated object's opacity array, indexed by these cell instance ids.
// EnvCell shells never carry a #188 TransparentPartHook translucency fade
// (that mechanism fades object PARTS, never cells), so every element is
// the constant no-op 1.0f.
private float[] _instanceAlphaData = Array.Empty<float>();
// A7 Fix D (D-2): this renderer owns its lighting (self-contained state,
// like uViewProjection) instead of reading whatever WbDrawDispatcher last
// bound. Global point-light snapshot (same data/indices as the dispatcher,
// via GlobalLightPacker) plus the complete 47-index retained product per
// EnvCell instance. Ordinary objects retain their separate 8-index stride.
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private sealed class CachedCellLightSet
{
public int FrameGeneration;
public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell];
}
private readonly System.Collections.Generic.Dictionary<uint, CachedCellLightSet> _cellLightSetCache = new();
private readonly List<uint> _cellLightRemovalScratch = new();
private int _lightFrameGeneration;
// Per-GPU-fenced-frame-slot draw bookkeeping.
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
/// <summary>
/// The dynamic per-frame-slot SSBO pool this used to report was raw-GL-only
/// bookkeeping, deleted with that arm at Campaign V slice V11. The RHI arm
/// allocates its storage from the GPU frame's own upload ring instead, so
/// there is no separate pool to count.
/// </summary>
internal int DynamicBufferSetCount => 0;
// 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>();
private uint[] _detailCategoryData = Array.Empty<uint>();
private readonly List<EnvCellLandblock> _prepareLandblocks = new();
private readonly List<InstanceData> _renderInstances = new();
private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new();
private readonly Dictionary<ulong, List<InstanceData>> _filteredGroups = new();
private readonly HashSet<List<InstanceData>> _filteredOwnedLists = new();
// S4-c2 F3: four cull modes for each fixed-state family. The original
// eight slots were non-additive/additive only; transparent replay also
// needs distinct pure-ClipMap DDS/paletted ranges because their alpha-test
// references differ and neither state may share an MDI call with ALPHA.
private const int CullGroupCount = 4;
private const int AdditiveGroupBase = 4;
private const int ClipDdsGroupBase = 8;
private const int ClipPalettedGroupBase = 12;
private const int BatchGroupCount = 16;
private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup =
Enumerable.Range(0, BatchGroupCount)
.Select(_ => new List<(ObjectRenderBatch, int, int)>())
.ToArray();
private readonly List<int> _activeCullGroups = new(8);
private readonly HashSet<uint> _transparentCellIds = new();
private readonly List<DrawCallRange> _drawCallRanges = new();
private readonly List<MdiDrawRange> _mdiDrawRanges = new();
private readonly record struct DrawCallRange(int First, int Count);
internal readonly record struct MdiDrawRange(
int GroupIndex,
int FirstCommand,
int CommandCount,
RetailSetSurfaceMaterialState MaterialState);
// Unfiltered rendering is retained for diagnostic callers only. Build its
// global grouping lazily instead of duplicating every prepared gameplay
// instance in both a per-cell and global tree each frame.
private readonly Dictionary<ulong, List<InstanceData>> _activeSnapshotGlobalGroups = new();
private readonly List<ulong> _activeSnapshotGlobalGfxObjIds = new();
public bool NeedsPrepare { get; private set; } = true;
// --- Prepare gate (2026-07-24) -------------------------------------------
// PrepareRenderBatches rebuilds the visibility snapshot only when one of its
// inputs changed: landblock commits/removals (NeedsPrepare), the visible-cell
// filter, the trim window, mesh render-data availability (the snapshot bakes
// per-cell transparency from TryGetRenderData), or the view-projection.
// NeedsPrepare existed since A8 but was never read — this wires it. The VP
// tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
// R-A2 note) while any real camera motion crosses it in the same frame.
private Matrix4x4 _preparedViewProjection;
private Vector3 _preparedCameraPosition;
private readonly HashSet<uint> _preparedFilter = new();
private bool _preparedFilterWasNull;
private (int? X, int? Y, int? Radius) _preparedTrim;
private long _preparedMeshVersion = -1;
private bool _hasPreparedSnapshot;
/// <summary>Bumps once per visibility-snapshot rebuild (tests + diagnostics).</summary>
internal int SnapshotGeneration { get; private set; }
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
public bool IsDisposed { get; private set; }
public LastFrameStats Stats => _lastFrameStats;
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
private LastFrameStats _lastFrameStats;
// ---------------------------------------------------------------------------
// Constructor
// Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step
// are deleted. EnvCellRenderer.Rhi.cs's constructor is now the class's sole
// constructor — it builds the three shell pipelines itself, so there is no
// second initialization step.
// ---------------------------------------------------------------------------
/// <summary>Resets the per-frame submission cursor for the GPU-fenced slot.</summary>
public void BeginFrame(int frameSlot)
{
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
if (++_lightFrameGeneration == 0)
{
_cellLightSetCache.Clear();
_lightFrameGeneration = 1;
}
}
// S3 review fix round 1 (F5): the Phase U.4 per-frame cellId→CellClip-slot
// map this renderer's own arming method used to install is deleted — its
// only caller ever passed null (WorldScenePassExecutor's
// BeginFrame/AbortFrame), so RenderModernMDIInternal's instanceClipSlot
// write always took the "every instance maps to slot 0" branch in every
// shipped build. See RenderModernMDIInternal's own doc comment
// (EnvCellRenderer.Rhi.cs) for where that write now lives, unconditionally.
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
/// reference this snapshot, which is also uploaded to binding=4 here, so the
/// pass is self-contained. Null/empty -> shells receive no point lights.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
=> _pointSnapshot = snapshot;
// ---------------------------------------------------------------------------
// GetEnvCellGeomId
// Shared collision-resistant content identity. Core retains WB's original
// polynomial for conformance evidence and the installed-DAT collision test.
// ---------------------------------------------------------------------------
/// <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).
/// See <see cref="AcDream.Core.Rendering.Wb.EnvCellGeometryIdentity"/>.
/// </summary>
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces)
=> EnvCellLandblockBuildBuilder.ComputeGeometryId(
environmentId,
cellStructure,
surfaces);
// ---------------------------------------------------------------------------
// CommitLandblock — render-thread transaction boundary
// ---------------------------------------------------------------------------
/// <summary>
/// Commits one complete worker-built landblock snapshot. A replacement is
/// constructed off to the side, then published with one dictionary write;
/// render preparation can observe either the previous complete snapshot or
/// this complete snapshot, never an in-progress cell list.
/// </summary>
public void CommitLandblock(EnvCellLandblockBuild build)
{
EnvCellLandblockPublication publication = PreparePublication(build);
while (!AdvancePreparationOne(publication))
{
}
CommitPublication(publication);
}
EnvCellLandblockPublication IEnvCellLandblockPublisher.PreparePublication(
EnvCellLandblockBuild build) =>
PreparePublication(build);
bool IEnvCellLandblockPublisher.AdvancePreparationOne(
EnvCellLandblockPublication publication) =>
AdvancePreparationOne(publication);
void IEnvCellLandblockPublisher.CommitPublication(
EnvCellLandblockPublication publication) =>
CommitPublication(publication);
internal EnvCellLandblockPublication PreparePublication(
EnvCellLandblockBuild build)
{
ArgumentNullException.ThrowIfNull(build);
return new EnvCellLandblockPublication(_publicationOwner, build);
}
internal bool AdvancePreparationOne(
EnvCellLandblockPublication publication)
{
ValidatePublication(publication);
if (publication.PreparationCommitted)
return true;
if (publication.ShellCursor < publication.Build.Shells.Length)
{
AddShell(
publication.Replacement,
publication.Build.Shells[publication.ShellCursor]);
WbBoundingBox bounds =
publication.Build.Shells[publication.ShellCursor].WorldBounds;
publication.TotalBounds =
publication.ShellCursor == 0
? bounds
: WbBoundingBox.Union(
publication.TotalBounds,
bounds);
publication.ShellCursor++;
return false;
}
publication.Replacement.TotalEnvCellBounds =
publication.Replacement.EnvCellBounds.Count == 0
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
: publication.TotalBounds;
publication.Replacement.InstancesReady = true;
publication.Replacement.MeshDataReady = true;
publication.Replacement.GpuReady = true;
publication.PreparationCommitted = true;
return true;
}
internal void CommitPublication(
EnvCellLandblockPublication publication)
{
ValidatePublication(publication);
if (!publication.PreparationCommitted)
throw new InvalidOperationException(
"EnvCell landblock publication cannot commit before preparation.");
if (publication.PublicationCommitted)
return;
_landblocks[publication.Build.LandblockId] = publication.Replacement;
NeedsPrepare = true;
publication.PublicationCommitted = true;
}
/// <summary>
/// Pure half of <see cref="CommitLandblock"/>. Kept separate so transaction
/// replacement semantics are regression-tested without an OpenGL context.
/// </summary>
internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build)
{
var replacement = new EnvCellLandblock
{
GridX = (int)((build.LandblockId >> 24) & 0xFFu),
GridY = (int)((build.LandblockId >> 16) & 0xFFu),
};
foreach (var shell in build.Shells)
AddShell(replacement, shell);
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
foreach (var bounds in replacement.EnvCellBounds.Values)
total = WbBoundingBox.Union(total, bounds);
replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
: total;
replacement.InstancesReady = true;
replacement.MeshDataReady = true;
replacement.GpuReady = true;
return replacement;
}
private static void AddShell(
EnvCellLandblock replacement,
EnvCellShellPlacement shell)
{
replacement.Instances.Add(new EnvCellSceneryInstance
{
ObjectId = shell.GeometryId,
InstanceId = shell.CellId,
IsBuilding = true,
IsEntryCell = false,
WorldPosition = shell.WorldPosition,
LocalPosition = Vector3.Zero,
Rotation = shell.Rotation,
Scale = Vector3.One,
Transform = shell.Transform,
LocalBoundingBox = shell.LocalBounds,
BoundingBox = shell.WorldBounds,
});
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
if (!replacement.BuildingPartGroups.TryGetValue(
shell.GeometryId,
out var instances))
{
instances = new List<InstanceData>();
replacement.BuildingPartGroups[shell.GeometryId] = instances;
}
instances.Add(new InstanceData
{
Transform = shell.Transform,
CellId = shell.CellId,
Flags = 0,
});
}
private void ValidatePublication(
EnvCellLandblockPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (!ReferenceEquals(publication.Owner, _publicationOwner))
{
throw new ArgumentException(
"The EnvCell publication receipt belongs to another renderer.",
nameof(publication));
}
}
/// <summary>
/// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
_landblocks.TryRemove(landblockId, out _);
uint cellPrefix = landblockId & 0xFFFF0000u;
_cellLightRemovalScratch.Clear();
foreach (uint cellId in _cellLightSetCache.Keys)
{
if ((cellId & 0xFFFF0000u) == cellPrefix)
_cellLightRemovalScratch.Add(cellId);
}
foreach (uint cellId in _cellLightRemovalScratch)
_cellLightSetCache.Remove(cellId);
NeedsPrepare = true;
}
// ---------------------------------------------------------------------------
// 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.
// Stashed even when the gate below skips the rebuild — Render must always
// project with the CURRENT frame's matrix (the U.4 stale-matrix root cause).
_lastViewProjection = viewProjection;
// WB EnvCellRenderManager.cs:249-250:
if (!_initialized || cameraPosition.Z > 4000) return;
long meshVersion = _meshManager is null ? 0L : _meshManager.RenderDataAvailabilityVersion;
if (filter is { Count: 0 })
{
// Idempotent: an empty filter always produces the empty snapshot, so
// skip once it is already active. Landblock/content changes cannot
// affect an empty result; NeedsPrepare stays observable for the next
// non-empty prepare.
if (_hasPreparedSnapshot && !_preparedFilterWasNull && _preparedFilter.Count == 0)
return;
lock (_renderLock)
{
_poolIndex = 0;
_activeSnapshot = new EnvCellVisibilitySnapshot();
_transparentCellIds.Clear();
NeedsPrepare = false;
}
RecordPreparedInputs(viewProjection, cameraPosition, filter, centerLbX, centerLbY, renderRadius, meshVersion);
return;
}
// Prepare gate: every snapshot input unchanged → keep the active snapshot.
// (Same-thread discipline makes the version sample exact: publish, release
// tickets, and this method all run on the render thread.)
if (_hasPreparedSnapshot
&& !NeedsPrepare
&& meshVersion == _preparedMeshVersion
&& _preparedTrim == (centerLbX, centerLbY, renderRadius)
&& FilterUnchanged(filter)
&& CameraApproximatelyEqual(
viewProjection, cameraPosition,
_preparedViewProjection, _preparedCameraPosition))
{
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.
List<EnvCellLandblock> landblocks = _prepareLandblocks;
landblocks.Clear();
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: worker-local grouping avoids contention. The scratch
// arenas persist so their backing arrays can be reused; Parallel.ForEach has completed before
// the merge below reads them, and Reset runs before the next workers start.
foreach (PrepareScratch scratch in _prepareScratch.Values)
scratch.Reset();
// 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;
PrepareScratch scratch = _prepareScratch.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(scratch, 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(scratch, instanceData.CellId, gfxObjId, instanceData);
}
return;
}
// WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test.
HashSet<uint> visibleCells = scratch.VisibleCells;
visibleCells.Clear();
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(scratch, instanceData.CellId, gfxObjId, instanceData);
}
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
foreach (var instanceData in instances)
{
if (visibleCells.Contains(instanceData.CellId))
AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData);
}
}
}
});
// WB EnvCellRenderManager.cs:327-373: merge thread-locals + atomic swap.
var newBatchedByCell = new Dictionary<uint, Dictionary<ulong, List<InstanceData>>>();
// WB EnvCellRenderManager.cs:333-347: merge per-cell batches.
foreach (PrepareScratch scratch in _prepareScratch.Values)
{
foreach (var cellKvp in scratch.BatchedByCell)
{
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: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,
};
RebuildTransparentCellIndex(newBatchedByCell);
_poolIndex = 0;
NeedsPrepare = false;
}
RecordPreparedInputs(viewProjection, cameraPosition, filter, centerLbX, centerLbY, renderRadius, meshVersion);
}
private void RecordPreparedInputs(
in Matrix4x4 viewProjection,
Vector3 cameraPosition,
HashSet<uint>? filter,
int? centerLbX,
int? centerLbY,
int? renderRadius,
long meshVersion)
{
_preparedViewProjection = viewProjection;
_preparedCameraPosition = cameraPosition;
_preparedFilterWasNull = filter is null;
_preparedFilter.Clear();
if (filter is not null)
_preparedFilter.UnionWith(filter);
_preparedTrim = (centerLbX, centerLbY, renderRadius);
_preparedMeshVersion = meshVersion;
_hasPreparedSnapshot = true;
SnapshotGeneration++;
}
private bool FilterUnchanged(HashSet<uint>? filter)
{
if (filter is null) return _preparedFilterWasNull;
if (_preparedFilterWasNull) return false;
// The caller reuses one scratch HashSet across frames, so compare by
// content against our recorded copy, never by reference.
return filter.Count == _preparedFilter.Count && _preparedFilter.SetEquals(filter);
}
/// <summary>
/// Pure half of the prepare gate's camera test (regression-tested without a
/// GL context, same pattern as <see cref="CreateCommittedSnapshot"/>).
/// Eye position uses a 1 mm ABSOLUTE epsilon: it swallows the ~36 µm rest
/// jitter but dirties on any real movement (a slow walk moves 20+ mm/frame).
/// Position must not be tested through the matrix — the view-projection's
/// translation row scales with world coordinates (~5e4 in AC), where a
/// relative tolerance would mask sub-meter motion. Rows 13 of
/// view × projection are position-independent (rotation × projection), so a
/// relative 1e-5 there dirties at ≈0.001° of rotation and on any
/// projection (FOV/aspect/near/far) change.
/// </summary>
internal static bool CameraApproximatelyEqual(
in Matrix4x4 vpA, Vector3 eyeA,
in Matrix4x4 vpB, Vector3 eyeB)
{
const float EyeEpsilonSq = 1e-3f * 1e-3f;
if (Vector3.DistanceSquared(eyeA, eyeB) > EyeEpsilonSq) return false;
return Close(vpA.M11, vpB.M11) && Close(vpA.M12, vpB.M12) && Close(vpA.M13, vpB.M13) && Close(vpA.M14, vpB.M14)
&& Close(vpA.M21, vpB.M21) && Close(vpA.M22, vpB.M22) && Close(vpA.M23, vpB.M23) && Close(vpA.M24, vpB.M24)
&& Close(vpA.M31, vpB.M31) && Close(vpA.M32, vpB.M32) && Close(vpA.M33, vpB.M33) && Close(vpA.M34, vpB.M34);
static bool Close(float x, float y)
{
const float Rel = 1e-5f;
return MathF.Abs(x - y) <= Rel * MathF.Max(1f, MathF.Max(MathF.Abs(x), MathF.Abs(y)));
}
}
private void RebuildTransparentCellIndex(
Dictionary<uint, Dictionary<ulong, List<InstanceData>>> batchedByCell)
{
_transparentCellIds.Clear();
foreach ((uint cellId, Dictionary<ulong, List<InstanceData>> groups) in batchedByCell)
{
foreach ((ulong gfxObjId, List<InstanceData> transforms) in groups)
{
if (transforms.Count == 0)
continue;
ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData is null)
continue;
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
if (!renderData.Batches[batchIndex].IsTransparent)
continue;
_transparentCellIds.Add(cellId);
goto NextCell;
}
}
NextCell:;
}
}
// ---------------------------------------------------------------------------
// AddToGroups (static helper)
// Verbatim port of WB EnvCellRenderManager.cs:375-393.
// ---------------------------------------------------------------------------
private static void AddToGroups(
PrepareScratch scratch,
uint cellId,
ulong gfxObjId,
InstanceData data)
{
Dictionary<uint, Dictionary<ulong, List<InstanceData>>> batchedByCell = scratch.BatchedByCell;
// The gameplay renderer always consumes cell-filtered PView batches.
// Keeping a second global copy doubled every prepared instance and was
// only useful to WorldBuilder's editor-wide unfiltered path.
if (!batchedByCell.TryGetValue(cellId, out var gfxDict))
{
gfxDict = scratch.RentGfxDictionary();
batchedByCell[cellId] = gfxDict;
}
if (!gfxDict.TryGetValue(gfxObjId, out var list))
{
list = scratch.RentList();
batchedByCell[cellId][gfxObjId] = list;
}
list.Add(data);
}
private sealed class PrepareScratch
{
public readonly Dictionary<uint, Dictionary<ulong, List<InstanceData>>> BatchedByCell = new();
public readonly HashSet<uint> VisibleCells = new();
private readonly List<Dictionary<ulong, List<InstanceData>>> _gfxDictionaryPool = new();
private readonly List<List<InstanceData>> _listPool = new();
private int _gfxDictionaryIndex;
private int _listIndex;
public void Reset()
{
BatchedByCell.Clear();
VisibleCells.Clear();
_gfxDictionaryIndex = 0;
_listIndex = 0;
}
public Dictionary<ulong, List<InstanceData>> RentGfxDictionary()
{
if (_gfxDictionaryIndex == _gfxDictionaryPool.Count)
_gfxDictionaryPool.Add(new Dictionary<ulong, List<InstanceData>>());
Dictionary<ulong, List<InstanceData>> value = _gfxDictionaryPool[_gfxDictionaryIndex++];
value.Clear();
return value;
}
public List<InstanceData> RentList()
{
if (_listIndex == _listPool.Count)
_listPool.Add(new List<InstanceData>());
List<InstanceData> value = _listPool[_listIndex++];
value.Clear();
return value;
}
}
// ---------------------------------------------------------------------------
// 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:
RenderCore(renderPass, null, null, EnvCellTransparentRoute.All, detailSurfaceActive: false);
}
/// <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)
=> RenderCore(renderPass, filter, null, EnvCellTransparentRoute.All, detailSurfaceActive: false);
/// <summary>
/// Draws transparent cell shells in the supplied far-to-near PView order.
/// All instance/command buffers are uploaded once, while command ranges
/// retain the same per-cell cull/blend ordering as the former one-Render-
/// call-per-cell path.
/// </summary>
public void RenderTransparentOrdered(IReadOnlyList<uint> orderedCellIds)
{
ArgumentNullException.ThrowIfNull(orderedCellIds);
RenderCore(
WbRenderPass.Transparent,
null,
orderedCellIds,
EnvCellTransparentRoute.All,
detailSurfaceActive: TransparentDetailEnabled);
}
/// <summary>
/// Replays only the transparent subsets whose exact retained retail mask
/// resolves to <paramref name="route"/>. S4-c2 uses this for its separate
/// CLIP/ALPHA queue tokens; the detail-on immediate turn uses the same
/// table with <paramref name="detailSurfaceActive"/> true.
/// </summary>
internal void RenderTransparentOrdered(
IReadOnlyList<uint> orderedCellIds,
EnvCellTransparentRoute route,
bool detailSurfaceActive)
{
ArgumentNullException.ThrowIfNull(orderedCellIds);
RenderCore(
WbRenderPass.Transparent,
null,
orderedCellIds,
route,
detailSurfaceActive);
}
private void RenderCore(
WbRenderPass renderPass,
HashSet<uint>? filter,
IReadOnlyList<uint>? orderedCellIds,
EnvCellTransparentRoute transparentRoute,
bool detailSurfaceActive)
{
// WB EnvCellRenderManager.cs:400: the RHI arm's SetSurface pipelines are built
// at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone
// answers whether this renderer is ready to draw.
if (!_initialized) return;
lock (_renderLock)
{
var snapshot = _activeSnapshot;
// 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;
List<InstanceData> allInstances = _renderInstances;
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls =
_renderDrawCalls;
allInstances.Clear();
drawCalls.Clear();
_drawCallRanges.Clear();
if (orderedCellIds is not null)
{
for (int cellIndex = 0; cellIndex < orderedCellIds.Count; cellIndex++)
{
uint cellId = orderedCellIds[cellIndex];
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var cellGroups))
continue;
int firstDrawCall = drawCalls.Count;
foreach ((ulong gfxObjId, List<InstanceData> transforms) in cellGroups)
{
if (transforms.Count == 0)
continue;
ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData is null || renderData.IsSetup)
continue;
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
allInstances.AddRange(transforms);
}
int drawCallCount = drawCalls.Count - firstDrawCall;
if (drawCallCount > 0)
_drawCallRanges.Add(new DrawCallRange(firstDrawCall, drawCallCount));
}
}
else if (filter is null)
{
RebuildUnfilteredGroups(snapshot);
// 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, gfxObjId, 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.
Dictionary<ulong, List<InstanceData>> filteredGroups = _filteredGroups;
HashSet<List<InstanceData>> ownedLists = _filteredOwnedLists;
filteredGroups.Clear();
ownedLists.Clear();
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, gfxObjId, 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).
if (_drawCallRanges.Count == 0 && drawCalls.Count > 0)
_drawCallRanges.Add(new DrawCallRange(0, drawCalls.Count));
RenderModernMDIInternal(
drawCalls,
allInstances,
_drawCallRanges,
renderPass,
transparentRoute,
detailSurfaceActive);
}
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
// Update frame stats for probe emission at the call site.
_lastFrameStats.CellsRendered = orderedCellIds?.Count
?? 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;
}
}
/// <summary>
/// True if the cell's prepared snapshot has any transparent render batch.
/// The pview shell pass uses this to skip the (heavy per-frame) transparent
/// <see cref="Render"/> call for opaque-only cells — most cell geometry is
/// opaque walls/floors/ceilings, so this removes the bulk of the per-cell
/// transparent draws. Read-only; mirrors the [shell] probe's batch scan.
/// </summary>
public bool CellHasTransparent(uint cellId)
=> _transparentCellIds.Contains(cellId);
/// <summary>
/// Scans the real prepared transparent render batches for one cell and
/// returns the set of outcomes from retail's shared DrawMesh table. A
/// cell may contribute one token to each deferred list; masks that resolve
/// Immediate remain at the cell turn.
/// </summary>
internal EnvCellTransparentRoute GetTransparentRoutes(
uint cellId,
bool detailSurfaceActive)
{
lock (_renderLock)
{
if (!_activeSnapshot.BatchedByCell.TryGetValue(cellId, out var groups))
return EnvCellTransparentRoute.None;
EnvCellTransparentRoute routes = EnvCellTransparentRoute.None;
foreach ((ulong gfxObjId, List<InstanceData> transforms) in groups)
{
if (transforms.Count == 0)
continue;
ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData is null || renderData.IsSetup)
continue;
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
ObjectRenderBatch batch = renderData.Batches[batchIndex];
if (batch.IsTransparent)
routes |= RouteTransparentBatch(batch, detailSurfaceActive);
}
}
return routes;
}
}
/// <summary>
/// Exact per-subset EnvCell route. Canonical installed-DAT witness
/// F4180104 / surface 08000BFF retains mask 0x08 and therefore reaches
/// CLIP when environment detail is absent; alpha-family mask 0x02 reaches
/// ALPHA. No collapsed <see cref="ObjectRenderBatch.Translucency"/> value
/// participates in this decision.
/// </summary>
internal static EnvCellTransparentRoute RouteTransparentBatch(
ObjectRenderBatch batch,
bool detailSurfaceActive)
{
RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
currentlyDrawingSky: false,
delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
detailSurfaceActive: detailSurfaceActive,
multiPassAlpha: false,
subsetMask: batch.RetailSurfaceMask,
materialHasAlpha: false);
return decision.Action switch
{
RetailAlphaMeshAction.Immediate => EnvCellTransparentRoute.Immediate,
RetailAlphaMeshAction.Append => decision.List == RetailAlphaList.Clip
? EnvCellTransparentRoute.Clip
: EnvCellTransparentRoute.Alpha,
RetailAlphaMeshAction.AppendClipAndImmediate =>
EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Immediate,
_ => throw new ArgumentOutOfRangeException(nameof(decision.Action)),
};
}
private static bool MatchesTransparentRoute(
ObjectRenderBatch batch,
EnvCellTransparentRoute route,
bool detailSurfaceActive) =>
(RouteTransparentBatch(batch, detailSurfaceActive) & route) != 0;
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Complete per-cell retained point-light product, cached per frame.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): all retained 7 dynamic + 40 static candidates. The shader
// applies the unchanged per-vertex range cutoff. Unused slots are -1.
private int[] GetCellLightSet(uint cellId)
{
if (!_cellLightSetCache.TryGetValue(cellId, out CachedCellLightSet? cached))
{
cached = new CachedCellLightSet();
_cellLightSetCache.Add(cellId, cached);
}
if (cached.FrameGeneration == _lightFrameGeneration)
return cached.Indices;
int[] set = cached.Indices;
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 })
AcDream.Core.Lighting.LightManager.SelectForCell(snap, set);
cached.FrameGeneration = _lightFrameGeneration;
return set;
}
// ---------------------------------------------------------------------------
// 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 RebuildUnfilteredGroups(EnvCellVisibilitySnapshot snapshot)
{
foreach (List<InstanceData> instances in _activeSnapshotGlobalGroups.Values)
instances.Clear();
_activeSnapshotGlobalGfxObjIds.Clear();
foreach (Dictionary<ulong, List<InstanceData>> cellGroups in snapshot.BatchedByCell.Values)
{
foreach ((ulong gfxObjId, List<InstanceData> transforms) in cellGroups)
{
if (!_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out List<InstanceData>? combined))
{
combined = new List<InstanceData>(transforms.Count);
_activeSnapshotGlobalGroups.Add(gfxObjId, combined);
}
if (combined.Count == 0)
_activeSnapshotGlobalGfxObjIds.Add(gfxObjId);
combined.AddRange(transforms);
}
}
}
private void RenderModernMDIInternal(
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
List<InstanceData> allInstances,
IReadOnlyList<DrawCallRange> drawCallRanges,
WbRenderPass renderPass,
EnvCellTransparentRoute transparentRoute,
bool detailSurfaceActive)
{
// WB BaseObjectRenderManager.cs:710-713:
if (drawCalls.Count == 0 || allInstances.Count == 0) return;
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// Campaign V slice V6j: the RHI arm has no vertex array — the pipeline
// owns one shaped by GpuVertexLayout.WorldMesh — so its readiness test is
// the backend-neutral HasStores flag the mesh arena publishes (V6i-3).
if (_meshManager.GlobalBuffer is not { HasStores: true })
return;
// WB BaseObjectRenderManager.cs:718-740: count the pass-filtered batches.
// A normal render has one range. The ordered transparent-shell path has
// one range per cell, retaining retail's far-to-near cell order while
// sharing a single set of buffer uploads for the entire shell pass.
int totalDraws = 0;
for (int rangeIndex = 0; rangeIndex < drawCallRanges.Count; rangeIndex++)
{
DrawCallRange range = drawCallRanges[rangeIndex];
int rangeEnd = Math.Min(range.First + range.Count, drawCalls.Count);
for (int callIndex = range.First; callIndex < rangeEnd; callIndex++)
{
var call = drawCalls[callIndex];
foreach (var batch in call.renderData.Batches)
{
// WB BaseObjectRenderManager.cs:723-731: pass-filter.
if (!BatchBelongsToPass(batch, renderPass))
continue;
if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All
&& !MatchesTransparentRoute(batch, transparentRoute, detailSurfaceActive))
{
continue;
}
totalDraws++;
}
}
}
// WB BaseObjectRenderManager.cs:743:
if (totalDraws == 0) return;
int uniqueInstanceCount = allInstances.Count;
// Campaign V slice V6j: the encoder arm owns no buffer pool and no
// imperative state bracket. Every per-frame section is a ring slice
// allocated fresh in SubmitRhi, and blend plus depth-write are baked
// into the three shell pipelines rather than set here. The frame-
// started invariant this used to enforce via ActivateNextDynamicBufferSet
// still matters (it gates the light-frame-generation cache), so it is
// checked directly.
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells.");
// 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:718-781: group and build commands.
// Group independently inside each ordered cell range. This preserves
// the old per-cell draw ordering exactly; only the repeated CPU-side
// buffer uploads have been coalesced.
_mdiDrawRanges.Clear();
int cmdIndex = 0;
for (int rangeIndex = 0; rangeIndex < drawCallRanges.Count; rangeIndex++)
{
_activeCullGroups.Clear();
for (int groupIndex = 0; groupIndex < _batchesByCullGroup.Length; groupIndex++)
_batchesByCullGroup[groupIndex].Clear();
DrawCallRange range = drawCallRanges[rangeIndex];
int rangeEnd = Math.Min(range.First + range.Count, drawCalls.Count);
for (int callIndex = range.First; callIndex < rangeEnd; callIndex++)
{
var call = drawCalls[callIndex];
foreach (var batch in call.renderData.Batches)
{
if (!BatchBelongsToPass(batch, renderPass))
continue;
if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All
&& !MatchesTransparentRoute(batch, transparentRoute, detailSurfaceActive))
{
continue;
}
int groupIndex = ResolveBatchGroupIndex(
batch,
renderPass);
List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group =
_batchesByCullGroup[groupIndex];
if (group.Count == 0)
_activeCullGroups.Add(groupIndex);
group.Add((batch, call.count, call.offset));
}
}
for (int activeIndex = 0; activeIndex < _activeCullGroups.Count; activeIndex++)
{
int groupIndex = _activeCullGroups[activeIndex];
List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group =
_batchesByCullGroup[groupIndex];
foreach (var item in group)
{
_modernBatches[cmdIndex] = new ModernBatchData
{
// Campaign V slice V2: table slot, not the raw handle.
// Slice V4t: the slot is the device's, assigned by the
// mesh manager at upload rather than interned here.
TextureTableIndex = item.batch.TextureSlot.Index,
SurfaceOpacity = item.batch.SurfaceOpacity,
TextureIndex = (uint)item.batch.TextureIndex,
// #226: this renderer submits built EnvCell meshes.
// Retail DrawMesh forwards curr_detail_surface to
// RenderMeshSubset for every material subset, including
// ClipMap, transparent, additive and inverse alpha.
Flags = 1u,
};
_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,
};
AppendMdiDrawRange(
_mdiDrawRanges,
groupIndex,
cmdIndex,
1,
item.batch.MaterialState);
cmdIndex++;
}
}
}
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
}
/// <summary>
/// Exact WB pass membership, with <see cref="WbRenderPass.SinglePass"/>
/// intentionally unchanged. A non-additive transparent batch is delayed
/// content and must never draw during the preceding opaque cell turn.
/// </summary>
internal static bool BatchBelongsToPass(
ObjectRenderBatch batch,
WbRenderPass renderPass) => renderPass switch
{
WbRenderPass.Opaque => !batch.IsAdditive && !batch.IsTransparent,
WbRenderPass.Transparent => batch.IsAdditive || batch.IsTransparent,
WbRenderPass.SinglePass => true,
_ => false,
};
private static int ResolveBatchGroupIndex(
ObjectRenderBatch batch,
WbRenderPass renderPass)
{
int cull = (int)batch.CullMode;
if ((uint)cull >= CullGroupCount)
throw new ArgumentOutOfRangeException(nameof(batch), batch.CullMode, "Unknown cell-shell cull mode.");
// Preserve the old SinglePass grouping exactly: non-additive 0..3,
// additive 4..7. Its submission path still keeps the opaque pipeline.
if (renderPass != WbRenderPass.Transparent)
return cull + (batch.IsAdditive ? AdditiveGroupBase : 0);
if (batch.IsAdditive)
return cull + AdditiveGroupBase;
// Fixed state follows ConstructMesh's retained per-subset class, not
// the DrawMesh row action. Detail-active row 1 is Immediate, but a
// mask-0x08 subset (including legal positive-stipple 0x09) still needs
// the CLIP base pipeline/reference. ConstructMesh's priority already
// excludes additive/alpha-family rows before retaining this class.
if ((batch.RetailSurfaceMask & RetailAlphaMeshRouter.MaskClipMap) == 0)
return cull;
return cull + (batch.Key.PaletteId != 0
? ClipPalettedGroupBase
: ClipDdsGroupBase);
}
internal static void AppendMdiDrawRange(
List<MdiDrawRange> ranges,
int groupIndex,
int firstCommand,
int commandCount,
RetailSetSurfaceMaterialState materialState)
{
ArgumentNullException.ThrowIfNull(ranges);
if (commandCount <= 0)
return;
if (ranges.Count > 0)
{
MdiDrawRange previous = ranges[^1];
if (previous.GroupIndex == groupIndex
&& previous.MaterialState == materialState
&& previous.FirstCommand + previous.CommandCount == firstCommand)
{
ranges[^1] = previous with
{
CommandCount = checked(previous.CommandCount + commandCount),
};
return;
}
}
ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount, materialState));
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// IDisposable
// ---------------------------------------------------------------------------
public void Dispose()
{
if (IsDisposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>
{
("prepare-scratch", _prepareScratch.Dispose),
};
// Campaign V slice V11: the raw-GL arm's dynamic buffer-set pool is
// deleted along with it. The RHI arm's pipelines route their
// physical free through the device's own retirement queue, so the
// ledger above holds only the scratch.
DisposeRhiResources();
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more EnvCell renderer resources could not be released.");
}
_dynamicFrameStarted = false;
_disposeResources = null;
IsDisposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"EnvCell renderer resources released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
}