The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.
What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.
The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.
Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.
That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.
Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.
Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.
Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.
The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.
Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.
What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.
Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 3/3
RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
validation errors, zero warnings, a captured world frame, and a graceful close.
Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.
No divergence-register row: no retail-facing behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2271 lines
104 KiB
C#
2271 lines
104 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 DatReaderWriter.Enums;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
public sealed unsafe partial class EnvCellRenderer :
|
||
IDisposable,
|
||
IEnvCellLandblockPublisher
|
||
{
|
||
private readonly object _publicationOwner = new();
|
||
|
||
// Campaign V slice V6j: null on the RHI arm. Every GL statement below is
|
||
// reached only when this is non-null; the encoder arm lives in
|
||
// EnvCellRenderer.Rhi.cs and the GL arm is unchanged.
|
||
private readonly GL? _gl;
|
||
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();
|
||
|
||
// Shader (set by caller via Initialize).
|
||
// Uses acdream's legacy Shader type (not WB's GLSLShader) to match the
|
||
// existing wire-in pattern in GameWindow.cs where _meshShader is loaded
|
||
// for mesh_modern.{vert,frag} and shared across multiple consumers.
|
||
// API mapping: Bind() -> Use(), SetUniform(s, int) -> SetInt(s, int),
|
||
// SetUniform(s, Vector4) -> SetVec4(s, Vector4).
|
||
private AcDream.App.Rendering.Shader? _shader;
|
||
|
||
// Phase U.4 root-cause fix: the view-projection captured in PrepareRenderBatches,
|
||
// re-uploaded by Render() so the cell-shell pass is self-contained and does NOT
|
||
// inherit WbDrawDispatcher's uViewProjection (which the opaque pass would read one
|
||
// frame stale, since it draws BEFORE the dispatcher's upload). Same matrix the
|
||
// portal clip planes are computed with (envCellViewProj).
|
||
private Matrix4x4 _lastViewProjection = Matrix4x4.Identity;
|
||
private bool _initialized;
|
||
|
||
// List pool — copied from WB ObjectRenderManagerBase.
|
||
// WB ObjectRenderManagerBase.cs:83-86: protected readonly List<List<InstanceData>> _listPool = new(); protected int _poolIndex = 0;
|
||
private readonly List<List<InstanceData>> _listPool = new();
|
||
private int _poolIndex = 0;
|
||
|
||
// 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
|
||
// We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws.
|
||
private uint _mdiCommandBuffer;
|
||
private int _mdiCommandCapacity;
|
||
private uint _modernInstanceBuffer;
|
||
private int _modernInstanceCapacity;
|
||
private uint _modernBatchBuffer;
|
||
private int _modernBatchCapacity;
|
||
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
|
||
// InstanceData below also carries CellId/Flags for filtering, so upload a
|
||
// packed transform array instead of the 80-byte CPU struct.
|
||
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
|
||
|
||
// Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
|
||
// _modernInstanceBuffer. One uint per instance selecting its CellClip slot,
|
||
// indexed by the same BaseInstance + gl_InstanceID the shader uses for
|
||
// binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
|
||
private uint _clipSlotBuffer;
|
||
private int _clipSlotCapacity;
|
||
private uint[] _clipSlotData = Array.Empty<uint>();
|
||
|
||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
|
||
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
|
||
// left bound. binding=4 = global point-light snapshot (same data/indices as the
|
||
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
|
||
private uint _globalLightsSsbo; // binding=4
|
||
private int _globalLightsCapacity;
|
||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||
private uint _instLightSetSsbo; // binding=5
|
||
private int _instLightSetCapacity;
|
||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||
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.MaxLightsPerObject];
|
||
}
|
||
|
||
private readonly System.Collections.Generic.Dictionary<uint, CachedCellLightSet> _cellLightSetCache = new();
|
||
private readonly List<uint> _cellLightRemovalScratch = new();
|
||
private int _lightFrameGeneration;
|
||
|
||
private sealed class DynamicBufferSet
|
||
{
|
||
public uint MdiCommandBuffer;
|
||
public uint ModernInstanceBuffer;
|
||
public uint ModernBatchBuffer;
|
||
public uint ClipSlotBuffer;
|
||
public uint GlobalLightsSsbo;
|
||
public uint InstanceLightSetSsbo;
|
||
public int MdiCommandCapacity;
|
||
public int ModernInstanceCapacity;
|
||
public int ModernBatchCapacity;
|
||
public int ClipSlotCapacity;
|
||
public int GlobalLightsCapacity;
|
||
public int InstanceLightSetCapacity;
|
||
}
|
||
|
||
private readonly List<DynamicBufferSet>[] _dynamicBufferSetsByFrame =
|
||
[[], [], []];
|
||
private int _dynamicFrameSlot;
|
||
private int _dynamicBufferSetCursor;
|
||
private bool _dynamicFrameStarted;
|
||
private DynamicBufferSet? _activeDynamicBufferSet;
|
||
|
||
internal int DynamicBufferSetCount =>
|
||
_dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count);
|
||
|
||
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
|
||
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
|
||
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
|
||
private uint _sharedClipRegionSsbo;
|
||
private uint _fallbackClipRegionSsbo;
|
||
|
||
// Campaign V slice V4t (2026-07-28): the interim per-renderer
|
||
// GlBindlessHandleTable is retired. ObjectRenderBatch already carries the
|
||
// device's own GpuTextureSlot, so this renderer shares WbDrawDispatcher's
|
||
// table — the device's — and only has to flush and bind it before its own
|
||
// raw-GL draws. That also removes the V2 caveat that two renderers could
|
||
// legitimately number the same texture differently: there is now one
|
||
// numbering, and it is the one the mesh manager assigned at upload.
|
||
private AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable =>
|
||
(_meshManager
|
||
?? throw new InvalidOperationException(
|
||
"EnvCellRenderer was constructed without a mesh manager: its texture " +
|
||
"slots come from that manager's GL device table (Campaign V slice V4t)."))
|
||
.WorldTextureTable;
|
||
|
||
// 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 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();
|
||
private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup =
|
||
Enumerable.Range(0, 8)
|
||
.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);
|
||
// 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();
|
||
|
||
// Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28.
|
||
// Shared across all manager instances on the same GL context.
|
||
private static uint _currentVao;
|
||
private static CullMode? _currentCullMode;
|
||
|
||
public bool NeedsPrepare { get; private set; } = true;
|
||
|
||
// --- 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;
|
||
|
||
/// <summary>
|
||
/// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28).
|
||
/// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water).
|
||
/// A divergence between expected and actual values would indicate a pool-
|
||
/// management regression — exactly the bug class the 2026-05-28 audit caught.
|
||
/// </summary>
|
||
public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics()
|
||
{
|
||
int poolTotal;
|
||
lock (_listPool) poolTotal = _listPool.Count;
|
||
int hwm;
|
||
lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex;
|
||
return (poolTotal, hwm);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up).
|
||
/// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes +
|
||
/// transparency flags + bindless-handle-non-zero status, so the operator
|
||
/// can read offline and identify why specific polys (e.g., floors) aren't
|
||
/// rendering. Set <c>ACDREAM_A8_AUDIT=1</c> to enable.
|
||
/// <para>Returns a deduplicated audit-line list per Render snapshot
|
||
/// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller
|
||
/// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs
|
||
/// have already been logged.</para>
|
||
/// </summary>
|
||
public IReadOnlyList<string> CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged)
|
||
{
|
||
var lines = new List<string>();
|
||
lock (_renderLock)
|
||
{
|
||
var snap = _activeSnapshot;
|
||
foreach (var (cellId, gfxDict) in snap.BatchedByCell)
|
||
{
|
||
foreach (var (gfxObjId, transforms) in gfxDict)
|
||
{
|
||
var key = (cellId, gfxObjId);
|
||
if (alreadyLogged.Contains(key)) continue;
|
||
alreadyLogged.Add(key);
|
||
|
||
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (rd is null)
|
||
{
|
||
lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null");
|
||
continue;
|
||
}
|
||
int totalIdx = 0;
|
||
var cullModes = new HashSet<DatReaderWriter.Enums.CullMode>();
|
||
int translucent = 0;
|
||
int additive = 0;
|
||
int zeroHandle = 0;
|
||
foreach (var b in rd.Batches)
|
||
{
|
||
totalIdx += b.IndexCount;
|
||
cullModes.Add(b.CullMode);
|
||
if (b.IsTransparent) translucent++;
|
||
if (b.IsAdditive) additive++;
|
||
if (!b.TextureSlot.IsAssigned) zeroHandle++;
|
||
}
|
||
var cullList = string.Join(",", cullModes);
|
||
lines.Add(
|
||
$"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " +
|
||
$"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " +
|
||
$"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}");
|
||
}
|
||
}
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constructor + Initialize
|
||
// ---------------------------------------------------------------------------
|
||
|
||
public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum)
|
||
{
|
||
_gl = gl;
|
||
_meshManager = meshManager;
|
||
_frustum = frustum;
|
||
}
|
||
|
||
public void Initialize(AcDream.App.Rendering.Shader shader)
|
||
{
|
||
_shader = shader;
|
||
_initialized = true;
|
||
}
|
||
|
||
/// <summary>Resets the per-frame submission cursor for the GPU-fenced slot.</summary>
|
||
public void BeginFrame(int frameSlot)
|
||
{
|
||
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
|
||
throw new ArgumentOutOfRangeException(nameof(frameSlot));
|
||
_dynamicFrameSlot = frameSlot;
|
||
_dynamicBufferSetCursor = 0;
|
||
_dynamicFrameStarted = true;
|
||
_activeDynamicBufferSet = null;
|
||
if (++_lightFrameGeneration == 0)
|
||
{
|
||
_cellLightSetCache.Clear();
|
||
_lightFrameGeneration = 1;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase U.3: hand the renderer the SHARED per-cell clip-region SSBO
|
||
/// (binding=2) created by <see cref="ClipFrame.UploadShared"/>. The renderer
|
||
/// re-binds it to binding=2 immediately before its MDI. Pass 0 to fall back to
|
||
/// the internal one-slot no-clip region buffer.
|
||
/// </summary>
|
||
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
|
||
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
|
||
|
||
// Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When
|
||
// non-null, RenderModernMDIInternal writes instanceClipSlot[i] =
|
||
// _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are
|
||
// gated to that cell's portal-clip region. When null (U.3 path), every
|
||
// instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0
|
||
// (no-clip) — but the caller's Render filter already restricts the draw to the
|
||
// map's keys, so that fallback should not fire in practice.
|
||
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
|
||
|
||
/// <summary>
|
||
/// Phase U.4: install the per-frame cellId→slot map used to gate cell shells
|
||
/// to their portal-clip regions. Call once per frame BEFORE
|
||
/// <see cref="Render(WbRenderPass, HashSet{uint}?)"/>. Pass null to revert to
|
||
/// the U.3 no-clip behavior (every shell instance → slot 0).
|
||
/// </summary>
|
||
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
|
||
=> _cellIdToSlot = cellIdToSlot;
|
||
|
||
/// <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);
|
||
}
|
||
|
||
/// <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);
|
||
|
||
/// <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);
|
||
}
|
||
|
||
private void RenderCore(
|
||
WbRenderPass renderPass,
|
||
HashSet<uint>? filter,
|
||
IReadOnlyList<uint>? orderedCellIds)
|
||
{
|
||
// WB EnvCellRenderManager.cs:400:
|
||
if (!_initialized) return;
|
||
// Campaign V slice V6j: the RHI arm has no linked program to check — the
|
||
// pipeline it draws with was built at construction and the same readiness
|
||
// question is answered by _initialized alone.
|
||
if (_gl is not null && (_shader is null || _shader.Program == 0)) return;
|
||
|
||
lock (_renderLock)
|
||
{
|
||
var snapshot = _activeSnapshot;
|
||
// WB EnvCellRenderManager.cs:403-404:
|
||
_shader?.Use();
|
||
// FIX 2026-05-28 (pool aliasing root cause): mirror WB
|
||
// EnvCellRenderManager.cs:405 — restore the pool cursor to the
|
||
// high-water mark Prepare's merge phase reached, so any
|
||
// GetPooledList calls below return lists past the snapshot's
|
||
// owned region. Original code used `snapshot.BatchedByCell.Count`
|
||
// (number of cells, e.g. 18) which has no relation to the pool
|
||
// index and pointed back into snapshot data, corrupting it
|
||
// mid-Render. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||
_poolIndex = snapshot.PostPreparePoolIndex;
|
||
|
||
// FIX 2026-05-28: invalidate static GL-state caches at start of Render.
|
||
// Mirrors WB EnvCellRenderManager.cs:404-410:
|
||
// CurrentVAO = 0; CurrentIBO = 0; CurrentAtlas = 0;
|
||
// CurrentInstanceBuffer = 0; CurrentCullMode = null;
|
||
//
|
||
// These caches let SetCullMode / BindVertexArray skip redundant GL
|
||
// calls when the state is already correct. BUT: between two Render()
|
||
// invocations, OTHER consumers (WbDrawDispatcher, terrain, the
|
||
// RenderInsideOutAcdream stencil pipeline) change the actual GL
|
||
// state without updating these caches. The cache then lies, and
|
||
// the per-batch SetCullMode in RenderModernMDIInternal skips its
|
||
// glCullFace call — leaving stale cull state from the prior
|
||
// consumer. For a cottage with mixed CullMode batches, half the
|
||
// walls end up culled and the user sees "missing walls".
|
||
//
|
||
// Forcing the cache to null/0 at entry guarantees each Render call
|
||
// re-establishes the GL state it expects.
|
||
_currentVao = 0;
|
||
_currentCullMode = null;
|
||
|
||
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
|
||
_shader?.SetInt("uRenderPass", (int)renderPass);
|
||
_shader?.SetInt("uFilterByCell", 0);
|
||
_shader?.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
|
||
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
|
||
_shader?.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
|
||
|
||
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
|
||
// moving"): upload uViewProjection HERE rather than inheriting it from
|
||
// WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's
|
||
// Draw (GameWindow ~7411 vs ~7418, the only other setter), so without
|
||
// this the opaque shells used the PREVIOUS frame's matrix — a stale
|
||
// gl_Position against this frame's clip planes → pose-dependent clipping,
|
||
// worst while moving. Same self-contained-GL-state precedent as the
|
||
// 2026-05-28 cull-state cache fix above.
|
||
_shader?.SetMatrix4("uViewProjection", _lastViewProjection);
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// #176 seam-draw probe: stash this call's filter so the opaque-pass
|
||
// emitter inside RenderModernMDIInternal can report flood membership
|
||
// per target cell (null on the unfiltered/outdoor path).
|
||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||
_seamProbeFilter = filter;
|
||
|
||
// 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(
|
||
_shader,
|
||
drawCalls,
|
||
allInstances,
|
||
_drawCallRanges,
|
||
renderPass);
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
|
||
|
||
// WB EnvCellRenderManager.cs:506-509: cleanup.
|
||
_shader?.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0));
|
||
_shader?.SetInt("uRenderPass", (int)renderPass);
|
||
_gl?.BindVertexArray(0);
|
||
_currentVao = 0;
|
||
|
||
// No cull restore at exit, matching WB's manager pattern: the
|
||
// last SetCullMode call reflects actual GL state, and the next
|
||
// Render call invalidates `_currentCullMode` before issuing its
|
||
// own per-batch state. The Landblock->None override below can
|
||
// intentionally leave cull disabled for the following IndoorPass,
|
||
// preserving the shipped Gate #5 baseline while deeper evidence is
|
||
// gathered.
|
||
|
||
// Update frame stats for probe emission at the call site.
|
||
_lastFrameStats.CellsRendered = 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;
|
||
|
||
// Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY.
|
||
// Per opaque-pass call: totals + per visible (filtered) cell whether it is
|
||
// present in the prepared snapshot, and its geometry/flags. Answers why the
|
||
// interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry
|
||
// prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture
|
||
// (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/
|
||
// occlusion or the geometry isn't the wall). Opaque pass only (halves noise).
|
||
if (renderPass == WbRenderPass.Opaque
|
||
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled)
|
||
{
|
||
var sb = new System.Text.StringBuilder(256);
|
||
sb.Append("[shell] filter=").Append(filter?.Count ?? -1)
|
||
.Append(" drawCalls=").Append(drawCalls.Count)
|
||
.Append(" inst=").Append(allInstances.Count)
|
||
.Append(" tris=").Append(_lastFrameStats.TrianglesDrawn);
|
||
if (filter != null)
|
||
{
|
||
foreach (var cellId in filter)
|
||
{
|
||
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict))
|
||
{
|
||
sb.Append(" [0x").Append(cellId.ToString("X8")).Append(":NOSNAP]");
|
||
continue;
|
||
}
|
||
int gfxN = 0, tf = 0, batch = 0, idx = 0, tr = 0, zh = 0;
|
||
foreach (var (gfxObjId, transforms) in gfxDict)
|
||
{
|
||
gfxN++; tf += transforms.Count;
|
||
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (rd != null)
|
||
foreach (var b in rd.Batches)
|
||
{ batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (!b.TextureSlot.IsAssigned) zh++; }
|
||
}
|
||
sb.Append(" [0x").Append(cellId.ToString("X8"))
|
||
.Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf)
|
||
.Append(" batch=").Append(batch).Append(" idx=").Append(idx)
|
||
.Append(" tr=").Append(tr).Append(" zh=").Append(zh).Append(']');
|
||
}
|
||
}
|
||
System.Console.WriteLine(sb.ToString());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GetCellLightSet (A7 Fix D D-2 helper)
|
||
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
|
||
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
|
||
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
|
||
// Cached per frame; unused slots are -1 (shader adds no point light there).
|
||
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;
|
||
// Landblocks are keyed by the streaming landblock id 0xXXYYFFFF
|
||
// (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock
|
||
// key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key
|
||
// (0xXXYY0000) NEVER matched a registered landblock, so this lookup always
|
||
// missed: SelectForObject never ran and every EnvCell wall received ZERO
|
||
// point lights (the entire "indoor torches/lanterns don't light the room"
|
||
// bug — confirmed by the [cell-light] probe: inBounds=False for every cell).
|
||
if (snap is { Count: > 0 } &&
|
||
_landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) &&
|
||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||
{
|
||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||
// #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL
|
||
// dynamic lights on every cell (stable), not the per-object sphere-overlap cull that
|
||
// let the portal set flip as the flood shifted → floor-lighting flap.
|
||
AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, 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 ActivateNextDynamicBufferSet()
|
||
{
|
||
if (!_dynamicFrameStarted)
|
||
throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells.");
|
||
|
||
List<DynamicBufferSet> slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot];
|
||
if (_dynamicBufferSetCursor == slotSets.Count)
|
||
slotSets.Add(CreateDynamicBufferSet());
|
||
|
||
DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++];
|
||
_activeDynamicBufferSet = set;
|
||
_mdiCommandBuffer = set.MdiCommandBuffer;
|
||
_modernInstanceBuffer = set.ModernInstanceBuffer;
|
||
_modernBatchBuffer = set.ModernBatchBuffer;
|
||
_clipSlotBuffer = set.ClipSlotBuffer;
|
||
_globalLightsSsbo = set.GlobalLightsSsbo;
|
||
_instLightSetSsbo = set.InstanceLightSetSsbo;
|
||
_mdiCommandCapacity = set.MdiCommandCapacity;
|
||
_modernInstanceCapacity = set.ModernInstanceCapacity;
|
||
_modernBatchCapacity = set.ModernBatchCapacity;
|
||
_clipSlotCapacity = set.ClipSlotCapacity;
|
||
_globalLightsCapacity = set.GlobalLightsCapacity;
|
||
_instLightSetCapacity = set.InstanceLightSetCapacity;
|
||
}
|
||
|
||
private DynamicBufferSet CreateDynamicBufferSet()
|
||
{
|
||
var set = new DynamicBufferSet();
|
||
try
|
||
{
|
||
set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell MDI buffer");
|
||
set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell instance SSBO");
|
||
set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell batch SSBO");
|
||
set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell clip-slot SSBO");
|
||
set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell global-light SSBO");
|
||
set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell light-set SSBO");
|
||
return set;
|
||
}
|
||
catch (Exception creationFailure)
|
||
{
|
||
try { DeleteDynamicBufferSet(set); }
|
||
catch (Exception cleanupFailure)
|
||
{
|
||
throw new AggregateException(
|
||
"EnvCell dynamic-buffer creation and rollback failed.",
|
||
creationFailure,
|
||
cleanupFailure);
|
||
}
|
||
throw;
|
||
}
|
||
}
|
||
|
||
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 DeleteDynamicBufferSet(DynamicBufferSet set)
|
||
{
|
||
List<Exception>? failures = null;
|
||
void Attempt(uint buffer, long bytes, string name)
|
||
{
|
||
try { TrackedGlResource.DeleteBuffer(_gl!, buffer, bytes, $"deleting {name}"); }
|
||
catch (Exception ex) { (failures ??= []).Add(ex); }
|
||
}
|
||
|
||
Attempt(
|
||
set.MdiCommandBuffer,
|
||
(long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand),
|
||
"EnvCell MDI buffer");
|
||
Attempt(
|
||
set.ModernInstanceBuffer,
|
||
(long)set.ModernInstanceCapacity * sizeof(Matrix4x4),
|
||
"EnvCell instance SSBO");
|
||
Attempt(
|
||
set.ModernBatchBuffer,
|
||
(long)set.ModernBatchCapacity * sizeof(ModernBatchData),
|
||
"EnvCell batch SSBO");
|
||
Attempt(set.ClipSlotBuffer, (long)set.ClipSlotCapacity * sizeof(uint), "EnvCell clip-slot SSBO");
|
||
Attempt(
|
||
set.GlobalLightsSsbo,
|
||
(long)set.GlobalLightsCapacity
|
||
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
|
||
* sizeof(float),
|
||
"EnvCell global-light SSBO");
|
||
Attempt(
|
||
set.InstanceLightSetSsbo,
|
||
(long)set.InstanceLightSetCapacity
|
||
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
|
||
* sizeof(int),
|
||
"EnvCell light-set SSBO");
|
||
|
||
if (failures is not null)
|
||
throw new AggregateException("One or more EnvCell dynamic buffers failed to delete.", failures);
|
||
}
|
||
|
||
private void PersistActiveDynamicBufferCapacities()
|
||
{
|
||
DynamicBufferSet set = _activeDynamicBufferSet
|
||
?? throw new InvalidOperationException("No dynamic EnvCell buffer set is active.");
|
||
set.MdiCommandCapacity = _mdiCommandCapacity;
|
||
set.ModernInstanceCapacity = _modernInstanceCapacity;
|
||
set.ModernBatchCapacity = _modernBatchCapacity;
|
||
set.ClipSlotCapacity = _clipSlotCapacity;
|
||
set.GlobalLightsCapacity = _globalLightsCapacity;
|
||
set.InstanceLightSetCapacity = _instLightSetCapacity;
|
||
}
|
||
|
||
private void RenderModernMDIInternal(
|
||
AcDream.App.Rendering.Shader? shader,
|
||
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||
List<InstanceData> allInstances,
|
||
IReadOnlyList<DrawCallRange> drawCallRanges,
|
||
WbRenderPass renderPass)
|
||
{
|
||
// WB BaseObjectRenderManager.cs:710-713:
|
||
if (drawCalls.Count == 0 || allInstances.Count == 0) return;
|
||
|
||
int passIdx = (int)renderPass;
|
||
if (passIdx < 0 || passIdx > 2) return;
|
||
|
||
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
|
||
// Without the global VAO nothing can draw, and returning AFTER the pass state
|
||
// was established leaked it (same early-out shape as the totalDraws==0 leak —
|
||
// see the comment on the state-establish block below).
|
||
// 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 the arena publishes (V6i-3).
|
||
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
|
||
if (_gl is not null)
|
||
{
|
||
if (globalVao == 0) return;
|
||
}
|
||
else if (_meshManager.GlobalBuffer is not { HasStores: true })
|
||
{
|
||
return;
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:715-716:
|
||
shader?.Use();
|
||
shader?.SetInt("uFilterByCell", 0);
|
||
|
||
// 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 (renderPass != WbRenderPass.SinglePass)
|
||
{
|
||
if (batch.IsAdditive)
|
||
{
|
||
if (renderPass == WbRenderPass.Opaque) continue;
|
||
}
|
||
else if (!batch.IsTransparent)
|
||
{
|
||
if (renderPass == WbRenderPass.Transparent) 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, so
|
||
// there is nothing to activate or grow, and blend plus depth-write are
|
||
// baked into the three shell pipelines rather than set here.
|
||
if (_gl is not null)
|
||
{
|
||
ActivateNextDynamicBufferSet();
|
||
|
||
// Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish
|
||
// background, flickering when moving"): establish this pass's BLEND + DepthMask
|
||
// state OURSELVES rather than inheriting it. Mirror the working WbDrawDispatcher
|
||
// passes (Disable(Blend)+DepthMask(true) opaque; Enable(Blend)+DepthMask(false)
|
||
// transparent). Restored to opaque defaults at the end of the draw loop so a
|
||
// Transparent pass can't leak into later draws.
|
||
//
|
||
// §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the
|
||
// totalDraws==0 early-out above. It used to run before the batch grouping, so a
|
||
// Transparent pass over a cell whose batches are ALL opaque (a plain cottage
|
||
// interior) set Blend-on/DepthMask-off and then returned at the count check
|
||
// WITHOUT reaching the restore. The frame ended with dmask=0; the NEXT frame's
|
||
// glClear(DEPTH) silently no-oped (depth clears honor glDepthMask), every world
|
||
// fragment failed GL_LESS against its own previous-frame depth ghost, and the
|
||
// whole screen dropped to the fog-tinted clear color — onset-locked to the
|
||
// building-flood merge (the first frame a flooded building shell draws), holding
|
||
// until camera rotation dropped the cell from the flood. From here down every
|
||
// path reaches the end-of-pass restore.
|
||
if (renderPass == WbRenderPass.Transparent)
|
||
{
|
||
_gl.Enable(EnableCap.Blend);
|
||
_gl.DepthMask(false);
|
||
}
|
||
else
|
||
{
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.DepthMask(true);
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:745-759: resize buffers if needed.
|
||
if (totalDraws > _mdiCommandCapacity)
|
||
{
|
||
int grownMdiCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
GLEnum.DrawIndirectBuffer,
|
||
_mdiCommandBuffer,
|
||
(long)_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand),
|
||
(long)grownMdiCapacity * sizeof(DrawElementsIndirectCommand),
|
||
GLEnum.DynamicDraw,
|
||
$"growing EnvCell MDI buffer to {grownMdiCapacity} commands");
|
||
_mdiCommandCapacity = grownMdiCapacity;
|
||
|
||
int grownBatchCapacity = grownMdiCapacity;
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
GLEnum.ShaderStorageBuffer,
|
||
_modernBatchBuffer,
|
||
(long)_modernBatchCapacity * sizeof(ModernBatchData),
|
||
(long)grownBatchCapacity * sizeof(ModernBatchData),
|
||
GLEnum.DynamicDraw,
|
||
$"growing EnvCell batch SSBO to {grownBatchCapacity} batches");
|
||
_modernBatchCapacity = grownBatchCapacity;
|
||
}
|
||
|
||
if (uniqueInstanceCount > _modernInstanceCapacity)
|
||
{
|
||
int grownInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
GLEnum.ShaderStorageBuffer,
|
||
_modernInstanceBuffer,
|
||
(long)_modernInstanceCapacity * sizeof(Matrix4x4),
|
||
(long)grownInstanceCapacity * sizeof(Matrix4x4),
|
||
GLEnum.DynamicDraw,
|
||
$"growing EnvCell instance SSBO to {grownInstanceCapacity} instances");
|
||
_modernInstanceCapacity = grownInstanceCapacity;
|
||
}
|
||
|
||
// Phase U.3: keep the clip-slot buffer (binding=3) sized to the
|
||
// instance prefix so instanceClipSlot[BaseInstance + gl_InstanceID]
|
||
// is always in range. It owns an independent committed capacity so a
|
||
// failed allocation can never publish the instance buffer's growth as
|
||
// if both resources had succeeded.
|
||
if (uniqueInstanceCount > _clipSlotCapacity)
|
||
{
|
||
int grownClipCapacity = Math.Max(_clipSlotCapacity * 2, uniqueInstanceCount);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
GLEnum.ShaderStorageBuffer,
|
||
_clipSlotBuffer,
|
||
(long)_clipSlotCapacity * sizeof(uint),
|
||
(long)grownClipCapacity * sizeof(uint),
|
||
GLEnum.DynamicDraw,
|
||
$"growing EnvCell clip-slot SSBO to {grownClipCapacity} instances");
|
||
_clipSlotCapacity = grownClipCapacity;
|
||
}
|
||
|
||
if (uniqueInstanceCount > _instLightSetCapacity)
|
||
{
|
||
int grownLightSetCapacity = Math.Max(_instLightSetCapacity * 2, uniqueInstanceCount);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
GLEnum.ShaderStorageBuffer,
|
||
_instLightSetSsbo,
|
||
(long)_instLightSetCapacity
|
||
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
|
||
* sizeof(int),
|
||
(long)grownLightSetCapacity
|
||
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
|
||
* sizeof(int),
|
||
GLEnum.DynamicDraw,
|
||
$"growing EnvCell light-set SSBO to {grownLightSetCapacity} instances");
|
||
_instLightSetCapacity = grownLightSetCapacity;
|
||
}
|
||
}
|
||
|
||
// 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 (renderPass != WbRenderPass.SinglePass)
|
||
{
|
||
if (batch.IsAdditive)
|
||
{
|
||
if (renderPass == WbRenderPass.Opaque) continue;
|
||
}
|
||
else if (!batch.IsTransparent)
|
||
{
|
||
if (renderPass == WbRenderPass.Transparent) continue;
|
||
}
|
||
}
|
||
|
||
int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0);
|
||
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];
|
||
int firstCommand = cmdIndex;
|
||
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,
|
||
TextureIndex = (uint)item.batch.TextureIndex,
|
||
};
|
||
|
||
_commands[cmdIndex] = new DrawElementsIndirectCommand
|
||
{
|
||
Count = (uint)item.batch.IndexCount,
|
||
InstanceCount = (uint)item.instanceCount,
|
||
FirstIndex = item.batch.FirstIndex,
|
||
BaseVertex = (int)item.batch.BaseVertex,
|
||
BaseInstance = (uint)item.instanceOffset,
|
||
};
|
||
cmdIndex++;
|
||
}
|
||
|
||
int commandCount = cmdIndex - firstCommand;
|
||
if (commandCount == 0)
|
||
continue;
|
||
|
||
// Adjacent cells frequently resolve to the same cull/blend
|
||
// state. Their commands are already contiguous and remain in
|
||
// strict cell order, so one MDI call can cover the complete run
|
||
// without changing alpha compositing or gl_DrawID indexing.
|
||
AppendMdiDrawRange(
|
||
_mdiDrawRanges,
|
||
groupIndex,
|
||
firstCommand,
|
||
commandCount);
|
||
}
|
||
}
|
||
|
||
if (_gl is null)
|
||
{
|
||
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
|
||
return;
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:784-805 upload. Retain capacity and
|
||
// update the active prefix so portal frames cannot enqueue an unbounded
|
||
// chain of retired driver allocations.
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||
fixed (DrawElementsIndirectCommand* ptr = _commands)
|
||
{
|
||
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
|
||
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr);
|
||
}
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
|
||
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
|
||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||
_gpuInstanceTransforms[i] = allInstances[i].Transform;
|
||
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
|
||
{
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
|
||
}
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
|
||
fixed (ModernBatchData* ptr = _modernBatches)
|
||
{
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(totalDraws * sizeof(ModernBatchData)), ptr);
|
||
}
|
||
|
||
// Phase U.4: upload the per-instance clip-slot buffer (binding=3). When
|
||
// _cellIdToSlot is set (indoor routing), each cell shell instance is gated
|
||
// to its cell's CellClip slot via allInstances[i].CellId; cells absent from
|
||
// the map (shouldn't happen — the Render filter is the map's keys) and the
|
||
// U.3 path both map to slot 0 (no-clip). allInstances is laid out in the
|
||
// SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so
|
||
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
|
||
if (_clipSlotData.Length < uniqueInstanceCount)
|
||
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
|
||
// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1): force every shell
|
||
// instance to slot 0 (no-clip) — retail draws cell shells WHOLE.
|
||
if (_cellIdToSlot is null
|
||
|| AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim)
|
||
{
|
||
Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
|
||
}
|
||
else
|
||
{
|
||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||
_clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot)
|
||
? (uint)slot : 0u;
|
||
}
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
|
||
fixed (uint* ptr = _clipSlotData)
|
||
{
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
|
||
}
|
||
|
||
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
|
||
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
|
||
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
|
||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
|
||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||
{
|
||
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||
}
|
||
|
||
// #176 seam-draw probe: emitted HERE (not in Render) so the per-cell light
|
||
// sets read through the just-cleared cache against THIS frame's
|
||
// _pointSnapshot — the exact data the SSBO upload below carries.
|
||
if (renderPass == WbRenderPass.Opaque
|
||
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||
EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter);
|
||
|
||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||
if (glUploadCount > _globalLightsCapacity)
|
||
{
|
||
int grownGlobalLightCapacity = Math.Max(_globalLightsCapacity * 2, glUploadCount);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
GLEnum.ShaderStorageBuffer,
|
||
_globalLightsSsbo,
|
||
(long)_globalLightsCapacity
|
||
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
|
||
* sizeof(float),
|
||
(long)grownGlobalLightCapacity
|
||
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
|
||
* sizeof(float),
|
||
GLEnum.DynamicDraw,
|
||
$"growing EnvCell global-light SSBO to {grownGlobalLightCapacity} lights");
|
||
_globalLightsCapacity = grownGlobalLightCapacity;
|
||
}
|
||
fixed (float* gp = _globalLightData)
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||
fixed (int* lp = _lightSetData)
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||
|
||
PersistActiveDynamicBufferCapacities();
|
||
|
||
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
|
||
// (globalVao validated at the top of the method — a return here would leak the
|
||
// pass state established above.)
|
||
if (_currentVao != globalVao)
|
||
{
|
||
_gl.BindVertexArray(globalVao);
|
||
_currentVao = globalVao;
|
||
}
|
||
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer);
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer);
|
||
// Phase U.3: per-instance clip slots (binding=3) + shared clip regions
|
||
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
|
||
BindClipRegionBinding2();
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
|
||
FlushAndBindTextureTable(); // Campaign V slice V2 (binding=9)
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||
|
||
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
|
||
|
||
// WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls.
|
||
// The ranges retain ordered-cell boundaries, so transparent geometry
|
||
// stays far-to-near even though all command data was uploaded once.
|
||
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
|
||
{
|
||
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
|
||
int groupIndex = drawRange.GroupIndex;
|
||
var cullMode = (CullMode)(groupIndex % 4);
|
||
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
|
||
// uniformly, but the room surfaces need to be visible from inside
|
||
// under acdream's current global winding state. Render cell polys
|
||
// double-sided while the architectural cause is isolated.
|
||
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
|
||
if (_currentCullMode != cullMode)
|
||
{
|
||
SetCullMode(cullMode);
|
||
}
|
||
|
||
bool isAdditive = groupIndex >= 4;
|
||
if (isAdditive)
|
||
{
|
||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||
shader!.SetInt("uRenderPass", (int)renderPass | 0x100);
|
||
}
|
||
else
|
||
{
|
||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||
shader!.SetInt("uRenderPass", (int)renderPass);
|
||
}
|
||
|
||
shader!.SetInt("uDrawIDOffset", drawRange.FirstCommand);
|
||
_gl.MultiDrawElementsIndirect(
|
||
PrimitiveType.Triangles,
|
||
DrawElementsType.UnsignedShort,
|
||
(void*)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
|
||
(uint)drawRange.CommandCount,
|
||
(uint)sizeof(DrawElementsIndirectCommand));
|
||
}
|
||
|
||
// Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's
|
||
// post-transparent restore) so a Transparent pass's Blend-on / DepthMask-off does
|
||
// not leak into particles or the next frame's draws.
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.DepthMask(true);
|
||
|
||
// WB BaseObjectRenderManager.cs:845-847:
|
||
shader!.SetInt("uDrawIDOffset", 0);
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||
}
|
||
|
||
internal static void AppendMdiDrawRange(
|
||
List<MdiDrawRange> ranges,
|
||
int groupIndex,
|
||
int firstCommand,
|
||
int commandCount)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(ranges);
|
||
if (commandCount <= 0)
|
||
return;
|
||
|
||
if (ranges.Count > 0)
|
||
{
|
||
MdiDrawRange previous = ranges[^1];
|
||
if (previous.GroupIndex == groupIndex
|
||
&& previous.FirstCommand + previous.CommandCount == firstCommand)
|
||
{
|
||
ranges[^1] = previous with
|
||
{
|
||
CommandCount = checked(previous.CommandCount + commandCount),
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
|
||
ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus.
|
||
// The in-engine replacement for the RenderDoc pixel-history the pipeline
|
||
// can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern
|
||
// startup gate throws). Per opaque pass: for each target cell — flood
|
||
// membership, every shell instance (count + translation, F3 z shows the
|
||
// +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the
|
||
// cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 +
|
||
// intensity; raw indices shuffle when the pool rebuilds). Plus the
|
||
// snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are
|
||
// ~1–2). Change-deduped block with a 2 s heartbeat: a purple identity
|
||
// flipping with flood membership = the snapshot-scope mechanism; two
|
||
// coincident instances = the z-fight. See RenderingDiagnostics.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private HashSet<uint>? _seamProbeFilter;
|
||
private string? _seamSig;
|
||
private long _seamLastEmitMs;
|
||
|
||
private void EmitSeamDrawProbe(
|
||
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||
List<InstanceData> allInstances,
|
||
HashSet<uint>? filter)
|
||
{
|
||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||
var snap = _pointSnapshot;
|
||
var sb = new System.Text.StringBuilder(640);
|
||
|
||
var sorted = new List<uint>(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells);
|
||
sorted.Sort();
|
||
foreach (uint cell in sorted)
|
||
{
|
||
sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8"));
|
||
sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N'));
|
||
|
||
int totalInst = 0;
|
||
foreach (var dc in drawCalls)
|
||
{
|
||
int n = 0;
|
||
InstanceData first = default;
|
||
for (int i = dc.offset; i < dc.offset + dc.count; i++)
|
||
{
|
||
if (allInstances[i].CellId != cell) continue;
|
||
if (n == 0) first = allInstances[i];
|
||
n++;
|
||
}
|
||
if (n == 0) continue;
|
||
totalInst += n;
|
||
var t = first.Transform.Translation;
|
||
sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})",
|
||
dc.gfxObjId, n, t.X, t.Y, t.Z);
|
||
}
|
||
if (totalInst == 0) sb.Append(" inst=0");
|
||
|
||
// The 8-light set this cell's instances carry (fresh: the per-pass
|
||
// cache was cleared at the top of RenderModernMDIInternal).
|
||
int[] set = GetCellLightSet(cell);
|
||
sb.Append(" L=[");
|
||
bool any = false;
|
||
for (int k = 0; k < set.Length; k++)
|
||
{
|
||
int idx = set[k];
|
||
if (idx < 0) continue;
|
||
if (any) sb.Append(',');
|
||
if (snap is not null && idx < snap.Count)
|
||
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||
else
|
||
sb.Append('?').Append(idx);
|
||
any = true;
|
||
}
|
||
sb.Append(']');
|
||
}
|
||
|
||
sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=[");
|
||
if (snap is not null)
|
||
{
|
||
bool anyHot = false;
|
||
for (int i = 0; i < snap.Count; i++)
|
||
{
|
||
var ls = snap[i];
|
||
if (ls.Intensity < 50f) continue;
|
||
if (anyHot) sb.Append(',');
|
||
sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})",
|
||
ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z);
|
||
anyHot = true;
|
||
}
|
||
}
|
||
sb.Append(']');
|
||
|
||
string sig = sb.ToString();
|
||
long now = System.Environment.TickCount64;
|
||
bool changed = sig != _seamSig;
|
||
if (!changed && (now - _seamLastEmitMs) < 2000) return;
|
||
_seamSig = sig;
|
||
_seamLastEmitMs = now;
|
||
System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SetCullMode
|
||
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private void SetCullMode(CullMode mode)
|
||
{
|
||
_currentCullMode = mode;
|
||
switch (mode)
|
||
{
|
||
case CullMode.None:
|
||
_gl!.Disable(EnableCap.CullFace);
|
||
break;
|
||
case CullMode.Clockwise:
|
||
_gl!.Enable(EnableCap.CullFace);
|
||
_gl.CullFace(TriangleFace.Front);
|
||
break;
|
||
case CullMode.CounterClockwise:
|
||
case CullMode.Landblock:
|
||
_gl!.Enable(EnableCap.CullFace);
|
||
_gl.CullFace(TriangleFace.Back);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FlushAndBindTextureTable (Campaign V slice V2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V4t: drains the device texture table's dirty runs and
|
||
/// (re)binds it at
|
||
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
|
||
/// A genuinely new slot is rare — new dat surfaces/atlases, not every frame
|
||
/// — but the bind is unconditional, because GL's storage-buffer binding
|
||
/// points are global and another raw-GL renderer's binding 9 sits there
|
||
/// between two of these draws.
|
||
/// </summary>
|
||
private void FlushAndBindTextureTable()
|
||
{
|
||
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable;
|
||
device.FlushTextureTable();
|
||
_gl!.BindBufferBase(
|
||
GLEnum.ShaderStorageBuffer,
|
||
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
|
||
device.TextureTableGlName);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BindClipRegionBinding2 (Phase U.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Bind the per-cell clip-region SSBO to binding=2. Prefers the shared
|
||
/// <see cref="ClipFrame"/> buffer (<see cref="SetClipRegionSsbo"/>); otherwise
|
||
/// lazily creates + binds a one-slot no-clip fallback (count 0 = pass-all) so
|
||
/// the shader never reads an unbound SSBO.
|
||
/// </summary>
|
||
private void BindClipRegionBinding2()
|
||
{
|
||
if (_sharedClipRegionSsbo != 0)
|
||
{
|
||
_gl!.BindBufferBase(GLEnum.ShaderStorageBuffer,
|
||
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
|
||
return;
|
||
}
|
||
|
||
if (_fallbackClipRegionSsbo == 0)
|
||
{
|
||
uint fallback = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell fallback clip SSBO");
|
||
bool allocated = false;
|
||
try
|
||
{
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl!,
|
||
GLEnum.ShaderStorageBuffer,
|
||
fallback,
|
||
0,
|
||
AcDream.App.Rendering.ClipFrame.CellClipStrideBytes,
|
||
GLEnum.DynamicDraw,
|
||
"allocating EnvCell fallback clip SSBO");
|
||
allocated = true;
|
||
// One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
|
||
Span<byte> zero = stackalloc byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes];
|
||
zero.Clear();
|
||
fixed (byte* p = zero)
|
||
{
|
||
_gl!.BufferSubData(
|
||
GLEnum.ShaderStorageBuffer,
|
||
0,
|
||
(nuint)zero.Length,
|
||
p);
|
||
}
|
||
GLHelpers.ThrowOnResourceError(_gl, "initializing EnvCell fallback clip SSBO");
|
||
_fallbackClipRegionSsbo = fallback;
|
||
}
|
||
catch
|
||
{
|
||
TrackedGlResource.DeleteBuffer(
|
||
_gl!,
|
||
fallback,
|
||
allocated ? AcDream.App.Rendering.ClipFrame.CellClipStrideBytes : 0,
|
||
"rolling back EnvCell fallback clip SSBO");
|
||
throw;
|
||
}
|
||
}
|
||
_gl!.BindBufferBase(GLEnum.ShaderStorageBuffer,
|
||
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// List pool (GetPooledList)
|
||
// Copied from WB ObjectRenderManagerBase (pattern).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private List<InstanceData> GetPooledList()
|
||
{
|
||
// Mirrors WB ObjectRenderManagerBase.cs:1221-1233 — the reuse
|
||
// branch MUST clear the list before returning. PrepareRenderBatches'
|
||
// merge phase pattern is `gfxDict[k] = list; list.AddRange(...)`,
|
||
// which assumes the list is empty. Without the clear, lists grow
|
||
// unbounded across frames and each frame's draw includes all prior
|
||
// frames' stale data. Original port omitted the Clear() call — root
|
||
// cause of post-Wave-5 visual chaos (FIX 2026-05-28). See
|
||
// docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||
lock (_listPool)
|
||
{
|
||
if (_poolIndex < _listPool.Count)
|
||
{
|
||
var list = _listPool[_poolIndex++];
|
||
list.Clear();
|
||
return list;
|
||
}
|
||
var fresh = new List<InstanceData>();
|
||
_listPool.Add(fresh);
|
||
_poolIndex++;
|
||
return fresh;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers: bounds computation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 V6j: the encoder arm owns no GL names — its
|
||
// pipelines route their physical free through the device's
|
||
// retirement queue — so the ledger below holds only the scratch.
|
||
if (_gl is null)
|
||
DisposeRhiResources();
|
||
|
||
for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++)
|
||
{
|
||
List<DynamicBufferSet> frameSets = _dynamicBufferSetsByFrame[frame];
|
||
for (int index = 0; index < frameSets.Count; index++)
|
||
{
|
||
DynamicBufferSet set = frameSets[index];
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.MdiCommandBuffer,
|
||
(long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand),
|
||
$"dynamic-{frame}-{index}-mdi",
|
||
"deleting EnvCell MDI buffer");
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.ModernInstanceBuffer,
|
||
(long)set.ModernInstanceCapacity * sizeof(Matrix4x4),
|
||
$"dynamic-{frame}-{index}-instances",
|
||
"deleting EnvCell instance SSBO");
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.ModernBatchBuffer,
|
||
(long)set.ModernBatchCapacity * sizeof(ModernBatchData),
|
||
$"dynamic-{frame}-{index}-batches",
|
||
"deleting EnvCell batch SSBO");
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.ClipSlotBuffer,
|
||
(long)set.ClipSlotCapacity * sizeof(uint),
|
||
$"dynamic-{frame}-{index}-clip-slots",
|
||
"deleting EnvCell clip-slot SSBO");
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.GlobalLightsSsbo,
|
||
(long)set.GlobalLightsCapacity
|
||
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
|
||
* sizeof(float),
|
||
$"dynamic-{frame}-{index}-global-lights",
|
||
"deleting EnvCell global-light SSBO");
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.InstanceLightSetSsbo,
|
||
(long)set.InstanceLightSetCapacity
|
||
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
|
||
* sizeof(int),
|
||
$"dynamic-{frame}-{index}-light-sets",
|
||
"deleting EnvCell light-set SSBO");
|
||
}
|
||
}
|
||
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
_fallbackClipRegionSsbo,
|
||
AcDream.App.Rendering.ClipFrame.CellClipStrideBytes,
|
||
"fallback-clip-region",
|
||
"deleting EnvCell fallback clip SSBO");
|
||
_disposeResources = new RetryableResourceReleaseLedger(releases);
|
||
}
|
||
|
||
ResourceReleaseAttempt attempt = _disposeResources.Advance();
|
||
if (!_disposeResources.IsComplete)
|
||
{
|
||
throw attempt.ToException(
|
||
"One or more EnvCell renderer resources could not be released.");
|
||
}
|
||
|
||
foreach (List<DynamicBufferSet> frameSets in _dynamicBufferSetsByFrame)
|
||
frameSets.Clear();
|
||
_activeDynamicBufferSet = null;
|
||
_dynamicFrameStarted = false;
|
||
_mdiCommandBuffer = 0;
|
||
_modernInstanceBuffer = 0;
|
||
_modernBatchBuffer = 0;
|
||
_clipSlotBuffer = 0;
|
||
_globalLightsSsbo = 0;
|
||
_instLightSetSsbo = 0;
|
||
_fallbackClipRegionSsbo = 0;
|
||
_disposeResources = null;
|
||
IsDisposed = true;
|
||
|
||
if (attempt.HasFailures)
|
||
{
|
||
throw attempt.ToException(
|
||
"EnvCell renderer resources released with exceptional committed outcomes.");
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_disposing = false;
|
||
}
|
||
}
|
||
|
||
private void AddTrackedBufferRelease(
|
||
List<(string Name, Action Release)> releases,
|
||
uint buffer,
|
||
long capacityBytes,
|
||
string name,
|
||
string context)
|
||
{
|
||
if (buffer == 0)
|
||
return;
|
||
RetryableGpuResourceRelease release =
|
||
TrackedGlResource.CreateRetryableBufferDeletion(
|
||
_gl!,
|
||
buffer,
|
||
capacityBytes,
|
||
context);
|
||
releases.Add((name, release.Run));
|
||
}
|
||
}
|