The #176 gate-2 failure ("stripes/triangles flickering when the camera
is pushed into walls; nothing when zoomed out") is NOT a render defect.
Isolation apparatus added this commit:
- ACDREAM_LIGHT_DEBUG shader modes (mesh_modern.vert/frag + uLightDebug
upload in EnvCellRenderer/WbDrawDispatcher): 1 = ambient-only,
2 = dynamics killed, 3 = raw vLit field (texture ignored). The
pattern SURVIVES mode 3 -> not texture; lives above the light data.
- ACDREAM_CLIP_DEBUG=1 (RenderingDiagnostics.ClipDebugNoShellTrim +
the EnvCellRenderer slot-fill gate): shell pass draws cells WHOLE
(retail's shape). Pattern survives -> the per-cell clip trim is
exonerated.
With every render suspect dead, an autonomous visual loop (synthetic
back-into-wall input + GDI window captures + the [flap-sweep] probe)
pinned the mechanism numerically: at a compressed moving boom the
camera-collision sweep is BISTABLE - consecutive sweeps with ~1.4 mm
input drift flip the first-contact solution 0.27 m along the boom
(pulledIn 0.27<->0.53, every ~5-10 frames, all 368k sweeps ok=True),
and at ~1700 fps unsynced every monitor refresh tear-interleaves the
two views = the stripe/hatch patterns. Filed as #180 with the retail
anchor: viewer_sought_position is STATEFUL (SmartBox 0x00452d75 feeds
the CURRENT swept viewer into CameraManager::UpdateCamera 0x00456660
and assigns the return to the sought, 0x00452d84) - the target
converges to the collided position instead of re-rolling the full
knife-edge ray per frame like our RetailChaseCamera does. SweepEye
itself ports update_viewer 0x00453ce0 faithfully and is exonerated.
Also recorded in #176: the site-A weenie light-registration leak (a
portal's I100 light stacked x2->x4 over one session as re-CreateObject
re-registered it under fresh entity ids).
The #176 lighting fix (d8984e87) remains live-verified; #176 re-gates
after #180 lands. ISSUES: #180 filed, #176 updated. Suites: Core
2599+2skip; toggles inert by default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1702 lines
81 KiB
C#
1702 lines
81 KiB
C#
// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the
|
||
// production cell-rendering pipeline for indoor visibility, replacing the
|
||
// broken "cell as WorldEntity with MeshRef(envCellId)" approach that the
|
||
// four reverted RR7 variants couldn't fix.
|
||
//
|
||
// Sources ported byte-for-byte:
|
||
// GetEnvCellGeomId <- WB EnvCellRenderManager.cs:94-103
|
||
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
|
||
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511
|
||
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
|
||
// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580
|
||
// AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393
|
||
//
|
||
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
|
||
// class owns the landblock-streaming loop (Update, _pendingGeneration,
|
||
// _uploadQueue). acdream's StreamingController already does that work —
|
||
// running a parallel loop would compete for dat I/O. Instead, we expose
|
||
// RegisterCell(...) as the seam: callers populate our instance store at
|
||
// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming).
|
||
|
||
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using DatReaderWriter.Enums;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
public sealed unsafe class EnvCellRenderer : IDisposable
|
||
{
|
||
private readonly GL _gl;
|
||
private readonly ObjectMeshManager _meshManager;
|
||
private readonly WbFrustum _frustum;
|
||
|
||
// Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000).
|
||
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks —
|
||
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
|
||
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
|
||
|
||
// Active snapshot (atomic swap under _renderLock).
|
||
// WB EnvCellRenderManager.cs:71: private VisibilitySnapshot _activeSnapshot = new();
|
||
private readonly object _renderLock = new();
|
||
private EnvCellVisibilitySnapshot _activeSnapshot = new();
|
||
|
||
// Shader (set by caller via Initialize).
|
||
// Uses acdream's legacy Shader type (not WB's GLSLShader) to match the
|
||
// existing wire-in pattern in GameWindow.cs where _meshShader is loaded
|
||
// for mesh_modern.{vert,frag} and shared across multiple consumers.
|
||
// API mapping: Bind() -> Use(), SetUniform(s, int) -> SetInt(s, int),
|
||
// SetUniform(s, Vector4) -> SetVec4(s, Vector4).
|
||
private AcDream.App.Rendering.Shader? _shader;
|
||
|
||
// Phase U.4 root-cause fix: the view-projection captured in PrepareRenderBatches,
|
||
// re-uploaded by Render() so the cell-shell pass is self-contained and does NOT
|
||
// inherit WbDrawDispatcher's uViewProjection (which the opaque pass would read one
|
||
// frame stale, since it draws BEFORE the dispatcher's upload). Same matrix the
|
||
// portal clip planes are computed with (envCellViewProj).
|
||
private Matrix4x4 _lastViewProjection = Matrix4x4.Identity;
|
||
private bool _initialized;
|
||
|
||
// List pool — copied from WB ObjectRenderManagerBase.
|
||
// WB ObjectRenderManagerBase.cs:83-86: protected readonly List<List<InstanceData>> _listPool = new(); protected int _poolIndex = 0;
|
||
private readonly List<List<InstanceData>> _listPool = new();
|
||
private int _poolIndex = 0;
|
||
|
||
// Modern-MDI scratch buffers (single slot — we re-upload every frame).
|
||
// WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers
|
||
// We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws.
|
||
private uint _mdiCommandBuffer;
|
||
private int _mdiCommandCapacity = 1024;
|
||
private uint _modernInstanceBuffer;
|
||
private int _modernInstanceCapacity = 1024;
|
||
private uint _modernBatchBuffer;
|
||
private int _modernBatchCapacity = 1024;
|
||
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
|
||
// InstanceData below also carries CellId/Flags for filtering, so upload a
|
||
// packed transform array instead of the 80-byte CPU struct.
|
||
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
|
||
|
||
// Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
|
||
// _modernInstanceBuffer. One uint per instance selecting its CellClip slot,
|
||
// indexed by the same BaseInstance + gl_InstanceID the shader uses for
|
||
// binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
|
||
private uint _clipSlotBuffer;
|
||
private uint[] _clipSlotData = Array.Empty<uint>();
|
||
|
||
// 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 float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||
private uint _instLightSetSsbo; // binding=5
|
||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
|
||
|
||
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
|
||
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
|
||
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
|
||
private uint _sharedClipRegionSsbo;
|
||
private uint _fallbackClipRegionSsbo;
|
||
|
||
// Reusable scratch arrays — avoid per-frame allocation.
|
||
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
|
||
private DrawElementsIndirectCommand[] _commands = Array.Empty<DrawElementsIndirectCommand>();
|
||
private ModernBatchData[] _modernBatches = Array.Empty<ModernBatchData>();
|
||
|
||
// Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28.
|
||
// Shared across all manager instances on the same GL context.
|
||
private static uint _currentVao;
|
||
private static CullMode? _currentCullMode;
|
||
|
||
public bool NeedsPrepare { get; private set; } = true;
|
||
public bool IsDisposed { get; private set; }
|
||
|
||
public LastFrameStats Stats => _lastFrameStats;
|
||
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
|
||
private LastFrameStats _lastFrameStats;
|
||
|
||
/// <summary>
|
||
/// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28).
|
||
/// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water).
|
||
/// A divergence between expected and actual values would indicate a pool-
|
||
/// management regression — exactly the bug class the 2026-05-28 audit caught.
|
||
/// </summary>
|
||
public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics()
|
||
{
|
||
int poolTotal;
|
||
lock (_listPool) poolTotal = _listPool.Count;
|
||
int hwm;
|
||
lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex;
|
||
return (poolTotal, hwm);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up).
|
||
/// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes +
|
||
/// transparency flags + bindless-handle-non-zero status, so the operator
|
||
/// can read offline and identify why specific polys (e.g., floors) aren't
|
||
/// rendering. Set <c>ACDREAM_A8_AUDIT=1</c> to enable.
|
||
/// <para>Returns a deduplicated audit-line list per Render snapshot
|
||
/// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller
|
||
/// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs
|
||
/// have already been logged.</para>
|
||
/// </summary>
|
||
public IReadOnlyList<string> CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged)
|
||
{
|
||
var lines = new List<string>();
|
||
lock (_renderLock)
|
||
{
|
||
var snap = _activeSnapshot;
|
||
foreach (var (cellId, gfxDict) in snap.BatchedByCell)
|
||
{
|
||
foreach (var (gfxObjId, transforms) in gfxDict)
|
||
{
|
||
var key = (cellId, gfxObjId);
|
||
if (alreadyLogged.Contains(key)) continue;
|
||
alreadyLogged.Add(key);
|
||
|
||
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (rd is null)
|
||
{
|
||
lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null");
|
||
continue;
|
||
}
|
||
int totalIdx = 0;
|
||
var cullModes = new HashSet<DatReaderWriter.Enums.CullMode>();
|
||
int translucent = 0;
|
||
int additive = 0;
|
||
int zeroHandle = 0;
|
||
foreach (var b in rd.Batches)
|
||
{
|
||
totalIdx += b.IndexCount;
|
||
cullModes.Add(b.CullMode);
|
||
if (b.IsTransparent) translucent++;
|
||
if (b.IsAdditive) additive++;
|
||
if (b.BindlessTextureHandle == 0) zeroHandle++;
|
||
}
|
||
var cullList = string.Join(",", cullModes);
|
||
lines.Add(
|
||
$"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " +
|
||
$"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " +
|
||
$"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}");
|
||
}
|
||
}
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constructor + Initialize
|
||
// ---------------------------------------------------------------------------
|
||
|
||
public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum)
|
||
{
|
||
_gl = gl;
|
||
_meshManager = meshManager;
|
||
_frustum = frustum;
|
||
}
|
||
|
||
public void Initialize(AcDream.App.Rendering.Shader shader)
|
||
{
|
||
_shader = shader;
|
||
AllocateMdiBuffers();
|
||
_initialized = true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AllocateMdiBuffers
|
||
// Mirrors WB BaseObjectRenderManager.cs:62-127 (slot-0 only).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private void AllocateMdiBuffers()
|
||
{
|
||
// MDI command buffer (DrawIndirectBuffer)
|
||
_gl.GenBuffers(1, out _mdiCommandBuffer);
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||
_gl.BufferData(GLEnum.DrawIndirectBuffer,
|
||
(nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw);
|
||
|
||
// Per-frame scratch instance SSBO (binding=0)
|
||
_gl.GenBuffers(1, out _modernInstanceBuffer);
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||
|
||
// Per-batch data SSBO (binding=1)
|
||
_gl.GenBuffers(1, out _modernBatchBuffer);
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
|
||
|
||
// Phase U.3: per-instance clip-slot SSBO (binding=3), sized to the
|
||
// instance capacity. Uploaded all-zeros each frame in RenderModernMDIInternal.
|
||
_gl.GenBuffers(1, out _clipSlotBuffer);
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
|
||
|
||
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
|
||
_gl.GenBuffers(1, out _globalLightsSsbo);
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
|
||
|
||
_gl.GenBuffers(1, out _instLightSetSsbo);
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||
null, GLEnum.DynamicDraw);
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase U.3: hand the renderer the SHARED per-cell clip-region SSBO
|
||
/// (binding=2) created by <see cref="ClipFrame.UploadShared"/>. The renderer
|
||
/// re-binds it to binding=2 immediately before its MDI. Pass 0 to fall back to
|
||
/// the internal one-slot no-clip region buffer.
|
||
/// </summary>
|
||
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
|
||
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
|
||
|
||
// Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When
|
||
// non-null, RenderModernMDIInternal writes instanceClipSlot[i] =
|
||
// _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are
|
||
// gated to that cell's portal-clip region. When null (U.3 path), every
|
||
// instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0
|
||
// (no-clip) — but the caller's Render filter already restricts the draw to the
|
||
// map's keys, so that fallback should not fire in practice.
|
||
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
|
||
|
||
/// <summary>
|
||
/// Phase U.4: install the per-frame cellId→slot map used to gate cell shells
|
||
/// to their portal-clip regions. Call once per frame BEFORE
|
||
/// <see cref="Render(WbRenderPass, HashSet{uint}?)"/>. Pass null to revert to
|
||
/// the U.3 no-clip behavior (every shell instance → slot 0).
|
||
/// </summary>
|
||
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
|
||
=> _cellIdToSlot = cellIdToSlot;
|
||
|
||
/// <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
|
||
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Returns a deduplicated geometry ID for an EnvCell based on its environment,
|
||
/// cell structure index, and surface IDs. Bit 33 is set to distinguish from
|
||
/// per-cell IDs (which use bit 32).
|
||
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
|
||
/// </summary>
|
||
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces)
|
||
{
|
||
// WB EnvCellRenderManager.cs:95-102 verbatim:
|
||
var hash = 17L;
|
||
hash = hash * 31 + (int)environmentId;
|
||
hash = hash * 31 + cellStructure;
|
||
foreach (var surface in surfaces) {
|
||
hash = hash * 31 + surface;
|
||
}
|
||
// Use bit 33 to indicate deduplicated EnvCell geometry (to avoid collision with bit 32 per-cell geometry)
|
||
return (ulong)hash | 0x2_0000_0000UL;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RegisterCell — streaming seam
|
||
// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
|
||
// time, ONCE per EnvCell that has non-null EnvironmentId.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Registers a single EnvCell and its static objects into the per-landblock
|
||
/// pending list. Call <see cref="FinalizeLandblock"/> after all cells for a
|
||
/// landblock have been registered.
|
||
/// </summary>
|
||
public void RegisterCell(
|
||
uint landblockId,
|
||
uint envCellId,
|
||
DatReaderWriter.DBObjs.EnvCell envCell,
|
||
DatReaderWriter.Types.CellStruct cellStruct,
|
||
Matrix4x4 cellTransform,
|
||
Vector3 cellWorldPosition,
|
||
Quaternion cellRotation,
|
||
IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects)
|
||
{
|
||
// 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId).
|
||
var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces);
|
||
|
||
// 2. Trigger mesh prep for the cell geometry on ObjectMeshManager.
|
||
// This populates ObjectMeshManager._renderData[cellGeomId] when complete.
|
||
// Verified Phase W Stage 4 (T4.4): ceilings are present by construction.
|
||
// PrepareCellStructMeshData iterates ALL polygons in CellStruct.Polygons
|
||
// (floor + 4 walls + ceiling, as authored in the EnvCell dat mesh) with NO
|
||
// surface filter — every polygon that passes the ≥3-vertex check is added to
|
||
// the batch. Retail PView::DrawCells draws cell->structure->drawing_bsp which
|
||
// is the same closed-box structure. No ceiling polygons are dropped.
|
||
_ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId,
|
||
envCell.CellStructure, envCell.Surfaces);
|
||
|
||
// 3. Compute local bounds from cellStruct vertex array.
|
||
var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct);
|
||
var worldBounds = TransformBoundingBox(localBounds, cellTransform);
|
||
|
||
// 4. Create the per-cell SceneryInstance.
|
||
var cellInstance = new EnvCellSceneryInstance
|
||
{
|
||
ObjectId = cellGeomId,
|
||
InstanceId = envCellId, // uint InstanceId maps to the cell id
|
||
IsBuilding = true,
|
||
IsEntryCell = false, // could be derived from entry-portal walk; default false
|
||
WorldPosition = cellWorldPosition,
|
||
LocalPosition = Vector3.Zero,
|
||
Rotation = cellRotation,
|
||
Scale = Vector3.One,
|
||
Transform = cellTransform,
|
||
LocalBoundingBox = localBounds,
|
||
BoundingBox = worldBounds,
|
||
};
|
||
|
||
var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock
|
||
{
|
||
GridX = (int)((id >> 24) & 0xFFu),
|
||
GridY = (int)((id >> 16) & 0xFFu),
|
||
});
|
||
|
||
lock (lb.Lock)
|
||
{
|
||
// TEMP diagnostic #105 (strip with fix): a registration landing AFTER
|
||
// this landblock was already finalized starts a fresh pending list that
|
||
// only commits if ANOTHER finalize arrives — and that one will REPLACE
|
||
// (not merge) the committed set. One-shot per landblock per pending list.
|
||
if (lb.InstancesReady && lb.PendingInstances is null)
|
||
Console.WriteLine($"[late-register] lb=0x{landblockId:X8} cell=0x{envCellId:X8} registered AFTER finalize — starting a new pending list ({(lb.Instances?.Count ?? 0)} already committed)");
|
||
lb.PendingInstances ??= new List<EnvCellSceneryInstance>(capacity: 32);
|
||
lb.PendingInstances.Add(cellInstance);
|
||
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
|
||
lb.PendingEnvCellBounds[envCellId] = worldBounds;
|
||
|
||
// Add static-object instances inside the cell.
|
||
foreach (var stab in staticObjects)
|
||
{
|
||
// Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes).
|
||
_ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup);
|
||
|
||
WbBoundingBox stabBoundsLocal;
|
||
var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup);
|
||
if (rawBounds.HasValue)
|
||
stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max);
|
||
else
|
||
stabBoundsLocal = new WbBoundingBox(Vector3.Zero, Vector3.Zero);
|
||
|
||
var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform);
|
||
|
||
lb.PendingInstances.Add(new EnvCellSceneryInstance
|
||
{
|
||
ObjectId = stab.StaticObjectId,
|
||
InstanceId = 0,
|
||
IsSetup = stab.IsSetup,
|
||
IsBuilding = false,
|
||
Transform = stab.Transform,
|
||
BoundingBox = stabBoundsWorld,
|
||
LocalBoundingBox = stabBoundsLocal,
|
||
Scale = Vector3.One,
|
||
Rotation = stab.Rot,
|
||
// CurrentPreviewCellId = 0; the per-cell CellId is written during PopulatePartGroups
|
||
CurrentPreviewCellId = envCellId, // carry the parent cellId for PartGroups dispatch
|
||
});
|
||
|
||
// Union the cell bounds with the stab bounds.
|
||
var current = lb.PendingEnvCellBounds[envCellId];
|
||
lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Atomically promotes the pending instance list to the committed list,
|
||
/// computes total EnvCell bounds, populates PartGroups, and marks the
|
||
/// landblock ready for rendering.
|
||
/// </summary>
|
||
public void FinalizeLandblock(uint landblockId)
|
||
{
|
||
if (!_landblocks.TryGetValue(landblockId, out var lb)) return;
|
||
lock (lb.Lock)
|
||
{
|
||
if (lb.PendingInstances is not null)
|
||
{
|
||
// TEMP diagnostic #105 (strip with fix): REPLACE semantics — if a
|
||
// previous finalize already committed instances for this landblock,
|
||
// this swap DISCARDS them in favor of the new pending set. A partial
|
||
// pending set (finalize racing a still-registering promote job)
|
||
// silently loses buildings.
|
||
if (lb.Instances is { Count: > 0 })
|
||
Console.WriteLine($"[finalize-replace] lb=0x{landblockId:X8} DISCARDING {lb.Instances.Count} committed instances, replacing with {lb.PendingInstances.Count} pending");
|
||
lb.Instances = lb.PendingInstances;
|
||
lb.PendingInstances = null;
|
||
}
|
||
if (lb.PendingEnvCellBounds is not null)
|
||
{
|
||
lb.EnvCellBounds = lb.PendingEnvCellBounds;
|
||
lb.PendingEnvCellBounds = null;
|
||
}
|
||
|
||
// Compute total bounds union across all cells.
|
||
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
|
||
foreach (var b in lb.EnvCellBounds.Values)
|
||
total = WbBoundingBox.Union(total, b);
|
||
lb.TotalEnvCellBounds = total;
|
||
|
||
// Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580).
|
||
PopulatePartGroups(lb);
|
||
|
||
lb.InstancesReady = true;
|
||
lb.MeshDataReady = true;
|
||
lb.GpuReady = true;
|
||
}
|
||
NeedsPrepare = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it.
|
||
/// </summary>
|
||
public void RemoveLandblock(uint landblockId)
|
||
{
|
||
_landblocks.TryRemove(landblockId, out _);
|
||
NeedsPrepare = true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PopulatePartGroups
|
||
// Verbatim port of WB EnvCellRenderManager.cs:572-580.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list.
|
||
/// Must be called under lb.Lock.
|
||
/// Source: WB EnvCellRenderManager.cs:572-580 (verbatim).
|
||
/// </summary>
|
||
private void PopulatePartGroups(EnvCellLandblock lb)
|
||
{
|
||
// WB EnvCellRenderManager.cs:573-574:
|
||
lb.StaticPartGroups.Clear();
|
||
lb.BuildingPartGroups.Clear();
|
||
|
||
// WB EnvCellRenderManager.cs:575-579:
|
||
foreach (var instance in lb.Instances)
|
||
{
|
||
var targetGroup = instance.IsBuilding ? lb.BuildingPartGroups : lb.StaticPartGroups;
|
||
// WB: var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId.DataId;
|
||
// We store the parent cellId in CurrentPreviewCellId (for stabs) or InstanceId (for cell geometry).
|
||
var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId;
|
||
PopulateRecursive(targetGroup, instance.ObjectId, instance.IsSetup, instance.Transform, cellId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Recursively walks a Setup's parts (or handles a plain GfxObj directly),
|
||
/// adding one <see cref="InstanceData"/> per leaf GfxObj to the target group.
|
||
/// Mirrors WB ObjectRenderManagerBase.PopulateRecursive.
|
||
/// </summary>
|
||
private void PopulateRecursive(
|
||
Dictionary<ulong, List<InstanceData>> group,
|
||
ulong objectId,
|
||
bool isSetup,
|
||
Matrix4x4 transform,
|
||
uint cellId)
|
||
{
|
||
if (isSetup)
|
||
{
|
||
// For a Setup, TryGetRenderData returns a record whose SetupParts lists
|
||
// (partId, partTransform) pairs. We recursively handle each part.
|
||
var rd = _meshManager.TryGetRenderData(objectId);
|
||
if (rd is null || !rd.IsSetup) return;
|
||
|
||
foreach (var (partId, partTransform) in rd.SetupParts)
|
||
{
|
||
// Compose: world = partTransform (relative to setup) * setup world transform.
|
||
//
|
||
// FIX 2026-05-28: detect nested Setups via the high-byte
|
||
// 0x02 convention (Setup IDs start with 0x02; plain GfxObj
|
||
// IDs start with 0x01). Mirrors WB
|
||
// ObjectRenderManagerBase.cs:813. Original port hardcoded
|
||
// isSetup:false which silently dropped any nested Setup —
|
||
// its TryGetRenderData returns IsSetup=true, and Render's
|
||
// `if (!renderData.IsSetup)` guard then skips the draw.
|
||
var combined = partTransform * transform;
|
||
bool partIsSetup = (partId >> 24) == 0x02UL;
|
||
PopulateRecursive(group, partId, partIsSetup, combined, cellId);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Plain GfxObj — just add one InstanceData.
|
||
if (!group.TryGetValue(objectId, out var list))
|
||
{
|
||
list = new List<InstanceData>();
|
||
group[objectId] = list;
|
||
}
|
||
list.Add(new InstanceData
|
||
{
|
||
Transform = transform,
|
||
CellId = cellId,
|
||
Flags = 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PrepareRenderBatches
|
||
// Verbatim port of WB EnvCellRenderManager.cs:247-373.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Frustum-culls all registered landblocks and builds a new
|
||
/// <see cref="EnvCellVisibilitySnapshot"/> that the render thread consumes.
|
||
/// Call once per frame, before <see cref="Render"/>.
|
||
/// Source: WB EnvCellRenderManager.cs:247-373 (verbatim).
|
||
/// </summary>
|
||
public void PrepareRenderBatches(
|
||
Matrix4x4 viewProjection,
|
||
Vector3 cameraPosition,
|
||
HashSet<uint>? filter = null,
|
||
int? centerLbX = null,
|
||
int? centerLbY = null,
|
||
int? renderRadius = null)
|
||
{
|
||
// Phase U.4 fix: stash the view-projection so Render() can upload it itself.
|
||
_lastViewProjection = viewProjection;
|
||
|
||
// WB EnvCellRenderManager.cs:249-250:
|
||
if (!_initialized || cameraPosition.Z > 4000) return;
|
||
|
||
if (filter is { Count: 0 })
|
||
{
|
||
lock (_renderLock)
|
||
{
|
||
_poolIndex = 0;
|
||
_activeSnapshot = new EnvCellVisibilitySnapshot();
|
||
_activeSnapshotGlobalGroups = new Dictionary<ulong, List<InstanceData>>();
|
||
_activeSnapshotGlobalGfxObjIds = new List<ulong>();
|
||
NeedsPrepare = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:251-253:
|
||
lock (_renderLock) { _poolIndex = 0; }
|
||
|
||
// WB skips _cameraLbX/Y update (from LandscapeDoc.Region) here in our variant
|
||
// because we don't need camera-LB tracking for the snapshot — just frustum tests.
|
||
|
||
// WB EnvCellRenderManager.cs:262:
|
||
// Filter loaded landblocks by GpuReady + Instances non-empty.
|
||
var landblocks = new List<EnvCellLandblock>();
|
||
foreach (var lb in _landblocks.Values)
|
||
{
|
||
if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue)
|
||
{
|
||
if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value ||
|
||
Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value)
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (lb.GpuReady && lb.Instances.Count > 0)
|
||
landblocks.Add(lb);
|
||
}
|
||
if (landblocks.Count == 0) return;
|
||
|
||
// WB EnvCellRenderManager.cs:265-267:
|
||
// Use ThreadLocal to avoid contention on ConcurrentDictionaries during parallel grouping.
|
||
using var threadLocalBatchedByCell =
|
||
new ThreadLocal<Dictionary<uint, Dictionary<ulong, List<InstanceData>>>>(
|
||
() => new(), trackAllValues: true);
|
||
using var threadLocalGlobalGroups =
|
||
new ThreadLocal<Dictionary<ulong, List<InstanceData>>>(
|
||
() => new(), trackAllValues: true);
|
||
|
||
// WB EnvCellRenderManager.cs:269:
|
||
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
|
||
|
||
// WB EnvCellRenderManager.cs:270-325:
|
||
Parallel.ForEach(landblocks, parallelOptions, lb =>
|
||
{
|
||
lock (lb.Lock)
|
||
{
|
||
var testResult = _frustum.TestBox(lb.TotalEnvCellBounds);
|
||
if (testResult == FrustumTestResult.Outside) return;
|
||
|
||
var lbBatchedByCell = threadLocalBatchedByCell.Value!;
|
||
var lbGlobalGroups = threadLocalGlobalGroups.Value!;
|
||
|
||
// WB EnvCellRenderManager.cs:279-295: fast path — LB fully inside.
|
||
if (testResult == FrustumTestResult.Inside)
|
||
{
|
||
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
|
||
foreach (var instanceData in instances)
|
||
{
|
||
if (filter != null && !filter.Contains(instanceData.CellId)) continue;
|
||
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
|
||
}
|
||
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
|
||
foreach (var instanceData in instances)
|
||
{
|
||
if (filter != null && !filter.Contains(instanceData.CellId)) continue;
|
||
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test.
|
||
var visibleCells = new HashSet<uint>();
|
||
foreach (var kvp in lb.EnvCellBounds)
|
||
{
|
||
var cellId = kvp.Key;
|
||
if (filter != null && !filter.Contains(cellId)) continue;
|
||
if (_frustum.Intersects(kvp.Value))
|
||
visibleCells.Add(cellId);
|
||
}
|
||
|
||
if (visibleCells.Count > 0)
|
||
{
|
||
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
|
||
foreach (var instanceData in instances)
|
||
{
|
||
if (visibleCells.Contains(instanceData.CellId))
|
||
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
|
||
}
|
||
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
|
||
foreach (var instanceData in instances)
|
||
{
|
||
if (visibleCells.Contains(instanceData.CellId))
|
||
AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// WB EnvCellRenderManager.cs:327-373: merge thread-locals + atomic swap.
|
||
|
||
var newBatchedByCell = new Dictionary<uint, Dictionary<ulong, List<InstanceData>>>();
|
||
var newVisibleGroups = new Dictionary<ulong, List<InstanceData>>();
|
||
var newVisibleGfxObjIds = new List<ulong>();
|
||
|
||
// WB EnvCellRenderManager.cs:333-347: merge per-cell batches.
|
||
foreach (var localBatchedByCell in threadLocalBatchedByCell.Values)
|
||
{
|
||
foreach (var cellKvp in localBatchedByCell)
|
||
{
|
||
if (!newBatchedByCell.TryGetValue(cellKvp.Key, out var gfxDict))
|
||
{
|
||
gfxDict = new Dictionary<ulong, List<InstanceData>>();
|
||
newBatchedByCell[cellKvp.Key] = gfxDict;
|
||
}
|
||
foreach (var gfxKvp in cellKvp.Value)
|
||
{
|
||
if (!gfxDict.TryGetValue(gfxKvp.Key, out var list))
|
||
{
|
||
list = GetPooledList();
|
||
gfxDict[gfxKvp.Key] = list;
|
||
}
|
||
list.AddRange(gfxKvp.Value);
|
||
}
|
||
}
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:349-358: merge global groups.
|
||
foreach (var localGlobalGroups in threadLocalGlobalGroups.Values)
|
||
{
|
||
foreach (var kvp in localGlobalGroups)
|
||
{
|
||
if (!newVisibleGroups.TryGetValue(kvp.Key, out var list))
|
||
{
|
||
list = GetPooledList();
|
||
newVisibleGroups[kvp.Key] = list;
|
||
newVisibleGfxObjIds.Add(kvp.Key);
|
||
}
|
||
list.AddRange(kvp.Value);
|
||
}
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:361-372: atomic swap under _renderLock.
|
||
//
|
||
// FIX 2026-05-28 (pool aliasing root cause): capture _poolIndex's
|
||
// high-water mark from the merge phase into the snapshot's
|
||
// PostPreparePoolIndex BEFORE the reset to 0. Render reads it back
|
||
// to set its pool cursor past the snapshot's owned lists. Without
|
||
// this capture, Render's filter-path GetPooledList returns lists
|
||
// the snapshot is still referencing, corrupting per-frame instance
|
||
// data. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||
lock (_renderLock)
|
||
{
|
||
_activeSnapshot = new EnvCellVisibilitySnapshot
|
||
{
|
||
BatchedByCell = newBatchedByCell,
|
||
VisibleLandblocks = landblocks,
|
||
PostPreparePoolIndex = _poolIndex,
|
||
// VisibleGroups / VisibleGfxObjIds are stored as extra fields below.
|
||
};
|
||
// Stash the global groups on the snapshot for use in the unfiltered render path.
|
||
_activeSnapshotGlobalGroups = newVisibleGroups;
|
||
_activeSnapshotGlobalGfxObjIds = newVisibleGfxObjIds;
|
||
|
||
_poolIndex = 0;
|
||
NeedsPrepare = false;
|
||
}
|
||
}
|
||
|
||
// Extra fields to carry global groups out of PrepareRenderBatches into Render().
|
||
// WB stores these in VisibilitySnapshot; we keep them as sibling fields since our
|
||
// EnvCellVisibilitySnapshot only exposes BatchedByCell (per spec Task 4).
|
||
private Dictionary<ulong, List<InstanceData>> _activeSnapshotGlobalGroups = new();
|
||
private List<ulong> _activeSnapshotGlobalGfxObjIds = new();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AddToGroups (static helper)
|
||
// Verbatim port of WB EnvCellRenderManager.cs:375-393.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private static void AddToGroups(
|
||
Dictionary<uint, Dictionary<ulong, List<InstanceData>>> batchedByCell,
|
||
Dictionary<ulong, List<InstanceData>> globalGroups,
|
||
uint cellId,
|
||
ulong gfxObjId,
|
||
InstanceData data)
|
||
{
|
||
// WB EnvCellRenderManager.cs:377-381: add to global grouping.
|
||
if (!globalGroups.TryGetValue(gfxObjId, out var globalList))
|
||
{
|
||
globalList = new List<InstanceData>();
|
||
globalGroups[gfxObjId] = globalList;
|
||
}
|
||
globalList.Add(data);
|
||
|
||
// WB EnvCellRenderManager.cs:383-392: add to per-cell grouping.
|
||
if (!batchedByCell.TryGetValue(cellId, out var gfxDict))
|
||
{
|
||
gfxDict = new Dictionary<ulong, List<InstanceData>>();
|
||
batchedByCell[cellId] = gfxDict;
|
||
}
|
||
if (!gfxDict.TryGetValue(gfxObjId, out var list))
|
||
{
|
||
list = new List<InstanceData>();
|
||
batchedByCell[cellId][gfxObjId] = list;
|
||
}
|
||
list.Add(data);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render
|
||
// Verbatim port of WB EnvCellRenderManager.cs:395-511.
|
||
// Deviations from WB (all documented):
|
||
// - Drop the _useModernRendering branch (our codebase asserts modern at startup per Phase N.5).
|
||
// - Drop SelectedInstance/HoveredInstance highlight block (lines 486-510) — no editor state.
|
||
// - Replace RenderModernMDI(base) with private RenderModernMDIInternal.
|
||
// - shader.Bind() / SetUniform API: mapped to acdream's legacy Shader
|
||
// class (Use() + SetInt/SetVec4/SetMatrix4) to match the existing
|
||
// wire-in pattern in GameWindow.cs where _meshShader is loaded once
|
||
// for mesh_modern.{vert,frag} and shared with WbDrawDispatcher.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
public void Render(WbRenderPass renderPass)
|
||
{
|
||
// WB EnvCellRenderManager.cs:396:
|
||
Render(renderPass, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Draws all visible EnvCells (and their static objects) for the given pass.
|
||
/// When <paramref name="filter"/> is non-null, only cells whose CellId is in
|
||
/// the set are drawn. As of Phase U.4 this is the portal-visibility SHELL
|
||
/// filter (the drawable visible cells from the PView traversal; each cell's
|
||
/// shell instances are clip-gated to its CellClip slot by the caller's
|
||
/// binding=3 map). NOTE: this is NOT the old two-pipe RenderInsideOut approach
|
||
/// — that flat camera-inside-building stencil pass was deleted in Phase U.1.
|
||
/// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights).
|
||
/// </summary>
|
||
public void Render(WbRenderPass renderPass, HashSet<uint>? filter)
|
||
{
|
||
// WB EnvCellRenderManager.cs:400:
|
||
if (!_initialized || _shader is null || _shader.Program == 0) return;
|
||
|
||
lock (_renderLock)
|
||
{
|
||
var snapshot = _activeSnapshot;
|
||
// WB EnvCellRenderManager.cs:403-404:
|
||
_shader.Use();
|
||
// FIX 2026-05-28 (pool aliasing root cause): mirror WB
|
||
// EnvCellRenderManager.cs:405 — restore the pool cursor to the
|
||
// high-water mark Prepare's merge phase reached, so any
|
||
// GetPooledList calls below return lists past the snapshot's
|
||
// owned region. Original code used `snapshot.BatchedByCell.Count`
|
||
// (number of cells, e.g. 18) which has no relation to the pool
|
||
// index and pointed back into snapshot data, corrupting it
|
||
// mid-Render. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||
_poolIndex = snapshot.PostPreparePoolIndex;
|
||
|
||
// FIX 2026-05-28: invalidate static GL-state caches at start of Render.
|
||
// Mirrors WB EnvCellRenderManager.cs:404-410:
|
||
// CurrentVAO = 0; CurrentIBO = 0; CurrentAtlas = 0;
|
||
// CurrentInstanceBuffer = 0; CurrentCullMode = null;
|
||
//
|
||
// These caches let SetCullMode / BindVertexArray skip redundant GL
|
||
// calls when the state is already correct. BUT: between two Render()
|
||
// invocations, OTHER consumers (WbDrawDispatcher, terrain, the
|
||
// RenderInsideOutAcdream stencil pipeline) change the actual GL
|
||
// state without updating these caches. The cache then lies, and
|
||
// the per-batch SetCullMode in RenderModernMDIInternal skips its
|
||
// glCullFace call — leaving stale cull state from the prior
|
||
// consumer. For a cottage with mixed CullMode batches, half the
|
||
// walls end up culled and the user sees "missing walls".
|
||
//
|
||
// Forcing the cache to null/0 at entry guarantees each Render call
|
||
// re-establishes the GL state it expects.
|
||
_currentVao = 0;
|
||
_currentCullMode = null;
|
||
|
||
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
|
||
_shader.SetInt("uRenderPass", (int)renderPass);
|
||
_shader.SetInt("uFilterByCell", 0);
|
||
_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);
|
||
|
||
var allInstances = new List<InstanceData>();
|
||
var drawCalls = new List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)>();
|
||
|
||
if (filter == null)
|
||
{
|
||
// WB EnvCellRenderManager.cs:418-429: optimized path — global groups.
|
||
foreach (var gfxObjId in _activeSnapshotGlobalGfxObjIds)
|
||
{
|
||
if (_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out var transforms))
|
||
{
|
||
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (renderData != null && !renderData.IsSetup)
|
||
{
|
||
drawCalls.Add((renderData, 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.
|
||
var filteredGroups = new Dictionary<ulong, List<InstanceData>>();
|
||
var ownedLists = new HashSet<List<InstanceData>>();
|
||
|
||
foreach (var cellId in filter)
|
||
{
|
||
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) continue;
|
||
foreach (var (gfxObjId, transforms) in gfxDict)
|
||
{
|
||
if (transforms.Count == 0) continue;
|
||
if (!filteredGroups.TryGetValue(gfxObjId, out var list))
|
||
{
|
||
list = transforms; // Optimization: just use the first list
|
||
filteredGroups[gfxObjId] = list;
|
||
}
|
||
else
|
||
{
|
||
if (list == transforms) continue;
|
||
|
||
// If we don't own this list yet, we must clone it before adding to it
|
||
if (!ownedLists.Contains(list))
|
||
{
|
||
var newList = GetPooledList();
|
||
newList.AddRange(list);
|
||
list = newList;
|
||
filteredGroups[gfxObjId] = list;
|
||
ownedLists.Add(list);
|
||
}
|
||
list.AddRange(transforms);
|
||
}
|
||
}
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:461-468:
|
||
foreach (var (gfxObjId, transforms) in filteredGroups)
|
||
{
|
||
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (renderData != null && !renderData.IsSetup)
|
||
{
|
||
drawCalls.Add((renderData, 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).
|
||
RenderModernMDIInternal(_shader, drawCalls, allInstances, renderPass);
|
||
}
|
||
|
||
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
|
||
|
||
// WB EnvCellRenderManager.cs:506-509: cleanup.
|
||
_shader.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0));
|
||
_shader.SetInt("uRenderPass", (int)renderPass);
|
||
_gl.BindVertexArray(0);
|
||
_currentVao = 0;
|
||
|
||
// No cull restore at exit, matching WB's manager pattern: the
|
||
// last SetCullMode call reflects actual GL state, and the next
|
||
// Render call invalidates `_currentCullMode` before issuing its
|
||
// own per-batch state. The Landblock->None override below can
|
||
// intentionally leave cull disabled for the following IndoorPass,
|
||
// preserving the shipped Gate #5 baseline while deeper evidence is
|
||
// gathered.
|
||
|
||
// Update frame stats for probe emission at the call site.
|
||
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
|
||
_lastFrameStats.TrianglesDrawn = 0;
|
||
foreach (var dc in drawCalls)
|
||
_lastFrameStats.TrianglesDrawn += (dc.renderData.Batches.Count > 0
|
||
? dc.renderData.Batches[0].IndexCount / 3
|
||
: 0) * dc.count;
|
||
|
||
// Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY.
|
||
// Per opaque-pass call: totals + per visible (filtered) cell whether it is
|
||
// present in the prepared snapshot, and its geometry/flags. Answers why the
|
||
// interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry
|
||
// prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture
|
||
// (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/
|
||
// occlusion or the geometry isn't the wall). Opaque pass only (halves noise).
|
||
if (renderPass == WbRenderPass.Opaque
|
||
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled)
|
||
{
|
||
var sb = new System.Text.StringBuilder(256);
|
||
sb.Append("[shell] filter=").Append(filter?.Count ?? -1)
|
||
.Append(" drawCalls=").Append(drawCalls.Count)
|
||
.Append(" inst=").Append(allInstances.Count)
|
||
.Append(" tris=").Append(_lastFrameStats.TrianglesDrawn);
|
||
if (filter != null)
|
||
{
|
||
foreach (var cellId in filter)
|
||
{
|
||
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict))
|
||
{
|
||
sb.Append(" [0x").Append(cellId.ToString("X8")).Append(":NOSNAP]");
|
||
continue;
|
||
}
|
||
int gfxN = 0, tf = 0, batch = 0, idx = 0, tr = 0, zh = 0;
|
||
foreach (var (gfxObjId, transforms) in gfxDict)
|
||
{
|
||
gfxN++; tf += transforms.Count;
|
||
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (rd != null)
|
||
foreach (var b in rd.Batches)
|
||
{ batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (b.BindlessTextureHandle == 0) zh++; }
|
||
}
|
||
sb.Append(" [0x").Append(cellId.ToString("X8"))
|
||
.Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf)
|
||
.Append(" batch=").Append(batch).Append(" idx=").Append(idx)
|
||
.Append(" tr=").Append(tr).Append(" zh=").Append(zh).Append(']');
|
||
}
|
||
}
|
||
System.Console.WriteLine(sb.ToString());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
var snapshot = _activeSnapshot;
|
||
if (snapshot is null || !snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict))
|
||
return false;
|
||
foreach (var (gfxObjId, transforms) in gfxDict)
|
||
{
|
||
if (transforms.Count == 0) continue;
|
||
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
||
if (rd is null) continue;
|
||
foreach (var b in rd.Batches)
|
||
if (b.IsTransparent) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 var cached)) return cached;
|
||
|
||
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||
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);
|
||
}
|
||
_cellLightSetCache[cellId] = set;
|
||
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 RenderModernMDIInternal(
|
||
AcDream.App.Rendering.Shader shader,
|
||
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||
List<InstanceData> allInstances,
|
||
WbRenderPass renderPass)
|
||
{
|
||
// WB BaseObjectRenderManager.cs:710-713:
|
||
if (drawCalls.Count == 0 || allInstances.Count == 0) return;
|
||
|
||
int passIdx = (int)renderPass;
|
||
if (passIdx < 0 || passIdx > 2) return;
|
||
|
||
// A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in
|
||
// GetCellLightSet below). Clear once here so each cell gets a fresh lookup
|
||
// using this frame's _pointSnapshot. Called for EVERY pass (opaque AND
|
||
// transparent); the cache entries are stable within a frame since PointSnapshot
|
||
// doesn't change between Render calls, so clearing once (at the opaque pass)
|
||
// and leaving stale entries for the transparent pass would also be correct, but
|
||
// clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet.
|
||
_cellLightSetCache.Clear();
|
||
|
||
// §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).
|
||
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
|
||
if (globalVao == 0) return;
|
||
|
||
// WB BaseObjectRenderManager.cs:715-716:
|
||
shader.Use();
|
||
shader.SetInt("uFilterByCell", 0);
|
||
|
||
// WB BaseObjectRenderManager.cs:718-740: group batches by CullMode + additive flag.
|
||
var batchesByCullMode = new Dictionary<int, List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>>();
|
||
int totalDraws = 0;
|
||
|
||
foreach (var call in drawCalls)
|
||
{
|
||
foreach (var batch in call.renderData.Batches)
|
||
{
|
||
// WB BaseObjectRenderManager.cs:723-731: pass-filter.
|
||
if (renderPass != WbRenderPass.SinglePass)
|
||
{
|
||
if (batch.IsAdditive)
|
||
{
|
||
if (renderPass == WbRenderPass.Opaque) continue;
|
||
}
|
||
else if (!batch.IsTransparent)
|
||
{
|
||
if (renderPass == WbRenderPass.Transparent) continue;
|
||
}
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:732-740:
|
||
var cullMode = batch.CullMode;
|
||
var groupIdx = (int)cullMode + (batch.IsAdditive ? 4 : 0);
|
||
if (!batchesByCullMode.TryGetValue(groupIdx, out var list))
|
||
{
|
||
list = new List<(ObjectRenderBatch, int, int)>();
|
||
batchesByCullMode[groupIdx] = list;
|
||
}
|
||
list.Add((batch, call.count, call.offset));
|
||
totalDraws++;
|
||
}
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:743:
|
||
if (totalDraws == 0) return;
|
||
|
||
// 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)
|
||
{
|
||
_mdiCommandCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws);
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||
_gl.BufferData(GLEnum.DrawIndirectBuffer,
|
||
(nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw);
|
||
|
||
_modernBatchCapacity = _mdiCommandCapacity;
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
|
||
}
|
||
|
||
int uniqueInstanceCount = allInstances.Count;
|
||
if (uniqueInstanceCount > _modernInstanceCapacity)
|
||
{
|
||
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||
|
||
// Phase U.3: keep the clip-slot buffer (binding=3) sized to the
|
||
// instance buffer so instanceClipSlot[BaseInstance + gl_InstanceID]
|
||
// is always in range.
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
|
||
if (_commands.Length < totalDraws)
|
||
Array.Resize(ref _commands, Math.Max(_commands.Length * 2, totalDraws));
|
||
if (_modernBatches.Length < totalDraws)
|
||
Array.Resize(ref _modernBatches, Math.Max(_modernBatches.Length * 2, totalDraws));
|
||
|
||
// WB BaseObjectRenderManager.cs:764-781: build commands array.
|
||
int cmdIndex = 0;
|
||
foreach (var group in batchesByCullMode)
|
||
{
|
||
foreach (var item in group.Value)
|
||
{
|
||
_modernBatches[cmdIndex] = new ModernBatchData
|
||
{
|
||
TextureHandle = item.batch.BindlessTextureHandle,
|
||
TextureIndex = (uint)item.batch.TextureIndex,
|
||
};
|
||
|
||
_commands[cmdIndex] = new DrawElementsIndirectCommand
|
||
{
|
||
Count = (uint)item.batch.IndexCount,
|
||
InstanceCount = (uint)item.instanceCount,
|
||
FirstIndex = item.batch.FirstIndex,
|
||
BaseVertex = (int)item.batch.BaseVertex,
|
||
BaseInstance = (uint)item.instanceOffset,
|
||
};
|
||
cmdIndex++;
|
||
}
|
||
}
|
||
|
||
// WB BaseObjectRenderManager.cs:784-805: upload (with orphaning).
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||
_gl.BufferData(GLEnum.DrawIndirectBuffer,
|
||
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw);
|
||
fixed (DrawElementsIndirectCommand* ptr = _commands)
|
||
{
|
||
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
|
||
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr);
|
||
}
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
|
||
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
|
||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||
_gpuInstanceTransforms[i] = allInstances[i].Transform;
|
||
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
|
||
{
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
|
||
}
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(totalDraws * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
|
||
fixed (ModernBatchData* ptr = _modernBatches)
|
||
{
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(totalDraws * sizeof(ModernBatchData)), ptr);
|
||
}
|
||
|
||
// Phase U.4: upload the per-instance clip-slot buffer (binding=3). When
|
||
// _cellIdToSlot is set (indoor routing), each cell shell instance is gated
|
||
// to its cell's CellClip slot via allInstances[i].CellId; cells absent from
|
||
// the map (shouldn't happen — the Render filter is the map's keys) and the
|
||
// U.3 path both map to slot 0 (no-clip). allInstances is laid out in the
|
||
// SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so
|
||
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
|
||
if (_clipSlotData.Length < uniqueInstanceCount)
|
||
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
|
||
// #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);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(uniqueInstanceCount * sizeof(uint)), null, GLEnum.DynamicDraw);
|
||
fixed (uint* ptr = _clipSlotData)
|
||
{
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
|
||
}
|
||
|
||
// 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);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
|
||
null, GLEnum.DynamicDraw);
|
||
fixed (float* gp = _globalLightData)
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
|
||
fixed (int* lp = _lightSetData)
|
||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||
|
||
// 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)
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||
|
||
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
|
||
|
||
// WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls.
|
||
int currentDrawOffset = 0;
|
||
foreach (var group in batchesByCullMode)
|
||
{
|
||
var cullMode = (CullMode)(group.Key % 4);
|
||
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
|
||
// uniformly, but the room surfaces need to be visible from inside
|
||
// under acdream's current global winding state. Render cell polys
|
||
// double-sided while the architectural cause is isolated.
|
||
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
|
||
if (_currentCullMode != cullMode)
|
||
{
|
||
SetCullMode(cullMode);
|
||
}
|
||
|
||
bool isAdditive = group.Key >= 4;
|
||
if (isAdditive)
|
||
{
|
||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||
shader.SetInt("uRenderPass", (int)renderPass | 0x100);
|
||
}
|
||
else
|
||
{
|
||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||
shader.SetInt("uRenderPass", (int)renderPass);
|
||
}
|
||
|
||
shader.SetInt("uDrawIDOffset", currentDrawOffset);
|
||
int numDraws = group.Value.Count;
|
||
_gl.MultiDrawElementsIndirect(
|
||
PrimitiveType.Triangles,
|
||
DrawElementsType.UnsignedShort,
|
||
(void*)(currentDrawOffset * sizeof(DrawElementsIndirectCommand)),
|
||
(uint)numDraws,
|
||
(uint)sizeof(DrawElementsIndirectCommand));
|
||
|
||
currentDrawOffset += numDraws;
|
||
}
|
||
|
||
// Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's
|
||
// post-transparent restore) so a Transparent pass's Blend-on / DepthMask-off does
|
||
// not leak into particles or the next frame's draws.
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.DepthMask(true);
|
||
|
||
// WB BaseObjectRenderManager.cs:845-847:
|
||
shader.SetInt("uDrawIDOffset", 0);
|
||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// #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;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BindClipRegionBinding2 (Phase U.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Bind the per-cell clip-region SSBO to binding=2. Prefers the shared
|
||
/// <see cref="ClipFrame"/> buffer (<see cref="SetClipRegionSsbo"/>); otherwise
|
||
/// lazily creates + binds a one-slot no-clip fallback (count 0 = pass-all) so
|
||
/// the shader never reads an unbound SSBO.
|
||
/// </summary>
|
||
private void BindClipRegionBinding2()
|
||
{
|
||
if (_sharedClipRegionSsbo != 0)
|
||
{
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer,
|
||
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
|
||
return;
|
||
}
|
||
|
||
if (_fallbackClipRegionSsbo == 0)
|
||
{
|
||
_gl.GenBuffers(1, out _fallbackClipRegionSsbo);
|
||
// One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
|
||
var zero = new byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes];
|
||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _fallbackClipRegionSsbo);
|
||
fixed (byte* p = zero)
|
||
{
|
||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||
(nuint)AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, p, GLEnum.DynamicDraw);
|
||
}
|
||
}
|
||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer,
|
||
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// List pool (GetPooledList)
|
||
// Copied from WB ObjectRenderManagerBase (pattern).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private List<InstanceData> GetPooledList()
|
||
{
|
||
// Mirrors WB ObjectRenderManagerBase.cs:1221-1233 — the reuse
|
||
// branch MUST clear the list before returning. PrepareRenderBatches'
|
||
// merge phase pattern is `gfxDict[k] = list; list.AddRange(...)`,
|
||
// which assumes the list is empty. Without the clear, lists grow
|
||
// unbounded across frames and each frame's draw includes all prior
|
||
// frames' stale data. Original port omitted the Clear() call — root
|
||
// cause of post-Wave-5 visual chaos (FIX 2026-05-28). See
|
||
// docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
|
||
lock (_listPool)
|
||
{
|
||
if (_poolIndex < _listPool.Count)
|
||
{
|
||
var list = _listPool[_poolIndex++];
|
||
list.Clear();
|
||
return list;
|
||
}
|
||
var fresh = new List<InstanceData>();
|
||
_listPool.Add(fresh);
|
||
_poolIndex++;
|
||
return fresh;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers: bounds computation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Computes a local-space AABB from a CellStruct's vertex array.
|
||
/// </summary>
|
||
private static WbBoundingBox ComputeLocalBoundsFromCellStruct(DatReaderWriter.Types.CellStruct cellStruct)
|
||
{
|
||
if (cellStruct.VertexArray is null || cellStruct.VertexArray.Vertices is null || cellStruct.VertexArray.Vertices.Count == 0)
|
||
return new WbBoundingBox(Vector3.Zero, Vector3.Zero);
|
||
|
||
var min = new Vector3(float.MaxValue);
|
||
var max = new Vector3(float.MinValue);
|
||
foreach (var v in cellStruct.VertexArray.Vertices.Values)
|
||
{
|
||
var p = v.Origin;
|
||
min = Vector3.Min(min, p);
|
||
max = Vector3.Max(max, p);
|
||
}
|
||
return new WbBoundingBox(min, max);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Transforms a local AABB to a world AABB by transforming all 8 corners.
|
||
/// </summary>
|
||
private static WbBoundingBox TransformBoundingBox(WbBoundingBox local, Matrix4x4 transform)
|
||
{
|
||
if (local.Min == local.Max && local.Min == Vector3.Zero)
|
||
return local;
|
||
|
||
var min = local.Min;
|
||
var max = local.Max;
|
||
|
||
Span<Vector3> corners = stackalloc Vector3[8]
|
||
{
|
||
new Vector3(min.X, min.Y, min.Z),
|
||
new Vector3(max.X, min.Y, min.Z),
|
||
new Vector3(min.X, max.Y, min.Z),
|
||
new Vector3(max.X, max.Y, min.Z),
|
||
new Vector3(min.X, min.Y, max.Z),
|
||
new Vector3(max.X, min.Y, max.Z),
|
||
new Vector3(min.X, max.Y, max.Z),
|
||
new Vector3(max.X, max.Y, max.Z),
|
||
};
|
||
|
||
var wMin = new Vector3(float.MaxValue);
|
||
var wMax = new Vector3(float.MinValue);
|
||
foreach (var c in corners)
|
||
{
|
||
var w = Vector3.Transform(c, transform);
|
||
wMin = Vector3.Min(wMin, w);
|
||
wMax = Vector3.Max(wMax, w);
|
||
}
|
||
return new WbBoundingBox(wMin, wMax);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IDisposable
|
||
// ---------------------------------------------------------------------------
|
||
|
||
public void Dispose()
|
||
{
|
||
if (IsDisposed) return;
|
||
IsDisposed = true;
|
||
|
||
if (_mdiCommandBuffer != 0) { _gl.DeleteBuffer(_mdiCommandBuffer); _mdiCommandBuffer = 0; }
|
||
if (_modernInstanceBuffer != 0){ _gl.DeleteBuffer(_modernInstanceBuffer); _modernInstanceBuffer = 0; }
|
||
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
|
||
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
|
||
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
|
||
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
|
||
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
|
||
}
|
||
}
|