From 71805e821d3fdc79a3e2b2421fe2c88dae1e74f1 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:14:44 -0700 Subject: [PATCH 01/55] M2 Phase A --- examples/CMakeLists.txt | 7 +++ examples/hello_threaded.cpp | 120 ++++++++++++++++++++++++++++++++++++ include/canvas.h | 36 +++++++++++ source/canvas.cpp | 24 ++++++++ source/frame_runner.cpp | 91 +++++++++++++++++++++++++++ source/frame_runner.h | 30 +++++++++ source/snapshot_buffers.h | 72 ++++++++++++++++++++++ 7 files changed, 380 insertions(+) create mode 100644 examples/hello_threaded.cpp create mode 100644 source/snapshot_buffers.h diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 35288712..10f64c4c 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -142,3 +142,10 @@ add_executable(hello_dpi_scaling ) set_property(TARGET hello_dpi_scaling PROPERTY CXX_STANDARD 20) target_link_libraries(hello_dpi_scaling PRIVATE nothofagus) + +add_executable(hello_threaded + "hello_threaded.cpp" +) +set_property(TARGET hello_threaded PROPERTY CXX_STANDARD 20) +find_package(Threads REQUIRED) +target_link_libraries(hello_threaded PRIVATE nothofagus Threads::Threads) diff --git a/examples/hello_threaded.cpp b/examples/hello_threaded.cpp new file mode 100644 index 00000000..919401ca --- /dev/null +++ b/examples/hello_threaded.cpp @@ -0,0 +1,120 @@ +// hello_threaded — two-thread sim/render split (M2 Phase A). +// +// The simulation runs on its own std::thread, animating a field of sprites and +// committing a frame snapshot each tick. The main thread renders the previous +// snapshot, so render of frame N overlaps simulation of frame N+1. +// +// nothofagus spawns no threads: this file owns both loops. Per the Phase-A +// contract, every texture/bellota is created up front and the simulation only +// mutates existing bellota *values* (transform) at runtime — no resource +// create/destroy and no ImGui on the threaded path. + +#include +#include +#include +#include +#include +#include + +int main() +{ + spdlog::info("hello_threaded: two-thread sim/render split"); + + Nothofagus::ScreenSize screenSize{200, 150}; + Nothofagus::Canvas canvas(screenSize, "Hello Threaded", {0.05f, 0.05f, 0.10f}, 5); + + Nothofagus::ColorPallete pallete{ + {0.0, 0.0, 0.0, 0.0}, + {0.9, 0.3, 0.2, 1.0}, + {1.0, 0.8, 0.2, 1.0}, + {0.3, 0.7, 1.0, 1.0}, + }; + + Nothofagus::IndirectTexture texture({8, 8}, {0.0, 0.0, 0.0, 0.0}); + texture.setPallete(pallete) + .setPixels( + { + 0,0,1,1,1,1,0,0, + 0,1,2,2,2,2,1,0, + 1,2,2,3,3,2,2,1, + 1,2,3,3,3,3,2,1, + 1,2,3,3,3,3,2,1, + 1,2,2,3,3,2,2,1, + 0,1,2,2,2,2,1,0, + 0,0,1,1,1,1,0,0, + }); + const Nothofagus::TextureId textureId = canvas.addTexture(texture); + + // --- All resources created up front (Phase A): a grid of sprites. --- + struct Sprite + { + Nothofagus::BellotaId id; + glm::vec2 home; + float phase; + }; + std::vector sprites; + + constexpr int columns = 9; + constexpr int rows = 7; + for (int row = 0; row < rows; ++row) + { + for (int col = 0; col < columns; ++col) + { + const float x = 20.0f + col * 20.0f; + const float y = 20.0f + row * 18.0f; + const Nothofagus::BellotaId id = canvas.addBellota({{{x, y}, 1.0f}, textureId}); + sprites.push_back({id, glm::vec2(x, y), 0.35f * (col + row)}); + } + } + + Nothofagus::Controller controller; + controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + canvas.close(); + }); + + // --- Simulation: animate sprite transforms (existing bellota values only). --- + float simTime = 0.0f; + auto update = [&](float dt) + { + simTime += dt; + for (const Sprite& sprite : sprites) + { + Nothofagus::Bellota& bellota = canvas.bellota(sprite.id); + const float wave = 0.0025f * simTime + sprite.phase; + bellota.transform().location().x = sprite.home.x + 8.0f * std::sin(wave); + bellota.transform().location().y = sprite.home.y + 8.0f * std::cos(wave * 0.7f); + bellota.transform().scale() = glm::vec2(1.0f + 0.4f * std::sin(wave * 1.3f)); + bellota.transform().angle() += 0.05f * dt; + } + }; + + canvas.beginThreadedSession(controller); + + // Simulation thread: commit at ~120 Hz, decoupled from the render cadence. + std::thread simThread([&]() + { + using clock = std::chrono::steady_clock; + auto previous = clock::now(); + constexpr auto targetPeriod = std::chrono::microseconds(8333); // ~120 Hz + while (canvas.isThreadedRunning()) + { + const auto now = clock::now(); + const float dt = std::chrono::duration(now - previous).count(); + previous = now; + + canvas.commit(dt, update); + + std::this_thread::sleep_for(targetPeriod); + } + }); + + // Main thread: render the latest committed snapshot + pump window/input. + while (canvas.isThreadedRunning()) + { + canvas.renderFrame(controller); + } + + simThread.join(); + return 0; +} diff --git a/include/canvas.h b/include/canvas.h index f3d11477..4edf99dd 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -595,6 +595,42 @@ class Canvas void tick(float deltaTime, std::function update); void tick(float deltaTime); + // ----- Threaded driver (two-thread sim/render split) ----- + // + // Opt-in alternative to run()/tick(). The application owns both loops; + // nothofagus spawns no threads. Run `commit()` on a simulation thread and + // `renderFrame()` on the main thread: + // + // canvas.beginThreadedSession(controller); // main thread + // std::thread sim([&]{ + // while (canvas.isThreadedRunning()) + // canvas.commit(dt, update); // sim thread + // }); + // while (canvas.isThreadedRunning()) + // canvas.renderFrame(controller); // main thread + // sim.join(); + // + // The sim thread mutates the scene and commits a snapshot; the main thread + // draws the previous snapshot, so render of frame N overlaps sim of N+1. + // + // Phase-A constraints: create all textures/meshes/bellotas up front (before + // the threads start); at runtime the sim may only mutate existing bellota + // values (transform, tint, opacity, layer). No ImGui on the threaded path, + // and no runtime resource create/destroy or explorers yet. `run()`/`tick()` + // remain the unrestricted single-threaded path. + + /// Main thread: start a threaded session (binds input, marks it running). + void beginThreadedSession(Controller& controller); + + /// Thread-safe: true until the window is closed. Drives both loop conditions. + bool isThreadedRunning() const; + + /// Sim thread: run `update(deltaTime)` and publish a frame snapshot. + void commit(float deltaTime, std::function update); + + /// Main thread: render the latest published snapshot and pump window/input. + void renderFrame(Controller& controller); + /// Enable or disable automatic removal of unreferenced textures each frame. /// Enabled by default. Disable during bulk asset loading to prevent premature removal. void setAutoRemoveUnusedTextures(bool enabled); diff --git a/source/canvas.cpp b/source/canvas.cpp index 929d9d28..ad9016f2 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -379,6 +379,30 @@ void Canvas::close() mImplPtr->frameRunner.close(); } +// --------------------------------------------------------------------------- +// Threaded driver (two-thread sim/render split) — forward to FrameRunner +// --------------------------------------------------------------------------- + +void Canvas::beginThreadedSession(Controller& controller) +{ + mImplPtr->frameRunner.beginThreadedSession(controller); +} + +bool Canvas::isThreadedRunning() const +{ + return mImplPtr->frameRunner.threadedRunning(); +} + +void Canvas::commit(float deltaTime, std::function update) +{ + mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update)); +} + +void Canvas::renderFrame(Controller& controller) +{ + mImplPtr->frameRunner.renderFrameThreaded(mImplPtr->assets, mImplPtr->imguiRtt, controller); +} + DirectTexture Canvas::takeScreenshot() const { return mImplPtr->frameRunner.takeScreenshot(); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index d1d4c768..d061fcd6 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -548,6 +548,97 @@ void FrameRunner::close() mWindow->requestClose(); } +// --------------------------------------------------------------------------- +// Threaded driver (M2 Phase A) +// --------------------------------------------------------------------------- + +void FrameRunner::beginThreadedSession(Controller& controller) +{ + // Bind input callbacks + reset the close flag (same as run()'s session start). + mWindow->beginSession(controller); + if (!mSessionStarted) + { + mSnapshot.draws.reserve(64); + mSessionStarted = true; + } + mLastRenderTimeValid = false; + mThreadedRunning.store(true, std::memory_order_release); +} + +void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update) +{ + // Sim thread: pure CPU. No GPU, no ImGui, no input, no explorers, no resource + // GC (Phase A keeps resources static at runtime; the render side owns the GPU + // containers exclusively). Only existing bellota *values* may be mutated by + // `update`. + ZoneScopedN("commitFrame"); + + { + ZoneScopedN("UserUpdate"); + update(deltaTimeMS); + } + + RenderSnapshot& snapshot = mTripleBuffer.writeSlot(); + snapshot.commitSeq = ++mCommitSeq; + snapshot.clearColor = mClearColor; + + { + ZoneScopedN("DepthSort"); + buildMainDraws(assets.bellotas(), snapshot.draws); + } + { + ZoneScopedN("RttGather"); + buildRttPasses(assets, snapshot.rttPasses); + } + + mTripleBuffer.publish(); +} + +void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller) +{ + // Main thread: owns the GL/window context and does all GPU work. + ZoneScopedN("renderFrameThreaded"); + + // Dispatch input events queued by the previous frame's poll (main thread, so + // any action callback — e.g. Escape → close() — runs here safely). + { + ZoneScopedN("Input"); + controller.processInputs(); + } + + imguiRtt.drainPendingFontOps(); + + // Pick up the freshest published snapshot (keeps the previous one if none new). + mTripleBuffer.acquire(); + const RenderSnapshot& snapshot = mTripleBuffer.readSlot(); + + auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); + mFramebufferWidth = framebufferWidth; + mFramebufferHeight = framebufferHeight; + mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, mScreenSize.width, mScreenSize.height); + + mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); + + // Empty main ImGui frame — the threaded path runs no user ImGui in Phase A, + // but a NewFrame/Render pair yields valid (empty) draw data so the shared + // present path works. ImGui stays entirely on this (render) thread. + mBackend.imguiNewFrame(); + mWindow->newImGuiFrame(); + applyMainContextScale(); + ImGui::NewFrame(); + + const float now = mWindow->getTime(); + const float deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; + mLastRenderTime = now; + mLastRenderTimeValid = true; + + renderSnapshot(assets, imguiRtt, snapshot, deltaTimeMS, controller); + + mThreadedRunning.store(mWindow->isRunning(), std::memory_order_release); + + FrameMark; +} + ScreenSize getPrimaryMonitorSize() { return SelectedWindowBackend::getPrimaryMonitorSize(); diff --git a/source/frame_runner.h b/source/frame_runner.h index 81f9bdc7..aff459b8 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -7,6 +7,7 @@ #include "explorer_manager.h" #include "bellota_container.h" #include "render_snapshot.h" +#include "snapshot_buffers.h" #include "aa_box.h" #include "backends/render_backend_select.h" #include @@ -15,6 +16,8 @@ #include #include #include +#include +#include struct ImGuiStyle; // global-scope (Dear ImGui); held by unique_ptr to keep imgui.h out of this header. @@ -149,6 +152,27 @@ class FrameRunner /// Captures the last rendered frame visible to the user as a DirectTexture (RGBA). DirectTexture takeScreenshot() const; + // ----- Threaded driver (M2 Phase A) ----- + // The app runs two threads: `commitFrame` on a sim thread and + // `renderFrameThreaded` on the main thread. nothofagus spawns nothing. + // Constraints in Phase A: resources are created up front; at runtime the sim + // only mutates existing bellota values. No ImGui, explorers, or runtime + // resource create/destroy on the threaded path yet (Phase B / M3). + + /// Main thread: bind input, reset the close flag, mark the threaded session live. + void beginThreadedSession(Controller& controller); + + /// Thread-safe: true until the window is closed. Read by the sim loop. + bool threadedRunning() const { return mThreadedRunning.load(std::memory_order_acquire); } + + /// Sim thread: run the user update, project the scene into a free snapshot + /// slot, and publish it. No GPU, ImGui, or input. + void commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update); + + /// Main thread: acquire the latest published snapshot and render it (GPU + /// upload + draw + present), poll window/input, and refresh the running flag. + void renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller); + private: void ensureSessionStarted(Controller& controller); void runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, @@ -220,6 +244,12 @@ class FrameRunner int mFramebufferWidth{0}; ///< Framebuffer size captured in buildSnapshot, reused by renderSnapshot. int mFramebufferHeight{0}; + // ----- Threaded driver state (M2 Phase A) ----- + SnapshotTripleBuffer mTripleBuffer; ///< sim→render snapshot hand-off (lock-free). + std::atomic mThreadedRunning{false}; ///< true while the threaded session is live. + float mLastRenderTime{0.0f}; ///< previous renderFrameThreaded timestamp (for the stats dt). + bool mLastRenderTimeValid{false}; + struct Window; ///< Forward declaration for window management. std::unique_ptr mWindow; ///< Pointer to the window object. diff --git a/source/snapshot_buffers.h b/source/snapshot_buffers.h new file mode 100644 index 00000000..de2d132e --- /dev/null +++ b/source/snapshot_buffers.h @@ -0,0 +1,72 @@ +#pragma once + +#include "render_snapshot.h" +#include +#include +#include + +namespace Nothofagus +{ + +/** + * @class SnapshotTripleBuffer + * @brief Lock-free single-producer / single-consumer triple buffer for + * `RenderSnapshot` (the sim→render hand-off). + * + * The sim thread fills `writeSlot()` then calls `publish()`; the render thread + * calls `acquire()` then reads `readSlot()`. Neither thread blocks: the producer + * always owns a private write slot and the consumer always owns a private read + * slot, while a third "middle" slot is swapped through a single atomic. If the + * producer outruns the consumer, intermediate snapshots are overwritten — + * latest-wins / mailbox semantics, which is what a renderer wants (never render a + * backlog of stale scene states). + * + * The three slots keep their `draws`/`rttPasses` allocations across publishes, so + * steady-state commits do not allocate. + * + * Exactly one thread may call `writeSlot()`/`publish()` and exactly one (other) + * thread may call `acquire()`/`readSlot()`. + */ +class SnapshotTripleBuffer +{ +public: + SnapshotTripleBuffer() = default; + + /// Producer-private slot to fill before publishing. + RenderSnapshot& writeSlot() { return mBuffers[mWriteIndex]; } + + /// Publish the filled write slot and pick up a fresh private write slot. + void publish() + { + const std::uint32_t published = mWriteIndex | kFreshBit; + const std::uint32_t previous = mShared.exchange(published, std::memory_order_acq_rel); + mWriteIndex = previous & kIndexMask; + } + + /// If a snapshot has been published since the last acquire, swap it into the + /// read slot and return true. Otherwise leave the read slot unchanged and + /// return false (the consumer may re-render the previous snapshot). + bool acquire() + { + if ((mShared.load(std::memory_order_acquire) & kFreshBit) == 0) + return false; + const std::uint32_t previous = mShared.exchange(mReadIndex, std::memory_order_acq_rel); + mReadIndex = previous & kIndexMask; + return true; + } + + /// Consumer-private slot. Valid after the first `acquire()`; before any + /// `publish()` it is a default-constructed (empty) snapshot. + const RenderSnapshot& readSlot() const { return mBuffers[mReadIndex]; } + +private: + static constexpr std::uint32_t kIndexMask = 0x3u; + static constexpr std::uint32_t kFreshBit = 0x4u; + + std::array mBuffers{}; + std::uint32_t mWriteIndex{0}; ///< producer-owned slot (starts 0) + std::uint32_t mReadIndex{2}; ///< consumer-owned slot (starts 2) + std::atomic mShared{1}; ///< middle slot index (starts 1), + fresh bit when newly published +}; + +} From 0301909caae7b1bb3842e76e961a913b30a29b6e Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:04:24 -0700 Subject: [PATCH 02/55] M2 Phase B --- examples/hello_threaded.cpp | 103 ++++++++++++++++++++++++---------- include/canvas.h | 11 ++++ source/canvas.cpp | 10 ++++ source/frame_runner.cpp | 109 +++++++++++++++++++++++++++++------- source/frame_runner.h | 23 ++++++++ 5 files changed, 206 insertions(+), 50 deletions(-) diff --git a/examples/hello_threaded.cpp b/examples/hello_threaded.cpp index 919401ca..ec792f74 100644 --- a/examples/hello_threaded.cpp +++ b/examples/hello_threaded.cpp @@ -1,26 +1,47 @@ -// hello_threaded — two-thread sim/render split (M2 Phase A). +// hello_threaded — two-thread sim/render split (M2). // -// The simulation runs on its own std::thread, animating a field of sprites and -// committing a frame snapshot each tick. The main thread renders the previous -// snapshot, so render of frame N overlaps simulation of frame N+1. +// The simulation runs on its own std::thread; the main thread renders the +// previous frame's snapshot, so render of frame N overlaps simulation of N+1. +// nothofagus spawns no threads — this file owns both loops. // -// nothofagus spawns no threads: this file owns both loops. Per the Phase-A -// contract, every texture/bellota is created up front and the simulation only -// mutates existing bellota *values* (transform) at runtime — no resource -// create/destroy and no ImGui on the threaded path. +// Phase A established the snapshot hand-off with a static scene (the sim only +// mutated existing bellota values). Phase B adds runtime structural churn: the +// simulation continuously *spawns and despawns* sprites via +// Canvas::spawnBellota / despawnBellota while the render thread is a frame +// behind. Those calls serialize structural changes against the renderer, and +// the GPU resources a despawn orphans (each sprite's auto-quad mesh) are freed +// by the render thread only once no in-flight frame still references them — so +// there is no use-after-free despite the one-frame lag. #include #include #include +#include #include #include #include +namespace +{ +// Tiny deterministic-ish LCG so the demo does not pull in and behaves +// the same regardless of platform RNG. +struct Lcg +{ + std::uint32_t state; + float next01() + { + state = state * 1664525u + 1013904223u; + return static_cast((state >> 8) & 0xFFFFFFu) / static_cast(0x1000000u); + } + float range(float lo, float hi) { return lo + (hi - lo) * next01(); } +}; +} + int main() { - spdlog::info("hello_threaded: two-thread sim/render split"); + spdlog::info("hello_threaded: two-thread sim/render with runtime spawn/despawn churn"); - Nothofagus::ScreenSize screenSize{200, 150}; + const Nothofagus::ScreenSize screenSize{200, 150}; Nothofagus::Canvas canvas(screenSize, "Hello Threaded", {0.05f, 0.05f, 0.10f}, 5); Nothofagus::ColorPallete pallete{ @@ -43,52 +64,72 @@ int main() 0,1,2,2,2,2,1,0, 0,0,1,1,1,1,0,0, }); + // Texture is created up front (uploaded once on the render thread); only + // bellotas (and their auto-quad meshes) churn at runtime. const Nothofagus::TextureId textureId = canvas.addTexture(texture); - // --- All resources created up front (Phase A): a grid of sprites. --- struct Sprite { Nothofagus::BellotaId id; glm::vec2 home; float phase; + float age; // ms + float lifespan; // ms }; - std::vector sprites; + std::vector sprites; // sim-thread-only ownership + Lcg rng{0x1234567u}; - constexpr int columns = 9; - constexpr int rows = 7; - for (int row = 0; row < rows; ++row) - { - for (int col = 0; col < columns; ++col) - { - const float x = 20.0f + col * 20.0f; - const float y = 20.0f + row * 18.0f; - const Nothofagus::BellotaId id = canvas.addBellota({{{x, y}, 1.0f}, textureId}); - sprites.push_back({id, glm::vec2(x, y), 0.35f * (col + row)}); - } - } + constexpr std::size_t targetCount = 60; - Nothofagus::Controller controller; - controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + auto spawnOne = [&](float now) { - canvas.close(); - }); + const float x = rng.range(15.0f, screenSize.width - 15.0f); + const float y = rng.range(15.0f, screenSize.height - 15.0f); + const Nothofagus::BellotaId id = canvas.spawnBellota({{{x, y}, 1.0f}, textureId}); + sprites.push_back({id, glm::vec2(x, y), rng.range(0.0f, 6.28f), 0.0f, rng.range(1500.0f, 4000.0f)}); + (void)now; + }; - // --- Simulation: animate sprite transforms (existing bellota values only). --- float simTime = 0.0f; auto update = [&](float dt) { simTime += dt; - for (const Sprite& sprite : sprites) + + // Age + animate live sprites; despawn the expired ones (swap-erase). + for (std::size_t i = 0; i < sprites.size();) { + Sprite& sprite = sprites[i]; + sprite.age += dt; + if (sprite.age >= sprite.lifespan) + { + canvas.despawnBellota(sprite.id); + sprite = sprites.back(); + sprites.pop_back(); + continue; + } Nothofagus::Bellota& bellota = canvas.bellota(sprite.id); const float wave = 0.0025f * simTime + sprite.phase; bellota.transform().location().x = sprite.home.x + 8.0f * std::sin(wave); bellota.transform().location().y = sprite.home.y + 8.0f * std::cos(wave * 0.7f); - bellota.transform().scale() = glm::vec2(1.0f + 0.4f * std::sin(wave * 1.3f)); + const float life = sprite.age / sprite.lifespan; // 0..1 + const float fade = std::sin(life * 3.14159f); // grow then shrink + bellota.transform().scale() = glm::vec2(0.4f + 1.2f * fade); bellota.transform().angle() += 0.05f * dt; + ++i; } + + // Refill toward the target population (a few per frame so churn is steady). + int budget = 3; + while (sprites.size() < targetCount && budget-- > 0) + spawnOne(simTime); }; + Nothofagus::Controller controller; + controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + canvas.close(); + }); + canvas.beginThreadedSession(controller); // Simulation thread: commit at ~120 Hz, decoupled from the render cadence. diff --git a/include/canvas.h b/include/canvas.h index 4edf99dd..ce6c331d 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -631,6 +631,17 @@ class Canvas /// Main thread: render the latest published snapshot and pump window/input. void renderFrame(Controller& controller); + /// Sim thread: add a bellota at runtime on the threaded path. Unlike + /// `addBellota`, this is safe to call from `commit()`'s update while the main + /// thread renders — it serializes structural changes against the renderer. + /// Returns the new id (usable immediately for value mutation via `bellota()`). + BellotaId spawnBellota(const Bellota& bellota); + + /// Sim thread: remove a bellota at runtime on the threaded path. The GPU + /// resources it orphans are freed by the render thread once no in-flight + /// frame still references them (deferred free) — no use-after-free. + void despawnBellota(BellotaId bellotaId); + /// Enable or disable automatic removal of unreferenced textures each frame. /// Enabled by default. Disable during bulk asset loading to prevent premature removal. void setAutoRemoveUnusedTextures(bool enabled); diff --git a/source/canvas.cpp b/source/canvas.cpp index ad9016f2..13f0a81e 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -403,6 +403,16 @@ void Canvas::renderFrame(Controller& controller) mImplPtr->frameRunner.renderFrameThreaded(mImplPtr->assets, mImplPtr->imguiRtt, controller); } +BellotaId Canvas::spawnBellota(const Bellota& bellota) +{ + return mImplPtr->frameRunner.threadedSpawnBellota(mImplPtr->assets, bellota); +} + +void Canvas::despawnBellota(BellotaId bellotaId) +{ + mImplPtr->frameRunner.threadedDespawnBellota(mImplPtr->assets, bellotaId); +} + DirectTexture Canvas::takeScreenshot() const { return mImplPtr->frameRunner.takeScreenshot(); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index d061fcd6..91c18546 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -417,15 +417,19 @@ void FrameRunner::drainPendingFrees(AssetRegistry& assets, std::uint64_t lastRen }); } -void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRtt, - const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller) -{ - ZoneScopedN("renderSnapshot"); - - // Deferred free: release resources retired no later than the last fully - // rendered snapshot. At depth-0 (single thread) the snapshot we are about to - // render IS the latest commit, so frees happen this frame, before upload — - // identical timing to the old clearUnused* path. +void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& imguiRtt, + const RenderSnapshot& snapshot, float deltaTimeMS) +{ + // Everything here touches the asset containers (`mTextures`/`mMeshes`, + // render targets) and the deferred-free queues. On the threaded path the + // caller holds `mThreadedAssetMutex` around this whole method so it cannot + // race the sim thread's structural spawns/despawns; on the single-threaded + // path there is no contention. The vsync swap is deliberately NOT here — it + // is done by the caller after releasing the lock. + + // Deferred free: release resources retired no later than the snapshot we are + // about to render. Single-threaded, that snapshot is the latest commit so + // frees happen this frame; threaded, the gate genuinely defers (render lags). mLastRenderedSeq = snapshot.commitSeq; drainPendingFrees(assets, mLastRenderedSeq); @@ -491,6 +495,14 @@ void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRt ZoneScopedN("MainDraw"); drawItems(snapshot.draws, assets.textures(), assets.meshes(), worldTransformMat, mBackend); } +} + +void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRtt, + const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller) +{ + ZoneScopedN("renderSnapshot"); + + renderSnapshotContents(assets, imguiRtt, snapshot, deltaTimeMS); if (mStats) { @@ -549,7 +561,8 @@ void FrameRunner::close() } // --------------------------------------------------------------------------- -// Threaded driver (M2 Phase A) +// Threaded driver (M2 Phase A: snapshot hand-off; Phase B: runtime resource +// mutation guarded by mThreadedAssetMutex) // --------------------------------------------------------------------------- void FrameRunner::beginThreadedSession(Controller& controller) @@ -567,19 +580,25 @@ void FrameRunner::beginThreadedSession(Controller& controller) void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update) { - // Sim thread: pure CPU. No GPU, no ImGui, no input, no explorers, no resource - // GC (Phase A keeps resources static at runtime; the render side owns the GPU - // containers exclusively). Only existing bellota *values* may be mutated by - // `update`. + // Sim thread: CPU only. No GPU, no ImGui, no input. The user `update` mutates + // existing bellota values and may also spawn/despawn bellotas via + // Canvas::spawnBellota/despawnBellota (Phase B) — those take the asset mutex + // internally; everything here (bellota value writes, snapshot projection) + // touches only sim-owned state and stays lock-free. ZoneScopedN("commitFrame"); + // Stamp the commit seq BEFORE `update` so spawn/despawn can tag retired + // resources with the commit at which they leave the scene (the first + // snapshot that no longer references them — this one). + const std::uint64_t commitSeq = ++mCommitSeq; + { ZoneScopedN("UserUpdate"); update(deltaTimeMS); } RenderSnapshot& snapshot = mTripleBuffer.writeSlot(); - snapshot.commitSeq = ++mCommitSeq; + snapshot.commitSeq = commitSeq; snapshot.clearColor = mClearColor; { @@ -619,9 +638,9 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); - // Empty main ImGui frame — the threaded path runs no user ImGui in Phase A, - // but a NewFrame/Render pair yields valid (empty) draw data so the shared - // present path works. ImGui stays entirely on this (render) thread. + // Empty main ImGui frame — the threaded path runs no user ImGui yet, but a + // NewFrame/Render pair yields valid (empty) draw data so the shared present + // path works. ImGui stays entirely on this (render) thread. mBackend.imguiNewFrame(); mWindow->newImGuiFrame(); applyMainContextScale(); @@ -632,13 +651,65 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im mLastRenderTime = now; mLastRenderTimeValid = true; - renderSnapshot(assets, imguiRtt, snapshot, deltaTimeMS, controller); + // Container-touching section (deferred frees, GPU upload, id→handle resolve + + // draw submission). Guarded against the sim thread's spawn/despawn. The lock + // is released BEFORE the vsync swap so a slow present never stalls the sim. + { + ZoneScopedN("AssetLockedRender"); + std::lock_guard lock(mThreadedAssetMutex); + renderSnapshotContents(assets, imguiRtt, snapshot, deltaTimeMS); + } + + if (mStats) + { + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Always); + ImGui::Begin("stats", NULL, ImGuiWindowFlags_NoTitleBar); + ImGui::Text("%.2f fps", deltaTimeMS > 0.0f ? 1000.0f / deltaTimeMS : 0.0f); + ImGui::Text("%.2f ms", deltaTimeMS); + ImGui::End(); + } + + { + ZoneScopedN("ImGuiRender"); + ImGui::Render(); + mBackend.endFrame(ImGui::GetDrawData(), mFramebufferWidth, mFramebufferHeight); + } + + { + ZoneScopedN("SwapBuffers"); + mWindow->endFrame(controller, mScreenSize); + } mThreadedRunning.store(mWindow->isRunning(), std::memory_order_release); FrameMark; } +Nothofagus::BellotaId FrameRunner::threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota) +{ + // Sim thread: structural mutation of the (render-shared) asset containers — + // adds the bellota plus its auto-quad mesh and registers usage entries. The + // mutex serializes this against the render thread's container access. + std::lock_guard lock(mThreadedAssetMutex); + return assets.addBellota(bellota); +} + +void FrameRunner::threadedDespawnBellota(AssetRegistry& assets, BellotaId bellotaId) +{ + // Sim thread: remove the bellota, then detect any texture/mesh it orphaned + // (typically its auto-quad mesh) and queue them for deferred GPU free. The + // actual free happens on the render thread once no in-flight snapshot still + // references them (retireSeq <= lastRenderedSeq). Tag with the current commit + // seq: the snapshot built this commit no longer references the removed bellota. + std::lock_guard lock(mThreadedAssetMutex); + assets.removeBellota(bellotaId); + for (TextureId textureId : assets.collectUnusedTextures()) + mPendingTextureFrees.push_back({textureId, mCommitSeq}); + for (MeshId meshId : assets.collectUnusedMeshes()) + mPendingMeshFrees.push_back({meshId, mCommitSeq}); +} + ScreenSize getPrimaryMonitorSize() { return SelectedWindowBackend::getPrimaryMonitorSize(); diff --git a/source/frame_runner.h b/source/frame_runner.h index aff459b8..c247a378 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -18,6 +18,7 @@ #include #include #include +#include struct ImGuiStyle; // global-scope (Dear ImGui); held by unique_ptr to keep imgui.h out of this header. @@ -173,6 +174,14 @@ class FrameRunner /// upload + draw + present), poll window/input, and refresh the running flag. void renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller); + /// Sim thread (Phase B): add a bellota at runtime, guarded against the render + /// thread's container access. Returns the new id. + BellotaId threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota); + + /// Sim thread (Phase B): remove a bellota at runtime and queue any resources + /// it orphaned for deferred GPU free on the render thread. + void threadedDespawnBellota(AssetRegistry& assets, BellotaId bellotaId); + private: void ensureSessionStarted(Controller& controller); void runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, @@ -197,6 +206,13 @@ class FrameRunner void renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRtt, const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller); + /// The container-touching core of a rendered frame: deferred frees, GPU + /// upload, RTT passes, and the main draw. Excludes the vsync swap and the + /// ImGui render. On the threaded path the caller holds `mThreadedAssetMutex` + /// around this; single-threaded there is no contention. + void renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& imguiRtt, + const RenderSnapshot& snapshot, float deltaTimeMS); + /// Gather the queued RTT passes (`mPendingRttPasses`) into POD draw lists on /// `out` and clear the queue. CPU-only — GPU existence of each render target /// is checked later, on the render side. @@ -250,6 +266,13 @@ class FrameRunner float mLastRenderTime{0.0f}; ///< previous renderFrameThreaded timestamp (for the stats dt). bool mLastRenderTimeValid{false}; + /// Phase B: serializes the sim thread's runtime structural mutations + /// (spawn/despawn → mTextures/mMeshes + usage monitors + pending-free queues) + /// against the render thread's container access (upload/resolve/free). Held + /// only for the fast container section — never across the vsync swap. Bellota + /// value mutation and the snapshot projection stay lock-free. + std::mutex mThreadedAssetMutex; + struct Window; ///< Forward declaration for window management. std::unique_ptr mWindow; ///< Pointer to the window object. From 3b9f702f014bbcdb973a797a135d3cde35b21685 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:29:04 -0700 Subject: [PATCH 03/55] enter imgui to the multi threaded world running within the sim thread --- CMakeLists.txt | 2 + examples/CMakeLists.txt | 6 ++ examples/hello_threaded_imgui.cpp | 173 ++++++++++++++++++++++++++++++ include/canvas.h | 10 +- source/canvas.cpp | 7 +- source/frame_runner.cpp | 169 +++++++++++++++++++++++------ source/frame_runner.h | 45 +++++++- source/imgui_draw_clone.cpp | 58 ++++++++++ source/imgui_draw_clone.h | 55 ++++++++++ source/render_snapshot.cpp | 12 +++ source/render_snapshot.h | 11 ++ third_party/imgui/imconfig.h | 14 +++ 12 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 examples/hello_threaded_imgui.cpp create mode 100644 source/imgui_draw_clone.cpp create mode 100644 source/imgui_draw_clone.h create mode 100644 source/render_snapshot.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b4c383b9..b08a76fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,8 @@ file(GLOB_RECURSE NOTHOFAGUS_PUBLIC_HEADERS "include/*.h") add_library(nothofagus "source/canvas.cpp" "source/frame_runner.cpp" + "source/render_snapshot.cpp" + "source/imgui_draw_clone.cpp" "source/asset_registry.cpp" "source/texture_container.cpp" "source/mesh_container.cpp" diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 10f64c4c..e5fc1a59 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -149,3 +149,9 @@ add_executable(hello_threaded set_property(TARGET hello_threaded PROPERTY CXX_STANDARD 20) find_package(Threads REQUIRED) target_link_libraries(hello_threaded PRIVATE nothofagus Threads::Threads) + +add_executable(hello_threaded_imgui + "hello_threaded_imgui.cpp" +) +set_property(TARGET hello_threaded_imgui PROPERTY CXX_STANDARD 20) +target_link_libraries(hello_threaded_imgui PRIVATE nothofagus Threads::Threads) diff --git a/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp new file mode 100644 index 00000000..91676118 --- /dev/null +++ b/examples/hello_threaded_imgui.cpp @@ -0,0 +1,173 @@ +// hello_threaded_imgui — interactive ImGui on the threaded sim/render split (M3). +// +// Same two-thread model as hello_threaded (sim thread commits; main thread +// renders the previous frame), but now the simulation thread also runs an +// *interactive* ImGui panel inside its update. The widgets execute on the sim +// thread (a dedicated ImGui context), their draw data is deep-cloned into the +// snapshot, and the main thread renders the clone. Mouse input is marshalled +// render→sim, so the sliders/checkbox respond to the cursor while the renderer +// runs a frame behind. No ImGui code runs on two threads at once. + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +struct Lcg +{ + std::uint32_t state; + float next01() + { + state = state * 1664525u + 1013904223u; + return static_cast((state >> 8) & 0xFFFFFFu) / static_cast(0x1000000u); + } + float range(float lo, float hi) { return lo + (hi - lo) * next01(); } +}; +} + +int main() +{ + spdlog::info("hello_threaded_imgui: interactive ImGui on the sim thread"); + + const Nothofagus::ScreenSize screenSize{220, 165}; + Nothofagus::Canvas canvas(screenSize, "Hello Threaded ImGui", {0.05f, 0.06f, 0.11f}, 5); + + Nothofagus::ColorPallete pallete{ + {0.0, 0.0, 0.0, 0.0}, + {0.9, 0.3, 0.2, 1.0}, + {1.0, 0.8, 0.2, 1.0}, + {0.3, 0.7, 1.0, 1.0}, + }; + Nothofagus::IndirectTexture texture({8, 8}, {0.0, 0.0, 0.0, 0.0}); + texture.setPallete(pallete) + .setPixels( + { + 0,0,1,1,1,1,0,0, + 0,1,2,2,2,2,1,0, + 1,2,2,3,3,2,2,1, + 1,2,3,3,3,3,2,1, + 1,2,3,3,3,3,2,1, + 1,2,2,3,3,2,2,1, + 0,1,2,2,2,2,1,0, + 0,0,1,1,1,1,0,0, + }); + const Nothofagus::TextureId textureId = canvas.addTexture(texture); + + struct Sprite + { + Nothofagus::BellotaId id; + glm::vec2 home; + float phase; + float age; + float lifespan; + }; + std::vector sprites; + Lcg rng{0x2468ace0u}; + + // Interactive controls — owned + read/written only by the sim thread. + int targetCount = 40; + float scaleScale = 1.0f; + float waveSpeed = 1.0f; + bool spawning = true; + float lastFps = 0.0f; + + auto spawnOne = [&]() + { + const float x = rng.range(15.0f, screenSize.width - 15.0f); + const float y = rng.range(15.0f, screenSize.height - 15.0f); + const Nothofagus::BellotaId id = canvas.spawnBellota({{{x, y}, 1.0f}, textureId}); + sprites.push_back({id, glm::vec2(x, y), rng.range(0.0f, 6.28f), 0.0f, rng.range(1500.0f, 4000.0f)}); + }; + + float simTime = 0.0f; + + // Game logic — runs lock-free on the sim thread (no ImGui here). + auto update = [&](float dt) + { + simTime += dt; + if (dt > 0.0f) lastFps = 0.9f * lastFps + 0.1f * (1000.0f / dt); + + // --- Simulation: animate + age/despawn --- + for (std::size_t i = 0; i < sprites.size();) + { + Sprite& sprite = sprites[i]; + sprite.age += dt; + const bool overTarget = static_cast(i) >= targetCount; + if (sprite.age >= sprite.lifespan || overTarget) + { + canvas.despawnBellota(sprite.id); + sprite = sprites.back(); + sprites.pop_back(); + continue; + } + Nothofagus::Bellota& bellota = canvas.bellota(sprite.id); + const float wave = 0.0025f * simTime * waveSpeed + sprite.phase; + bellota.transform().location().x = sprite.home.x + 8.0f * std::sin(wave); + bellota.transform().location().y = sprite.home.y + 8.0f * std::cos(wave * 0.7f); + const float life = sprite.age / sprite.lifespan; + const float fade = std::sin(life * 3.14159f); + bellota.transform().scale() = glm::vec2((0.4f + 1.2f * fade) * scaleScale); + bellota.transform().angle() += 0.05f * dt; + ++i; + } + + if (spawning) + { + int budget = 3; + while (static_cast(sprites.size()) < targetCount && budget-- > 0) + spawnOne(); + } + }; + + // Interactive ImGui panel — runs on the sim-UI context (sim thread), mouse + // input marshalled from the render thread. Drives the controls the game logic + // above reads. + auto ui = [&](float /*dt*/) + { + ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(250, 0), ImGuiCond_Once); + ImGui::Begin("Simulation controls"); + ImGui::Text("sim %.0f fps sprites %d", lastFps, (int)sprites.size()); + ImGui::SliderInt("target count", &targetCount, 0, 120); + ImGui::SliderFloat("scale", &scaleScale, 0.3f, 3.0f); + ImGui::SliderFloat("wave speed", &waveSpeed, 0.0f, 4.0f); + ImGui::Checkbox("spawning", &spawning); + if (ImGui::Button("clear all")) + targetCount = 0; + ImGui::End(); + }; + + Nothofagus::Controller controller; + controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + canvas.close(); + }); + + canvas.beginThreadedSession(controller); + + std::thread simThread([&]() + { + using clock = std::chrono::steady_clock; + auto previous = clock::now(); + constexpr auto targetPeriod = std::chrono::microseconds(8333); // ~120 Hz + while (canvas.isThreadedRunning()) + { + const auto now = clock::now(); + const float dt = std::chrono::duration(now - previous).count(); + previous = now; + canvas.commit(dt, update, ui); + std::this_thread::sleep_for(targetPeriod); + } + }); + + while (canvas.isThreadedRunning()) + canvas.renderFrame(controller); + + simThread.join(); + return 0; +} diff --git a/include/canvas.h b/include/canvas.h index ce6c331d..eeb4aae4 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -625,9 +625,17 @@ class Canvas /// Thread-safe: true until the window is closed. Drives both loop conditions. bool isThreadedRunning() const; - /// Sim thread: run `update(deltaTime)` and publish a frame snapshot. + /// Sim thread: run `update(deltaTime)` (game logic) and publish a frame + /// snapshot. No ImGui. void commit(float deltaTime, std::function update); + /// Sim thread: run `update(deltaTime)` (game logic, lock-free) then + /// `uiCallback(deltaTime)` as an interactive ImGui frame whose draw data is + /// cloned into the snapshot and rendered by the main thread. ImGui widgets in + /// `uiCallback` run on the sim thread and respond to the mouse (input is + /// marshalled render→sim). Keep ImGui calls in `uiCallback`, not `update`. + void commit(float deltaTime, std::function update, std::function uiCallback); + /// Main thread: render the latest published snapshot and pump window/input. void renderFrame(Controller& controller); diff --git a/source/canvas.cpp b/source/canvas.cpp index 13f0a81e..934ab5e5 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -395,7 +395,12 @@ bool Canvas::isThreadedRunning() const void Canvas::commit(float deltaTime, std::function update) { - mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update)); + mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update), {}); +} + +void Canvas::commit(float deltaTime, std::function update, std::function uiCallback) +{ + mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update), std::move(uiCallback)); } void Canvas::renderFrame(Controller& controller) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 91c18546..3b2070bd 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "imgui_draw_clone.h" #include "imgui_overlay.h" #include "backends/window_backend.h" #include @@ -83,6 +84,17 @@ FrameRunner::~FrameRunner() // working. Canvas's dtor is responsible for draining `mImguiRtt` and // `mAssets` GPU resources BEFORE destroying FrameRunner — by the time this // body runs they're already torn down, leaving us to shut the backend. + // Destroy the sim-UI context (M3) first — it shares (does not own) the main + // context's font atlas, so it must go before the main context is destroyed. + // It has no platform/renderer backend attached, so no backend teardown is + // needed. The caller has already joined the sim thread, so it is not current + // on any thread. + if (mSimUiContext != nullptr) + { + ImGui::DestroyContext(mSimUiContext); + mSimUiContext = nullptr; + } + mBackend.shutdown(); // detaches the ImGui renderer backend (ImGui_Impl*_Shutdown). // Tear the window backend down now (instead of waiting for member destruction) @@ -575,23 +587,58 @@ void FrameRunner::beginThreadedSession(Controller& controller) mSessionStarted = true; } mLastRenderTimeValid = false; + + // M3: create the sim-thread UI context, sharing the main font atlas, so the + // user's ImGui widgets can run on the sim thread. It has no platform/renderer + // backend (the sim issues no GL); textures are flagged backend-managed and are + // actually uploaded on the render thread when the cloned draw data is rendered. + if (mSimUiContext == nullptr) + { + ImGuiContext* mainContext = ImGui::GetCurrentContext(); + ImFontAtlas* sharedAtlas = ImGui::GetIO().Fonts; + + mSimUiContext = ImGui::CreateContext(sharedAtlas); + // CreateContext restores the previous (main) context on return, so make + // the sim-UI context current explicitly before configuring its IO. + ImGui::SetCurrentContext(mSimUiContext); + ImGuiIO& simIo = ImGui::GetIO(); + simIo.IniFilename = nullptr; + simIo.BackendPlatformName = "nothofagus_sim_ui"; + simIo.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // atlas uploaded render-side + simIo.DisplaySize = ImVec2(static_cast(mScreenSize.width), + static_cast(mScreenSize.height)); + ImGui::SetCurrentContext(mainContext); // restore the render thread's context + + // Prime the font atlas on the render thread (uploads the texture) before + // any sim commit references it, so the shared atlas is ready and the sim's + // NewFrame/layout never races a first-time upload. + auto [fbW, fbH] = mWindow->getFramebufferSize(); + const ViewportRect viewport = computeLetterboxViewport(fbW, fbH, mScreenSize.width, mScreenSize.height); + mBackend.beginFrame(mClearColor, viewport, fbW, fbH); + mBackend.imguiNewFrame(); + mWindow->newImGuiFrame(); + applyMainContextScale(); + ImGui::NewFrame(); + ImGui::Render(); + mBackend.endFrame(ImGui::GetDrawData(), fbW, fbH); + } + mThreadedRunning.store(true, std::memory_order_release); } -void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update) +void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, std::function uiCallback) { - // Sim thread: CPU only. No GPU, no ImGui, no input. The user `update` mutates - // existing bellota values and may also spawn/despawn bellotas via - // Canvas::spawnBellota/despawnBellota (Phase B) — those take the asset mutex - // internally; everything here (bellota value writes, snapshot projection) - // touches only sim-owned state and stays lock-free. + // Sim thread: CPU only, no GL. ZoneScopedN("commitFrame"); // Stamp the commit seq BEFORE `update` so spawn/despawn can tag retired - // resources with the commit at which they leave the scene (the first - // snapshot that no longer references them — this one). + // resources with the commit at which they leave the scene. const std::uint64_t commitSeq = ++mCommitSeq; + // 1) Game logic — lock-free, so it overlaps the render thread's sprite work. + // (spawn/despawn take the asset mutex internally; bellota value writes are + // sim-exclusive.) { ZoneScopedN("UserUpdate"); update(deltaTimeMS); @@ -601,6 +648,46 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::fun snapshot.commitSeq = commitSeq; snapshot.clearColor = mClearColor; + // 2) ImGui frame on the sim-UI context — under the ImGui mutex so it never + // touches the shared font atlas concurrently with the render thread. + { + ZoneScopedN("SimImgui"); + std::lock_guard imguiLock(mImguiMutex); + + ImGui::SetCurrentContext(mSimUiContext); // thread-local current context (sim thread) + ImGuiIO& io = ImGui::GetIO(); + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // must match the atlas owner + + ThreadedImguiInput input; + { + std::lock_guard inputLock(mThreadedImguiInputMutex); + input = mThreadedImguiInput; + mThreadedImguiInput.wheelX = 0.0f; // consume accumulated wheel + mThreadedImguiInput.wheelY = 0.0f; + } + const float displayWidth = input.displayWidth > 0.0f ? input.displayWidth : static_cast(mScreenSize.width); + const float displayHeight = input.displayHeight > 0.0f ? input.displayHeight : static_cast(mScreenSize.height); + io.DisplaySize = ImVec2(displayWidth, displayHeight); + io.DisplayFramebufferScale = ImVec2(input.framebufferScaleX, input.framebufferScaleY); + io.AddMousePosEvent(input.mouseX, input.mouseY); + io.AddMouseButtonEvent(0, input.mouseDown[0]); + io.AddMouseButtonEvent(1, input.mouseDown[1]); + io.AddMouseButtonEvent(2, input.mouseDown[2]); + if (input.wheelX != 0.0f || input.wheelY != 0.0f) + io.AddMouseWheelEvent(input.wheelX, input.wheelY); + io.DeltaTime = std::max(deltaTimeMS * 0.001f, 1e-6f); + + ImGui::NewFrame(); + if (uiCallback) + uiCallback(deltaTimeMS); // user ImGui widgets, on the sim thread + ImGui::Render(); + + if (!snapshot.mainUi) + snapshot.mainUi = std::make_unique(); + snapshot.mainUi->cloneFrom(ImGui::GetDrawData()); + } + + // 3) Project the scene (POD) — lock-free; the write slot is producer-owned. { ZoneScopedN("DepthSort"); buildMainDraws(assets.bellotas(), snapshot.draws); @@ -625,8 +712,6 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im controller.processInputs(); } - imguiRtt.drainPendingFontOps(); - // Pick up the freshest published snapshot (keeps the previous one if none new). mTripleBuffer.acquire(); const RenderSnapshot& snapshot = mTripleBuffer.readSlot(); @@ -638,13 +723,23 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); - // Empty main ImGui frame — the threaded path runs no user ImGui yet, but a - // NewFrame/Render pair yields valid (empty) draw data so the shared present - // path works. ImGui stays entirely on this (render) thread. - mBackend.imguiNewFrame(); - mWindow->newImGuiFrame(); - applyMainContextScale(); - ImGui::NewFrame(); + // Main-context ImGui frame on the render thread (under the ImGui mutex, so it + // never touches the shared font atlas concurrently with the sim-UI context). + // It does NOT draw user UI (that arrives as a clone) — it (a) drains pending + // font ops + lets ImGui_ImplGlfw process window input so we can harvest it for + // the sim, and (b) provides valid empty draw data for the frames before the + // first UI commit. + { + ZoneScopedN("RenderImguiNewFrame"); + std::lock_guard imguiLock(mImguiMutex); + imguiRtt.drainPendingFontOps(); + mBackend.imguiNewFrame(); + mWindow->newImGuiFrame(); + applyMainContextScale(); + ImGui::NewFrame(); + harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame + ImGui::Render(); + } const float now = mWindow->getTime(); const float deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; @@ -656,24 +751,21 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im // is released BEFORE the vsync swap so a slow present never stalls the sim. { ZoneScopedN("AssetLockedRender"); - std::lock_guard lock(mThreadedAssetMutex); + std::lock_guard assetLock(mThreadedAssetMutex); renderSnapshotContents(assets, imguiRtt, snapshot, deltaTimeMS); } - if (mStats) - { - ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Always); - ImGui::Begin("stats", NULL, ImGuiWindowFlags_NoTitleBar); - ImGui::Text("%.2f fps", deltaTimeMS > 0.0f ? 1000.0f / deltaTimeMS : 0.0f); - ImGui::Text("%.2f ms", deltaTimeMS); - ImGui::End(); - } - { ZoneScopedN("ImGuiRender"); - ImGui::Render(); - mBackend.endFrame(ImGui::GetDrawData(), mFramebufferWidth, mFramebufferHeight); + // Render the sim's cloned UI if present; otherwise the main empty frame. + // Under the ImGui mutex: RenderDrawData applies font-atlas texture uploads + // (draw_data->Textures) which touch the shared atlas. + std::lock_guard imguiLock(mImguiMutex); + ImDrawData* uiData = (snapshot.mainUi && snapshot.mainUi->hasData()) + ? snapshot.mainUi->drawData() + : ImGui::GetDrawData(); + uiData->Textures = &ImGui::GetPlatformIO().Textures; + mBackend.endFrame(uiData, mFramebufferWidth, mFramebufferHeight); } { @@ -686,6 +778,23 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im FrameMark; } +void FrameRunner::harvestImguiInput() +{ + const ImGuiIO& io = ImGui::GetIO(); + std::lock_guard lock(mThreadedImguiInputMutex); + mThreadedImguiInput.displayWidth = io.DisplaySize.x; + mThreadedImguiInput.displayHeight = io.DisplaySize.y; + mThreadedImguiInput.framebufferScaleX = io.DisplayFramebufferScale.x; + mThreadedImguiInput.framebufferScaleY = io.DisplayFramebufferScale.y; + mThreadedImguiInput.mouseX = io.MousePos.x; + mThreadedImguiInput.mouseY = io.MousePos.y; + mThreadedImguiInput.mouseDown[0] = io.MouseDown[0]; + mThreadedImguiInput.mouseDown[1] = io.MouseDown[1]; + mThreadedImguiInput.mouseDown[2] = io.MouseDown[2]; + mThreadedImguiInput.wheelX += io.MouseWheelH; // accumulate; sim consumes + resets + mThreadedImguiInput.wheelY += io.MouseWheel; +} + Nothofagus::BellotaId FrameRunner::threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota) { // Sim thread: structural mutation of the (render-shared) asset containers — diff --git a/source/frame_runner.h b/source/frame_runner.h index c247a378..fd995725 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -20,7 +20,8 @@ #include #include -struct ImGuiStyle; // global-scope (Dear ImGui); held by unique_ptr to keep imgui.h out of this header. +struct ImGuiStyle; // global-scope (Dear ImGui); held by unique_ptr to keep imgui.h out of this header. +struct ImGuiContext; // global-scope; the sim-thread UI context (M3) is held as an opaque pointer. namespace Nothofagus { @@ -166,9 +167,12 @@ class FrameRunner /// Thread-safe: true until the window is closed. Read by the sim loop. bool threadedRunning() const { return mThreadedRunning.load(std::memory_order_acquire); } - /// Sim thread: run the user update, project the scene into a free snapshot - /// slot, and publish it. No GPU, ImGui, or input. - void commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update); + /// Sim thread: run the user `update` (game logic, lock-free), then — under the + /// ImGui mutex — run `uiCallback` as an ImGui frame on the sim-UI context and + /// clone its draw data, and project the scene into a free snapshot slot, then + /// publish. `uiCallback` may be empty (no ImGui). No GL. + void commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, std::function uiCallback); /// Main thread: acquire the latest published snapshot and render it (GPU /// upload + draw + present), poll window/input, and refresh the running flag. @@ -183,6 +187,10 @@ class FrameRunner void threadedDespawnBellota(AssetRegistry& assets, BellotaId bellotaId); private: + /// Render thread (M3): snapshot the main context's processed ImGui input + /// (mouse pos/buttons/wheel + display size/scale) into mThreadedImguiInput so + /// the sim thread can feed it to the sim-UI context, making widgets interactive. + void harvestImguiInput(); void ensureSessionStarted(Controller& controller); void runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, float deltaTimeMS, std::function update, Controller& controller); @@ -273,6 +281,35 @@ class FrameRunner /// value mutation and the snapshot projection stay lock-free. std::mutex mThreadedAssetMutex; + // ----- M3: interactive ImGui on the threaded path ----- + /// Dedicated ImGui context driven on the sim thread (NewFrame/widgets/Render), + /// sharing the main font atlas. Null until the threaded session starts. The + /// render thread keeps the original (renderer-bearing) context; thread-local + /// GImGui (see imconfig.h) lets the two be current on the two threads at once. + ImGuiContext* mSimUiContext{nullptr}; + + /// Main-context ImGui input snapshotted on the render thread and consumed by + /// the sim thread, so sim-thread widgets are interactive. Guarded by its mutex. + struct ThreadedImguiInput + { + float displayWidth{0.0f}, displayHeight{0.0f}; + float framebufferScaleX{1.0f}, framebufferScaleY{1.0f}; + float mouseX{0.0f}, mouseY{0.0f}; + bool mouseDown[3]{false, false, false}; + float wheelX{0.0f}, wheelY{0.0f}; // accumulated on render, consumed+reset on sim + }; + ThreadedImguiInput mThreadedImguiInput; + std::mutex mThreadedImguiInputMutex; + + /// Serializes all access to the (shared) ImGui font atlas between the sim-UI + /// context (NewFrame + widgets + Render + clone, on the sim thread) and the + /// render/main context (NewFrame + RenderDrawData + atlas rebuild, on the + /// render thread). ImGui 1.92's dynamic atlas is mutated every NewFrame and + /// during glyph baking, so the two threads' ImGui sections must be mutually + /// exclusive. Held only for the short ImGui sections — the game-sim update and + /// the sprite render (mThreadedAssetMutex) run outside it and still overlap. + std::mutex mImguiMutex; + struct Window; ///< Forward declaration for window management. std::unique_ptr mWindow; ///< Pointer to the window object. diff --git a/source/imgui_draw_clone.cpp b/source/imgui_draw_clone.cpp new file mode 100644 index 00000000..4e880ff5 --- /dev/null +++ b/source/imgui_draw_clone.cpp @@ -0,0 +1,58 @@ +#include "imgui_draw_clone.h" + +namespace Nothofagus +{ + +// Definition of the thread-local current-context pointer declared in imconfig.h +// (`#define GImGui GNothofagusImGuiTLS`). Zero-initialized per thread; each thread +// that uses ImGui sets it via ImGui::CreateContext / SetCurrentContext. +} + +thread_local ImGuiContext* GNothofagusImGuiTLS = nullptr; + +namespace Nothofagus +{ + +ClonedImDrawData::~ClonedImDrawData() +{ + clear(); +} + +void ClonedImDrawData::clear() +{ + for (ImDrawList* list : mOwnedLists) + IM_DELETE(list); + mOwnedLists.clear(); + mDrawData.Clear(); +} + +void ClonedImDrawData::cloneFrom(const ImDrawData* src) +{ + clear(); + if (src == nullptr) + return; + + mDrawData.Valid = src->Valid; + mDrawData.DisplayPos = src->DisplayPos; + mDrawData.DisplaySize = src->DisplaySize; + mDrawData.FramebufferScale = src->FramebufferScale; + mDrawData.OwnerViewport = nullptr; + mDrawData.Textures = nullptr; // set render-side to the render context's list + + // Populate the ImDrawData manually rather than via AddDrawList(): the latter + // asserts the list is a live, mid-build draw list (write pointer at the end), + // whereas CloneOutput() returns an already-sealed copy. This is the idiom + // used by imgui_club's threaded-rendering helper. + mOwnedLists.reserve(static_cast(src->CmdListsCount)); + for (int i = 0; i < src->CmdListsCount; ++i) + { + ImDrawList* cloned = src->CmdLists[i]->CloneOutput(); + mOwnedLists.push_back(cloned); + mDrawData.CmdLists.push_back(cloned); + mDrawData.TotalVtxCount += cloned->VtxBuffer.Size; + mDrawData.TotalIdxCount += cloned->IdxBuffer.Size; + } + mDrawData.CmdListsCount = mDrawData.CmdLists.Size; +} + +} diff --git a/source/imgui_draw_clone.h b/source/imgui_draw_clone.h new file mode 100644 index 00000000..456e6ebc --- /dev/null +++ b/source/imgui_draw_clone.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +namespace Nothofagus +{ + +/** + * @class ClonedImDrawData + * @brief A deep, independently-owned copy of a frame's `ImDrawData`. + * + * Dear ImGui's `ImDrawData` only points at draw lists owned by its + * `ImGuiContext`, which the next `NewFrame()` overwrites. To hand a UI frame + * from the simulation thread (which runs `NewFrame`/widgets/`Render`) to the + * render thread (which runs `RenderDrawData`), the draw lists must be copied. + * `cloneFrom` deep-copies each list via the upstream `ImDrawList::CloneOutput()` + * (its own doc recommends it for exactly this multi-threaded use) and rebuilds an + * `ImDrawData` referencing the owned copies. Storage is reused across calls. + * + * Ownership rule (matches the triple-buffer hand-off): every `cloneFrom`/`clear` + * — i.e. every allocation/free of the owned lists — happens on the producer + * (simulation) thread, which only ever touches a slot the render thread is not + * reading. The render thread only *reads* `drawData()`. + * + * `Textures` is left null; the render thread points it at its own context's + * platform texture list before `RenderDrawData`, so font-atlas uploads are + * applied render-side without touching the producer context's texture list. + */ +class ClonedImDrawData +{ +public: + ClonedImDrawData() = default; + ~ClonedImDrawData(); + + ClonedImDrawData(const ClonedImDrawData&) = delete; + ClonedImDrawData& operator=(const ClonedImDrawData&) = delete; + + /// Deep-copy `src` into owned storage and rebuild the local `ImDrawData`. + void cloneFrom(const ImDrawData* src); + + /// True when a valid, non-empty frame has been cloned. + bool hasData() const { return mDrawData.Valid && mDrawData.CmdListsCount > 0; } + + /// The cloned draw data, valid until the next `cloneFrom`/`clear`. + ImDrawData* drawData() { return &mDrawData; } + +private: + void clear(); + + ImDrawData mDrawData; + std::vector mOwnedLists; +}; + +} diff --git a/source/render_snapshot.cpp b/source/render_snapshot.cpp new file mode 100644 index 00000000..bea2ff4b --- /dev/null +++ b/source/render_snapshot.cpp @@ -0,0 +1,12 @@ +#include "render_snapshot.h" +#include "imgui_draw_clone.h" // complete type for the unique_ptr member + +namespace Nothofagus +{ + +// Defined out-of-line so RenderSnapshot can hold a unique_ptr to the +// forward-declared ClonedImDrawData in the header (imgui.h stays out of it). +RenderSnapshot::RenderSnapshot() = default; +RenderSnapshot::~RenderSnapshot() = default; + +} diff --git a/source/render_snapshot.h b/source/render_snapshot.h index bde3ebf7..2c932f68 100644 --- a/source/render_snapshot.h +++ b/source/render_snapshot.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "texture_id.h" #include "mesh.h" // MeshId #include "render_target.h" // RenderTargetId @@ -10,6 +11,8 @@ namespace Nothofagus { +class ClonedImDrawData; // fwd-decl keeps imgui.h out of this header + /** * @file render_snapshot.h * @brief POD projection of the scene that the render side consumes. @@ -62,6 +65,14 @@ struct RenderSnapshot std::vector draws; ///< main pass, depth-sorted at commit std::vector rttPasses; ///< insertion order preserved (nested-RTT dependency) glm::vec3 clearColor{0.0f}; + /// Cloned main-context ImGui draw data produced on the sim thread (M3), + /// null until ImGui has been committed. Lazily allocated by the producer. + std::unique_ptr mainUi; + + RenderSnapshot(); + ~RenderSnapshot(); // out-of-line: ClonedImDrawData is incomplete here + RenderSnapshot(const RenderSnapshot&) = delete; // owns unique_ptr; never copied + RenderSnapshot& operator=(const RenderSnapshot&) = delete; }; } diff --git a/third_party/imgui/imconfig.h b/third_party/imgui/imconfig.h index 0d843be2..413d48b6 100644 --- a/third_party/imgui/imconfig.h +++ b/third_party/imgui/imconfig.h @@ -14,6 +14,20 @@ #pragma once +//---- [Nothofagus] Thread-local current-context pointer. +// Nothofagus' threaded canvas drives two ImGui contexts on two threads at once: +// a UI context on the simulation thread (NewFrame + widgets + Render) and the +// renderer-bearing context on the render thread (RenderDrawData). Dear ImGui's +// implicit current-context pointer (GImGui) is a single global, so concurrent +// use from two threads would race. This is the upstream-documented fix (see the +// GImGui comment in imgui.cpp): make the pointer thread-local so each thread +// refers to its own context. The symbol is defined in source/imgui_draw_clone.cpp. +// Single-threaded use (run()/tick(), RTT secondary contexts) is unaffected — it +// all happens on one thread, whose TLS holds the current context as before. +struct ImGuiContext; +extern thread_local ImGuiContext* GNothofagusImGuiTLS; +#define GImGui GNothofagusImGuiTLS + //---- Define assertion handler. Defaults to calling assert(). // - If your macro uses multiple statements, make sure is enclosed in a 'do { .. } while (0)' block so it can be used as a single statement. // - Compiling with NDEBUG will usually strip out assert() to nothing, which is NOT recommended because we use asserts to notify of programmer mistakes. From e47836d391a23fb228dc846d37a8f8d1c6ab3923 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:26:53 -0700 Subject: [PATCH 04/55] threaded imgui supporting all inputs --- examples/hello_threaded_imgui.cpp | 17 ++++++- include/canvas.h | 7 +++ source/canvas.cpp | 10 ++++ source/frame_runner.cpp | 79 ++++++++++++++++++++++++++++++- source/frame_runner.h | 22 +++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) diff --git a/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp index 91676118..f8bf884e 100644 --- a/examples/hello_threaded_imgui.cpp +++ b/examples/hello_threaded_imgui.cpp @@ -75,6 +75,7 @@ int main() float waveSpeed = 1.0f; bool spawning = true; float lastFps = 0.0f; + char noteBuf[64] = "type here (Ctrl+C/V works)"; auto spawnOne = [&]() { @@ -116,7 +117,9 @@ int main() ++i; } - if (spawning) + // Refill toward the target — paused while the cursor is over the panel + // (demonstrates imguiWantsMouse(): UI focus suppresses world activity). + if (spawning && !canvas.imguiWantsMouse()) { int budget = 3; while (static_cast(sprites.size()) < targetCount && budget-- > 0) @@ -139,6 +142,18 @@ int main() ImGui::Checkbox("spawning", &spawning); if (ImGui::Button("clear all")) targetCount = 0; + + // Text input (exercises keyboard + the in-process clipboard). + ImGui::InputText("note", noteBuf, sizeof(noteBuf)); + + // Keyboard shortcut: Space toggles spawning — but not while typing into a + // text field (so the space character goes to the InputText instead). + if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space)) + spawning = !spawning; + + ImGui::TextDisabled("space: toggle spawning capture m/k: %d/%d", + ImGui::GetIO().WantCaptureMouse ? 1 : 0, + ImGui::GetIO().WantCaptureKeyboard ? 1 : 0); ImGui::End(); }; diff --git a/include/canvas.h b/include/canvas.h index eeb4aae4..39dece11 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -650,6 +650,13 @@ class Canvas /// frame still references them (deferred free) — no use-after-free. void despawnBellota(BellotaId bellotaId); + /// Whether the threaded ImGui UI captured the mouse / keyboard on the most + /// recent commit. Read these in the game `update` (which runs before the UI + /// frame) to skip world interaction while a widget has focus. Thread-safe; + /// the value is one frame old by construction. + bool imguiWantsMouse() const; + bool imguiWantsKeyboard() const; + /// Enable or disable automatic removal of unreferenced textures each frame. /// Enabled by default. Disable during bulk asset loading to prevent premature removal. void setAutoRemoveUnusedTextures(bool enabled); diff --git a/source/canvas.cpp b/source/canvas.cpp index 934ab5e5..39bccad6 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -418,6 +418,16 @@ void Canvas::despawnBellota(BellotaId bellotaId) mImplPtr->frameRunner.threadedDespawnBellota(mImplPtr->assets, bellotaId); } +bool Canvas::imguiWantsMouse() const +{ + return mImplPtr->frameRunner.threadedWantsMouse(); +} + +bool Canvas::imguiWantsKeyboard() const +{ + return mImplPtr->frameRunner.threadedWantsKeyboard(); +} + DirectTexture Canvas::takeScreenshot() const { return mImplPtr->frameRunner.takeScreenshot(); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 3b2070bd..b7db9072 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -28,6 +28,28 @@ namespace Nothofagus { +namespace +{ +// In-process clipboard for the sim-UI ImGui context. GLFW's clipboard is +// main-thread-only, so the sim thread cannot use it; this gives working +// copy/paste within the app's own text fields. (OS-clipboard integration across +// the thread boundary is a documented follow-up.) Accessed only on the sim thread +// (ImGui calls these during the sim UI frame), so no synchronization is needed. +std::string& threadedClipboardStorage() +{ + static std::string storage; + return storage; +} +const char* threadedGetClipboardText(ImGuiContext*) +{ + return threadedClipboardStorage().c_str(); +} +void threadedSetClipboardText(ImGuiContext*, const char* text) +{ + threadedClipboardStorage() = (text != nullptr) ? text : ""; +} +} + // Window is the selected backend type. Forward declared in frame_runner.h; // defined here so the backend headers are only included from this translation unit. struct FrameRunner::Window : public SelectedWindowBackend @@ -607,6 +629,12 @@ void FrameRunner::beginThreadedSession(Controller& controller) simIo.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // atlas uploaded render-side simIo.DisplaySize = ImVec2(static_cast(mScreenSize.width), static_cast(mScreenSize.height)); + // Enable keyboard nav and wire an in-process clipboard so InputText + // copy/paste works on the sim thread (GLFW clipboard is main-thread-only). + simIo.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + ImGuiPlatformIO& simPlatformIo = ImGui::GetPlatformIO(); + simPlatformIo.Platform_GetClipboardTextFn = threadedGetClipboardText; + simPlatformIo.Platform_SetClipboardTextFn = threadedSetClipboardText; ImGui::SetCurrentContext(mainContext); // restore the render thread's context // Prime the font atlas on the render thread (uploads the texture) before @@ -664,17 +692,33 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, input = mThreadedImguiInput; mThreadedImguiInput.wheelX = 0.0f; // consume accumulated wheel mThreadedImguiInput.wheelY = 0.0f; + mThreadedImguiInput.textCharCount = 0; // consume typed characters } const float displayWidth = input.displayWidth > 0.0f ? input.displayWidth : static_cast(mScreenSize.width); const float displayHeight = input.displayHeight > 0.0f ? input.displayHeight : static_cast(mScreenSize.height); io.DisplaySize = ImVec2(displayWidth, displayHeight); io.DisplayFramebufferScale = ImVec2(input.framebufferScaleX, input.framebufferScaleY); + + // Focus first: a loss releases held keys/mouse, avoiding stuck input. + io.AddFocusEvent(input.focused); + io.AddMousePosEvent(input.mouseX, input.mouseY); io.AddMouseButtonEvent(0, input.mouseDown[0]); io.AddMouseButtonEvent(1, input.mouseDown[1]); io.AddMouseButtonEvent(2, input.mouseDown[2]); if (input.wheelX != 0.0f || input.wheelY != 0.0f) io.AddMouseWheelEvent(input.wheelX, input.wheelY); + + // Keyboard: replay key state (ImGui dedups to changes), modifiers, text. + for (int key = ImGuiKey_NamedKey_BEGIN; key < ImGuiKey_MouseLeft; ++key) + io.AddKeyEvent(static_cast(key), input.keyDown[key - ImGuiKey_NamedKey_BEGIN]); + io.AddKeyEvent(ImGuiMod_Ctrl, input.keyCtrl); + io.AddKeyEvent(ImGuiMod_Shift, input.keyShift); + io.AddKeyEvent(ImGuiMod_Alt, input.keyAlt); + io.AddKeyEvent(ImGuiMod_Super, input.keySuper); + for (int i = 0; i < input.textCharCount; ++i) + io.AddInputCharacter(input.textChars[i]); + io.DeltaTime = std::max(deltaTimeMS * 0.001f, 1e-6f); ImGui::NewFrame(); @@ -682,6 +726,11 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, uiCallback(deltaTimeMS); // user ImGui widgets, on the sim thread ImGui::Render(); + // Publish what the UI captured this frame so the host's game update (which + // runs before this section) can ignore that input next frame. + mImguiWantsMouse.store(io.WantCaptureMouse, std::memory_order_release); + mImguiWantsKeyboard.store(io.WantCaptureKeyboard, std::memory_order_release); + if (!snapshot.mainUi) snapshot.mainUi = std::make_unique(); snapshot.mainUi->cloneFrom(ImGui::GetDrawData()); @@ -780,7 +829,10 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im void FrameRunner::harvestImguiInput() { - const ImGuiIO& io = ImGui::GetIO(); + static_assert(ImGuiKey_NamedKey_COUNT <= ThreadedImguiInput::kKeyCount, + "ThreadedImguiInput::kKeyCount too small for ImGuiKey_NamedKey_COUNT"); + + ImGuiIO& io = ImGui::GetIO(); std::lock_guard lock(mThreadedImguiInputMutex); mThreadedImguiInput.displayWidth = io.DisplaySize.x; mThreadedImguiInput.displayHeight = io.DisplaySize.y; @@ -793,6 +845,31 @@ void FrameRunner::harvestImguiInput() mThreadedImguiInput.mouseDown[2] = io.MouseDown[2]; mThreadedImguiInput.wheelX += io.MouseWheelH; // accumulate; sim consumes + resets mThreadedImguiInput.wheelY += io.MouseWheel; + + // Keyboard state (M4). Iterate the named-key range, skipping the mouse-button + // sub-range (handled above) and the reserved-mod entries — both live at the top + // of the range from ImGuiKey_MouseLeft onward. Gamepad keys are below that and + // harvested harmlessly (all up without a gamepad). + for (int key = ImGuiKey_NamedKey_BEGIN; key < ImGuiKey_NamedKey_END; ++key) + { + const int index = key - ImGuiKey_NamedKey_BEGIN; + mThreadedImguiInput.keyDown[index] = + (key < ImGuiKey_MouseLeft) ? ImGui::IsKeyDown(static_cast(key)) : false; + } + mThreadedImguiInput.keyCtrl = io.KeyCtrl; + mThreadedImguiInput.keyShift = io.KeyShift; + mThreadedImguiInput.keyAlt = io.KeyAlt; + mThreadedImguiInput.keySuper = io.KeySuper; + mThreadedImguiInput.focused = (io.AppFocusLost == false); + + // Append this frame's typed characters (consumed + cleared by the sim). + for (int i = 0; i < io.InputQueueCharacters.Size; ++i) + { + if (mThreadedImguiInput.textCharCount >= ThreadedImguiInput::kMaxTextChars) + break; + mThreadedImguiInput.textChars[mThreadedImguiInput.textCharCount++] = + static_cast(io.InputQueueCharacters[i]); + } } Nothofagus::BellotaId FrameRunner::threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota) diff --git a/source/frame_runner.h b/source/frame_runner.h index fd995725..288db3ad 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -167,6 +167,11 @@ class FrameRunner /// Thread-safe: true until the window is closed. Read by the sim loop. bool threadedRunning() const { return mThreadedRunning.load(std::memory_order_acquire); } + /// Thread-safe: whether the threaded ImGui UI captured the mouse / keyboard on + /// the most recent commit (so host game logic can ignore that input). + bool threadedWantsMouse() const { return mImguiWantsMouse.load(std::memory_order_acquire); } + bool threadedWantsKeyboard() const { return mImguiWantsKeyboard.load(std::memory_order_acquire); } + /// Sim thread: run the user `update` (game logic, lock-free), then — under the /// ImGui mutex — run `uiCallback` as an ImGui frame on the sim-UI context and /// clone its draw data, and project the scene into a free snapshot slot, then @@ -297,10 +302,27 @@ class FrameRunner float mouseX{0.0f}, mouseY{0.0f}; bool mouseDown[3]{false, false, false}; float wheelX{0.0f}, wheelY{0.0f}; // accumulated on render, consumed+reset on sim + + // Keyboard (M4). Sized generously to avoid pulling imgui.h into this header; + // a static_assert in the .cpp verifies it covers ImGuiKey_NamedKey_COUNT. + static constexpr int kKeyCount = 256; + bool keyDown[kKeyCount]{}; // indexed by (ImGuiKey - ImGuiKey_NamedKey_BEGIN) + bool keyCtrl{false}, keyShift{false}, keyAlt{false}, keySuper{false}; + static constexpr int kMaxTextChars = 32; + unsigned int textChars[kMaxTextChars]{}; // chars typed this frame (consumed on sim) + int textCharCount{0}; + bool focused{true}; }; ThreadedImguiInput mThreadedImguiInput; std::mutex mThreadedImguiInputMutex; + /// Set from the sim-UI frame each commit; read by the host's game update (and + /// available via Canvas) so world interaction can be suppressed while an ImGui + /// widget has focus. One frame stale by construction (game update runs before + /// the UI frame), which is the correct, expected behavior. + std::atomic mImguiWantsMouse{false}; + std::atomic mImguiWantsKeyboard{false}; + /// Serializes all access to the (shared) ImGui font atlas between the sim-UI /// context (NewFrame + widgets + Render + clone, on the sim thread) and the /// render/main context (NewFrame + RenderDrawData + atlas rebuild, on the From 8fedf5c59703f0f5fe1f56eaa7dda9101c88a558 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:55:34 -0700 Subject: [PATCH 05/55] missing call in vulkan backend --- source/frame_runner.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index b7db9072..b4c12ad8 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -648,6 +648,12 @@ void FrameRunner::beginThreadedSession(Controller& controller) applyMainContextScale(); ImGui::NewFrame(); ImGui::Render(); + // Open the main render pass before endFrame closes it. A real frame reaches + // beginMainPass via renderSnapshotContents; this priming frame draws nothing + // but must still produce a balanced begin/end pass — otherwise the Vulkan + // backend's endFrame issues vkCmdEndRenderPass with no active pass (the GL + // backend has no render-pass concept, so it was unaffected). + mBackend.beginMainPass(viewport); mBackend.endFrame(ImGui::GetDrawData(), fbW, fbH); } From b90153f87f42304c61c986ba13d3b82bf70ae86c Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:17:20 -0700 Subject: [PATCH 06/55] demo fix --- examples/hello_threaded_imgui.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp index f8bf884e..4425a840 100644 --- a/examples/hello_threaded_imgui.cpp +++ b/examples/hello_threaded_imgui.cpp @@ -93,13 +93,20 @@ int main() simTime += dt; if (dt > 0.0f) lastFps = 0.9f * lastFps + 0.1f * (1000.0f / dt); + // While the cursor is over the panel, freeze world activity (demonstrates + // imguiWantsMouse(): UI focus suppresses the world). Crucially this gates + // *aging/despawn together with spawning* — gating only spawning would let + // the population drain to nothing while you interact with the controls. + const bool worldActive = !canvas.imguiWantsMouse(); + // --- Simulation: animate + age/despawn --- for (std::size_t i = 0; i < sprites.size();) { Sprite& sprite = sprites[i]; - sprite.age += dt; + if (worldActive) sprite.age += dt; + const bool expired = worldActive && sprite.age >= sprite.lifespan; const bool overTarget = static_cast(i) >= targetCount; - if (sprite.age >= sprite.lifespan || overTarget) + if (expired || overTarget) { canvas.despawnBellota(sprite.id); sprite = sprites.back(); @@ -117,9 +124,8 @@ int main() ++i; } - // Refill toward the target — paused while the cursor is over the panel - // (demonstrates imguiWantsMouse(): UI focus suppresses world activity). - if (spawning && !canvas.imguiWantsMouse()) + // Refill toward the target — paused (with aging, above) while interacting. + if (spawning && worldActive) { int budget = 3; while (static_cast(sprites.size()) < targetCount && budget-- > 0) From fd9f2eb8161f810937f552c95370d01716b72498 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:36:46 -0700 Subject: [PATCH 07/55] demo fix pinning texture to avoid auto GC removal --- examples/hello_threaded_imgui.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp index 4425a840..4ef637af 100644 --- a/examples/hello_threaded_imgui.cpp +++ b/examples/hello_threaded_imgui.cpp @@ -58,6 +58,18 @@ int main() }); const Nothofagus::TextureId textureId = canvas.addTexture(texture); + // Anchor bellota: a single, hidden, never-despawned bellota that holds a + // permanent reference to the shared sprite texture. The engine auto-GCs any + // texture left unreferenced by every bellota, and the sprite population can + // momentarily drop to zero (all sprites aging out, or "clear all"). Without + // this anchor, the texture would be reclaimed and the next spawn reusing + // textureId would reference a freed texture. Keeping one hidden bellota alive + // for the whole program pins textureId. It is intentionally never added to + // `sprites`, so the despawn/aging logic can never destroy it. + const Nothofagus::BellotaId textureAnchorId = + canvas.addBellota({{{0.0f, 0.0f}}, textureId}); + canvas.bellota(textureAnchorId).visual().visible() = false; + struct Sprite { Nothofagus::BellotaId id; From b3f0f4029dccfaeac15d06fbe2f4224b821c7ebd Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:45:55 -0700 Subject: [PATCH 08/55] fix(threaded): imguiWantsKeyboard() reflects active capture, not nav presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sim-UI context enables keyboard nav, which made ImGui set WantCaptureKeyboard true whenever a panel had nav focus — pinning imguiWantsKeyboard() at true and making it useless for gating game input. Set ConfigNavCaptureKeyboard = false so capture reflects an actively-edited widget or open modal; nav (arrows/Tab/Enter), InputText, and clipboard are unaffected. --- include/canvas.h | 10 ++++++++-- source/frame_runner.cpp | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/include/canvas.h b/include/canvas.h index 39dece11..0dbe66e7 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -652,8 +652,14 @@ class Canvas /// Whether the threaded ImGui UI captured the mouse / keyboard on the most /// recent commit. Read these in the game `update` (which runs before the UI - /// frame) to skip world interaction while a widget has focus. Thread-safe; - /// the value is one frame old by construction. + /// frame) to skip world interaction while the UI is using that input. + /// Thread-safe; the value is one frame old by construction. + /// + /// imguiWantsMouse() is true while the cursor is over a UI window or a widget + /// is being dragged. imguiWantsKeyboard() is true only while the UI is actively + /// capturing keystrokes — a widget being edited (e.g. an InputText) or an open + /// modal — NOT merely because a panel is visible (keyboard nav stays enabled + /// but does not, on its own, claim the keyboard). bool imguiWantsMouse() const; bool imguiWantsKeyboard() const; diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index b4c12ad8..6f16c28e 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -632,6 +632,14 @@ void FrameRunner::beginThreadedSession(Controller& controller) // Enable keyboard nav and wire an in-process clipboard so InputText // copy/paste works on the sim thread (GLFW clipboard is main-thread-only). simIo.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + // With keyboard nav on, ImGui would set io.WantCaptureKeyboard true whenever + // io.NavActive is true — i.e. merely because a window exists with nav focus, + // even when nothing is being typed. That makes imguiWantsKeyboard() stuck at + // true and useless for gating game input. Disabling nav keyboard *capture* + // keeps nav itself working (arrows/Tab/Enter move focus) but makes + // WantCaptureKeyboard reflect real capture only — an active widget (e.g. an + // InputText being edited) or an open modal. + simIo.ConfigNavCaptureKeyboard = false; ImGuiPlatformIO& simPlatformIo = ImGui::GetPlatformIO(); simPlatformIo.Platform_GetClipboardTextFn = threadedGetClipboardText; simPlatformIo.Platform_SetClipboardTextFn = threadedSetClipboardText; From c00b63456ee62badcbdaa16bcda546e762ce14dc Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:51:48 -0700 Subject: [PATCH 09/55] refactor(frame_runner): unify single-threaded and threaded frame paths Collapse the duplicated buildSnapshot/renderSnapshot (run/tick) and commitFrame/renderFrameThreaded (commit/renderFrame) orchestration into one produce(FrameMode) + consume(FrameMode), gated by Single/Threaded. Extract the shared main-context ImGui setup into beginMainImguiFrame(). Entry points become thin call sites; dead buildSnapshot/renderSnapshot removed. No public API or behavior change. Goldens 17/17 (release+debug), OpenGL/Vulkan/SDL3 build, threaded demos TSan/ASan clean. --- source/frame_runner.cpp | 380 +++++++++++++++++++++------------------ source/frame_runner.h | 63 ++++--- source/render_snapshot.h | 8 +- 3 files changed, 250 insertions(+), 201 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 6f16c28e..65390acb 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -168,6 +168,14 @@ void FrameRunner::applyMainContextScale() mAppliedScale = scale; } +void FrameRunner::beginMainImguiFrame() +{ + mBackend.imguiNewFrame(); + mWindow->newImGuiFrame(); + applyMainContextScale(); // DPI-scale the main (standard-UI) context before NewFrame. + ImGui::NewFrame(); +} + std::size_t FrameRunner::getCurrentMonitor() const { return mWindow->getCurrentMonitor(); @@ -320,20 +328,123 @@ void FrameRunner::runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttMan { ZoneScopedN("runOneFrame"); - const RenderSnapshot& snapshot = buildSnapshot(canvas, assets, imguiRtt, deltaTimeMS, update, controller); - renderSnapshot(assets, imguiRtt, snapshot, deltaTimeMS, controller); + // Single-threaded frame: producer and consumer back-to-back on this thread. + const RenderSnapshot& snapshot = produce(FrameMode::Single, &canvas, assets, &imguiRtt, + deltaTimeMS, std::move(update), {}, &controller); + consume(FrameMode::Single, assets, imguiRtt, snapshot, deltaTimeMS, controller); FrameMark; } -const RenderSnapshot& FrameRunner::buildSnapshot(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, - float deltaTimeMS, std::function update, Controller& controller) +const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, AssetRegistry& assets, + ImguiRttManager* imguiRtt, float deltaTimeMS, + std::function update, + std::function uiCallback, + Controller* controller) { + if (mode == FrameMode::Threaded) + { + // Sim thread: CPU only, no GL. + ZoneScopedN("commitFrame"); + + // Stamp the commit seq BEFORE `update` so spawn/despawn can tag retired + // resources with the commit at which they leave the scene. + const std::uint64_t commitSeq = ++mCommitSeq; + + // 1) Game logic — lock-free, so it overlaps the render thread's sprite work. + // (spawn/despawn take the asset mutex internally; bellota value writes are + // sim-exclusive.) + { + ZoneScopedN("UserUpdate"); + update(deltaTimeMS); + } + + RenderSnapshot& snapshot = mTripleBuffer.writeSlot(); + snapshot.commitSeq = commitSeq; + snapshot.clearColor = mClearColor; + + // 2) ImGui frame on the sim-UI context — under the ImGui mutex so it never + // touches the shared font atlas concurrently with the render thread. + { + ZoneScopedN("SimImgui"); + std::lock_guard imguiLock(mImguiMutex); + + ImGui::SetCurrentContext(mSimUiContext); // thread-local current context (sim thread) + ImGuiIO& io = ImGui::GetIO(); + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // must match the atlas owner + + ThreadedImguiInput input; + { + std::lock_guard inputLock(mThreadedImguiInputMutex); + input = mThreadedImguiInput; + mThreadedImguiInput.wheelX = 0.0f; // consume accumulated wheel + mThreadedImguiInput.wheelY = 0.0f; + mThreadedImguiInput.textCharCount = 0; // consume typed characters + } + const float displayWidth = input.displayWidth > 0.0f ? input.displayWidth : static_cast(mScreenSize.width); + const float displayHeight = input.displayHeight > 0.0f ? input.displayHeight : static_cast(mScreenSize.height); + io.DisplaySize = ImVec2(displayWidth, displayHeight); + io.DisplayFramebufferScale = ImVec2(input.framebufferScaleX, input.framebufferScaleY); + + // Focus first: a loss releases held keys/mouse, avoiding stuck input. + io.AddFocusEvent(input.focused); + + io.AddMousePosEvent(input.mouseX, input.mouseY); + io.AddMouseButtonEvent(0, input.mouseDown[0]); + io.AddMouseButtonEvent(1, input.mouseDown[1]); + io.AddMouseButtonEvent(2, input.mouseDown[2]); + if (input.wheelX != 0.0f || input.wheelY != 0.0f) + io.AddMouseWheelEvent(input.wheelX, input.wheelY); + + // Keyboard: replay key state (ImGui dedups to changes), modifiers, text. + for (int key = ImGuiKey_NamedKey_BEGIN; key < ImGuiKey_MouseLeft; ++key) + io.AddKeyEvent(static_cast(key), input.keyDown[key - ImGuiKey_NamedKey_BEGIN]); + io.AddKeyEvent(ImGuiMod_Ctrl, input.keyCtrl); + io.AddKeyEvent(ImGuiMod_Shift, input.keyShift); + io.AddKeyEvent(ImGuiMod_Alt, input.keyAlt); + io.AddKeyEvent(ImGuiMod_Super, input.keySuper); + for (int i = 0; i < input.textCharCount; ++i) + io.AddInputCharacter(input.textChars[i]); + + io.DeltaTime = std::max(deltaTimeMS * 0.001f, 1e-6f); + + ImGui::NewFrame(); + if (uiCallback) + uiCallback(deltaTimeMS); // user ImGui widgets, on the sim thread + ImGui::Render(); + + // Publish what the UI captured this frame so the host's game update (which + // runs before this section) can ignore that input next frame. + mImguiWantsMouse.store(io.WantCaptureMouse, std::memory_order_release); + mImguiWantsKeyboard.store(io.WantCaptureKeyboard, std::memory_order_release); + + if (!snapshot.mainUi) + snapshot.mainUi = std::make_unique(); + snapshot.mainUi->cloneFrom(ImGui::GetDrawData()); + } + + // 3) Project the scene (POD) — lock-free; the write slot is producer-owned. + { + ZoneScopedN("DepthSort"); + buildMainDraws(assets.bellotas(), snapshot.draws); + } + { + ZoneScopedN("RttGather"); + buildRttPasses(assets, snapshot.rttPasses); + } + + mTripleBuffer.publish(); + return snapshot; + } + + // FrameMode::Single — one thread; ImGui runs on the main context inside `update` + // and the consumer renders the live draw data, so the GPU + main ImGui frame are + // opened here (before `update`). canvas/imguiRtt/controller are non-null here. ZoneScopedN("buildSnapshot"); { ZoneScopedN("Input"); - controller.processInputs(); + controller->processInputs(); } // Drain any deferred ImGui font ops (bake-on-miss / remove) accumulated @@ -341,10 +452,10 @@ const RenderSnapshot& FrameRunner::buildSnapshot(Canvas& canvas, AssetRegistry& // the previous frame's ImGui::Render() and this frame's ImGui::NewFrame(). // Must run BEFORE mBackend.imguiNewFrame() so ImGui_Impl*_NewFrame()'s // lazy font-texture re-upload picks up the rebuilt atlas. - imguiRtt.drainPendingFontOps(); + imguiRtt->drainPendingFontOps(); // Get current framebuffer size and compute letterboxed viewport. Stored so - // renderSnapshot can reuse the exact same values (one getFramebufferSize per + // the consumer can reuse the exact same values (one getFramebufferSize per // frame). gameViewport() is read by user code during update (overlay // positioning), so it must be set before update(). auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); @@ -357,10 +468,7 @@ const RenderSnapshot& FrameRunner::buildSnapshot(Canvas& canvas, AssetRegistry& mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); // Start the Dear ImGui frame - mBackend.imguiNewFrame(); - mWindow->newImGuiFrame(); - applyMainContextScale(); // DPI-scale the main (standard-UI) context before NewFrame. - ImGui::NewFrame(); + beginMainImguiFrame(); { ZoneScopedN("UserUpdate"); @@ -369,16 +477,16 @@ const RenderSnapshot& FrameRunner::buildSnapshot(Canvas& canvas, AssetRegistry& { ZoneScopedN("DenseLandExplorers"); - mDenseLandManager.updateExplorers(canvas); + mDenseLandManager.updateExplorers(*canvas); } { ZoneScopedN("SparseLandExplorers"); - mSparseLandManager.updateExplorers(canvas); + mSparseLandManager.updateExplorers(*canvas); } // Stamp this frame's commit number, then detect unused resources and enqueue - // them for deferred free (the actual GPU free happens in renderSnapshot, once + // them for deferred free (the actual GPU free happens in the consumer, once // no in-flight snapshot references them). At depth-0 this is the same frame. mSnapshot.commitSeq = ++mCommitSeq; mSnapshot.clearColor = mClearColor; @@ -531,11 +639,81 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& } } -void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRtt, - const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller) +void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager& imguiRtt, + const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller) { - ZoneScopedN("renderSnapshot"); + if (mode == FrameMode::Threaded) + { + // Main thread: owns the GL/window context and does all GPU work. The caller + // has already acquired the freshest published snapshot into `snapshot`. + + // Dispatch input events queued by the previous frame's poll (main thread, so + // any action callback — e.g. Escape → close() — runs here safely). + { + ZoneScopedN("Input"); + controller.processInputs(); + } + + auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); + mFramebufferWidth = framebufferWidth; + mFramebufferHeight = framebufferHeight; + mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, mScreenSize.width, mScreenSize.height); + + mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); + + // Main-context ImGui frame on the render thread (under the ImGui mutex, so it + // never touches the shared font atlas concurrently with the sim-UI context). + // It does NOT draw user UI (that arrives as a clone) — it (a) drains pending + // font ops + lets ImGui_ImplGlfw process window input so we can harvest it for + // the sim, and (b) provides valid empty draw data for the frames before the + // first UI commit. + { + ZoneScopedN("RenderImguiNewFrame"); + std::lock_guard imguiLock(mImguiMutex); + imguiRtt.drainPendingFontOps(); + beginMainImguiFrame(); + harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame + ImGui::Render(); + } + + const float now = mWindow->getTime(); + deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; + mLastRenderTime = now; + mLastRenderTimeValid = true; + + // Container-touching section (deferred frees, GPU upload, id→handle resolve + + // draw submission). Guarded against the sim thread's spawn/despawn. The lock + // is released BEFORE the vsync swap so a slow present never stalls the sim. + { + ZoneScopedN("AssetLockedRender"); + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotContents(assets, imguiRtt, snapshot, deltaTimeMS); + } + + { + ZoneScopedN("ImGuiRender"); + // Render the sim's cloned UI if present; otherwise the main empty frame. + // Under the ImGui mutex: RenderDrawData applies font-atlas texture uploads + // (draw_data->Textures) which touch the shared atlas. + std::lock_guard imguiLock(mImguiMutex); + ImDrawData* uiData = (snapshot.mainUi && snapshot.mainUi->hasData()) + ? snapshot.mainUi->drawData() + : ImGui::GetDrawData(); + uiData->Textures = &ImGui::GetPlatformIO().Textures; + mBackend.endFrame(uiData, mFramebufferWidth, mFramebufferHeight); + } + + { + ZoneScopedN("SwapBuffers"); + mWindow->endFrame(controller, mScreenSize); + } + + mThreadedRunning.store(mWindow->isRunning(), std::memory_order_release); + return; + } + // FrameMode::Single — the producer (buildSnapshot) already opened the GPU frame + // and the main-context ImGui frame, so the consumer just renders + presents. renderSnapshotContents(assets, imguiRtt, snapshot, deltaTimeMS); if (mStats) @@ -560,6 +738,7 @@ void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRt } } + void FrameRunner::run(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, std::function update, Controller& controller) { @@ -651,10 +830,7 @@ void FrameRunner::beginThreadedSession(Controller& controller) auto [fbW, fbH] = mWindow->getFramebufferSize(); const ViewportRect viewport = computeLetterboxViewport(fbW, fbH, mScreenSize.width, mScreenSize.height); mBackend.beginFrame(mClearColor, viewport, fbW, fbH); - mBackend.imguiNewFrame(); - mWindow->newImGuiFrame(); - applyMainContextScale(); - ImGui::NewFrame(); + beginMainImguiFrame(); ImGui::Render(); // Open the main render pass before endFrame closes it. A real frame reaches // beginMainPass via renderSnapshotContents; this priming frame draws nothing @@ -671,172 +847,22 @@ void FrameRunner::beginThreadedSession(Controller& controller) void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update, std::function uiCallback) { - // Sim thread: CPU only, no GL. - ZoneScopedN("commitFrame"); - - // Stamp the commit seq BEFORE `update` so spawn/despawn can tag retired - // resources with the commit at which they leave the scene. - const std::uint64_t commitSeq = ++mCommitSeq; - - // 1) Game logic — lock-free, so it overlaps the render thread's sprite work. - // (spawn/despawn take the asset mutex internally; bellota value writes are - // sim-exclusive.) - { - ZoneScopedN("UserUpdate"); - update(deltaTimeMS); - } - - RenderSnapshot& snapshot = mTripleBuffer.writeSlot(); - snapshot.commitSeq = commitSeq; - snapshot.clearColor = mClearColor; - - // 2) ImGui frame on the sim-UI context — under the ImGui mutex so it never - // touches the shared font atlas concurrently with the render thread. - { - ZoneScopedN("SimImgui"); - std::lock_guard imguiLock(mImguiMutex); - - ImGui::SetCurrentContext(mSimUiContext); // thread-local current context (sim thread) - ImGuiIO& io = ImGui::GetIO(); - io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // must match the atlas owner - - ThreadedImguiInput input; - { - std::lock_guard inputLock(mThreadedImguiInputMutex); - input = mThreadedImguiInput; - mThreadedImguiInput.wheelX = 0.0f; // consume accumulated wheel - mThreadedImguiInput.wheelY = 0.0f; - mThreadedImguiInput.textCharCount = 0; // consume typed characters - } - const float displayWidth = input.displayWidth > 0.0f ? input.displayWidth : static_cast(mScreenSize.width); - const float displayHeight = input.displayHeight > 0.0f ? input.displayHeight : static_cast(mScreenSize.height); - io.DisplaySize = ImVec2(displayWidth, displayHeight); - io.DisplayFramebufferScale = ImVec2(input.framebufferScaleX, input.framebufferScaleY); - - // Focus first: a loss releases held keys/mouse, avoiding stuck input. - io.AddFocusEvent(input.focused); - - io.AddMousePosEvent(input.mouseX, input.mouseY); - io.AddMouseButtonEvent(0, input.mouseDown[0]); - io.AddMouseButtonEvent(1, input.mouseDown[1]); - io.AddMouseButtonEvent(2, input.mouseDown[2]); - if (input.wheelX != 0.0f || input.wheelY != 0.0f) - io.AddMouseWheelEvent(input.wheelX, input.wheelY); - - // Keyboard: replay key state (ImGui dedups to changes), modifiers, text. - for (int key = ImGuiKey_NamedKey_BEGIN; key < ImGuiKey_MouseLeft; ++key) - io.AddKeyEvent(static_cast(key), input.keyDown[key - ImGuiKey_NamedKey_BEGIN]); - io.AddKeyEvent(ImGuiMod_Ctrl, input.keyCtrl); - io.AddKeyEvent(ImGuiMod_Shift, input.keyShift); - io.AddKeyEvent(ImGuiMod_Alt, input.keyAlt); - io.AddKeyEvent(ImGuiMod_Super, input.keySuper); - for (int i = 0; i < input.textCharCount; ++i) - io.AddInputCharacter(input.textChars[i]); - - io.DeltaTime = std::max(deltaTimeMS * 0.001f, 1e-6f); - - ImGui::NewFrame(); - if (uiCallback) - uiCallback(deltaTimeMS); // user ImGui widgets, on the sim thread - ImGui::Render(); - - // Publish what the UI captured this frame so the host's game update (which - // runs before this section) can ignore that input next frame. - mImguiWantsMouse.store(io.WantCaptureMouse, std::memory_order_release); - mImguiWantsKeyboard.store(io.WantCaptureKeyboard, std::memory_order_release); - - if (!snapshot.mainUi) - snapshot.mainUi = std::make_unique(); - snapshot.mainUi->cloneFrom(ImGui::GetDrawData()); - } - - // 3) Project the scene (POD) — lock-free; the write slot is producer-owned. - { - ZoneScopedN("DepthSort"); - buildMainDraws(assets.bellotas(), snapshot.draws); - } - { - ZoneScopedN("RttGather"); - buildRttPasses(assets, snapshot.rttPasses); - } - - mTripleBuffer.publish(); + // Sim thread: CPU only, no GL. canvas/imguiRtt/controller are unused on this + // path (no explorers, font drain, or input poll on the sim thread). + produce(FrameMode::Threaded, nullptr, assets, nullptr, deltaTimeMS, + std::move(update), std::move(uiCallback), nullptr); } void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller) { - // Main thread: owns the GL/window context and does all GPU work. ZoneScopedN("renderFrameThreaded"); - // Dispatch input events queued by the previous frame's poll (main thread, so - // any action callback — e.g. Escape → close() — runs here safely). - { - ZoneScopedN("Input"); - controller.processInputs(); - } - // Pick up the freshest published snapshot (keeps the previous one if none new). mTripleBuffer.acquire(); const RenderSnapshot& snapshot = mTripleBuffer.readSlot(); - auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); - mFramebufferWidth = framebufferWidth; - mFramebufferHeight = framebufferHeight; - mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, mScreenSize.width, mScreenSize.height); - - mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); - - // Main-context ImGui frame on the render thread (under the ImGui mutex, so it - // never touches the shared font atlas concurrently with the sim-UI context). - // It does NOT draw user UI (that arrives as a clone) — it (a) drains pending - // font ops + lets ImGui_ImplGlfw process window input so we can harvest it for - // the sim, and (b) provides valid empty draw data for the frames before the - // first UI commit. - { - ZoneScopedN("RenderImguiNewFrame"); - std::lock_guard imguiLock(mImguiMutex); - imguiRtt.drainPendingFontOps(); - mBackend.imguiNewFrame(); - mWindow->newImGuiFrame(); - applyMainContextScale(); - ImGui::NewFrame(); - harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame - ImGui::Render(); - } - - const float now = mWindow->getTime(); - const float deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; - mLastRenderTime = now; - mLastRenderTimeValid = true; - - // Container-touching section (deferred frees, GPU upload, id→handle resolve + - // draw submission). Guarded against the sim thread's spawn/despawn. The lock - // is released BEFORE the vsync swap so a slow present never stalls the sim. - { - ZoneScopedN("AssetLockedRender"); - std::lock_guard assetLock(mThreadedAssetMutex); - renderSnapshotContents(assets, imguiRtt, snapshot, deltaTimeMS); - } - - { - ZoneScopedN("ImGuiRender"); - // Render the sim's cloned UI if present; otherwise the main empty frame. - // Under the ImGui mutex: RenderDrawData applies font-atlas texture uploads - // (draw_data->Textures) which touch the shared atlas. - std::lock_guard imguiLock(mImguiMutex); - ImDrawData* uiData = (snapshot.mainUi && snapshot.mainUi->hasData()) - ? snapshot.mainUi->drawData() - : ImGui::GetDrawData(); - uiData->Textures = &ImGui::GetPlatformIO().Textures; - mBackend.endFrame(uiData, mFramebufferWidth, mFramebufferHeight); - } - - { - ZoneScopedN("SwapBuffers"); - mWindow->endFrame(controller, mScreenSize); - } - - mThreadedRunning.store(mWindow->isRunning(), std::memory_order_release); + // dt is recomputed from the window clock inside the Threaded arm. + consume(FrameMode::Threaded, assets, imguiRtt, snapshot, 0.0f, controller); FrameMark; } diff --git a/source/frame_runner.h b/source/frame_runner.h index 288db3ad..1620b951 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -192,6 +192,13 @@ class FrameRunner void threadedDespawnBellota(AssetRegistry& assets, BellotaId bellotaId); private: + /// Selects which orchestration a unified producer/consumer runs. `Single` is + /// the run()/tick() path (one thread; ImGui on the main context, live draw + /// data, no locks); `Threaded` is the commit()/renderFrame() path (sim/render + /// split; sim-UI context + cloned draw data, asset/ImGui mutexes). The two + /// share their leaf work and differ only in the mode-gated arms. + enum class FrameMode { Single, Threaded }; + /// Render thread (M3): snapshot the main context's processed ImGui input /// (mouse pos/buttons/wheel + display size/scale) into mThreadedImguiInput so /// the sim thread can feed it to the sim-UI context, making widgets interactive. @@ -200,24 +207,33 @@ class FrameRunner void runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, float deltaTimeMS, std::function update, Controller& controller); - /// Simulation/commit half of a frame: runs input + the user update + - /// explorers, detects unused resources (enqueuing them for deferred free), - /// and projects the live scene into the POD `mSnapshot` (depth-sorted main - /// draw list + RTT passes). Returns a reference to that snapshot. - /// - /// NOTE (Milestone 1): this also performs some GPU-frame setup inline - /// (`beginFrame`, ImGui `NewFrame`) because the user update issues ImGui - /// calls and reads `gameViewport()`. That setup migrates to the render side - /// at the thread-flip milestone; for now it stays here to keep the - /// single-threaded frame byte-identical. - const RenderSnapshot& buildSnapshot(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, - float deltaTimeMS, std::function update, Controller& controller); - - /// Render/consume half of a frame: drains deferred frees, uploads dirty GPU - /// resources, draws the snapshot's RTT passes and main pass, renders ImGui, - /// and presents. Touches no `Bellota` — only the POD snapshot. - void renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRtt, - const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller); + /// Unified producer for both modes. `Single` (run/tick) polls input, opens the + /// GPU + main ImGui frame, runs `update` (which issues main-context ImGui), + /// updates explorers, stamps the commit seq + GCs unused resources, projects + /// into the reused `mSnapshot`, and returns it. `Threaded` (commit) stamps the + /// seq first, runs `update` lock-free, then under `mImguiMutex` runs `uiCallback` + /// on the sim-UI context + clones its draw data into the triple-buffer write + /// slot, projects, publishes, and returns that slot. `canvas`, `imguiRtt`, and + /// `controller` are used only in `Single` (the sim thread has none) — pass + /// `nullptr` in `Threaded`; `uiCallback` is empty in `Single`. + const RenderSnapshot& produce(FrameMode mode, Canvas* canvas, AssetRegistry& assets, + ImguiRttManager* imguiRtt, float deltaTimeMS, + std::function update, + std::function uiCallback, + Controller* controller); + + /// Unified consumer for both modes. `Single` (run/tick): the GPU frame + main + /// ImGui frame were already opened by the producer, so this just runs the + /// render core, the optional stats overlay, the live-ImGui render, and the + /// swap — no locks. `Threaded` (renderFrame): the caller has already + /// acquired/read the snapshot; this dispatches queued input, opens the GPU + /// frame + an empty main ImGui frame (harvesting input for the sim), computes + /// wall-clock dt, runs the render core under `mThreadedAssetMutex`, renders the + /// sim's cloned draw data under `mImguiMutex`, swaps, and refreshes the running + /// flag. The `deltaTimeMS` argument is used only in `Single`; `Threaded` + /// recomputes it from the window clock. + void consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager& imguiRtt, + const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller); /// The container-touching core of a rendered frame: deferred frees, GPU /// upload, RTT passes, and the main draw. Excludes the vsync swap and the @@ -239,6 +255,13 @@ class FrameRunner /// style (metrics, only when the scale changed). Main context must be current. void applyMainContextScale(); + /// Open a Dear ImGui frame on the main (render/standard-UI) context: backend + /// imguiNewFrame + window newImGuiFrame + DPI scale + ImGui::NewFrame, in that + /// order. Shared by the single-threaded producer, the threaded consumer, and + /// the threaded-session priming frame. Caller is responsible for `beginFrame`, + /// `drainPendingFontOps`, and any ImGui mutex around this call. + void beginMainImguiFrame(); + ScreenSize mScreenSize; ///< The screen size of the canvas. std::string mTitle; ///< The title of the canvas window. glm::vec3 mClearColor; ///< The background color of the canvas. @@ -258,7 +281,7 @@ class FrameRunner bool mAutoTextureGC{true}; ///< When true, unreferenced textures are removed each frame. bool mAutoMeshGC{true}; ///< When true, unreferenced meshes are removed each frame. - RenderSnapshot mSnapshot; ///< Reused POD projection of the scene (produced by buildSnapshot, consumed by renderSnapshot). + RenderSnapshot mSnapshot; ///< Reused POD projection of the scene for Single mode (produced + consumed by produce()/consume()). std::uint64_t mCommitSeq{0}; ///< Monotonic commit counter; stamped onto each snapshot. std::uint64_t mLastRenderedSeq{0}; ///< Highest commit seq fully rendered; gates deferred frees. @@ -270,7 +293,7 @@ class FrameRunner std::vector mPendingTextureFrees; std::vector mPendingMeshFrees; - int mFramebufferWidth{0}; ///< Framebuffer size captured in buildSnapshot, reused by renderSnapshot. + int mFramebufferWidth{0}; ///< Framebuffer size captured in produce(), reused by consume(). int mFramebufferHeight{0}; // ----- Threaded driver state (M2 Phase A) ----- diff --git a/source/render_snapshot.h b/source/render_snapshot.h index 2c932f68..971b3409 100644 --- a/source/render_snapshot.h +++ b/source/render_snapshot.h @@ -23,10 +23,10 @@ class ClonedImDrawData; // fwd-decl keeps imgui.h out of this header * `RenderSnapshot` — a flat, trivially-copyable display list. The render side * draws only from the snapshot and never touches a `Bellota`. * - * In Milestone 1 the snapshot is built and consumed back-to-back on the same - * thread (`FrameRunner::buildSnapshot` → `renderSnapshot`), so it carries no - * concurrency cost yet; it exists to establish the POD data boundary. At the - * thread-flip milestone the snapshot becomes the double-buffered hand-off. + * In Single mode the snapshot is built and consumed back-to-back on the same + * thread (`FrameRunner::produce(Single)` → `consume(Single)`), so it carries no + * concurrency cost; it exists to establish the POD data boundary. In Threaded + * mode the same POD becomes the triple-buffered sim→render hand-off. * * Resources are referenced by id (`TextureId`/`MeshId`/`RenderTargetId`), never * by GPU handle (`DTexture`/`DMesh`): a freshly created texture has no GPU From 4966785cd6d358bac217cee363e7cdf545437ff7 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:41:36 -0700 Subject: [PATCH 10/55] Gamepad input now reaches the sim thread via the unified threaded core. --- examples/CMakeLists.txt | 6 ++ examples/hello_threaded_gamepad.cpp | 152 ++++++++++++++++++++++++++++ include/canvas.h | 9 ++ source/canvas.cpp | 5 + source/frame_runner.cpp | 93 +++++++++++++++++ source/frame_runner.h | 37 +++++++ 6 files changed, 302 insertions(+) create mode 100644 examples/hello_threaded_gamepad.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index e5fc1a59..9960e187 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -155,3 +155,9 @@ add_executable(hello_threaded_imgui ) set_property(TARGET hello_threaded_imgui PROPERTY CXX_STANDARD 20) target_link_libraries(hello_threaded_imgui PRIVATE nothofagus Threads::Threads) + +add_executable(hello_threaded_gamepad + "hello_threaded_gamepad.cpp" +) +set_property(TARGET hello_threaded_gamepad PROPERTY CXX_STANDARD 20) +target_link_libraries(hello_threaded_gamepad PRIVATE nothofagus Threads::Threads) diff --git a/examples/hello_threaded_gamepad.cpp b/examples/hello_threaded_gamepad.cpp new file mode 100644 index 00000000..d10f93d7 --- /dev/null +++ b/examples/hello_threaded_gamepad.cpp @@ -0,0 +1,152 @@ +// hello_threaded_gamepad — gamepad input on the sim thread (M5). +// +// The game logic runs on its own std::thread (inside canvas.commit's update); +// the main thread renders the previous frame's snapshot. nothofagus spawns no +// threads — this file owns both loops. +// +// The threaded path uses TWO controllers: +// * a RENDER controller, bound to the window on the main thread (here: Escape +// -> close). The window backend polls the OS input — keyboard/mouse/gamepad — +// into it on the render thread. +// * a SIM controller, the game's own. nothofagus marshals the render +// controller's normalized gamepad state across the thread boundary and +// replays it onto the sim controller right before each commit's update, so +// the game can poll it (getGamepadAxis/...) and receive its callbacks +// (registerGamepadAction/...) the normal way — all on the sim thread. +// +// This mirrors examples/test_gamepad.cpp, but on the threaded driver. Gamepad +// input is one frame stale by construction (same uniform sim<-render latency as +// the rest of the threaded path). Real stick input needs a connected pad; with +// none, the marshal/feed runs every frame as a clean no-op. + +#include +#include +#include +#include +#include +#include + +int main() +{ + spdlog::info("hello_threaded_gamepad: gamepad input on the sim thread (two-controller model)"); + + const Nothofagus::ScreenSize screenSize{200, 150}; + Nothofagus::Canvas canvas(screenSize, "Hello Threaded Gamepad", {0.15f, 0.15f, 0.2f}, 6); + + Nothofagus::ColorPallete pallete{ + {0.0, 0.0, 0.0, 0.0}, + {0.2, 0.5, 0.9, 1.0}, + {0.4, 0.7, 1.0, 1.0}, + {0.6, 0.9, 1.0, 1.0}, + }; + + Nothofagus::IndirectTexture texture({8, 8}, {0.5, 0.5, 0.5, 1.0}); + texture.setPallete(pallete) + .setPixels( + { + 0,0,3,3,3,3,0,0, + 0,3,2,2,2,2,3,0, + 3,2,1,2,2,1,2,3, + 3,2,2,2,2,2,2,3, + 3,2,2,2,2,2,2,3, + 3,2,1,2,2,1,2,3, + 0,3,2,2,2,2,3,0, + 0,0,3,3,3,3,0,0, + }); + // Resources are created up front (uploaded once on the render thread) before + // the threads start; at runtime the sim only mutates the bellota's values. + const Nothofagus::TextureId textureId = canvas.addTexture(texture); + const Nothofagus::BellotaId bellotaId = canvas.addBellota({{{100.0f, 75.0f}}, textureId}); + + constexpr float horizontalSpeed = 0.12f; + constexpr float angularSpeed = 0.1f; + constexpr float discreteStep = 10.0f; + bool rotate = false; + float pulseTimer = 0.0f; + + // ----- Sim controller: the game's gamepad input, consumed on the sim thread. + // Register all actions before the threads start (the sim thread is the only + // one that touches this controller afterward). + Nothofagus::Controller simController; + + simController.registerGamepadAction({0, Nothofagus::GamepadButton::A, Nothofagus::DiscreteTrigger::Press}, + [&]() { pulseTimer = 500.0f; }); + simController.registerGamepadAction({0, Nothofagus::GamepadButton::Start, Nothofagus::DiscreteTrigger::Press}, + [&]() { rotate = not rotate; }); + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadUp, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.bellota(bellotaId).transform().location().y += discreteStep; }); + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadDown, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.bellota(bellotaId).transform().location().y -= discreteStep; }); + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadLeft, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.bellota(bellotaId).transform().location().x -= discreteStep; }); + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadRight, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.bellota(bellotaId).transform().location().x += discreteStep; }); + + auto update = [&](float deltaTime) + { + Nothofagus::Bellota& bellota = canvas.bellota(bellotaId); + + // Smooth movement via the left stick (polling the sim controller). + const std::vector connectedIds = simController.getConnectedGamepadIds(); + if (not connectedIds.empty()) + { + const int gamepadId = connectedIds[0]; + const float leftX = simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftX); + const float leftY = simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftY); + bellota.transform().location().x += leftX * horizontalSpeed * deltaTime; + bellota.transform().location().y += leftY * horizontalSpeed * deltaTime; + } + + bellota.transform().location().x = std::clamp(bellota.transform().location().x, 10.0f, static_cast(screenSize.width) - 10.0f); + bellota.transform().location().y = std::clamp(bellota.transform().location().y, 10.0f, static_cast(screenSize.height) - 10.0f); + + if (rotate) + bellota.transform().angle() += angularSpeed * deltaTime; + + if (pulseTimer > 0.0f) + { + pulseTimer -= deltaTime; + const float pulse = 1.0f + 0.5f * std::sin(pulseTimer * 0.02f); + bellota.transform().scale() = glm::vec2(pulse * 3.0f, pulse * 3.0f); + } + else + { + bellota.transform().scale() = glm::vec2(3.0f, 3.0f); + } + }; + + // ----- Render controller: window input on the main thread (Escape -> close). + Nothofagus::Controller renderController; + renderController.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.close(); }); + + canvas.beginThreadedSession(renderController); + + // Simulation thread: commit at ~120 Hz, decoupled from the render cadence. + // The gamepad-aware commit overload feeds simController before update. + std::thread simThread([&]() + { + using clock = std::chrono::steady_clock; + auto previous = clock::now(); + constexpr auto targetPeriod = std::chrono::microseconds(8333); // ~120 Hz + while (canvas.isThreadedRunning()) + { + const auto now = clock::now(); + const float dt = std::chrono::duration(now - previous).count(); + previous = now; + + canvas.commit(dt, update, simController); + + std::this_thread::sleep_for(targetPeriod); + } + }); + + // Main thread: render the latest committed snapshot + pump window/input. + while (canvas.isThreadedRunning()) + { + canvas.renderFrame(renderController); + } + + simThread.join(); + return 0; +} diff --git a/include/canvas.h b/include/canvas.h index 0dbe66e7..4e0a05d7 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -636,6 +636,15 @@ class Canvas /// marshalled render→sim). Keep ImGui calls in `uiCallback`, not `update`. void commit(float deltaTime, std::function update, std::function uiCallback); + /// Sim thread: run `update(deltaTime)` (game logic) and publish a frame + /// snapshot, feeding `simController` from the gamepad first so the game can + /// poll it / receive its callbacks inside `update`. `simController` is the + /// game's controller, distinct from the render controller passed to + /// `beginThreadedSession`/`renderFrame` (which owns window input + `close`). + /// Gamepad state is marshalled render→sim (one frame stale by construction). + /// No ImGui. + void commit(float deltaTime, std::function update, Controller& simController); + /// Main thread: render the latest published snapshot and pump window/input. void renderFrame(Controller& controller); diff --git a/source/canvas.cpp b/source/canvas.cpp index 39bccad6..5290ccd5 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -403,6 +403,11 @@ void Canvas::commit(float deltaTime, std::function update, std::fun mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update), std::move(uiCallback)); } +void Canvas::commit(float deltaTime, std::function update, Controller& simController) +{ + mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update), simController); +} + void Canvas::renderFrame(Controller& controller) { mImplPtr->frameRunner.renderFrameThreaded(mImplPtr->assets, mImplPtr->imguiRtt, controller); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 65390acb..0f5af84e 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -351,6 +351,14 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset // resources with the commit at which they leave the scene. const std::uint64_t commitSeq = ++mCommitSeq; + // M5: feed the sim controller from the latest gamepad snapshot (harvested + // render-side after the window poll) so the game `update` sees the gamepad. + if (controller != nullptr) + { + ZoneScopedN("FeedGamepad"); + feedGamepadInput(*controller); + } + // 1) Game logic — lock-free, so it overlaps the render thread's sprite work. // (spawn/despawn take the asset mutex internally; bellota value writes are // sim-exclusive.) @@ -708,6 +716,13 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager mWindow->endFrame(controller, mScreenSize); } + // M5: snapshot the render controller's freshly-polled gamepad state for the + // sim thread to replay onto its own controller next commit. + { + ZoneScopedN("HarvestGamepad"); + harvestGamepadInput(controller); + } + mThreadedRunning.store(mWindow->isRunning(), std::memory_order_release); return; } @@ -853,6 +868,15 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::move(update), std::move(uiCallback), nullptr); } +void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, Controller& simController) +{ + // Sim thread (M5): feed the sim controller from the latest gamepad snapshot + // (inside produce, before update) so the game `update` sees the gamepad. No ImGui. + produce(FrameMode::Threaded, nullptr, assets, nullptr, deltaTimeMS, + std::move(update), {}, &simController); +} + void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller) { ZoneScopedN("renderFrameThreaded"); @@ -912,6 +936,75 @@ void FrameRunner::harvestImguiInput() } } +void FrameRunner::harvestGamepadInput(Controller& renderController) +{ + static_assert(static_cast(GamepadButton::DpadLeft) + 1 == GamepadSnapshot::kButtonCount, + "GamepadSnapshot::kButtonCount out of sync with the GamepadButton enum"); + static_assert(static_cast(GamepadAxis::RightTrigger) + 1 == GamepadSnapshot::kAxisCount, + "GamepadSnapshot::kAxisCount out of sync with the GamepadAxis enum"); + + // Read the render controller's normalized state into a local POD, then publish + // it with a single mutexed copy (keeps the critical section tiny). + GamepadSnapshot snapshot; + for (int id = 0; id < GamepadSnapshot::kMaxGamepads; ++id) + { + GamepadSnapshot::Pad& pad = snapshot.pads[id]; + pad.connected = renderController.isGamepadConnected(id); + if (!pad.connected) + continue; + for (int b = 0; b < GamepadSnapshot::kButtonCount; ++b) + pad.buttons[b] = renderController.getGamepadButton(id, static_cast(b)); + for (int a = 0; a < GamepadSnapshot::kAxisCount; ++a) + pad.axes[a] = renderController.getGamepadAxis(id, static_cast(a)); + } + + std::lock_guard lock(mThreadedGamepadMutex); + mThreadedGamepadState = snapshot; +} + +void FrameRunner::feedGamepadInput(Controller& simController) +{ + GamepadSnapshot snapshot; + { + std::lock_guard lock(mThreadedGamepadMutex); + snapshot = mThreadedGamepadState; + } + + for (int id = 0; id < GamepadSnapshot::kMaxGamepads; ++id) + { + const GamepadSnapshot::Pad& pad = snapshot.pads[id]; + const bool wasConnected = simController.isGamepadConnected(id); + + if (pad.connected && !wasConnected) + simController.gamepadConnected(id); + else if (!pad.connected && wasConnected) + simController.gamepadDisconnected(id); + + if (!pad.connected) + continue; + + // Buttons: reconstruct press/release edges by diffing the snapshot against + // the sim controller's current state (activateGamepadButton sets state + queues + // the edge for processInputs() below). + for (int b = 0; b < GamepadSnapshot::kButtonCount; ++b) + { + const GamepadButton button = static_cast(b); + const bool pressed = pad.buttons[b]; + if (pressed != simController.getGamepadButton(id, button)) + simController.activateGamepadButton( + {id, button, pressed ? DiscreteTrigger::Press : DiscreteTrigger::Release}); + } + + // Axes: set unconditionally — updateGamepadAxis fires the axis callback only + // when the value actually changes, so replaying a steady value is a no-op. + for (int a = 0; a < GamepadSnapshot::kAxisCount; ++a) + simController.updateGamepadAxis(id, static_cast(a), pad.axes[a]); + } + + // Dispatch the queued button edges to the game's registered callbacks. + simController.processInputs(); +} + Nothofagus::BellotaId FrameRunner::threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota) { // Sim thread: structural mutation of the (render-shared) asset containers — diff --git a/source/frame_runner.h b/source/frame_runner.h index 1620b951..500d1372 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -179,6 +179,11 @@ class FrameRunner void commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update, std::function uiCallback); + /// Sim thread (M5): like commitFrame above but, before the user update, feeds + /// the given sim controller from the latest gamepad snapshot (no ImGui). + void commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, Controller& simController); + /// Main thread: acquire the latest published snapshot and render it (GPU /// upload + draw + present), poll window/input, and refresh the running flag. void renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller); @@ -203,6 +208,15 @@ class FrameRunner /// (mouse pos/buttons/wheel + display size/scale) into mThreadedImguiInput so /// the sim thread can feed it to the sim-UI context, making widgets interactive. void harvestImguiInput(); + + /// Render thread (M5): snapshot the render controller's normalized gamepad + /// state into mThreadedGamepadState (called after the window poll). + void harvestGamepadInput(Controller& renderController); + + /// Sim thread (M5): replay the latest gamepad snapshot onto the sim controller + /// (diff buttons/connection vs its current state, set axes, then dispatch the + /// queued button edges). Called before the user update. + void feedGamepadInput(Controller& simController); void ensureSessionStarted(Controller& controller); void runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, float deltaTimeMS, std::function update, Controller& controller); @@ -339,6 +353,29 @@ class FrameRunner ThreadedImguiInput mThreadedImguiInput; std::mutex mThreadedImguiInputMutex; + // ----- M5: gamepad input on the sim thread ----- + /// Normalized gamepad state snapshotted from the render controller on the + /// render thread (post window poll) and replayed onto the sim controller on + /// the sim thread, so a game whose logic runs in commit()'s update sees the + /// gamepad. POD (no GLFW): kMaxGamepads mirrors GLFW_JOYSTICK_LAST + 1, the + /// button/axis counts mirror the GamepadButton/GamepadAxis enums (a static + /// assert in the .cpp verifies). Guarded by its mutex. + struct GamepadSnapshot + { + static constexpr int kMaxGamepads = 16; + static constexpr int kButtonCount = 15; + static constexpr int kAxisCount = 6; + struct Pad + { + bool connected{false}; + bool buttons[kButtonCount]{}; + float axes[kAxisCount]{}; + }; + Pad pads[kMaxGamepads]{}; + }; + GamepadSnapshot mThreadedGamepadState; + std::mutex mThreadedGamepadMutex; + /// Set from the sim-UI frame each commit; read by the host's game update (and /// available via Canvas) so world interaction can be suppressed while an ImGui /// widget has focus. One frame stale by construction (game update runs before From 13c74029fbd84feae1393753a50321541272531a Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:59:51 -0700 Subject: [PATCH 11/55] showing stats --- examples/hello_threaded_gamepad.cpp | 5 +++++ source/frame_runner.cpp | 26 ++++++++++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/examples/hello_threaded_gamepad.cpp b/examples/hello_threaded_gamepad.cpp index d10f93d7..2bb7745c 100644 --- a/examples/hello_threaded_gamepad.cpp +++ b/examples/hello_threaded_gamepad.cpp @@ -58,6 +58,11 @@ int main() const Nothofagus::TextureId textureId = canvas.addTexture(texture); const Nothofagus::BellotaId bellotaId = canvas.addBellota({{{100.0f, 75.0f}}, textureId}); + // Show the built-in FPS/ms overlay (render-thread frame time). On the threaded + // path this draws on the main context, which is the rendered UI here since this + // demo commits no sim ImGui. + canvas.stats() = true; + constexpr float horizontalSpeed = 0.12f; constexpr float angularSpeed = 0.1f; constexpr float discreteStep = 10.0f; diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 0f5af84e..fd645ee6 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -669,26 +669,40 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); + // Wall-clock render dt (the actual render-thread frame time), computed before + // the ImGui frame so the stats overlay can show it. + const float now = mWindow->getTime(); + deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; + mLastRenderTime = now; + mLastRenderTimeValid = true; + // Main-context ImGui frame on the render thread (under the ImGui mutex, so it // never touches the shared font atlas concurrently with the sim-UI context). // It does NOT draw user UI (that arrives as a clone) — it (a) drains pending // font ops + lets ImGui_ImplGlfw process window input so we can harvest it for // the sim, and (b) provides valid empty draw data for the frames before the - // first UI commit. + // first UI commit. The optional stats overlay is drawn here on the main + // context; it shows whenever this main frame is what gets rendered (i.e. when + // the sim commits no UI clone — apps with sim ImGui would need stats in the + // clone instead). { ZoneScopedN("RenderImguiNewFrame"); std::lock_guard imguiLock(mImguiMutex); imguiRtt.drainPendingFontOps(); beginMainImguiFrame(); harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame + if (mStats) + { + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Always); + ImGui::Begin("stats", NULL, ImGuiWindowFlags_NoTitleBar); + ImGui::Text("%.2f fps", deltaTimeMS > 0.0f ? 1000.0f / deltaTimeMS : 0.0f); + ImGui::Text("%.2f ms", deltaTimeMS); + ImGui::End(); + } ImGui::Render(); } - const float now = mWindow->getTime(); - deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; - mLastRenderTime = now; - mLastRenderTimeValid = true; - // Container-touching section (deferred frees, GPU upload, id→handle resolve + // draw submission). Guarded against the sim thread's spawn/despawn. The lock // is released BEFORE the vsync swap so a slow present never stalls the sim. From e16648ab27c48ae329e4393a29fac63bf231191c Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:08:30 -0700 Subject: [PATCH 12/55] using performance monitor --- source/frame_runner.cpp | 14 +++++++------- source/frame_runner.h | 8 ++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index fd645ee6..2a130768 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -669,12 +669,11 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); - // Wall-clock render dt (the actual render-thread frame time), computed before - // the ImGui frame so the stats overlay can show it. - const float now = mWindow->getTime(); - deltaTimeMS = mLastRenderTimeValid ? (now - mLastRenderTime) * 1000.0f : 0.0f; - mLastRenderTime = now; - mLastRenderTimeValid = true; + // Smoothed render-thread frame time via the same PerformanceMonitor recipe + // run() uses single-threaded (averaged over its period). Computed before the + // ImGui frame so the stats overlay can show it; also fed to RTT ImGui timing. + mThreadedPerfMonitor->update(mWindow->getTime()); + deltaTimeMS = mThreadedPerfMonitor->getMS(); // Main-context ImGui frame on the render thread (under the ImGui mutex, so it // never touches the shared font atlas concurrently with the sim-UI context). @@ -816,7 +815,8 @@ void FrameRunner::beginThreadedSession(Controller& controller) mSnapshot.draws.reserve(64); mSessionStarted = true; } - mLastRenderTimeValid = false; + // Start the render-loop frame-time monitor (same period as run()'s). + mThreadedPerfMonitor.emplace(mWindow->getTime(), 0.5f); // M3: create the sim-thread UI context, sharing the main font atlas, so the // user's ImGui widgets can run on the sim thread. It has no platform/renderer diff --git a/source/frame_runner.h b/source/frame_runner.h index 500d1372..66bbb7f3 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -8,6 +8,7 @@ #include "bellota_container.h" #include "render_snapshot.h" #include "snapshot_buffers.h" +#include "performance_monitor.h" #include "aa_box.h" #include "backends/render_backend_select.h" #include @@ -313,8 +314,11 @@ class FrameRunner // ----- Threaded driver state (M2 Phase A) ----- SnapshotTripleBuffer mTripleBuffer; ///< sim→render snapshot hand-off (lock-free). std::atomic mThreadedRunning{false}; ///< true while the threaded session is live. - float mLastRenderTime{0.0f}; ///< previous renderFrameThreaded timestamp (for the stats dt). - bool mLastRenderTimeValid{false}; + /// Render-loop frame-time monitor for the threaded path, mirroring the local + /// PerformanceMonitor that run() uses single-threaded: the smoothed getMS() is + /// the dt fed to the stats overlay and to RTT ImGui timing (flushPending), so + /// both paths report the same averaged value. Emplaced in beginThreadedSession. + std::optional mThreadedPerfMonitor; /// Phase B: serializes the sim thread's runtime structural mutations /// (spawn/despawn → mTextures/mMeshes + usage monitors + pending-free queues) From 9e44844bd0747586e36e32c55d7ad2c30aceff0e Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:12:12 -0700 Subject: [PATCH 13/55] adding stats --- examples/test_gamepad.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/test_gamepad.cpp b/examples/test_gamepad.cpp index 607cdaef..66409e3f 100644 --- a/examples/test_gamepad.cpp +++ b/examples/test_gamepad.cpp @@ -34,6 +34,9 @@ int main() Nothofagus::TextureId textureId = canvas.addTexture(texture); Nothofagus::BellotaId bellotaId = canvas.addBellota({{{100.0f, 75.0f}}, textureId}); + // Show the built-in FPS/ms overlay. + canvas.stats() = true; + float time = 0.0f; constexpr float horizontalSpeed = 0.12f; constexpr float angularSpeed = 0.1f; From 645c278c83f79699889e91c83dff79b5079e5852 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:17:01 -0700 Subject: [PATCH 14/55] =?UTF-8?q?The=20duplicated=20bellota-mutation=20sur?= =?UTF-8?q?face=20is=20gone=20=E2=80=94=20there's=20now=20one=20addBellota?= =?UTF-8?q?/removeBellota=20that's=20safe=20in=20both=20single-threaded=20?= =?UTF-8?q?and=20live-threaded-session=20contexts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/hello_threaded.cpp | 17 ++++++------ examples/hello_threaded_imgui.cpp | 4 +-- include/canvas.h | 24 +++++++---------- source/canvas.cpp | 14 ++-------- source/frame_runner.cpp | 44 ++++++++++++++++++++----------- source/frame_runner.h | 16 ++++++----- 6 files changed, 59 insertions(+), 60 deletions(-) diff --git a/examples/hello_threaded.cpp b/examples/hello_threaded.cpp index ec792f74..0d44c015 100644 --- a/examples/hello_threaded.cpp +++ b/examples/hello_threaded.cpp @@ -6,12 +6,13 @@ // // Phase A established the snapshot hand-off with a static scene (the sim only // mutated existing bellota values). Phase B adds runtime structural churn: the -// simulation continuously *spawns and despawns* sprites via -// Canvas::spawnBellota / despawnBellota while the render thread is a frame -// behind. Those calls serialize structural changes against the renderer, and -// the GPU resources a despawn orphans (each sprite's auto-quad mesh) are freed -// by the render thread only once no in-flight frame still references them — so -// there is no use-after-free despite the one-frame lag. +// simulation continuously adds and removes sprites via the regular +// Canvas::addBellota / removeBellota while the render thread is a frame behind. +// Called from commit()'s update during a live threaded session, those serialize +// structural changes against the renderer, and the GPU resources a removal +// orphans (each sprite's auto-quad mesh) are freed by the render thread only +// once no in-flight frame still references them — so there is no use-after-free +// despite the one-frame lag. #include #include @@ -85,7 +86,7 @@ int main() { const float x = rng.range(15.0f, screenSize.width - 15.0f); const float y = rng.range(15.0f, screenSize.height - 15.0f); - const Nothofagus::BellotaId id = canvas.spawnBellota({{{x, y}, 1.0f}, textureId}); + const Nothofagus::BellotaId id = canvas.addBellota({{{x, y}, 1.0f}, textureId}); sprites.push_back({id, glm::vec2(x, y), rng.range(0.0f, 6.28f), 0.0f, rng.range(1500.0f, 4000.0f)}); (void)now; }; @@ -102,7 +103,7 @@ int main() sprite.age += dt; if (sprite.age >= sprite.lifespan) { - canvas.despawnBellota(sprite.id); + canvas.removeBellota(sprite.id); sprite = sprites.back(); sprites.pop_back(); continue; diff --git a/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp index 4ef637af..2d61a816 100644 --- a/examples/hello_threaded_imgui.cpp +++ b/examples/hello_threaded_imgui.cpp @@ -93,7 +93,7 @@ int main() { const float x = rng.range(15.0f, screenSize.width - 15.0f); const float y = rng.range(15.0f, screenSize.height - 15.0f); - const Nothofagus::BellotaId id = canvas.spawnBellota({{{x, y}, 1.0f}, textureId}); + const Nothofagus::BellotaId id = canvas.addBellota({{{x, y}, 1.0f}, textureId}); sprites.push_back({id, glm::vec2(x, y), rng.range(0.0f, 6.28f), 0.0f, rng.range(1500.0f, 4000.0f)}); }; @@ -120,7 +120,7 @@ int main() const bool overTarget = static_cast(i) >= targetCount; if (expired || overTarget) { - canvas.despawnBellota(sprite.id); + canvas.removeBellota(sprite.id); sprite = sprites.back(); sprites.pop_back(); continue; diff --git a/include/canvas.h b/include/canvas.h index 5613867f..2e1d064e 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -643,10 +643,15 @@ class Canvas // The sim thread mutates the scene and commits a snapshot; the main thread // draws the previous snapshot, so render of frame N overlaps sim of N+1. // - // Phase-A constraints: create all textures/meshes/bellotas up front (before - // the threads start); at runtime the sim may only mutate existing bellota - // values (transform, tint, opacity, layer). No ImGui on the threaded path, - // and no runtime resource create/destroy or explorers yet. `run()`/`tick()` + // From inside `commit()`'s update (sim thread) you may: mutate existing bellota + // values (transform, tint, opacity, layer) and add/remove bellotas at runtime + // via the regular `addBellota`/`removeBellota` (they serialize against the + // renderer and defer GPU frees while a threaded session is live — see those + // methods). Interactive ImGui runs via the `commit(dt, update, uiCallback)` + // overload (widgets on the sim thread, cloned to the render thread); gamepad + // input via `commit(dt, update, simController)`. Still single-threaded-only: + // explorers (Dense/Sparse land) and other structural resource ops + // (textures/meshes/render targets) — create those up front. `run()`/`tick()` // remain the unrestricted single-threaded path. /// Main thread: start a threaded session (binds input, marks it running). @@ -678,17 +683,6 @@ class Canvas /// Main thread: render the latest published snapshot and pump window/input. void renderFrame(Controller& controller); - /// Sim thread: add a bellota at runtime on the threaded path. Unlike - /// `addBellota`, this is safe to call from `commit()`'s update while the main - /// thread renders — it serializes structural changes against the renderer. - /// Returns the new id (usable immediately for value mutation via `bellota()`). - BellotaId spawnBellota(const Bellota& bellota); - - /// Sim thread: remove a bellota at runtime on the threaded path. The GPU - /// resources it orphans are freed by the render thread once no in-flight - /// frame still references them (deferred free) — no use-after-free. - void despawnBellota(BellotaId bellotaId); - /// Whether the threaded ImGui UI captured the mouse / keyboard on the most /// recent commit. Read these in the game `update` (which runs before the UI /// frame) to skip world interaction while the UI is using that input. diff --git a/source/canvas.cpp b/source/canvas.cpp index bb7e563a..7e3c46f1 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -149,13 +149,13 @@ void Canvas::setContentScaleOverride(std::optional scale) { mImplPtr->fra // Bellotas — forward to AssetRegistry; remove gates against DenseLandExplorer pool // --------------------------------------------------------------------------- -BellotaId Canvas::addBellota(const Bellota& bellota) { return mImplPtr->assets.addBellota(bellota); } +BellotaId Canvas::addBellota(const Bellota& bellota) { return mImplPtr->frameRunner.addBellota(mImplPtr->assets, bellota); } void Canvas::removeBellota(const BellotaId bellotaId) { debugCheck(!mImplPtr->frameRunner.isExplorerManagedBellota(bellotaId.id), "Bellota is owned by an explorer pool — use canvas.removeDenseLandExplorer() / canvas.removeSparseLandExplorer() instead of removing slot bellotas directly."); - mImplPtr->assets.removeBellota(bellotaId); + mImplPtr->frameRunner.removeBellota(mImplPtr->assets, bellotaId); } Bellota& Canvas::bellota(BellotaId bellotaId) { return mImplPtr->assets.bellota(bellotaId); } @@ -424,16 +424,6 @@ void Canvas::renderFrame(Controller& controller) mImplPtr->frameRunner.renderFrameThreaded(mImplPtr->assets, mImplPtr->imguiRtt, controller); } -BellotaId Canvas::spawnBellota(const Bellota& bellota) -{ - return mImplPtr->frameRunner.threadedSpawnBellota(mImplPtr->assets, bellota); -} - -void Canvas::despawnBellota(BellotaId bellotaId) -{ - mImplPtr->frameRunner.threadedDespawnBellota(mImplPtr->assets, bellotaId); -} - bool Canvas::imguiWantsMouse() const { return mImplPtr->frameRunner.threadedWantsMouse(); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 2b0b2fe4..8cebb55f 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -1038,28 +1038,40 @@ void FrameRunner::feedGamepadInput(Controller& simController) simController.processInputs(); } -Nothofagus::BellotaId FrameRunner::threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota) +Nothofagus::BellotaId FrameRunner::addBellota(AssetRegistry& assets, const Bellota& bellota) { - // Sim thread: structural mutation of the (render-shared) asset containers — - // adds the bellota plus its auto-quad mesh and registers usage entries. The - // mutex serializes this against the render thread's container access. - std::lock_guard lock(mThreadedAssetMutex); + // Structural mutation of the asset containers (adds the bellota plus its + // auto-quad mesh and registers usage entries). When a threaded session is live + // this may run on the sim thread concurrently with the render thread's + // container access, so take the asset mutex; single-threaded (run/tick or + // setup) there is no other party and we skip the lock entirely. + std::unique_lock lock; + if (mThreadedRunning.load(std::memory_order_acquire)) + lock = std::unique_lock(mThreadedAssetMutex); return assets.addBellota(bellota); } -void FrameRunner::threadedDespawnBellota(AssetRegistry& assets, BellotaId bellotaId) +void FrameRunner::removeBellota(AssetRegistry& assets, BellotaId bellotaId) { - // Sim thread: remove the bellota, then detect any texture/mesh it orphaned - // (typically its auto-quad mesh) and queue them for deferred GPU free. The - // actual free happens on the render thread once no in-flight snapshot still - // references them (retireSeq <= lastRenderedSeq). Tag with the current commit - // seq: the snapshot built this commit no longer references the removed bellota. - std::lock_guard lock(mThreadedAssetMutex); + // In a live threaded session: take the asset mutex, remove, then detect any + // texture/mesh the bellota orphaned (typically its auto-quad mesh) and queue + // them for deferred GPU free — freed render-side once no in-flight snapshot + // still references them (retireSeq <= lastRenderedSeq). Tag with the current + // commit seq: the snapshot built this commit no longer references the removed + // bellota. + if (mThreadedRunning.load(std::memory_order_acquire)) + { + std::lock_guard lock(mThreadedAssetMutex); + assets.removeBellota(bellotaId); + for (TextureId textureId : assets.collectUnusedTextures()) + mPendingTextureFrees.push_back({textureId, mCommitSeq}); + for (MeshId meshId : assets.collectUnusedMeshes()) + mPendingMeshFrees.push_back({meshId, mCommitSeq}); + return; + } + // Single-threaded: plain remove; orphaned resources are GC'd by the + // produce(Single) collectUnused* pass on the next frame (unchanged behavior). assets.removeBellota(bellotaId); - for (TextureId textureId : assets.collectUnusedTextures()) - mPendingTextureFrees.push_back({textureId, mCommitSeq}); - for (MeshId meshId : assets.collectUnusedMeshes()) - mPendingMeshFrees.push_back({meshId, mCommitSeq}); } ScreenSize getPrimaryMonitorSize() diff --git a/source/frame_runner.h b/source/frame_runner.h index 41bf2fe2..80f4c647 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -192,13 +192,15 @@ class FrameRunner /// upload + draw + present), poll window/input, and refresh the running flag. void renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller); - /// Sim thread (Phase B): add a bellota at runtime, guarded against the render - /// thread's container access. Returns the new id. - BellotaId threadedSpawnBellota(AssetRegistry& assets, const Bellota& bellota); - - /// Sim thread (Phase B): remove a bellota at runtime and queue any resources - /// it orphaned for deferred GPU free on the render thread. - void threadedDespawnBellota(AssetRegistry& assets, BellotaId bellotaId); + /// Add a bellota. Safe both single-threaded (run/tick or setup — no lock) and + /// from inside commit()'s update on the sim thread while a threaded session is + /// live (takes the asset mutex). Returns the new id. + BellotaId addBellota(AssetRegistry& assets, const Bellota& bellota); + + /// Remove a bellota. In a live threaded session this also queues any resources + /// it orphaned for deferred GPU free on the render thread; single-threaded the + /// orphans are GC'd by the next produce(Single) pass. + void removeBellota(AssetRegistry& assets, BellotaId bellotaId); private: /// Selects which orchestration a unified producer/consumer runs. `Single` is From 6b5d32aaf7f0b627c2b7ee3770ce4a933c3134dd Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:13:46 -0700 Subject: [PATCH 15/55] keyboard + mouse game input now reaches the sim thread, mirroring the M5 gamepad path. --- examples/hello_threaded_gamepad.cpp | 42 +++++++++++---- include/controller.h | 17 ++++++ source/controller.cpp | 26 +++++++++ source/frame_runner.cpp | 82 ++++++++++++++++++++++++++--- source/frame_runner.h | 27 ++++++++++ 5 files changed, 179 insertions(+), 15 deletions(-) diff --git a/examples/hello_threaded_gamepad.cpp b/examples/hello_threaded_gamepad.cpp index 2bb7745c..c3f9b861 100644 --- a/examples/hello_threaded_gamepad.cpp +++ b/examples/hello_threaded_gamepad.cpp @@ -1,4 +1,4 @@ -// hello_threaded_gamepad — gamepad input on the sim thread (M5). +// hello_threaded_gamepad — game input (gamepad + keyboard + mouse) on the sim thread. // // The game logic runs on its own std::thread (inside canvas.commit's update); // the main thread renders the previous frame's snapshot. nothofagus spawns no @@ -9,15 +9,18 @@ // -> close). The window backend polls the OS input — keyboard/mouse/gamepad — // into it on the render thread. // * a SIM controller, the game's own. nothofagus marshals the render -// controller's normalized gamepad state across the thread boundary and -// replays it onto the sim controller right before each commit's update, so -// the game can poll it (getGamepadAxis/...) and receive its callbacks -// (registerGamepadAction/...) the normal way — all on the sim thread. +// controller's input — gamepad state (M5) plus held keyboard/mouse state and +// per-frame scroll — across the thread boundary and replays it onto the sim +// controller right before each commit's update, so the game can poll it +// (getGamepadAxis / isKeyDown / isMouseButtonDown / getMousePosition) and +// receive its callbacks (registerGamepadAction / registerAction / ...) the +// normal way — all on the sim thread. // -// This mirrors examples/test_gamepad.cpp, but on the threaded driver. Gamepad -// input is one frame stale by construction (same uniform sim<-render latency as -// the rest of the threaded path). Real stick input needs a connected pad; with -// none, the marshal/feed runs every frame as a clean no-op. +// Controls: left stick or WASD move the sprite; hold left mouse to ease it toward +// the cursor; gamepad Start or the R key toggle rotation; A pulses; D-pad steps. +// Input is one frame stale by construction (the uniform sim<-render latency). +// Real stick input needs a connected pad; with none, the marshal/feed runs every +// frame as a clean no-op. #include #include @@ -87,6 +90,11 @@ int main() simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadRight, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().x += discreteStep; }); + // Keyboard (R) toggles rotation too — a registered key action on the sim + // controller, dispatched on the sim thread (parity with the gamepad Start). + simController.registerAction({Nothofagus::Key::R, Nothofagus::DiscreteTrigger::Press}, + [&]() { rotate = not rotate; }); + auto update = [&](float deltaTime) { Nothofagus::Bellota& bellota = canvas.bellota(bellotaId); @@ -102,6 +110,22 @@ int main() bellota.transform().location().y += leftY * horizontalSpeed * deltaTime; } + // Keyboard WASD movement (polling the sim controller's held key state). + glm::vec2 keyboardMove{0.0f, 0.0f}; + if (simController.isKeyDown(Nothofagus::Key::W)) keyboardMove.y += 1.0f; + if (simController.isKeyDown(Nothofagus::Key::S)) keyboardMove.y -= 1.0f; + if (simController.isKeyDown(Nothofagus::Key::D)) keyboardMove.x += 1.0f; + if (simController.isKeyDown(Nothofagus::Key::A)) keyboardMove.x -= 1.0f; + bellota.transform().location() += keyboardMove * horizontalSpeed * deltaTime; + + // Mouse: while the left button is held, ease the sprite toward the cursor + // (mouse position arrives in canvas space, like the single-threaded path). + if (simController.isMouseButtonDown(Nothofagus::MouseButton::Left)) + { + const glm::vec2 cursor = simController.getMousePosition(); + bellota.transform().location() += (cursor - bellota.transform().location()) * 0.01f * deltaTime; + } + bellota.transform().location().x = std::clamp(bellota.transform().location().x, 10.0f, static_cast(screenSize.width) - 10.0f); bellota.transform().location().y = std::clamp(bellota.transform().location().y, 10.0f, static_cast(screenSize.height) - 10.0f); diff --git a/include/controller.h b/include/controller.h index 91a37256..34d074d6 100644 --- a/include/controller.h +++ b/include/controller.h @@ -190,6 +190,16 @@ class Controller glm::vec2 getMousePosition() const; + // Keyboard / mouse polling — current held state (parity with the gamepad + // getters). Updated by the activate*/scrolled feed primitives. + bool isKeyDown(Key key) const; + bool isMouseButtonDown(MouseButton button) const; + + /// Return the scroll offset accumulated since the previous call and reset it + /// to zero. Used by the threaded input marshal to forward per-frame scroll to + /// the sim controller; single-threaded apps typically use registerMouseScroll. + glm::vec2 consumeScroll(); + // Gamepad registration bool registerGamepadAction(GamepadButtonTrigger trigger, Action action); bool deleteGamepadAction(GamepadButtonTrigger trigger); @@ -231,6 +241,13 @@ class Controller std::optional> mMouseScrollCallback; glm::vec2 mMousePosition{0.0f, 0.0f}; + // Held-state mirrors of keyboard/mouse, updated by activate*/scrolled so the + // state is pollable (isKeyDown/isMouseButtonDown) and harvestable by the + // threaded input marshal. mAccumulatedScroll sums offsets until consumeScroll(). + std::bitset(Key::SIZEOF)> mKeyDown; + bool mMouseButtonDown[3]{false, false, false}; + glm::vec2 mAccumulatedScroll{0.0f, 0.0f}; + GamepadTriggerActions mGamepadTriggerActions; ActiveGamepadActions mActiveGamepadActions; GamepadAxisCallbacks mGamepadAxisCallbacks; diff --git a/source/controller.cpp b/source/controller.cpp index 83db6e1c..abee4b83 100644 --- a/source/controller.cpp +++ b/source/controller.cpp @@ -43,6 +43,23 @@ glm::vec2 Controller::getMousePosition() const return mMousePosition; } +bool Controller::isKeyDown(Key key) const +{ + return mKeyDown.test(static_cast(key)); +} + +bool Controller::isMouseButtonDown(MouseButton button) const +{ + return mMouseButtonDown[static_cast(button)]; +} + +glm::vec2 Controller::consumeScroll() +{ + const glm::vec2 accumulated = mAccumulatedScroll; + mAccumulatedScroll = glm::vec2(0.0f, 0.0f); + return accumulated; +} + void Controller::processInputs() { // processing all the keyboard inputs since the last update @@ -87,11 +104,15 @@ void Controller::processInputs() void Controller::activate(KeyboardTrigger keyboardTrigger) { + mKeyDown.set(static_cast(keyboardTrigger.key), + keyboardTrigger.trigger == DiscreteTrigger::Press); mActiveActions.push_back(keyboardTrigger); } void Controller::activateMouseButton(MouseButtonTrigger mouseButtonTrigger) { + mMouseButtonDown[static_cast(mouseButtonTrigger.button)] = + (mouseButtonTrigger.trigger == DiscreteTrigger::Press); mActiveMouseActions.push_back(mouseButtonTrigger); } @@ -110,6 +131,8 @@ void Controller::registerMouseScroll(std::function callback) void Controller::scrolled(glm::vec2 offset) { + mAccumulatedScroll += offset; + if (mMouseScrollCallback.has_value()) (*mMouseScrollCallback)(offset); } @@ -215,6 +238,9 @@ void Controller::clear() mActiveMouseActions.clear(); mMouseMoveCallback.reset(); mMouseScrollCallback.reset(); + mKeyDown.reset(); + mMouseButtonDown[0] = mMouseButtonDown[1] = mMouseButtonDown[2] = false; + mAccumulatedScroll = glm::vec2(0.0f, 0.0f); mGamepadTriggerActions.clear(); mActiveGamepadActions.clear(); mGamepadAxisCallbacks.clear(); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 8cebb55f..143a4b1f 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -354,12 +354,14 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset // resources with the commit at which they leave the scene. const std::uint64_t commitSeq = ++mCommitSeq; - // M5: feed the sim controller from the latest gamepad snapshot (harvested - // render-side after the window poll) so the game `update` sees the gamepad. + // Feed the sim controller from the latest input snapshots (harvested + // render-side after the window poll) so the game `update` sees gamepad, + // keyboard, and mouse. if (controller != nullptr) { - ZoneScopedN("FeedGamepad"); + ZoneScopedN("FeedInput"); feedGamepadInput(*controller); + feedGameInput(*controller); } // 1) Game logic — lock-free, so it overlaps the render thread's sprite work. @@ -746,11 +748,12 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager mWindow->endFrame(controller, mScreenSize); } - // M5: snapshot the render controller's freshly-polled gamepad state for the - // sim thread to replay onto its own controller next commit. + // Snapshot the render controller's freshly-polled input (gamepad + keyboard + // + mouse) for the sim thread to replay onto its own controller next commit. { - ZoneScopedN("HarvestGamepad"); + ZoneScopedN("HarvestInput"); harvestGamepadInput(controller); + harvestGameInput(controller); } mThreadedRunning.store(mWindow->isRunning(), std::memory_order_release); @@ -1038,6 +1041,73 @@ void FrameRunner::feedGamepadInput(Controller& simController) simController.processInputs(); } +void FrameRunner::harvestGameInput(Controller& renderController) +{ + // Read the render controller's held keyboard/mouse state + per-frame scroll + // into a local, then publish under the mutex. Scroll is a DELTA, so accumulate + // it into the shared state (the sim resets it on consume) rather than overwrite. + GameInputSnapshot local; + for (std::size_t k = 0; k < GameInputSnapshot::kKeyCount; ++k) + local.keyDown[k] = renderController.isKeyDown(static_cast(k)); + for (std::size_t b = 0; b < 3; ++b) + local.mouseDown[b] = renderController.isMouseButtonDown(static_cast(b)); + const glm::vec2 mousePos = renderController.getMousePosition(); + local.mouseX = mousePos.x; + local.mouseY = mousePos.y; + const glm::vec2 scroll = renderController.consumeScroll(); + + std::lock_guard lock(mThreadedGameInputMutex); + std::copy(std::begin(local.keyDown), std::end(local.keyDown), std::begin(mThreadedGameInputState.keyDown)); + std::copy(std::begin(local.mouseDown), std::end(local.mouseDown), std::begin(mThreadedGameInputState.mouseDown)); + mThreadedGameInputState.mouseX = local.mouseX; + mThreadedGameInputState.mouseY = local.mouseY; + mThreadedGameInputState.scrollX += scroll.x; + mThreadedGameInputState.scrollY += scroll.y; +} + +void FrameRunner::feedGameInput(Controller& simController) +{ + GameInputSnapshot snapshot; + { + std::lock_guard lock(mThreadedGameInputMutex); + snapshot = mThreadedGameInputState; + mThreadedGameInputState.scrollX = 0.0f; // consume accumulated scroll + mThreadedGameInputState.scrollY = 0.0f; + } + + // Keyboard: reconstruct press/release edges by diffing the snapshot against the + // sim controller's held state (activate sets state + queues the edge). + for (std::size_t k = 0; k < GameInputSnapshot::kKeyCount; ++k) + { + const Key key = static_cast(k); + const bool down = snapshot.keyDown[k]; + if (down != simController.isKeyDown(key)) + simController.activate({key, down ? DiscreteTrigger::Press : DiscreteTrigger::Release}); + } + + // Mouse buttons: same edge reconstruction. + for (std::size_t b = 0; b < 3; ++b) + { + const MouseButton button = static_cast(b); + const bool down = snapshot.mouseDown[b]; + if (down != simController.isMouseButtonDown(button)) + simController.activateMouseButton({button, down ? DiscreteTrigger::Press : DiscreteTrigger::Release}); + } + + // Mouse position (already canvas-space): only on change, to match the + // single-threaded path (the backend updates it per move event, not per frame). + const glm::vec2 newPos(snapshot.mouseX, snapshot.mouseY); + if (newPos != simController.getMousePosition()) + simController.updateMousePosition(newPos); + + // Scroll: forward this frame's accumulated delta (fires the scroll callback). + if (snapshot.scrollX != 0.0f || snapshot.scrollY != 0.0f) + simController.scrolled(glm::vec2(snapshot.scrollX, snapshot.scrollY)); + + // Dispatch the queued key / mouse-button edges to the game's callbacks. + simController.processInputs(); +} + Nothofagus::BellotaId FrameRunner::addBellota(AssetRegistry& assets, const Bellota& bellota) { // Structural mutation of the asset containers (adds the bellota plus its diff --git a/source/frame_runner.h b/source/frame_runner.h index 80f4c647..0ecf0ab6 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -223,6 +223,15 @@ class FrameRunner /// (diff buttons/connection vs its current state, set axes, then dispatch the /// queued button edges). Called before the user update. void feedGamepadInput(Controller& simController); + + /// Render thread: snapshot the render controller's held keyboard/mouse state + + /// per-frame scroll into mThreadedGameInputState (called after the window poll). + void harvestGameInput(Controller& renderController); + + /// Sim thread: replay the latest keyboard/mouse snapshot onto the sim controller + /// (diff key/button state vs its current state → press/release edges, set mouse + /// position, forward scroll, then dispatch the queued edges). Before the update. + void feedGameInput(Controller& simController); void ensureSessionStarted(Controller& controller); void runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, ImguiImageManager& imguiImages, @@ -391,6 +400,24 @@ class FrameRunner GamepadSnapshot mThreadedGamepadState; std::mutex mThreadedGamepadMutex; + // ----- Keyboard + mouse game input on the sim thread ----- + /// Held keyboard/mouse state snapshotted from the render controller on the + /// render thread (post window poll) and replayed onto the sim controller before + /// the sim update, so a game running in commit()'s update can poll/receive + /// keyboard + mouse the normal way. POD; mouse position is in canvas space + /// (already converted render-side); scroll is the per-frame accumulated delta + /// (consumed from the render controller). Guarded by its mutex. + struct GameInputSnapshot + { + static constexpr std::size_t kKeyCount = static_cast(Key::SIZEOF); + bool keyDown[kKeyCount]{}; + bool mouseDown[3]{false, false, false}; + float mouseX{0.0f}, mouseY{0.0f}; + float scrollX{0.0f}, scrollY{0.0f}; + }; + GameInputSnapshot mThreadedGameInputState; + std::mutex mThreadedGameInputMutex; + /// Set from the sim-UI frame each commit; read by the host's game update (and /// available via Canvas) so world interaction can be suppressed while an ImGui /// widget has focus. One frame stale by construction (game update runs before From d45063c844119cd7dee973cd1fc3b152be45a2a5 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:40:51 -0700 Subject: [PATCH 16/55] textures, meshes, and render targets can now be created/destroyed from the sim thread during a live threaded session. --- examples/hello_threaded.cpp | 42 ++++++++++---- source/asset_registry.cpp | 17 ++++++ source/asset_registry.h | 20 +++++++ source/canvas.cpp | 26 ++++++--- source/frame_runner.cpp | 111 +++++++++++++++++++++++++++++++++++- source/frame_runner.h | 32 +++++++++-- 6 files changed, 220 insertions(+), 28 deletions(-) diff --git a/examples/hello_threaded.cpp b/examples/hello_threaded.cpp index 0d44c015..62d27655 100644 --- a/examples/hello_threaded.cpp +++ b/examples/hello_threaded.cpp @@ -5,20 +5,24 @@ // nothofagus spawns no threads — this file owns both loops. // // Phase A established the snapshot hand-off with a static scene (the sim only -// mutated existing bellota values). Phase B adds runtime structural churn: the -// simulation continuously adds and removes sprites via the regular -// Canvas::addBellota / removeBellota while the render thread is a frame behind. -// Called from commit()'s update during a live threaded session, those serialize -// structural changes against the renderer, and the GPU resources a removal -// orphans (each sprite's auto-quad mesh) are freed by the render thread only -// once no in-flight frame still references them — so there is no use-after-free -// despite the one-frame lag. +// mutated existing bellota values). Later phases add runtime structural churn: +// from commit()'s update during a live threaded session the simulation +// continuously creates and destroys resources via the regular Canvas API — +// bellotas (addBellota/removeBellota) with a per-sprite texture (addTexture), and +// a rotating set of render targets (addRenderTarget/removeRenderTarget) — while +// the render thread is a frame behind. Those serialize structural changes against +// the renderer, and the GPU resources a removal frees (the sprite's texture + +// auto-quad mesh, or the RTT's FBO/proxy/ImGui context) are released by the +// render thread only once no in-flight frame still references them — so there is +// no use-after-free despite the one-frame lag. #include #include #include #include +#include #include +#include #include #include @@ -65,10 +69,6 @@ int main() 0,1,2,2,2,2,1,0, 0,0,1,1,1,1,0,0, }); - // Texture is created up front (uploaded once on the render thread); only - // bellotas (and their auto-quad meshes) churn at runtime. - const Nothofagus::TextureId textureId = canvas.addTexture(texture); - struct Sprite { Nothofagus::BellotaId id; @@ -82,15 +82,24 @@ int main() constexpr std::size_t targetCount = 60; + // Each sprite gets its OWN texture, created on the sim thread via addBellota's + // sibling addTexture and freed (deferred) when its bellota is removed — so + // textures churn on the sim thread too, not just auto-quad meshes. auto spawnOne = [&](float now) { const float x = rng.range(15.0f, screenSize.width - 15.0f); const float y = rng.range(15.0f, screenSize.height - 15.0f); + const Nothofagus::TextureId textureId = canvas.addTexture(texture); const Nothofagus::BellotaId id = canvas.addBellota({{{x, y}, 1.0f}, textureId}); sprites.push_back({id, glm::vec2(x, y), rng.range(0.0f, 6.28f), 0.0f, rng.range(1500.0f, 4000.0f)}); (void)now; }; + // Render-target churn — create/destroy RTTs from the sim thread to exercise + // the deferred render-target free (FBO/VkImage + proxy texture + per-RTT ImGui + // context, all torn down on the render thread once no in-flight frame uses them). + std::deque> scratchRenderTargets; + float simTime = 0.0f; auto update = [&](float dt) { @@ -123,6 +132,15 @@ int main() int budget = 3; while (sprites.size() < targetCount && budget-- > 0) spawnOne(simTime); + + // Render-target churn: spawn one roughly every 100 ms, retire after ~400 ms. + if (scratchRenderTargets.empty() || simTime - scratchRenderTargets.back().second > 100.0f) + scratchRenderTargets.push_back({canvas.addRenderTarget({32, 32}), simTime}); + while (!scratchRenderTargets.empty() && simTime - scratchRenderTargets.front().second > 400.0f) + { + canvas.removeRenderTarget(scratchRenderTargets.front().first); + scratchRenderTargets.pop_front(); + } }; Nothofagus::Controller controller; diff --git a/source/asset_registry.cpp b/source/asset_registry.cpp index ac4e7d77..06b0102f 100644 --- a/source/asset_registry.cpp +++ b/source/asset_registry.cpp @@ -205,6 +205,14 @@ void AssetRegistry::freeRetiredTexture(TextureId textureId) mTextures.remove(textureId.id); } +bool AssetRegistry::retireTexture(TextureId textureId) +{ + // Tolerant: returns false if the texture was already swept (by a prior + // removeBellota's collectUnusedTextures) or is still referenced. GPU free + + // container erase happen later in freeRetiredTexture() only if true. + return mTextureUsageMonitor.removeUnused(textureId); +} + // --------------------------------------------------------------------------- // Meshes // --------------------------------------------------------------------------- @@ -322,6 +330,15 @@ void AssetRegistry::freeRetiredMesh(MeshId meshId) mMeshes.remove(meshId.id); } +bool AssetRegistry::retireMesh(MeshId meshId) +{ + debugCheck(mMeshes.contains(meshId.id), "retireMesh: unknown MeshId"); + debugCheck(not mMeshes.at(meshId.id).isAutoQuad, + "retireMesh: cannot remove an engine-allocated auto-quad — it is owned by the canvas"); + // Tolerant (see retireTexture): false if already swept or still referenced. + return mMeshUsageMonitor.removeUnused(meshId); +} + // --------------------------------------------------------------------------- // Render targets // --------------------------------------------------------------------------- diff --git a/source/asset_registry.h b/source/asset_registry.h index c96bc2c8..9ae1f6a2 100644 --- a/source/asset_registry.h +++ b/source/asset_registry.h @@ -74,6 +74,18 @@ class AssetRegistry /// already done by collectUnusedTextures(). void freeRetiredTexture(TextureId textureId); + /// Threaded explicit-remove counterpart to removeTexture(): does only the + /// usage-monitor bookkeeping (removes the texture from the unused set) and + /// leaves GPU free + container erase to a later freeRetiredTexture() once no + /// in-flight snapshot references it. Returns true if the texture was in the + /// unused set (and so should be queued for deferred free); false if it was + /// already retired/swept (e.g. a prior removeBellota's collectUnusedTextures + /// already collected it) or is still referenced — in both cases the caller + /// must NOT queue it (avoids a double free). Tolerant by design, so the + /// removeBellota-sweep + explicit-removeTexture ordering (explorer teardown) + /// is safe. + bool retireTexture(TextureId textureId); + // ---------- Meshes ---------- MeshId addMesh(const Mesh& mesh); MeshId addMesh(Mesh&& mesh); @@ -94,6 +106,14 @@ class AssetRegistry /// it from the container. Does not touch the usage monitor. void freeRetiredMesh(MeshId meshId); + /// Threaded explicit-remove counterpart to removeMesh(): keeps the removeMesh + /// asserts (known MeshId, not an auto-quad) but does only the usage-monitor + /// bookkeeping, leaving GPU free + container erase to a later freeRetiredMesh(). + /// Returns true if the mesh was in the unused set (queue it for deferred free); + /// false if already retired/swept or still referenced (do NOT queue). Tolerant, + /// mirroring retireTexture(). + bool retireMesh(MeshId meshId); + // ---------- Render targets ---------- RenderTargetId addRenderTarget(ScreenSize size); void removeRenderTarget(RenderTargetId renderTargetId); diff --git a/source/canvas.cpp b/source/canvas.cpp index 7e3c46f1..7c4345c5 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -167,16 +167,16 @@ void Canvas::removeTint(const BellotaId bellotaId) { mImplPtr->asse // Textures — forward to AssetRegistry; remove gates against DenseLandExplorer pool // --------------------------------------------------------------------------- -TextureId Canvas::addTexture(const Texture& texture) { return mImplPtr->assets.addTexture(texture); } +TextureId Canvas::addTexture(const Texture& texture) { return mImplPtr->frameRunner.addTexture(mImplPtr->assets, texture); } void Canvas::removeTexture(const TextureId textureId) { debugCheck(!mImplPtr->frameRunner.isExplorerManagedTexture(textureId.id), "Texture is owned by an explorer pool — use canvas.removeDenseLandExplorer() / canvas.removeSparseLandExplorer() instead of removing slot textures directly."); - mImplPtr->assets.removeTexture(textureId); + mImplPtr->frameRunner.removeTexture(mImplPtr->assets, textureId); } -void Canvas::setTexture(const BellotaId bellotaId, const TextureId textureId) { mImplPtr->assets.setTexture(bellotaId, textureId); } +void Canvas::setTexture(const BellotaId bellotaId, const TextureId textureId) { mImplPtr->frameRunner.setTexture(mImplPtr->assets, bellotaId, textureId); } void Canvas::markTextureAsDirty(const TextureId textureId) { mImplPtr->assets.markTextureAsDirty(textureId); } void Canvas::setTextureMinFilter(const TextureId textureId, TextureSampleMode mode) { mImplPtr->assets.setTextureMinFilter(textureId, mode); } void Canvas::setTextureMagFilter(const TextureId textureId, TextureSampleMode mode) { mImplPtr->assets.setTextureMagFilter(textureId, mode); } @@ -187,10 +187,10 @@ const Texture& Canvas::texture(TextureId textureId) const // Meshes — forward to AssetRegistry // --------------------------------------------------------------------------- -MeshId Canvas::addMesh(const Mesh& mesh) { return mImplPtr->assets.addMesh(mesh); } -MeshId Canvas::addMesh(Mesh&& mesh) { return mImplPtr->assets.addMesh(std::move(mesh)); } -void Canvas::removeMesh(MeshId meshId) { mImplPtr->assets.removeMesh(meshId); } -void Canvas::setMesh(const BellotaId bellotaId, const MeshId meshId) { mImplPtr->assets.setMesh(bellotaId, meshId); } +MeshId Canvas::addMesh(const Mesh& mesh) { return mImplPtr->frameRunner.addMesh(mImplPtr->assets, mesh); } +MeshId Canvas::addMesh(Mesh&& mesh) { return mImplPtr->frameRunner.addMesh(mImplPtr->assets, std::move(mesh)); } +void Canvas::removeMesh(MeshId meshId) { mImplPtr->frameRunner.removeMesh(mImplPtr->assets, meshId); } +void Canvas::setMesh(const BellotaId bellotaId, const MeshId meshId) { mImplPtr->frameRunner.setMesh(mImplPtr->assets, bellotaId, meshId); } const Mesh& Canvas::mesh(MeshId meshId) const { return mImplPtr->assets.mesh(meshId); } const Mesh& Canvas::mesh(BellotaId bellotaId) const { return mImplPtr->assets.mesh(bellotaId); } @@ -200,16 +200,24 @@ const Mesh& Canvas::mesh(BellotaId bellotaId) const // render pass / FBO that the registry is about to free). // --------------------------------------------------------------------------- -RenderTargetId Canvas::addRenderTarget(ScreenSize size) { return mImplPtr->assets.addRenderTarget(size); } +RenderTargetId Canvas::addRenderTarget(ScreenSize size) { return mImplPtr->frameRunner.addRenderTarget(mImplPtr->assets, size); } void Canvas::removeRenderTarget(RenderTargetId renderTargetId) { + // During a live threaded session the GPU free AND the per-RTT ImGui-context + // teardown are deferred to the render thread (drainPendingFrees handles both); + // single-threaded they happen immediately here. + if (mImplPtr->frameRunner.threadedRunning()) + { + mImplPtr->frameRunner.removeRenderTarget(mImplPtr->assets, renderTargetId); + return; + } mImplPtr->imguiRtt.releaseContext(renderTargetId); mImplPtr->assets.removeRenderTarget(renderTargetId); } TextureId Canvas::renderTargetTexture(RenderTargetId renderTargetId) const { return mImplPtr->assets.renderTargetTexture(renderTargetId); } -void Canvas::setRenderTargetClearColor(RenderTargetId renderTargetId, glm::vec4 clearColor) { mImplPtr->assets.setRenderTargetClearColor(renderTargetId, clearColor); } +void Canvas::setRenderTargetClearColor(RenderTargetId renderTargetId, glm::vec4 clearColor) { mImplPtr->frameRunner.setRenderTargetClearColor(mImplPtr->assets, renderTargetId, clearColor); } // --------------------------------------------------------------------------- // DenseLands — forward to FrameRunner (ExplorerManager lives there) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 143a4b1f..45837095 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -555,7 +555,7 @@ void FrameRunner::buildRttPasses(AssetRegistry& assets, std::vector& ou mPendingRttPasses.clear(); } -void FrameRunner::drainPendingFrees(AssetRegistry& assets, std::uint64_t lastRenderedSeq) +void FrameRunner::drainPendingFrees(AssetRegistry& assets, ImguiRttManager& imguiRtt, std::uint64_t lastRenderedSeq) { std::erase_if(mPendingTextureFrees, [&](const PendingTextureFree& pending) { @@ -575,6 +575,19 @@ void FrameRunner::drainPendingFrees(AssetRegistry& assets, std::uint64_t lastRen } return false; }); + std::erase_if(mPendingRenderTargetFrees, [&](const PendingRenderTargetFree& pending) + { + if (pending.retireSeq <= lastRenderedSeq) + { + // Tear down the per-RTT ImGui secondary context (GPU + render-thread + // affine) first, then free the FBO/VkImage + proxy texture and erase + // both containers. Safe here on the render thread under the asset mutex. + imguiRtt.releaseContext(pending.id); + assets.removeRenderTarget(pending.id); + return true; + } + return false; + }); } void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& imguiRtt, @@ -592,7 +605,7 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& // about to render. Single-threaded, that snapshot is the latest commit so // frees happen this frame; threaded, the gate genuinely defers (render lags). mLastRenderedSeq = snapshot.commitSeq; - drainPendingFrees(assets, mLastRenderedSeq); + drainPendingFrees(assets, imguiRtt, mLastRenderedSeq); { ZoneScopedN("TextureUpload"); @@ -1144,6 +1157,100 @@ void FrameRunner::removeBellota(AssetRegistry& assets, BellotaId bellotaId) assets.removeBellota(bellotaId); } +std::unique_lock FrameRunner::lockAssetsIfThreaded() +{ + if (mThreadedRunning.load(std::memory_order_acquire)) + return std::unique_lock(mThreadedAssetMutex); + return std::unique_lock(); +} + +TextureId FrameRunner::addTexture(AssetRegistry& assets, const Texture& texture) +{ + auto lock = lockAssetsIfThreaded(); + return assets.addTexture(texture); // GPU upload is lazy (syncToGpu), so add is sim-safe. +} + +void FrameRunner::removeTexture(AssetRegistry& assets, TextureId textureId) +{ + // Threaded: do only the usage-monitor bookkeeping now and defer the GPU free + + // container erase to the render thread (drainPendingFrees -> freeRetiredTexture), + // so an in-flight snapshot referencing this id is never freed mid-render. + if (mThreadedRunning.load(std::memory_order_acquire)) + { + std::lock_guard lock(mThreadedAssetMutex); + // Queue only if retire actually removed it from the unused set; a prior + // removeBellota sweep may have already collected it (tolerant — no double free). + if (assets.retireTexture(textureId)) + mPendingTextureFrees.push_back({textureId, mCommitSeq}); + return; + } + assets.removeTexture(textureId); +} + +void FrameRunner::setTexture(AssetRegistry& assets, BellotaId bellotaId, TextureId textureId) +{ + auto lock = lockAssetsIfThreaded(); + assets.setTexture(bellotaId, textureId); +} + +MeshId FrameRunner::addMesh(AssetRegistry& assets, const Mesh& mesh) +{ + auto lock = lockAssetsIfThreaded(); + return assets.addMesh(mesh); +} + +MeshId FrameRunner::addMesh(AssetRegistry& assets, Mesh&& mesh) +{ + auto lock = lockAssetsIfThreaded(); + return assets.addMesh(std::move(mesh)); +} + +void FrameRunner::removeMesh(AssetRegistry& assets, MeshId meshId) +{ + if (mThreadedRunning.load(std::memory_order_acquire)) + { + std::lock_guard lock(mThreadedAssetMutex); + if (assets.retireMesh(meshId)) + mPendingMeshFrees.push_back({meshId, mCommitSeq}); + return; + } + assets.removeMesh(meshId); +} + +void FrameRunner::setMesh(AssetRegistry& assets, BellotaId bellotaId, MeshId meshId) +{ + auto lock = lockAssetsIfThreaded(); + assets.setMesh(bellotaId, meshId); +} + +RenderTargetId FrameRunner::addRenderTarget(AssetRegistry& assets, ScreenSize size) +{ + auto lock = lockAssetsIfThreaded(); + return assets.addRenderTarget(size); // GPU creation is lazy (syncToGpu), so add is sim-safe. +} + +void FrameRunner::removeRenderTarget(AssetRegistry& assets, RenderTargetId renderTargetId) +{ + // Threaded: defer the whole removeRenderTarget (it frees GPU + erases the RT and + // its proxy texture immediately) to the render thread. The RT + proxy stay live + // for any in-flight snapshot until drainPendingFrees runs it at a safe seq. + if (mThreadedRunning.load(std::memory_order_acquire)) + { + std::lock_guard lock(mThreadedAssetMutex); + mPendingRenderTargetFrees.push_back({renderTargetId, mCommitSeq}); + return; + } + assets.removeRenderTarget(renderTargetId); +} + +void FrameRunner::setRenderTargetClearColor(AssetRegistry& assets, RenderTargetId renderTargetId, glm::vec4 clearColor) +{ + // The render thread reads the RT clear color live during the RTT pass (under the + // asset mutex), so serialize the write when threaded. + auto lock = lockAssetsIfThreaded(); + assets.setRenderTargetClearColor(renderTargetId, clearColor); +} + ScreenSize getPrimaryMonitorSize() { return SelectedWindowBackend::getPrimaryMonitorSize(); diff --git a/source/frame_runner.h b/source/frame_runner.h index 0ecf0ab6..24c9461c 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -202,7 +202,27 @@ class FrameRunner /// orphans are GC'd by the next produce(Single) pass. void removeBellota(AssetRegistry& assets, BellotaId bellotaId); + // ----- Mode-aware resource create/destroy ----- + // Each takes the asset mutex only while a threaded session is live (no lock + // single-threaded). Adds + CPU-only mutators just forward (GPU upload is lazy + // via syncToGpu). Removes defer the GPU free to the render thread when threaded + // (enqueue + drainPendingFrees), or free immediately single-threaded. + TextureId addTexture(AssetRegistry& assets, const Texture& texture); + void removeTexture(AssetRegistry& assets, TextureId textureId); + void setTexture(AssetRegistry& assets, BellotaId bellotaId, TextureId textureId); + MeshId addMesh(AssetRegistry& assets, const Mesh& mesh); + MeshId addMesh(AssetRegistry& assets, Mesh&& mesh); + void removeMesh(AssetRegistry& assets, MeshId meshId); + void setMesh(AssetRegistry& assets, BellotaId bellotaId, MeshId meshId); + RenderTargetId addRenderTarget(AssetRegistry& assets, ScreenSize size); + void removeRenderTarget(AssetRegistry& assets, RenderTargetId renderTargetId); + void setRenderTargetClearColor(AssetRegistry& assets, RenderTargetId renderTargetId, glm::vec4 clearColor); + private: + /// Returns a lock on the asset mutex while a threaded session is live, else an + /// empty (unlocked) lock — the single-threaded fast path takes no mutex. + std::unique_lock lockAssetsIfThreaded(); + /// Selects which orchestration a unified producer/consumer runs. `Single` is /// the run()/tick() path (one thread; ImGui on the main context, live draw /// data, no locks); `Threaded` is the commit()/renderFrame() path (sim/render @@ -284,7 +304,7 @@ class FrameRunner /// Free every pending resource whose retire commit is no later than /// `lastRenderedSeq` (i.e. no in-flight snapshot still references it). - void drainPendingFrees(AssetRegistry& assets, std::uint64_t lastRenderedSeq); + void drainPendingFrees(AssetRegistry& assets, ImguiRttManager& imguiRtt, std::uint64_t lastRenderedSeq); /// Apply the current effective content scale to the main ImGui context: /// FontScaleDpi (fonts, every frame) + ScaleAllSizes from the pristine base /// style (metrics, only when the scale changed). Main context must be current. @@ -323,10 +343,12 @@ class FrameRunner /// A resource removed from the scene at commit `retireSeq`, awaiting GPU free. /// Held until `mLastRenderedSeq >= retireSeq` so no in-flight snapshot can /// still reference it (at depth-0 this is the same frame). - struct PendingTextureFree { TextureId id; std::uint64_t retireSeq; }; - struct PendingMeshFree { MeshId id; std::uint64_t retireSeq; }; - std::vector mPendingTextureFrees; - std::vector mPendingMeshFrees; + struct PendingTextureFree { TextureId id; std::uint64_t retireSeq; }; + struct PendingMeshFree { MeshId id; std::uint64_t retireSeq; }; + struct PendingRenderTargetFree { RenderTargetId id; std::uint64_t retireSeq; }; + std::vector mPendingTextureFrees; + std::vector mPendingMeshFrees; + std::vector mPendingRenderTargetFrees; int mFramebufferWidth{0}; ///< Framebuffer size captured in produce(), reused by consume(). int mFramebufferHeight{0}; From 4174e3e34defe21585b1b382c4a7c59858c24909 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:04:01 -0700 Subject: [PATCH 17/55] Dense/Sparse land explorers now run on the threaded path, driven from the sim thread. --- examples/CMakeLists.txt | 6 + examples/hello_threaded_dense_land.cpp | 161 +++++++++++++++++++++++++ source/canvas.cpp | 2 +- source/frame_runner.cpp | 49 +++++--- source/frame_runner.h | 21 ++-- 5 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 examples/hello_threaded_dense_land.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 02537d3e..a5d143f7 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -167,3 +167,9 @@ add_executable(hello_threaded_gamepad ) set_property(TARGET hello_threaded_gamepad PROPERTY CXX_STANDARD 20) target_link_libraries(hello_threaded_gamepad PRIVATE nothofagus Threads::Threads) + +add_executable(hello_threaded_dense_land + "hello_threaded_dense_land.cpp" +) +set_property(TARGET hello_threaded_dense_land PROPERTY CXX_STANDARD 20) +target_link_libraries(hello_threaded_dense_land PRIVATE nothofagus Threads::Threads) diff --git a/examples/hello_threaded_dense_land.cpp b/examples/hello_threaded_dense_land.cpp new file mode 100644 index 00000000..b0409c4f --- /dev/null +++ b/examples/hello_threaded_dense_land.cpp @@ -0,0 +1,161 @@ +// hello_threaded_dense_land — Dense/Sparse land explorers on the sim thread. +// +// The DenseLand world + DenseLandExplorer pool are driven from commit()'s update +// on the SIM thread: the camera is panned via denseLandExplorer.setCamera(...) and +// the world is edited via denseLand.setCell(...). nothofagus runs the explorer +// pre-pass (chunk re-sync: pool-texture setMapBulk + pool-bellota repositioning) +// inside produce(Threaded), serialized against the render thread by the asset +// mutex — so the pooled huge-world renderer works on the threaded driver with no +// app-side changes beyond owning the two loops. +// +// Camera: WASD (polled on the sim controller) or the auto-pan circular sweep, +// which forces a steady stream of border-slot chunk swaps every frame. Escape +// (render controller) closes. + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr std::uint8_t kBlack = 1; + +std::vector makeBorderedTile(glm::ivec2 tileSize, std::uint8_t fillIndex) +{ + std::vector data(static_cast(tileSize.x * tileSize.y), fillIndex); + for (int x = 0; x < tileSize.x; ++x) + { + data[static_cast(x)] = kBlack; + data[static_cast((tileSize.y - 1) * tileSize.x + x)] = kBlack; + } + for (int y = 0; y < tileSize.y; ++y) + { + data[static_cast(y * tileSize.x)] = kBlack; + data[static_cast(y * tileSize.x + (tileSize.x - 1))] = kBlack; + } + return data; +} +} + +int main() +{ + spdlog::info("hello_threaded_dense_land: explorers driven from the sim thread"); + + constexpr glm::ivec2 tileSize {16, 16}; + constexpr glm::ivec2 chunkSize{16, 16}; + constexpr glm::ivec2 mapSize {256, 256}; + constexpr int pixelScale = 2; + + Nothofagus::Canvas canvas({480, 320}, "Hello Threaded DenseLand", {0.05f, 0.05f, 0.07f}, pixelScale); + + Nothofagus::ColorPallete palette{ + {0.0f, 0.0f, 0.0f, 0.0f}, // 0 transparent + {0.0f, 0.0f, 0.0f, 1.0f}, // 1 black + {1.0f, 1.0f, 1.0f, 1.0f}, // 2 white + {0.85f, 0.20f, 0.20f, 1.0f}, // 3 red + {0.95f, 0.85f, 0.20f, 1.0f}, // 4 yellow + {0.20f, 0.45f, 0.85f, 1.0f}, // 5 blue + {0.20f, 0.75f, 0.35f, 1.0f}, // 6 green + }; + + // Atlas: layers 0..3 are the four bordered band colors (palette 3..6). + std::vector> tileGraphics; + for (std::uint8_t color = 3; color <= 6; ++color) + tileGraphics.push_back(makeBorderedTile(tileSize, color)); + + Nothofagus::DenseLandId denseLandId = canvas.addDenseLand( + Nothofagus::DenseLand(mapSize, chunkSize, tileSize, palette, + std::span>(tileGraphics))); + Nothofagus::DenseLandExplorerId explorerId = + canvas.addDenseLandExplorer(Nothofagus::DenseLandExplorer(denseLandId)); + + // Banded pattern over the whole world (created up front, single-threaded). + for (int row = 0; row < mapSize.y; ++row) + for (int col = 0; col < mapSize.x; ++col) + canvas.denseLand(denseLandId).setCell({col, row}, + static_cast(((row / 8 + col / 8) % 4))); + + // ----- Sim controller: WASD camera, polled inside update on the sim thread. + Nothofagus::Controller simController; + + // ----- Render controller: Escape closes (window input on the main thread). + Nothofagus::Controller renderController; + renderController.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.close(); }); + + glm::vec2 camera{0.0f, 0.0f}; + float autoPanPhase = 0.0f; + bool autoPan = true; // headless-friendly stress: constant chunk swaps + std::uint32_t rngState = 0x9E3779B9u; + auto nextRandomCell = [&]() -> glm::ivec2 + { + rngState = rngState * 1664525u + 1013904223u; + const int col = static_cast((rngState >> 8) % static_cast(mapSize.x)); + rngState = rngState * 1664525u + 1013904223u; + const int row = static_cast((rngState >> 8) % static_cast(mapSize.y)); + return {col, row}; + }; + + auto update = [&](float deltaTimeMS) + { + const float dt = deltaTimeMS / 1000.0f; + constexpr float panSpeed = 200.0f; // world px/s + + glm::vec2 dir{0.0f, 0.0f}; + if (simController.isKeyDown(Nothofagus::Key::W)) dir.y += 1.0f; + if (simController.isKeyDown(Nothofagus::Key::S)) dir.y -= 1.0f; + if (simController.isKeyDown(Nothofagus::Key::A)) dir.x -= 1.0f; + if (simController.isKeyDown(Nothofagus::Key::D)) dir.x += 1.0f; + + if (dir.x != 0.0f || dir.y != 0.0f) + { + autoPan = false; + camera += glm::normalize(dir) * panSpeed * dt; + } + else if (autoPan) + { + autoPanPhase += dt * 0.25f * 2.0f * 3.14159265f; + const glm::vec2 worldCenter{0.5f * mapSize.x * tileSize.x, 0.5f * mapSize.y * tileSize.y}; + camera = worldCenter + glm::vec2{1000.0f * std::cos(autoPanPhase), 1000.0f * std::sin(autoPanPhase)}; + } + + canvas.denseLandExplorer(explorerId).setCamera(camera); + + // A few random world edits per commit — bumps chunk generations so the pool + // re-syncs displayed slots (exercises the threaded explorer write path). + for (int i = 0; i < 8; ++i) + canvas.denseLand(denseLandId).setCell(nextRandomCell(), 2); + }; + + canvas.beginThreadedSession(renderController); + + std::thread simThread([&]() + { + using clock = std::chrono::steady_clock; + auto previous = clock::now(); + constexpr auto targetPeriod = std::chrono::microseconds(8333); // ~120 Hz + while (canvas.isThreadedRunning()) + { + const auto now = clock::now(); + const float dt = std::chrono::duration(now - previous).count(); + previous = now; + canvas.commit(dt, update, simController); + std::this_thread::sleep_for(targetPeriod); + } + }); + + while (canvas.isThreadedRunning()) + canvas.renderFrame(renderController); + + simThread.join(); + + // Tear down explorers before their dense land (explorer-managed pool slots). + canvas.removeDenseLandExplorer(explorerId); + canvas.removeDenseLand(denseLandId); + return 0; +} diff --git a/source/canvas.cpp b/source/canvas.cpp index 7c4345c5..04d5ddce 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -404,7 +404,7 @@ void Canvas::close() void Canvas::beginThreadedSession(Controller& controller) { - mImplPtr->frameRunner.beginThreadedSession(controller); + mImplPtr->frameRunner.beginThreadedSession(*this, controller); } bool Canvas::isThreadedRunning() const diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 45837095..4115c071 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -372,6 +372,21 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset update(deltaTimeMS); } + // 1b) Explorer pre-pass (Dense/Sparse land). It rewrites pool textures' + // cell maps (setMapBulk → mMapDirty) and repositions pool bellotas, all + // from the camera the user set in `update`. The map writes race the + // render thread's syncToGpu upload, so hold the asset mutex around it; + // the resize path re-enters via the self-locking add/remove wrappers + // (mThreadedAssetMutex is recursive). canvas is null only if a malformed + // session skipped beginThreadedSession. + if (canvas != nullptr) + { + ZoneScopedN("Explorers"); + std::lock_guard assetLock(mThreadedAssetMutex); + mDenseLandManager.updateExplorers(*canvas); + mSparseLandManager.updateExplorers(*canvas); + } + RenderSnapshot& snapshot = mTripleBuffer.writeSlot(); snapshot.commitSeq = commitSeq; snapshot.clearColor = mClearColor; @@ -739,7 +754,7 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager // is released BEFORE the vsync swap so a slow present never stalls the sim. { ZoneScopedN("AssetLockedRender"); - std::lock_guard assetLock(mThreadedAssetMutex); + std::lock_guard assetLock(mThreadedAssetMutex); renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); } @@ -841,8 +856,11 @@ void FrameRunner::close() // mutation guarded by mThreadedAssetMutex) // --------------------------------------------------------------------------- -void FrameRunner::beginThreadedSession(Controller& controller) +void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) { + // Remember the canvas so produce(Threaded) can drive the explorer pre-pass. + mThreadedCanvas = &canvas; + // Bind input callbacks + reset the close flag (same as run()'s session start). mWindow->beginSession(controller); if (!mSessionStarted) @@ -911,9 +929,10 @@ void FrameRunner::beginThreadedSession(Controller& controller) void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update, std::function uiCallback) { - // Sim thread: CPU only, no GL. canvas/imguiRtt/controller are unused on this - // path (no explorers, font drain, or input poll on the sim thread). - produce(FrameMode::Threaded, nullptr, assets, nullptr, nullptr, deltaTimeMS, + // Sim thread: CPU only, no GL. The canvas (bound in beginThreadedSession) drives + // the explorer pre-pass; imguiRtt/controller stay null (no font drain or input + // poll on the sim thread). + produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, nullptr, deltaTimeMS, std::move(update), std::move(uiCallback), nullptr); } @@ -922,7 +941,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, { // Sim thread (M5): feed the sim controller from the latest gamepad snapshot // (inside produce, before update) so the game `update` sees the gamepad. No ImGui. - produce(FrameMode::Threaded, nullptr, assets, nullptr, nullptr, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, nullptr, deltaTimeMS, std::move(update), {}, &simController); } @@ -1128,9 +1147,9 @@ Nothofagus::BellotaId FrameRunner::addBellota(AssetRegistry& assets, const Bello // this may run on the sim thread concurrently with the render thread's // container access, so take the asset mutex; single-threaded (run/tick or // setup) there is no other party and we skip the lock entirely. - std::unique_lock lock; + std::unique_lock lock; if (mThreadedRunning.load(std::memory_order_acquire)) - lock = std::unique_lock(mThreadedAssetMutex); + lock = std::unique_lock(mThreadedAssetMutex); return assets.addBellota(bellota); } @@ -1144,7 +1163,7 @@ void FrameRunner::removeBellota(AssetRegistry& assets, BellotaId bellotaId) // bellota. if (mThreadedRunning.load(std::memory_order_acquire)) { - std::lock_guard lock(mThreadedAssetMutex); + std::lock_guard lock(mThreadedAssetMutex); assets.removeBellota(bellotaId); for (TextureId textureId : assets.collectUnusedTextures()) mPendingTextureFrees.push_back({textureId, mCommitSeq}); @@ -1157,11 +1176,11 @@ void FrameRunner::removeBellota(AssetRegistry& assets, BellotaId bellotaId) assets.removeBellota(bellotaId); } -std::unique_lock FrameRunner::lockAssetsIfThreaded() +std::unique_lock FrameRunner::lockAssetsIfThreaded() { if (mThreadedRunning.load(std::memory_order_acquire)) - return std::unique_lock(mThreadedAssetMutex); - return std::unique_lock(); + return std::unique_lock(mThreadedAssetMutex); + return std::unique_lock(); } TextureId FrameRunner::addTexture(AssetRegistry& assets, const Texture& texture) @@ -1177,7 +1196,7 @@ void FrameRunner::removeTexture(AssetRegistry& assets, TextureId textureId) // so an in-flight snapshot referencing this id is never freed mid-render. if (mThreadedRunning.load(std::memory_order_acquire)) { - std::lock_guard lock(mThreadedAssetMutex); + std::lock_guard lock(mThreadedAssetMutex); // Queue only if retire actually removed it from the unused set; a prior // removeBellota sweep may have already collected it (tolerant — no double free). if (assets.retireTexture(textureId)) @@ -1209,7 +1228,7 @@ void FrameRunner::removeMesh(AssetRegistry& assets, MeshId meshId) { if (mThreadedRunning.load(std::memory_order_acquire)) { - std::lock_guard lock(mThreadedAssetMutex); + std::lock_guard lock(mThreadedAssetMutex); if (assets.retireMesh(meshId)) mPendingMeshFrees.push_back({meshId, mCommitSeq}); return; @@ -1236,7 +1255,7 @@ void FrameRunner::removeRenderTarget(AssetRegistry& assets, RenderTargetId rende // for any in-flight snapshot until drainPendingFrees runs it at a safe seq. if (mThreadedRunning.load(std::memory_order_acquire)) { - std::lock_guard lock(mThreadedAssetMutex); + std::lock_guard lock(mThreadedAssetMutex); mPendingRenderTargetFrees.push_back({renderTargetId, mCommitSeq}); return; } diff --git a/source/frame_runner.h b/source/frame_runner.h index 24c9461c..80b5823f 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -161,12 +161,14 @@ class FrameRunner // ----- Threaded driver (M2 Phase A) ----- // The app runs two threads: `commitFrame` on a sim thread and // `renderFrameThreaded` on the main thread. nothofagus spawns nothing. - // Constraints in Phase A: resources are created up front; at runtime the sim - // only mutates existing bellota values. No ImGui, explorers, or runtime - // resource create/destroy on the threaded path yet (Phase B / M3). + // The sim thread (commit's update) may mutate bellota values, create/destroy + // bellotas/textures/meshes/render targets, run interactive ImGui (uiCallback), + // use gamepad/keyboard/mouse, and drive Dense/Sparse land explorers — all + // serialized against the renderer. - /// Main thread: bind input, reset the close flag, mark the threaded session live. - void beginThreadedSession(Controller& controller); + /// Main thread: bind input, reset the close flag, mark the threaded session + /// live, and remember the canvas so the threaded producer can drive explorers. + void beginThreadedSession(Canvas& canvas, Controller& controller); /// Thread-safe: true until the window is closed. Read by the sim loop. bool threadedRunning() const { return mThreadedRunning.load(std::memory_order_acquire); } @@ -221,7 +223,7 @@ class FrameRunner private: /// Returns a lock on the asset mutex while a threaded session is live, else an /// empty (unlocked) lock — the single-threaded fast path takes no mutex. - std::unique_lock lockAssetsIfThreaded(); + std::unique_lock lockAssetsIfThreaded(); /// Selects which orchestration a unified producer/consumer runs. `Single` is /// the run()/tick() path (one thread; ImGui on the main context, live draw @@ -356,6 +358,7 @@ class FrameRunner // ----- Threaded driver state (M2 Phase A) ----- SnapshotTripleBuffer mTripleBuffer; ///< sim→render snapshot hand-off (lock-free). std::atomic mThreadedRunning{false}; ///< true while the threaded session is live. + Canvas* mThreadedCanvas{nullptr}; ///< canvas bound by beginThreadedSession; drives explorers in produce(Threaded). /// Render-loop frame-time monitor for the threaded path, mirroring the local /// PerformanceMonitor that run() uses single-threaded: the smoothed getMS() is /// the dt fed to the stats overlay and to RTT ImGui timing (flushPending), so @@ -367,7 +370,11 @@ class FrameRunner /// against the render thread's container access (upload/resolve/free). Held /// only for the fast container section — never across the vsync swap. Bellota /// value mutation and the snapshot projection stay lock-free. - std::mutex mThreadedAssetMutex; + // Recursive: produce(Threaded) holds it around updateExplorers, whose resize + // path calls the self-locking addBellota/removeBellota/addTexture/removeTexture + // wrappers — so the sim thread re-enters it. The render thread only ever locks + // it once (renderSnapshotContents). + std::recursive_mutex mThreadedAssetMutex; // ----- M3: interactive ImGui on the threaded path ----- /// Dedicated ImGui context driven on the sim thread (NewFrame/widgets/Render), From eb29bdf4b6295f76efa639c7c0da273ba1785508 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:25:35 -0700 Subject: [PATCH 18/55] setScreenSize is now race-safe on the threaded path. --- examples/hello_threaded_dense_land.cpp | 14 ++++++++++++ include/canvas.h | 8 +++++-- source/canvas.cpp | 2 +- source/frame_runner.cpp | 31 +++++++++++++++----------- source/frame_runner.h | 9 +++++--- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/examples/hello_threaded_dense_land.cpp b/examples/hello_threaded_dense_land.cpp index b0409c4f..f8b2502e 100644 --- a/examples/hello_threaded_dense_land.cpp +++ b/examples/hello_threaded_dense_land.cpp @@ -91,6 +91,8 @@ int main() glm::vec2 camera{0.0f, 0.0f}; float autoPanPhase = 0.0f; bool autoPan = true; // headless-friendly stress: constant chunk swaps + float resizeTimerMs = 0.0f; // toggles the logical canvas size to exercise pool resize + bool bigCanvas = true; std::uint32_t rngState = 0x9E3779B9u; auto nextRandomCell = [&]() -> glm::ivec2 { @@ -130,6 +132,18 @@ int main() // re-syncs displayed slots (exercises the threaded explorer write path). for (int i = 0; i < 8; ++i) canvas.denseLand(denseLandId).setCell(nextRandomCell(), 2); + + // Every ~2 s, change the logical canvas size from the SIM thread — exercises + // A4: setScreenSize is now race-safe (atomic mScreenSize) and the explorer + // pre-pass rebuilds the pool for the new size next commit. + resizeTimerMs += deltaTimeMS; + if (resizeTimerMs >= 2000.0f) + { + resizeTimerMs = 0.0f; + bigCanvas = !bigCanvas; + canvas.setScreenSize(bigCanvas ? Nothofagus::ScreenSize{480, 320} + : Nothofagus::ScreenSize{360, 240}); + } }; canvas.beginThreadedSession(renderController); diff --git a/include/canvas.h b/include/canvas.h index 2e1d064e..7415b980 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -99,10 +99,14 @@ class Canvas void setWindowed(); /** - * @brief Returns reference to the screen size. + * @brief Returns the logical screen size (by value). + * + * Returned by value (not by reference) because the size is stored atomically: + * on the threaded path the sim thread may `setScreenSize()` from commit's + * update while the render thread reads it. Safe to call from any thread. * @return The screen size. */ - const ScreenSize& screenSize() const; + ScreenSize screenSize() const; void setScreenSize(const ScreenSize& screenSize); diff --git a/source/canvas.cpp b/source/canvas.cpp index 04d5ddce..b2cfc669 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -121,7 +121,7 @@ std::size_t Canvas::getCurrentMonitor() const { return mImplPt bool Canvas::isFullscreen() const { return mImplPtr->frameRunner.isFullscreen(); } void Canvas::setFullScreenOnMonitor(std::size_t monitor) { mImplPtr->frameRunner.setFullScreenOnMonitor(monitor); } void Canvas::setWindowed() { mImplPtr->frameRunner.setWindowed(); } -const ScreenSize& Canvas::screenSize() const { return mImplPtr->frameRunner.screenSize(); } +ScreenSize Canvas::screenSize() const { return mImplPtr->frameRunner.screenSize(); } void Canvas::setScreenSize(const ScreenSize& screenSize) { mImplPtr->frameRunner.setScreenSize(screenSize); } void Canvas::setClearColor(glm::vec3 clearColor) { mImplPtr->frameRunner.setClearColor(clearColor); } void Canvas::setAutoRemoveUnusedTextures(bool enabled) { mImplPtr->frameRunner.setAutoRemoveUnusedTextures(enabled); } diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 4115c071..bf419268 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -76,8 +76,8 @@ FrameRunner::FrameRunner( // Initialize the window backend (creates window, GL/Vulkan context, loads GLAD for OpenGL) mWindow = std::make_unique( mTitle, - static_cast(mScreenSize.width * mPixelSize), - static_cast(mScreenSize.height * mPixelSize), + static_cast(screenSize.width * mPixelSize), + static_cast(screenSize.height * mPixelSize), !mHeadless // visible ); @@ -94,7 +94,7 @@ FrameRunner::FrameRunner( mWindow->initImGuiPlatform(); // Render backend init (GPU resources, shader compilation, ImGui renderer binding). - mBackend.initialize(mWindow->nativeHandle(), {static_cast(mScreenSize.width), static_cast(mScreenSize.height)}); + mBackend.initialize(mWindow->nativeHandle(), {static_cast(screenSize.width), static_cast(screenSize.height)}); mBackend.initImGuiRenderer(); // Font setup happens after construction at the Canvas level — once `mAssets` @@ -217,7 +217,8 @@ ScreenSize FrameRunner::windowSize() const DirectTexture FrameRunner::takeScreenshot() const { - const glm::ivec2 gameSize{static_cast(mScreenSize.width), static_cast(mScreenSize.height)}; + const ScreenSize screen = mScreenSize.load(std::memory_order_acquire); + const glm::ivec2 gameSize{static_cast(screen.width), static_cast(screen.height)}; ScreenshotPixels pixels = mBackend.takeScreenshot(mGameViewport, gameSize); TextureData textureData(pixels.width, pixels.height, 1); std::copy(pixels.data.begin(), pixels.data.end(), textureData.getDataSpan().begin()); @@ -409,8 +410,9 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset mThreadedImguiInput.wheelY = 0.0f; mThreadedImguiInput.textCharCount = 0; // consume typed characters } - const float displayWidth = input.displayWidth > 0.0f ? input.displayWidth : static_cast(mScreenSize.width); - const float displayHeight = input.displayHeight > 0.0f ? input.displayHeight : static_cast(mScreenSize.height); + const ScreenSize screen = mScreenSize.load(std::memory_order_acquire); + const float displayWidth = input.displayWidth > 0.0f ? input.displayWidth : static_cast(screen.width); + const float displayHeight = input.displayHeight > 0.0f ? input.displayHeight : static_cast(screen.height); io.DisplaySize = ImVec2(displayWidth, displayHeight); io.DisplayFramebufferScale = ImVec2(input.framebufferScaleX, input.framebufferScaleY); @@ -492,7 +494,8 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); mFramebufferWidth = framebufferWidth; mFramebufferHeight = framebufferHeight; - mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, mScreenSize.width, mScreenSize.height); + const ScreenSize singleScreen = mScreenSize.load(std::memory_order_acquire); + mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, singleScreen.width, singleScreen.height); // M1: GPU-frame setup + ImGui NewFrame stay inline here (the user update // issues ImGui calls). These migrate to the render side at the thread flip. @@ -640,7 +643,7 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& meshPack.syncToGpu(mBackend); } - const glm::mat3 worldTransformMat = computeWorldTransformMat(mScreenSize); + const glm::mat3 worldTransformMat = computeWorldTransformMat(mScreenSize.load(std::memory_order_acquire)); { ZoneScopedN("RttPasses"); @@ -712,7 +715,8 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); mFramebufferWidth = framebufferWidth; mFramebufferHeight = framebufferHeight; - mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, mScreenSize.width, mScreenSize.height); + const ScreenSize threadedScreen = mScreenSize.load(std::memory_order_acquire); + mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, threadedScreen.width, threadedScreen.height); mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); @@ -773,7 +777,7 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager { ZoneScopedN("SwapBuffers"); - mWindow->endFrame(controller, mScreenSize); + mWindow->endFrame(controller, mScreenSize.load(std::memory_order_acquire)); } // Snapshot the render controller's freshly-polled input (gamepad + keyboard @@ -884,12 +888,13 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) // CreateContext restores the previous (main) context on return, so make // the sim-UI context current explicitly before configuring its IO. ImGui::SetCurrentContext(mSimUiContext); + const ScreenSize primingScreen = mScreenSize.load(std::memory_order_acquire); ImGuiIO& simIo = ImGui::GetIO(); simIo.IniFilename = nullptr; simIo.BackendPlatformName = "nothofagus_sim_ui"; simIo.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // atlas uploaded render-side - simIo.DisplaySize = ImVec2(static_cast(mScreenSize.width), - static_cast(mScreenSize.height)); + simIo.DisplaySize = ImVec2(static_cast(primingScreen.width), + static_cast(primingScreen.height)); // Enable keyboard nav and wire an in-process clipboard so InputText // copy/paste works on the sim thread (GLFW clipboard is main-thread-only). simIo.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; @@ -910,7 +915,7 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) // any sim commit references it, so the shared atlas is ready and the sim's // NewFrame/layout never races a first-time upload. auto [fbW, fbH] = mWindow->getFramebufferSize(); - const ViewportRect viewport = computeLetterboxViewport(fbW, fbH, mScreenSize.width, mScreenSize.height); + const ViewportRect viewport = computeLetterboxViewport(fbW, fbH, primingScreen.width, primingScreen.height); mBackend.beginFrame(mClearColor, viewport, fbW, fbH); beginMainImguiFrame(); ImGui::Render(); diff --git a/source/frame_runner.h b/source/frame_runner.h index 80b5823f..4b14c37d 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -111,8 +111,11 @@ class FrameRunner void close(); // ----- Canvas state ----- - const ScreenSize& screenSize() const { return mScreenSize; } - void setScreenSize(const ScreenSize& screenSize) { mScreenSize = screenSize; } + // mScreenSize is atomic: on the threaded path the sim thread may setScreenSize() + // from commit's update while the render thread reads it (viewport/letterbox). + // Returns by value (a consistent 8-byte load); never a reference into the atomic. + ScreenSize screenSize() const { return mScreenSize.load(std::memory_order_acquire); } + void setScreenSize(const ScreenSize& screenSize) { mScreenSize.store(screenSize, std::memory_order_release); } void setClearColor(glm::vec3 clearColor) { mClearColor = clearColor; } ViewportRect gameViewport() const { return mGameViewport; } bool& stats() { return mStats; } @@ -319,7 +322,7 @@ class FrameRunner /// `drainPendingFontOps`, and any ImGui mutex around this call. void beginMainImguiFrame(); - ScreenSize mScreenSize; ///< The screen size of the canvas. + std::atomic mScreenSize; ///< The screen size of the canvas (atomic: sim-thread setScreenSize vs render-thread reads). std::string mTitle; ///< The title of the canvas window. glm::vec3 mClearColor; ///< The background color of the canvas. unsigned int mPixelSize; ///< The pixel size on the canvas. From 0fabc0f1dd0ad19e1586e8ccc265427bfab7e49a Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:04:09 -0700 Subject: [PATCH 19/55] =?UTF-8?q?Cursor-shape=20feedback=20on=20the=20thre?= =?UTF-8?q?aded=20path:=20the=20sim=20UI's=20ImGui=20mouse=20cursor=20is?= =?UTF-8?q?=20marshalled=20to=20the=20render=20thread=20and=20applied=20be?= =?UTF-8?q?fore=20its=20main-context=20NewFrame,=20so=20I-beam/resize=20cu?= =?UTF-8?q?rsors=20work=20over=20threaded=20ImGui=20(B5).=20Reuses=20the?= =?UTF-8?q?=20platform=20backend's=20existing=20cursor=20handling=20?= =?UTF-8?q?=E2=80=94=20no=20backend=20changes;=20render-backend-agnostic.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THREADED_MODE_ROADMAP.md | 85 +++++++ nothofagus_threading_plan.md | 432 +++++++++++++++++++++++++++++++++++ source/frame_runner.cpp | 9 + source/frame_runner.h | 5 + 4 files changed, 531 insertions(+) create mode 100644 THREADED_MODE_ROADMAP.md create mode 100644 nothofagus_threading_plan.md diff --git a/THREADED_MODE_ROADMAP.md b/THREADED_MODE_ROADMAP.md new file mode 100644 index 00000000..39f137fe --- /dev/null +++ b/THREADED_MODE_ROADMAP.md @@ -0,0 +1,85 @@ +# Threaded mode — completion roadmap + +> Status of the sim/render threaded path (Option A). What's done, what's left to reach full +> feature parity with the single-threaded path, and related work tracked separately. +> Branch: `thread_aware_phase2_unification`. Companion: `nothofagus_threading_plan.md`, +> `multithreading_design_discussion.md`. +> +> `main` was merged in (commit `7a10d63`): it brought the **Vulkan present-mode fix** (now done — see +> group D) and the **ImGui image registry** (`registerImage`/`drawImage`, `imgui_image_manager`); the +> threaded `produce`/`consume` integrate both (the per-frame ImGui-image pre-pass is single-thread +> only, null-guarded on the threaded path). + +## Foundation (done & committed) + +- **Step 1 — unify frame paths** (`c00b634`): one `produce(FrameMode)` + one `consume(FrameMode)` + (`Single` = run/tick, `Threaded` = commit/renderFrame). Behavior-preserving. +- **M5 — gamepad on the sim thread** (`4966785`): two-controller model (render + sim) via + harvest→POD→feed. +- **Stats + PerformanceMonitor dt on the threaded path** (`13c7402`, `9e44844`, `e16648a`). +- **Step 2 (scoped) — bellota mutation unify** (`645c278`): one mode-aware `addBellota`/`removeBellota`; + deleted `spawnBellota`/`despawnBellota`. + +## A. Feature-parity gaps (single-threaded can; threaded couldn't) + +1. **Keyboard + mouse game input on the sim thread** — ✅ DONE (`6b5d32a`). `Controller` gained + `isKeyDown`/`isMouseButtonDown` + a scroll accumulator; `GameInputSnapshot` harvest/feed; the + `commit(dt, update, Controller&)` overload now feeds gamepad + keyboard + mouse. +2. **Explorers (Dense/Sparse land) on the threaded path** — ✅ DONE (`4174e3e`). `updateExplorers` + runs in `produce(Threaded)` under the asset mutex; `Canvas*` captured in `beginThreadedSession`. +3. **Runtime create/destroy of textures / meshes / render targets** — ✅ DONE (`d45063c`). Mode-aware + asset wrappers (lazy adds + deferred removes), tolerant `retireTexture`/`retireMesh`, a new + render-target deferred-free queue (drain also releases the per-RTT ImGui context). +4. **Threaded-safe `setScreenSize` (explorer pool resize during a live session)** — ✅ DONE. + `mScreenSize` is now `std::atomic` (lock-free 8-byte); `screenSize()` returns by + value, every reader loads atomically, `setScreenSize` stores. Resizing the logical canvas from + the sim `update` is race-free and rebuilds the explorer pool next commit. Verified: + `hello_threaded_dense_land` toggles `setScreenSize` every ~2 s — TSan 0 conflicts in our code + (no `mScreenSize` race), ASan/UBSan clean. Remaining cosmetic nuance: a 1-frame letterbox + transient on resize (render uses live size while drawing the previous snapshot's pool); fully + removing it = carry `screenSize` in `RenderSnapshot` (optional, see C). + +## B. ImGui parity on the threaded path — 🔧 OPEN + +5. **Cursor-shape feedback** — marshal sim `ImGui::GetMouseCursor()` → render `glfwSetCursor`/SDL + equivalent (I-beam over text, resize handles). Output, sim→render (reverse of the input harvest). +6. **ImGui gamepad nav** — only `NavEnableKeyboard` is set on the sim-UI context; add + `NavEnableGamepad` + marshal the gamepad ImGui-keys into it. +7. **OS clipboard across the thread boundary** — today the sim-UI context uses an in-process + clipboard; bridge to the real OS clipboard (GLFW/SDL, main-thread-only) across the boundary. + +## C. Polish / stretch — 🔧 OPEN + +8. **Stats overlay when a sim-UI clone IS present** — stats render on the main context, which is only + shown when the app commits no `uiCallback` (e.g. the gamepad demo). Apps with sim ImGui need + stats injected into the clone (sim side). +9. **Triple-buffer interpolation** — render could interpolate between the two most recent snapshots + by stable id for extra smoothness. +10. **Carry `screenSize` in `RenderSnapshot`** — removes the 1-frame letterbox transient on a + threaded `setScreenSize` (the render would use the snapshot's size, matching its pool, instead of + the live size). Stamp it in `produce`; `consume` uses `snapshot.screenSize` for the viewport / + world transform / `endFrame`. A4 is already race-safe without this. + +## D. Related — tracked separately (not part of "completing threaded mode") + +- **Vulkan present-mode perf** (windowed FIFO → ~45 fps on a compositor) — ✅ DONE, landed in `main` + and merged here. A `PresentMode` enum ([include/present_mode.h](include/present_mode.h), + default `Mailbox`) is a `Canvas` ctor arg; it selects the Vulkan present mode and the OpenGL swap + interval (`presentModeToSwapInterval`). Mailbox is vsync'd + triple-buffered (no tearing, + compositor-friendly) and avoids the ~45 fps FIFO pacing. (The old `VULKAN_PRESENT_MODE_FIX.md` + planning doc is superseded and was removed.) +- **Full Option A driver collapse** (`run`/`tick` → only `commit`/`renderFrame`) — deferred by + choice; would force the ~15 ImGui-in-`update` single-threaded examples onto the threaded ImGui model. +- **markTextureAsDirty / setTextureMin|MagFilter threaded-safety** — currently single/main-thread-only + (immediate GPU work); deferred-GPU versions are a follow-up if needed. +- **alice_engine / PocketPy integration** — wiring the engine's Python loop onto `commit`/`renderFrame`; + out of nothofagus scope by design. + +## Verification recipe (every change) + +- Cross-backend builds: `linux-release-glfw-opengl-examples`, `-glfw-vulkan-examples`, `-sdl3-vulkan-examples`. +- SwiftShader goldens unchanged: `linux-release-headless-vulkan-tests` + + `NOTHOFAGUS_RENDER_BACKEND=swiftshader .../rendering_tests`. +- TSan + ASan on the threaded demos (`hello_threaded`, `_imgui`, `_gamepad`, `_dense_land`) under + `xvfb-run` — expect only Mesa/glib environmental noise, 0 conflicts in our code, 0 ASan/UBSan errors. +- Any new shared state must be mutex-guarded like `mThreadedGamepadState` / `mThreadedGameInputState`. diff --git a/nothofagus_threading_plan.md b/nothofagus_threading_plan.md new file mode 100644 index 00000000..08665ca0 --- /dev/null +++ b/nothofagus_threading_plan.md @@ -0,0 +1,432 @@ +# Nothofagus: make the renderer thread-aware + +> Implementation plan + status for the nothofagus sim/render thread split (M1–M4). +> Companion to [multithreading_design_discussion.md](multithreading_design_discussion.md) +> (the exploratory design). This file tracks the concrete, as-built work. + +## Context + +Alice Engine is 100% single-threaded today. The long-term goal is a sim/render +split: the simulation thread mutates the scene and **commits** a frame snapshot; +the render thread draws the **previous** frame's snapshot; the GPU is the implicit +third stage. This decouples render of frame N from sim of frame N+1, and requires +deferred GPU-resource removal so a resource dropped by sim this tick isn't freed +while an in-flight snapshot still references it. + +Scoped to **nothofagus only** (no `alice_engine` / PocketPy integration yet), +following [multithreading_design_discussion.md](multithreading_design_discussion.md) +**option (I)** (the double buffer lives inside nothofagus at the scene/draw-data +level), staged as: *seam first → thread flip → committed ImGui → full ImGui input +→ game input on the sim thread*. + +## Status + +- **M1 — single-threaded seam: DONE & verified** (details + verification below). +- **M2 — flip to two threads: DONE & verified.** Phase A (snapshot hand-off, + triple buffer, value-only runtime) + Phase B (runtime spawn/despawn via a + scoped asset mutex + deferred free). New `hello_threaded` demo. Validated: + TSan/ASan clean in our code, SwiftShader goldens unchanged. No ImGui on the + threaded path yet. +- **M3 — interactive committed ImGui on the threaded path: DONE & verified.** + Sim-UI ImGui context + `ImDrawData` clone in the snapshot + thread-local + `GImGui` + render→sim **mouse** marshalling. The shared-atlas race (ImGui 1.92's + per-frame dynamic atlas) is serialized by `mImguiMutex` (short ImGui sections + only; game-sim + sprite render still overlap). `commit(dt, update, uiCallback)` + splits lock-free game logic from the locked UI frame. New `hello_threaded_imgui` + demo. Validated: TSan (atlas races gone; only Mesa driver noise), ASan clean, + goldens unchanged, alice_engine builds. +- **M4 — full keyboard / text / focus input for threaded ImGui: DONE & verified.** + Render→sim marshalling of the full keyboard state + modifiers + typed + characters + focus (reusing ImGui_ImplGlfw's keymap on the main context), + replayed on the sim-UI context; `WantCaptureMouse/Keyboard` back-marshalled + (`Canvas::imguiWantsMouse()/imguiWantsKeyboard()`); in-process clipboard for + `InputText` copy/paste; `NavEnableKeyboard`. Demo extended (InputText + Space + shortcut + capture gating). Validated: TSan 0 races in our code, ASan clean, + goldens 49/49, alice_engine builds. +- **M5 — gamepad input on the sim thread (game input, not ImGui): NEXT** (plan + below). Marshal the render `Controller`'s normalized gamepad state → feed a + sim-side `Controller` the game `update` polls / gets callbacks from. Two- + controller model (render controller for window/`close`; sim controller for game + input). nothofagus-only, no ImGui. + +## Implemented (on disk, not committed to git) vs Pending + +**Implemented & validated — M1–M4:** the full nothofagus-only sim/render thread +split. Single-threaded `run()`/`tick()` are unchanged; the threaded path is an +additive, opt-in API (`beginThreadedSession` / `commit(dt, update[, uiCallback])` +/ `renderFrame` / `isThreadedRunning` / `imguiWantsMouse|Keyboard`; runtime add/ +remove via the unified `addBellota` / `removeBellota`, which detect a live threaded +session) exercised by `hello_threaded` (no-ImGui baseline) and +`hello_threaded_imgui` (interactive). Snapshot triple-buffer, deferred GPU free +across the one-frame lag, runtime spawn/despawn, and interactive sim-thread ImGui +(mouse + keyboard + text + clipboard) all work and are TSan/ASan-clean (only +environmental Mesa-driver noise) with goldens unchanged. + +**Pending / deferred (none required for the threaded path to work):** +- **Game input on the sim thread** — M1–M4 only marshalled input to the *ImGui* + context, not to the game's `Controller` on the sim side. **M5 (below) closes + this for gamepad.** Keyboard/mouse game-input on the sim thread (same + marshal→sim-`Controller` pattern) and **ImGui gamepad nav** (`NavEnableGamepad` + + gamepad-key marshalling) remain after M5. +- **Cursor-shape feedback** — sim UI's `GetMouseCursor()` marshalled sim→render + + `glfwSetCursor` (I-beam over text, resize handles). Output, not input. +- **OS clipboard** across the thread boundary (today: in-process app-local only). +- **Explorers (Dense/Sparse land) on the threaded path** — they mutate pool + textures; would route through the same asset-mutex/command path. Out of scope so + far; works single-threaded. +- **Triple-buffer interpolation** — render could interpolate between the two most + recent snapshots by stable id for extra smoothness (the doc's stretch goal). +- **alice_engine / PocketPy integration** — wiring the engine's Python loop onto + `commit`/`renderFrame` (Python `update` on the sim thread, etc.). Entire effort + so far is nothofagus-only by design. +- **Git** — nothing committed; M1–M4 (and `M1_PR.md`) sit in the working tree. + +## Architecture invariants (settled, hold across all milestones) + +- **Nothofagus stays driven, not driving.** The library spawns no threads. The + app drives one or two threads and calls into the canvas. +- **One mutable scene + one POD projection.** No second `BellotaContainer`. The + live `Canvas::bellota(id) -> Bellota&` mutation path is untouched; the render + side consumes only the POD `RenderSnapshot` and never touches a `Bellota`. +- **Snapshot references resources by id, not GPU handle.** `DrawItem` carries + `TextureId`/`MeshId`; the render side resolves id → `DTexture`/`DMesh` *after* + GPU upload (a freshly created texture has no handle at commit time). +- **The seam sits above `ActiveBackend`** — GL and Vulkan inherit the split for + free; the one invariant is *all GPU create/destroy/draw on the single render + thread*. +- **RTT passes ride in the same snapshot, no extra-frame deferral.** Within a + render frame the render side already runs RTT → main → present in order, so RTT + inherits the same uniform one-frame sim→render latency as sprites. Deferring it + would make RTT lag its own sprites. + +## M1 — single-threaded seam (DONE) + +Inverted `FrameRunner::runOneFrame` into **`buildSnapshot()` (produce POD) → +`renderSnapshot()` (consume POD)** on the same thread, behavior-preserving, with +the deferred-free plumbing wired (draining immediately at depth-0). + +**What was built:** +- [source/render_snapshot.h](../third_party/nothofagus/source/render_snapshot.h) — + new POD types `DrawItem` (resolved transform + `TextureId`/`MeshId` + tint / + opacity / layer / depth), `RttPass` (target + sorted draws), `RenderSnapshot` + (commitSeq + main draws + RTT passes + clearColor). +- [source/frame_runner.cpp](../third_party/nothofagus/source/frame_runner.cpp) / + `.h` — `runOneFrame` = `buildSnapshot(...)` → `renderSnapshot(...)`. + `buildSnapshot` runs input/ImGui-NewFrame/`update`/explorers, detects unused + resources (enqueues for deferred free), and projects the scene into the reused + `mSnapshot`. `renderSnapshot` drains frees, uploads, draws the snapshot's RTT + + main passes, ImGui render, presents. The old `drawBellotaPacks` / + `makeSpriteDrawParams` / `sortByDepthOffset` were replaced by `drawItems` / + `makeDrawItem` / `buildMainDraws` over POD. New members: `mSnapshot`, + `mCommitSeq`, `mLastRenderedSeq`, `mPendingTextureFrees` / `mPendingMeshFrees`, + `mFramebufferWidth/Height`; `mSortedBellotaPacks` removed. +- [source/asset_registry.cpp](../third_party/nothofagus/source/asset_registry.cpp) / + `.h` — `collectUnusedTextures` / `collectUnusedMeshes` (detect + monitor-clear, + no free) and `freeRetiredTexture` / `freeRetiredMesh` (free GPU + container + erase, no monitor touch). Deferred-free gates on `retireSeq <= lastRenderedSeq`; + at depth-0 `lastRenderedSeq == commitSeq` so timing matches the old + `clearUnused*` exactly. + +**Verification (passed):** +- nothofagus `linux-debug-glfw-opengl-examples` — clean build. +- nothofagus SwiftShader visual + nonvisual suite — **0 allowed diff pixels** + (the deterministic proof the seam is behavior-identical: animation, RTT, + nested-RTT, tilemap all byte-identical). +- parent `alice_engine` `linux-release` — clean build through the changed library. +- engine golden demos — 5/6 byte-exact on GPU; `anim_text` shows a stable + 2/10240-pixel diff = real-GPU-vs-SwiftShader rasterization variance (confirmed + acceptable), not a regression. + +## M2 — flip to two threads (DONE) + +> **As built:** Phase A landed as planned; **Phase B used a scoped +> `mThreadedAssetMutex` for runtime spawn/despawn + deferred free, not the +> lock-free resource command queue** the section below describes (the mutex reuses +> AssetRegistry/`syncToGpu` with far less churn; the command queue remains a +> possible future optimization). The deliverables/verification below are the +> original plan, kept as the record. + +Goal: a **new** nothofagus-only example, `examples/hello_threaded.cpp`, driven by +two `std::thread`s — a sim thread commits frame N+1 while the main/render thread +draws frame N. **Existing examples are not modified**; they stay on `run()`/ +`tick()` as the single-threaded regression baseline. + +**Shared invariants for the threaded path:** +- The GL/window context lives on the **main thread**; `renderFrame()` (all GPU + work + present) runs there. The sim thread issues **zero** GPU calls. +- `run()`/`tick()` are untouched (additive change), so the whole existing example + + golden suite keeps passing. + +### Phase A — snapshot handoff, value-only runtime + +At runtime (while both threads run) the sim may only **mutate existing bellota +values** — transform, tint, opacity, layer/animation. All textures, meshes, and +bellotas are created up front, single-threaded, before the threads start ⇒ +`mTextures`/`mMeshes` are render-exclusive and `mBellotas` is sim-exclusive at +runtime, so **no container locks** are needed. This proves the handoff + the +real sim/render overlap. + +Built: +1. **Triple-buffered snapshot store** — + [source/snapshot_buffers.h](../third_party/nothofagus/source/snapshot_buffers.h): + 3 `RenderSnapshot` slots + a lock-free mailbox protocol (atomic published + index/seq). Sim never blocks; render always gets the freshest and drops stale + intermediates. Each slot reuses its `draws`/`rttPasses` vectors. +2. **Public Canvas API (additive)**: `commit(dt, update)` (sim — projection, + published into a slot) and `renderFrame(controller)` (main — acquire, upload, + RTT + main passes, present, drain frees, poll). `beginThreadedSession` / + `isThreadedRunning` / `close` drive the loops. `run()`/`tick()` unchanged. +3. **No-ImGui present path** in `renderFrame` (empty draw data) — replaced in M3. +4. **`hello_threaded` v1**: a field of sprites animated by the sim thread, drawn + one frame behind by the main thread. + +### Phase B — runtime container mutation (scoped mutex, as built) + +> The plan proposed a lock-free **resource command queue + AssetRegistry +> ownership split**; the as-built solution is a narrower **`mThreadedAssetMutex`** +> held only for the short container section (deferred frees + GPU upload + id→ +> handle resolve + draw submission), released **before** the vsync swap, while the +> game-sim update and bellota value mutation stay lock-free. Reuses all existing +> AssetRegistry/`syncToGpu`/deferred-free machinery. + +Built (originally `Canvas::spawnBellota`/`despawnBellota`; later unified — see the +Step-2 note below — into the regular `addBellota`/`removeBellota`, which detect a +live threaded session): +- runtime add/remove guarded by + `mThreadedAssetMutex`; removal detects orphaned resources and queues them for + deferred GPU free (tagged with the commit seq), freed render-side once + `retireSeq <= lastRenderedSeq` (no use-after-free across the one-frame lag). +- `commitFrame` stamps the commit seq before `update` so removal tags resources + correctly; `renderFrameThreaded` runs `renderSnapshotContents` under the asset + mutex, releasing it before the swap. +- **`hello_threaded` escalated**: continuous spawn/despawn burst (sprites + their + auto-quad meshes) while render lags one frame. + +**Validation (A + B):** built `*-glfw-opengl-examples`; ran `hello_threaded` +(smooth, no flicker) under **TSan** (no races in our code — only environmental +Mesa-driver noise; sim thread never a racing party) and **ASan** (sustained +create/destroy churn, no use-after-free); SwiftShader goldens unchanged; parent +`alice_engine` builds. + +## M3 — interactive committed ImGui on the threaded path (DONE) + +> **As built:** matches the plan below, with one discovery — ImGui 1.92's dynamic +> font atlas is mutated every `NewFrame`, so thread-local `GImGui` alone wasn't +> enough; the two threads' ImGui sections are serialized by `mImguiMutex` (short +> sections; game-sim + sprite render still overlap). The clone uses manual +> `CmdLists` fill (`CloneOutput` returns sealed lists). + +Goal: user ImGui widget code runs on the **sim** thread (inside the UI callback, +where the game state it reads/writes lives) and is **interactive** — mouse drives +sliders/buttons/checkboxes — while the **render** thread only issues +`RenderDrawData` from a **deep-cloned `ImDrawData`** carried in the snapshot. All +ImGui logic stays single-threaded (ImGui is not thread-safe; for the future +alice_engine the callbacks are Python on one VM). ImGui is **1.92.8**; +`ImDrawList::CloneOutput()` is the core primitive. + +### Thread/context model +- **Sim-UI context** (new): a dedicated `ImGuiContext` sharing the main font atlas + (manual IO, no platform/renderer `NewFrame`). The sim thread runs `NewFrame` → + widgets → `Render` on it. +- **Render/backend context**: the original ctor context (renderer bound to it) + stays on the render thread, used only for `RenderDrawData` of the clone. +- **Thread-local `GImGui`** ([imconfig.h](../third_party/nothofagus/third_party/imgui/imconfig.h), + definition in `imgui_draw_clone.cpp`) so each thread refers to its own context. +- **`mImguiMutex`** serializes the shared font-atlas access between the two + threads' ImGui sections (the 1.92 dynamic atlas is mutated every `NewFrame`). + +### What was built +1. **`ImDrawData` deep-clone util** — + [source/imgui_draw_clone.h/.cpp](../third_party/nothofagus/source/imgui_draw_clone.cpp): + `ClonedImDrawData` owns `ImDrawList*` via `CloneOutput()` and rebuilds an + `ImDrawData` (manual `CmdLists` fill + count sums). Reused per slot; lists are + allocated/freed on the sim thread only. +2. **Clone in the snapshot** — + [render_snapshot.h](../third_party/nothofagus/source/render_snapshot.h) gains a + forward-declared `std::unique_ptr mainUi` (out-of-line dtor in + `render_snapshot.cpp` keeps `imgui.h` out of the header). Dropped snapshots skip + a UI frame; nothing leaks. +3. **Mouse marshalling render→sim** — `harvestImguiInput` snapshots the main + context's processed mouse pos/buttons/wheel + DisplaySize/scale; the sim replays + via the event API before `NewFrame`. +4. **`commit(dt, update, uiCallback)`** — game `update` runs lock-free (overlaps + render); the UI callback runs in a `mImguiMutex`-locked section that feeds IO, + `NewFrame`, runs widgets, `Render`, and clones the draw data. +5. **Render the clone** — `renderFrameThreaded` runs the main empty ImGui frame + (harvest + atlas upload) and `RenderDrawData` of the clone, both under + `mImguiMutex`; the sprite render stays under `mThreadedAssetMutex`; the swap is + outside both locks. + +### Constraints (still hold) +- **Fonts baked up-front / render-side.** Runtime font baking from the sim thread + is out of scope (would need atlas-mutation marshalling). + +### Demo +[examples/hello_threaded_imgui.cpp](../third_party/nothofagus/examples/hello_threaded_imgui.cpp) +— interactive panel (sliders / checkbox / button) on the sim thread driving the +simulation; `hello_threaded` stays the no-ImGui baseline. + +**Validation:** ran the demo (no asserts); **TSan** — the real atlas races are +gone (only environmental Mesa-driver noise; sim ImGui never a racing party); +**ASan** clean; SwiftShader goldens unchanged; `alice_engine` builds. + +## M4 — full input for threaded ImGui (keyboard / text / focus) (DONE) + +> **As built:** matches the plan below. + +Goal: complete the M3 input path so sim-thread ImGui widgets get **everything** — +keyboard keys, modifiers, text entry, focus — so `InputText`, shortcuts, nav, and +keyboard-driven widgets work on the threaded path. + +### Key enabler +The render thread's main ImGui context **already receives all input** every frame: +nothofagus forwards keys via `glfwKeyCallback → ImGui_ImplGlfw_KeyCallback` +([glfw_backend.cpp](../third_party/nothofagus/source/backends/glfw_backend.cpp)), +and `ImGui_ImplGlfw_InitForOpenGL(window, true)` installs the chaining **char** +callback (which `beginSession` does not override). So after the render thread's +`ImGui::NewFrame()` the main context's `io` holds the full keyboard state + typed +characters. M4 **harvests and replays** it on the sim-UI context — reusing +ImGui_ImplGlfw's GLFW→`ImGuiKey` mapping, no new GLFW callbacks. It extends the +existing M3 `harvestImguiInput` → `mThreadedImguiInput` → replay-in-`commitFrame` +channel. + +### What was built +1. **Extended `ThreadedImguiInput`** (frame_runner.h): full named-key state array, + modifier flags, a typed-char buffer, focus. +2. **Harvest (render)**: keyboard named-key range (excluding the mouse-button + sub-range), modifiers, `io.InputQueueCharacters`, `io.AppFocusLost`. +3. **Replay (sim)**: `AddKeyEvent` per key + `ImGuiMod_*`, `AddInputCharacter` + (consumed once under the input mutex), `AddFocusEvent`. +4. **`WantCaptureMouse/Keyboard` back-marshal** → `Canvas::imguiWantsMouse()` / + `imguiWantsKeyboard()`, so the host game `update` can skip world interaction + while a widget has focus (one frame stale by construction). +5. **In-process clipboard** wired onto the sim-UI context + (`Platform_Get/SetClipboardTextFn`) for `InputText` copy/paste (GLFW clipboard + is main-thread-only); `ImGuiConfigFlags_NavEnableKeyboard` enabled. +6. **Demo**: `InputText`, Space shortcut (suppressed while typing), capture-state + readout, auto-spawn gated on `!imguiWantsMouse()`. + +### Known follow-ups (deferred) +- **Cursor shape** (I-beam / resize) — sim→render marshal + `glfwSetCursor`. +- **OS clipboard** across the thread boundary (vs the in-process one). +- **Gamepad nav** — harvest gamepad keys + `NavEnableGamepad`. + +**Validation:** ran the demo; **TSan** 0 races in our code (new state lives in the +existing mutex-guarded sections; only Mesa-driver noise); **ASan** clean; +SwiftShader goldens **49/49**; `alice_engine` builds. + +> Caveat: automated runs are headless (no synthetic keystrokes), so the harvest+ +> replay code executes every frame and `InputText` renders, but real typing / +> shortcut / copy-paste interactivity is best confirmed by running the demo +> interactively. + +## M5 — gamepad input on the sim thread (nothofagus, no ImGui) (NEXT) + +Goal: a game whose logic runs on the **sim** thread can use gamepad input the +normal nothofagus way — both **polling** (`getGamepadAxis`/`getGamepadButton`/ +`isGamepadConnected`/`getConnectedGamepadIds`) and **callbacks** +(`registerGamepadAction`/`registerGamepadAxis`/`registerGamepadConnected`/ +`Disconnected`) — from inside `commit`'s `update`. Today gamepad is polled and +dispatched on the **render** thread only (`renderFrameThreaded` → +`mWindow->endFrame(controller, …)` + `processInputs`); the sim `update` never sees +it. No ImGui involvement. + +### Why a second Controller + +`Controller` is not thread-safe and the window backend polls it on the render +thread (and `close()` must stay render-thread — GLFW is main-thread-only). So the +threaded path uses **two controllers**: +- **render controller** (already passed to `beginThreadedSession`/`renderFrame`): + the raw window-input sink polled by `endFrame`; keep render-thread handling here + (e.g. `Escape → close()`). +- **sim controller** (new): the game's controller, consumed on the sim thread — + the game registers gamepad callbacks on it and/or polls it inside `update`. + +### Mechanism — marshal render → sim, then replay (reuses all of `Controller`) + +The render controller already holds the backend-**normalized** gamepad state +(deadzone / Y-invert / trigger remap done in +[GlfwBackend::endFrame](../third_party/nothofagus/source/backends/glfw_backend.cpp) +before `updateGamepadAxis`), queryable via its getters; and `Controller`'s +`activateGamepadButton` (sets state + queues the edge) / `updateGamepadAxis` (sets +value + fires axis cb on change) / `gamepadConnected`/`Disconnected` are the exact +feed primitives the backend uses. So: + +1. **Harvest (render, after `endFrame`)** — new `harvestGamepadInput()`: read the + render controller into a POD `GamepadSnapshot` (per id `0..GLFW_JOYSTICK_LAST`: + `connected` + `bool buttons[15]` + `float axes[6]`, iterating the + `GamepadButton`/`GamepadAxis` enums), stored under a new `mThreadedGamepadMutex`. +2. **Feed (sim, in `commitFrame` before `update`)** — copy the snapshot under the + mutex, then for each id diff it against the **sim controller's** current state + and replay: changed button → `activateGamepadButton({id,btn,Press|Release})`; + each axis → `updateGamepadAxis(id,axis,value)`; connect/disconnect by comparing + the connected sets → `gamepadConnected`/`Disconnected`; finally + `simController.processInputs()` to dispatch the queued button edges. The game's + `update` then polls / has received callbacks on the sim controller. + +Backend-agnostic ~30-line replay; no GLFW on the sim thread, no new gamepad logic, +no change to `Controller`. + +### API + +- **`Canvas::commit(dt, update, Controller& simController)`** (new overload, + symmetric with `renderFrame(controller)`): `FrameRunner::commitFrame` feeds the + given sim controller from the latest gamepad snapshot *before* invoking `update`. + Existing `commit(dt, update)` and `commit(dt, update, uiCallback)` stay; a + `commit(dt, update, controller, uiCallback)` 4-arg form can be added for the + combined gamepad+ImGui case (not needed for this milestone). +- No new public types — the game uses the existing `Controller` gamepad API. + +### Threading correctness + +`mThreadedGamepadState` is the only shared state (mutex-guarded; tiny critical +sections). The render controller stays render-thread-exclusive; the sim controller +stays sim-thread-exclusive (fed + polled only there). No new overlap cost — the +feed is part of the existing lock-free `commit` game phase. + +### Demo / Verification + +New `examples/hello_threaded_gamepad.cpp`: render controller with `Escape → +close`; a sim controller whose left-stick axes + face buttons move/color a sprite, +polled inside `update` (mirrors `examples/test_gamepad.cpp` on the threaded path). +Build + run (responds with a pad connected; clean no-op without one — harvest/feed +run every frame regardless); **TSan/ASan** clean (only `mThreadedGamepadState` +shared + mutex-guarded; both controllers single-thread-exclusive); SwiftShader +goldens unchanged; parent `alice_engine` builds. + +> Note: real gamepad interaction needs hardware (same as `test_gamepad`); the +> automated checks confirm the marshal/feed machinery runs clean and race-free, +> not actual stick input. + +## Critical files (as built) + +- `third_party/nothofagus/source/render_snapshot.h` / `.cpp` — POD draw list + + `mainUi` clone handle (out-of-line dtor). +- `third_party/nothofagus/source/snapshot_buffers.h` — triple buffer + mailbox. +- `third_party/nothofagus/source/imgui_draw_clone.h` / `.cpp` — `ClonedImDrawData` + + thread-local `GImGui` definition. +- `third_party/nothofagus/third_party/imgui/imconfig.h` — thread-local `GImGui`. +- `third_party/nothofagus/source/frame_runner.h` / `.cpp` — `buildSnapshot` / + `renderSnapshot[Contents]`, threaded `commitFrame` / `renderFrameThreaded`, + sim-UI context, input marshalling (mouse + keyboard/text/focus), the two mutexes + (`mThreadedAssetMutex`, `mImguiMutex`), deferred-free, `WantCapture` back-marshal. +- `third_party/nothofagus/include/canvas.h` + `source/canvas.cpp` — threaded API + (`beginThreadedSession`, `commit[+uiCallback]`, `renderFrame`, `isThreadedRunning`, + `imguiWantsMouse`/`imguiWantsKeyboard`); runtime add/remove via the unified + `addBellota`/`removeBellota`. +- `third_party/nothofagus/source/asset_registry.*` — `collectUnused*` / + `freeRetired*` (deferred free). +- `third_party/nothofagus/examples/hello_threaded.cpp` — no-ImGui threaded demo. +- `third_party/nothofagus/examples/hello_threaded_imgui.cpp` — interactive demo. +- `third_party/nothofagus/examples/CMakeLists.txt`, `CMakeLists.txt` — build entries. + +### M5 (planned, not yet implemented) + +- `third_party/nothofagus/source/frame_runner.h` / `.cpp` — `GamepadSnapshot` + + `mThreadedGamepadState` / `mThreadedGamepadMutex`, `harvestGamepadInput()` + (render), and the sim-controller feed in `commitFrame`. No change to `Controller` + or the window backends. +- `third_party/nothofagus/include/canvas.h` + `source/canvas.cpp` — new + `commit(dt, update, Controller& simController)` overload. +- `third_party/nothofagus/examples/hello_threaded_gamepad.cpp` + + `examples/CMakeLists.txt` — new threaded gamepad demo. diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 24bf9c2a..5464ca72 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -453,6 +453,11 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset mImguiWantsMouse.store(io.WantCaptureMouse, std::memory_order_release); mImguiWantsKeyboard.store(io.WantCaptureKeyboard, std::memory_order_release); + // Marshal the sim UI's desired cursor shape to the render thread, which + // applies it via the platform backend. Valid here (post-Render, before + // the next NewFrame resets it). int storage keeps imgui.h out of the header. + mThreadedCursor.store(static_cast(ImGui::GetMouseCursor()), std::memory_order_release); + if (!snapshot.mainUi) snapshot.mainUi = std::make_unique(); snapshot.mainUi->cloneFrom(ImGui::GetDrawData()); @@ -745,6 +750,10 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager ZoneScopedN("RenderImguiNewFrame"); std::lock_guard imguiLock(mImguiMutex); imguiRtt.drainPendingFontOps(); + // Apply the sim UI's marshalled cursor shape before newImGuiFrame (inside + // beginMainImguiFrame) runs the platform backend's UpdateMouseCursor, which + // reads the main context's GetMouseCursor() and drives glfwSetCursor/SDL. + ImGui::SetMouseCursor(static_cast(mThreadedCursor.load(std::memory_order_acquire))); beginMainImguiFrame(); harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame if (mStats) diff --git a/source/frame_runner.h b/source/frame_runner.h index 1eebf18c..2e5302ab 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -459,6 +459,11 @@ class FrameRunner std::atomic mImguiWantsMouse{false}; std::atomic mImguiWantsKeyboard{false}; + /// Sim UI's desired mouse cursor shape (an ImGuiMouseCursor; int keeps imgui.h + /// out of this header), set each commit from the sim-UI frame and applied by the + /// render thread before its main-context NewFrame. 0 == ImGuiMouseCursor_Arrow. + std::atomic mThreadedCursor{0}; + /// Serializes all access to the (shared) ImGui font atlas between the sim-UI /// context (NewFrame + widgets + Render + clone, on the sim thread) and the /// render/main context (NewFrame + RenderDrawData + atlas rebuild, on the From 5ad2d957e4d6bc4a2d50f3b55cf4a9d24060a41b Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:07:14 -0700 Subject: [PATCH 20/55] ImGui gamepad navigation on the threaded path (B6): enable NavEnableGamepad on the main context (so the platform backend populates the ImGuiKey_Gamepad* keys that are already harvested+replayed) and on the sim-UI context (+HasGamepad) to consume them. Threaded-only (set in beginThreadedSession); single-threaded unchanged. Digital D-pad/face-button nav; analog stick nav is a documented follow-up (bool-only marshal). --- source/frame_runner.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 5464ca72..20787057 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -908,11 +908,15 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) simIo.IniFilename = nullptr; simIo.BackendPlatformName = "nothofagus_sim_ui"; simIo.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // atlas uploaded render-side + // HasGamepad + NavEnableGamepad let the sim-UI process the gamepad nav keys + // the render thread harvests from the main context and replays here (B6). + simIo.BackendFlags |= ImGuiBackendFlags_HasGamepad; simIo.DisplaySize = ImVec2(static_cast(primingScreen.width), static_cast(primingScreen.height)); - // Enable keyboard nav and wire an in-process clipboard so InputText + // Enable keyboard + gamepad nav and wire an in-process clipboard so InputText // copy/paste works on the sim thread (GLFW clipboard is main-thread-only). simIo.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + simIo.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // With keyboard nav on, ImGui would set io.WantCaptureKeyboard true whenever // io.NavActive is true — i.e. merely because a window exists with nav focus, // even when nothing is being typed. That makes imguiWantsKeyboard() stuck at @@ -926,6 +930,14 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) simPlatformIo.Platform_SetClipboardTextFn = threadedSetClipboardText; ImGui::SetCurrentContext(mainContext); // restore the render thread's context + // Enable gamepad nav on the MAIN context too (threaded path only): GLFW's + // ImGui_ImplGlfw_UpdateGamepads is gated on this flag, so without it the main + // context's per-frame NewFrame wouldn't populate the ImGuiKey_Gamepad* keys + // that harvestImguiInput captures and the sim replays. (SDL3 populates them + // regardless; the flag is harmless there.) Single-threaded run/tick never + // calls beginThreadedSession, so its main context is unaffected. + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + // Prime the font atlas on the render thread (uploads the texture) before // any sim commit references it, so the shared atlas is ready and the sim's // NewFrame/layout never races a first-time upload. From 43879a738fcfd767a91c56e1f88b31de3bebc0ad Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:16:02 -0700 Subject: [PATCH 21/55] OS clipboard across the thread boundary on the threaded path (B7): add getClipboardText/setClipboardText to the WindowBackend concept (GLFW glfwGet/SetClipboardString, SDL3 SDL_Get/SetClipboardText, Headless no-op). The sim-UI clipboard callbacks (sim thread) read/write a mutex-guarded marshal; the render thread flushes sim->OS writes immediately and refreshes the OS->sim cache throttled (~0.25s) to avoid a per-frame X11 round-trip. Replaces the in-process-only clipboard. --- source/backends/glfw_backend.cpp | 11 ++++ source/backends/glfw_backend.h | 3 ++ source/backends/headless_backend.cpp | 9 ++++ source/backends/headless_backend.h | 3 ++ source/backends/sdl3_backend.cpp | 15 ++++++ source/backends/sdl3_backend.h | 3 ++ source/backends/window_backend.h | 5 ++ source/frame_runner.cpp | 76 +++++++++++++++++++++------- source/frame_runner.h | 14 +++++ 9 files changed, 120 insertions(+), 19 deletions(-) diff --git a/source/backends/glfw_backend.cpp b/source/backends/glfw_backend.cpp index afc677d1..8d742206 100644 --- a/source/backends/glfw_backend.cpp +++ b/source/backends/glfw_backend.cpp @@ -338,6 +338,17 @@ void GlfwBackend::setWindowTitle(const std::string& title) glfwSetWindowTitle(mGlfwWindow, title.c_str()); } +std::string GlfwBackend::getClipboardText() const +{ + const char* text = glfwGetClipboardString(mGlfwWindow); + return text != nullptr ? std::string(text) : std::string(); +} + +void GlfwBackend::setClipboardText(const std::string& text) +{ + glfwSetClipboardString(mGlfwWindow, text.c_str()); +} + ScreenSize GlfwBackend::getPrimaryMonitorSize() { glfwInit(); // idempotent — safe if Canvas has already initialised it diff --git a/source/backends/glfw_backend.h b/source/backends/glfw_backend.h index 4c3dc485..4d27845b 100644 --- a/source/backends/glfw_backend.h +++ b/source/backends/glfw_backend.h @@ -67,6 +67,9 @@ class GlfwBackend void setWindowTitle(const std::string& title); + std::string getClipboardText() const; + void setClipboardText(const std::string& text); + static ScreenSize getPrimaryMonitorSize(); static float getPrimaryMonitorContentScale(); diff --git a/source/backends/headless_backend.cpp b/source/backends/headless_backend.cpp index 36628dc1..c131e71e 100644 --- a/source/backends/headless_backend.cpp +++ b/source/backends/headless_backend.cpp @@ -87,6 +87,15 @@ void HeadlessBackend::setWindowTitle(const std::string& /*title*/) { } +std::string HeadlessBackend::getClipboardText() const +{ + return std::string(); +} + +void HeadlessBackend::setClipboardText(const std::string& /*text*/) +{ +} + ScreenSize HeadlessBackend::getPrimaryMonitorSize() { return {1920u, 1080u}; diff --git a/source/backends/headless_backend.h b/source/backends/headless_backend.h index c372f552..3308578f 100644 --- a/source/backends/headless_backend.h +++ b/source/backends/headless_backend.h @@ -53,6 +53,9 @@ class HeadlessBackend void setWindowTitle(const std::string& title); + std::string getClipboardText() const; + void setClipboardText(const std::string& text); + static ScreenSize getPrimaryMonitorSize(); static float getPrimaryMonitorContentScale(); diff --git a/source/backends/sdl3_backend.cpp b/source/backends/sdl3_backend.cpp index 0647fe2e..b059011d 100644 --- a/source/backends/sdl3_backend.cpp +++ b/source/backends/sdl3_backend.cpp @@ -337,6 +337,21 @@ void Sdl3Backend::setWindowTitle(const std::string& title) SDL_SetWindowTitle(mSdlWindow, title.c_str()); } +std::string Sdl3Backend::getClipboardText() const +{ + // SDL_GetClipboardText returns a freshly-allocated, never-null string the + // caller must SDL_free. + char* text = SDL_GetClipboardText(); + std::string result = text != nullptr ? std::string(text) : std::string(); + SDL_free(text); + return result; +} + +void Sdl3Backend::setClipboardText(const std::string& text) +{ + SDL_SetClipboardText(text.c_str()); +} + ScreenSize Sdl3Backend::getPrimaryMonitorSize() { SDL_Init(SDL_INIT_VIDEO); // idempotent — safe if Canvas has already initialised it diff --git a/source/backends/sdl3_backend.h b/source/backends/sdl3_backend.h index eed43ad4..d6983cb9 100644 --- a/source/backends/sdl3_backend.h +++ b/source/backends/sdl3_backend.h @@ -56,6 +56,9 @@ class Sdl3Backend void setWindowTitle(const std::string& title); + std::string getClipboardText() const; + void setClipboardText(const std::string& text); + static ScreenSize getPrimaryMonitorSize(); static float getPrimaryMonitorContentScale(); diff --git a/source/backends/window_backend.h b/source/backends/window_backend.h index 49ea93f0..e30e2b85 100644 --- a/source/backends/window_backend.h +++ b/source/backends/window_backend.h @@ -52,6 +52,11 @@ concept WindowBackend = requires( { backend.getWindowSize() } -> std::same_as; { backend.requestClose() } -> std::same_as; { backend.setWindowTitle(title) } -> std::same_as; + + // OS clipboard (main-thread-only on GLFW/SDL). Used by the threaded path to + // marshal the sim-UI context's copy/paste across the thread boundary. + { backend.getClipboardText() } -> std::same_as; + { backend.setClipboardText(title) } -> std::same_as; }; } // namespace Nothofagus diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 20787057..282a25df 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -29,26 +29,32 @@ namespace Nothofagus { -namespace +// Sim-UI ImGui clipboard callbacks (B7). They run on the SIM thread and so can't +// call the main-thread-only window clipboard API; instead they read/write the +// FrameRunner's marshal buffers, which the render thread syncs with the OS clipboard. +// Plain function pointers (ImGui's callback type), so they reach the live instance +// via the static sThreadedClipboardOwner set in beginThreadedSession. +FrameRunner* FrameRunner::sThreadedClipboardOwner = nullptr; + +const char* FrameRunner::threadedGetClipboardText(ImGuiContext*) { -// In-process clipboard for the sim-UI ImGui context. GLFW's clipboard is -// main-thread-only, so the sim thread cannot use it; this gives working -// copy/paste within the app's own text fields. (OS-clipboard integration across -// the thread boundary is a documented follow-up.) Accessed only on the sim thread -// (ImGui calls these during the sim UI frame), so no synchronization is needed. -std::string& threadedClipboardStorage() -{ - static std::string storage; - return storage; -} -const char* threadedGetClipboardText(ImGuiContext*) -{ - return threadedClipboardStorage().c_str(); + // ImGui requires the returned pointer to stay valid until the next call; a + // thread_local buffer (this only runs on the sim thread) satisfies that. + thread_local std::string buffer; + if (sThreadedClipboardOwner != nullptr) + { + std::lock_guard lock(sThreadedClipboardOwner->mClipboardMutex); + buffer = sThreadedClipboardOwner->mClipboardFromOs; + } + return buffer.c_str(); } -void threadedSetClipboardText(ImGuiContext*, const char* text) + +void FrameRunner::threadedSetClipboardText(ImGuiContext*, const char* text) { - threadedClipboardStorage() = (text != nullptr) ? text : ""; -} + if (sThreadedClipboardOwner == nullptr) + return; + std::lock_guard lock(sThreadedClipboardOwner->mClipboardMutex); + sThreadedClipboardOwner->mClipboardToOs = (text != nullptr) ? std::string(text) : std::string(); } // Window is the selected backend type. Forward declared in frame_runner.h; @@ -123,6 +129,11 @@ FrameRunner::~FrameRunner() mSimUiContext = nullptr; } + // Drop the clipboard-callback back-pointer if it targets this instance, so a + // later stray callback can't dereference a destroyed FrameRunner. + if (sThreadedClipboardOwner == this) + sThreadedClipboardOwner = nullptr; + mBackend.shutdown(); // detaches the ImGui renderer backend (ImGui_Impl*_Shutdown). // Tear the window backend down now (instead of waiting for member destruction) @@ -723,6 +734,30 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager controller.processInputs(); } + // OS clipboard marshal (B7): flush any sim→OS write, then refresh the OS→sim + // cache. The OS read is throttled (it's a synchronous X11/Wayland round-trip + // on GLFW) — our own writes are reflected into the cache immediately so + // app-internal copy/paste is instant; cross-app paste lands within ~0.25 s. + { + ZoneScopedN("Clipboard"); + std::lock_guard lock(mClipboardMutex); + if (mClipboardToOs.has_value()) + { + mWindow->setClipboardText(*mClipboardToOs); + mClipboardFromOs = *mClipboardToOs; + mClipboardToOs.reset(); + } + else + { + const float now = mWindow->getTime(); + if (mLastClipboardPollTime < 0.0f || (now - mLastClipboardPollTime) >= 0.25f) + { + mClipboardFromOs = mWindow->getClipboardText(); + mLastClipboardPollTime = now; + } + } + } + auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); mFramebufferWidth = framebufferWidth; mFramebufferHeight = framebufferHeight; @@ -925,9 +960,12 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) // WantCaptureKeyboard reflect real capture only — an active widget (e.g. an // InputText being edited) or an open modal. simIo.ConfigNavCaptureKeyboard = false; + // Route the sim-UI clipboard through the OS marshal (B7): the callbacks + // touch this instance's buffers; the render thread syncs them with the OS. + sThreadedClipboardOwner = this; ImGuiPlatformIO& simPlatformIo = ImGui::GetPlatformIO(); - simPlatformIo.Platform_GetClipboardTextFn = threadedGetClipboardText; - simPlatformIo.Platform_SetClipboardTextFn = threadedSetClipboardText; + simPlatformIo.Platform_GetClipboardTextFn = &FrameRunner::threadedGetClipboardText; + simPlatformIo.Platform_SetClipboardTextFn = &FrameRunner::threadedSetClipboardText; ImGui::SetCurrentContext(mainContext); // restore the render thread's context // Enable gamepad nav on the MAIN context too (threaded path only): GLFW's diff --git a/source/frame_runner.h b/source/frame_runner.h index 2e5302ab..4834f83c 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -464,6 +464,20 @@ class FrameRunner /// render thread before its main-context NewFrame. 0 == ImGuiMouseCursor_Arrow. std::atomic mThreadedCursor{0}; + // ----- OS clipboard marshal (threaded path) ----- + // The sim-UI clipboard callbacks run on the sim thread and can't call the + // main-thread-only window clipboard API, so they read/write these buffers; the + // render thread (consume) refreshes mClipboardFromOs from the OS and flushes a + // pending mClipboardToOs to the OS. sThreadedClipboardOwner lets the plain + // function-pointer ImGui callbacks reach the live instance. + std::mutex mClipboardMutex; + std::string mClipboardFromOs; ///< last OS clipboard text (render refreshes, throttled). + std::optional mClipboardToOs; ///< pending sim→OS write, flushed render-side. + float mLastClipboardPollTime{-1.0f}; ///< throttles the per-frame OS clipboard read. + static FrameRunner* sThreadedClipboardOwner; ///< target for the ImGui clipboard callbacks. + static const char* threadedGetClipboardText(ImGuiContext* ctx); + static void threadedSetClipboardText(ImGuiContext* ctx, const char* text); + /// Serializes all access to the (shared) ImGui font atlas between the sim-UI /// context (NewFrame + widgets + Render + clone, on the sim thread) and the /// render/main context (NewFrame + RenderDrawData + atlas rebuild, on the From a1c38da6f0134e0bbab6fc2382c8fd69cd080cb7 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:27:55 -0700 Subject: [PATCH 22/55] feat(threaded): carry screenSize in RenderSnapshot (C10) RenderSnapshot gains a ScreenSize field, stamped in both produce() arms. consume(Threaded) and renderSnapshotContents now derive the viewport, world transform, and endFrame size from the snapshot rather than the live mScreenSize atomic, so the render thread draws each snapshot's explorer pool in the exact logical size it was committed at. This removes the 1-frame letterbox transient that A4's threaded setScreenSize otherwise produced (render was using the live size while drawing the previous snapshot's pool). Priming / never-produced triple-buffer slots are default-constructed with screenSize {0,0}; reading that directly divides by a zero aspect ratio in computeLetterboxViewport / computeWorldTransformMat (caught as a UBSan error in verification). Both read sites fall back to the live atomic when the snapshot size is degenerate. Single mode always stamps a valid size, so the fallback is a no-op there. Verified: builds across glfw/sdl3 x opengl/vulkan + headless; SwiftShader goldens 21/21; nonvisual tests pass; TSan clean on GLFW and SDL3 (no snapshot-path race; only environmental driver/libc noise); ASan/UBSan 0 errors. --- source/frame_runner.cpp | 22 +++++++++++++++++++--- source/render_snapshot.h | 6 ++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 282a25df..fe5dacc9 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -407,6 +407,9 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset RenderSnapshot& snapshot = mTripleBuffer.writeSlot(); snapshot.commitSeq = commitSeq; snapshot.clearColor = mClearColor; + // Capture the logical canvas size so the render side draws this snapshot's + // pool in its own viewport/world transform (C10: no resize letterbox transient). + snapshot.screenSize = mScreenSize.load(std::memory_order_acquire); // 2) ImGui frame on the sim-UI context — under the ImGui mutex so it never // touches the shared font atlas concurrently with the render thread. @@ -546,6 +549,7 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset // no in-flight snapshot references them). At depth-0 this is the same frame. mSnapshot.commitSeq = ++mCommitSeq; mSnapshot.clearColor = mClearColor; + mSnapshot.screenSize = singleScreen; if (mAutoTextureGC) for (TextureId textureId : assets.collectUnusedTextures()) @@ -665,7 +669,13 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& meshPack.syncToGpu(mBackend); } - const glm::mat3 worldTransformMat = computeWorldTransformMat(mScreenSize.load(std::memory_order_acquire)); + // Snapshot size, falling back to the live atomic for un-stamped priming slots + // (default {0,0} would make the world transform degenerate). Single mode always + // stamps a valid size, so this is a no-op there. + const ScreenSize renderScreen = (snapshot.screenSize.width != 0 && snapshot.screenSize.height != 0) + ? snapshot.screenSize + : mScreenSize.load(std::memory_order_acquire); + const glm::mat3 worldTransformMat = computeWorldTransformMat(renderScreen); { ZoneScopedN("RttPasses"); @@ -761,7 +771,13 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager auto [framebufferWidth, framebufferHeight] = mWindow->getFramebufferSize(); mFramebufferWidth = framebufferWidth; mFramebufferHeight = framebufferHeight; - const ScreenSize threadedScreen = mScreenSize.load(std::memory_order_acquire); + // Use the snapshot's captured size (not the live atomic) so the viewport + // matches the pool this snapshot was built for — no resize letterbox transient (C10). + // Fall back to the live size for priming frames whose slot was never produced + // (default-constructed snapshot → {0,0}; a zero size would divide by a 0 aspect). + const ScreenSize threadedScreen = (snapshot.screenSize.width != 0 && snapshot.screenSize.height != 0) + ? snapshot.screenSize + : mScreenSize.load(std::memory_order_acquire); mGameViewport = computeLetterboxViewport(framebufferWidth, framebufferHeight, threadedScreen.width, threadedScreen.height); mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); @@ -827,7 +843,7 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager { ZoneScopedN("SwapBuffers"); - mWindow->endFrame(controller, mScreenSize.load(std::memory_order_acquire)); + mWindow->endFrame(controller, threadedScreen); } // Snapshot the render controller's freshly-polled input (gamepad + keyboard diff --git a/source/render_snapshot.h b/source/render_snapshot.h index 971b3409..d5dd5bfb 100644 --- a/source/render_snapshot.h +++ b/source/render_snapshot.h @@ -7,6 +7,7 @@ #include "texture_id.h" #include "mesh.h" // MeshId #include "render_target.h" // RenderTargetId +#include "screen_size.h" // ScreenSize namespace Nothofagus { @@ -65,6 +66,11 @@ struct RenderSnapshot std::vector draws; ///< main pass, depth-sorted at commit std::vector rttPasses; ///< insertion order preserved (nested-RTT dependency) glm::vec3 clearColor{0.0f}; + /// Logical canvas size captured at commit. The render side uses this (not the + /// live atomic) so the viewport / world transform match the pool this snapshot + /// was built for — removes the 1-frame letterbox transient on a threaded + /// setScreenSize (roadmap C10). + ScreenSize screenSize{}; /// Cloned main-context ImGui draw data produced on the sim thread (M3), /// null until ImGui has been committed. Lazily allocated by the producer. std::unique_ptr mainUi; From 21534f92264735929050ad0f94f6451ab01642e2 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:27:55 -0700 Subject: [PATCH 23/55] feat(threaded): analog gamepad nav on the threaded ImGui path (C11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B6 enabled ImGui gamepad nav on the threaded path but only marshalled digital key state (bool keyDown via IsKeyDown/AddKeyEvent), so the analog gamepad keys (L2/R2 triggers, L/R stick directions) crossed the render->sim boundary as mere on/off — losing ImGui's smooth, continuous gamepad nav. ThreadedImguiInput gains a float keyAnalog[] side-channel (same indexing as keyDown). harvestImguiInput now captures io.KeysData[index].AnalogValue (the public per-key state array) alongside the down bit, and the sim-side replay routes the analog gamepad keys through AddKeyAnalogEvent instead of AddKeyEvent (selected by a small isAnalogNavKey helper covering GamepadL2/R2 and the GamepadL/RStick* range). Digital keys still replay via AddKeyEvent. Render-backend-independent (the platform backend already populates the analog values on the main context). Verified: builds across glfw/sdl3 x opengl/vulkan + headless; SwiftShader goldens 21/21; TSan clean on GLFW and SDL3 (0 races in our code); ASan/UBSan 0 errors. --- source/frame_runner.cpp | 26 ++++++++++++++++++++++++-- source/frame_runner.h | 4 ++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index fe5dacc9..8b79767a 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -355,6 +355,15 @@ void FrameRunner::runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttMan FrameMark; } +// The ImGui gamepad keys that carry an analog 0..1 value (sticks + triggers), as +// opposed to digital buttons/d-pad. These need AddKeyAnalogEvent on replay so the +// sim-UI gets ImGui's smooth gamepad nav; everything else replays digitally (C11). +static bool isAnalogNavKey(int key) +{ + return key == ImGuiKey_GamepadL2 || key == ImGuiKey_GamepadR2 + || (key >= ImGuiKey_GamepadLStickLeft && key <= ImGuiKey_GamepadRStickDown); +} + const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, AssetRegistry& assets, ImguiRttManager* imguiRtt, ImguiImageManager* imguiImages, float deltaTimeMS, @@ -446,8 +455,16 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset io.AddMouseWheelEvent(input.wheelX, input.wheelY); // Keyboard: replay key state (ImGui dedups to changes), modifiers, text. + // Gamepad stick/trigger keys go through AddKeyAnalogEvent so smooth nav + // (continuous scroll/tween) survives the marshal, not just on/off (C11). for (int key = ImGuiKey_NamedKey_BEGIN; key < ImGuiKey_MouseLeft; ++key) - io.AddKeyEvent(static_cast(key), input.keyDown[key - ImGuiKey_NamedKey_BEGIN]); + { + const int index = key - ImGuiKey_NamedKey_BEGIN; + if (isAnalogNavKey(key)) + io.AddKeyAnalogEvent(static_cast(key), input.keyDown[index], input.keyAnalog[index]); + else + io.AddKeyEvent(static_cast(key), input.keyDown[index]); + } io.AddKeyEvent(ImGuiMod_Ctrl, input.keyCtrl); io.AddKeyEvent(ImGuiMod_Shift, input.keyShift); io.AddKeyEvent(ImGuiMod_Alt, input.keyAlt); @@ -1071,8 +1088,13 @@ void FrameRunner::harvestImguiInput() for (int key = ImGuiKey_NamedKey_BEGIN; key < ImGuiKey_NamedKey_END; ++key) { const int index = key - ImGuiKey_NamedKey_BEGIN; + const bool isRealKey = (key < ImGuiKey_MouseLeft); mThreadedImguiInput.keyDown[index] = - (key < ImGuiKey_MouseLeft) ? ImGui::IsKeyDown(static_cast(key)) : false; + isRealKey ? ImGui::IsKeyDown(static_cast(key)) : false; + // Analog value (gamepad sticks/triggers); 0 for digital keys. KeysData is the + // public per-key state array, indexed by (key - ImGuiKey_NamedKey_BEGIN) (C11). + mThreadedImguiInput.keyAnalog[index] = + isRealKey ? io.KeysData[index].AnalogValue : 0.0f; } mThreadedImguiInput.keyCtrl = io.KeyCtrl; mThreadedImguiInput.keyShift = io.KeyShift; diff --git a/source/frame_runner.h b/source/frame_runner.h index 4834f83c..c7068df1 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -402,6 +402,10 @@ class FrameRunner // a static_assert in the .cpp verifies it covers ImGuiKey_NamedKey_COUNT. static constexpr int kKeyCount = 256; bool keyDown[kKeyCount]{}; // indexed by (ImGuiKey - ImGuiKey_NamedKey_BEGIN) + // Analog value per key (C11). Only the gamepad stick/trigger keys carry a + // meaningful 0..1 value (ImGui's smooth nav); 0 for everything else. Same + // indexing as keyDown. Lets sim-UI gamepad-stick nav match single-threaded. + float keyAnalog[kKeyCount]{}; bool keyCtrl{false}, keyShift{false}, keyAlt{false}, keySuper{false}; static constexpr int kMaxTextChars = 32; unsigned int textChars[kMaxTextChars]{}; // chars typed this frame (consumed on sim) From ab23b6fd4231d56a3f08827b7f0a400b23466c8a Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:27:55 -0700 Subject: [PATCH 24/55] feat(threaded): stats overlay into the sim-UI clone, render + sim rates (C8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the threaded path the stats overlay was drawn on the render-thread main context, but when an app commits its own ImGui the sim-UI clone is what gets presented — so stats only showed for apps with no uiCallback (e.g. the gamepad demo). Move the stats window into the sim-UI frame (produce, after the user uiCallback, before Render) so it lands in the cloned draw data and shows uniformly, with or without app ImGui. The overlay now reports both rates: the render cadence is marshalled to the sim via mRenderFps/mRenderMs atomics (written each consume from the existing mThreadedPerfMonitor), and the sim commit rate comes from a new sim-side PerformanceMonitor fed by an accumulated commit clock (the sim thread has no window clock). The render-thread main-context stats block is removed. hello_threaded_imgui now sets canvas.stats() = true to demonstrate the case (a uiCallback app) that previously hid the overlay. Verified: builds across glfw/sdl3 x opengl/vulkan + headless; SwiftShader goldens 21/21; TSan clean on GLFW and SDL3 (0 races in our code — the sim PerformanceMonitor is sim-exclusive, the render cadence crosses via atomics); ASan/UBSan 0 errors. --- examples/hello_threaded_imgui.cpp | 5 ++++ source/frame_runner.cpp | 43 +++++++++++++++++++++---------- source/frame_runner.h | 11 ++++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp index 2d61a816..deca4543 100644 --- a/examples/hello_threaded_imgui.cpp +++ b/examples/hello_threaded_imgui.cpp @@ -181,6 +181,11 @@ int main() canvas.close(); }); + // Stats overlay alongside the interactive panel: on the threaded path it's drawn + // into the sim-UI frame so it survives in the cloned draw data even though this + // app commits its own ImGui (C8). Shows render fps/ms next to the sim commit rate. + canvas.stats() = true; + canvas.beginThreadedSession(controller); std::thread simThread([&]() diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 8b79767a..da051210 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -474,9 +474,28 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset io.DeltaTime = std::max(deltaTimeMS * 0.001f, 1e-6f); + // Advance the sim commit-rate monitor off an accumulated clock (the sim + // thread has no window clock); smoothed like the render-side monitor (C8). + mSimClockMs += deltaTimeMS; + mSimPerfMonitor->update(mSimClockMs * 0.001f); + ImGui::NewFrame(); if (uiCallback) uiCallback(deltaTimeMS); // user ImGui widgets, on the sim thread + // Stats overlay is drawn HERE (into the sim-UI frame) rather than on the + // render-thread main context, so it ends up in the cloned draw data that + // gets presented even when the app commits its own ImGui. Shows the + // render cadence (marshalled) next to the sim commit rate (C8). + if (mStats) + { + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Always); + ImGui::Begin("stats", NULL, ImGuiWindowFlags_NoTitleBar); + ImGui::Text("%.2f fps (render)", mRenderFps.load(std::memory_order_acquire)); + ImGui::Text("%.2f ms (render)", mRenderMs.load(std::memory_order_acquire)); + ImGui::Text("%.2f fps (sim)", mSimPerfMonitor->getFPS()); + ImGui::End(); + } ImGui::Render(); // Publish what the UI captured this frame so the host's game update (which @@ -804,16 +823,19 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager // ImGui frame so the stats overlay can show it; also fed to RTT ImGui timing. mThreadedPerfMonitor->update(mWindow->getTime()); deltaTimeMS = mThreadedPerfMonitor->getMS(); + // Marshal the render cadence to the sim thread, which draws the stats overlay + // into its UI frame so it survives in the cloned draw data (C8). + mRenderFps.store(mThreadedPerfMonitor->getFPS(), std::memory_order_release); + mRenderMs.store(deltaTimeMS, std::memory_order_release); // Main-context ImGui frame on the render thread (under the ImGui mutex, so it // never touches the shared font atlas concurrently with the sim-UI context). // It does NOT draw user UI (that arrives as a clone) — it (a) drains pending // font ops + lets ImGui_ImplGlfw process window input so we can harvest it for // the sim, and (b) provides valid empty draw data for the frames before the - // first UI commit. The optional stats overlay is drawn here on the main - // context; it shows whenever this main frame is what gets rendered (i.e. when - // the sim commits no UI clone — apps with sim ImGui would need stats in the - // clone instead). + // first UI commit. The stats overlay is NOT drawn here: it lives in the sim-UI + // frame (produce) so it survives in the cloned draw data presented for apps + // that commit their own ImGui (C8). { ZoneScopedN("RenderImguiNewFrame"); std::lock_guard imguiLock(mImguiMutex); @@ -824,15 +846,6 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager ImGui::SetMouseCursor(static_cast(mThreadedCursor.load(std::memory_order_acquire))); beginMainImguiFrame(); harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame - if (mStats) - { - ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Always); - ImGui::Begin("stats", NULL, ImGuiWindowFlags_NoTitleBar); - ImGui::Text("%.2f fps", deltaTimeMS > 0.0f ? 1000.0f / deltaTimeMS : 0.0f); - ImGui::Text("%.2f ms", deltaTimeMS); - ImGui::End(); - } ImGui::Render(); } @@ -957,6 +970,10 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) } // Start the render-loop frame-time monitor (same period as run()'s). mThreadedPerfMonitor.emplace(mWindow->getTime(), 0.5f); + // Sim-thread commit-rate monitor, fed by an accumulated commit clock in + // produce(Threaded) (the sim thread can't use the window clock) (C8). + mSimClockMs = 0.0f; + mSimPerfMonitor.emplace(0.0f, 0.5f); // M3: create the sim-thread UI context, sharing the main font atlas, so the // user's ImGui widgets can run on the sim thread. It has no platform/renderer diff --git a/source/frame_runner.h b/source/frame_runner.h index c7068df1..9016377b 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -369,6 +369,17 @@ class FrameRunner /// the dt fed to the stats overlay and to RTT ImGui timing (flushPending), so /// both paths report the same averaged value. Emplaced in beginThreadedSession. std::optional mThreadedPerfMonitor; + /// Render-thread cadence marshalled to the sim thread so the stats overlay, + /// which is drawn into the sim-UI frame (so it survives in the cloned draw + /// data even when the app commits its own ImGui), can report render fps/ms + /// next to the sim commit rate (C8). Lock-free, written each consume(Threaded). + std::atomic mRenderFps{0.0f}; + std::atomic mRenderMs{0.0f}; + /// Sim-thread commit-rate monitor (smoothed, same recipe as the render one). + /// Updated each produce(Threaded) off an accumulated commit clock; read only + /// on the sim thread for the stats overlay, so no synchronization is needed. + std::optional mSimPerfMonitor; + float mSimClockMs{0.0f}; /// Phase B: serializes the sim thread's runtime structural mutations /// (spawn/despawn → mTextures/mMeshes + usage monitors + pending-free queues) From ccc11a3eaf17cd17d0ee3d7e9478860852f5a1a3 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:51:25 -0700 Subject: [PATCH 25/55] feat(threaded): threaded-safe markTextureAsDirty / setTextureMin|MagFilter These three Canvas texture mutators did immediate GPU work on the calling thread (markTextureAsDirty -> freeGpuResources; setTextureMin|MagFilter -> glTexParameteri / Vulkan sampler rebuild when uploaded), so calling them from a commit() update raced the render thread. They also bypassed the FrameRunner mode-aware wrappers, calling AssetRegistry directly. Defer the GPU work to the render thread via two pure-CPU flags on TexturePack, consumed by syncToGpu (which already runs render-side under mThreadedAssetMutex): - mContentDirty (markTextureAsDirty): syncToGpu frees the GPU handles then the existing first-upload branch re-uploads from the retained CPU texture. - mFilterDirty (setTextureMin|MagFilter): syncToGpu re-applies the filters to the live texture; first upload already bakes them. Indirect index textures are never flagged (they require GL_NEAREST), preserving the prior early-return. The AssetRegistry setters become pure-CPU (no backend access). New FrameRunner mode-aware wrappers take the asset lock when threaded (lockAssetsIfThreaded, same pattern as setTexture) so the sim-thread flag write is serialized against the render thread's syncToGpu read; Canvas routes the three calls through them. No backend changes. Single-threaded behavior is preserved (flags consumed the same frame in consume(Single)); markTextureAsDirty now re-uploads the same frame instead of the next. hello_threaded exercises both from its sim update on a persistent Direct texture (filter flip + markTextureAsDirty every ~250 ms). Verified: builds across glfw/sdl3 x opengl/vulkan + headless; SwiftShader goldens 21/21; nonvisual tests pass; TSan clean on GLFW and SDL3 (0 races in our code); ASan/UBSan 0 errors. --- examples/hello_threaded.cpp | 26 ++++++++++++++++++++++++++ source/asset_registry.cpp | 15 ++++++++------- source/canvas.cpp | 6 +++--- source/frame_runner.cpp | 22 ++++++++++++++++++++++ source/frame_runner.h | 3 +++ source/texture_container.cpp | 22 ++++++++++++++++++++++ source/texture_container.h | 8 ++++++++ 7 files changed, 92 insertions(+), 10 deletions(-) diff --git a/examples/hello_threaded.cpp b/examples/hello_threaded.cpp index 62d27655..8f397f37 100644 --- a/examples/hello_threaded.cpp +++ b/examples/hello_threaded.cpp @@ -100,6 +100,19 @@ int main() // context, all torn down on the render thread once no in-flight frame uses them). std::deque> scratchRenderTargets; + // A persistent Direct (RGBA) texture exercised from the sim thread to cover the + // threaded-safe markTextureAsDirty / setTextureMin|MagFilter path. Filters apply + // to Direct textures (Indirect ones force GL_NEAREST), so the sprites above can't + // exercise them. Both calls now only flag the texture on the sim thread — the GPU + // work (re-upload, sampler change) runs render-side in syncToGpu. + Nothofagus::DirectTexture directTexture(glm::ivec2{4, 4}); + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 4; ++x) + directTexture.setColor(x, y, ((x + y) % 2) ? glm::vec4(1, 1, 1, 1) : glm::vec4(0.2, 0.4, 0.9, 1)); + const Nothofagus::TextureId directTexId = canvas.addTexture(directTexture); + canvas.addBellota({{{screenSize.width * 0.5f, screenSize.height * 0.5f}, 6}, directTexId}); + int lastFilterPhase = -1; + float simTime = 0.0f; auto update = [&](float dt) { @@ -141,6 +154,19 @@ int main() canvas.removeRenderTarget(scratchRenderTargets.front().first); scratchRenderTargets.pop_front(); } + + // Flip the Direct texture's filter (and force a re-upload) ~every 250 ms, + // all from the sim thread — the threaded-safe filter / dirty path. + const int filterPhase = static_cast(simTime / 250.0f) & 1; + if (filterPhase != lastFilterPhase) + { + lastFilterPhase = filterPhase; + const auto mode = filterPhase ? Nothofagus::TextureSampleMode::Linear + : Nothofagus::TextureSampleMode::Nearest; + canvas.setTextureMinFilter(directTexId, mode); + canvas.setTextureMagFilter(directTexId, mode); + canvas.markTextureAsDirty(directTexId); + } }; Nothofagus::Controller controller; diff --git a/source/asset_registry.cpp b/source/asset_registry.cpp index 06b0102f..04406587 100644 --- a/source/asset_registry.cpp +++ b/source/asset_registry.cpp @@ -131,31 +131,32 @@ void AssetRegistry::setTexture(const BellotaId bellotaId, const TextureId textur replaceBellota(bellotaId, bellotaWithNewTexture); } +// These three setters are pure-CPU: they only flag the TexturePack. The actual GPU +// work is performed by TexturePack::syncToGpu on the render thread, so the calls are +// safe from the sim thread (threaded-safety, via the FrameRunner mode-aware wrappers). void AssetRegistry::markTextureAsDirty(const TextureId textureId) { TexturePack& texturePack = mTextures.at(textureId.id); debugCheck(not texturePack.isProxy(), "markTextureAsDirty called on a render target proxy texture."); - texturePack.freeGpuResources(mBackend); + texturePack.mContentDirty = true; // syncToGpu will free + re-upload } void AssetRegistry::setTextureMinFilter(const TextureId textureId, TextureSampleMode mode) { TexturePack& texturePack = mTextures.at(textureId.id); texturePack.minFilter = mode; - // Indirect index textures require GL_NEAREST — skip filter updates for them. + // Indirect index textures require GL_NEAREST — never apply filters to them. if (texturePack.mode == TextureMode::Indirect) return; - if (texturePack.dtextureOpt.has_value()) - mBackend.setTextureMinFilter(texturePack.dtextureOpt.value(), mode); + texturePack.mFilterDirty = true; // syncToGpu re-applies once the texture is uploaded } void AssetRegistry::setTextureMagFilter(const TextureId textureId, TextureSampleMode mode) { TexturePack& texturePack = mTextures.at(textureId.id); texturePack.magFilter = mode; - // Indirect index textures require GL_NEAREST — skip filter updates for them. + // Indirect index textures require GL_NEAREST — never apply filters to them. if (texturePack.mode == TextureMode::Indirect) return; - if (texturePack.dtextureOpt.has_value()) - mBackend.setTextureMagFilter(texturePack.dtextureOpt.value(), mode); + texturePack.mFilterDirty = true; // syncToGpu re-applies once the texture is uploaded } Texture& AssetRegistry::texture(TextureId textureId) diff --git a/source/canvas.cpp b/source/canvas.cpp index b6f260a1..bb57a0ec 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -179,9 +179,9 @@ void Canvas::removeTexture(const TextureId textureId) } void Canvas::setTexture(const BellotaId bellotaId, const TextureId textureId) { mImplPtr->frameRunner.setTexture(mImplPtr->assets, bellotaId, textureId); } -void Canvas::markTextureAsDirty(const TextureId textureId) { mImplPtr->assets.markTextureAsDirty(textureId); } -void Canvas::setTextureMinFilter(const TextureId textureId, TextureSampleMode mode) { mImplPtr->assets.setTextureMinFilter(textureId, mode); } -void Canvas::setTextureMagFilter(const TextureId textureId, TextureSampleMode mode) { mImplPtr->assets.setTextureMagFilter(textureId, mode); } +void Canvas::markTextureAsDirty(const TextureId textureId) { mImplPtr->frameRunner.markTextureAsDirty(mImplPtr->assets, textureId); } +void Canvas::setTextureMinFilter(const TextureId textureId, TextureSampleMode mode) { mImplPtr->frameRunner.setTextureMinFilter(mImplPtr->assets, textureId, mode); } +void Canvas::setTextureMagFilter(const TextureId textureId, TextureSampleMode mode) { mImplPtr->frameRunner.setTextureMagFilter(mImplPtr->assets, textureId, mode); } Texture& Canvas::texture(TextureId textureId) { return mImplPtr->assets.texture(textureId); } const Texture& Canvas::texture(TextureId textureId) const { return mImplPtr->assets.texture(textureId); } diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index da051210..a52b485d 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -1337,6 +1337,28 @@ void FrameRunner::setTexture(AssetRegistry& assets, BellotaId bellotaId, Texture assets.setTexture(bellotaId, textureId); } +// markTextureAsDirty / setTextureMin|MagFilter only set CPU flags on the TexturePack +// (the GPU work is deferred to syncToGpu on the render thread), but those flags are +// read by the render thread's syncToGpu, so take the asset lock when threaded to +// serialize the write — same pattern as setTexture. +void FrameRunner::markTextureAsDirty(AssetRegistry& assets, TextureId textureId) +{ + auto lock = lockAssetsIfThreaded(); + assets.markTextureAsDirty(textureId); +} + +void FrameRunner::setTextureMinFilter(AssetRegistry& assets, TextureId textureId, TextureSampleMode mode) +{ + auto lock = lockAssetsIfThreaded(); + assets.setTextureMinFilter(textureId, mode); +} + +void FrameRunner::setTextureMagFilter(AssetRegistry& assets, TextureId textureId, TextureSampleMode mode) +{ + auto lock = lockAssetsIfThreaded(); + assets.setTextureMagFilter(textureId, mode); +} + MeshId FrameRunner::addMesh(AssetRegistry& assets, const Mesh& mesh) { auto lock = lockAssetsIfThreaded(); diff --git a/source/frame_runner.h b/source/frame_runner.h index 9016377b..727aa1f9 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -216,6 +216,9 @@ class FrameRunner TextureId addTexture(AssetRegistry& assets, const Texture& texture); void removeTexture(AssetRegistry& assets, TextureId textureId); void setTexture(AssetRegistry& assets, BellotaId bellotaId, TextureId textureId); + void markTextureAsDirty(AssetRegistry& assets, TextureId textureId); + void setTextureMinFilter(AssetRegistry& assets, TextureId textureId, TextureSampleMode mode); + void setTextureMagFilter(AssetRegistry& assets, TextureId textureId, TextureSampleMode mode); MeshId addMesh(AssetRegistry& assets, const Mesh& mesh); MeshId addMesh(AssetRegistry& assets, Mesh&& mesh); void removeMesh(AssetRegistry& assets, MeshId meshId); diff --git a/source/texture_container.cpp b/source/texture_container.cpp index a692742c..a0008d9d 100644 --- a/source/texture_container.cpp +++ b/source/texture_container.cpp @@ -25,11 +25,23 @@ void TexturePack::syncToGpu(ActiveBackend& backend) mode == TextureMode::Indirect || mode == TextureMode::TileMap; + // markTextureAsDirty() deferral: free the GPU handles here (render thread) so the + // first-upload branch below re-uploads from the retained CPU texture. clear() (in + // freeGpuResources) only resets the GPU optionals, so `texture` survives. + if (mContentDirty && !isProxy()) + { + if (dtextureOpt.has_value()) + freeGpuResources(backend); + mContentDirty = false; + mFilterDirty = false; // a re-upload bakes the current filters + } + if (isDirty() && !isProxy()) { // First upload — bring atlas, palette, and (for tilemaps) map online, // then wire them together via the backend's link* binding ops. dtextureOpt = backend.uploadTexture(texture.value(), minFilter, magFilter); + mFilterDirty = false; // uploadTexture baked the current filters if (isIndirectOrTileMap) { @@ -85,6 +97,16 @@ void TexturePack::syncToGpu(ActiveBackend& backend) indirectTexture.clearPaletteDirty(); } } + + // setTextureMin|MagFilter() deferral: apply filters to the live GPU texture here + // (render thread). Skipped for Indirect (the setters never flag those — they need + // GL_NEAREST) and when no GPU texture exists yet (the first upload bakes filters). + if (mFilterDirty && dtextureOpt.has_value() && !isProxy()) + { + backend.setTextureMinFilter(*dtextureOpt, minFilter); + backend.setTextureMagFilter(*dtextureOpt, magFilter); + mFilterDirty = false; + } } } diff --git a/source/texture_container.h b/source/texture_container.h index e5fa6da4..466d5bef 100644 --- a/source/texture_container.h +++ b/source/texture_container.h @@ -24,6 +24,14 @@ struct TexturePack TextureSampleMode magFilter = TextureSampleMode::Nearest; TextureMode mode = TextureMode::Direct; ///< CPU-side texture kind. Proxy entries (no CPU texture) keep Direct since they are RGBA color attachments. + /// Pure-CPU deferral flags so sim-thread mutations never touch the GPU directly + /// (the GPU work is consumed by `syncToGpu` on the render thread). `mContentDirty` + /// forces a full free + re-upload (the `markTextureAsDirty` signal, e.g. after the + /// caller mutated raw pixels); `mFilterDirty` re-applies min/mag filters to the + /// live GPU texture (`setTextureMin|MagFilter`). + bool mContentDirty = false; + bool mFilterDirty = false; + bool isProxy() const { return not texture.has_value(); } bool isDirty() const { return not dtextureOpt.has_value(); } From 24bfbaa2ee7b1ddd21ae24dc711561c2d7961b12 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:39:05 -0700 Subject: [PATCH 26/55] roadmap update --- THREADED_MODE_ROADMAP.md | 63 ++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/THREADED_MODE_ROADMAP.md b/THREADED_MODE_ROADMAP.md index 39f137fe..f90da576 100644 --- a/THREADED_MODE_ROADMAP.md +++ b/THREADED_MODE_ROADMAP.md @@ -39,26 +39,47 @@ transient on resize (render uses live size while drawing the previous snapshot's pool); fully removing it = carry `screenSize` in `RenderSnapshot` (optional, see C). -## B. ImGui parity on the threaded path — 🔧 OPEN +## B. ImGui parity on the threaded path — ✅ DONE -5. **Cursor-shape feedback** — marshal sim `ImGui::GetMouseCursor()` → render `glfwSetCursor`/SDL - equivalent (I-beam over text, resize handles). Output, sim→render (reverse of the input harvest). -6. **ImGui gamepad nav** — only `NavEnableKeyboard` is set on the sim-UI context; add - `NavEnableGamepad` + marshal the gamepad ImGui-keys into it. -7. **OS clipboard across the thread boundary** — today the sim-UI context uses an in-process - clipboard; bridge to the real OS clipboard (GLFW/SDL, main-thread-only) across the boundary. +5. **Cursor-shape feedback** — ✅ DONE (`0fabc0f`). Sim `ImGui::GetMouseCursor()` → `mThreadedCursor` + (atomic) → render `ImGui::SetMouseCursor` before the main-context `NewFrame`, so the platform + backend's existing `UpdateMouseCursor` applies it. No backend changes; render-backend-agnostic. +6. **ImGui gamepad nav** — ✅ DONE (`5ad2d95`). `NavEnableGamepad` on the main context (so the + platform backend populates the `ImGuiKey_Gamepad*` keys already harvested+replayed) + on the + sim-UI context (+`HasGamepad`). Threaded-only. Digital D-pad/face-button nav; **analog stick nav + not yet marshalled** (bool-only harvest) — follow-up needs a float side-channel. +7. **OS clipboard across the thread boundary** — ✅ DONE (`43879a7`). New `WindowBackend` + `get/setClipboardText` (GLFW/SDL3/headless); sim-UI clipboard callbacks read/write a mutex-guarded + marshal that the render thread syncs with the OS (writes immediate, OS reads throttled ~0.25 s). -## C. Polish / stretch — 🔧 OPEN +## C. Polish / stretch — 🔧 C8/C10/C11 DONE; C9 deferred -8. **Stats overlay when a sim-UI clone IS present** — stats render on the main context, which is only - shown when the app commits no `uiCallback` (e.g. the gamepad demo). Apps with sim ImGui need - stats injected into the clone (sim side). -9. **Triple-buffer interpolation** — render could interpolate between the two most recent snapshots - by stable id for extra smoothness. -10. **Carry `screenSize` in `RenderSnapshot`** — removes the 1-frame letterbox transient on a - threaded `setScreenSize` (the render would use the snapshot's size, matching its pool, instead of - the live size). Stamp it in `produce`; `consume` uses `snapshot.screenSize` for the viewport / - world transform / `endFrame`. A4 is already race-safe without this. +8. **Stats overlay when a sim-UI clone IS present** — ✅ DONE. The stats window is now drawn into the + sim-UI frame (in `produce(Threaded)`, after the user `uiCallback`, before `Render`) instead of the + render-thread main context, so it ends up in the cloned draw data that gets presented even when + the app commits its own ImGui. The render cadence is marshalled to the sim via `mRenderFps`/ + `mRenderMs` atomics; a sim-side `PerformanceMonitor` (fed by an accumulated commit clock) gives + the sim rate. Overlay shows render fps/ms next to the sim fps. Demo: `hello_threaded_imgui` now + sets `canvas.stats() = true` (a `uiCallback` app — the case that previously hid stats). +9. **Triple-buffer interpolation** — ⏸ DEFERRED. Render could interpolate between the two most recent + snapshots by stable id for extra smoothness. Larger change: needs per-drawable stable IDs in + `DrawItem` (today only `TextureId`/`MeshId`), previous-snapshot retention (consume reads only the + latest `readSlot()`), ID-matching, and a render-time alpha. C10's per-snapshot `screenSize` is a + prerequisite it can build on. +10. **Carry `screenSize` in `RenderSnapshot`** — ✅ DONE. `RenderSnapshot` gained a `ScreenSize + screenSize` field, stamped in both `produce` arms; `consume(Threaded)`/`renderSnapshotContents` + use it for the viewport / world transform / `endFrame`, so the render draws each snapshot's pool + in the size it was built for — no 1-frame letterbox transient on a threaded `setScreenSize`. + Priming/un-stamped slots ({0,0}) fall back to the live atomic to avoid a 0-aspect divide + (a real UBSan bug caught in verification). Single mode always stamps a valid size, so the + fallback is a no-op there. +11. **Analog gamepad nav on the threaded path** — ✅ DONE. `ThreadedImguiInput` gained a + `float keyAnalog[]` side-channel alongside `keyDown`; `harvestImguiInput` captures + `io.KeysData[index].AnalogValue` (the public per-key array), and the replay routes the analog + gamepad keys (`GamepadL2`/`R2`, `GamepadL/RStick*` — via the `isAnalogNavKey` helper) through + `AddKeyAnalogEvent` instead of `AddKeyEvent`. ImGui's smooth gamepad nav (continuous + scroll/tween) now survives the marshal, matching single-threaded. Render-backend-independent, + works on GLFW and SDL3. ## D. Related — tracked separately (not part of "completing threaded mode") @@ -70,8 +91,12 @@ planning doc is superseded and was removed.) - **Full Option A driver collapse** (`run`/`tick` → only `commit`/`renderFrame`) — deferred by choice; would force the ~15 ImGui-in-`update` single-threaded examples onto the threaded ImGui model. -- **markTextureAsDirty / setTextureMin|MagFilter threaded-safety** — currently single/main-thread-only - (immediate GPU work); deferred-GPU versions are a follow-up if needed. +- **markTextureAsDirty / setTextureMin|MagFilter threaded-safety** — ✅ DONE (`ccc11a3`). The three + Canvas mutators no longer do immediate GPU work: two pure-CPU `TexturePack` flags (`mContentDirty` + for re-upload, `mFilterDirty` for min/mag filters) are consumed by `syncToGpu` on the render thread; + `AssetRegistry` setters became pure-CPU and new `FrameRunner` mode-aware wrappers take the asset + lock when threaded (same pattern as `setTexture`). Single-threaded behavior preserved. Demo: + `hello_threaded` flips a Direct texture's filter + marks it dirty from the sim update. - **alice_engine / PocketPy integration** — wiring the engine's Python loop onto `commit`/`renderFrame`; out of nothofagus scope by design. From 556fe18bee7d6721dadacf1742f41a5253d5b44e Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:24:46 -0700 Subject: [PATCH 27/55] feat(threaded): thread-affinity debug guards (debugCheckRenderThread/SimThread) Turns the "sim controller => sim-thread work / render controller => main-thread work" convention into an enforced, self-locating runtime check. During a live threaded session, window/monitor ops must run on the render (main) thread and live-scene access on the sim thread; a mis-placed Controller action now trips an immediate, message-carrying debugCheck instead of a silent race / GLFW-off-main- thread UB. FrameRunner captures both thread ids (render id in beginThreadedSession; sim id on the first threaded produce(), with a consistency check on later commits) and exposes debugCheckRenderThread/debugCheckSimThread. The checks fire only when a threaded session is live and the relevant id is captured, so single-threaded run()/tick() and pre-session setup are unaffected; the whole body is compiled out under NDEBUG (zero release cost). Canvas routes the window ops (getCurrentMonitor/isFullscreen/setFullScreenOnMonitor/setWindowed/ setWindowTitle/windowSize) and the live-scene accessor bellota() through the guards. Already-thread-safe ops (setScreenSize atomic, add/removeBellota, setTexture, markTextureAsDirty/setTextureMin|MagFilter, setTint, close) stay unguarded. Audit confirmed current code respects the rules: the render path draws from the POD snapshot (assets.bellotas().at), never Canvas::bellota(); the explorer pre-pass's Canvas::bellota() runs in produce() (sim thread); existing threaded demos' render/single controllers are close()-only. Verified: all four threaded demos run clean under a debug build (guards active, 0 trips) and a mis-placed op positively trips the guard with the right message; single-thread demos inert; release builds compile the guards out; SwiftShader goldens 21/21. --- source/canvas.cpp | 16 ++++++++-------- source/frame_runner.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ source/frame_runner.h | 15 +++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/source/canvas.cpp b/source/canvas.cpp index 8fb23113..67ade50d 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -121,10 +121,10 @@ Canvas::~Canvas() // Window / display — forward to FrameRunner // --------------------------------------------------------------------------- -std::size_t Canvas::getCurrentMonitor() const { return mImplPtr->frameRunner.getCurrentMonitor(); } -bool Canvas::isFullscreen() const { return mImplPtr->frameRunner.isFullscreen(); } -void Canvas::setFullScreenOnMonitor(std::size_t monitor) { mImplPtr->frameRunner.setFullScreenOnMonitor(monitor); } -void Canvas::setWindowed() { mImplPtr->frameRunner.setWindowed(); } +std::size_t Canvas::getCurrentMonitor() const { mImplPtr->frameRunner.debugCheckRenderThread("getCurrentMonitor"); return mImplPtr->frameRunner.getCurrentMonitor(); } +bool Canvas::isFullscreen() const { mImplPtr->frameRunner.debugCheckRenderThread("isFullscreen"); return mImplPtr->frameRunner.isFullscreen(); } +void Canvas::setFullScreenOnMonitor(std::size_t monitor) { mImplPtr->frameRunner.debugCheckRenderThread("setFullScreenOnMonitor"); mImplPtr->frameRunner.setFullScreenOnMonitor(monitor); } +void Canvas::setWindowed() { mImplPtr->frameRunner.debugCheckRenderThread("setWindowed"); mImplPtr->frameRunner.setWindowed(); } ScreenSize Canvas::screenSize() const { return mImplPtr->frameRunner.screenSize(); } void Canvas::setScreenSize(const ScreenSize& screenSize) { mImplPtr->frameRunner.setScreenSize(screenSize); } void Canvas::setClearColor(glm::vec3 clearColor) { mImplPtr->frameRunner.setClearColor(clearColor); } @@ -132,8 +132,8 @@ void Canvas::setTargetFps(std::optional targetFps) { mImplPtr->fram std::optional Canvas::targetFps() const { return mImplPtr->frameRunner.targetFps(); } void Canvas::setAutoRemoveUnusedTextures(bool enabled) { mImplPtr->frameRunner.setAutoRemoveUnusedTextures(enabled); } void Canvas::setAutoRemoveUnusedMeshes(bool enabled) { mImplPtr->frameRunner.setAutoRemoveUnusedMeshes(enabled); } -void Canvas::setWindowTitle(const std::string& title) { mImplPtr->frameRunner.setWindowTitle(title); } -ScreenSize Canvas::windowSize() const { return mImplPtr->frameRunner.windowSize(); } +void Canvas::setWindowTitle(const std::string& title) { mImplPtr->frameRunner.debugCheckRenderThread("setWindowTitle"); mImplPtr->frameRunner.setWindowTitle(title); } +ScreenSize Canvas::windowSize() const { mImplPtr->frameRunner.debugCheckRenderThread("windowSize"); return mImplPtr->frameRunner.windowSize(); } ViewportRect Canvas::gameViewport() const { return mImplPtr->frameRunner.gameViewport(); } ImguiOverlayRect Canvas::imguiOverlayViewport() const @@ -164,8 +164,8 @@ void Canvas::removeBellota(const BellotaId bellotaId) mImplPtr->frameRunner.removeBellota(mImplPtr->assets, bellotaId); } -Bellota& Canvas::bellota(BellotaId bellotaId) { return mImplPtr->assets.bellota(bellotaId); } -const Bellota& Canvas::bellota(BellotaId bellotaId) const { return mImplPtr->assets.bellota(bellotaId); } +Bellota& Canvas::bellota(BellotaId bellotaId) { mImplPtr->frameRunner.debugCheckSimThread("bellota"); return mImplPtr->assets.bellota(bellotaId); } +const Bellota& Canvas::bellota(BellotaId bellotaId) const { mImplPtr->frameRunner.debugCheckSimThread("bellota"); return mImplPtr->assets.bellota(bellotaId); } void Canvas::setTint(const BellotaId bellotaId, const Tint& tint) { mImplPtr->assets.setTint(bellotaId, tint); } void Canvas::removeTint(const BellotaId bellotaId) { mImplPtr->assets.removeTint(bellotaId); } diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 7a327efd..6aad2703 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -235,6 +235,30 @@ ScreenSize FrameRunner::windowSize() const return mWindow->getWindowSize(); } +void FrameRunner::debugCheckRenderThread(const char* op) const +{ +#ifndef NDEBUG + if (mThreadedRunning.load(std::memory_order_acquire) && mRenderThreadId != std::thread::id{}) + debugCheck(std::this_thread::get_id() == mRenderThreadId, + std::string("Canvas::") + op + " must be called on the render (main) thread " + "during a threaded session (it touches the window/monitor)."); +#else + (void)op; +#endif +} + +void FrameRunner::debugCheckSimThread(const char* op) const +{ +#ifndef NDEBUG + if (mThreadedRunning.load(std::memory_order_acquire) && mSimThreadId != std::thread::id{}) + debugCheck(std::this_thread::get_id() == mSimThreadId, + std::string("Canvas::") + op + " must be called on the sim thread during a " + "threaded session (it accesses the live scene)."); +#else + (void)op; +#endif +} + DirectTexture FrameRunner::takeScreenshot() const { const ScreenSize screen = mScreenSize.load(std::memory_order_acquire); @@ -380,6 +404,17 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset // Sim thread: CPU only, no GL. ZoneScopedN("commitFrame"); + // Thread-affinity guards: capture the sim thread on the first commit of the + // session (before `update`/explorers touch the live scene), and assert every + // later commit is the same thread. Debug-only. +#ifndef NDEBUG + const std::thread::id thisThread = std::this_thread::get_id(); + if (mSimThreadId == std::thread::id{}) + mSimThreadId = thisThread; + else + debugCheck(mSimThreadId == thisThread, "commit() called from more than one thread"); +#endif + // Stamp the commit seq BEFORE `update` so spawn/despawn can tag retired // resources with the commit at which they leave the scene. const std::uint64_t commitSeq = ++mCommitSeq; @@ -1004,6 +1039,11 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) // Remember the canvas so produce(Threaded) can drive the explorer pre-pass. mThreadedCanvas = &canvas; + // Thread-affinity guards: this runs on the render (main) thread. Reset the sim id + // so the first produce(Threaded) of this session re-captures it. + mRenderThreadId = std::this_thread::get_id(); + mSimThreadId = std::thread::id{}; + // Bind input callbacks + reset the close flag (same as run()'s session start). mWindow->beginSession(controller); if (!mSessionStarted) diff --git a/source/frame_runner.h b/source/frame_runner.h index 00281505..fede68ed 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -21,6 +21,7 @@ #include #include #include +#include struct ImGuiStyle; // global-scope (Dear ImGui); held by unique_ptr to keep imgui.h out of this header. struct ImGuiContext; // global-scope; the sim-thread UI context (M3) is held as an opaque pointer. @@ -182,6 +183,15 @@ class FrameRunner /// Thread-safe: true until the window is closed. Read by the sim loop. bool threadedRunning() const { return mThreadedRunning.load(std::memory_order_acquire); } + /// Thread-affinity guards (debug-only). During a live threaded session, assert the + /// caller is on the thread that owns the operation: window/monitor ops on the render + /// (main) thread, live-scene access on the sim thread. No-op when no session is live + /// (single-thread run/tick, setup) or the relevant thread id isn't captured yet, and + /// fully compiled out under NDEBUG. Turns a mis-placed Controller action (e.g. a + /// window op from a sim action) into an immediate, located failure instead of UB. + void debugCheckRenderThread(const char* op) const; + void debugCheckSimThread(const char* op) const; + /// Thread-safe: whether the threaded ImGui UI captured the mouse / keyboard on /// the most recent commit (so host game logic can ignore that input). bool threadedWantsMouse() const { return mImguiWantsMouse.load(std::memory_order_acquire); } @@ -378,6 +388,11 @@ class FrameRunner SnapshotTripleBuffer mTripleBuffer; ///< sim→render snapshot hand-off (lock-free). std::atomic mThreadedRunning{false}; ///< true while the threaded session is live. Canvas* mThreadedCanvas{nullptr}; ///< canvas bound by beginThreadedSession; drives explorers in produce(Threaded). + /// Thread identities for the affinity guards (debug-only). Render id is captured in + /// beginThreadedSession (main thread); sim id is captured on the first threaded + /// produce(). Default-constructed (== no id) until then. + std::thread::id mRenderThreadId; + std::thread::id mSimThreadId; /// Render-loop frame-time monitor for the threaded path, mirroring the local /// PerformanceMonitor that run() uses single-threaded: the smoothed getMS() is /// the dt fed to the stats overlay and to RTT ImGui timing (flushPending), so From 5f6192a2cd5b0d341aa8d3212916f4a7a3edfbfe Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:10:12 -0700 Subject: [PATCH 28/55] refactor(threaded): make the render-thread guard always-on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window/monitor ops require the main thread in every mode (GLFW/SDL), so the render-thread guard no longer needs the mThreadedRunning gate. Anchor mRenderThreadId at construction (the Canvas ctor runs on the main thread, where the window is created) and assert unconditionally, so a window op invoked off the main thread is caught single-threaded and outside a session too — not only during a live threaded session. The sim-thread guard stays session-scoped: a distinct sim thread only exists while a threaded session is live, and the captured sim id would be stale during post-session teardown (where main-thread cleanup legitimately touches the scene), so gating it on mThreadedRunning is the correct, race-free predicate. beginThreadedSession drops its now-redundant render-id capture (still resets the sim id per session). Verified (debug, guards active): single-thread window op on the main thread passes; the same op from the sim thread trips with the message; all threaded + single-thread demos run clean. Release compiles the guards out; goldens 21/21. --- source/frame_runner.cpp | 18 +++++++++++++----- source/frame_runner.h | 14 ++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 6aad2703..5ed585d2 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -85,6 +85,11 @@ FrameRunner::FrameRunner( mHeadless(headless), mGameViewport{0, 0, 0, 0} { + // The constructing thread is the render/main thread: the window lives here and + // GLFW/SDL require window + monitor ops on it, in every mode. Anchor the + // render-thread guard to it so window ops are checked even single-threaded. + mRenderThreadId = std::this_thread::get_id(); + // Initialize the window backend (creates window, GL/Vulkan context, loads GLAD for OpenGL). // The GL swap interval (derived from the present mode) is applied here, while the GL // context is being made current; it is a no-op in Vulkan builds. @@ -238,10 +243,13 @@ ScreenSize FrameRunner::windowSize() const void FrameRunner::debugCheckRenderThread(const char* op) const { #ifndef NDEBUG - if (mThreadedRunning.load(std::memory_order_acquire) && mRenderThreadId != std::thread::id{}) + // Ungated: window/monitor ops require the main thread in every mode, and the + // render-thread id is anchored at construction, so this holds before/after a + // session and in single-thread run()/tick() too (all on the construction thread). + if (mRenderThreadId != std::thread::id{}) debugCheck(std::this_thread::get_id() == mRenderThreadId, std::string("Canvas::") + op + " must be called on the render (main) thread " - "during a threaded session (it touches the window/monitor)."); + "(it touches the window/monitor)."); #else (void)op; #endif @@ -1039,9 +1047,9 @@ void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) // Remember the canvas so produce(Threaded) can drive the explorer pre-pass. mThreadedCanvas = &canvas; - // Thread-affinity guards: this runs on the render (main) thread. Reset the sim id - // so the first produce(Threaded) of this session re-captures it. - mRenderThreadId = std::this_thread::get_id(); + // Thread-affinity guards: the render-thread id is anchored at construction. Reset + // the sim id so the first produce(Threaded) of this session re-captures it (the + // sim guard is session-scoped — a distinct sim thread only exists while live). mSimThreadId = std::thread::id{}; // Bind input callbacks + reset the close flag (same as run()'s session start). diff --git a/source/frame_runner.h b/source/frame_runner.h index fede68ed..ccacc04a 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -183,12 +183,14 @@ class FrameRunner /// Thread-safe: true until the window is closed. Read by the sim loop. bool threadedRunning() const { return mThreadedRunning.load(std::memory_order_acquire); } - /// Thread-affinity guards (debug-only). During a live threaded session, assert the - /// caller is on the thread that owns the operation: window/monitor ops on the render - /// (main) thread, live-scene access on the sim thread. No-op when no session is live - /// (single-thread run/tick, setup) or the relevant thread id isn't captured yet, and - /// fully compiled out under NDEBUG. Turns a mis-placed Controller action (e.g. a - /// window op from a sim action) into an immediate, located failure instead of UB. + /// Thread-affinity guards (debug-only, compiled out under NDEBUG). Turn a mis-placed + /// Canvas call into an immediate, located failure instead of UB / a data race. + /// - Render guard: **always on**. Window/monitor ops require the main thread in every + /// mode; the id is anchored at construction, so it holds single-threaded and outside + /// a session too. + /// - Sim guard: **session-scoped**. A distinct sim thread only exists while a threaded + /// session is live, so this no-ops otherwise (single-thread run/tick, setup, and + /// post-session teardown — where the captured sim id would be stale). void debugCheckRenderThread(const char* op) const; void debugCheckSimThread(const char* op) const; From 6245a4c2d10b8609adddcc5031c4ae3fcd7d2da2 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:19:50 -0700 Subject: [PATCH 29/55] feat(threaded): commit(dt, update, ui, Controller&) overload Feeds the sim controller AND runs the ImGui uiCallback in one commit (produce already supports both). Backs the run(...) convenience. --- include/canvas.h | 7 +++++++ source/canvas.cpp | 6 ++++++ source/frame_runner.cpp | 10 ++++++++++ source/frame_runner.h | 6 ++++++ 4 files changed, 29 insertions(+) diff --git a/include/canvas.h b/include/canvas.h index 10b6ee18..6b3678b6 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -751,6 +751,13 @@ class Canvas /// No ImGui. void commit(float deltaTime, std::function update, Controller& simController); + /// Sim thread: feed `simController` (game input) AND run `uiCallback` as the + /// interactive ImGui frame — the combination of the two overloads above. Keep + /// ImGui calls in `uiCallback`, not `update`. Used by the `run(update, ui, + /// simController, renderController)` convenience. + void commit(float deltaTime, std::function update, + std::function uiCallback, Controller& simController); + /// Main thread: render the latest published snapshot and pump window/input. void renderFrame(Controller& controller); diff --git a/source/canvas.cpp b/source/canvas.cpp index 67ade50d..3468f745 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -461,6 +461,12 @@ void Canvas::commit(float deltaTime, std::function update, Controll mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update), simController); } +void Canvas::commit(float deltaTime, std::function update, + std::function uiCallback, Controller& simController) +{ + mImplPtr->frameRunner.commitFrame(mImplPtr->assets, deltaTime, std::move(update), std::move(uiCallback), simController); +} + void Canvas::renderFrame(Controller& controller) { mImplPtr->frameRunner.renderFrameThreaded(mImplPtr->assets, mImplPtr->imguiRtt, controller); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 5ed585d2..7b84a53b 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -1156,6 +1156,16 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, std::move(update), {}, &simController); } +void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, std::function uiCallback, + Controller& simController) +{ + // Sim thread: feed the sim controller AND run the ImGui uiCallback. produce() + // already handles both independently (feed before update; ui after update). + produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, nullptr, deltaTimeMS, + std::move(update), std::move(uiCallback), &simController); +} + void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller) { ZoneScopedN("renderFrameThreaded"); diff --git a/source/frame_runner.h b/source/frame_runner.h index ccacc04a..98b9ba54 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -211,6 +211,12 @@ class FrameRunner void commitFrame(AssetRegistry& assets, float deltaTimeMS, std::function update, Controller& simController); + /// Sim thread: feeds `simController` AND runs `uiCallback` as the ImGui frame — + /// the combination the run(update, ui, simController, renderController) convenience needs. + void commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, std::function uiCallback, + Controller& simController); + /// Main thread: acquire the latest published snapshot and render it (GPU /// upload + draw + present), poll window/input, and refresh the running flag. void renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller); From 01a6c18c3128561c4f81afe696fdb27e781f466b Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:23:19 -0700 Subject: [PATCH 30/55] feat(threaded): run(update, ui, simController, renderController) convenience FrameRunner::runThreaded owns the sim thread + both loops (commit on the sim thread, renderFrame on the main thread, join on close); internal PerformanceMonitor (steady_clock) + limitFrameRate. Makes the two-thread split a one-liner. --- include/canvas.h | 10 ++++++++++ source/canvas.cpp | 7 +++++++ source/frame_runner.cpp | 34 ++++++++++++++++++++++++++++++++++ source/frame_runner.h | 9 +++++++++ 4 files changed, 60 insertions(+) diff --git a/include/canvas.h b/include/canvas.h index 6b3678b6..996638dc 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -691,6 +691,16 @@ class Canvas */ void run(std::function update, Controller& controller); + /// Threaded convenience: run the sim/render split as a one-liner. nothofagus owns + /// the sim thread and both loops. `update` (game logic) and `uiCallback` (ImGui, + /// on the sim-UI context) run on the sim thread against `simController` (game + /// input); the render loop pumps window/input/present on this thread against + /// `renderController` (window ops + `close`). Returns when the window closes. + /// Keep ImGui in `uiCallback`, not `update`. The raw beginThreadedSession/commit/ + /// renderFrame primitives remain for apps that want to own their threading. + void run(std::function update, std::function uiCallback, + Controller& simController, Controller& renderController); + /// Execute a single frame with a caller-supplied delta time (in milliseconds). void tick(float deltaTime, std::function update, Controller& controller); void tick(float deltaTime, std::function update); diff --git a/source/canvas.cpp b/source/canvas.cpp index 3468f745..3ba9aa8c 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -410,6 +410,13 @@ void Canvas::run(std::function update, Controller& contro mImplPtr->frameRunner.run(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, update, controller); } +void Canvas::run(std::function update, std::function uiCallback, + Controller& simController, Controller& renderController) +{ + mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, + std::move(update), std::move(uiCallback), simController, renderController); +} + void Canvas::tick(float deltaTime, std::function update, Controller& controller) { mImplPtr->frameRunner.tick(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, deltaTime, update, controller); diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 7b84a53b..642e8253 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -991,6 +991,40 @@ void FrameRunner::run(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& im } } +void FrameRunner::runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, + std::function update, std::function uiCallback, + Controller& simController, Controller& renderController) +{ + beginThreadedSession(canvas, renderController); + + std::thread simThread([&]() + { + // Sim cadence off a monotonic steady_clock — the window clock belongs to the + // render thread, so keep this thread off it. Smoothed dt like run()'s. + const auto simEpoch = std::chrono::steady_clock::now(); + auto simSeconds = [&]{ return std::chrono::duration(std::chrono::steady_clock::now() - simEpoch).count(); }; + PerformanceMonitor performanceMonitor(simSeconds(), 0.5f); + auto nextDeadline = std::chrono::steady_clock::now(); + while (mThreadedRunning.load(std::memory_order_acquire)) + { + performanceMonitor.update(simSeconds()); + commitFrame(assets, performanceMonitor.getMS(), update, uiCallback, simController); + limitFrameRate(nextDeadline); // honors setTargetFps; free-runs otherwise + } + }); + + // Render loop on the main thread (window/input/present); vsync-governed, plus the + // same optional targetFps cap as run(). + auto nextDeadline = std::chrono::steady_clock::now(); + while (mThreadedRunning.load(std::memory_order_acquire)) + { + renderFrameThreaded(assets, imguiRtt, renderController); + limitFrameRate(nextDeadline); + } + + simThread.join(); +} + void FrameRunner::limitFrameRate(std::chrono::steady_clock::time_point& nextDeadline) { using namespace std::chrono; diff --git a/source/frame_runner.h b/source/frame_runner.h index 98b9ba54..5117578b 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -165,6 +165,15 @@ class FrameRunner ImguiImageManager& imguiImages, float deltaTimeMS, std::function update, Controller& controller); + /// Convenience over the threaded primitives: owns the sim thread and both loops. + /// Runs the commit loop (update + uiCallback + simController) on a spawned thread + /// and the renderFrame loop (renderController) on this (main) thread, then joins. + /// The raw beginThreadedSession/commitFrame/renderFrameThreaded primitives stay + /// for apps that want to own their threading. + void runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, + std::function update, std::function uiCallback, + Controller& simController, Controller& renderController); + /// Captures the last rendered frame visible to the user as a DirectTexture (RGBA). DirectTexture takeScreenshot() const; From b46687072999c32fae5cabaffc1480756a8fd3bd Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:24:56 -0700 Subject: [PATCH 31/55] example(test_keyboard): drive via run(update, ui, simController, renderController) ImGui moved from update to a ui callback; controller split into simController (W/S/A/D/SPACE/Q game input) and renderController (F fullscreen, ESC close). Now a genuine two-thread app via the run(...) convenience. --- examples/test_keyboard.cpp | 61 ++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/examples/test_keyboard.cpp b/examples/test_keyboard.cpp index 18e6a0c9..8c04c5a4 100644 --- a/examples/test_keyboard.cpp +++ b/examples/test_keyboard.cpp @@ -42,6 +42,7 @@ int main() bool leftKeyPressed = false; bool rightKeyPressed = false; + // Game logic — runs on the sim thread (no ImGui here). auto update = [&](float dt) { time += dt; @@ -50,14 +51,6 @@ int main() float scale = 2.0f + 0.5f * std::sin(0.005f * time); bellota.transform().scale() = glm::vec2(scale, scale); - ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); - ImGui::Begin("Hello there!"); - ImGui::Text("Discrete control keys: W, S, ESCAPE"); - ImGui::Text("Continuous control keys: A, D"); - ImGui::Text("Show/hide performance stats: Q"); - ImGui::Text("Toggle fullscreen/windowed in the current monitor: F"); - ImGui::End(); - if (rotate) bellota.transform().angle() += angularSpeed * dt; @@ -70,36 +63,56 @@ int main() bellota.transform().location().x = std::clamp(bellota.transform().location().x, 10, screenSize.width-10); }; - Nothofagus::Controller controller; - controller.registerAction({Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press}, [&]() + // ImGui — runs on the sim-UI context (sim thread), cloned to the render thread. + auto ui = [&](float) + { + ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); + ImGui::Begin("Hello there!"); + ImGui::Text("Discrete control keys: W, S, ESCAPE"); + ImGui::Text("Continuous control keys: A, D"); + ImGui::Text("Show/hide performance stats: Q"); + ImGui::Text("Toggle fullscreen/windowed in the current monitor: F"); + ImGui::End(); + }; + + // Game input — dispatched on the sim thread; mutates the live scene / sim state. + Nothofagus::Controller simController; + simController.registerAction({Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().y += 10.0f; }); - controller.registerAction({Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press}, [&]() + simController.registerAction({Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().y -= 10.0f; }); - controller.registerAction({Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Press}, [&]() + simController.registerAction({Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Press}, [&]() { leftKeyPressed = true; }); - controller.registerAction({Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Release}, [&]() + simController.registerAction({Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Release}, [&]() { leftKeyPressed = false; }); - controller.registerAction({Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Press}, [&]() + simController.registerAction({Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Press}, [&]() { rightKeyPressed = true; }); - controller.registerAction({Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Release}, [&]() + simController.registerAction({Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Release}, [&]() { rightKeyPressed = false; }); - controller.registerAction({ Nothofagus::Key::Q, Nothofagus::DiscreteTrigger::Press }, [&]() + simController.registerAction({ Nothofagus::Key::Q, Nothofagus::DiscreteTrigger::Press }, [&]() { canvas.stats() = not canvas.stats(); }); - controller.registerAction({ Nothofagus::Key::F, Nothofagus::DiscreteTrigger::Press }, [&]() + simController.registerAction({Nothofagus::Key::SPACE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + rotate = not rotate; + }); + + // Window input — dispatched on the main thread; window/monitor ops + close. + Nothofagus::Controller renderController; + renderController.registerAction({ Nothofagus::Key::F, Nothofagus::DiscreteTrigger::Press }, [&]() { if (canvas.isFullscreen()) { @@ -111,13 +124,11 @@ int main() canvas.setFullScreenOnMonitor(monitor); } }); - controller.registerAction({Nothofagus::Key::SPACE, Nothofagus::DiscreteTrigger::Press}, [&]() - { - rotate = not rotate; - }); - controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.close(); }); - - canvas.run(update, controller); - + renderController.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.close(); }); + + // Multithreaded convenience: sim thread runs update + ui + simController; the + // main thread runs the render loop + renderController. + canvas.run(update, ui, simController, renderController); + return 0; } \ No newline at end of file From c43539aaa967d98f519a81034a1032d3c3e3ff3e Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:51:57 -0700 Subject: [PATCH 32/55] migrating hello_nothofagus to threaded --- examples/hello_nothofagus.cpp | 25 ++++++++++++++++--------- include/canvas.h | 5 +++++ source/canvas.cpp | 10 ++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/examples/hello_nothofagus.cpp b/examples/hello_nothofagus.cpp index 7ac3f99f..5a25abb2 100644 --- a/examples/hello_nothofagus.cpp +++ b/examples/hello_nothofagus.cpp @@ -63,9 +63,12 @@ int main() Nothofagus::BellotaId bellotaId4 = canvas.addBellota({ {{100.0f, 50.0f}, 2.0}, textureId2 }); float time = 0.0f; + // Shared between update and ui — both run on the sim thread, so plain bools are safe + // (ui writes them, update reads them; no atomics needed). bool rotate = true; bool visible = true; + // Game logic — runs on the sim thread (no ImGui here). auto update = [&](float dt) { time += dt; @@ -74,22 +77,26 @@ int main() bellota2.transform().location().x = 75.0f + 60.0f * std::sin(0.0005f * time); Nothofagus::Bellota& bellota3 = canvas.bellota(bellotaId3); + if (rotate) + bellota3.transform().angle() = 0.1f * time; + bellota3.visible() = visible; + }; - // you can directly use ImGui + // Interactive ImGui — runs on the sim-UI context (sim thread), cloned to the render + // thread. Widgets respond to the mouse (input is marshaled render -> sim). + auto ui = [&](float) + { ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); ImGui::Begin("Hello there!"); ImGui::Text("May ImGui be with you..."); ImGui::Checkbox("Rotate?", &rotate); - if (rotate) - { - bellota3.transform().angle() = 0.1f * time; - } ImGui::Checkbox("Visible?", &visible); - bellota3.visible() = visible; ImGui::End(); }; - - canvas.run(update); - + + // Multithreaded convenience: the sim thread runs update + ui; the main thread runs + // the render loop. No controllers — this demo closes via the window's X button. + canvas.run(update, ui); + return 0; } \ No newline at end of file diff --git a/include/canvas.h b/include/canvas.h index 996638dc..7580ca82 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -701,6 +701,11 @@ class Canvas void run(std::function update, std::function uiCallback, Controller& simController, Controller& renderController); + /// Threaded convenience for apps with no game/window input controllers (they close + /// via the window). Same as the four-arg overload but nothofagus supplies empty + /// controllers internally. Owns the sim thread; keep ImGui in `uiCallback`. + void run(std::function update, std::function uiCallback); + /// Execute a single frame with a caller-supplied delta time (in milliseconds). void tick(float deltaTime, std::function update, Controller& controller); void tick(float deltaTime, std::function update); diff --git a/source/canvas.cpp b/source/canvas.cpp index 3ba9aa8c..d00b0e2b 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -417,6 +417,16 @@ void Canvas::run(std::function update, std::function u std::move(update), std::move(uiCallback), simController, renderController); } +void Canvas::run(std::function update, std::function uiCallback) +{ + // Distinct empty controllers: the sim controller is fed on the sim thread and the + // render controller pumped on the main thread, so they must not be the same object. + Controller simController; + Controller renderController; + mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, + std::move(update), std::move(uiCallback), simController, renderController); +} + void Canvas::tick(float deltaTime, std::function update, Controller& controller) { mImplPtr->frameRunner.tick(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, deltaTime, update, controller); From 36b432dddc38db3239f82dc369028393504b136d Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:36:07 -0700 Subject: [PATCH 33/55] newer tracking doc --- THREADED_MODE.md | 133 +++++++++++ THREADED_MODE_ROADMAP.md | 110 --------- include/canvas.h | 10 +- nothofagus_threading_plan.md | 432 ----------------------------------- 4 files changed, 139 insertions(+), 546 deletions(-) create mode 100644 THREADED_MODE.md delete mode 100644 THREADED_MODE_ROADMAP.md delete mode 100644 nothofagus_threading_plan.md diff --git a/THREADED_MODE.md b/THREADED_MODE.md new file mode 100644 index 00000000..16c72720 --- /dev/null +++ b/THREADED_MODE.md @@ -0,0 +1,133 @@ +# Threaded mode — sim/render split (status) + +> As-built status of nothofagus's optional two-thread sim/render path. `run()`/`tick()` +> remain the unchanged single-threaded default; the threaded path is additive and opt-in. +> Branch: `thread_aware_phase2_unification`. + +## What it is + +A sim thread mutates the scene and **commits** a POD frame snapshot; the render thread +draws the **previous** snapshot; the GPU is the implicit third stage. Render of frame N +overlaps sim of frame N+1. GPU-resource frees are **deferred** across the one-frame lag so +a resource dropped by sim this tick isn't freed while an in-flight snapshot still uses it. + +Opt-in API (nothofagus spawns no threads unless you use a `run(...)` convenience overload): +`beginThreadedSession` / `commit(dt, update[, uiCallback][, simController])` / `renderFrame` +/ `isThreadedRunning`, plus the `run(update, ui[, simController, renderController])` +convenience overloads that own the sim thread for you. + +## Architecture invariants (hold across the whole path) + +- **Nothofagus stays driven, not driving** — the library doesn't spawn threads except in + the `run(...)` convenience; the app can drive both loops itself. +- **One mutable scene + one POD projection** — no second bellota container. The live + `bellota(id) -> Bellota&` mutation path is untouched; the render side consumes only the + POD `RenderSnapshot` and never touches a `Bellota`. +- **Snapshot references resources by id, not GPU handle** — `DrawItem` carries + `TextureId`/`MeshId`; the render side resolves id → GPU handle *after* upload. +- **The seam sits above `ActiveBackend`** — GL and Vulkan inherit the split; the invariant + is *all GPU create/destroy/draw on the single render thread*. +- **RTT passes ride in the same snapshot** — no extra-frame deferral; uniform one-frame lag. + +## Done (verified in source) + +### Foundation +- **Unified frame path** — one `produce(FrameMode)` + one `consume(FrameMode)` + (`Single` = run/tick, `Threaded` = commit/renderFrame). Behavior-preserving. +- **Triple-buffered snapshot store** (`snapshot_buffers.h`) — 3 slots + lock-free mailbox; + sim never blocks, render always gets the freshest, stale intermediates dropped. +- **Deferred GPU free** — `collectUnused*` / `freeRetired*` in `asset_registry.*`, gated on + `retireSeq <= lastRenderedSeq`; render-target deferred-free queue drains + releases the + per-RTT ImGui context. +- **Unified `addBellota`/`removeBellota`** — one mode-aware add/remove safe in both + single-threaded and live-threaded contexts (old `spawnBellota`/`despawnBellota` deleted). + +### Game input on the sim thread (two-controller model) +- **Gamepad** — `commit(dt, update, Controller& simController)`; render-side `GamepadSnapshot` + harvest → POD → sim-side feed/replay against the sim controller. +- **Keyboard + mouse** — `Controller` gained `isKeyDown`/`isMouseButtonDown` + a scroll + accumulator; `GameInputSnapshot` harvest/feed. The 4-arg `commit` feeds gamepad + keyboard + + mouse before `update`. +- **render controller vs sim controller** — render controller owns window input + `close` + (GLFW is main-thread-only); sim controller is the game's, consumed only on the sim thread. + +### Interactive ImGui on the sim thread +- **Sim-UI context + `ImDrawData` deep-clone** carried in the snapshot; thread-local `GImGui`; + the shared 1.92 dynamic font atlas is serialized by `mImguiMutex` (short sections; game-sim + + sprite render still overlap). `commit(dt, update, uiCallback)` splits lock-free game logic + from the locked UI frame. +- **Full input marshalling** render→sim — mouse, full keyboard state + modifiers + typed + characters + focus; `WantCaptureMouse/Keyboard` back-marshalled to + `imguiWantsMouse()`/`imguiWantsKeyboard()`. +- **Cursor-shape feedback** — sim `GetMouseCursor()` → atomic → render `SetMouseCursor`. +- **ImGui gamepad nav** — `NavEnableGamepad` on both contexts; digital D-pad/face-button plus + **analog** stick nav (`keyAnalog[]` side-channel + `AddKeyAnalogEvent`). +- **OS clipboard** across the thread boundary — `WindowBackend get/setClipboardText` + (GLFW/SDL3/headless); mutex-guarded marshal, writes immediate, OS reads throttled ~0.25 s. +- **Stats overlay** drawn into the sim-UI clone (so it survives even when the app commits its + own ImGui); render fps/ms marshalled to sim via atomics. + +### Runtime asset mutation from the sim thread +- **Explorers (Dense/Sparse land)** run in `produce(Threaded)` under the asset mutex. +- **Create/destroy of textures / meshes / render targets** from `commit`'s update — mode-aware + asset wrappers (lazy adds + deferred removes), tolerant retire, RT deferred-free queue. +- **Threaded-safe `setScreenSize`** — `mScreenSize` is `std::atomic`; resizing the + logical canvas from the sim update is race-free and rebuilds the explorer pool next commit. +- **`screenSize` carried in `RenderSnapshot`** — each snapshot renders in the size it was built + for; no 1-frame letterbox transient on a threaded `setScreenSize`. +- **Threaded-safe `markTextureAsDirty` / `setTextureMin|MagFilter`** — now pure-CPU dirty flags + (`mContentDirty` / `mFilterDirty`) consumed by `syncToGpu` on the render thread. + +## Pending — port all demos to the threaded path (main remaining work) + +The threaded core is complete; the outstanding effort is migrating the example demos so the +suite exercises the threaded path as the default. The `run(update, ui[, simController, +renderController])` convenience makes each migration mechanical: split the single `run` lambda +into a game-logic `update` + an ImGui `ui`, and split input into `simController` (game) vs +`renderController` (window/`close`). + +**Ported (6 / 29):** `hello_threaded`, `hello_threaded_imgui`, `hello_threaded_gamepad`, +`hello_threaded_dense_land` (dedicated); `hello_nothofagus`, `test_keyboard` (migrated in place). + +**Remaining (23):** hello_animation, hello_animation_state_machine, hello_custom_font, +hello_dense_land, hello_direct_texture, hello_dpi_scaling, hello_headless, hello_imgui_image_registry, +hello_imgui_overlay, hello_imgui_rtt, hello_imgui_visual, hello_layers, hello_markdown, hello_mesh, +hello_nested_render_targets, hello_render_to_texture, hello_screenshot, hello_sparse_land, +hello_text, hello_tilemap, hello_tint, test_create_destroy, test_gamepad. + +Per-demo caveats: +- **hello_headless** uses `tick()` (deliberate single-step/headless harness) — a threaded port + may not be meaningful; keep as the single-threaded/manual-tick reference. +- **hello_imgui_visual / hello_imgui_image_registry** exercise `imguiVisual`/`imguiImages`, which + is single-threaded-only for now — port only once/if that path gains threaded support. + +## Out of scope + +- **Triple-buffer interpolation** — render interpolating between the two most recent snapshots + by stable id for extra smoothness. Deferred: needs per-drawable stable IDs in `DrawItem`, + previous-snapshot retention, ID-matching, and a render-time alpha. +- **Integrating a nothofagus consumer's game/scripting loop onto `commit`/`renderFrame`** — + out of nothofagus scope by design; not tracked here. +- **Full driver collapse** (`run`/`tick` → only `commit`/`renderFrame`) — deferred by choice; + it would force every ImGui-in-`update` demo onto the threaded ImGui model. The in-place demo + migration above is the incremental path toward this, without forcing it. + +## Critical files + +- `source/render_snapshot.h`/`.cpp` — POD draw list + `screenSize` + `mainUi` clone handle. +- `source/snapshot_buffers.h` — triple buffer + lock-free mailbox. +- `source/frame_runner.h`/`.cpp` — `produce`/`consume`, threaded `commitFrame`/`renderFrameThreaded`, + `runThreaded`, sim-UI context, input marshalling, the asset + ImGui mutexes, deferred frees. +- `source/imgui_draw_clone.h`/`.cpp` — `ClonedImDrawData` + thread-local `GImGui`. +- `source/asset_registry.*` — `collectUnused*` / `freeRetired*`. +- `include/canvas.h` + `source/canvas.cpp` — threaded API + `run(...)` convenience overloads. +- `examples/hello_threaded*.cpp` — threaded demos. + +## Verification recipe (every change) + +- Cross-backend builds: `linux-release-glfw-opengl-examples`, `-glfw-vulkan-examples`, `-sdl3-vulkan-examples`. +- SwiftShader goldens unchanged: `linux-release-headless-vulkan-tests` + + `NOTHOFAGUS_RENDER_BACKEND=swiftshader .../rendering_tests`. +- TSan + ASan on the threaded demos under `xvfb-run` — expect only environmental Mesa/glib noise, + 0 conflicts in our code, 0 ASan/UBSan errors. +- Any new shared state must be mutex- or atomic-guarded like the existing marshal channels. diff --git a/THREADED_MODE_ROADMAP.md b/THREADED_MODE_ROADMAP.md deleted file mode 100644 index f90da576..00000000 --- a/THREADED_MODE_ROADMAP.md +++ /dev/null @@ -1,110 +0,0 @@ -# Threaded mode — completion roadmap - -> Status of the sim/render threaded path (Option A). What's done, what's left to reach full -> feature parity with the single-threaded path, and related work tracked separately. -> Branch: `thread_aware_phase2_unification`. Companion: `nothofagus_threading_plan.md`, -> `multithreading_design_discussion.md`. -> -> `main` was merged in (commit `7a10d63`): it brought the **Vulkan present-mode fix** (now done — see -> group D) and the **ImGui image registry** (`registerImage`/`drawImage`, `imgui_image_manager`); the -> threaded `produce`/`consume` integrate both (the per-frame ImGui-image pre-pass is single-thread -> only, null-guarded on the threaded path). - -## Foundation (done & committed) - -- **Step 1 — unify frame paths** (`c00b634`): one `produce(FrameMode)` + one `consume(FrameMode)` - (`Single` = run/tick, `Threaded` = commit/renderFrame). Behavior-preserving. -- **M5 — gamepad on the sim thread** (`4966785`): two-controller model (render + sim) via - harvest→POD→feed. -- **Stats + PerformanceMonitor dt on the threaded path** (`13c7402`, `9e44844`, `e16648a`). -- **Step 2 (scoped) — bellota mutation unify** (`645c278`): one mode-aware `addBellota`/`removeBellota`; - deleted `spawnBellota`/`despawnBellota`. - -## A. Feature-parity gaps (single-threaded can; threaded couldn't) - -1. **Keyboard + mouse game input on the sim thread** — ✅ DONE (`6b5d32a`). `Controller` gained - `isKeyDown`/`isMouseButtonDown` + a scroll accumulator; `GameInputSnapshot` harvest/feed; the - `commit(dt, update, Controller&)` overload now feeds gamepad + keyboard + mouse. -2. **Explorers (Dense/Sparse land) on the threaded path** — ✅ DONE (`4174e3e`). `updateExplorers` - runs in `produce(Threaded)` under the asset mutex; `Canvas*` captured in `beginThreadedSession`. -3. **Runtime create/destroy of textures / meshes / render targets** — ✅ DONE (`d45063c`). Mode-aware - asset wrappers (lazy adds + deferred removes), tolerant `retireTexture`/`retireMesh`, a new - render-target deferred-free queue (drain also releases the per-RTT ImGui context). -4. **Threaded-safe `setScreenSize` (explorer pool resize during a live session)** — ✅ DONE. - `mScreenSize` is now `std::atomic` (lock-free 8-byte); `screenSize()` returns by - value, every reader loads atomically, `setScreenSize` stores. Resizing the logical canvas from - the sim `update` is race-free and rebuilds the explorer pool next commit. Verified: - `hello_threaded_dense_land` toggles `setScreenSize` every ~2 s — TSan 0 conflicts in our code - (no `mScreenSize` race), ASan/UBSan clean. Remaining cosmetic nuance: a 1-frame letterbox - transient on resize (render uses live size while drawing the previous snapshot's pool); fully - removing it = carry `screenSize` in `RenderSnapshot` (optional, see C). - -## B. ImGui parity on the threaded path — ✅ DONE - -5. **Cursor-shape feedback** — ✅ DONE (`0fabc0f`). Sim `ImGui::GetMouseCursor()` → `mThreadedCursor` - (atomic) → render `ImGui::SetMouseCursor` before the main-context `NewFrame`, so the platform - backend's existing `UpdateMouseCursor` applies it. No backend changes; render-backend-agnostic. -6. **ImGui gamepad nav** — ✅ DONE (`5ad2d95`). `NavEnableGamepad` on the main context (so the - platform backend populates the `ImGuiKey_Gamepad*` keys already harvested+replayed) + on the - sim-UI context (+`HasGamepad`). Threaded-only. Digital D-pad/face-button nav; **analog stick nav - not yet marshalled** (bool-only harvest) — follow-up needs a float side-channel. -7. **OS clipboard across the thread boundary** — ✅ DONE (`43879a7`). New `WindowBackend` - `get/setClipboardText` (GLFW/SDL3/headless); sim-UI clipboard callbacks read/write a mutex-guarded - marshal that the render thread syncs with the OS (writes immediate, OS reads throttled ~0.25 s). - -## C. Polish / stretch — 🔧 C8/C10/C11 DONE; C9 deferred - -8. **Stats overlay when a sim-UI clone IS present** — ✅ DONE. The stats window is now drawn into the - sim-UI frame (in `produce(Threaded)`, after the user `uiCallback`, before `Render`) instead of the - render-thread main context, so it ends up in the cloned draw data that gets presented even when - the app commits its own ImGui. The render cadence is marshalled to the sim via `mRenderFps`/ - `mRenderMs` atomics; a sim-side `PerformanceMonitor` (fed by an accumulated commit clock) gives - the sim rate. Overlay shows render fps/ms next to the sim fps. Demo: `hello_threaded_imgui` now - sets `canvas.stats() = true` (a `uiCallback` app — the case that previously hid stats). -9. **Triple-buffer interpolation** — ⏸ DEFERRED. Render could interpolate between the two most recent - snapshots by stable id for extra smoothness. Larger change: needs per-drawable stable IDs in - `DrawItem` (today only `TextureId`/`MeshId`), previous-snapshot retention (consume reads only the - latest `readSlot()`), ID-matching, and a render-time alpha. C10's per-snapshot `screenSize` is a - prerequisite it can build on. -10. **Carry `screenSize` in `RenderSnapshot`** — ✅ DONE. `RenderSnapshot` gained a `ScreenSize - screenSize` field, stamped in both `produce` arms; `consume(Threaded)`/`renderSnapshotContents` - use it for the viewport / world transform / `endFrame`, so the render draws each snapshot's pool - in the size it was built for — no 1-frame letterbox transient on a threaded `setScreenSize`. - Priming/un-stamped slots ({0,0}) fall back to the live atomic to avoid a 0-aspect divide - (a real UBSan bug caught in verification). Single mode always stamps a valid size, so the - fallback is a no-op there. -11. **Analog gamepad nav on the threaded path** — ✅ DONE. `ThreadedImguiInput` gained a - `float keyAnalog[]` side-channel alongside `keyDown`; `harvestImguiInput` captures - `io.KeysData[index].AnalogValue` (the public per-key array), and the replay routes the analog - gamepad keys (`GamepadL2`/`R2`, `GamepadL/RStick*` — via the `isAnalogNavKey` helper) through - `AddKeyAnalogEvent` instead of `AddKeyEvent`. ImGui's smooth gamepad nav (continuous - scroll/tween) now survives the marshal, matching single-threaded. Render-backend-independent, - works on GLFW and SDL3. - -## D. Related — tracked separately (not part of "completing threaded mode") - -- **Vulkan present-mode perf** (windowed FIFO → ~45 fps on a compositor) — ✅ DONE, landed in `main` - and merged here. A `PresentMode` enum ([include/present_mode.h](include/present_mode.h), - default `Mailbox`) is a `Canvas` ctor arg; it selects the Vulkan present mode and the OpenGL swap - interval (`presentModeToSwapInterval`). Mailbox is vsync'd + triple-buffered (no tearing, - compositor-friendly) and avoids the ~45 fps FIFO pacing. (The old `VULKAN_PRESENT_MODE_FIX.md` - planning doc is superseded and was removed.) -- **Full Option A driver collapse** (`run`/`tick` → only `commit`/`renderFrame`) — deferred by - choice; would force the ~15 ImGui-in-`update` single-threaded examples onto the threaded ImGui model. -- **markTextureAsDirty / setTextureMin|MagFilter threaded-safety** — ✅ DONE (`ccc11a3`). The three - Canvas mutators no longer do immediate GPU work: two pure-CPU `TexturePack` flags (`mContentDirty` - for re-upload, `mFilterDirty` for min/mag filters) are consumed by `syncToGpu` on the render thread; - `AssetRegistry` setters became pure-CPU and new `FrameRunner` mode-aware wrappers take the asset - lock when threaded (same pattern as `setTexture`). Single-threaded behavior preserved. Demo: - `hello_threaded` flips a Direct texture's filter + marks it dirty from the sim update. -- **alice_engine / PocketPy integration** — wiring the engine's Python loop onto `commit`/`renderFrame`; - out of nothofagus scope by design. - -## Verification recipe (every change) - -- Cross-backend builds: `linux-release-glfw-opengl-examples`, `-glfw-vulkan-examples`, `-sdl3-vulkan-examples`. -- SwiftShader goldens unchanged: `linux-release-headless-vulkan-tests` + - `NOTHOFAGUS_RENDER_BACKEND=swiftshader .../rendering_tests`. -- TSan + ASan on the threaded demos (`hello_threaded`, `_imgui`, `_gamepad`, `_dense_land`) under - `xvfb-run` — expect only Mesa/glib environmental noise, 0 conflicts in our code, 0 ASan/UBSan errors. -- Any new shared state must be mutex-guarded like `mThreadedGamepadState` / `mThreadedGameInputState`. diff --git a/include/canvas.h b/include/canvas.h index a6e44bd3..91647e69 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -735,10 +735,12 @@ class Canvas // renderer and defer GPU frees while a threaded session is live — see those // methods). Interactive ImGui runs via the `commit(dt, update, uiCallback)` // overload (widgets on the sim thread, cloned to the render thread); gamepad - // input via `commit(dt, update, simController)`. Still single-threaded-only: - // explorers (Dense/Sparse land) and other structural resource ops - // (textures/meshes/render targets) — create those up front. `run()`/`tick()` - // remain the unrestricted single-threaded path. + // input via `commit(dt, update, simController)`. Explorers (Dense/Sparse land) + // and runtime create/destroy of textures/meshes/render targets are also + // supported from `commit`'s update (they serialize against the renderer and + // defer GPU frees like `addBellota`/`removeBellota`). Still single-threaded-only: + // `imguiVisual`/`imguiImages`. `run()`/`tick()` remain the unrestricted + // single-threaded path. /// Main thread: start a threaded session (binds input, marks it running). void beginThreadedSession(Controller& controller); diff --git a/nothofagus_threading_plan.md b/nothofagus_threading_plan.md deleted file mode 100644 index 08665ca0..00000000 --- a/nothofagus_threading_plan.md +++ /dev/null @@ -1,432 +0,0 @@ -# Nothofagus: make the renderer thread-aware - -> Implementation plan + status for the nothofagus sim/render thread split (M1–M4). -> Companion to [multithreading_design_discussion.md](multithreading_design_discussion.md) -> (the exploratory design). This file tracks the concrete, as-built work. - -## Context - -Alice Engine is 100% single-threaded today. The long-term goal is a sim/render -split: the simulation thread mutates the scene and **commits** a frame snapshot; -the render thread draws the **previous** frame's snapshot; the GPU is the implicit -third stage. This decouples render of frame N from sim of frame N+1, and requires -deferred GPU-resource removal so a resource dropped by sim this tick isn't freed -while an in-flight snapshot still references it. - -Scoped to **nothofagus only** (no `alice_engine` / PocketPy integration yet), -following [multithreading_design_discussion.md](multithreading_design_discussion.md) -**option (I)** (the double buffer lives inside nothofagus at the scene/draw-data -level), staged as: *seam first → thread flip → committed ImGui → full ImGui input -→ game input on the sim thread*. - -## Status - -- **M1 — single-threaded seam: DONE & verified** (details + verification below). -- **M2 — flip to two threads: DONE & verified.** Phase A (snapshot hand-off, - triple buffer, value-only runtime) + Phase B (runtime spawn/despawn via a - scoped asset mutex + deferred free). New `hello_threaded` demo. Validated: - TSan/ASan clean in our code, SwiftShader goldens unchanged. No ImGui on the - threaded path yet. -- **M3 — interactive committed ImGui on the threaded path: DONE & verified.** - Sim-UI ImGui context + `ImDrawData` clone in the snapshot + thread-local - `GImGui` + render→sim **mouse** marshalling. The shared-atlas race (ImGui 1.92's - per-frame dynamic atlas) is serialized by `mImguiMutex` (short ImGui sections - only; game-sim + sprite render still overlap). `commit(dt, update, uiCallback)` - splits lock-free game logic from the locked UI frame. New `hello_threaded_imgui` - demo. Validated: TSan (atlas races gone; only Mesa driver noise), ASan clean, - goldens unchanged, alice_engine builds. -- **M4 — full keyboard / text / focus input for threaded ImGui: DONE & verified.** - Render→sim marshalling of the full keyboard state + modifiers + typed - characters + focus (reusing ImGui_ImplGlfw's keymap on the main context), - replayed on the sim-UI context; `WantCaptureMouse/Keyboard` back-marshalled - (`Canvas::imguiWantsMouse()/imguiWantsKeyboard()`); in-process clipboard for - `InputText` copy/paste; `NavEnableKeyboard`. Demo extended (InputText + Space - shortcut + capture gating). Validated: TSan 0 races in our code, ASan clean, - goldens 49/49, alice_engine builds. -- **M5 — gamepad input on the sim thread (game input, not ImGui): NEXT** (plan - below). Marshal the render `Controller`'s normalized gamepad state → feed a - sim-side `Controller` the game `update` polls / gets callbacks from. Two- - controller model (render controller for window/`close`; sim controller for game - input). nothofagus-only, no ImGui. - -## Implemented (on disk, not committed to git) vs Pending - -**Implemented & validated — M1–M4:** the full nothofagus-only sim/render thread -split. Single-threaded `run()`/`tick()` are unchanged; the threaded path is an -additive, opt-in API (`beginThreadedSession` / `commit(dt, update[, uiCallback])` -/ `renderFrame` / `isThreadedRunning` / `imguiWantsMouse|Keyboard`; runtime add/ -remove via the unified `addBellota` / `removeBellota`, which detect a live threaded -session) exercised by `hello_threaded` (no-ImGui baseline) and -`hello_threaded_imgui` (interactive). Snapshot triple-buffer, deferred GPU free -across the one-frame lag, runtime spawn/despawn, and interactive sim-thread ImGui -(mouse + keyboard + text + clipboard) all work and are TSan/ASan-clean (only -environmental Mesa-driver noise) with goldens unchanged. - -**Pending / deferred (none required for the threaded path to work):** -- **Game input on the sim thread** — M1–M4 only marshalled input to the *ImGui* - context, not to the game's `Controller` on the sim side. **M5 (below) closes - this for gamepad.** Keyboard/mouse game-input on the sim thread (same - marshal→sim-`Controller` pattern) and **ImGui gamepad nav** (`NavEnableGamepad` - + gamepad-key marshalling) remain after M5. -- **Cursor-shape feedback** — sim UI's `GetMouseCursor()` marshalled sim→render + - `glfwSetCursor` (I-beam over text, resize handles). Output, not input. -- **OS clipboard** across the thread boundary (today: in-process app-local only). -- **Explorers (Dense/Sparse land) on the threaded path** — they mutate pool - textures; would route through the same asset-mutex/command path. Out of scope so - far; works single-threaded. -- **Triple-buffer interpolation** — render could interpolate between the two most - recent snapshots by stable id for extra smoothness (the doc's stretch goal). -- **alice_engine / PocketPy integration** — wiring the engine's Python loop onto - `commit`/`renderFrame` (Python `update` on the sim thread, etc.). Entire effort - so far is nothofagus-only by design. -- **Git** — nothing committed; M1–M4 (and `M1_PR.md`) sit in the working tree. - -## Architecture invariants (settled, hold across all milestones) - -- **Nothofagus stays driven, not driving.** The library spawns no threads. The - app drives one or two threads and calls into the canvas. -- **One mutable scene + one POD projection.** No second `BellotaContainer`. The - live `Canvas::bellota(id) -> Bellota&` mutation path is untouched; the render - side consumes only the POD `RenderSnapshot` and never touches a `Bellota`. -- **Snapshot references resources by id, not GPU handle.** `DrawItem` carries - `TextureId`/`MeshId`; the render side resolves id → `DTexture`/`DMesh` *after* - GPU upload (a freshly created texture has no handle at commit time). -- **The seam sits above `ActiveBackend`** — GL and Vulkan inherit the split for - free; the one invariant is *all GPU create/destroy/draw on the single render - thread*. -- **RTT passes ride in the same snapshot, no extra-frame deferral.** Within a - render frame the render side already runs RTT → main → present in order, so RTT - inherits the same uniform one-frame sim→render latency as sprites. Deferring it - would make RTT lag its own sprites. - -## M1 — single-threaded seam (DONE) - -Inverted `FrameRunner::runOneFrame` into **`buildSnapshot()` (produce POD) → -`renderSnapshot()` (consume POD)** on the same thread, behavior-preserving, with -the deferred-free plumbing wired (draining immediately at depth-0). - -**What was built:** -- [source/render_snapshot.h](../third_party/nothofagus/source/render_snapshot.h) — - new POD types `DrawItem` (resolved transform + `TextureId`/`MeshId` + tint / - opacity / layer / depth), `RttPass` (target + sorted draws), `RenderSnapshot` - (commitSeq + main draws + RTT passes + clearColor). -- [source/frame_runner.cpp](../third_party/nothofagus/source/frame_runner.cpp) / - `.h` — `runOneFrame` = `buildSnapshot(...)` → `renderSnapshot(...)`. - `buildSnapshot` runs input/ImGui-NewFrame/`update`/explorers, detects unused - resources (enqueues for deferred free), and projects the scene into the reused - `mSnapshot`. `renderSnapshot` drains frees, uploads, draws the snapshot's RTT + - main passes, ImGui render, presents. The old `drawBellotaPacks` / - `makeSpriteDrawParams` / `sortByDepthOffset` were replaced by `drawItems` / - `makeDrawItem` / `buildMainDraws` over POD. New members: `mSnapshot`, - `mCommitSeq`, `mLastRenderedSeq`, `mPendingTextureFrees` / `mPendingMeshFrees`, - `mFramebufferWidth/Height`; `mSortedBellotaPacks` removed. -- [source/asset_registry.cpp](../third_party/nothofagus/source/asset_registry.cpp) / - `.h` — `collectUnusedTextures` / `collectUnusedMeshes` (detect + monitor-clear, - no free) and `freeRetiredTexture` / `freeRetiredMesh` (free GPU + container - erase, no monitor touch). Deferred-free gates on `retireSeq <= lastRenderedSeq`; - at depth-0 `lastRenderedSeq == commitSeq` so timing matches the old - `clearUnused*` exactly. - -**Verification (passed):** -- nothofagus `linux-debug-glfw-opengl-examples` — clean build. -- nothofagus SwiftShader visual + nonvisual suite — **0 allowed diff pixels** - (the deterministic proof the seam is behavior-identical: animation, RTT, - nested-RTT, tilemap all byte-identical). -- parent `alice_engine` `linux-release` — clean build through the changed library. -- engine golden demos — 5/6 byte-exact on GPU; `anim_text` shows a stable - 2/10240-pixel diff = real-GPU-vs-SwiftShader rasterization variance (confirmed - acceptable), not a regression. - -## M2 — flip to two threads (DONE) - -> **As built:** Phase A landed as planned; **Phase B used a scoped -> `mThreadedAssetMutex` for runtime spawn/despawn + deferred free, not the -> lock-free resource command queue** the section below describes (the mutex reuses -> AssetRegistry/`syncToGpu` with far less churn; the command queue remains a -> possible future optimization). The deliverables/verification below are the -> original plan, kept as the record. - -Goal: a **new** nothofagus-only example, `examples/hello_threaded.cpp`, driven by -two `std::thread`s — a sim thread commits frame N+1 while the main/render thread -draws frame N. **Existing examples are not modified**; they stay on `run()`/ -`tick()` as the single-threaded regression baseline. - -**Shared invariants for the threaded path:** -- The GL/window context lives on the **main thread**; `renderFrame()` (all GPU - work + present) runs there. The sim thread issues **zero** GPU calls. -- `run()`/`tick()` are untouched (additive change), so the whole existing example - + golden suite keeps passing. - -### Phase A — snapshot handoff, value-only runtime - -At runtime (while both threads run) the sim may only **mutate existing bellota -values** — transform, tint, opacity, layer/animation. All textures, meshes, and -bellotas are created up front, single-threaded, before the threads start ⇒ -`mTextures`/`mMeshes` are render-exclusive and `mBellotas` is sim-exclusive at -runtime, so **no container locks** are needed. This proves the handoff + the -real sim/render overlap. - -Built: -1. **Triple-buffered snapshot store** — - [source/snapshot_buffers.h](../third_party/nothofagus/source/snapshot_buffers.h): - 3 `RenderSnapshot` slots + a lock-free mailbox protocol (atomic published - index/seq). Sim never blocks; render always gets the freshest and drops stale - intermediates. Each slot reuses its `draws`/`rttPasses` vectors. -2. **Public Canvas API (additive)**: `commit(dt, update)` (sim — projection, - published into a slot) and `renderFrame(controller)` (main — acquire, upload, - RTT + main passes, present, drain frees, poll). `beginThreadedSession` / - `isThreadedRunning` / `close` drive the loops. `run()`/`tick()` unchanged. -3. **No-ImGui present path** in `renderFrame` (empty draw data) — replaced in M3. -4. **`hello_threaded` v1**: a field of sprites animated by the sim thread, drawn - one frame behind by the main thread. - -### Phase B — runtime container mutation (scoped mutex, as built) - -> The plan proposed a lock-free **resource command queue + AssetRegistry -> ownership split**; the as-built solution is a narrower **`mThreadedAssetMutex`** -> held only for the short container section (deferred frees + GPU upload + id→ -> handle resolve + draw submission), released **before** the vsync swap, while the -> game-sim update and bellota value mutation stay lock-free. Reuses all existing -> AssetRegistry/`syncToGpu`/deferred-free machinery. - -Built (originally `Canvas::spawnBellota`/`despawnBellota`; later unified — see the -Step-2 note below — into the regular `addBellota`/`removeBellota`, which detect a -live threaded session): -- runtime add/remove guarded by - `mThreadedAssetMutex`; removal detects orphaned resources and queues them for - deferred GPU free (tagged with the commit seq), freed render-side once - `retireSeq <= lastRenderedSeq` (no use-after-free across the one-frame lag). -- `commitFrame` stamps the commit seq before `update` so removal tags resources - correctly; `renderFrameThreaded` runs `renderSnapshotContents` under the asset - mutex, releasing it before the swap. -- **`hello_threaded` escalated**: continuous spawn/despawn burst (sprites + their - auto-quad meshes) while render lags one frame. - -**Validation (A + B):** built `*-glfw-opengl-examples`; ran `hello_threaded` -(smooth, no flicker) under **TSan** (no races in our code — only environmental -Mesa-driver noise; sim thread never a racing party) and **ASan** (sustained -create/destroy churn, no use-after-free); SwiftShader goldens unchanged; parent -`alice_engine` builds. - -## M3 — interactive committed ImGui on the threaded path (DONE) - -> **As built:** matches the plan below, with one discovery — ImGui 1.92's dynamic -> font atlas is mutated every `NewFrame`, so thread-local `GImGui` alone wasn't -> enough; the two threads' ImGui sections are serialized by `mImguiMutex` (short -> sections; game-sim + sprite render still overlap). The clone uses manual -> `CmdLists` fill (`CloneOutput` returns sealed lists). - -Goal: user ImGui widget code runs on the **sim** thread (inside the UI callback, -where the game state it reads/writes lives) and is **interactive** — mouse drives -sliders/buttons/checkboxes — while the **render** thread only issues -`RenderDrawData` from a **deep-cloned `ImDrawData`** carried in the snapshot. All -ImGui logic stays single-threaded (ImGui is not thread-safe; for the future -alice_engine the callbacks are Python on one VM). ImGui is **1.92.8**; -`ImDrawList::CloneOutput()` is the core primitive. - -### Thread/context model -- **Sim-UI context** (new): a dedicated `ImGuiContext` sharing the main font atlas - (manual IO, no platform/renderer `NewFrame`). The sim thread runs `NewFrame` → - widgets → `Render` on it. -- **Render/backend context**: the original ctor context (renderer bound to it) - stays on the render thread, used only for `RenderDrawData` of the clone. -- **Thread-local `GImGui`** ([imconfig.h](../third_party/nothofagus/third_party/imgui/imconfig.h), - definition in `imgui_draw_clone.cpp`) so each thread refers to its own context. -- **`mImguiMutex`** serializes the shared font-atlas access between the two - threads' ImGui sections (the 1.92 dynamic atlas is mutated every `NewFrame`). - -### What was built -1. **`ImDrawData` deep-clone util** — - [source/imgui_draw_clone.h/.cpp](../third_party/nothofagus/source/imgui_draw_clone.cpp): - `ClonedImDrawData` owns `ImDrawList*` via `CloneOutput()` and rebuilds an - `ImDrawData` (manual `CmdLists` fill + count sums). Reused per slot; lists are - allocated/freed on the sim thread only. -2. **Clone in the snapshot** — - [render_snapshot.h](../third_party/nothofagus/source/render_snapshot.h) gains a - forward-declared `std::unique_ptr mainUi` (out-of-line dtor in - `render_snapshot.cpp` keeps `imgui.h` out of the header). Dropped snapshots skip - a UI frame; nothing leaks. -3. **Mouse marshalling render→sim** — `harvestImguiInput` snapshots the main - context's processed mouse pos/buttons/wheel + DisplaySize/scale; the sim replays - via the event API before `NewFrame`. -4. **`commit(dt, update, uiCallback)`** — game `update` runs lock-free (overlaps - render); the UI callback runs in a `mImguiMutex`-locked section that feeds IO, - `NewFrame`, runs widgets, `Render`, and clones the draw data. -5. **Render the clone** — `renderFrameThreaded` runs the main empty ImGui frame - (harvest + atlas upload) and `RenderDrawData` of the clone, both under - `mImguiMutex`; the sprite render stays under `mThreadedAssetMutex`; the swap is - outside both locks. - -### Constraints (still hold) -- **Fonts baked up-front / render-side.** Runtime font baking from the sim thread - is out of scope (would need atlas-mutation marshalling). - -### Demo -[examples/hello_threaded_imgui.cpp](../third_party/nothofagus/examples/hello_threaded_imgui.cpp) -— interactive panel (sliders / checkbox / button) on the sim thread driving the -simulation; `hello_threaded` stays the no-ImGui baseline. - -**Validation:** ran the demo (no asserts); **TSan** — the real atlas races are -gone (only environmental Mesa-driver noise; sim ImGui never a racing party); -**ASan** clean; SwiftShader goldens unchanged; `alice_engine` builds. - -## M4 — full input for threaded ImGui (keyboard / text / focus) (DONE) - -> **As built:** matches the plan below. - -Goal: complete the M3 input path so sim-thread ImGui widgets get **everything** — -keyboard keys, modifiers, text entry, focus — so `InputText`, shortcuts, nav, and -keyboard-driven widgets work on the threaded path. - -### Key enabler -The render thread's main ImGui context **already receives all input** every frame: -nothofagus forwards keys via `glfwKeyCallback → ImGui_ImplGlfw_KeyCallback` -([glfw_backend.cpp](../third_party/nothofagus/source/backends/glfw_backend.cpp)), -and `ImGui_ImplGlfw_InitForOpenGL(window, true)` installs the chaining **char** -callback (which `beginSession` does not override). So after the render thread's -`ImGui::NewFrame()` the main context's `io` holds the full keyboard state + typed -characters. M4 **harvests and replays** it on the sim-UI context — reusing -ImGui_ImplGlfw's GLFW→`ImGuiKey` mapping, no new GLFW callbacks. It extends the -existing M3 `harvestImguiInput` → `mThreadedImguiInput` → replay-in-`commitFrame` -channel. - -### What was built -1. **Extended `ThreadedImguiInput`** (frame_runner.h): full named-key state array, - modifier flags, a typed-char buffer, focus. -2. **Harvest (render)**: keyboard named-key range (excluding the mouse-button - sub-range), modifiers, `io.InputQueueCharacters`, `io.AppFocusLost`. -3. **Replay (sim)**: `AddKeyEvent` per key + `ImGuiMod_*`, `AddInputCharacter` - (consumed once under the input mutex), `AddFocusEvent`. -4. **`WantCaptureMouse/Keyboard` back-marshal** → `Canvas::imguiWantsMouse()` / - `imguiWantsKeyboard()`, so the host game `update` can skip world interaction - while a widget has focus (one frame stale by construction). -5. **In-process clipboard** wired onto the sim-UI context - (`Platform_Get/SetClipboardTextFn`) for `InputText` copy/paste (GLFW clipboard - is main-thread-only); `ImGuiConfigFlags_NavEnableKeyboard` enabled. -6. **Demo**: `InputText`, Space shortcut (suppressed while typing), capture-state - readout, auto-spawn gated on `!imguiWantsMouse()`. - -### Known follow-ups (deferred) -- **Cursor shape** (I-beam / resize) — sim→render marshal + `glfwSetCursor`. -- **OS clipboard** across the thread boundary (vs the in-process one). -- **Gamepad nav** — harvest gamepad keys + `NavEnableGamepad`. - -**Validation:** ran the demo; **TSan** 0 races in our code (new state lives in the -existing mutex-guarded sections; only Mesa-driver noise); **ASan** clean; -SwiftShader goldens **49/49**; `alice_engine` builds. - -> Caveat: automated runs are headless (no synthetic keystrokes), so the harvest+ -> replay code executes every frame and `InputText` renders, but real typing / -> shortcut / copy-paste interactivity is best confirmed by running the demo -> interactively. - -## M5 — gamepad input on the sim thread (nothofagus, no ImGui) (NEXT) - -Goal: a game whose logic runs on the **sim** thread can use gamepad input the -normal nothofagus way — both **polling** (`getGamepadAxis`/`getGamepadButton`/ -`isGamepadConnected`/`getConnectedGamepadIds`) and **callbacks** -(`registerGamepadAction`/`registerGamepadAxis`/`registerGamepadConnected`/ -`Disconnected`) — from inside `commit`'s `update`. Today gamepad is polled and -dispatched on the **render** thread only (`renderFrameThreaded` → -`mWindow->endFrame(controller, …)` + `processInputs`); the sim `update` never sees -it. No ImGui involvement. - -### Why a second Controller - -`Controller` is not thread-safe and the window backend polls it on the render -thread (and `close()` must stay render-thread — GLFW is main-thread-only). So the -threaded path uses **two controllers**: -- **render controller** (already passed to `beginThreadedSession`/`renderFrame`): - the raw window-input sink polled by `endFrame`; keep render-thread handling here - (e.g. `Escape → close()`). -- **sim controller** (new): the game's controller, consumed on the sim thread — - the game registers gamepad callbacks on it and/or polls it inside `update`. - -### Mechanism — marshal render → sim, then replay (reuses all of `Controller`) - -The render controller already holds the backend-**normalized** gamepad state -(deadzone / Y-invert / trigger remap done in -[GlfwBackend::endFrame](../third_party/nothofagus/source/backends/glfw_backend.cpp) -before `updateGamepadAxis`), queryable via its getters; and `Controller`'s -`activateGamepadButton` (sets state + queues the edge) / `updateGamepadAxis` (sets -value + fires axis cb on change) / `gamepadConnected`/`Disconnected` are the exact -feed primitives the backend uses. So: - -1. **Harvest (render, after `endFrame`)** — new `harvestGamepadInput()`: read the - render controller into a POD `GamepadSnapshot` (per id `0..GLFW_JOYSTICK_LAST`: - `connected` + `bool buttons[15]` + `float axes[6]`, iterating the - `GamepadButton`/`GamepadAxis` enums), stored under a new `mThreadedGamepadMutex`. -2. **Feed (sim, in `commitFrame` before `update`)** — copy the snapshot under the - mutex, then for each id diff it against the **sim controller's** current state - and replay: changed button → `activateGamepadButton({id,btn,Press|Release})`; - each axis → `updateGamepadAxis(id,axis,value)`; connect/disconnect by comparing - the connected sets → `gamepadConnected`/`Disconnected`; finally - `simController.processInputs()` to dispatch the queued button edges. The game's - `update` then polls / has received callbacks on the sim controller. - -Backend-agnostic ~30-line replay; no GLFW on the sim thread, no new gamepad logic, -no change to `Controller`. - -### API - -- **`Canvas::commit(dt, update, Controller& simController)`** (new overload, - symmetric with `renderFrame(controller)`): `FrameRunner::commitFrame` feeds the - given sim controller from the latest gamepad snapshot *before* invoking `update`. - Existing `commit(dt, update)` and `commit(dt, update, uiCallback)` stay; a - `commit(dt, update, controller, uiCallback)` 4-arg form can be added for the - combined gamepad+ImGui case (not needed for this milestone). -- No new public types — the game uses the existing `Controller` gamepad API. - -### Threading correctness - -`mThreadedGamepadState` is the only shared state (mutex-guarded; tiny critical -sections). The render controller stays render-thread-exclusive; the sim controller -stays sim-thread-exclusive (fed + polled only there). No new overlap cost — the -feed is part of the existing lock-free `commit` game phase. - -### Demo / Verification - -New `examples/hello_threaded_gamepad.cpp`: render controller with `Escape → -close`; a sim controller whose left-stick axes + face buttons move/color a sprite, -polled inside `update` (mirrors `examples/test_gamepad.cpp` on the threaded path). -Build + run (responds with a pad connected; clean no-op without one — harvest/feed -run every frame regardless); **TSan/ASan** clean (only `mThreadedGamepadState` -shared + mutex-guarded; both controllers single-thread-exclusive); SwiftShader -goldens unchanged; parent `alice_engine` builds. - -> Note: real gamepad interaction needs hardware (same as `test_gamepad`); the -> automated checks confirm the marshal/feed machinery runs clean and race-free, -> not actual stick input. - -## Critical files (as built) - -- `third_party/nothofagus/source/render_snapshot.h` / `.cpp` — POD draw list + - `mainUi` clone handle (out-of-line dtor). -- `third_party/nothofagus/source/snapshot_buffers.h` — triple buffer + mailbox. -- `third_party/nothofagus/source/imgui_draw_clone.h` / `.cpp` — `ClonedImDrawData` - + thread-local `GImGui` definition. -- `third_party/nothofagus/third_party/imgui/imconfig.h` — thread-local `GImGui`. -- `third_party/nothofagus/source/frame_runner.h` / `.cpp` — `buildSnapshot` / - `renderSnapshot[Contents]`, threaded `commitFrame` / `renderFrameThreaded`, - sim-UI context, input marshalling (mouse + keyboard/text/focus), the two mutexes - (`mThreadedAssetMutex`, `mImguiMutex`), deferred-free, `WantCapture` back-marshal. -- `third_party/nothofagus/include/canvas.h` + `source/canvas.cpp` — threaded API - (`beginThreadedSession`, `commit[+uiCallback]`, `renderFrame`, `isThreadedRunning`, - `imguiWantsMouse`/`imguiWantsKeyboard`); runtime add/remove via the unified - `addBellota`/`removeBellota`. -- `third_party/nothofagus/source/asset_registry.*` — `collectUnused*` / - `freeRetired*` (deferred free). -- `third_party/nothofagus/examples/hello_threaded.cpp` — no-ImGui threaded demo. -- `third_party/nothofagus/examples/hello_threaded_imgui.cpp` — interactive demo. -- `third_party/nothofagus/examples/CMakeLists.txt`, `CMakeLists.txt` — build entries. - -### M5 (planned, not yet implemented) - -- `third_party/nothofagus/source/frame_runner.h` / `.cpp` — `GamepadSnapshot` + - `mThreadedGamepadState` / `mThreadedGamepadMutex`, `harvestGamepadInput()` - (render), and the sim-controller feed in `commitFrame`. No change to `Controller` - or the window backends. -- `third_party/nothofagus/include/canvas.h` + `source/canvas.cpp` — new - `commit(dt, update, Controller& simController)` overload. -- `third_party/nothofagus/examples/hello_threaded_gamepad.cpp` + - `examples/CMakeLists.txt` — new threaded gamepad demo. From 0af7656e1d844a6e2e14aedd96c69a24b0445346 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:49:31 -0700 Subject: [PATCH 34/55] first commit hash recorded --- THREADED_MODE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/THREADED_MODE.md b/THREADED_MODE.md index 16c72720..7485dfe1 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -2,7 +2,7 @@ > As-built status of nothofagus's optional two-thread sim/render path. `run()`/`tick()` > remain the unchanged single-threaded default; the threaded path is additive and opt-in. -> Branch: `thread_aware_phase2_unification`. +> Branch: `thread_aware_phase2_unification` (first commit `71805e8`). ## What it is From cd04dff604feaaa8c4ca19c49afc292f23e4bf83 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:00:48 -0700 Subject: [PATCH 35/55] pending threaded diagetic imgui --- THREADED_DIEGETIC_IMGUI.md | 129 +++++++++++++++++++++++++++++++++++++ THREADED_MODE.md | 4 ++ 2 files changed, 133 insertions(+) create mode 100644 THREADED_DIEGETIC_IMGUI.md diff --git a/THREADED_DIEGETIC_IMGUI.md b/THREADED_DIEGETIC_IMGUI.md new file mode 100644 index 00000000..77ee7435 --- /dev/null +++ b/THREADED_DIEGETIC_IMGUI.md @@ -0,0 +1,129 @@ +# Threaded mode — the diegetic-ImGui / registered-image gap + +> Companion to [THREADED_MODE.md](THREADED_MODE.md). That doc covers the *main-canvas* ImGui +> path, which is fully threaded (sim-UI context + `ImDrawData` deep-clone in the snapshot). +> **This doc scopes the two ImGui features that are NOT yet threaded**, so their support can be +> designed and implemented separately. Until then, examples using them stay on the +> single-threaded `run(update, Controller&)` path. + +## TL;DR — what does not work threaded today + +| Feature | Public API | Threaded status | Blocked examples | +|---|---|---|---| +| **Diegetic ImGui in an RTT** | `Canvas::renderImguiTo(rtId, fontId, callback)` | **Unsupported** | `hello_imgui_rtt`, `hello_dpi_scaling` | +| **Registered ImGui images** | `Canvas::registerImguiImage` / `imguiImage` / `updateImguiImage` | **Unsupported** | `hello_imgui_visual`, `hello_imgui_image_registry`, `hello_markdown` (spinner) | + +Everything else the examples need is already threaded: main-canvas ImGui (interactive, input-marshalled), +`renderTo` sprite RTT passes (ride the snapshot's `rttPasses`), screenshots, explorers, runtime asset +mutation, `setScreenSize`. See THREADED_MODE.md § Done. + +> Note: THREADED_MODE.md's "Per-demo caveats" currently lists only the `imguiImages` examples. +> The diegetic `renderImguiTo` demos (`hello_imgui_rtt`, `hello_dpi_scaling`) and +> `hello_markdown` belong in the same deferred bucket — this doc is the authoritative list. + +## Why it's blocked — the mechanics + +Both features are driven **exclusively from the `FrameMode::Single` arm** of +`FrameRunner::produce` (`source/frame_runner.cpp`). The `FrameMode::Threaded` arm (the sim-thread +commit, ends ~line 592) never touches them, and `commitFrame` passes both managers as `nullptr`. + +### 1. Registered ImGui images (`ImguiImageManager`) + +Single-arm-only calls, absent in the Threaded arm: +- `imguiImages->beginFrame()` — advances the manager's frame clock (frame_runner.cpp ~line 606). +- `imguiImages->appendInternalPasses(snapshot.rttPasses)` — pushes the internal RTT passes that + rasterize each registered Visual into its off-screen target (frame_runner.cpp ~line 671). + +Consequence: in threaded mode no internal pass is ever appended, so the image's off-screen target is +never rendered and its `ImTextureID` handle stays `0` (`isReady` false forever). `imguiImage(id)` in a +sim-side `ui` would bake a null/empty handle. + +Additional wiring gaps: +- `commitFrame` (frame_runner.cpp ~line 1191+) calls `produce(FrameMode::Threaded, …, imguiImages = nullptr, …)`. + `runThreaded`/`commitFrame` must thread the real `ImguiImageManager&` through to the sim side. +- `ImguiImageManager::resolveImages()` (handle creation) must run render-side in `consume`, and its + `Entry` map is mutated from registration (potentially the sim thread) while read render-side — needs + the same id-referenced / deferred-free discipline the asset registry already uses. + +### 2. Diegetic ImGui (`ImguiRttManager::flushPending`) + +`Canvas::renderImguiTo(rtId, fontId, cb)` → `ImguiRttManager::enqueue(rtId, cb)` pushes onto +`mPendingPasses`. The queue is drained by `ImguiRttManager::flushPending(dt, sharedFonts)` +(`source/imgui_rtt_manager.cpp`), called from `renderSnapshotContents` (~line 820) — which runs on the +**render/main thread in both modes**. For each pass `flushPending`: +``` +SetCurrentContext(secondary rttCtx) +imguiNewFrameForRenderTarget(...) ; ImGui::NewFrame() +imguiDrawCallback() // <-- the USER's ImGui code +ImGui::Render() +beginRttPass(...) + render draw data +SetCurrentContext(mainCtx) +``` + +Why that's wrong under threading: +- **User callback runs on the render thread.** In the threaded model every user callback (`update`, `ui`, + input actions) runs on the **sim** thread; `renderImguiTo`'s callback captures app state by reference and + would execute on the **render** thread — the exact cross-thread aliasing the sim/render split exists to + prevent. Widgets mutating shared game state would race `update`. +- **Unsynchronized enqueue.** `enqueue` mutates `mPendingPasses` from the sim thread (inside `ui`/`update`) + while the render thread iterates it in `flushPending` — no mutex, no snapshot handoff. +- **No draw-data clone.** Unlike the main sim-UI context (whose `ImDrawData` is deep-cloned into the + snapshot via `imgui_draw_clone.*`), each secondary RTT context does a synchronous NewFrame→Render on the + render thread. There is no per-RTT clone riding the snapshot. +- **Shared font atlas.** The `fontId` auto-push/pop and secondary-context glyphs read the shared 1.92 + dynamic atlas, which is only mutex-guarded on the main sim-UI path today. + +## The reuse template (how the main path already solved the analogous problem) + +The main-canvas ImGui path is the working blueprint — the fix is to generalize it from one context to N: + +- **Sim-side render of the frame's ImGui**, on a per-context basis (main today; +N RTT contexts). +- **`ImDrawData` deep-clone into the `RenderSnapshot`** (`source/imgui_draw_clone.h/.cpp`, + `RenderSnapshot::mainUi`) — add an analogous per-RTT clone list to the snapshot. +- **Render thread replays cloned draw data only** — never runs a user callback, never a secondary NewFrame. +- **`mImguiMutex`** already serializes the shared atlas around the short sim-UI section. +- **Input marshalling** render→sim already exists for the main context; diegetic panels currently take no + input (v1 limitation noted in CLAUDE.md), so parity, not new input, is the near-term bar. + +## Rough implementation sketch (for the follow-up agent to expand) + +Not a committed design — a starting point. + +**Registered images (smaller):** +1. Thread `ImguiImageManager&` through `runThreaded` → `commitFrame` → `produce(FrameMode::Threaded)`. +2. In the Threaded arm, call `imguiImages->beginFrame()` before `uiCallback`, and + `imguiImages->appendInternalPasses(snapshot.rttPasses)` during the RTT gather (mirror lines 606/671). +3. Keep `resolveImages()` render-side in `consume`; audit `Entry`-map access for sim/render sharing and + apply the deferred-free / id-reference discipline used by `AssetRegistry`. +4. Verify `imguiImage(id)` draw baked in the sim-UI `ui` resolves the render-created handle across the + one-frame lag (handle stability is already a design goal of the registry). + +**Diegetic ImGui (larger):** +1. Move secondary-context NewFrame → `renderImguiTo` callback → Render onto the **sim thread**, against + per-RTT secondary contexts, under `mImguiMutex`. +2. Deep-clone each RTT's `ImDrawData` into a new per-RTT field of the snapshot (extend `imgui_draw_clone`). +3. `enqueue` becomes sim-thread-local (drained within the same commit); the render thread only executes + `beginRttPass` + replays the cloned draw data — no user callback, no NewFrame render-side. +4. Reconcile secondary-context lifecycle (lazy create / `releaseContext` / `drainPendingFontOps`) with the + sim/render split and deferred frees. + +## Affected files + +- `source/frame_runner.cpp` / `.h` — `produce(FrameMode::Threaded)` arm, `commitFrame` (drop the + `nullptr` managers), `renderFrameThreaded`, `consume`. +- `source/imgui_rtt_manager.h` / `.cpp` — `enqueue` / `flushPending` split into sim-produce vs + render-replay. +- `source/imgui_image_manager.h` / `.cpp` — sim-side `beginFrame`/`appendInternalPasses`, render-side + `resolveImages`, thread-safe `Entry` map. +- `source/imgui_draw_clone.h` / `.cpp` + `source/render_snapshot.h` — per-RTT cloned draw data in the + snapshot. +- `include/canvas.h` / `source/canvas.cpp` — `renderImguiTo` / `registerImguiImage` thread-affinity docs. + +## Verification (when implemented) + +- Migrate `hello_imgui_rtt`, `hello_dpi_scaling`, `hello_imgui_visual`, `hello_imgui_image_registry`, + `hello_markdown` to `run(update, ui[, sim, render])`; each renders identically to its single-thread form. +- TSan under `xvfb-run` on all five: 0 races in our code (proves the RTT-context and image-Entry sharing is + guarded). ASan/UBSan clean. +- SwiftShader goldens unchanged. +- Then drop these five from the deferred bucket in the migration plan and this doc's TL;DR table. diff --git a/THREADED_MODE.md b/THREADED_MODE.md index 7485dfe1..f909ff46 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -100,6 +100,10 @@ Per-demo caveats: may not be meaningful; keep as the single-threaded/manual-tick reference. - **hello_imgui_visual / hello_imgui_image_registry** exercise `imguiVisual`/`imguiImages`, which is single-threaded-only for now — port only once/if that path gains threaded support. +- **hello_imgui_rtt / hello_dpi_scaling** use `renderImguiTo` (diegetic ImGui in an RTT), and + **hello_markdown** an animated registered-image spinner — both the diegetic and registered-image + ImGui paths are single-threaded-only for now (see [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md)); + port only once that path gains threaded support. ## Out of scope From 1588e71949af1f0779765e7822e3f9e441b63eac Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:05:12 -0700 Subject: [PATCH 36/55] examples(threaded): migrate no-UI/no-controller demos to run(update, ui) Wave A: hello_animation, hello_text, hello_render_to_texture, hello_nested_render_targets. renderTo passes ride the frame snapshot, so they work threaded; empty ui callback (no ImGui/controllers). --- examples/hello_animation.cpp | 5 +++-- examples/hello_nested_render_targets.cpp | 8 ++++++-- examples/hello_render_to_texture.cpp | 8 ++++++-- examples/hello_text.cpp | 6 ++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/examples/hello_animation.cpp b/examples/hello_animation.cpp index bad0c1f9..54ffb621 100644 --- a/examples/hello_animation.cpp +++ b/examples/hello_animation.cpp @@ -101,8 +101,9 @@ int main() textureArrayAnimationTree.update(dt); }; - // Run the canvas with the update loop - canvas.run(update); + // Multithreaded convenience: the sim thread runs update; the main thread renders. + // No ImGui and no controllers, so the ui callback is empty. + canvas.run(update, [](float){}); return 0; } diff --git a/examples/hello_nested_render_targets.cpp b/examples/hello_nested_render_targets.cpp index 579578ca..97b2050f 100644 --- a/examples/hello_nested_render_targets.cpp +++ b/examples/hello_nested_render_targets.cpp @@ -100,7 +100,9 @@ int main() float time = 0.0f; constexpr float pi = std::numbers::pi_v; - canvas.run([&](float deltaTime) + // Sim thread runs update (scene + nested renderTo); the main thread renders. The RTT + // passes ride the frame snapshot, so nesting works on the threaded path. + auto update = [&](float deltaTime) { time += deltaTime; @@ -131,7 +133,9 @@ int main() // before the next level samples it as a texture. canvas.renderTo(innerRenderTargetId, {redBellotaId, yellowBellotaId}); canvas.renderTo(middleRenderTargetId, {innerDisplayBellotaId, blueBellotaId}); - }); + }; + + canvas.run(update, [](float){}); return 0; } diff --git a/examples/hello_render_to_texture.cpp b/examples/hello_render_to_texture.cpp index ff775b86..147944f6 100644 --- a/examples/hello_render_to_texture.cpp +++ b/examples/hello_render_to_texture.cpp @@ -62,7 +62,9 @@ int main() float time = 0.0f; - canvas.run([&](float deltaTime) + // Sim thread runs update (scene + renderTo); the main thread renders. renderTo rides + // the frame snapshot, so it works on the threaded path. No ImGui / controllers here. + auto update = [&](float deltaTime) { time += deltaTime; @@ -75,7 +77,9 @@ int main() // Schedule both source sprites to be rendered into the render target this frame. canvas.renderTo(renderTargetId, {redBellotaId, blueBellotaId}); - }); + }; + + canvas.run(update, [](float){}); return 0; } diff --git a/examples/hello_text.cpp b/examples/hello_text.cpp index 59977027..0ccdd64c 100644 --- a/examples/hello_text.cpp +++ b/examples/hello_text.cpp @@ -101,7 +101,9 @@ int main() } }; - canvas.run(update); - + // Multithreaded convenience: the sim thread runs update; the main thread renders. + // No ImGui and no controllers, so the ui callback is empty. + canvas.run(update, [](float){}); + return 0; } \ No newline at end of file From b4235e1658665ade415e37033ed63de21a877b21 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:07:07 -0700 Subject: [PATCH 37/55] examples(threaded): split ImGui into ui callback for no-controller demos Wave B: hello_tint, hello_mesh, hello_imgui_overlay, hello_custom_font. Scene logic stays in update (sim); ImGui moves to the ui callback. Flags shared between the two are plain (both run on the sim thread). --- examples/hello_custom_font.cpp | 9 +++++++-- examples/hello_imgui_overlay.cpp | 7 +++++-- examples/hello_mesh.cpp | 22 ++++++++++++++-------- examples/hello_tint.cpp | 21 ++++++++++++--------- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/examples/hello_custom_font.cpp b/examples/hello_custom_font.cpp index 5565f56f..fb23855e 100644 --- a/examples/hello_custom_font.cpp +++ b/examples/hello_custom_font.cpp @@ -71,7 +71,10 @@ int main() fontDialog.SetTitle("Choose a font file"); fontDialog.SetTypeFilters({ ".ttf", ".otf" }); - canvas.run([&](float) + // Everything here is ImGui + font baking, so it all lives in the ui callback (sim-UI + // context, sim thread). Font ops (bake/add/remove/push) drain on the render thread; the + // id-stable font API keeps handles valid across the sim/render boundary. + auto ui = [&](float) { bool commitPath = false; @@ -195,7 +198,9 @@ int main() lastError.clear(); } } - }); + }; + + canvas.run([](float){}, ui); return 0; } diff --git a/examples/hello_imgui_overlay.cpp b/examples/hello_imgui_overlay.cpp index 839d374b..b876a31f 100644 --- a/examples/hello_imgui_overlay.cpp +++ b/examples/hello_imgui_overlay.cpp @@ -75,13 +75,16 @@ int main() const Nothofagus::TextureId textureId = canvas.addTexture(texture); canvas.addBellota({{{100.0f, 75.0f}, 6.0f}, textureId}); - auto update = [&](float) + // ImGui overlay — runs on the sim-UI context (sim thread), cloned to the render thread. + // imguiOverlayViewport()/imguiScaledFontSize() are render-computed; reading them here + // may lag one frame, which is imperceptible for the bars. + auto ui = [&](float) { drawOverlayBars(canvas, "HEADER - resize the window; the bar tracks the canvas", "FOOTER - imguiOverlayViewport() + imguiBaseFontSize()"); }; - canvas.run(update); + canvas.run([](float){}, ui); return 0; } diff --git a/examples/hello_mesh.cpp b/examples/hello_mesh.cpp index 1e0103bb..7a61c4fb 100644 --- a/examples/hello_mesh.cpp +++ b/examples/hello_mesh.cpp @@ -153,12 +153,25 @@ int main() bool swap = false; bool prevSwap = false; + // Game logic — runs on the sim thread (no ImGui here). Reads `swap`, which the ui + // writes; both callbacks run on the sim thread, so a plain bool is safe. auto update = [&](float dt) { time += dt; canvas.bellota(triangleId).transform().angle() = 0.05f * time; canvas.bellota(pentagonId).transform().angle() = -0.04f * time; + if (swap != prevSwap) + { + canvas.setMesh(triangleId, swap ? pentagonMeshId : triangleMeshId); + canvas.setMesh(pentagonId, swap ? triangleMeshId : pentagonMeshId); + prevSwap = swap; + } + }; + + // Interactive ImGui — runs on the sim-UI context (sim thread), cloned to the render thread. + auto ui = [&](float) + { ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); ImGui::Begin("Custom meshes"); ImGui::Text("Left: user triangle mesh"); @@ -166,15 +179,8 @@ int main() ImGui::Text("Bottom: user quad mesh"); ImGui::Checkbox("Swap triangle <-> pentagon", &swap); ImGui::End(); - - if (swap != prevSwap) - { - canvas.setMesh(triangleId, swap ? pentagonMeshId : triangleMeshId); - canvas.setMesh(pentagonId, swap ? triangleMeshId : pentagonMeshId); - prevSwap = swap; - } }; - canvas.run(update); + canvas.run(update, ui); return 0; } diff --git a/examples/hello_tint.cpp b/examples/hello_tint.cpp index a6081228..a569262f 100644 --- a/examples/hello_tint.cpp +++ b/examples/hello_tint.cpp @@ -59,16 +59,15 @@ int main() Nothofagus::BellotaId bellotaIdBlue = canvas.addBellota({ {{100.0f, 50.0f}}, textureIdBlue }); float time = 0.0f; - bool rotate = true; float intensity = 0.0f; float tintColor[3] = { 1.0f, 1.0f, 1.0f }; + // Game logic — runs on the sim thread (no ImGui here). Reads intensity/tintColor, + // which the ui writes; both callbacks run on the sim thread, so plain floats are safe. auto update = [&](float dt) { time += dt; - Nothofagus::Bellota& bellotaRed = canvas.bellota(bellotaIdRed); - Nothofagus::Bellota& bellotaGreen = canvas.bellota(bellotaIdGreen); bellotaGreen.transform().location() = glm::vec2(100.0f, 50.0f) + 20.0f * glm::vec2( @@ -83,18 +82,22 @@ int main() std::sin(0.001f * time + std::numbers::pi/4) ); + canvas.setTint(bellotaIdGreen, { intensity, glm::make_vec3(tintColor) }); + + canvas.setTint(bellotaIdRed, { std::abs(std::sin(0.005f * time)), {1.0, 1.0, 1.0} }); + }; + + // Interactive ImGui — runs on the sim-UI context (sim thread), cloned to the render thread. + auto ui = [&](float) + { ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); ImGui::Begin("Green Tint"); ImGui::SliderFloat("Intensity", &intensity, 0.0f, 1.0f); ImGui::ColorPicker3("Color", tintColor); ImGui::End(); + }; - canvas.setTint(bellotaIdGreen, { intensity, glm::make_vec3(tintColor) }); + canvas.run(update, ui); - canvas.setTint(bellotaIdRed, { std::abs(std::sin(0.005f * time)), {1.0, 1.0, 1.0} }); - }; - - canvas.run(update); - return 0; } \ No newline at end of file From 6c90f8c57fb8ac453c774c353569e5d617cdb5c7 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:11:45 -0700 Subject: [PATCH 38/55] examples(threaded): split controller into sim/render for single-controller demos Wave C: hello_layers, hello_direct_texture, hello_animation_state_machine, test_create_destroy, hello_tilemap, test_gamepad. Game input + gamepad -> simController; ESC/window ops -> renderController; display ImGui -> ui. hello_screenshot deferred: threaded screenshot needs cross-thread request/result marshaling that isn't built yet. --- examples/hello_animation_state_machine.cpp | 20 ++++---- examples/hello_direct_texture.cpp | 11 +++-- examples/hello_layers.cpp | 14 +++--- examples/hello_tilemap.cpp | 10 ++-- examples/test_create_destroy.cpp | 20 +++++--- examples/test_gamepad.cpp | 55 +++++++++++++--------- 6 files changed, 81 insertions(+), 49 deletions(-) diff --git a/examples/hello_animation_state_machine.cpp b/examples/hello_animation_state_machine.cpp index 66dc5c88..a4607b84 100644 --- a/examples/hello_animation_state_machine.cpp +++ b/examples/hello_animation_state_machine.cpp @@ -153,7 +153,8 @@ int main() // Hold a direction to look that way; release recenters the gaze. Arrows and // WASD are aliases. (Known demo limitation: holding two direction keys and // releasing one recenters even though the other is still down.) - Nothofagus::Controller controller; + // Game input — dispatched on the sim thread; drives the animation state machine. + Nothofagus::Controller simController; auto goLeft = [&]() { machine.goToState("left"); }; auto goRight = [&]() { machine.goToState("right"); }; auto goUp = [&]() { machine.goToState("up"); }; @@ -163,21 +164,24 @@ int main() using Nothofagus::DiscreteTrigger; for (Key key : {Key::LEFT, Key::A}) { - controller.registerAction({key, DiscreteTrigger::Press}, goLeft); + simController.registerAction({key, DiscreteTrigger::Press}, goLeft); } for (Key key : {Key::RIGHT, Key::D}) { - controller.registerAction({key, DiscreteTrigger::Press}, goRight); + simController.registerAction({key, DiscreteTrigger::Press}, goRight); } for (Key key : {Key::UP, Key::W}) { - controller.registerAction({key, DiscreteTrigger::Press}, goUp); - controller.registerAction({key, DiscreteTrigger::Release}, goIdle); + simController.registerAction({key, DiscreteTrigger::Press}, goUp); + simController.registerAction({key, DiscreteTrigger::Release}, goIdle); } - controller.registerAction({Key::DOWN, DiscreteTrigger::Press}, goIdle); - controller.registerAction({Key::S, DiscreteTrigger::Press}, goIdle); + simController.registerAction({Key::DOWN, DiscreteTrigger::Press}, goIdle); + simController.registerAction({Key::S, DiscreteTrigger::Press}, goIdle); - canvas.run(update, controller); + // Multithreaded convenience: sim thread runs update + simController; the main thread + // runs the render loop. No ImGui and no window input, so ui + renderController are empty. + Nothofagus::Controller renderController; + canvas.run(update, [](float){}, simController, renderController); return 0; } diff --git a/examples/hello_direct_texture.cpp b/examples/hello_direct_texture.cpp index 8b9cf629..cc391836 100644 --- a/examples/hello_direct_texture.cpp +++ b/examples/hello_direct_texture.cpp @@ -67,8 +67,9 @@ int main() bellota2.transform().location().x = 75.0f + 60.0f * std::sin(0.0005f * time); }; - Nothofagus::Controller controller; - controller.registerAction({Nothofagus::Key::SPACE, Nothofagus::DiscreteTrigger::Press}, [&]() + // Game input — dispatched on the sim thread; mutates external texture memory + marks it dirty. + Nothofagus::Controller simController; + simController.registerAction({Nothofagus::Key::SPACE, Nothofagus::DiscreteTrigger::Press}, [&]() { // rotating selection of new color const std::uint8_t newR = static_cast(255.f * textureColors.at(colorIndex).r); @@ -94,7 +95,11 @@ int main() spdlog::info("Texture modified during runtime. Current color: ({}, {}, {}, {})", newR, newG, newB, newA); }); - canvas.run(update, controller); + + // Multithreaded convenience: sim thread runs update + simController; the main thread + // runs the render loop. No ImGui and no window input, so ui + renderController are empty. + Nothofagus::Controller renderController; + canvas.run(update, [](float){}, simController, renderController); return 0; } \ No newline at end of file diff --git a/examples/hello_layers.cpp b/examples/hello_layers.cpp index 9919389a..27c5b390 100644 --- a/examples/hello_layers.cpp +++ b/examples/hello_layers.cpp @@ -86,25 +86,27 @@ int main() // Initialize layer index variable int layer = 0; - // Create a controller for handling user inputs - Nothofagus::Controller controller; + // Game input — dispatched on the sim thread; mutates the live scene. + Nothofagus::Controller simController; // Register a keybinding for "W" to increment the current layer - controller.registerAction({Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press}, [&]() + simController.registerAction({Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press}, [&]() { layer = (layer + 1) % 5; // Cycle to the next layer (0-4) animatedbellota.currentLayer() = layer; // Update the visible layer }); // Register a keybinding for "S" to decrement the current layer - controller.registerAction({Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press}, [&]() + simController.registerAction({Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press}, [&]() { layer = (layer + 4) % 5; // Cycle to the previous layer (0-4) animatedbellota.currentLayer() = layer; // Update the visible layer }); - // Run the canvas with the update function and controller logic - canvas.run(update, controller); + // Multithreaded convenience: sim thread runs update + simController; the main thread + // runs the render loop. No ImGui and no window input, so ui + renderController are empty. + Nothofagus::Controller renderController; + canvas.run(update, [](float){}, simController, renderController); return 0; } diff --git a/examples/hello_tilemap.cpp b/examples/hello_tilemap.cpp index 2b7a73a1..d2f5ff25 100644 --- a/examples/hello_tilemap.cpp +++ b/examples/hello_tilemap.cpp @@ -125,11 +125,15 @@ int main() tileMapTexId }); - Nothofagus::Controller controller; - controller.registerAction( + // Window input — dispatched on the main thread; ESC closes the window. + Nothofagus::Controller renderController; + renderController.registerAction( { Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press }, [&]() { canvas.close(); }); - canvas.run([](float) {}, controller); + // Multithreaded convenience: no game logic/ImGui/input here (static tile map), so + // update, ui, and the sim controller are all empty; ESC lives on the render controller. + Nothofagus::Controller simController; + canvas.run([](float){}, [](float){}, simController, renderController); return 0; } diff --git a/examples/test_create_destroy.cpp b/examples/test_create_destroy.cpp index 02356374..6f557144 100644 --- a/examples/test_create_destroy.cpp +++ b/examples/test_create_destroy.cpp @@ -74,7 +74,9 @@ int main() std::vector bellotaIds; - auto update = [&](float dt) + // ImGui — runs on the sim-UI context (sim thread). Reads bellotaIds, which the + // sim-thread controller actions mutate; both are sim-side, so no atomics needed. + auto ui = [&](float) { ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); ImGui::Begin("Use W to create and S to destroy"); @@ -82,8 +84,9 @@ int main() ImGui::End(); }; - Nothofagus::Controller controller; - controller.registerAction({Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press}, [&]() + // Game input — dispatched on the sim thread; creates/destroys bellotas in the live scene. + Nothofagus::Controller simController; + simController.registerAction({Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press}, [&]() { glm::vec2 randomPosition = generateRandomPosition(screenSize.width, screenSize.height); Nothofagus::BellotaId newBellotaId = addBellotaWithWrittenId(canvas, dummyTextureId, pallete, randomPosition); @@ -91,7 +94,7 @@ int main() spdlog::info("Bellota {} created!", newBellotaId.id); }); - controller.registerAction({Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press}, [&]() + simController.registerAction({Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press}, [&]() { if (bellotaIds.empty()) return; @@ -106,8 +109,11 @@ int main() spdlog::info("Bellota {} destroyed :(", bellotaIdToDelete.id); }); - - canvas.run(update, controller); - + + // Multithreaded convenience: sim thread runs ui + simController; the main thread renders. + // No window input, so renderController is empty. + Nothofagus::Controller renderController; + canvas.run([](float){}, ui, simController, renderController); + return 0; } \ No newline at end of file diff --git a/examples/test_gamepad.cpp b/examples/test_gamepad.cpp index 66409e3f..fba10597 100644 --- a/examples/test_gamepad.cpp +++ b/examples/test_gamepad.cpp @@ -44,30 +44,34 @@ int main() bool rotate = false; float pulseTimer = 0.0f; - Nothofagus::Controller controller; - - // Keyboard: Escape to quit - controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, - [&]() { canvas.close(); }); + // Game input — dispatched on the sim thread. Gamepad state is harvested render-side + // and fed to the sim controller before update, so gamepad actions + axis polling + // (in update and ui) all resolve against simController on the sim thread. + Nothofagus::Controller simController; // Gamepad: A button pulse - controller.registerGamepadAction({0, Nothofagus::GamepadButton::A, Nothofagus::DiscreteTrigger::Press}, + simController.registerGamepadAction({0, Nothofagus::GamepadButton::A, Nothofagus::DiscreteTrigger::Press}, [&]() { pulseTimer = 500.0f; }); // Gamepad: Start toggles rotation - controller.registerGamepadAction({0, Nothofagus::GamepadButton::Start, Nothofagus::DiscreteTrigger::Press}, + simController.registerGamepadAction({0, Nothofagus::GamepadButton::Start, Nothofagus::DiscreteTrigger::Press}, [&]() { rotate = not rotate; }); // Gamepad: D-pad discrete movement - controller.registerGamepadAction({0, Nothofagus::GamepadButton::DpadUp, Nothofagus::DiscreteTrigger::Press}, + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadUp, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().y += discreteStep; }); - controller.registerGamepadAction({0, Nothofagus::GamepadButton::DpadDown, Nothofagus::DiscreteTrigger::Press}, + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadDown, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().y -= discreteStep; }); - controller.registerGamepadAction({0, Nothofagus::GamepadButton::DpadLeft, Nothofagus::DiscreteTrigger::Press}, + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadLeft, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().x -= discreteStep; }); - controller.registerGamepadAction({0, Nothofagus::GamepadButton::DpadRight, Nothofagus::DiscreteTrigger::Press}, + simController.registerGamepadAction({0, Nothofagus::GamepadButton::DpadRight, Nothofagus::DiscreteTrigger::Press}, [&]() { canvas.bellota(bellotaId).transform().location().x += discreteStep; }); + // Window input — dispatched on the main thread; Escape closes the window. + Nothofagus::Controller renderController; + renderController.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, + [&]() { canvas.close(); }); + auto update = [&](float deltaTime) { time += deltaTime; @@ -75,12 +79,12 @@ int main() Nothofagus::Bellota& bellota = canvas.bellota(bellotaId); // Smooth movement via left stick (polling) - std::vector connectedIds = controller.getConnectedGamepadIds(); + std::vector connectedIds = simController.getConnectedGamepadIds(); if (not connectedIds.empty()) { int gamepadId = connectedIds[0]; - float leftX = controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftX); - float leftY = controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftY); + float leftX = simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftX); + float leftY = simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftY); bellota.transform().location().x += leftX * horizontalSpeed * deltaTime; bellota.transform().location().y += leftY * horizontalSpeed * deltaTime; } @@ -104,8 +108,13 @@ int main() { bellota.transform().scale() = glm::vec2(3.0f, 3.0f); } + }; + + // ImGui status — runs on the sim-UI context (sim thread); polls the same simController. + auto ui = [&](float) + { + std::vector connectedIds = simController.getConnectedGamepadIds(); - // ImGui status ImGui::SetNextWindowSize(ImVec2(0.0f, 0.0f), ImGuiCond_Once); ImGui::Begin("Gamepad Test"); ImGui::Text("Left stick: move sprite"); @@ -129,19 +138,21 @@ int main() int gamepadId = connectedIds[0]; ImGui::Text("LX: %.2f LY: %.2f", - controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftX), - controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftY)); + simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftX), + simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftY)); ImGui::Text("RX: %.2f RY: %.2f", - controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::RightX), - controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::RightY)); + simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::RightX), + simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::RightY)); ImGui::Text("LT: %.2f RT: %.2f", - controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftTrigger), - controller.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::RightTrigger)); + simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::LeftTrigger), + simController.getGamepadAxis(gamepadId, Nothofagus::GamepadAxis::RightTrigger)); } ImGui::End(); }; - canvas.run(update, controller); + // Multithreaded convenience: sim thread runs update + ui + simController (gamepad); + // the main thread runs the render loop + renderController (ESC/close). + canvas.run(update, ui, simController, renderController); return 0; } From 735e52ead450c50aa278cfce70b76f74b8e2a75b Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:14:34 -0700 Subject: [PATCH 39/55] examples(threaded): migrate dense/sparse land explorers to run(update, ui, sim, render) Wave D: hello_dense_land, hello_sparse_land. Camera pan + streaming/edit storm -> update; interactive ImGui (incl. addChunk/removeChunk/rebuild/ setScreenSize asset ops) -> ui; WASD -> simController, ESC -> renderController. TSan clean on hello_dense_land (sim-thread sharing race-free). --- examples/hello_dense_land.cpp | 43 +++++++++++++++++++++---------- examples/hello_sparse_land.cpp | 46 +++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/examples/hello_dense_land.cpp b/examples/hello_dense_land.cpp index 7177cca0..a0df36ba 100644 --- a/examples/hello_dense_land.cpp +++ b/examples/hello_dense_land.cpp @@ -238,20 +238,27 @@ int main() // Track WASD as held state via Press/Release actions (Controller is event-driven). bool wDown = false, sDown = false, aDown = false, dDown = false; - Nothofagus::Controller controller; - controller.registerAction( + // Game input — dispatched on the sim thread; WASD toggles held-pan bools read by update. + Nothofagus::Controller simController; + simController.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press }, [&]() { wDown = true; }); + simController.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Release }, [&]() { wDown = false; }); + simController.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press }, [&]() { sDown = true; }); + simController.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Release }, [&]() { sDown = false; }); + simController.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Press }, [&]() { aDown = true; }); + simController.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Release }, [&]() { aDown = false; }); + simController.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Press }, [&]() { dDown = true; }); + simController.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Release }, [&]() { dDown = false; }); + + // Window input — dispatched on the main thread; ESC closes the window. + Nothofagus::Controller renderController; + renderController.registerAction( { Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press }, [&]() { canvas.close(); }); - controller.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press }, [&]() { wDown = true; }); - controller.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Release }, [&]() { wDown = false; }); - controller.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press }, [&]() { sDown = true; }); - controller.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Release }, [&]() { sDown = false; }); - controller.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Press }, [&]() { aDown = true; }); - controller.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Release }, [&]() { aDown = false; }); - controller.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Press }, [&]() { dDown = true; }); - controller.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Release }, [&]() { dDown = false; }); - - canvas.run([&](float deltaTimeMS) + + // Game logic — runs on the sim thread (no ImGui here): camera pan + edit storm. + // Shared UI state (autoPan, editsPerFrame, camera, mapSize, ...) is written by the + // sim-thread ui and read here; both callbacks are sim-side, so no atomics are needed. + auto update = [&](float deltaTimeMS) { // ── Camera pan ────────────────────────────────────────────────── const float dt = deltaTimeMS / 1000.0f; @@ -302,7 +309,13 @@ int main() world.setCell(cellCoord, static_cast(1 + (next() % 4))); } } + }; + // Interactive ImGui — runs on the sim-UI context (sim thread), cloned to the render + // thread. Teleport, rebuild (remove/add denseLand + explorer) and setScreenSize issued + // here are sim-side, same as update; all are threaded-safe asset ops. + auto ui = [&](float) + { // ── ImGui control panel ───────────────────────────────────────── ImGui::Begin("DenseLand"); @@ -434,7 +447,11 @@ int main() } ImGui::End(); - }, controller); + }; + + // Multithreaded convenience: sim thread runs update + ui + simController (WASD); + // the main thread runs the render loop + renderController (ESC/close). + canvas.run(update, ui, simController, renderController); return 0; } diff --git a/examples/hello_sparse_land.cpp b/examples/hello_sparse_land.cpp index 020f4ab5..3b7d5504 100644 --- a/examples/hello_sparse_land.cpp +++ b/examples/hello_sparse_land.cpp @@ -220,20 +220,27 @@ int main() constexpr float panSpeed = 1000.0f; // world px / s — fast enough to cross ~4 chunks/s at 256-px chunks bool wDown = false, sDown = false, aDown = false, dDown = false; - Nothofagus::Controller controller; - controller.registerAction( + // Game input — dispatched on the sim thread; WASD toggles held-pan bools read by update. + Nothofagus::Controller simController; + simController.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press }, [&]() { wDown = true; }); + simController.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Release }, [&]() { wDown = false; }); + simController.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press }, [&]() { sDown = true; }); + simController.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Release }, [&]() { sDown = false; }); + simController.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Press }, [&]() { aDown = true; }); + simController.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Release }, [&]() { aDown = false; }); + simController.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Press }, [&]() { dDown = true; }); + simController.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Release }, [&]() { dDown = false; }); + + // Window input — dispatched on the main thread; ESC closes the window. + Nothofagus::Controller renderController; + renderController.registerAction( { Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press }, [&]() { canvas.close(); }); - controller.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Press }, [&]() { wDown = true; }); - controller.registerAction({ Nothofagus::Key::W, Nothofagus::DiscreteTrigger::Release }, [&]() { wDown = false; }); - controller.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Press }, [&]() { sDown = true; }); - controller.registerAction({ Nothofagus::Key::S, Nothofagus::DiscreteTrigger::Release }, [&]() { sDown = false; }); - controller.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Press }, [&]() { aDown = true; }); - controller.registerAction({ Nothofagus::Key::A, Nothofagus::DiscreteTrigger::Release }, [&]() { aDown = false; }); - controller.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Press }, [&]() { dDown = true; }); - controller.registerAction({ Nothofagus::Key::D, Nothofagus::DiscreteTrigger::Release }, [&]() { dDown = false; }); - - canvas.run([&](float deltaTimeMS) + + // Game logic — runs on the sim thread (no ImGui here): camera pan + chunk streaming. + // Shared UI state (streamingOn, density, radii) is written by the sim-thread ui and + // read here, so plain values are safe (both callbacks are sim-side). + auto update = [&](float deltaTimeMS) { const float dt = deltaTimeMS / 1000.0f; @@ -294,8 +301,15 @@ int main() world.removeChunk({cameraChunk.x + dx, cameraChunk.y + dy}); } } + }; + + // Interactive ImGui — runs on the sim-UI context (sim thread), cloned to the render + // thread. World mutations (addChunk/removeChunk/setCell) and setScreenSize issued here + // are sim-side, same as update; both are threaded-safe asset ops. + auto ui = [&](float) + { + Nothofagus::SparseLand& world = canvas.sparseLand(sparseLandId); - // ── UI ────────────────────────────────────────────────────────── ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver); ImGui::Begin("SparseLand"); @@ -429,7 +443,11 @@ int main() "while the pool stays flat regardless of world extent."); ImGui::End(); - }, controller); + }; + + // Multithreaded convenience: sim thread runs update + ui + simController (WASD); + // the main thread runs the render loop + renderController (ESC/close). + canvas.run(update, ui, simController, renderController); return 0; } From ec542a7e1c80d84e4f9d0d9dead7d72960731d61 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:19:26 -0700 Subject: [PATCH 40/55] canvas(threaded): reroute run()/run(update) to the threaded path, deprecate run(update, Controller&) run() and run(update) now forward to the sim/render split (empty ui + controllers); the single-controller run(update, Controller&) is marked [[deprecated]] but kept as the single-thread survivor. The six demos whose threaded support isn't built yet (registered ImGui images, diegetic renderImguiTo, scheduled screenshot) are parked on it with TODOs; each emits the intended deprecation warning. Goldens 25/25. --- examples/hello_dpi_scaling.cpp | 7 ++++++- examples/hello_imgui_image_registry.cpp | 6 +++++- examples/hello_imgui_rtt.cpp | 7 ++++++- examples/hello_imgui_visual.cpp | 6 +++++- examples/hello_markdown.cpp | 7 ++++++- examples/hello_screenshot.cpp | 4 ++++ include/canvas.h | 18 ++++++++++++++---- source/canvas.cpp | 12 +++++++----- 8 files changed, 53 insertions(+), 14 deletions(-) diff --git a/examples/hello_dpi_scaling.cpp b/examples/hello_dpi_scaling.cpp index 61dc374f..44675455 100644 --- a/examples/hello_dpi_scaling.cpp +++ b/examples/hello_dpi_scaling.cpp @@ -48,6 +48,11 @@ int main() char nameBuf[64] = "Nothofagus"; float time = 0.0f; + // TODO(threaded): the diegetic RTT panel uses renderImguiTo, which runs its user + // callback on the render thread and has no per-RTT draw-data clone, so it is not wired + // for the sim/render split yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the + // deprecated single-thread run(update, Controller&) until threaded support lands. + Nothofagus::Controller deferredController; canvas.run([&](float deltaTimeMS) { time += deltaTimeMS; @@ -154,7 +159,7 @@ int main() ImGui::ProgressBar(0.5f + 0.5f * std::sin(0.003f * time)); ImGui::End(); }); - }); + }, deferredController); return 0; } diff --git a/examples/hello_imgui_image_registry.cpp b/examples/hello_imgui_image_registry.cpp index 50a8a129..170630c7 100644 --- a/examples/hello_imgui_image_registry.cpp +++ b/examples/hello_imgui_image_registry.cpp @@ -64,6 +64,10 @@ int main() float drawScale = 1.0f; bool registered = true; + // TODO(threaded): registered-image passes (registerImguiImage / imguiImage) are not + // wired for the sim/render split yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the + // deprecated single-thread run(update, Controller&) until threaded support lands. + Nothofagus::Controller deferredController; canvas.run([&](float dt) { // Advance the animation by re-registering the current layer onto the big image's id. @@ -116,7 +120,7 @@ int main() } ImGui::End(); - }); + }, deferredController); return 0; } diff --git a/examples/hello_imgui_rtt.cpp b/examples/hello_imgui_rtt.cpp index c7baa61a..b0965027 100644 --- a/examples/hello_imgui_rtt.cpp +++ b/examples/hello_imgui_rtt.cpp @@ -34,6 +34,11 @@ int main() Nothofagus::ImguiFontId small16Id{}; bool wantSmall16 = false; + // TODO(threaded): diegetic renderImguiTo runs its user callback on the render thread + // and has no per-RTT draw-data clone, so it is not wired for the sim/render split yet + // (see THREADED_DIEGETIC_IMGUI.md). Parked on the deprecated single-thread + // run(update, Controller&) until threaded support lands. + Nothofagus::Controller deferredController; canvas.run([&](float deltaTimeMS) { time += deltaTimeMS; @@ -108,7 +113,7 @@ int main() wantSmall16 = false; } ImGui::End(); - }); + }, deferredController); return 0; } diff --git a/examples/hello_imgui_visual.cpp b/examples/hello_imgui_visual.cpp index 7d554447..470c7798 100644 --- a/examples/hello_imgui_visual.cpp +++ b/examples/hello_imgui_visual.cpp @@ -128,6 +128,10 @@ int main() float scale = 8.0f; float elapsedMs = 0.0f; + // TODO(threaded): registered-image passes (registerImguiImage / imguiImage) are not + // wired for the sim/render split yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the + // deprecated single-thread run(update, Controller&) until threaded support lands. + Nothofagus::Controller deferredController; canvas.run([&](float dt) { // Advance the animation (current layer) and push it — plus the live opacity — onto @@ -210,7 +214,7 @@ int main() canvas.imguiImage(sceneRtImageId); ImGui::End(); - }); + }, deferredController); return 0; } diff --git a/examples/hello_markdown.cpp b/examples/hello_markdown.cpp index 3f6b77b8..b2efbd42 100644 --- a/examples/hello_markdown.cpp +++ b/examples/hello_markdown.cpp @@ -196,6 +196,11 @@ wider than the window, so it overflows — drag the horizontal scrollbar to pan: float elapsedMs = 0.0f; + // TODO(threaded): the animated inline spinner uses a registered image + // (registerImguiImage / updateImguiImage), which is not wired for the sim/render split + // yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the deprecated single-thread + // run(update, Controller&) until threaded support lands. + Nothofagus::Controller deferredController; canvas.run([&](float dt) { // Keep the inline spinner animating: advance its layer and push it onto the @@ -221,7 +226,7 @@ wider than the window, so it overflows — drag the horizontal scrollbar to pan: ImGui::Begin("Oversized image (horizontal scroll)", nullptr, ImGuiWindowFlags_HorizontalScrollbar); markdown.print(kOverflowSample); ImGui::End(); - }); + }, deferredController); return 0; } diff --git a/examples/hello_screenshot.cpp b/examples/hello_screenshot.cpp index b59834c6..3ce477f2 100644 --- a/examples/hello_screenshot.cpp +++ b/examples/hello_screenshot.cpp @@ -161,6 +161,10 @@ int main() canvas.requestScreenshot(); }); + // TODO(threaded): scheduled screenshots cross the sim/render boundary unsynchronized — + // requestScreenshot() arms flags on the sim thread while finishScreenshot() writes + // mScreenshotResult on the render thread, with no marshaling. Parked on the deprecated + // single-thread run(update, Controller&) until a threaded request/result handoff lands. canvas.run(update, controller); return 0; } diff --git a/include/canvas.h b/include/canvas.h index 91647e69..3786d256 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -674,21 +674,31 @@ class Canvas const bool& stats() const; /** - * @brief Start the canvas main loop with the default update function. + * @brief Start the canvas main loop with no update. Threaded: spins the sim/render + * split with empty update/ui/controllers, closing via the window. */ void run(); /** - * @brief Start the canvas main loop with a custom update function. - * @param update The custom update function to call each frame. + * @brief Start the canvas main loop with a custom update function. Threaded: `update` + * runs on the sim thread with empty ui + controllers. Keep any ImGui in the + * `run(update, ui[, simController, renderController])` overloads — the sim + * thread has no open main-context ImGui frame, so ImGui in `update` won't draw. + * @param update The custom update function to call each frame on the sim thread. */ void run(std::function update); /** - * @brief Start the canvas main loop with a custom update function and controller. + * @brief [DEPRECATED] Single-threaded loop with one controller (ImGui and input share + * the update callback on the main thread). Superseded by the threaded + * run(update, ui, simController, renderController); split ImGui into `ui` and + * input into a sim + render controller. Kept for demos not yet ported to the + * threaded path (diegetic ImGui / registered images / screenshot). * @param update The custom update function to call each frame. * @param controller The controller object to handle inputs. */ + [[deprecated("Use the threaded run(update, ui, simController, renderController); " + "see THREADED_MODE.md")]] void run(std::function update, Controller& controller); /// Threaded convenience: run the sim/render split as a one-liner. nothofagus owns diff --git a/source/canvas.cpp b/source/canvas.cpp index 83b57627..6aba4107 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -394,19 +394,21 @@ const bool& Canvas::stats() const { return mImplP void Canvas::run() { - auto update = [](float){}; - Controller controller; - mImplPtr->frameRunner.run(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, update, controller); + // Threaded: empty update + ui, forwarded to the sim/render split like run(update, ui). + run([](float){}, [](float){}); } void Canvas::run(std::function update) { - Controller controller; - mImplPtr->frameRunner.run(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, update, controller); + // Threaded: run `update` on the sim thread with an empty ui. (ImGui belongs in the + // run(update, ui[, ...]) overloads — see the header note.) + run(std::move(update), [](float){}); } void Canvas::run(std::function update, Controller& controller) { + // Deprecated single-threaded path (kept for demos not yet ported to the threaded + // sim/render split). Suppress the self-deprecation warning on this definition. mImplPtr->frameRunner.run(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, update, controller); } From 88be312150b445196ad6e656de6b99c88c0051f7 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:00:56 -0700 Subject: [PATCH 41/55] feat(threaded): registered ImGui images in the sim/render split Thread ImguiImageManager through runThreaded/commitFrame/renderFrameThreaded, call beginFrame + appendInternalPasses in the Threaded produce arm, guard the registry under the asset mutex, and defer GPU handle/RTT frees to the render thread. Migrate hello_imgui_visual / hello_imgui_image_registry / hello_markdown to run(update, ui). --- examples/hello_imgui_image_registry.cpp | 19 +++++++------ examples/hello_imgui_visual.cpp | 22 ++++++++------- examples/hello_markdown.cpp | 20 +++++++------ source/canvas.cpp | 15 ++++++++-- source/frame_runner.cpp | 28 ++++++++++++++++--- source/frame_runner.h | 9 ++++-- source/imgui_image_manager.cpp | 37 +++++++++++++++++++++++-- source/imgui_image_manager.h | 18 +++++++++++- 8 files changed, 130 insertions(+), 38 deletions(-) diff --git a/examples/hello_imgui_image_registry.cpp b/examples/hello_imgui_image_registry.cpp index 170630c7..aaced6d6 100644 --- a/examples/hello_imgui_image_registry.cpp +++ b/examples/hello_imgui_image_registry.cpp @@ -64,14 +64,11 @@ int main() float drawScale = 1.0f; bool registered = true; - // TODO(threaded): registered-image passes (registerImguiImage / imguiImage) are not - // wired for the sim/render split yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the - // deprecated single-thread run(update, Controller&) until threaded support lands. - Nothofagus::Controller deferredController; - canvas.run([&](float dt) + // Game logic on the sim thread: advance the animation by re-registering the current + // layer onto the big image's id. The id (and its ImTextureID) stays stable across the + // update — no re-warm. Runs before `ui` each commit, so the frame `ui` draws is current. + auto update = [&](float dt) { - // Advance the animation by re-registering the current layer onto the big image's id. - // The id (and its ImTextureID) stays stable across the update — no re-warm. elapsedMs += dt; const std::size_t frame = static_cast(elapsedMs / 180.0f) % kFrames; if (registered) @@ -80,7 +77,11 @@ int main() animated.currentLayer() = frame; canvas.updateImguiImage(bigId, animated); } + }; + // ImGui on the sim thread (drawn into the sim-UI context, cloned to the render thread). + auto ui = [&](float) + { ImGui::Begin("Registered images"); ImGui::TextUnformatted("Pre-registered -> no one-frame warm-up."); @@ -120,7 +121,9 @@ int main() } ImGui::End(); - }, deferredController); + }; + + canvas.run(update, ui); return 0; } diff --git a/examples/hello_imgui_visual.cpp b/examples/hello_imgui_visual.cpp index 470c7798..3281d67a 100644 --- a/examples/hello_imgui_visual.cpp +++ b/examples/hello_imgui_visual.cpp @@ -128,14 +128,12 @@ int main() float scale = 8.0f; float elapsedMs = 0.0f; - // TODO(threaded): registered-image passes (registerImguiImage / imguiImage) are not - // wired for the sim/render split yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the - // deprecated single-thread run(update, Controller&) until threaded support lands. - Nothofagus::Controller deferredController; - canvas.run([&](float dt) + // Game logic on the sim thread: advance the animation (current layer) and push it — plus + // the live opacity — onto every registered view of the animated visual (same size => no + // re-warm), spin the RTT sprite, and schedule its off-screen pass so the registered image + // sampling sceneRtTexId has fresh pixels. Runs before `ui`, so the draws below are current. + auto update = [&](float dt) { - // Advance the animation (current layer) and push it — plus the live opacity — onto - // every registered view of the animated visual. Same size => no re-warm. elapsedMs += dt; const std::size_t frame = static_cast(elapsedMs / 180.0f) % kFrames; Nothofagus::Visual animVisual{animTexId}; @@ -144,11 +142,13 @@ int main() for (const Nothofagus::ImguiImageId id : animIds) canvas.updateImguiImage(id, animVisual); - // Spin the RTT sprite and schedule it into the off-screen target this frame, so the - // registered image sampling sceneRtTexId below has fresh pixels to read. canvas.bellota(sceneBellotaId).transform().angle() += dt * 0.05f; canvas.renderTo(sceneRtId, {sceneBellotaId}); + }; + // ImGui on the sim thread (drawn into the sim-UI context, cloned to render). + auto ui = [&](float) + { ImGui::Begin("Visuals in ImGui"); ImGui::SliderFloat("draw scale", &scale, 1.0f, kMaxScale); @@ -214,7 +214,9 @@ int main() canvas.imguiImage(sceneRtImageId); ImGui::End(); - }, deferredController); + }; + + canvas.run(update, ui); return 0; } diff --git a/examples/hello_markdown.cpp b/examples/hello_markdown.cpp index b2efbd42..7c47fae5 100644 --- a/examples/hello_markdown.cpp +++ b/examples/hello_markdown.cpp @@ -196,21 +196,21 @@ wider than the window, so it overflows — drag the horizontal scrollbar to pan: float elapsedMs = 0.0f; - // TODO(threaded): the animated inline spinner uses a registered image - // (registerImguiImage / updateImguiImage), which is not wired for the sim/render split - // yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the deprecated single-thread - // run(update, Controller&) until threaded support lands. - Nothofagus::Controller deferredController; - canvas.run([&](float dt) + // Game logic on the sim thread: keep the inline spinner animating — advance its layer and + // push it onto the registered image (stable id, same size -> no re-warm). Runs before `ui` + // each commit, so the frame the markdown draws is current. + auto update = [&](float dt) { - // Keep the inline spinner animating: advance its layer and push it onto the - // registered image (stable id, same size -> no re-warm). elapsedMs += dt; const std::size_t frame = static_cast(elapsedMs / 150.0f) % kSpinFrames; Nothofagus::Visual spinVisual{spinTexId}; spinVisual.currentLayer() = frame; canvas.updateImguiImage(spinImageId, spinVisual); + }; + // ImGui / markdown on the sim thread (drawn into the sim-UI context, cloned to render). + auto ui = [&](float) + { ImGui::SetNextWindowPos(ImVec2(20.0f, 20.0f), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(520.0f, 680.0f), ImGuiCond_FirstUseEver); ImGui::Begin("Readme"); @@ -226,7 +226,9 @@ wider than the window, so it overflows — drag the horizontal scrollbar to pan: ImGui::Begin("Oversized image (horizontal scroll)", nullptr, ImGuiWindowFlags_HorizontalScrollbar); markdown.print(kOverflowSample); ImGui::End(); - }, deferredController); + }; + + canvas.run(update, ui); return 0; } diff --git a/source/canvas.cpp b/source/canvas.cpp index 6aba4107..f005a5a5 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -281,38 +281,49 @@ void Canvas::renderImguiTo(RenderTargetId renderTargetId, ImguiFontId fontId, Im }); } +// The ImguiImageManager shares the AssetRegistry (RTT alloc/free, texture/mesh pins) and the +// registered-entry map with the render thread's resolveImages(). Serialize every sim-side call +// under the same asset mutex that guards renderSnapshotContents, so a threaded session's +// register/update/unregister/draw can't race the render side (a no-op lock single-threaded). ImguiImageId Canvas::registerImguiImage(const Visual& visual, const ImguiImageSize::Spec& sizeSpec) { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); return mImplPtr->imguiImages.registerImage(visual, sizeSpec, mImplPtr->frameRunner.contentScale()); } void Canvas::updateImguiImage(ImguiImageId imageId, const Visual& visual) { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); mImplPtr->imguiImages.updateImage(imageId, visual, mImplPtr->frameRunner.contentScale()); } void Canvas::unregisterImguiImage(ImguiImageId imageId) { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); mImplPtr->imguiImages.unregisterImage(imageId); } void Canvas::imguiImage(ImguiImageId imageId, std::optional drawSize) { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); mImplPtr->imguiImages.drawImage(imageId, drawSize); } std::uint64_t Canvas::imguiImageHandle(ImguiImageId imageId) const { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); return mImplPtr->imguiImages.handleOf(imageId); } glm::vec2 Canvas::imguiImageSize(ImguiImageId imageId) const { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); return mImplPtr->imguiImages.sizeOf(imageId); } bool Canvas::isImguiImageReady(ImguiImageId imageId) const { + auto lock = mImplPtr->frameRunner.lockAssetsIfThreaded(); return mImplPtr->imguiImages.isReady(imageId); } @@ -415,7 +426,7 @@ void Canvas::run(std::function update, Controller& contro void Canvas::run(std::function update, std::function uiCallback, Controller& simController, Controller& renderController) { - mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, + mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, std::move(update), std::move(uiCallback), simController, renderController); } @@ -425,7 +436,7 @@ void Canvas::run(std::function update, std::function u // render controller pumped on the main thread, so they must not be the same object. Controller simController; Controller renderController; - mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, + mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, std::move(update), std::move(uiCallback), simController, renderController); } diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 3c08ff10..646b90bd 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -454,6 +454,12 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset feedGameInput(*controller); } + // Advance the registered-image frame clock before update/ui so drawImage() + // (called from the ui callback) stamps lastUsedFrame against this commit and + // appendInternalPasses() below emits the "displayed this frame" targets. + if (imguiImages != nullptr) + imguiImages->beginFrame(); + // 1) Game logic — lock-free, so it overlaps the render thread's sprite work. // (spawn/despawn take the asset mutex internally; bellota value writes are // sim-exclusive.) @@ -585,6 +591,15 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset { ZoneScopedN("RttGather"); buildRttPasses(assets, snapshot.rttPasses); + // Internal RTT passes for registered ImGui images that need (re)rendering + // this frame, appended after the user-scheduled passes (mirrors the Single arm). + // Under the asset mutex: appendInternalPasses writes each entry's + // renderedThisFrame, which the render thread reads in resolveImages(). + if (imguiImages != nullptr) + { + std::lock_guard assetLock(mThreadedAssetMutex); + imguiImages->appendInternalPasses(snapshot.rttPasses); + } } mTripleBuffer.publish(); @@ -1009,10 +1024,14 @@ void FrameRunner::run(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& im } void FrameRunner::runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, + ImguiImageManager& imguiImages, std::function update, std::function uiCallback, Controller& simController, Controller& renderController) { beginThreadedSession(canvas, renderController); + // Bind the image manager so the threaded commit/render primitives thread it through + // produce/consume (registered ImGui images). Cleared after the loops join. + mThreadedImguiImages = &imguiImages; std::thread simThread([&]() { @@ -1040,6 +1059,7 @@ void FrameRunner::runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttMan } simThread.join(); + mThreadedImguiImages = nullptr; } void FrameRunner::limitFrameRate(std::chrono::steady_clock::time_point& nextDeadline) @@ -1194,7 +1214,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, // Sim thread: CPU only, no GL. The canvas (bound in beginThreadedSession) drives // the explorer pre-pass; imguiRtt/controller stay null (no font drain or input // poll on the sim thread). - produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, nullptr, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, mThreadedImguiImages, deltaTimeMS, std::move(update), std::move(uiCallback), nullptr); } @@ -1203,7 +1223,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, { // Sim thread (M5): feed the sim controller from the latest gamepad snapshot // (inside produce, before update) so the game `update` sees the gamepad. No ImGui. - produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, nullptr, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, mThreadedImguiImages, deltaTimeMS, std::move(update), {}, &simController); } @@ -1213,7 +1233,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, { // Sim thread: feed the sim controller AND run the ImGui uiCallback. produce() // already handles both independently (feed before update; ui after update). - produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, nullptr, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, mThreadedImguiImages, deltaTimeMS, std::move(update), std::move(uiCallback), &simController); } @@ -1226,7 +1246,7 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im const RenderSnapshot& snapshot = mTripleBuffer.readSlot(); // dt is recomputed from the window clock inside the Threaded arm. - consume(FrameMode::Threaded, assets, imguiRtt, nullptr, snapshot, 0.0f, controller); + consume(FrameMode::Threaded, assets, imguiRtt, mThreadedImguiImages, snapshot, 0.0f, controller); // Deferred screenshot: the backend recorded/read the capture during this frame's // endFrame; finish it (read back after the fence) and hold the result for retrieval. diff --git a/source/frame_runner.h b/source/frame_runner.h index 2be65959..f3659e06 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -171,6 +171,7 @@ class FrameRunner /// The raw beginThreadedSession/commitFrame/renderFrameThreaded primitives stay /// for apps that want to own their threading. void runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, + ImguiImageManager& imguiImages, std::function update, std::function uiCallback, Controller& simController, Controller& renderController); @@ -261,11 +262,14 @@ class FrameRunner void removeRenderTarget(AssetRegistry& assets, RenderTargetId renderTargetId); void setRenderTargetClearColor(AssetRegistry& assets, RenderTargetId renderTargetId, glm::vec4 clearColor); -private: /// Returns a lock on the asset mutex while a threaded session is live, else an - /// empty (unlocked) lock — the single-threaded fast path takes no mutex. + /// empty (unlocked) lock — the single-threaded fast path takes no mutex. Public so + /// Canvas can serialize the ImguiImageManager's sim-side registry mutations against + /// the render thread's resolveImages() (which runs under the same mutex). std::unique_lock lockAssetsIfThreaded(); +private: + /// Selects which orchestration a unified producer/consumer runs. `Single` is /// the run()/tick() path (one thread; ImGui on the main context, live draw /// data, no locks); `Threaded` is the commit()/renderFrame() path (sim/render @@ -412,6 +416,7 @@ class FrameRunner SnapshotTripleBuffer mTripleBuffer; ///< sim→render snapshot hand-off (lock-free). std::atomic mThreadedRunning{false}; ///< true while the threaded session is live. Canvas* mThreadedCanvas{nullptr}; ///< canvas bound by beginThreadedSession; drives explorers in produce(Threaded). + ImguiImageManager* mThreadedImguiImages{nullptr}; ///< image manager bound by runThreaded; null on the raw-primitive path (registered images then unsupported there). /// Thread identities for the affinity guards (debug-only). Render id is captured in /// beginThreadedSession (main thread); sim id is captured on the first threaded /// produce(). Default-constructed (== no id) until then. diff --git a/source/imgui_image_manager.cpp b/source/imgui_image_manager.cpp index f59abeae..fce0d664 100644 --- a/source/imgui_image_manager.cpp +++ b/source/imgui_image_manager.cpp @@ -231,7 +231,7 @@ void ImguiImageManager::updateImage(ImguiImageId id, const Visual& visual, float geom.meshId.id != entry.mesh.id) { // Geometry/source changed enough to need a fresh RTT (this re-warms the handle). - freeEntryGpu(entry); + retireEntryGpu(entry); unpinEntry(entry); Entry fresh; allocateEntry(fresh, geom); @@ -255,7 +255,7 @@ void ImguiImageManager::unregisterImage(ImguiImageId id) auto it = mRegistered.find(id.id); if (it == mRegistered.end()) return; - freeEntryGpu(it->second); + retireEntryGpu(it->second); unpinEntry(it->second); mRegistered.erase(it); } @@ -334,6 +334,10 @@ void ImguiImageManager::appendInternalPasses(std::vector& out) void ImguiImageManager::resolveImages() { + // Free entries retired at least one render generation ago before touching handles this + // frame (the retired RTTs are no longer referenced by any snapshot being rendered). + drainRetiredGpu(); + RenderTargetContainer& renderTargets = mAssets.renderTargets(); // For images rendered this frame, refresh the flat-2D and create the ImGui handle if it @@ -369,6 +373,31 @@ void ImguiImageManager::freeEntryGpu(Entry& entry) mAssets.removeRenderTarget(entry.rt); } +void ImguiImageManager::retireEntryGpu(Entry& entry) +{ + // Hand the GPU handle + RTT to the deferred-free queue; the actual backend release runs + // render-side in drainRetiredGpu(). Zero the handle so the entry can't double-free it. + mRetirePending.push_back({entry.handle, entry.rt}); + entry.handle = 0; +} + +void ImguiImageManager::drainRetiredGpu() +{ + RenderTargetContainer& renderTargets = mAssets.renderTargets(); + for (const RetiredGpu& retired : mRetireDraining) + { + if (retired.handle != 0 && renderTargets.contains(retired.rt.id)) + { + RenderTargetPack& pack = renderTargets.at(retired.rt.id); + if (pack.dRenderTargetOpt.has_value()) + mBackend.releaseFlat2DImguiHandle(pack.dRenderTargetOpt.value(), retired.handle); + } + mAssets.removeRenderTarget(retired.rt); + } + mRetireDraining.clear(); + mRetireDraining.swap(mRetirePending); // this render's retires drain on the next. +} + void ImguiImageManager::unpinEntry(const Entry& entry) { mTexturePins.release(entry.texture, [&](TextureId id) { mAssets.releaseTexture(id); }); @@ -385,6 +414,10 @@ void ImguiImageManager::unpinEntry(const Entry& entry) void ImguiImageManager::releaseAll() { + // Flush both deferred-free buckets first (render-side teardown; inline backend release + // is fine here), then free the still-registered entries directly. + drainRetiredGpu(); // frees `draining`, rotates `pending` -> `draining` + drainRetiredGpu(); // frees the rotated `pending`; both buckets now empty for (auto& [id, entry] : mRegistered) { freeEntryGpu(entry); diff --git a/source/imgui_image_manager.h b/source/imgui_image_manager.h index 0a7ec89b..55691b70 100644 --- a/source/imgui_image_manager.h +++ b/source/imgui_image_manager.h @@ -134,9 +134,18 @@ class ImguiImageManager /// Draw `handle` at `displaySize` in the current ImGui window, honoring the texture's /// magFilter and `opacity`. Caller guarantees `handle != 0`. void drawResolvedImage(std::uint64_t handle, glm::vec2 displaySize, TextureId texForFilter, float opacity); - void freeEntryGpu(Entry& entry); ///< render-side: free handle + RTT. + void freeEntryGpu(Entry& entry); ///< render-side: free handle + RTT (used by releaseAll teardown). void unpinEntry(const Entry& entry); + /// Sim-side (threaded) or same-thread (single): hand an entry's GPU handle + RTT to the + /// deferred-free queue instead of freeing inline, so the backend release always runs on + /// the render thread and never while an in-flight snapshot's pass still references the RTT. + void retireEntryGpu(Entry& entry); + /// Render-side: free everything retired at least one render generation ago, then rotate the + /// pending bucket in. Two-phase, so a freed RTT is one full render behind its last use + /// (the last-using frame has fenced). Called at the top of resolveImages(). + void drainRetiredGpu(); + ActiveBackend& mBackend; AssetRegistry& mAssets; @@ -144,6 +153,13 @@ class ImguiImageManager std::size_t mNextImageId = 1; std::map, MeshId> mOwnedQuads; ///< quad mesh per texture size (shared). + /// Deferred GPU-free of retired entries (handle + RTT). Two buckets rotated by + /// drainRetiredGpu(): `pending` is filled sim-side by retireEntryGpu(); it becomes + /// `draining` on the next render, then is freed on the render after that. + struct RetiredGpu { std::uint64_t handle; RenderTargetId rt; }; + std::vector mRetirePending; + std::vector mRetireDraining; + // Reference counts for resource pins so a texture/mesh shared by several entries // is retained on the AssetRegistry once and released only when the last entry // referencing it is retired. From 62c96ae9d71e03edf276c22315c156363ccaf951 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:03:59 -0700 Subject: [PATCH 42/55] docs(threaded): mark registered ImGui images supported; record Phase 1 verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registered-image threading verified end-to-end: - Builds --parallel across glfw-opengl, glfw-vulkan, sdl3-vulkan, sdl3-opengl (library + the 3 migrated demos) and the headless-vulkan test lib. - xvfb-run smoke of hello_imgui_visual / hello_imgui_image_registry / hello_markdown: render live, clean close (exit 124 timeout, no asserts). - TSan (glfw-opengl) on hello_imgui_image_registry + hello_imgui_visual under xvfb: 0 ThreadSanitizer warnings — the per-frame updateImguiImage -> render resolveImages sharing and the teardown retire-drain are race-free. - SwiftShader goldens unchanged: 25/25 (50 assertions). Flip the registered-image row to Supported in THREADED_DIEGETIC_IMGUI.md and update the THREADED_MODE.md per-demo caveats; diegetic renderImguiTo stays the only deferred ImGui feature. --- THREADED_DIEGETIC_IMGUI.md | 14 +++++++++++--- THREADED_MODE.md | 12 ++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/THREADED_DIEGETIC_IMGUI.md b/THREADED_DIEGETIC_IMGUI.md index 77ee7435..5d3ec18b 100644 --- a/THREADED_DIEGETIC_IMGUI.md +++ b/THREADED_DIEGETIC_IMGUI.md @@ -6,12 +6,20 @@ > designed and implemented separately. Until then, examples using them stay on the > single-threaded `run(update, Controller&)` path. -## TL;DR — what does not work threaded today +## TL;DR — threaded status -| Feature | Public API | Threaded status | Blocked examples | +| Feature | Public API | Threaded status | Examples | |---|---|---|---| | **Diegetic ImGui in an RTT** | `Canvas::renderImguiTo(rtId, fontId, callback)` | **Unsupported** | `hello_imgui_rtt`, `hello_dpi_scaling` | -| **Registered ImGui images** | `Canvas::registerImguiImage` / `imguiImage` / `updateImguiImage` | **Unsupported** | `hello_imgui_visual`, `hello_imgui_image_registry`, `hello_markdown` (spinner) | +| **Registered ImGui images** | `Canvas::registerImguiImage` / `imguiImage` / `updateImguiImage` | **Supported** (Phase 1) | `hello_imgui_visual`, `hello_imgui_image_registry`, `hello_markdown` (spinner) | + +> **Registered images are done.** `ImguiImageManager` is now threaded through +> `runThreaded`/`commitFrame`/`renderFrameThreaded`; the sim-side `beginFrame` + +> `appendInternalPasses` run in the `FrameMode::Threaded` produce arm, the registry is +> serialized under the asset mutex (the same one `resolveImages` holds render-side), and GPU +> handle/RTT frees are deferred to the render thread via a two-phase retire queue. The three +> examples above run on `run(update, ui)`. What remains below is the **diegetic `renderImguiTo`** +> path. Everything else the examples need is already threaded: main-canvas ImGui (interactive, input-marshalled), `renderTo` sprite RTT passes (ride the snapshot's `rttPasses`), screenshots, explorers, runtime asset diff --git a/THREADED_MODE.md b/THREADED_MODE.md index f909ff46..5b06ce90 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -98,12 +98,12 @@ hello_text, hello_tilemap, hello_tint, test_create_destroy, test_gamepad. Per-demo caveats: - **hello_headless** uses `tick()` (deliberate single-step/headless harness) — a threaded port may not be meaningful; keep as the single-threaded/manual-tick reference. -- **hello_imgui_visual / hello_imgui_image_registry** exercise `imguiVisual`/`imguiImages`, which - is single-threaded-only for now — port only once/if that path gains threaded support. -- **hello_imgui_rtt / hello_dpi_scaling** use `renderImguiTo` (diegetic ImGui in an RTT), and - **hello_markdown** an animated registered-image spinner — both the diegetic and registered-image - ImGui paths are single-threaded-only for now (see [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md)); - port only once that path gains threaded support. +- **hello_imgui_rtt / hello_dpi_scaling** use `renderImguiTo` (diegetic ImGui in an RTT), which is + single-threaded-only for now (see [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md)); port + only once that path gains threaded support. +- **hello_imgui_visual / hello_imgui_image_registry / hello_markdown** exercise registered ImGui + images (`registerImguiImage`/`imguiImage`), which is **now threaded** (Phase 1) — all three run on + `run(update, ui)`. ## Out of scope From b8c440ca5324147bf26c4c24e908b6e0266df18e Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:25:21 -0700 Subject: [PATCH 43/55] feat(threaded): diegetic renderImguiTo in the sim/render split Generalize the main-context ImGui clone to N secondary RTT contexts. Split ImguiRttManager::flushPending into produceClones (sim: run each renderImguiTo callback on its secondary context, deep-clone its draw data into the snapshot's new rttUi list) and replayClones (render: lazy backend init + RenderDrawData of the clones, no user code). Wire the RTT manager through the threaded produce arm (mThreadedImguiRtt); the render consume now holds the ImGui mutex outer of the asset mutex so the RTT-clone atlas access matches the sim's imgui>asset order and can't deadlock. Migrate hello_imgui_rtt / hello_dpi_scaling to run(update, ui). --- examples/hello_dpi_scaling.cpp | 19 +++--- examples/hello_imgui_rtt.cpp | 21 ++++--- source/frame_runner.cpp | 60 ++++++++++++------- source/frame_runner.h | 1 + source/imgui_rtt_manager.cpp | 104 +++++++++++++++++++++++++-------- source/imgui_rtt_manager.h | 33 ++++++++--- source/render_snapshot.cpp | 7 +++ source/render_snapshot.h | 18 ++++++ 8 files changed, 193 insertions(+), 70 deletions(-) diff --git a/examples/hello_dpi_scaling.cpp b/examples/hello_dpi_scaling.cpp index 44675455..f96fea37 100644 --- a/examples/hello_dpi_scaling.cpp +++ b/examples/hello_dpi_scaling.cpp @@ -48,15 +48,17 @@ int main() char nameBuf[64] = "Nothofagus"; float time = 0.0f; - // TODO(threaded): the diegetic RTT panel uses renderImguiTo, which runs its user - // callback on the render thread and has no per-RTT draw-data clone, so it is not wired - // for the sim/render split yet (see THREADED_DIEGETIC_IMGUI.md). Parked on the - // deprecated single-thread run(update, Controller&) until threaded support lands. - Nothofagus::Controller deferredController; - canvas.run([&](float deltaTimeMS) + // Game logic on the sim thread: advance time, rotate the diegetic display bellota. + auto update = [&](float deltaTimeMS) { time += deltaTimeMS; + canvas.bellota(diegeticBellotaId).transform().angle() = 0.0003f * time; + }; + // Standard-UI + diegetic ImGui on the sim thread (sim-UI context, cloned to render). The + // diegetic renderImguiTo callback also runs sim-side and is cloned per RTT. + auto ui = [&](float) + { // Apply the chosen scale knobs to the main (standard-UI) context. canvas.setContentScaleOverride(useOsScale ? std::optional{} : std::optional{overrideScale}); ImGui::GetStyle().FontScaleMain = fontZoom; @@ -143,7 +145,6 @@ int main() ImGui::End(); // ----- The diegetic panel: game-resolution, NOT DPI-scaled ------------ - canvas.bellota(diegeticBellotaId).transform().angle() = 0.0003f * time; canvas.renderImguiTo(renderTargetId, diegeticId, [&] { ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Always); @@ -159,7 +160,9 @@ int main() ImGui::ProgressBar(0.5f + 0.5f * std::sin(0.003f * time)); ImGui::End(); }); - }, deferredController); + }; + + canvas.run(update, ui); return 0; } diff --git a/examples/hello_imgui_rtt.cpp b/examples/hello_imgui_rtt.cpp index b0965027..9a5fd338 100644 --- a/examples/hello_imgui_rtt.cpp +++ b/examples/hello_imgui_rtt.cpp @@ -34,18 +34,19 @@ int main() Nothofagus::ImguiFontId small16Id{}; bool wantSmall16 = false; - // TODO(threaded): diegetic renderImguiTo runs its user callback on the render thread - // and has no per-RTT draw-data clone, so it is not wired for the sim/render split yet - // (see THREADED_DIEGETIC_IMGUI.md). Parked on the deprecated single-thread - // run(update, Controller&) until threaded support lands. - Nothofagus::Controller deferredController; - canvas.run([&](float deltaTimeMS) + // Game logic on the sim thread: advance time, rotate the display bellota. Runs before + // `ui` each commit. + auto update = [&](float deltaTimeMS) { time += deltaTimeMS; - - // Rotate the display bellota around its center. canvas.bellota(displayBellotaId).transform().angle() = 0.0005f * time; + }; + // ImGui on the sim thread (sim-UI context, cloned to render). The diegetic renderImguiTo + // callback also runs here on the sim thread and is deep-cloned into the snapshot per RTT — + // the render thread only replays it, never runs user code. + auto ui = [&](float) + { // Queue the ImGui content to be rendered into the RTT this frame. // The id-bearing renderImguiTo overload auto-pushes diegeticId for // the duration of the callback (no ImFont* in user code, falls back @@ -113,7 +114,9 @@ int main() wantSmall16 = false; } ImGui::End(); - }, deferredController); + }; + + canvas.run(update, ui); return 0; } diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index 646b90bd..d727f88a 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -581,6 +581,12 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset if (!snapshot.mainUi) snapshot.mainUi = std::make_unique(); snapshot.mainUi->cloneFrom(ImGui::GetDrawData()); + + // Diegetic ImGui: run each renderImguiTo callback on its secondary context here + // (sim thread, still under the ImGui mutex — shared atlas) and clone its draw data + // into the snapshot. The render thread only replays the clones. Restores mSimUiContext. + if (imguiRtt != nullptr) + imguiRtt->produceClones(deltaTimeMS, ImGui::GetIO().Fonts, snapshot.rttUi); } // 3) Project the scene (POD) — lock-free; the write slot is producer-owned. @@ -686,6 +692,11 @@ const RenderSnapshot& FrameRunner::produce(FrameMode mode, Canvas* canvas, Asset imguiImages->appendInternalPasses(mSnapshot.rttPasses); } + // Diegetic ImGui: drain renderImguiTo passes into per-RTT draw-data clones (the render + // side replays them). Same path as the threaded arm; here producer + consumer are the + // same thread. The user's callbacks ran during update() above and enqueued the passes. + imguiRtt->produceClones(deltaTimeMS, ImGui::GetIO().Fonts, mSnapshot.rttUi); + return mSnapshot; } @@ -828,11 +839,13 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& if (imguiImages) imguiImages->resolveImages(); - // ImGui-to-RTT passes — each uses a secondary ImGuiContext owned by the - // render target, rendered with a pipeline compiled against the RTT render - // pass (Vulkan) or into the RTT FBO (OpenGL). Lazy context creation on - // first use; destroyed in removeRenderTarget() and the destructor. - imguiRtt.flushPending(deltaTimeMS, ImGui::GetIO().Fonts); + // Diegetic ImGui-to-RTT passes — replay the sim's per-RTT draw-data clones into their + // render targets (before the main pass samples them). Each uses a secondary ImGuiContext + // rendered with a pipeline compiled against the RTT render pass (Vulkan) or into the RTT + // FBO (OpenGL); the backend is lazily created render-side on first replay, torn down in + // removeRenderTarget() and the destructor. Touches the shared atlas, so the caller holds + // the ImGui mutex (see consume()); no-op when there are no clones. + imguiRtt.replayClones(snapshot.rttUi); } mBackend.beginMainPass(mGameViewport); @@ -928,21 +941,24 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager ImGui::Render(); } - // Container-touching section (deferred frees, GPU upload, id→handle resolve + - // draw submission). Guarded against the sim thread's spawn/despawn. The lock - // is released BEFORE the vsync swap so a slow present never stalls the sim. + // Container-touching section (deferred frees, GPU upload, id→handle resolve + draw + // submission) plus the ImGui render. The ImGui mutex is the OUTER lock here because + // renderSnapshotContents now replays diegetic-ImGui clones (replayClones) and the main + // endFrame below both do RenderDrawData, which touches the shared font atlas. The asset + // mutex nests INSIDE it — matching the sim side's imgui⊃asset order (a Canvas ImGui-image + // draw in the sim `ui` takes the asset mutex while the ImGui mutex is held), so the two + // threads acquire the pair in the same order and can't deadlock. The asset lock is + // released before the vsync swap so a slow present never stalls the sim. { - ZoneScopedN("AssetLockedRender"); - std::lock_guard assetLock(mThreadedAssetMutex); - renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); - } + std::lock_guard imguiLock(mImguiMutex); + { + ZoneScopedN("AssetLockedRender"); + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); + } - { ZoneScopedN("ImGuiRender"); // Render the sim's cloned UI if present; otherwise the main empty frame. - // Under the ImGui mutex: RenderDrawData applies font-atlas texture uploads - // (draw_data->Textures) which touch the shared atlas. - std::lock_guard imguiLock(mImguiMutex); ImDrawData* uiData = (snapshot.mainUi && snapshot.mainUi->hasData()) ? snapshot.mainUi->drawData() : ImGui::GetDrawData(); @@ -1029,9 +1045,10 @@ void FrameRunner::runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttMan Controller& simController, Controller& renderController) { beginThreadedSession(canvas, renderController); - // Bind the image manager so the threaded commit/render primitives thread it through - // produce/consume (registered ImGui images). Cleared after the loops join. + // Bind the image + RTT managers so the threaded commit primitives thread them through + // produce (registered ImGui images; diegetic renderImguiTo clones). Cleared after join. mThreadedImguiImages = &imguiImages; + mThreadedImguiRtt = &imguiRtt; std::thread simThread([&]() { @@ -1060,6 +1077,7 @@ void FrameRunner::runThreaded(Canvas& canvas, AssetRegistry& assets, ImguiRttMan simThread.join(); mThreadedImguiImages = nullptr; + mThreadedImguiRtt = nullptr; } void FrameRunner::limitFrameRate(std::chrono::steady_clock::time_point& nextDeadline) @@ -1214,7 +1232,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, // Sim thread: CPU only, no GL. The canvas (bound in beginThreadedSession) drives // the explorer pre-pass; imguiRtt/controller stay null (no font drain or input // poll on the sim thread). - produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, mThreadedImguiImages, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, mThreadedImguiRtt, mThreadedImguiImages, deltaTimeMS, std::move(update), std::move(uiCallback), nullptr); } @@ -1223,7 +1241,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, { // Sim thread (M5): feed the sim controller from the latest gamepad snapshot // (inside produce, before update) so the game `update` sees the gamepad. No ImGui. - produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, mThreadedImguiImages, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, mThreadedImguiRtt, mThreadedImguiImages, deltaTimeMS, std::move(update), {}, &simController); } @@ -1233,7 +1251,7 @@ void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, { // Sim thread: feed the sim controller AND run the ImGui uiCallback. produce() // already handles both independently (feed before update; ui after update). - produce(FrameMode::Threaded, mThreadedCanvas, assets, nullptr, mThreadedImguiImages, deltaTimeMS, + produce(FrameMode::Threaded, mThreadedCanvas, assets, mThreadedImguiRtt, mThreadedImguiImages, deltaTimeMS, std::move(update), std::move(uiCallback), &simController); } diff --git a/source/frame_runner.h b/source/frame_runner.h index f3659e06..8335de55 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -417,6 +417,7 @@ class FrameRunner std::atomic mThreadedRunning{false}; ///< true while the threaded session is live. Canvas* mThreadedCanvas{nullptr}; ///< canvas bound by beginThreadedSession; drives explorers in produce(Threaded). ImguiImageManager* mThreadedImguiImages{nullptr}; ///< image manager bound by runThreaded; null on the raw-primitive path (registered images then unsupported there). + ImguiRttManager* mThreadedImguiRtt{nullptr}; ///< RTT manager bound by runThreaded; lets the sim commit produce diegetic-ImGui clones (null on the raw-primitive path). /// Thread identities for the affinity guards (debug-only). Render id is captured in /// beginThreadedSession (main thread); sim id is captured on the first threaded /// produce(). Default-constructed (== no id) until then. diff --git a/source/imgui_rtt_manager.cpp b/source/imgui_rtt_manager.cpp index 8ffefc80..81dcc93d 100644 --- a/source/imgui_rtt_manager.cpp +++ b/source/imgui_rtt_manager.cpp @@ -1,7 +1,9 @@ #include "imgui_rtt_manager.h" +#include "imgui_draw_clone.h" // ClonedImDrawData (complete type for the clones) #include "profiling.h" #include #include +#include namespace Nothofagus { @@ -49,15 +51,21 @@ bool shutdownContextBackendIfAlive( } -void ImguiRttManager::flushPending(float deltaTimeMS, ImFontAtlas* sharedFonts) +void ImguiRttManager::produceClones(float deltaTimeMS, ImFontAtlas* sharedFonts, + std::vector& out) { - if (mPendingPasses.empty()) return; + if (mPendingPasses.empty()) + { + out.clear(); + return; + } ImFont* rttFont = mFonts.defaultFont(); - ZoneScopedN("ImGuiRttPasses"); - ImGuiContext* mainCtx = ImGui::GetCurrentContext(); + ZoneScopedN("ImGuiRttProduce"); + ImGuiContext* callerCtx = ImGui::GetCurrentContext(); + std::size_t slot = 0; for (auto& [renderTargetId, imguiDrawCallback] : mPendingPasses) { if (!mRenderTargets.contains(renderTargetId.id)) continue; @@ -65,10 +73,11 @@ void ImguiRttManager::flushPending(float deltaTimeMS, ImFontAtlas* sharedFonts) if (!renderTargetPack.dRenderTargetOpt.has_value()) continue; if (!imguiDrawCallback) continue; - const DRenderTarget& dRenderTarget = renderTargetPack.dRenderTargetOpt.value(); - const glm::vec4& clearColor = renderTargetPack.renderTarget.mClearColor; + const glm::ivec2 size = renderTargetPack.dRenderTargetOpt.value().size; - // Lazy-create the secondary context for this RTT. + // Lazy-create the secondary context CPU-side only — no backend renderer init here + // (that is render-side in replayClones). Mirrors mSimUiContext: shared atlas, headless + // IO, RendererHasTextures so the shared atlas uploads render-side from the cloned data. ImGuiContextPtr& rttCtx = mContexts[renderTargetId.id]; if (!rttCtx) { @@ -78,36 +87,80 @@ void ImguiRttManager::flushPending(float deltaTimeMS, ImFontAtlas* sharedFonts) rttIo.IniFilename = nullptr; rttIo.BackendPlatformName = "nothofagus_rtt_headless"; rttIo.BackendPlatformUserData = nullptr; - rttIo.DisplaySize = ImVec2( - static_cast(dRenderTarget.size.x), - static_cast(dRenderTarget.size.y)); + rttIo.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; // atlas owner is render-side // Default to the unscaled-size RTT font so glyphs are crisp at // their logical pixel height regardless of OS DPI. if (rttFont != nullptr) rttIo.FontDefault = rttFont; - mBackend.initImguiForRenderTarget(dRenderTarget); } ImGui::SetCurrentContext(rttCtx.get()); ImGuiIO& io = ImGui::GetIO(); - io.DisplaySize = ImVec2( - static_cast(dRenderTarget.size.x), - static_cast(dRenderTarget.size.y)); + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; + io.DisplaySize = ImVec2(static_cast(size.x), static_cast(size.y)); io.DeltaTime = std::max(deltaTimeMS * 0.001f, 1e-6f); - mBackend.imguiNewFrameForRenderTarget(dRenderTarget); ImGui::NewFrame(); + imguiDrawCallback(); // the user's ImGui code, on the sim thread + ImGui::Render(); - imguiDrawCallback(); + // Reuse the snapshot slot's clone storage across frames (producer owns the write slot). + if (slot >= out.size()) + out.emplace_back(); + RttImguiClone& clone = out[slot]; + clone.target = renderTargetId; + if (!clone.ui) + clone.ui = std::make_unique(); + clone.ui->cloneFrom(ImGui::GetDrawData()); + ++slot; + } - ImGui::Render(); + out.resize(slot); // drop unused slots (frees their clones) + ImGui::SetCurrentContext(callerCtx); + mPendingPasses.clear(); +} + +void ImguiRttManager::replayClones(const std::vector& clones) +{ + if (clones.empty()) return; + + ZoneScopedN("ImGuiRttReplay"); + ImGuiContext* callerCtx = ImGui::GetCurrentContext(); + + for (const RttImguiClone& clone : clones) + { + if (!clone.ui || !clone.ui->hasData()) continue; + if (!mRenderTargets.contains(clone.target.id)) continue; + RenderTargetPack& renderTargetPack = mRenderTargets.at(clone.target.id); + if (!renderTargetPack.dRenderTargetOpt.has_value()) continue; + + auto ctxIt = mContexts.find(clone.target.id); + if (ctxIt == mContexts.end() || !ctxIt->second) continue; // context is created sim-side + + const DRenderTarget& dRenderTarget = renderTargetPack.dRenderTargetOpt.value(); + const glm::vec4& clearColor = renderTargetPack.renderTarget.mClearColor; + + ImGui::SetCurrentContext(ctxIt->second.get()); + + // Lazy-init the per-RTT ImGui renderer backend once, render-side (GPU). + if (mBackendInited.insert(clone.target.id).second) + mBackend.initImguiForRenderTarget(dRenderTarget); + + // Render-side renderer NewFrame: creates the backend's device objects (GL shader/VBO) + // on first use and processes the shared-atlas texture queue. Independent of core + // ImGui::NewFrame (which ran sim-side); must run before RenderDrawData. + mBackend.imguiNewFrameForRenderTarget(dRenderTarget); + + // Point the clone at this context's platform texture list so RenderDrawData applies + // the shared-atlas upload here (cloneFrom left Textures null) — mirrors the main path. + ImDrawData* drawData = clone.ui->drawData(); + drawData->Textures = &ImGui::GetPlatformIO().Textures; mBackend.beginRttPass(dRenderTarget, clearColor); - mBackend.renderImguiDrawDataToRenderTarget(ImGui::GetDrawData(), dRenderTarget); + mBackend.renderImguiDrawDataToRenderTarget(drawData, dRenderTarget); mBackend.endRttPass(); - - ImGui::SetCurrentContext(mainCtx); } - mPendingPasses.clear(); + + ImGui::SetCurrentContext(callerCtx); } void ImguiRttManager::releaseContext(RenderTargetId renderTargetId) @@ -117,7 +170,10 @@ void ImguiRttManager::releaseContext(RenderTargetId renderTargetId) return; ImGuiContext* mainCtx = ImGui::GetCurrentContext(); - shutdownContextBackendIfAlive(renderTargetId.id, it->second.get(), mBackend, mRenderTargets); + // Only tear down the renderer backend if replayClones actually initialized it (the context + // is created sim-side, the backend lazily render-side; an un-replayed context has none). + if (mBackendInited.erase(renderTargetId.id) > 0) + shutdownContextBackendIfAlive(renderTargetId.id, it->second.get(), mBackend, mRenderTargets); ImGui::SetCurrentContext(mainCtx); mContexts.erase(it); // unique_ptr deleter calls ImGui::DestroyContext. @@ -129,7 +185,9 @@ void ImguiRttManager::releaseAll() ImGuiContext* mainCtx = ImGui::GetCurrentContext(); for (auto& [renderTargetIndex, rttCtx] : mContexts) - shutdownContextBackendIfAlive(renderTargetIndex, rttCtx.get(), mBackend, mRenderTargets); + if (mBackendInited.count(renderTargetIndex) > 0) + shutdownContextBackendIfAlive(renderTargetIndex, rttCtx.get(), mBackend, mRenderTargets); + mBackendInited.clear(); mContexts.clear(); // unique_ptr deleters call ImGui::DestroyContext for each. ImGui::SetCurrentContext(mainCtx); } diff --git a/source/imgui_rtt_manager.h b/source/imgui_rtt_manager.h index ea1e26cb..ebc2c16e 100644 --- a/source/imgui_rtt_manager.h +++ b/source/imgui_rtt_manager.h @@ -4,10 +4,12 @@ #include "imgui_font_manager.h" #include "render_target.h" #include "render_target_container.h" +#include "render_snapshot.h" // RttImguiClone #include "backends/render_backend_select.h" #include #include #include +#include #include #include @@ -40,17 +42,26 @@ class ImguiRttManager float imguiFontSize); /// Queue an ImGui draw callback to run against renderTargetId this frame. + /// The queue is sim-thread-local: enqueued from the user's `ui`/`update` (via + /// Canvas::renderImguiTo) and drained within the same commit by produceClones(). void enqueue(RenderTargetId renderTargetId, ImguiDrawCallback imguiDrawCallback); - /// Drain the queue: lazy-create the secondary context per RTT, run the - /// callback, submit through the backend's RTT pass methods. - /// Caller must have the main ImGuiContext current on entry; this method - /// restores it before returning. - /// On lazy-create, the secondary context's `io.FontDefault` is set to - /// `fonts().defaultFont()` (if non-null) so glyphs render crisp at their - /// logical pixel height in RTT pixels (RTT pixels are game-canvas pixels - /// — OS DPI scaling has no meaning there). - void flushPending(float deltaTimeMS, ImFontAtlas* sharedFonts); + /// Sim-side: drain the queue into per-RTT ImGui draw-data clones. For each pending pass, + /// lazy-create the secondary ImGuiContext (CPU-only — no backend renderer init here), + /// run the user callback as a NewFrame/Render on it, and deep-clone the draw data into + /// `out` (analogous to the main sim-UI clone). Restores the caller's current context. + /// Runs under the ImGui mutex so it never touches the shared atlas concurrently with the + /// render thread. On lazy-create the secondary context's `io.FontDefault` is set to + /// `fonts().defaultFont()` (if non-null) so glyphs render crisp at their logical pixel + /// height in RTT pixels (RTT pixels are game-canvas pixels — OS DPI has no meaning there). + void produceClones(float deltaTimeMS, ImFontAtlas* sharedFonts, std::vector& out); + + /// Render-side: replay the sim's per-RTT clones into their render targets (before the + /// main pass). Lazy-inits the per-RTT ImGui renderer backend once, then beginRttPass + + /// RenderDrawData(clone) + endRttPass. Never runs a user callback or a secondary NewFrame. + /// Caller must hold the ImGui mutex (RenderDrawData uploads shared-atlas textures) and + /// have the main ImGuiContext current on entry; this method restores it before returning. + void replayClones(const std::vector& clones); /// Tear down the secondary context for one render target (no-op if absent). /// Safe to call even if the RTT's dRenderTargetOpt is empty. @@ -82,6 +93,10 @@ class ImguiRttManager RenderTargetContainer& mRenderTargets; std::vector> mPendingPasses; std::unordered_map mContexts; + /// Render-side: per-RTT ImGui renderer backend initialized (contexts are created + /// sim-side in produceClones; the backend is lazily initialized in replayClones). + /// Cleared per RTT in releaseContext / releaseAll. + std::unordered_set mBackendInited; ImguiFontManager mFonts; }; diff --git a/source/render_snapshot.cpp b/source/render_snapshot.cpp index bea2ff4b..e5b44b32 100644 --- a/source/render_snapshot.cpp +++ b/source/render_snapshot.cpp @@ -9,4 +9,11 @@ namespace Nothofagus RenderSnapshot::RenderSnapshot() = default; RenderSnapshot::~RenderSnapshot() = default; +// Same reason for RttImguiClone: the unique_ptr needs the complete +// type at construction/destruction/move, which only this .cpp has. +RttImguiClone::RttImguiClone() = default; +RttImguiClone::~RttImguiClone() = default; +RttImguiClone::RttImguiClone(RttImguiClone&&) noexcept = default; +RttImguiClone& RttImguiClone::operator=(RttImguiClone&&) noexcept = default; + } diff --git a/source/render_snapshot.h b/source/render_snapshot.h index d5dd5bfb..65bccc04 100644 --- a/source/render_snapshot.h +++ b/source/render_snapshot.h @@ -59,12 +59,30 @@ struct RttPass std::vector draws; }; +/// One diegetic-ImGui pass: the sim thread ran the user's renderImguiTo callback on a +/// per-RTT secondary ImGui context and deep-cloned its draw data here (analogous to +/// `RenderSnapshot::mainUi`). The render thread replays the clone into `target` — it never +/// runs a user callback or a secondary NewFrame. `ui` is owned; storage is reused per slot. +struct RttImguiClone +{ + RenderTargetId target; + std::unique_ptr ui; + + RttImguiClone(); + ~RttImguiClone(); // out-of-line: ClonedImDrawData incomplete here + RttImguiClone(RttImguiClone&&) noexcept; + RttImguiClone& operator=(RttImguiClone&&) noexcept; + RttImguiClone(const RttImguiClone&) = delete; + RttImguiClone& operator=(const RttImguiClone&) = delete; +}; + /// The full per-frame display list the render side consumes. struct RenderSnapshot { std::uint64_t commitSeq{0}; ///< monotonic commit counter; the deferred-free clock std::vector draws; ///< main pass, depth-sorted at commit std::vector rttPasses; ///< insertion order preserved (nested-RTT dependency) + std::vector rttUi; ///< diegetic-ImGui clones (one per renderImguiTo pass this commit) glm::vec3 clearColor{0.0f}; /// Logical canvas size captured at commit. The render side uses this (not the /// live atomic) so the viewport / world transform match the pool this snapshot From b44ef2e4d48f7dfca0ae92b7504863705762def4 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:29:04 -0700 Subject: [PATCH 44/55] docs(threaded): mark diegetic renderImguiTo supported; record Phase 2 verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diegetic renderImguiTo threading verified end-to-end: - Builds --parallel across glfw-opengl, glfw-vulkan, sdl3-vulkan, sdl3-opengl (all examples). Only remaining deprecation warning is hello_screenshot (its screenshot request/result handoff is a separate, still-deferred gap). - xvfb-run smoke of hello_imgui_rtt / hello_dpi_scaling threaded on glfw-opengl, glfw-vulkan, sdl3-vulkan: render, clean close; raw hello_threaded_imgui (nullptr manager path) unaffected. No Vulkan validation output. - Single-thread parity: the refactored produce/replay path renders the diegetic demos unchanged, and a headless content check confirms the RTT panel draws actual ImGui pixels (not a blank target). - TSan on hello_imgui_rtt + hello_dpi_scaling threaded: 0 warnings — the per-RTT secondary-context sharing and shared-atlas access under the imgui>asset lock order are race-free. - SwiftShader goldens unchanged: 25/25 (50 assertions), incl. the imguiImage single-mode tests that exercise the refactored retire-queue path. As-built the two ImGui features in THREADED_DIEGETIC_IMGUI.md / THREADED_MODE.md and note threaded support in CLAUDE.md; the deferred bucket is now only hello_screenshot. --- CLAUDE.md | 5 +++- THREADED_DIEGETIC_IMGUI.md | 61 +++++++++++++++++++++++++------------- THREADED_MODE.md | 23 ++++++++++---- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ab10a0e..0262f354 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -654,8 +654,10 @@ canvas.renderImguiTo(renderTargetId, canvas.defaultImguiFontId(), [&] { ... }); - The OpenGL ImGui backend is FBO-agnostic; the Vulkan backend's per-context pipeline is built against `mRttRenderPass`, so it is render-pass-compatible with `beginRttPass`. **No changes to `imgui_impl_opengl3.*` / `imgui_impl_vulkan.*` are required.** - The platform backend (GLFW/SDL3) is skipped for secondary contexts — they run headless-style with `IO.DisplaySize` / `IO.DeltaTime` set manually. This means the feature works identically across GLFW+OpenGL, GLFW+Vulkan, SDL3+OpenGL, SDL3+Vulkan, and headless Vulkan. +**Threaded (sim/render split):** `renderImguiTo` works under the threaded `run(update, ui[, sim, render])` — call it from the `ui` callback. The user callback runs on the **sim** thread against a per-RTT secondary context and its draw data is deep-cloned into the frame snapshot (`RenderSnapshot::rttUi`); the render thread only replays the clone. See [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md). + **Limitations (v1):** -- **Input is not forwarded** to the secondary context — widgets render correctly but mouse/keyboard events only reach the main context. Forwarding canvas-space mouse coords into the RTT's `IO.MousePos` is a natural follow-up. +- **Input is not forwarded** to the secondary context — widgets render correctly but mouse/keyboard events only reach the main context (true in both single-threaded and threaded modes). Forwarding canvas-space mouse coords into the RTT's `IO.MousePos` is a natural follow-up. - Each secondary context has its own ID stack, window state, and widget values — widgets with the same name in different RTTs do not collide, and neither inherits state from the main UI. #### Draw a Visual inside ImGui — `Canvas::registerImguiImage` / `imguiImage` @@ -706,6 +708,7 @@ bool isImguiImageReady(ImguiImageId id) const; - **Lifecycle.** Registered entries are **never** garbage-collected (only `unregisterImguiImage` frees them) and only re-render on frames they are displayed, are `updateImguiImage`-dirtied, or have not yet been populated — so off-screen registered images cost nothing yet keep a valid handle and last-rendered content. `updateImguiImage` keeps the id/handle stable for layer/opacity/mesh changes; it recreates the RTT (a one-tick re-warm) only when the resolved physical size or the source texture/mesh changes. Sim/render-split aware: the sim side bakes the stable handle into the ImGui draw list and all GPU work (RTT creation/draw, flat-2D handle creation) is render-side and id-driven — nothing is created from the user callback. - **Backends.** OpenGL keeps a flat `GL_TEXTURE_2D` companion blitted (Y-flipped) from the RTT array texture each frame, since ImGui binds `GL_TEXTURE_2D`; Vulkan adds a `VK_IMAGE_VIEW_TYPE_2D` view of the RTT color image + `ImGui_ImplVulkan_AddTexture` (no copy). Both via three render-agnostic `RenderBackend` methods (`acquireFlat2DImguiHandle` / `resolveRenderTargetFlat2D` / `releaseFlat2DImguiHandle`). - **Vulkan image budget.** Each on-screen registered image holds one ImGui descriptor set from a fixed Vulkan pool sized by `kImguiImageDescriptorPoolSize` ([source/backends/vulkan_backend.h](source/backends/vulkan_backend.h), default 1024; ~tens of KB reserved). Past that many *concurrent* images the backend logs an error once and no-ops the surplus (those images draw nothing) rather than aborting; it recovers as images leave the screen. Raise the constant if you display more at once. OpenGL has no such limit — it binds GL texture handles directly, with no descriptor pool. +- **Threaded (sim/render split):** registered images work under the threaded `run(update, ui[, sim, render])` — register/update/draw from `update`/`ui` on the sim thread; the manager is serialized under the asset mutex and GPU frees are deferred to the render thread. See [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md). - **v1 scope:** works in the main UI context. Calling `imguiImage` inside a `renderImguiTo` diegetic panel is a planned follow-up. ### ImGui fonts — `ImguiFontManager`, `ImguiFontSourceId`, `ImguiFontId` diff --git a/THREADED_DIEGETIC_IMGUI.md b/THREADED_DIEGETIC_IMGUI.md index 5d3ec18b..9560d679 100644 --- a/THREADED_DIEGETIC_IMGUI.md +++ b/THREADED_DIEGETIC_IMGUI.md @@ -1,35 +1,56 @@ -# Threaded mode — the diegetic-ImGui / registered-image gap +# Threaded mode — diegetic ImGui & registered images (as-built) > Companion to [THREADED_MODE.md](THREADED_MODE.md). That doc covers the *main-canvas* ImGui -> path, which is fully threaded (sim-UI context + `ImDrawData` deep-clone in the snapshot). -> **This doc scopes the two ImGui features that are NOT yet threaded**, so their support can be -> designed and implemented separately. Until then, examples using them stay on the -> single-threaded `run(update, Controller&)` path. +> path (sim-UI context + `ImDrawData` deep-clone in the snapshot). This doc tracked the two +> ImGui features that were the last to be threaded. **Both are now supported** — it is kept as +> the as-built record of how they were wired; the historical "why it was blocked" analysis +> follows the status table. ## TL;DR — threaded status | Feature | Public API | Threaded status | Examples | |---|---|---|---| -| **Diegetic ImGui in an RTT** | `Canvas::renderImguiTo(rtId, fontId, callback)` | **Unsupported** | `hello_imgui_rtt`, `hello_dpi_scaling` | | **Registered ImGui images** | `Canvas::registerImguiImage` / `imguiImage` / `updateImguiImage` | **Supported** (Phase 1) | `hello_imgui_visual`, `hello_imgui_image_registry`, `hello_markdown` (spinner) | +| **Diegetic ImGui in an RTT** | `Canvas::renderImguiTo(rtId, fontId, callback)` | **Supported** (Phase 2) | `hello_imgui_rtt`, `hello_dpi_scaling` | -> **Registered images are done.** `ImguiImageManager` is now threaded through -> `runThreaded`/`commitFrame`/`renderFrameThreaded`; the sim-side `beginFrame` + -> `appendInternalPasses` run in the `FrameMode::Threaded` produce arm, the registry is -> serialized under the asset mutex (the same one `resolveImages` holds render-side), and GPU -> handle/RTT frees are deferred to the render thread via a two-phase retire queue. The three -> examples above run on `run(update, ui)`. What remains below is the **diegetic `renderImguiTo`** -> path. +All five examples now run on `run(update, ui)`. Everything else was already threaded: main-canvas +ImGui (interactive, input-marshalled), `renderTo` sprite RTT passes, screenshots, explorers, runtime +asset mutation, `setScreenSize`. -Everything else the examples need is already threaded: main-canvas ImGui (interactive, input-marshalled), -`renderTo` sprite RTT passes (ride the snapshot's `rttPasses`), screenshots, explorers, runtime asset -mutation, `setScreenSize`. See THREADED_MODE.md § Done. +### As-built — registered images (Phase 1) -> Note: THREADED_MODE.md's "Per-demo caveats" currently lists only the `imguiImages` examples. -> The diegetic `renderImguiTo` demos (`hello_imgui_rtt`, `hello_dpi_scaling`) and -> `hello_markdown` belong in the same deferred bucket — this doc is the authoritative list. +`ImguiImageManager` is threaded through `runThreaded`/`commitFrame`/`renderFrameThreaded` (via a +`mThreadedImguiImages` member). The sim-side `beginFrame` + `appendInternalPasses` run in the +`FrameMode::Threaded` produce arm; the registry is serialized under the asset mutex (the same one +`resolveImages` holds render-side, wrapped around the Canvas entry points); GPU handle/RTT frees are +deferred to the render thread via a two-phase retire queue (`retireEntryGpu` / `drainRetiredGpu`). -## Why it's blocked — the mechanics +### As-built — diegetic ImGui (Phase 2) + +`ImguiRttManager::flushPending` split into **`produceClones`** (sim thread: for each `renderImguiTo` +pass, run the user callback on its secondary context and deep-clone the draw data into the snapshot's +new `rttUi` list — the reuse template from `mainUi`) and **`replayClones`** (render thread: lazy +per-RTT renderer-backend init + `RenderDrawData` of the clones — no user callback, no secondary +NewFrame). The RTT manager is threaded through the produce arm via `mThreadedImguiRtt`. Because the +RTT-clone replay touches the shared font atlas, the render consume holds the **ImGui mutex outer of +the asset mutex** around `renderSnapshotContents`, matching the sim side's `imgui⊃asset` order +(Phase 1's Canvas image draw takes the asset mutex while the ImGui mutex is held) so the two threads +acquire the pair in the same order and cannot deadlock. Secondary-context teardown +(`releaseContext`/`releaseAll`) only shuts down the renderer backend for RTTs that were actually +replayed (tracked in `mBackendInited`). + +**Known v1 limitation (unchanged):** diegetic panels take **no input** — mouse/keyboard reach only the +main context. This was true single-threaded too; threading is render parity, not new input. + +--- + +## Historical: why it was blocked — the mechanics + +*(Retained for context; both gaps below are now closed as described above.)* + +Both features were originally driven **exclusively from the `FrameMode::Single` arm** of +`FrameRunner::produce` (`source/frame_runner.cpp`). The `FrameMode::Threaded` arm (the sim-thread +commit, ends ~line 592) never touched them, and `commitFrame` passed both managers as `nullptr`. Both features are driven **exclusively from the `FrameMode::Single` arm** of `FrameRunner::produce` (`source/frame_runner.cpp`). The `FrameMode::Threaded` arm (the sim-thread diff --git a/THREADED_MODE.md b/THREADED_MODE.md index 5b06ce90..6b7d2830 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -78,6 +78,16 @@ convenience overloads that own the sim thread for you. - **Threaded-safe `markTextureAsDirty` / `setTextureMin|MagFilter`** — now pure-CPU dirty flags (`mContentDirty` / `mFilterDirty`) consumed by `syncToGpu` on the render thread. +### Diegetic ImGui + registered ImGui images on the sim thread +- **Registered ImGui images** (`registerImguiImage`/`imguiImage`/`updateImguiImage`) — the + `ImguiImageManager` is threaded through the produce/consume arms; sim-side registry access is + serialized under the asset mutex and GPU frees deferred via a two-phase retire queue. +- **Diegetic `renderImguiTo`** — `ImguiRttManager` split into sim-side `produceClones` (run each + callback on its secondary context, deep-clone into `RenderSnapshot::rttUi`) + render-side + `replayClones` (backend init + `RenderDrawData`, no user code). Render consume takes the ImGui + mutex outer of the asset mutex so the RTT-clone atlas access can't deadlock with the sim. + See [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md) for the full as-built. + ## Pending — port all demos to the threaded path (main remaining work) The threaded core is complete; the outstanding effort is migrating the example demos so the @@ -98,12 +108,13 @@ hello_text, hello_tilemap, hello_tint, test_create_destroy, test_gamepad. Per-demo caveats: - **hello_headless** uses `tick()` (deliberate single-step/headless harness) — a threaded port may not be meaningful; keep as the single-threaded/manual-tick reference. -- **hello_imgui_rtt / hello_dpi_scaling** use `renderImguiTo` (diegetic ImGui in an RTT), which is - single-threaded-only for now (see [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md)); port - only once that path gains threaded support. -- **hello_imgui_visual / hello_imgui_image_registry / hello_markdown** exercise registered ImGui - images (`registerImguiImage`/`imguiImage`), which is **now threaded** (Phase 1) — all three run on - `run(update, ui)`. +- **hello_screenshot** stays on the deprecated single-thread `run(update, Controller&)`: the + scheduled screenshot request/result handoff crosses the sim/render boundary unsynchronized + (`requestScreenshot` arms sim-side, `finishScreenshot` writes render-side). This is a *separate* + gap from the ImGui features and is the only remaining deferred demo. +- **hello_imgui_rtt / hello_dpi_scaling** (diegetic `renderImguiTo`) and **hello_imgui_visual / + hello_imgui_image_registry / hello_markdown** (registered ImGui images) are **now threaded** — + all five run on `run(update, ui)` (see [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md)). ## Out of scope From f8ab7ca89c15896fdab10298cc3d61e61611ecc0 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:06 -0700 Subject: [PATCH 45/55] perf(threaded): gate the diegetic imgui>asset lock on frames that use renderImguiTo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 2 render consume held the ImGui mutex outer of the asset mutex around renderSnapshotContents unconditionally, which serialized the render draw against the sim UI-build for EVERY threaded frame — regressing sim/render overlap even for apps that never use diegetic RTT ImGui. Only replayClones (empty-guarded) touches the shared atlas, and registered-image resolve is asset-mutex-covered, so gate the coarse lock on snapshot.rttUi being non-empty: no-clone frames keep the pre-diegetic asset-only renderSnapshotContents + short imgui main draw (full overlap); only renderImguiTo frames pay the coarse lock. Neither path takes asset>imgui, so the choice stays deadlock-safe. Verified: builds glfw-opengl/glfw-vulkan; xvfb smoke of diegetic + non-diegetic threaded demos (opengl + vulkan, no VUID); TSan 0 on both lock-discipline paths (hello_imgui_rtt non-empty, hello_threaded_imgui + hello_threaded_dense_land empty); SwiftShader goldens 25/25. --- source/frame_runner.cpp | 54 +++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index d727f88a..a217ee71 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -941,29 +941,51 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager ImGui::Render(); } - // Container-touching section (deferred frees, GPU upload, id→handle resolve + draw - // submission) plus the ImGui render. The ImGui mutex is the OUTER lock here because - // renderSnapshotContents now replays diegetic-ImGui clones (replayClones) and the main - // endFrame below both do RenderDrawData, which touches the shared font atlas. The asset - // mutex nests INSIDE it — matching the sim side's imgui⊃asset order (a Canvas ImGui-image - // draw in the sim `ui` takes the asset mutex while the ImGui mutex is held), so the two - // threads acquire the pair in the same order and can't deadlock. The asset lock is - // released before the vsync swap so a slow present never stalls the sim. + // Main-context clone RenderDrawData — always needs the ImGui mutex (touches the shared + // font atlas). Render the sim's cloned UI if present; otherwise the main empty frame. + auto drawMainUi = [&] { - std::lock_guard imguiLock(mImguiMutex); - { - ZoneScopedN("AssetLockedRender"); - std::lock_guard assetLock(mThreadedAssetMutex); - renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); - } - ZoneScopedN("ImGuiRender"); - // Render the sim's cloned UI if present; otherwise the main empty frame. ImDrawData* uiData = (snapshot.mainUi && snapshot.mainUi->hasData()) ? snapshot.mainUi->drawData() : ImGui::GetDrawData(); uiData->Textures = &ImGui::GetPlatformIO().Textures; mBackend.endFrame(uiData, mFramebufferWidth, mFramebufferHeight); + }; + + // Container-touching render (deferred frees, GPU upload, id→handle resolve, sprite + + // RTT-sprite draw, main draw). The lock discipline depends on whether this frame carries + // diegetic-ImGui clones, so the coarse lock is paid ONLY when renderImguiTo is in use: + // - No clones (the common case): renderSnapshotContents touches only the asset containers + // (registered-image resolve is asset-mutex-covered), so it runs under the asset mutex + // ALONE and keeps overlapping the sim's UI-build; only the short main RenderDrawData + // takes the ImGui mutex — the pre-diegetic behavior. + // - With clones: replayClones inside renderSnapshotContents does RenderDrawData on the RTT + // contexts, touching the shared atlas, so the whole block runs under the ImGui mutex + // OUTER of the asset mutex — matching the sim side's imgui⊃asset order (a Canvas + // ImGui-image draw in `ui` takes the asset mutex while the ImGui mutex is held) so the + // two threads acquire the pair in the same order and can't deadlock. Neither path ever + // takes asset⊃imgui, so the choice is deadlock-safe regardless of which frames use it. + // The asset lock is released before the vsync swap so a slow present never stalls the sim. + if (snapshot.rttUi.empty()) + { + { + ZoneScopedN("AssetLockedRender"); + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); + } + std::lock_guard imguiLock(mImguiMutex); + drawMainUi(); + } + else + { + std::lock_guard imguiLock(mImguiMutex); + { + ZoneScopedN("AssetLockedRender"); + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); + } + drawMainUi(); } { From 88d24e9aebc8a8c6e18090ae11f71d16183f4857 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:42:35 -0700 Subject: [PATCH 46/55] perf(threaded): keep sprite work overlapping the sim on diegetic frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diegetic gate (f8ab7ca) still dragged the whole render — GPU uploads, sprite RTT passes, resolveImages, main sprite draw — under the ImGui mutex on frames that use renderImguiTo, so none of it overlapped the sim UI-build. Only the actual shared-atlas work needs that lock. Split renderSnapshotContents at the replayClones seam into renderSnapshotPreMain (uploads + sprite RTT passes + resolveImages) and renderSnapshotMain (main sprite draw), both atlas-free, and drive the phases from consume(Threaded) with per-phase locks: preMain -> asset only (overlaps sim) replayClones -> imgui>asset (only when rttUi non-empty) main sprite draw -> asset only (overlaps sim) main endFrame -> imgui drainPendingFrees moves under the existing render-side imgui block (imgui>asset): its render-target branch tears down secondary ImGui contexts (releaseContext -> DestroyContext), which races the sim's produceClones on ImGui globals. Releasing the asset mutex between phases is safe — GPU frees are deferred and each phase re-looks-up by id. No path takes asset>imgui, so still deadlock-free. Single mode is a thin orchestrator over the same phases (byte-identical order, no locks). Verified: 4 backends build; xvfb smoke of diegetic + non-diegetic + explorer demos (opengl/vulkan, no VUID); TSan 0 on hello_imgui_rtt/_dpi_scaling (diegetic phase churn + releaseContext-under-imgui) and hello_threaded_imgui/_dense_land (asset-only); headless content check still draws the RTT panel; goldens 25/25. --- source/frame_runner.cpp | 127 +++++++++++++++++++++------------------- source/frame_runner.h | 19 +++++- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index a217ee71..eab4a186 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -762,21 +762,24 @@ void FrameRunner::drainPendingFrees(AssetRegistry& assets, ImguiRttManager& imgu void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& imguiRtt, ImguiImageManager* imguiImages, - const RenderSnapshot& snapshot, float deltaTimeMS) -{ - // Everything here touches the asset containers (`mTextures`/`mMeshes`, - // render targets) and the deferred-free queues. On the threaded path the - // caller holds `mThreadedAssetMutex` around this whole method so it cannot - // race the sim thread's structural spawns/despawns; on the single-threaded - // path there is no contention. The vsync swap is deliberately NOT here — it - // is done by the caller after releasing the lock. - - // Deferred free: release resources retired no later than the snapshot we are - // about to render. Single-threaded, that snapshot is the latest commit so - // frees happen this frame; threaded, the gate genuinely defers (render lags). + const RenderSnapshot& snapshot) +{ + // Single-mode orchestrator: producer + consumer are the same thread, so run the + // phases back-to-back with no locks. The threaded consume drives these same phases + // itself, locking each at the right granularity (see consume(FrameMode::Threaded)). mLastRenderedSeq = snapshot.commitSeq; drainPendingFrees(assets, imguiRtt, mLastRenderedSeq); + renderSnapshotPreMain(assets, imguiImages, snapshot); + imguiRtt.replayClones(snapshot.rttUi); + renderSnapshotMain(assets, snapshot); +} +void FrameRunner::renderSnapshotPreMain(AssetRegistry& assets, ImguiImageManager* imguiImages, + const RenderSnapshot& snapshot) +{ + // Asset-only: GPU uploads + sprite RTT passes + registered-image resolve. None of this + // touches the shared font atlas, so on the threaded path it needs only the asset mutex + // and overlaps the sim's UI-build. (The vsync swap is done by the caller, unlocked.) { ZoneScopedN("TextureUpload"); for (auto& [textureIndex, texturePack] : assets.textures()) @@ -795,14 +798,6 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& meshPack.syncToGpu(mBackend); } - // Snapshot size, falling back to the live atomic for un-stamped priming slots - // (default {0,0} would make the world transform degenerate). Single mode always - // stamps a valid size, so this is a no-op there. - const ScreenSize renderScreen = (snapshot.screenSize.width != 0 && snapshot.screenSize.height != 0) - ? snapshot.screenSize - : mScreenSize.load(std::memory_order_acquire); - const glm::mat3 worldTransformMat = computeWorldTransformMat(renderScreen); - { ZoneScopedN("RttPasses"); // RTT pre-passes — render the snapshot's RTT draw lists into their render @@ -834,20 +829,27 @@ void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& // The internal RTTs for registered ImGui images were just drawn (they are ordinary // RTT passes); refresh their flat-2D and create the ImGui handles the main UI samples. - // Runs before the main ImGui render below. Null on the threaded path (ImGui images - // are single-threaded only for now). + // Creates an ImGui-image descriptor set / GL view of the RTT color image — NOT the + // shared font atlas — so this stays on the asset-only path. Non-null in both modes now. if (imguiImages) imguiImages->resolveImages(); - - // Diegetic ImGui-to-RTT passes — replay the sim's per-RTT draw-data clones into their - // render targets (before the main pass samples them). Each uses a secondary ImGuiContext - // rendered with a pipeline compiled against the RTT render pass (Vulkan) or into the RTT - // FBO (OpenGL); the backend is lazily created render-side on first replay, torn down in - // removeRenderTarget() and the destructor. Touches the shared atlas, so the caller holds - // the ImGui mutex (see consume()); no-op when there are no clones. - imguiRtt.replayClones(snapshot.rttUi); } + // NB: diegetic ImGui replay (imguiRtt.replayClones) happens AFTER this phase — it touches + // the shared atlas, so the threaded consume runs it under the ImGui mutex; here it is left + // to the caller so the atlas-free work above can stay asset-only. +} + +void FrameRunner::renderSnapshotMain(AssetRegistry& assets, const RenderSnapshot& snapshot) +{ + // Asset-only: the main framebuffer sprite draw. Snapshot size, falling back to the live + // atomic for un-stamped priming slots (default {0,0} would make the world transform + // degenerate). Single mode always stamps a valid size, so the fallback is a no-op there. + const ScreenSize renderScreen = (snapshot.screenSize.width != 0 && snapshot.screenSize.height != 0) + ? snapshot.screenSize + : mScreenSize.load(std::memory_order_acquire); + const glm::mat3 worldTransformMat = computeWorldTransformMat(renderScreen); + mBackend.beginMainPass(mGameViewport); { @@ -932,6 +934,16 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager ZoneScopedN("RenderImguiNewFrame"); std::lock_guard imguiLock(mImguiMutex); imguiRtt.drainPendingFontOps(); + // Deferred frees run here, under imgui⊃asset: the render-target branch tears down + // secondary ImGui contexts (releaseContext → ImGui::DestroyContext), which mutates + // ImGui's global context list and can race the sim's produceClones (CreateContext / + // NewFrame), so it must hold the ImGui mutex — not just the asset mutex. Kept out of + // the asset-only render phases below (which must NOT touch ImGui globals). + { + std::lock_guard assetLock(mThreadedAssetMutex); + mLastRenderedSeq = snapshot.commitSeq; + drainPendingFrees(assets, imguiRtt, mLastRenderedSeq); + } // Apply the sim UI's marshalled cursor shape before newImGuiFrame (inside // beginMainImguiFrame) runs the platform backend's UpdateMouseCursor, which // reads the main context's GetMouseCursor() and drives glfwSetCursor/SDL. @@ -953,38 +965,33 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager mBackend.endFrame(uiData, mFramebufferWidth, mFramebufferHeight); }; - // Container-touching render (deferred frees, GPU upload, id→handle resolve, sprite + - // RTT-sprite draw, main draw). The lock discipline depends on whether this frame carries - // diegetic-ImGui clones, so the coarse lock is paid ONLY when renderImguiTo is in use: - // - No clones (the common case): renderSnapshotContents touches only the asset containers - // (registered-image resolve is asset-mutex-covered), so it runs under the asset mutex - // ALONE and keeps overlapping the sim's UI-build; only the short main RenderDrawData - // takes the ImGui mutex — the pre-diegetic behavior. - // - With clones: replayClones inside renderSnapshotContents does RenderDrawData on the RTT - // contexts, touching the shared atlas, so the whole block runs under the ImGui mutex - // OUTER of the asset mutex — matching the sim side's imgui⊃asset order (a Canvas - // ImGui-image draw in `ui` takes the asset mutex while the ImGui mutex is held) so the - // two threads acquire the pair in the same order and can't deadlock. Neither path ever - // takes asset⊃imgui, so the choice is deadlock-safe regardless of which frames use it. - // The asset lock is released before the vsync swap so a slow present never stalls the sim. - if (snapshot.rttUi.empty()) + // Container-touching render, split into phases so only the steps that actually touch the + // shared font atlas hold the ImGui mutex — the sprite/upload work stays asset-only and + // overlaps the sim's UI-build (like the non-ImGui renderTo path). Every phase locks in + // imgui⊃asset order or asset-only — NEVER asset⊃imgui — matching the sim, so it can't + // deadlock. Releasing the asset mutex between phases is safe because GPU frees are deferred + // (a sim remove in a gap only enqueues; the resource stays alive) and each phase re-looks + // up resources by id, holding no iterators across a gap. The asset lock is released before + // the vsync swap so a slow present never stalls the sim. { - { - ZoneScopedN("AssetLockedRender"); - std::lock_guard assetLock(mThreadedAssetMutex); - renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); - } - std::lock_guard imguiLock(mImguiMutex); - drawMainUi(); + ZoneScopedN("PreMain"); // uploads + sprite RTT + resolve + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotPreMain(assets, imguiImages, snapshot); } - else + if (not snapshot.rttUi.empty()) // diegetic RTT: atlas draw { - std::lock_guard imguiLock(mImguiMutex); - { - ZoneScopedN("AssetLockedRender"); - std::lock_guard assetLock(mThreadedAssetMutex); - renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); - } + ZoneScopedN("DiegeticReplay"); + std::lock_guard imguiLock(mImguiMutex); // outer + std::lock_guard assetLock(mThreadedAssetMutex); // inner + imguiRtt.replayClones(snapshot.rttUi); + } + { + ZoneScopedN("MainPass"); // main framebuffer sprite draw + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotMain(assets, snapshot); + } + { + std::lock_guard imguiLock(mImguiMutex); // main-context ImGui: atlas draw drawMainUi(); } @@ -1007,7 +1014,7 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager // FrameMode::Single — the producer (buildSnapshot) already opened the GPU frame // and the main-context ImGui frame, so the consumer just renders + presents. - renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS); + renderSnapshotContents(assets, imguiRtt, imguiImages, snapshot); if (mStats) { diff --git a/source/frame_runner.h b/source/frame_runner.h index 8335de55..11f3d61a 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -338,11 +338,24 @@ class FrameRunner /// The container-touching core of a rendered frame: deferred frees, GPU /// upload, RTT passes (including imguiVisual's internal RTTs, resolved + /// GC'd via `imguiImages` when non-null), and the main draw. Excludes the - /// vsync swap and the ImGui render. On the threaded path the caller holds - /// `mThreadedAssetMutex` around this; single-threaded there is no contention. + /// vsync swap and the ImGui render. Single-mode orchestrator: runs the phases + /// below back-to-back with no locks (`drainPendingFrees` → preMain → diegetic + /// replay → main). The threaded consume drives the phases itself so it can lock + /// each at the right granularity (only the atlas-touching steps take the ImGui + /// mutex; the sprite/upload work stays asset-only and overlaps the sim). void renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& imguiRtt, ImguiImageManager* imguiImages, - const RenderSnapshot& snapshot, float deltaTimeMS); + const RenderSnapshot& snapshot); + + /// Asset-only render phase: GPU uploads (textures / RT sync / meshes) + sprite + /// RTT passes + registered-image `resolveImages`. Touches only the asset + /// containers + the ImGui-image descriptor pool — never the shared font atlas — + /// so it needs only `mThreadedAssetMutex` on the threaded path. + void renderSnapshotPreMain(AssetRegistry& assets, ImguiImageManager* imguiImages, + const RenderSnapshot& snapshot); + + /// Asset-only render phase: `beginMainPass` + the main framebuffer sprite draw. + void renderSnapshotMain(AssetRegistry& assets, const RenderSnapshot& snapshot); /// Gather the queued RTT passes (`mPendingRttPasses`) into POD draw lists on /// `out` and clear the queue. CPU-only — GPU existence of each render target From ec86a629b0edd93030f28774194d22c45e8b0bed Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:51:09 -0700 Subject: [PATCH 47/55] docs(threaded): fold THREADED_DIEGETIC_IMGUI.md into THREADED_MODE.md and remove it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ImGui features (registered images, diegetic renderImguiTo) are shipped, so the scoping doc is obsolete. Absorb its as-built essentials — the produceClones/replayClones split, the finer-grained imgui/asset lock phases, and the no-input v1 limitation — into THREADED_MODE.md's Done section, refresh the stale demo-migration status (all demos ported except hello_headless/hello_screenshot), and repoint the two CLAUDE.md links. --- CLAUDE.md | 4 +- THREADED_DIEGETIC_IMGUI.md | 158 ------------------------------------- THREADED_MODE.md | 70 ++++++++-------- 3 files changed, 37 insertions(+), 195 deletions(-) delete mode 100644 THREADED_DIEGETIC_IMGUI.md diff --git a/CLAUDE.md b/CLAUDE.md index 0262f354..03b459c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -654,7 +654,7 @@ canvas.renderImguiTo(renderTargetId, canvas.defaultImguiFontId(), [&] { ... }); - The OpenGL ImGui backend is FBO-agnostic; the Vulkan backend's per-context pipeline is built against `mRttRenderPass`, so it is render-pass-compatible with `beginRttPass`. **No changes to `imgui_impl_opengl3.*` / `imgui_impl_vulkan.*` are required.** - The platform backend (GLFW/SDL3) is skipped for secondary contexts — they run headless-style with `IO.DisplaySize` / `IO.DeltaTime` set manually. This means the feature works identically across GLFW+OpenGL, GLFW+Vulkan, SDL3+OpenGL, SDL3+Vulkan, and headless Vulkan. -**Threaded (sim/render split):** `renderImguiTo` works under the threaded `run(update, ui[, sim, render])` — call it from the `ui` callback. The user callback runs on the **sim** thread against a per-RTT secondary context and its draw data is deep-cloned into the frame snapshot (`RenderSnapshot::rttUi`); the render thread only replays the clone. See [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md). +**Threaded (sim/render split):** `renderImguiTo` works under the threaded `run(update, ui[, sim, render])` — call it from the `ui` callback. The user callback runs on the **sim** thread against a per-RTT secondary context and its draw data is deep-cloned into the frame snapshot (`RenderSnapshot::rttUi`); the render thread only replays the clone. See [THREADED_MODE.md](THREADED_MODE.md). **Limitations (v1):** - **Input is not forwarded** to the secondary context — widgets render correctly but mouse/keyboard events only reach the main context (true in both single-threaded and threaded modes). Forwarding canvas-space mouse coords into the RTT's `IO.MousePos` is a natural follow-up. @@ -708,7 +708,7 @@ bool isImguiImageReady(ImguiImageId id) const; - **Lifecycle.** Registered entries are **never** garbage-collected (only `unregisterImguiImage` frees them) and only re-render on frames they are displayed, are `updateImguiImage`-dirtied, or have not yet been populated — so off-screen registered images cost nothing yet keep a valid handle and last-rendered content. `updateImguiImage` keeps the id/handle stable for layer/opacity/mesh changes; it recreates the RTT (a one-tick re-warm) only when the resolved physical size or the source texture/mesh changes. Sim/render-split aware: the sim side bakes the stable handle into the ImGui draw list and all GPU work (RTT creation/draw, flat-2D handle creation) is render-side and id-driven — nothing is created from the user callback. - **Backends.** OpenGL keeps a flat `GL_TEXTURE_2D` companion blitted (Y-flipped) from the RTT array texture each frame, since ImGui binds `GL_TEXTURE_2D`; Vulkan adds a `VK_IMAGE_VIEW_TYPE_2D` view of the RTT color image + `ImGui_ImplVulkan_AddTexture` (no copy). Both via three render-agnostic `RenderBackend` methods (`acquireFlat2DImguiHandle` / `resolveRenderTargetFlat2D` / `releaseFlat2DImguiHandle`). - **Vulkan image budget.** Each on-screen registered image holds one ImGui descriptor set from a fixed Vulkan pool sized by `kImguiImageDescriptorPoolSize` ([source/backends/vulkan_backend.h](source/backends/vulkan_backend.h), default 1024; ~tens of KB reserved). Past that many *concurrent* images the backend logs an error once and no-ops the surplus (those images draw nothing) rather than aborting; it recovers as images leave the screen. Raise the constant if you display more at once. OpenGL has no such limit — it binds GL texture handles directly, with no descriptor pool. -- **Threaded (sim/render split):** registered images work under the threaded `run(update, ui[, sim, render])` — register/update/draw from `update`/`ui` on the sim thread; the manager is serialized under the asset mutex and GPU frees are deferred to the render thread. See [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md). +- **Threaded (sim/render split):** registered images work under the threaded `run(update, ui[, sim, render])` — register/update/draw from `update`/`ui` on the sim thread; the manager is serialized under the asset mutex and GPU frees are deferred to the render thread. See [THREADED_MODE.md](THREADED_MODE.md). - **v1 scope:** works in the main UI context. Calling `imguiImage` inside a `renderImguiTo` diegetic panel is a planned follow-up. ### ImGui fonts — `ImguiFontManager`, `ImguiFontSourceId`, `ImguiFontId` diff --git a/THREADED_DIEGETIC_IMGUI.md b/THREADED_DIEGETIC_IMGUI.md deleted file mode 100644 index 9560d679..00000000 --- a/THREADED_DIEGETIC_IMGUI.md +++ /dev/null @@ -1,158 +0,0 @@ -# Threaded mode — diegetic ImGui & registered images (as-built) - -> Companion to [THREADED_MODE.md](THREADED_MODE.md). That doc covers the *main-canvas* ImGui -> path (sim-UI context + `ImDrawData` deep-clone in the snapshot). This doc tracked the two -> ImGui features that were the last to be threaded. **Both are now supported** — it is kept as -> the as-built record of how they were wired; the historical "why it was blocked" analysis -> follows the status table. - -## TL;DR — threaded status - -| Feature | Public API | Threaded status | Examples | -|---|---|---|---| -| **Registered ImGui images** | `Canvas::registerImguiImage` / `imguiImage` / `updateImguiImage` | **Supported** (Phase 1) | `hello_imgui_visual`, `hello_imgui_image_registry`, `hello_markdown` (spinner) | -| **Diegetic ImGui in an RTT** | `Canvas::renderImguiTo(rtId, fontId, callback)` | **Supported** (Phase 2) | `hello_imgui_rtt`, `hello_dpi_scaling` | - -All five examples now run on `run(update, ui)`. Everything else was already threaded: main-canvas -ImGui (interactive, input-marshalled), `renderTo` sprite RTT passes, screenshots, explorers, runtime -asset mutation, `setScreenSize`. - -### As-built — registered images (Phase 1) - -`ImguiImageManager` is threaded through `runThreaded`/`commitFrame`/`renderFrameThreaded` (via a -`mThreadedImguiImages` member). The sim-side `beginFrame` + `appendInternalPasses` run in the -`FrameMode::Threaded` produce arm; the registry is serialized under the asset mutex (the same one -`resolveImages` holds render-side, wrapped around the Canvas entry points); GPU handle/RTT frees are -deferred to the render thread via a two-phase retire queue (`retireEntryGpu` / `drainRetiredGpu`). - -### As-built — diegetic ImGui (Phase 2) - -`ImguiRttManager::flushPending` split into **`produceClones`** (sim thread: for each `renderImguiTo` -pass, run the user callback on its secondary context and deep-clone the draw data into the snapshot's -new `rttUi` list — the reuse template from `mainUi`) and **`replayClones`** (render thread: lazy -per-RTT renderer-backend init + `RenderDrawData` of the clones — no user callback, no secondary -NewFrame). The RTT manager is threaded through the produce arm via `mThreadedImguiRtt`. Because the -RTT-clone replay touches the shared font atlas, the render consume holds the **ImGui mutex outer of -the asset mutex** around `renderSnapshotContents`, matching the sim side's `imgui⊃asset` order -(Phase 1's Canvas image draw takes the asset mutex while the ImGui mutex is held) so the two threads -acquire the pair in the same order and cannot deadlock. Secondary-context teardown -(`releaseContext`/`releaseAll`) only shuts down the renderer backend for RTTs that were actually -replayed (tracked in `mBackendInited`). - -**Known v1 limitation (unchanged):** diegetic panels take **no input** — mouse/keyboard reach only the -main context. This was true single-threaded too; threading is render parity, not new input. - ---- - -## Historical: why it was blocked — the mechanics - -*(Retained for context; both gaps below are now closed as described above.)* - -Both features were originally driven **exclusively from the `FrameMode::Single` arm** of -`FrameRunner::produce` (`source/frame_runner.cpp`). The `FrameMode::Threaded` arm (the sim-thread -commit, ends ~line 592) never touched them, and `commitFrame` passed both managers as `nullptr`. - -Both features are driven **exclusively from the `FrameMode::Single` arm** of -`FrameRunner::produce` (`source/frame_runner.cpp`). The `FrameMode::Threaded` arm (the sim-thread -commit, ends ~line 592) never touches them, and `commitFrame` passes both managers as `nullptr`. - -### 1. Registered ImGui images (`ImguiImageManager`) - -Single-arm-only calls, absent in the Threaded arm: -- `imguiImages->beginFrame()` — advances the manager's frame clock (frame_runner.cpp ~line 606). -- `imguiImages->appendInternalPasses(snapshot.rttPasses)` — pushes the internal RTT passes that - rasterize each registered Visual into its off-screen target (frame_runner.cpp ~line 671). - -Consequence: in threaded mode no internal pass is ever appended, so the image's off-screen target is -never rendered and its `ImTextureID` handle stays `0` (`isReady` false forever). `imguiImage(id)` in a -sim-side `ui` would bake a null/empty handle. - -Additional wiring gaps: -- `commitFrame` (frame_runner.cpp ~line 1191+) calls `produce(FrameMode::Threaded, …, imguiImages = nullptr, …)`. - `runThreaded`/`commitFrame` must thread the real `ImguiImageManager&` through to the sim side. -- `ImguiImageManager::resolveImages()` (handle creation) must run render-side in `consume`, and its - `Entry` map is mutated from registration (potentially the sim thread) while read render-side — needs - the same id-referenced / deferred-free discipline the asset registry already uses. - -### 2. Diegetic ImGui (`ImguiRttManager::flushPending`) - -`Canvas::renderImguiTo(rtId, fontId, cb)` → `ImguiRttManager::enqueue(rtId, cb)` pushes onto -`mPendingPasses`. The queue is drained by `ImguiRttManager::flushPending(dt, sharedFonts)` -(`source/imgui_rtt_manager.cpp`), called from `renderSnapshotContents` (~line 820) — which runs on the -**render/main thread in both modes**. For each pass `flushPending`: -``` -SetCurrentContext(secondary rttCtx) -imguiNewFrameForRenderTarget(...) ; ImGui::NewFrame() -imguiDrawCallback() // <-- the USER's ImGui code -ImGui::Render() -beginRttPass(...) + render draw data -SetCurrentContext(mainCtx) -``` - -Why that's wrong under threading: -- **User callback runs on the render thread.** In the threaded model every user callback (`update`, `ui`, - input actions) runs on the **sim** thread; `renderImguiTo`'s callback captures app state by reference and - would execute on the **render** thread — the exact cross-thread aliasing the sim/render split exists to - prevent. Widgets mutating shared game state would race `update`. -- **Unsynchronized enqueue.** `enqueue` mutates `mPendingPasses` from the sim thread (inside `ui`/`update`) - while the render thread iterates it in `flushPending` — no mutex, no snapshot handoff. -- **No draw-data clone.** Unlike the main sim-UI context (whose `ImDrawData` is deep-cloned into the - snapshot via `imgui_draw_clone.*`), each secondary RTT context does a synchronous NewFrame→Render on the - render thread. There is no per-RTT clone riding the snapshot. -- **Shared font atlas.** The `fontId` auto-push/pop and secondary-context glyphs read the shared 1.92 - dynamic atlas, which is only mutex-guarded on the main sim-UI path today. - -## The reuse template (how the main path already solved the analogous problem) - -The main-canvas ImGui path is the working blueprint — the fix is to generalize it from one context to N: - -- **Sim-side render of the frame's ImGui**, on a per-context basis (main today; +N RTT contexts). -- **`ImDrawData` deep-clone into the `RenderSnapshot`** (`source/imgui_draw_clone.h/.cpp`, - `RenderSnapshot::mainUi`) — add an analogous per-RTT clone list to the snapshot. -- **Render thread replays cloned draw data only** — never runs a user callback, never a secondary NewFrame. -- **`mImguiMutex`** already serializes the shared atlas around the short sim-UI section. -- **Input marshalling** render→sim already exists for the main context; diegetic panels currently take no - input (v1 limitation noted in CLAUDE.md), so parity, not new input, is the near-term bar. - -## Rough implementation sketch (for the follow-up agent to expand) - -Not a committed design — a starting point. - -**Registered images (smaller):** -1. Thread `ImguiImageManager&` through `runThreaded` → `commitFrame` → `produce(FrameMode::Threaded)`. -2. In the Threaded arm, call `imguiImages->beginFrame()` before `uiCallback`, and - `imguiImages->appendInternalPasses(snapshot.rttPasses)` during the RTT gather (mirror lines 606/671). -3. Keep `resolveImages()` render-side in `consume`; audit `Entry`-map access for sim/render sharing and - apply the deferred-free / id-reference discipline used by `AssetRegistry`. -4. Verify `imguiImage(id)` draw baked in the sim-UI `ui` resolves the render-created handle across the - one-frame lag (handle stability is already a design goal of the registry). - -**Diegetic ImGui (larger):** -1. Move secondary-context NewFrame → `renderImguiTo` callback → Render onto the **sim thread**, against - per-RTT secondary contexts, under `mImguiMutex`. -2. Deep-clone each RTT's `ImDrawData` into a new per-RTT field of the snapshot (extend `imgui_draw_clone`). -3. `enqueue` becomes sim-thread-local (drained within the same commit); the render thread only executes - `beginRttPass` + replays the cloned draw data — no user callback, no NewFrame render-side. -4. Reconcile secondary-context lifecycle (lazy create / `releaseContext` / `drainPendingFontOps`) with the - sim/render split and deferred frees. - -## Affected files - -- `source/frame_runner.cpp` / `.h` — `produce(FrameMode::Threaded)` arm, `commitFrame` (drop the - `nullptr` managers), `renderFrameThreaded`, `consume`. -- `source/imgui_rtt_manager.h` / `.cpp` — `enqueue` / `flushPending` split into sim-produce vs - render-replay. -- `source/imgui_image_manager.h` / `.cpp` — sim-side `beginFrame`/`appendInternalPasses`, render-side - `resolveImages`, thread-safe `Entry` map. -- `source/imgui_draw_clone.h` / `.cpp` + `source/render_snapshot.h` — per-RTT cloned draw data in the - snapshot. -- `include/canvas.h` / `source/canvas.cpp` — `renderImguiTo` / `registerImguiImage` thread-affinity docs. - -## Verification (when implemented) - -- Migrate `hello_imgui_rtt`, `hello_dpi_scaling`, `hello_imgui_visual`, `hello_imgui_image_registry`, - `hello_markdown` to `run(update, ui[, sim, render])`; each renders identically to its single-thread form. -- TSan under `xvfb-run` on all five: 0 races in our code (proves the RTT-context and image-Entry sharing is - guarded). ASan/UBSan clean. -- SwiftShader goldens unchanged. -- Then drop these five from the deferred bucket in the migration plan and this doc's TL;DR table. diff --git a/THREADED_MODE.md b/THREADED_MODE.md index 6b7d2830..ef1e83bf 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -80,41 +80,41 @@ convenience overloads that own the sim thread for you. ### Diegetic ImGui + registered ImGui images on the sim thread - **Registered ImGui images** (`registerImguiImage`/`imguiImage`/`updateImguiImage`) — the - `ImguiImageManager` is threaded through the produce/consume arms; sim-side registry access is - serialized under the asset mutex and GPU frees deferred via a two-phase retire queue. -- **Diegetic `renderImguiTo`** — `ImguiRttManager` split into sim-side `produceClones` (run each - callback on its secondary context, deep-clone into `RenderSnapshot::rttUi`) + render-side - `replayClones` (backend init + `RenderDrawData`, no user code). Render consume takes the ImGui - mutex outer of the asset mutex so the RTT-clone atlas access can't deadlock with the sim. - See [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md) for the full as-built. - -## Pending — port all demos to the threaded path (main remaining work) - -The threaded core is complete; the outstanding effort is migrating the example demos so the -suite exercises the threaded path as the default. The `run(update, ui[, simController, -renderController])` convenience makes each migration mechanical: split the single `run` lambda -into a game-logic `update` + an ImGui `ui`, and split input into `simController` (game) vs -`renderController` (window/`close`). - -**Ported (6 / 29):** `hello_threaded`, `hello_threaded_imgui`, `hello_threaded_gamepad`, -`hello_threaded_dense_land` (dedicated); `hello_nothofagus`, `test_keyboard` (migrated in place). - -**Remaining (23):** hello_animation, hello_animation_state_machine, hello_custom_font, -hello_dense_land, hello_direct_texture, hello_dpi_scaling, hello_headless, hello_imgui_image_registry, -hello_imgui_overlay, hello_imgui_rtt, hello_imgui_visual, hello_layers, hello_markdown, hello_mesh, -hello_nested_render_targets, hello_render_to_texture, hello_screenshot, hello_sparse_land, -hello_text, hello_tilemap, hello_tint, test_create_destroy, test_gamepad. - -Per-demo caveats: -- **hello_headless** uses `tick()` (deliberate single-step/headless harness) — a threaded port - may not be meaningful; keep as the single-threaded/manual-tick reference. -- **hello_screenshot** stays on the deprecated single-thread `run(update, Controller&)`: the - scheduled screenshot request/result handoff crosses the sim/render boundary unsynchronized - (`requestScreenshot` arms sim-side, `finishScreenshot` writes render-side). This is a *separate* - gap from the ImGui features and is the only remaining deferred demo. -- **hello_imgui_rtt / hello_dpi_scaling** (diegetic `renderImguiTo`) and **hello_imgui_visual / - hello_imgui_image_registry / hello_markdown** (registered ImGui images) are **now threaded** — - all five run on `run(update, ui)` (see [THREADED_DIEGETIC_IMGUI.md](THREADED_DIEGETIC_IMGUI.md)). + `ImguiImageManager` is threaded through the produce/consume arms (via `mThreadedImguiImages`); sim-side + `beginFrame`/`appendInternalPasses` run in the `produce(Threaded)` arm, registry access is serialized + under the asset mutex (the same one `resolveImages` holds render-side — the Canvas entry points wrap + `lockAssetsIfThreaded`), and GPU handle/RTT frees are deferred render-side via a two-phase retire queue + (`retireEntryGpu`/`drainRetiredGpu`). Demos: `hello_imgui_visual`/`_image_registry`/`hello_markdown`. +- **Diegetic `renderImguiTo`** — `ImguiRttManager::flushPending` split into sim-side **`produceClones`** + (run each `renderImguiTo` callback on its secondary context, deep-clone its draw data into the snapshot's + `rttUi` — the `mainUi` reuse template) + render-side **`replayClones`** (lazy per-RTT backend init + + `RenderDrawData` of the clones, no user code). Wired through `produce(Threaded)` via `mThreadedImguiRtt`. + Demos: `hello_imgui_rtt`/`hello_dpi_scaling`. +- **Lock order (both features).** The sim takes `imgui⊃asset` (a Canvas ImGui-image draw in `ui` grabs the + asset mutex while the ImGui mutex is held), so any render-side step that touches the shared font atlas + must take the pair in the same order. `renderSnapshotContents` is split so only the atlas-touching steps + hold the ImGui mutex: `renderSnapshotPreMain` (uploads + sprite RTT passes + `resolveImages`) and + `renderSnapshotMain` (main sprite draw) run **asset-only** and overlap the sim; `replayClones` (diegetic) + and the main `endFrame` run under **`imgui⊃asset`**. So a diegetic frame serializes only the ImGui draws, + not the sprite work. `drainPendingFrees` runs under `imgui⊃asset` too (its RT branch tears down secondary + ImGui contexts). No render path ever takes `asset⊃imgui`, so it's deadlock-free. +- **v1 limitation (both single-threaded and threaded):** diegetic panels take **no input** — mouse/keyboard + reach only the main context. Threaded support is render parity, not new input forwarding. + +## Demo migration — done + +Every example now runs on the threaded path via the `run(update, ui[, simController, +renderController])` convenience (split the single `run` lambda into a game-logic `update` + an ImGui +`ui`, and input into `simController` (game) vs `renderController` (window/`close`)). `run()`/`run(update)` +are rerouted to the threaded path and `run(update, Controller&)` is `[[deprecated]]`. + +Two demos deliberately stay off the threaded path: +- **hello_headless** uses `tick()` (single-step/headless harness) — a threaded port isn't meaningful; + it's the single-threaded/manual-tick reference. +- **hello_screenshot** stays on the deprecated single-thread `run(update, Controller&)`: the scheduled + screenshot request/result handoff crosses the sim/render boundary unsynchronized (`requestScreenshot` + arms sim-side, `finishScreenshot` writes render-side). A *separate* gap from the ImGui features — the + only remaining deferred demo, and the last caller of the deprecated overload. ## Out of scope From e5d79ba8a753ab7bf9d6cce7c13009de096e1bc3 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:57:15 -0700 Subject: [PATCH 48/55] docs(threaded): track threaded screenshots as pending work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hello_screenshot was lumped under 'demos that deliberately stay off the threaded path' next to hello_headless, implying it was intentional like the manual-tick reference. It isn't — it's blocked by an unclosed gap. Split it out into a new Pending/deferred work section with the fix approach (marshal the screenshot request + result across the boundary via the existing harvest->POD->feed / atomic channels, then migrate the demo and drop the last deprecated caller). hello_headless stays as the only by-design single-thread demo. --- THREADED_MODE.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/THREADED_MODE.md b/THREADED_MODE.md index ef1e83bf..f1d8ac1a 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -108,13 +108,21 @@ renderController])` convenience (split the single `run` lambda into a game-logic `ui`, and input into `simController` (game) vs `renderController` (window/`close`)). `run()`/`run(update)` are rerouted to the threaded path and `run(update, Controller&)` is `[[deprecated]]`. -Two demos deliberately stay off the threaded path: -- **hello_headless** uses `tick()` (single-step/headless harness) — a threaded port isn't meaningful; - it's the single-threaded/manual-tick reference. -- **hello_screenshot** stays on the deprecated single-thread `run(update, Controller&)`: the scheduled - screenshot request/result handoff crosses the sim/render boundary unsynchronized (`requestScreenshot` - arms sim-side, `finishScreenshot` writes render-side). A *separate* gap from the ImGui features — the - only remaining deferred demo, and the last caller of the deprecated overload. +**hello_headless** deliberately stays off the threaded path: it uses `tick()` (single-step/headless +harness), so a threaded port isn't meaningful — it's the single-threaded/manual-tick reference. + +**hello_screenshot** is the one demo still on the deprecated single-thread `run(update, Controller&)` +because of an unclosed gap (see below), not by design — it's the last caller of the deprecated overload. + +## Pending / deferred work + +- **Threaded screenshots** (blocks migrating `hello_screenshot`). The scheduled screenshot crosses the + sim/render boundary unsynchronized: `requestScreenshot()` arms `mScreenshotArmed` on the sim thread while + `finishScreenshot()` writes `mScreenshotResult` on the render thread, with no marshaling. Close it by + carrying the request across the boundary and returning the captured pixels via the same + `harvest→POD→feed` / atomic-flag channels already used for gamepad/keyboard/mouse/clipboard, then migrate + `hello_screenshot` to `run(update, ui, sim, render)` and drop the last `[[deprecated]]` caller. A + *separate* gap from the ImGui features (which are done). ## Out of scope From a300e05216b57300b6ba84180ccee283d55c4076 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:52:08 -0700 Subject: [PATCH 49/55] feat(threaded): marshal scheduled screenshots across the sim/render boundary requestScreenshot() previously armed the render-owned backend directly and finishScreenshot() wrote mScreenshotResult on the render thread while retrieveScreenshot() read it on the sim thread, all unsynchronized. Route the request sim->render via a new std::atomic mScreenshotRequested (the render thread observes it in consume(Threaded) and does armScreenshot there, sized to the snapshot), and guard mScreenshotResult with mScreenshotResultMutex for the render-write / sim-read handoff. mScreenshotArmed is now render-thread-local on the threaded path. Single-threaded keeps the eager arm (byte-identical). --- source/frame_runner.cpp | 32 ++++++++++++++++++++++++++++++-- source/frame_runner.h | 9 ++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/source/frame_runner.cpp b/source/frame_runner.cpp index eab4a186..9a8ea953 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -269,6 +269,16 @@ void FrameRunner::debugCheckSimThread(const char* op) const void FrameRunner::requestScreenshot() { + // Threaded: the backend is render-thread-owned, so we can't arm it from the sim + // thread. Raise a flag; the render thread observes it in consume(Threaded) and arms + // there (mirrors the mThreadedCursor sim->render channel). + if (mThreadedRunning.load(std::memory_order_acquire)) + { + mScreenshotRequested.store(true, std::memory_order_release); + return; + } + + // Single-threaded: eager arm, unchanged (same thread finishes the capture). mScreenshotArmed = true; const ScreenSize screen = mScreenSize.load(std::memory_order_acquire); const glm::ivec2 gameSize{static_cast(screen.width), static_cast(screen.height)}; @@ -277,6 +287,7 @@ void FrameRunner::requestScreenshot() std::optional FrameRunner::retrieveScreenshot() { + std::lock_guard lock(mScreenshotResultMutex); return std::exchange(mScreenshotResult, std::nullopt); } @@ -400,7 +411,10 @@ void FrameRunner::runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttMan { TextureData textureData(pixels->width, pixels->height, 1); std::copy(pixels->data.begin(), pixels->data.end(), textureData.getDataSpan().begin()); - mScreenshotResult = DirectTexture(std::move(textureData)); + { + std::lock_guard lock(mScreenshotResultMutex); + mScreenshotResult = DirectTexture(std::move(textureData)); + } mScreenshotArmed = false; } } @@ -912,6 +926,17 @@ void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager mBackend.beginFrame(mClearColor, mGameViewport, framebufferWidth, framebufferHeight); + // Threaded screenshot: the sim raised mScreenshotRequested (requestScreenshot can't + // touch the render-owned backend). Arm here — on the render thread, before endFrame + // records the capture — sizing to the snapshot being rendered. mScreenshotArmed then + // stays render-thread-local; the finish block below writes the result under its mutex. + if (mScreenshotRequested.load(std::memory_order_acquire) && not mScreenshotArmed) + { + mScreenshotRequested.store(false, std::memory_order_release); + mScreenshotArmed = true; + mBackend.armScreenshot({static_cast(threadedScreen.width), static_cast(threadedScreen.height)}); + } + // Smoothed render-thread frame time via the same PerformanceMonitor recipe // run() uses single-threaded (averaged over its period). Computed before the // ImGui frame so the stats overlay can show it; also fed to RTT ImGui timing. @@ -1304,7 +1329,10 @@ void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& im { TextureData textureData(pixels->width, pixels->height, 1); std::copy(pixels->data.begin(), pixels->data.end(), textureData.getDataSpan().begin()); - mScreenshotResult = DirectTexture(std::move(textureData)); + { + std::lock_guard lock(mScreenshotResultMutex); + mScreenshotResult = DirectTexture(std::move(textureData)); + } mScreenshotArmed = false; } } diff --git a/source/frame_runner.h b/source/frame_runner.h index 11f3d61a..78647ed6 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -397,7 +397,14 @@ class FrameRunner /// Deferred screenshot: armed by requestScreenshot(), finished at the end of the /// captured frame; the result is held until retrieveScreenshot() consumes it. - bool mScreenshotArmed{false}; + /// On the threaded path the request/result cross the sim/render boundary: the sim + /// raises mScreenshotRequested (like mThreadedCursor), the render thread does the + /// backend arm and writes mScreenshotResult under mScreenshotResultMutex (like + /// mClipboardFromOs), so mScreenshotArmed stays render-thread-local. Single-threaded + /// keeps the eager arm and touches these on one thread. + std::atomic mScreenshotRequested{false}; ///< sim -> render arm request (threaded path). + bool mScreenshotArmed{false}; ///< render-side (threaded) / same-thread (single) armed flag. + std::mutex mScreenshotResultMutex; ///< Guards mScreenshotResult (render write vs sim read). std::optional mScreenshotResult; PresentMode mPresentMode{DEFAULT_PRESENT_MODE}; ///< Swapchain / vsync preference (construction-time). From f49104823379c9b052543c943d012f94d32be2ab Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:52:14 -0700 Subject: [PATCH 50/55] refactor(examples): migrate hello_screenshot to the threaded run overload --- examples/hello_screenshot.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/hello_screenshot.cpp b/examples/hello_screenshot.cpp index 3ce477f2..d7d3fa6b 100644 --- a/examples/hello_screenshot.cpp +++ b/examples/hello_screenshot.cpp @@ -144,27 +144,40 @@ int main() canvas.bellota(*screenshotFrameBellotaId).opacity() = 0.0f; } } + }; + // ImGui — runs on the sim-UI context (sim thread), cloned to the render thread. + auto ui = [&](float) + { ImGui::SetNextWindowPos({4, 4}); ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_AlwaysAutoResize); ImGui::Text("SPACE: capture screenshot"); + ImGui::Text("ESC: quit"); ImGui::End(); }; - Nothofagus::Controller controller; - controller.registerAction({Nothofagus::Key::SPACE, Nothofagus::DiscreteTrigger::Press}, [&]() + // Game input on the sim thread: SPACE schedules a screenshot. requestScreenshot() now + // marshals across the sim/render boundary (it raises an atomic the render thread arms + // from), so it is safe to call from the sim-side controller action. + Nothofagus::Controller simController; + simController.registerAction({Nothofagus::Key::SPACE, Nothofagus::DiscreteTrigger::Press}, [&]() { // Schedule a screenshot of the next rendered frame. Capture is deferred: - // the pixels arrive via canvas.retrieveScreenshot() on the following frame (see + // the pixels arrive via canvas.retrieveScreenshot() on a following frame (see // the poll at the top of update()), so it can be requested from inside a callback. canvas.requestScreenshot(); }); - // TODO(threaded): scheduled screenshots cross the sim/render boundary unsynchronized — - // requestScreenshot() arms flags on the sim thread while finishScreenshot() writes - // mScreenshotResult on the render thread, with no marshaling. Parked on the deprecated - // single-thread run(update, Controller&) until a threaded request/result handoff lands. - canvas.run(update, controller); + // Window/close input on the main thread. + Nothofagus::Controller renderController; + renderController.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + canvas.close(); + }); + + // Multithreaded convenience: sim thread runs update + ui + simController; the + // main thread runs the render loop + renderController. + canvas.run(update, ui, simController, renderController); return 0; } From 23dc164fc8ee7a85903fa0deb010ae7a356aa5ac Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:52:14 -0700 Subject: [PATCH 51/55] docs(threaded): record threaded screenshots as done --- THREADED_MODE.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/THREADED_MODE.md b/THREADED_MODE.md index f1d8ac1a..35b71740 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -77,6 +77,14 @@ convenience overloads that own the sim thread for you. for; no 1-frame letterbox transient on a threaded `setScreenSize`. - **Threaded-safe `markTextureAsDirty` / `setTextureMin|MagFilter`** — now pure-CPU dirty flags (`mContentDirty` / `mFilterDirty`) consumed by `syncToGpu` on the render thread. +- **Scheduled screenshots across the sim/render boundary** — `requestScreenshot()` can't touch the + render-owned backend from the sim thread, so it raises `std::atomic mScreenshotRequested` + (a sim→render channel modeled on `mThreadedCursor`); the render thread observes it at the top of + `consume(Threaded)` and does the `armScreenshot` there, sized to the snapshot being rendered — so + `mScreenshotArmed` stays render-thread-local. The captured pixels come back render→sim via + `mScreenshotResult` guarded by `mScreenshotResultMutex` (a heap-payload channel modeled on + `mClipboardFromOs`); `retrieveScreenshot()` consumes it under the same mutex. Single-threaded keeps + the eager arm (byte-identical). Demo: `hello_screenshot` (migrated to the 4-arg threaded `run`). ### Diegetic ImGui + registered ImGui images on the sim thread - **Registered ImGui images** (`registerImguiImage`/`imguiImage`/`updateImguiImage`) — the @@ -109,20 +117,15 @@ renderController])` convenience (split the single `run` lambda into a game-logic are rerouted to the threaded path and `run(update, Controller&)` is `[[deprecated]]`. **hello_headless** deliberately stays off the threaded path: it uses `tick()` (single-step/headless -harness), so a threaded port isn't meaningful — it's the single-threaded/manual-tick reference. - -**hello_screenshot** is the one demo still on the deprecated single-thread `run(update, Controller&)` -because of an unclosed gap (see below), not by design — it's the last caller of the deprecated overload. +harness), so a threaded port isn't meaningful — it's the single-threaded/manual-tick reference. It is now +the **only** by-design single-thread demo; `hello_screenshot` was migrated to `run(update, ui, +simController, renderController)` once the threaded-screenshot channel landed (see above), so there are no +in-tree callers of the `[[deprecated]] run(update, Controller&)` overload left. ## Pending / deferred work -- **Threaded screenshots** (blocks migrating `hello_screenshot`). The scheduled screenshot crosses the - sim/render boundary unsynchronized: `requestScreenshot()` arms `mScreenshotArmed` on the sim thread while - `finishScreenshot()` writes `mScreenshotResult` on the render thread, with no marshaling. Close it by - carrying the request across the boundary and returning the captured pixels via the same - `harvest→POD→feed` / atomic-flag channels already used for gamepad/keyboard/mouse/clipboard, then migrate - `hello_screenshot` to `run(update, ui, sim, render)` and drop the last `[[deprecated]]` caller. A - *separate* gap from the ImGui features (which are done). +- _(none tracked — the threaded-screenshot gap that previously lived here is closed; see + "Scheduled screenshots across the sim/render boundary" under Done.)_ ## Out of scope From addccdcc03c61e086d159cd2c7bea2bbe4177834 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 04:14:30 -0700 Subject: [PATCH 52/55] docs(threaded): verification recipe reflects direct-GPU runs + SwiftShader headless (drop xvfb/TSan) --- THREADED_MODE.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/THREADED_MODE.md b/THREADED_MODE.md index 35b71740..320ebea2 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -151,9 +151,13 @@ in-tree callers of the `[[deprecated]] run(update, Controller&)` overload left. ## Verification recipe (every change) -- Cross-backend builds: `linux-release-glfw-opengl-examples`, `-glfw-vulkan-examples`, `-sdl3-vulkan-examples`. -- SwiftShader goldens unchanged: `linux-release-headless-vulkan-tests` + - `NOTHOFAGUS_RENDER_BACKEND=swiftshader .../rendering_tests`. -- TSan + ASan on the threaded demos under `xvfb-run` — expect only environmental Mesa/glib noise, - 0 conflicts in our code, 0 ASan/UBSan errors. -- Any new shared state must be mutex- or atomic-guarded like the existing marshal channels. +- Cross-backend builds: `linux-release-glfw-opengl-examples`, `-glfw-vulkan-examples`, + `-sdl3-opengl-examples`, `-sdl3-vulkan-examples`. +- Run the threaded demos **directly on the GPU** for each window×render combo (glfw/sdl3 × + opengl/vulkan) and confirm they behave; on the Vulkan builds check the validation output is free of + `VUID-` / `SYNC-HAZARD` messages. +- SwiftShader Vulkan headless goldens unchanged (this is the CI lane): `linux-release-headless-vulkan-tests` + + `NOTHOFAGUS_RENDER_BACKEND=swiftshader .../rendering_tests`. +- Any new cross-thread shared state must be mutex- or atomic-guarded like the existing marshal channels + (`mThreadedGamepadState`/`mThreadedGameInputState`, the `mThreadedCursor` atomic, the `mClipboardMutex` + payload) — argue race-freedom by mirroring those. From cac5399212a74bd3697555ffcce591786949f04c Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:03:53 -0700 Subject: [PATCH 53/55] fix(threaded): make HeadlessBackend::mRunning atomic (data race found by TSan) close() from a run(update, ...) callback runs on the sim thread and calls HeadlessBackend::requestClose(), which wrote the plain bool mRunning while the render thread read it via isRunning() in consume() (mThreadedRunning refresh). TSan (under SwiftShader headless) flagged this as a data race. Make mRunning a std::atomic (acquire/release), matching mScreenSize / mThreadedRunning. --- source/backends/headless_backend.cpp | 4 ++-- source/backends/headless_backend.h | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/source/backends/headless_backend.cpp b/source/backends/headless_backend.cpp index c131e71e..abb1a1ef 100644 --- a/source/backends/headless_backend.cpp +++ b/source/backends/headless_backend.cpp @@ -24,7 +24,7 @@ void HeadlessBackend::beginSession(Controller& /*controller*/) bool HeadlessBackend::isRunning() const { - return mRunning; + return mRunning.load(std::memory_order_acquire); } void HeadlessBackend::newImGuiFrame() @@ -80,7 +80,7 @@ ScreenSize HeadlessBackend::getWindowSize() const void HeadlessBackend::requestClose() { - mRunning = false; + mRunning.store(false, std::memory_order_release); } void HeadlessBackend::setWindowTitle(const std::string& /*title*/) diff --git a/source/backends/headless_backend.h b/source/backends/headless_backend.h index 3308578f..b3a836f4 100644 --- a/source/backends/headless_backend.h +++ b/source/backends/headless_backend.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace Nothofagus { @@ -63,7 +64,9 @@ class HeadlessBackend int mWidth; int mHeight; std::chrono::steady_clock::time_point mStartTime; - bool mRunning = true; + // Written by requestClose() (which close() may invoke from the sim thread) and read by + // isRunning() on the render thread in the threaded run loop — atomic to avoid a data race. + std::atomic mRunning{true}; }; } // namespace Nothofagus From e0d848ef2f6a1af3078afc167369b34179aa3001 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:03:53 -0700 Subject: [PATCH 54/55] test(sanitizers): threaded TSan/ASan fixture on SwiftShader headless Adds tools/sanitizers/: a small self-closing threaded workload (threaded_smoke.cpp) exercising sim-thread scene mutation, asset create/destroy, threaded ImGui, and the scheduled-screenshot channel; a TSan suppressions file scoped to third-party (SwiftShader ICD / validation layer / ImGui-Vulkan init-teardown) noise only; and run_sanitizers.sh which builds + runs TSan and ASan+UBSan against the SwiftShader Vulkan ICD (Mesa is buggy under sanitizers). Gated by NOTHOFAGUS_BUILD_SANITIZER_FIXTURE (default OFF). Both sanitizers report 0 issues on the current threaded path. --- CMakeLists.txt | 8 +++ tools/sanitizers/CMakeLists.txt | 15 ++++++ tools/sanitizers/run_sanitizers.sh | 79 +++++++++++++++++++++++++++ tools/sanitizers/threaded_smoke.cpp | 82 +++++++++++++++++++++++++++++ tools/sanitizers/tsan.supp | 22 ++++++++ 5 files changed, 206 insertions(+) create mode 100644 tools/sanitizers/CMakeLists.txt create mode 100755 tools/sanitizers/run_sanitizers.sh create mode 100644 tools/sanitizers/threaded_smoke.cpp create mode 100644 tools/sanitizers/tsan.supp diff --git a/CMakeLists.txt b/CMakeLists.txt index e6ea311a..ecbb428a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -379,6 +379,14 @@ if (${NOTHOFAGUS_BUILD_TESTS}) add_subdirectory("tests") endif() +# Sanitizer fixture: a small threaded workload for running TSan / ASan+UBSan against the +# SwiftShader Vulkan headless backend (Mesa is buggy under sanitizers). OFF by default; +# tools/sanitizers/run_sanitizers.sh turns it on with the right sanitizer + SwiftShader flags. +option(NOTHOFAGUS_BUILD_SANITIZER_FIXTURE "Build the threaded sanitizer smoke fixture?" OFF) +if (${NOTHOFAGUS_BUILD_SANITIZER_FIXTURE}) + add_subdirectory("tools/sanitizers") +endif() + # Visual Tests Explorer: a windowed Nothofagus tool to inspect golden/actual/diff # images and update goldens. Pulls in stb_image_plus for file I/O. OFF by default # (interactive tool, not part of the headless CI lane). diff --git a/tools/sanitizers/CMakeLists.txt b/tools/sanitizers/CMakeLists.txt new file mode 100644 index 00000000..a442b02c --- /dev/null +++ b/tools/sanitizers/CMakeLists.txt @@ -0,0 +1,15 @@ +# Sanitizer fixture: a small self-closing threaded workload used by run_sanitizers.sh to +# exercise the threaded sim/render path under TSan and ASan+UBSan against SwiftShader. +# Gated by NOTHOFAGUS_BUILD_SANITIZER_FIXTURE (default OFF); the helper script turns it on. +# Requires the headless-Vulkan backend (SwiftShader is a Vulkan ICD). + +if(NOT NOTHOFAGUS_HEADLESS_VULKAN) + message(FATAL_ERROR + "NOTHOFAGUS_BUILD_SANITIZER_FIXTURE requires NOTHOFAGUS_HEADLESS_VULKAN=ON " + "(the sanitizer fixture renders on the SwiftShader Vulkan ICD). " + "Use tools/sanitizers/run_sanitizers.sh, which sets this up.") +endif() + +add_executable(sanitizer_threaded_smoke "threaded_smoke.cpp") +set_property(TARGET sanitizer_threaded_smoke PROPERTY CXX_STANDARD 20) +target_link_libraries(sanitizer_threaded_smoke PRIVATE nothofagus) diff --git a/tools/sanitizers/run_sanitizers.sh b/tools/sanitizers/run_sanitizers.sh new file mode 100755 index 00000000..09f89e68 --- /dev/null +++ b/tools/sanitizers/run_sanitizers.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Run TSan and ASan+UBSan on the threaded sim/render path against SwiftShader (deterministic +# CPU Vulkan, headless). Mesa is buggy under sanitizers, so we never use the system GPU here. +# +# Usage: tools/sanitizers/run_sanitizers.sh [tsan|asan|all] (default: all) +# +# What it does, per sanitizer: +# 1. Configure a headless-Vulkan build with NOTHOFAGUS_FETCH_SWIFTSHADER=ON (downloads + +# verifies a pinned SwiftShader ICD into the build dir) and NOTHOFAGUS_BUILD_SANITIZER_FIXTURE=ON. +# 2. Build the sanitizer_threaded_smoke fixture with the sanitizer flags. +# 3. Run it against the SwiftShader ICD, with the validation layer disabled (its own worker +# thread/locks are sanitizer noise), and TSan pointed at tools/sanitizers/tsan.supp. +# 4. Report PASS only if the fixture completed AND the sanitizer reported no (non-suppressed) issues. +# +# Requires: clang/clang++, ninja, cmake, and network access on first run (SwiftShader fetch). +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" +mode="${1:-all}" +supp="$here/tsan.supp" + +configure() { + local build_dir="$1"; shift + local cxx_flags="$1"; shift + local link_flags="$1"; shift + cmake -S "$root" -G Ninja -B "$build_dir" \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_BUILD_TYPE=Debug \ + -DNOTHOFAGUS_BACKEND_VULKAN=ON -DNOTHOFAGUS_HEADLESS_VULKAN=ON \ + -DNOTHOFAGUS_FETCH_SWIFTSHADER=ON \ + -DNOTHOFAGUS_BUILD_SANITIZER_FIXTURE=ON \ + -DCMAKE_CXX_FLAGS="-Wno-narrowing $cxx_flags" \ + -DCMAKE_EXE_LINKER_FLAGS="$link_flags" \ + >/dev/null || { echo "configure failed for $build_dir"; return 1; } + cmake --build "$build_dir" --parallel --target sanitizer_threaded_smoke >/dev/null \ + || { echo "build failed for $build_dir"; return 1; } +} + +run_fixture() { + # $1 build_dir, $2 label. Sanitizer env (TSAN_OPTIONS/ASAN_OPTIONS/...) comes from the caller. + local build_dir="$1" label="$2" + local icd="$build_dir/swiftshader/swiftshader_icd.json" + if [[ ! -f "$icd" ]]; then echo "[$label] SwiftShader ICD missing ($icd)"; return 1; fi + local bin; bin="$(find "$build_dir" -type f -name sanitizer_threaded_smoke | head -1)" + if [[ -z "$bin" ]]; then echo "[$label] fixture binary not found under $build_dir"; return 1; fi + local out err; out="$(mktemp)"; err="$(mktemp)" + VK_ICD_FILENAMES="$icd" VK_DRIVER_FILES="$icd" VK_LOADER_LAYERS_DISABLE='*' \ + timeout 600 "$bin" >"$out" 2>"$err" + local rc=$? + local done warnings + done=$(grep -c 'smoke done' "$out") + warnings=$(grep -cE 'WARNING: ThreadSanitizer|ERROR: AddressSanitizer|runtime error:' "$err") + echo "[$label] rc=$rc done=$done sanitizer_reports=$warnings" + if [[ "$warnings" -gt 0 ]]; then + echo "----- $label reports -----"; grep -E 'WARNING: ThreadSanitizer|ERROR: AddressSanitizer|runtime error:|SUMMARY:' "$err" | head + fi + rm -f "$out" "$err" + [[ "$done" -ge 1 && "$warnings" -eq 0 ]] +} + +status=0 + +if [[ "$mode" == "tsan" || "$mode" == "all" ]]; then + echo "== TSan (SwiftShader headless) ==" + configure "$root/build/sanitizer-tsan" "-fsanitize=thread -g -O1" "-fsanitize=thread" || status=1 + TSAN_OPTIONS="halt_on_error=0 suppressions=$supp" run_fixture "$root/build/sanitizer-tsan" "tsan" || status=1 +fi + +if [[ "$mode" == "asan" || "$mode" == "all" ]]; then + echo "== ASan + UBSan (SwiftShader headless) ==" + configure "$root/build/sanitizer-asan" "-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" "-fsanitize=address,undefined" || status=1 + ASAN_OPTIONS="halt_on_error=1 detect_leaks=0" UBSAN_OPTIONS="print_stacktrace=1 halt_on_error=1" \ + run_fixture "$root/build/sanitizer-asan" "asan" || status=1 +fi + +echo +[[ "$status" -eq 0 ]] && echo "ALL SANITIZERS CLEAN" || echo "SANITIZER FAILURES (see above)" +exit "$status" diff --git a/tools/sanitizers/threaded_smoke.cpp b/tools/sanitizers/threaded_smoke.cpp new file mode 100644 index 00000000..65c134ba --- /dev/null +++ b/tools/sanitizers/threaded_smoke.cpp @@ -0,0 +1,82 @@ +// Threaded sanitizer smoke — a self-closing workload for running TSan / ASan+UBSan against +// the SwiftShader Vulkan headless backend (see tools/sanitizers/run_sanitizers.sh). Mesa is +// buggy under sanitizers, so we render on the deterministic CPU rasterizer instead. +// +// It drives the threaded run(update, ui) path and touches the cross-thread surfaces most +// likely to race: +// - sim-thread scene mutation (bellota transform) +// - sim-thread asset create/destroy (mode-aware wrappers + deferred GPU free) +// - threaded ImGui (a ui window built on the sim-UI context, cloned to the render thread) +// - the scheduled-screenshot channel (requestScreenshot / retrieveScreenshot marshaling) +// then closes itself after a fixed number of frames so the process exits with a real return +// code (rather than being killed at a timeout). +// +// Kept small on purpose: SwiftShader under TSan serializes on its worker pool, so a tiny +// device resolution keeps the run tractable. pixelSize > 1 still exercises the device- +// resolution screenshot path that crashed the headless backend before #114. +// +// Tunables via env: NOTHO_SMOKE_FRAMES (default 24), NOTHO_SMOKE_PIXELSIZE (default 2). + +#include +#include +#include +#include +#include + +static int envInt(const char* name, int fallback) +{ + if (const char* v = std::getenv(name)) { const int n = std::atoi(v); if (n > 0) return n; } + return fallback; +} + +int main() +{ + const int totalFrames = envInt("NOTHO_SMOKE_FRAMES", 24); + const int pixelSize = envInt("NOTHO_SMOKE_PIXELSIZE", 2); // > 1 exercises device-resolution capture + + Nothofagus::Canvas canvas({32, 24}, "sanitizer-threaded-smoke", {0.1f, 0.15f, 0.2f}, + static_cast(pixelSize)); + + Nothofagus::IndirectTexture sprite({8, 8}, {0.0f, 0.0f, 0.0f, 0.0f}); + sprite.setPallete({{0, 0, 0, 0}, {1, 0.3f, 0.2f, 1}}); + sprite.setPixels({1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}); + auto texId = canvas.addTexture(sprite); + auto bellotaId = canvas.addBellota({{{16.0f, 12.0f}, 2.0f}, texId}); + + canvas.setTargetFps(120.0f); // keep sim/render cadences aligned + + int frame = 0; + bool requested = false, gotShot = false; + glm::ivec2 shotSize{0, 0}; + std::optional churn; + + const int requestAt = totalFrames / 2; + + auto update = [&](float) + { + ++frame; + canvas.bellota(bellotaId).transform().location().x = 16.0f + 4.0f * ((frame % 8) - 4) / 4.0f; + + // Sim-thread asset create/destroy churn (mode-aware wrappers + deferred free). + if (frame % 6 == 3) + churn = canvas.addBellota({{{4.0f, 4.0f}, 1.0f}, texId}); + else if (frame % 6 == 0 && churn) + { canvas.removeBellota(*churn); churn.reset(); } + + // Scheduled screenshot across the sim/render boundary (device-resolution capture). + if (frame == requestAt && !requested) { requested = true; canvas.requestScreenshot(); } + if (std::optional shot = canvas.retrieveScreenshot()) + if (shot->size().x > 0) { gotShot = true; shotSize = shot->size(); } + + if (frame >= totalFrames) canvas.close(); + }; + auto ui = [&](float) { ImGui::Begin("smoke"); ImGui::Text("f%d", frame); ImGui::End(); }; + + canvas.run(update, ui); + + std::printf("smoke done: frames=%d gotShot=%d shot=%dx%d\n", frame, gotShot ? 1 : 0, shotSize.x, shotSize.y); + return (frame >= totalFrames && gotShot) ? 0 : 1; +} diff --git a/tools/sanitizers/tsan.supp b/tools/sanitizers/tsan.supp new file mode 100644 index 00000000..6eed0c59 --- /dev/null +++ b/tools/sanitizers/tsan.supp @@ -0,0 +1,22 @@ +# ThreadSanitizer suppressions for nothofagus sanitizer runs against SwiftShader (headless +# Vulkan). These target KNOWN-BENIGN noise from the software Vulkan ICD / validation layer / +# the ImGui Vulkan backend's single-threaded init & shutdown — NOT nothofagus's own sim/render +# synchronization (mImguiMutex, mThreadedAssetMutex, the scheduled-screenshot channel, etc.). +# +# Keep this list tight: only third-party / init-teardown noise belongs here. A race in +# nothofagus code must be fixed, never suppressed. + +# SwiftShader / Vulkan ICD internals (its own JIT'd worker-thread pool). +called_from_lib:libvk_swiftshader.so +deadlock:libvk_swiftshader.so +race:libvk_swiftshader.so + +# Khronos validation layer (own worker thread + shared_mutex), if it is ever enabled. +called_from_lib:libVkLayer_khronos_validation.so +deadlock:libVkLayer_khronos_validation.so +race:libVkLayer_khronos_validation.so + +# ImGui Vulkan backend create/destroy: locks taken inside vkCreate*/vkDestroy* during the +# single-threaded Canvas construction/teardown (cannot deadlock — never run concurrently). +deadlock:ImGui_ImplVulkan_Init +deadlock:ImGui_ImplVulkan_Shutdown From 147246d033165222f64c462b7581c4b532d6dce3 Mon Sep 17 00:00:00 2001 From: Daniel Calderon <21376367+dantros@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:03:53 -0700 Subject: [PATCH 55/55] docs(threaded): document the SwiftShader TSan/ASan sanitizer recipe --- THREADED_MODE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/THREADED_MODE.md b/THREADED_MODE.md index 320ebea2..b39c252c 100644 --- a/THREADED_MODE.md +++ b/THREADED_MODE.md @@ -158,6 +158,16 @@ in-tree callers of the `[[deprecated]] run(update, Controller&)` overload left. `VUID-` / `SYNC-HAZARD` messages. - SwiftShader Vulkan headless goldens unchanged (this is the CI lane): `linux-release-headless-vulkan-tests` + `NOTHOFAGUS_RENDER_BACKEND=swiftshader .../rendering_tests`. +- **TSan + ASan/UBSan on the threaded path — against SwiftShader, not the GPU:** `tools/sanitizers/run_sanitizers.sh` + (builds a headless-Vulkan + SwiftShader fixture, `tools/sanitizers/threaded_smoke.cpp`, with the sanitizer + flags and runs it). Expect **0 sanitizer reports** and a clean exit. Two things to know: + - **Mesa is buggy under sanitizers** — run the sanitizers on SwiftShader (deterministic CPU Vulkan) only; + do **not** point them at the system GPU. + - The validation layer is disabled for the run (`VK_LOADER_LAYERS_DISABLE`) and TSan uses + `tools/sanitizers/tsan.supp` to filter SwiftShader-ICD / ImGui-Vulkan init-teardown noise. That file is + for third-party/init noise **only** — a race in nothofagus code must be fixed, never suppressed. + - TSan under SwiftShader is slow (it instruments SwiftShader's worker pool), so the fixture is small and + self-closing; give it a generous timeout. - Any new cross-thread shared state must be mutex- or atomic-guarded like the existing marshal channels (`mThreadedGamepadState`/`mThreadedGameInputState`, the `mThreadedCursor` atomic, the `mClipboardMutex` - payload) — argue race-freedom by mirroring those. + payload, `HeadlessBackend::mRunning`) — argue race-freedom by mirroring those, and confirm with TSan.