From f16b8e981229980c5250e2eed07ff0ebc5f0df96 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 14:55:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20Wave=202=20?= =?UTF-8?q?=E2=80=94=20EnvCellRenderer=20(WB=20EnvCellRenderManager=20port?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core port. 1013 LOC of WB-faithful rendering algorithm: - GetEnvCellGeomId : WB EnvCellRenderManager.cs:94-103 verbatim - PrepareRenderBatches : WB EnvCellRenderManager.cs:247-373 verbatim (parallel frustum-cull, per-cell slow path, ThreadLocal merge, atomic snapshot swap) - Render(filter:) : WB EnvCellRenderManager.cs:395-511 verbatim (filter-driven gfxObj group + draw call build) - RenderModernMDIInternal : WB BaseObjectRenderManager.cs:709-848 (single-slot variant; resize buffers, group by cull mode + additive, MDI draw) - PopulatePartGroups : WB EnvCellRenderManager.cs:572-580 verbatim (Setup part recursion via PopulateRecursive) - RegisterCell / FinalizeLandblock / RemoveLandblock — streaming seam (no WB analog; bridges acdream's existing StreamingController + LandblockStreamer to the renderer's per-cell instance store) Documented deviations from WB: - Drop _useModernRendering branch (Phase N.5 mandatory modern path) - Drop SelectedInstance/HoveredInstance highlights (no editor state) - _activeSnapshotGlobalGroups/GfxObjIds as sibling fields on the class rather than on the snapshot (EnvCellVisibilitySnapshot per Task 4 spec only carries BatchedByCell + VisibleLandblocks; global groups only used in the unfiltered Render(pass) path which we don't take) - ConcurrentDictionary keyed by full 32-bit landblock id (WB uses ushort packed key; acdream uses full id throughout) 10 unit tests (GetEnvCellGeomId determinism + bit-33 dedup flag + NeedsPrepare + dispose semantics + RemoveLandblock idempotence). Build green; 23/23 Wave 1+2 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/EnvCellRenderer.cs | 1013 +++++++++++++++++ .../Rendering/Wb/EnvCellRendererTests.cs | 116 ++ 2 files changed, 1129 insertions(+) create mode 100644 src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs new file mode 100644 index 0000000..abf3a6a --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -0,0 +1,1013 @@ +// 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.Runtime.InteropServices; +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 _landblocks — + // we use uint (full LB id) because acdream uses 32-bit landblock keys throughout. + private readonly ConcurrentDictionary _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). + private GLSLShader? _shader; + private bool _initialized; + + // List pool — copied from WB ObjectRenderManagerBase. + // WB ObjectRenderManagerBase.cs:83-86: protected readonly List> _listPool = new(); protected int _poolIndex = 0; + private readonly List> _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; + + // Reusable scratch arrays — avoid per-frame allocation. + // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() + private DrawElementsIndirectCommand[] _commands = Array.Empty(); + private ModernBatchData[] _modernBatches = Array.Empty(); + + // 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; + + // --------------------------------------------------------------------------- + // Constructor + Initialize + // --------------------------------------------------------------------------- + + public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum) + { + _gl = gl; + _meshManager = meshManager; + _frustum = frustum; + } + + public void Initialize(GLSLShader 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(InstanceData)), 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); + + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + } + + // --------------------------------------------------------------------------- + // GetEnvCellGeomId + // Verbatim copy of WB EnvCellRenderManager.cs:94-103. + // --------------------------------------------------------------------------- + + /// + /// 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). + /// + public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List 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. + // --------------------------------------------------------------------------- + + /// + /// Registers a single EnvCell and its static objects into the per-landblock + /// pending list. Call after all cells for a + /// landblock have been registered. + /// + public void RegisterCell( + uint landblockId, + uint envCellId, + DatReaderWriter.DBObjs.EnvCell envCell, + DatReaderWriter.Types.CellStruct cellStruct, + Matrix4x4 cellTransform, + Vector3 cellWorldPosition, + Quaternion cellRotation, + IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects) + { + // 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId). + var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces); + + // 2. Trigger mesh prep for the cell geometry on ObjectMeshManager. + // This populates ObjectMeshManager._renderData[cellGeomId] when complete. + _ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId, + envCell.CellStructure, envCell.Surfaces); + + // 3. Compute local bounds from cellStruct vertex array. + var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct); + var worldBounds = TransformBoundingBox(localBounds, cellTransform); + + // 4. Create the per-cell SceneryInstance. + var cellInstance = new EnvCellSceneryInstance + { + ObjectId = cellGeomId, + InstanceId = envCellId, // uint InstanceId maps to the cell id + IsBuilding = true, + IsEntryCell = false, // could be derived from entry-portal walk; default false + WorldPosition = cellWorldPosition, + LocalPosition = Vector3.Zero, + Rotation = cellRotation, + Scale = Vector3.One, + Transform = cellTransform, + LocalBoundingBox = localBounds, + BoundingBox = worldBounds, + }; + + var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock + { + GridX = (int)((id >> 24) & 0xFFu), + GridY = (int)((id >> 16) & 0xFFu), + }); + + lock (lb.Lock) + { + lb.PendingInstances ??= new List(capacity: 32); + lb.PendingInstances.Add(cellInstance); + lb.PendingEnvCellBounds ??= new Dictionary(); + 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); + } + } + } + + /// + /// Atomically promotes the pending instance list to the committed list, + /// computes total EnvCell bounds, populates PartGroups, and marks the + /// landblock ready for rendering. + /// + public void FinalizeLandblock(uint landblockId) + { + if (!_landblocks.TryGetValue(landblockId, out var lb)) return; + lock (lb.Lock) + { + if (lb.PendingInstances is not null) + { + lb.Instances = lb.PendingInstances; + lb.PendingInstances = null; + } + if (lb.PendingEnvCellBounds is not null) + { + lb.EnvCellBounds = lb.PendingEnvCellBounds; + lb.PendingEnvCellBounds = null; + } + + // Compute total bounds union across all cells. + var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); + foreach (var b in lb.EnvCellBounds.Values) + total = WbBoundingBox.Union(total, b); + lb.TotalEnvCellBounds = total; + + // Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580). + PopulatePartGroups(lb); + + lb.InstancesReady = true; + lb.MeshDataReady = true; + lb.GpuReady = true; + } + NeedsPrepare = true; + } + + /// + /// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it. + /// + public void RemoveLandblock(uint landblockId) + { + _landblocks.TryRemove(landblockId, out _); + NeedsPrepare = true; + } + + // --------------------------------------------------------------------------- + // PopulatePartGroups + // Verbatim port of WB EnvCellRenderManager.cs:572-580. + // --------------------------------------------------------------------------- + + /// + /// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list. + /// Must be called under lb.Lock. + /// Source: WB EnvCellRenderManager.cs:572-580 (verbatim). + /// + 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); + } + } + + /// + /// Recursively walks a Setup's parts (or handles a plain GfxObj directly), + /// adding one per leaf GfxObj to the target group. + /// Mirrors WB ObjectRenderManagerBase.PopulateRecursive. + /// + private void PopulateRecursive( + Dictionary> 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) + { + // Each Setup part is a GfxObj — not a nested Setup. + // Compose: world = partTransform (relative to setup) * setup world transform. + var combined = partTransform * transform; + PopulateRecursive(group, partId, isSetup: false, combined, cellId); + } + } + else + { + // Plain GfxObj — just add one InstanceData. + if (!group.TryGetValue(objectId, out var list)) + { + list = new List(); + group[objectId] = list; + } + list.Add(new InstanceData + { + Transform = transform, + CellId = cellId, + Flags = 0, + }); + } + } + + // --------------------------------------------------------------------------- + // PrepareRenderBatches + // Verbatim port of WB EnvCellRenderManager.cs:247-373. + // --------------------------------------------------------------------------- + + /// + /// Frustum-culls all registered landblocks and builds a new + /// that the render thread consumes. + /// Call once per frame, before . + /// Source: WB EnvCellRenderManager.cs:247-373 (verbatim). + /// + public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet? filter = null) + { + // WB EnvCellRenderManager.cs:249-250: + if (!_initialized || cameraPosition.Z > 4000) 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(); + foreach (var lb in _landblocks.Values) + 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>>>( + () => new(), trackAllValues: true); + using var threadLocalGlobalGroups = + new ThreadLocal>>( + () => 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(); + 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>>(); + var newVisibleGroups = new Dictionary>(); + var newVisibleGfxObjIds = new List(); + + // 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>(); + 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. + lock (_renderLock) + { + _activeSnapshot = new EnvCellVisibilitySnapshot + { + BatchedByCell = newBatchedByCell, + VisibleLandblocks = landblocks, + // 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> _activeSnapshotGlobalGroups = new(); + private List _activeSnapshotGlobalGfxObjIds = new(); + + // --------------------------------------------------------------------------- + // AddToGroups (static helper) + // Verbatim port of WB EnvCellRenderManager.cs:375-393. + // --------------------------------------------------------------------------- + + private static void AddToGroups( + Dictionary>> batchedByCell, + Dictionary> 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(); + 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>(); + batchedByCell[cellId] = gfxDict; + } + if (!gfxDict.TryGetValue(gfxObjId, out var list)) + { + list = new List(); + 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: use our GLSLShader.Bind() + SetUniform(string,int). + // --------------------------------------------------------------------------- + + public void Render(WbRenderPass renderPass) + { + // WB EnvCellRenderManager.cs:396: + Render(renderPass, null); + } + + /// + /// Draws all visible EnvCells (and their static objects) for the given pass. + /// When is non-null, only cells whose CellId is in + /// the set are drawn — used for indoor RenderInsideOut to restrict to camera- + /// building cells. + /// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights). + /// + public void Render(WbRenderPass renderPass, HashSet? 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.Bind(); + _poolIndex = snapshot.BatchedByCell.Count; // reset point (mirrors WB line 405) + + // WB EnvCellRenderManager.cs:406-409: uniform state setup. + _shader.SetUniform("uRenderPass", (int)renderPass); + _shader.SetUniform("uFilterByCell", 0); + + var allInstances = new List(); + var drawCalls = new List<(ObjectRenderData renderData, int count, int offset)>(); + + if (filter == null) + { + // WB EnvCellRenderManager.cs:418-429: optimized path — global groups. + foreach (var gfxObjId in _activeSnapshotGlobalGfxObjIds) + { + if (_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out var transforms)) + { + var renderData = _meshManager.TryGetRenderData(gfxObjId); + if (renderData != null && !renderData.IsSetup) + { + drawCalls.Add((renderData, transforms.Count, allInstances.Count)); + allInstances.AddRange(transforms); + } + } + } + } + else + { + // WB EnvCellRenderManager.cs:431-468: filtered path. + // Group by gfxObjId within the filtered cells to minimize draw calls. + var filteredGroups = new Dictionary>(); + var ownedLists = new HashSet>(); + + foreach (var cellId in filter) + { + if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) continue; + foreach (var (gfxObjId, transforms) in gfxDict) + { + if (transforms.Count == 0) continue; + if (!filteredGroups.TryGetValue(gfxObjId, out var list)) + { + list = transforms; // Optimization: just use the first list + filteredGroups[gfxObjId] = list; + } + else + { + if (list == transforms) continue; + + // If we don't own this list yet, we must clone it before adding to it + if (!ownedLists.Contains(list)) + { + var newList = GetPooledList(); + newList.AddRange(list); + list = newList; + filteredGroups[gfxObjId] = list; + ownedLists.Add(list); + } + list.AddRange(transforms); + } + } + } + + // WB EnvCellRenderManager.cs:461-468: + foreach (var (gfxObjId, transforms) in filteredGroups) + { + var renderData = _meshManager.TryGetRenderData(gfxObjId); + if (renderData != null && !renderData.IsSetup) + { + drawCalls.Add((renderData, transforms.Count, allInstances.Count)); + allInstances.AddRange(transforms); + } + } + } + + // WB EnvCellRenderManager.cs:470-483: + if (allInstances.Count > 0) + { + // WB uses: if (_useModernRendering) { RenderModernMDI(...) } else { legacy } + // We always use modern (Phase N.5 mandatory). + RenderModernMDIInternal(_shader, drawCalls, allInstances, renderPass); + } + + // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). + + // WB EnvCellRenderManager.cs:506-509: cleanup. + _shader.SetUniform("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0)); + _shader.SetUniform("uRenderPass", (int)renderPass); + _gl.BindVertexArray(0); + _currentVao = 0; + + // 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; + } + } + + // --------------------------------------------------------------------------- + // 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( + GLSLShader shader, + List<(ObjectRenderData renderData, int count, int offset)> drawCalls, + List allInstances, + WbRenderPass renderPass) + { + // WB BaseObjectRenderManager.cs:710-713: + if (drawCalls.Count == 0 || allInstances.Count == 0) return; + + int passIdx = (int)renderPass; + if (passIdx < 0 || passIdx > 2) return; + + // WB BaseObjectRenderManager.cs:715-716: + shader.Bind(); + shader.SetUniform("uFilterByCell", 0); + + // WB BaseObjectRenderManager.cs:718-740: group batches by CullMode + additive flag. + var batchesByCullMode = new Dictionary>(); + int totalDraws = 0; + + foreach (var call in drawCalls) + { + foreach (var batch in call.renderData.Batches) + { + // WB BaseObjectRenderManager.cs:723-731: pass-filter. + if (renderPass != WbRenderPass.SinglePass) + { + if (batch.IsAdditive) + { + if (renderPass == WbRenderPass.Opaque) continue; + } + else if (!batch.IsTransparent) + { + if (renderPass == WbRenderPass.Transparent) continue; + } + } + + // WB BaseObjectRenderManager.cs:732-740: + var cullMode = batch.CullMode; + var groupIdx = (int)cullMode + (batch.IsAdditive ? 4 : 0); + if (!batchesByCullMode.TryGetValue(groupIdx, out var list)) + { + list = new List<(ObjectRenderBatch, int, int)>(); + batchesByCullMode[groupIdx] = list; + } + list.Add((batch, call.count, call.offset)); + totalDraws++; + } + } + + // WB BaseObjectRenderManager.cs:743: + if (totalDraws == 0) return; + + // WB BaseObjectRenderManager.cs:745-759: resize buffers if needed. + if (totalDraws > _mdiCommandCapacity) + { + _mdiCommandCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); + _gl.BufferData(GLEnum.DrawIndirectBuffer, + (nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); + + _modernBatchCapacity = _mdiCommandCapacity; + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); + } + + int uniqueInstanceCount = allInstances.Count; + if (uniqueInstanceCount > _modernInstanceCapacity) + { + _modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(_modernInstanceCapacity * sizeof(InstanceData)), 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(InstanceData)), null, GLEnum.DynamicDraw); + var instancesSpan = CollectionsMarshal.AsSpan(allInstances); + fixed (InstanceData* ptr = instancesSpan) + { + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(uniqueInstanceCount * sizeof(InstanceData)), 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); + } + + // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. + var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u; + if (globalVao == 0) return; + if (_currentVao != globalVao) + { + _gl.BindVertexArray(globalVao); + _currentVao = globalVao; + } + + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer); + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer); + _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); + if (_currentCullMode != cullMode) + { + SetCullMode(cullMode); + } + + bool isAdditive = group.Key >= 4; + if (isAdditive) + { + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + shader.SetUniform("uRenderPass", (int)renderPass | 0x100); + } + else + { + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + shader.SetUniform("uRenderPass", (int)renderPass); + } + + shader.SetUniform("uDrawIDOffset", currentDrawOffset); + int numDraws = group.Value.Count; + _gl.MultiDrawElementsIndirect( + PrimitiveType.Triangles, + DrawElementsType.UnsignedShort, + (void*)(currentDrawOffset * sizeof(DrawElementsIndirectCommand)), + (uint)numDraws, + (uint)sizeof(DrawElementsIndirectCommand)); + + currentDrawOffset += numDraws; + } + + // WB BaseObjectRenderManager.cs:845-847: + shader.SetUniform("uDrawIDOffset", 0); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + } + + // --------------------------------------------------------------------------- + // SetCullMode + // Verbatim copy of WB BaseObjectRenderManager.cs:850-866. + // --------------------------------------------------------------------------- + + private void SetCullMode(CullMode mode) + { + _currentCullMode = mode; + switch (mode) + { + case CullMode.None: + _gl.Disable(EnableCap.CullFace); + break; + case CullMode.Clockwise: + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Front); + break; + case CullMode.CounterClockwise: + case CullMode.Landblock: + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); + break; + } + } + + // --------------------------------------------------------------------------- + // List pool (GetPooledList) + // Copied from WB ObjectRenderManagerBase (pattern). + // --------------------------------------------------------------------------- + + private List GetPooledList() + { + lock (_listPool) + { + if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++]; + var fresh = new List(); + _listPool.Add(fresh); + _poolIndex++; + return fresh; + } + } + + // --------------------------------------------------------------------------- + // Helpers: bounds computation + // --------------------------------------------------------------------------- + + /// + /// Computes a local-space AABB from a CellStruct's vertex array. + /// + 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); + } + + /// + /// Transforms a local AABB to a world AABB by transforming all 8 corners. + /// + 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 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; } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs new file mode 100644 index 0000000..a3d9bb2 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -0,0 +1,116 @@ +// Tests for EnvCellRenderer (Phase A8, 2026-05-28). +// These cover the pure data-handling portions of EnvCellRenderer. +// The GL-dependent Render() and RenderModernMDIInternal() paths require a +// GL context and are visual-verified at the render frame (Task 10). + +using System.Collections.Generic; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class EnvCellRendererTests +{ + // ----------------------------------------------------------------------- + // GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103 + // ----------------------------------------------------------------------- + + [Fact] + public void GetEnvCellGeomId_DedupBitSet() + { + var id = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1, 2, 3 }); + // Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids. + Assert.NotEqual(0UL, id & 0x2_0000_0000UL); + } + + [Fact] + public void GetEnvCellGeomId_Deterministic() + { + var s = new List { 1, 2, 3 }; + var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s); + var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s); + Assert.Equal(a, b); + } + + [Fact] + public void GetEnvCellGeomId_DiffersByEnvironmentId() + { + var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1 }); + var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List { 1 }); + Assert.NotEqual(a, b); + } + + [Fact] + public void GetEnvCellGeomId_DiffersByCellStructure() + { + var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1 }); + var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 8, new List { 1 }); + Assert.NotEqual(a, b); + } + + [Fact] + public void GetEnvCellGeomId_DiffersBySurfaces() + { + var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1 }); + var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 2 }); + Assert.NotEqual(a, b); + } + + // ----------------------------------------------------------------------- + // Constructor — pure data, no GL + // ----------------------------------------------------------------------- + + [Fact] + public void NewRenderer_NeedsPrepareIsTrue() + { + // GL and meshManager are null — only valid for pure-data tests (no + // Initialize() is called, so no GL calls are made). + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + Assert.True(r.NeedsPrepare); + } + + [Fact] + public void NewRenderer_NotDisposed() + { + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + Assert.False(r.IsDisposed); + } + + // ----------------------------------------------------------------------- + // RemoveLandblock — pure data path + // ----------------------------------------------------------------------- + + [Fact] + public void RemoveLandblock_NonExistent_DoesNotThrow() + { + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + // Should silently no-op. + r.RemoveLandblock(0xA9B40000u); + Assert.True(r.NeedsPrepare); + } + + // ----------------------------------------------------------------------- + // GetEnvCellGeomId — additional edge cases + // ----------------------------------------------------------------------- + + [Fact] + public void GetEnvCellGeomId_EmptySurfaces_Deterministic() + { + var a = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List()); + var b = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List()); + Assert.Equal(a, b); + Assert.NotEqual(0UL, a & 0x2_0000_0000UL); + } + + [Fact] + public void GetEnvCellGeomId_SurfaceOrderMatters() + { + var a = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List { 10, 20 }); + var b = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List { 20, 10 }); + // The hash is order-sensitive (matches WB's foreach loop), so + // swapped order should produce a different id. + Assert.NotEqual(a, b); + } + + // (Render() requires a GL context — visual-verified in Task 10.) +}