diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index fee413b..61f4084 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -6076,6 +6076,12 @@ public sealed class GameWindow : IDisposable
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
+ // Phase N.4: drain WB pipeline queues (staged mesh data +
+ // GL thread queue). Must happen before any draw work so that
+ // resources uploaded this frame are available immediately.
+ // No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
+ _wbMeshAdapter?.Tick();
+
// Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
index ec5f407..b8a3a23 100644
--- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
@@ -94,6 +94,38 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager.DecrementRefCount(id);
}
+ ///
+ /// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
+ /// called once per frame from the render thread. Without this, the staged
+ /// mesh data queue grows unbounded (memory leak) and queued GL actions
+ /// never execute.
+ ///
+ ///
+ /// Order matters: ProcessGLQueue runs first to apply any pending GL
+ /// state changes (e.g., texture uploads queued by background workers
+ /// during mesh prep). Then we drain staged mesh data, calling
+ /// UploadMeshData on each item to materialize the actual GL VAO /
+ /// VBO / IBO resources. After Tick, GetRenderData for any id
+ /// previously passed to IncrementRefCount may return non-null.
+ ///
+ ///
+ ///
+ /// No-op when the adapter is uninitialized (e.g., flag is off and the
+ /// adapter was constructed via CreateUninitialized).
+ ///
+ ///
+ public void Tick()
+ {
+ if (_isUninitialized) return;
+ if (_disposed) return;
+
+ _graphicsDevice!.ProcessGLQueue();
+ while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
+ {
+ _meshManager.UploadMeshData(meshData);
+ }
+ }
+
///
public void Dispose()
{
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
index 1aaa33d..5758026 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
@@ -46,4 +46,20 @@ public sealed class WbMeshAdapterTests
var adapter = WbMeshAdapter.CreateUninitialized();
Assert.Null(adapter.GetRenderData(0x01000001ul));
}
+
+ [Fact]
+ public void Tick_OnUninitializedAdapter_DoesNotThrow()
+ {
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ adapter.Tick(); // no-op, no throw
+ adapter.Tick(); // idempotent
+ }
+
+ [Fact]
+ public void Tick_AfterDispose_DoesNotThrow()
+ {
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ adapter.Dispose();
+ adapter.Tick(); // no-op, no throw
+ }
}