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.
1467 lines
65 KiB
C#
1467 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 <- 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 1–3 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;
|
||
}
|
||
}
|
||
}
|