From 4ad7a985cf22ff17b9052035b1a441c5cda7acd2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 13:31:30 +0200 Subject: [PATCH] phase(N.4) Task 9: real WB pipeline bring-up + InstancedMeshRenderer routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WbMeshAdapter now actually constructs the WB pipeline: - OpenGLGraphicsDevice(gl, logger, DebugRenderSettings) - DefaultDatReaderWriter(datDir) — opens its own file handles for now (memory cost ~50-100MB of duplicate index caches, acceptable for foundation work per plan Adjustment 1) - ObjectMeshManager(graphicsDevice, dats, NullLogger) InstancedMeshRenderer.EnsureUploaded routes through the adapter when ACDREAM_USE_WB_FOUNDATION=1 is set; uses a WbManagedSentinel entry in the local cache to mark "this GfxObj lives in WB now". CollectGroups skips sentinel entries; both Draw passes skip them; Dispose skips them (no GL resources to free — ObjectMeshManager owns those). Task 22's WbDrawDispatcher will eventually draw WB-managed objects. With flag off, behavior is byte-identical to before. WbMeshAdapter constructor signature changed from (GL, DatCollection, Logger) to (GL, string datDir, Logger). Updated tests to use CreateUninitialized() for behavior tests and single null-GL guard test for constructor validation. GameWindow updated to pass _datDir and to wire _wbMeshAdapter into InstancedMeshRenderer. AcDream.App.csproj gets direct ProjectReferences to WorldBuilder.Shared and Chorizite.OpenGLSDLBackend — project refs are not transitive in .NET, so AcDream.App must list them explicitly even though AcDream.Core already references them. Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/AcDream.App.csproj | 6 ++ src/AcDream.App/Rendering/GameWindow.cs | 11 +-- .../Rendering/InstancedMeshRenderer.cs | 49 ++++++++++- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 88 +++++++++++++------ .../Rendering/Wb/WbMeshAdapterTests.cs | 32 +++---- 5 files changed, 137 insertions(+), 49 deletions(-) diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index c8a473b..a0c4b77 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -26,6 +26,12 @@ + + + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 543a1f4..902ca5b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1427,17 +1427,18 @@ public sealed class GameWindow : IDisposable // Phase N.4 — WB rendering pipeline foundation. Constructed only when // ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer - // path stays in charge. The full ObjectMeshManager bring-up is - // deferred to Task 9 — for now this is a stub adapter that exposes - // the public API so call sites can wire without behavioral effect. + // path stays in charge. The full ObjectMeshManager bring-up lives in + // WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter + // + ObjectMeshManager. WbMeshAdapter opens its own file handles for + // the dat files (independent of our DatCollection). if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled) { var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; - _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _dats, wbLogger); + _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, wbLogger); Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager."); } - _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); + _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter); // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) // with depth writes off + far plane 1e6 so celestial meshes diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 92e8f5c..2ba5093 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -20,6 +20,7 @@ // needs to update the shader and uniform setup at the call sites. using System.Numerics; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Wb; using AcDream.Core.Meshing; using AcDream.Core.Terrain; using AcDream.Core.World; @@ -33,6 +34,20 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable private readonly Shader _shader; private readonly TextureCache _textures; + /// + /// Optional WB adapter. When non-null and , + /// hands the GfxObj ref to the WB pipeline instead of + /// uploading into our own VAO pool. The draw loop skips sentinel entries — Task 22's + /// WbDrawDispatcher will eventually draw them. + /// + private readonly WbMeshAdapter? _wbMeshAdapter; + + // Sentinel: a GfxObj that has been handed to the WB pipeline gets this list + // stored in _gpuByGfxObj. The Draw loop recognises it by reference identity + // (object.ReferenceEquals) and skips it — no legacy VAO draw for WB-managed + // objects until Task 22 wires up WbDrawDispatcher. + private static readonly List WbManagedSentinel = new(0); + // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes. private readonly Dictionary> _gpuByGfxObj = new(); @@ -67,11 +82,13 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature); - public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures) + public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures, + WbMeshAdapter? wbMeshAdapter = null) { _gl = gl; _shader = shader; _textures = textures; + _wbMeshAdapter = wbMeshAdapter; _instanceVbo = _gl.GenBuffer(); } @@ -83,6 +100,17 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; + // Phase N.4 Task 9: when the WB foundation flag is on and we have an + // adapter, hand this GfxObj to the WB pipeline instead of uploading our + // own VAO. The sentinel entry marks "this GfxObj lives in WB now" so the + // draw loop knows to skip it. Task 22's WbDrawDispatcher will draw them. + if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null) + { + _wbMeshAdapter.IncrementRefCount(gfxObjId); + _gpuByGfxObj[gfxObjId] = WbManagedSentinel; + return; + } + var list = new List(subMeshes.Count); foreach (var sm in subMeshes) list.Add(UploadSubMesh(sm)); @@ -217,6 +245,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes)) continue; + // WB-managed GfxObjs have a sentinel entry; Task 22 (WbDrawDispatcher) + // will draw them. Skip here to avoid drawing with stale/null VAO data. + if (object.ReferenceEquals(subMeshes, WbManagedSentinel)) + continue; + bool hasOpaqueSubMesh = false; foreach (var sub in subMeshes) { @@ -292,6 +325,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes)) continue; + // WB-managed GfxObjs — skip; Task 22 will draw them. + if (object.ReferenceEquals(subMeshes, WbManagedSentinel)) + continue; + bool hasTranslucentSubMesh = false; foreach (var sub in subMeshes) { @@ -419,7 +456,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable foreach (var meshRef in entity.MeshRefs) { - if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId)) + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes)) + continue; + // WB-managed GfxObjs don't go through our instance pipeline. + if (object.ReferenceEquals(cachedMeshes, WbManagedSentinel)) continue; var model = meshRef.PartTransform * entityRoot; @@ -525,6 +565,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable { foreach (var subs in _gpuByGfxObj.Values) { + // WB-managed entries use the sentinel — no GL resources to free here; + // ObjectMeshManager owns those resources. + if (object.ReferenceEquals(subs, WbManagedSentinel)) + continue; + foreach (var sub in subs) { _gl.DeleteBuffer(sub.Vbo); diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 0f00620..ec5f407 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,72 +1,106 @@ using System; -using DatReaderWriter; +using Chorizite.OpenGLSDLBackend; +using Chorizite.OpenGLSDLBackend.Lib; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Silk.NET.OpenGL; +using WorldBuilder.Shared.Models; +using WorldBuilder.Shared.Services; namespace AcDream.App.Rendering.Wb; /// /// Single seam between acdream and WB's render pipeline. Owns the -/// ObjectMeshManager instance (when fully initialized) and exposes -/// a stable acdream-shaped API so the rest of the renderer doesn't need -/// to know about WB's types directly. +/// ObjectMeshManager instance and exposes a stable acdream-shaped API +/// so the rest of the renderer doesn't need to know about WB's types directly. /// /// -/// Phase N.4 staging: currently a stub. Real ObjectMeshManager -/// + OpenGLGraphicsDevice initialization is added in Task 9 once -/// the dat-reader adapter (Task 6) lands. Until then, methods no-op so -/// call sites can wire the adapter without behavioral effect when the -/// flag is on. +/// The adapter constructs its own DefaultDatReaderWriter internally; it +/// does NOT share file handles with our DatCollection. This duplicates +/// index-cache memory (~50–100 MB) but keeps the two subsystems fully decoupled. +/// Acceptable for Phase N.4 foundation work (plan Adjustment 1). /// /// public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { - // _meshManager and _graphicsDevice will be wired in Task 9 once - // WbDatReaderAdapter (Task 6) lands. For now, both are null and all - // methods no-op. - // private ObjectMeshManager? _meshManager; - // private OpenGLGraphicsDevice? _graphicsDevice; + private readonly OpenGLGraphicsDevice? _graphicsDevice; + private readonly DefaultDatReaderWriter? _wbDats; + private readonly ObjectMeshManager? _meshManager; + + /// + /// True when this instance was created via ; + /// all public methods no-op when uninitialized. + /// + private readonly bool _isUninitialized; + private bool _disposed; - public WbMeshAdapter(GL gl, DatCollection dats, ILogger logger) + /// + /// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter + /// → ObjectMeshManager. + /// + /// Active Silk.NET GL context. Must be bound to the current + /// thread (construction runs GL queries; call from OnLoad). + /// Path to the dat directory (same as the one supplied + /// to our DatCollection). DefaultDatReaderWriter opens its own file handles. + /// Logger for the adapter; ObjectMeshManager uses + /// NullLogger internally. + public WbMeshAdapter(GL gl, string datDir, ILogger logger) { ArgumentNullException.ThrowIfNull(gl); - ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(datDir); ArgumentNullException.ThrowIfNull(logger); - // TODO(N.4 Task 9): construct OpenGLGraphicsDevice and ObjectMeshManager - // once WbDatReaderAdapter (Task 6) is available to bridge our DatCollection - // to WB's IDatReaderWriter. + _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); + _wbDats = new DefaultDatReaderWriter(datDir); + _meshManager = new ObjectMeshManager( + _graphicsDevice, + _wbDats, + NullLogger.Instance); } private WbMeshAdapter() { - // Uninitialized constructor — only for tests / for cases where the - // flag is off and the caller wants a Dispose-safe no-op instance. + // Uninitialized constructor — only for tests / flag-off cases where + // the caller wants a Dispose-safe no-op instance. + _isUninitialized = true; } /// Test/init helper — produces a Dispose-safe instance with no /// underlying mesh manager. Public methods are all no-ops. public static WbMeshAdapter CreateUninitialized() => new(); - /// Returns null until Task 9 wires up the real mesh manager. - public object? GetRenderData(ulong id) => null; + /// + /// Returns the WB render data for , or null if not + /// yet uploaded or if this adapter is uninitialized. + /// + public ObjectRenderData? GetRenderData(ulong id) + { + if (_isUninitialized || _meshManager is null) return null; + return _meshManager.GetRenderData(id); + } + /// public void IncrementRefCount(ulong id) { - // No-op until Task 9. + if (_isUninitialized || _meshManager is null) return; + _meshManager.IncrementRefCount(id); } + /// public void DecrementRefCount(ulong id) { - // No-op until Task 9. + if (_isUninitialized || _meshManager is null) return; + _meshManager.DecrementRefCount(id); } + /// public void Dispose() { if (_disposed) return; _disposed = true; - // _meshManager?.Dispose(); - // _graphicsDevice?.Dispose(); + _meshManager?.Dispose(); + _wbDats?.Dispose(); + _graphicsDevice?.Dispose(); } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs index d92bd46..1aaa33d 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs @@ -10,22 +10,11 @@ public sealed class WbMeshAdapterTests [Fact] public void Construct_WithNullGl_ThrowsArgumentNull() { + // GL is the first guarded parameter; verifies the constructor validates inputs. + // We can't pass a real GL (no context in tests), so we verify only the + // null-GL guard. The real pipeline is tested via integration. Assert.Throws(() => - new WbMeshAdapter(gl: null!, dats: null!, logger: NullLogger.Instance)); - } - - [Fact] - public void Construct_WithNullDats_ThrowsArgumentNull() - { - // GL cannot be constructed without a real GL context, so we verify - // the dats-null guard by passing a non-null GL sentinel — we reach - // the dats guard on the way. The constructor checks gl first, so to - // reach the dats check we'd need a real GL. Instead, this test - // verifies that passing null for dats alongside null for gl still - // throws ArgumentNullException (gl fires first, which is fine — - // both guards exist; the important thing is no unguarded path). - Assert.Throws(() => - new WbMeshAdapter(gl: null!, dats: null!, logger: NullLogger.Instance)); + new WbMeshAdapter(gl: null!, datDir: "some/path", logger: NullLogger.Instance)); } [Fact] @@ -42,6 +31,19 @@ public sealed class WbMeshAdapterTests var adapter = WbMeshAdapter.CreateUninitialized(); // Should not throw, even though there's no underlying mesh manager. adapter.IncrementRefCount(0x01000001ul); + } + + [Fact] + public void DecrementRefCount_OnUninitializedAdapter_NoOps() + { + var adapter = WbMeshAdapter.CreateUninitialized(); adapter.DecrementRefCount(0x01000001ul); } + + [Fact] + public void GetRenderData_OnUninitializedAdapter_ReturnsNull() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + Assert.Null(adapter.GetRenderData(0x01000001ul)); + } }