diff --git a/CLAUDE.md b/CLAUDE.md index 78da2be8..ce0e866b 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_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. 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_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/CMakeLists.txt b/CMakeLists.txt index 0d593353..ecbb428a 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" @@ -377,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/THREADED_MODE.md b/THREADED_MODE.md new file mode 100644 index 00000000..b39c252c --- /dev/null +++ b/THREADED_MODE.md @@ -0,0 +1,173 @@ +# 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` (first commit `71805e8`). + +## 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. +- **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 + `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]]`. + +**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. 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 + +- _(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 + +- **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-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`. +- **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, `HeadlessBackend::mRunning`) — argue race-freedom by mirroring those, and confirm with TSan. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 4206221c..28bc12fc 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -154,3 +154,28 @@ 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) + +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) + +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) + +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_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_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_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_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_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_dpi_scaling.cpp b/examples/hello_dpi_scaling.cpp index 61dc374f..f96fea37 100644 --- a/examples/hello_dpi_scaling.cpp +++ b/examples/hello_dpi_scaling.cpp @@ -48,10 +48,17 @@ int main() char nameBuf[64] = "Nothofagus"; float time = 0.0f; - 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; @@ -138,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); @@ -154,7 +160,9 @@ int main() ImGui::ProgressBar(0.5f + 0.5f * std::sin(0.003f * time)); ImGui::End(); }); - }); + }; + + canvas.run(update, ui); return 0; } diff --git a/examples/hello_imgui_image_registry.cpp b/examples/hello_imgui_image_registry.cpp index 50a8a129..aaced6d6 100644 --- a/examples/hello_imgui_image_registry.cpp +++ b/examples/hello_imgui_image_registry.cpp @@ -64,10 +64,11 @@ int main() float drawScale = 1.0f; bool registered = true; - 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) @@ -76,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."); @@ -116,7 +121,9 @@ int main() } ImGui::End(); - }); + }; + + canvas.run(update, 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_imgui_rtt.cpp b/examples/hello_imgui_rtt.cpp index c7baa61a..9a5fd338 100644 --- a/examples/hello_imgui_rtt.cpp +++ b/examples/hello_imgui_rtt.cpp @@ -34,13 +34,19 @@ int main() Nothofagus::ImguiFontId small16Id{}; bool wantSmall16 = false; - 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 @@ -108,7 +114,9 @@ int main() wantSmall16 = false; } ImGui::End(); - }); + }; + + canvas.run(update, ui); return 0; } diff --git a/examples/hello_imgui_visual.cpp b/examples/hello_imgui_visual.cpp index 7d554447..3281d67a 100644 --- a/examples/hello_imgui_visual.cpp +++ b/examples/hello_imgui_visual.cpp @@ -128,10 +128,12 @@ int main() float scale = 8.0f; float elapsedMs = 0.0f; - 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}; @@ -140,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); @@ -210,7 +214,9 @@ int main() canvas.imguiImage(sceneRtImageId); ImGui::End(); - }); + }; + + canvas.run(update, ui); return 0; } 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_markdown.cpp b/examples/hello_markdown.cpp index 3f6b77b8..7c47fae5 100644 --- a/examples/hello_markdown.cpp +++ b/examples/hello_markdown.cpp @@ -196,16 +196,21 @@ wider than the window, so it overflows — drag the horizontal scrollbar to pan: float elapsedMs = 0.0f; - 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"); @@ -221,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(); - }); + }; + + canvas.run(update, 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_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_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/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_screenshot.cpp b/examples/hello_screenshot.cpp index b59834c6..d7d3fa6b 100644 --- a/examples/hello_screenshot.cpp +++ b/examples/hello_screenshot.cpp @@ -144,23 +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(); }); - 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; } 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; } 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 diff --git a/examples/hello_threaded.cpp b/examples/hello_threaded.cpp new file mode 100644 index 00000000..8f397f37 --- /dev/null +++ b/examples/hello_threaded.cpp @@ -0,0 +1,206 @@ +// hello_threaded — two-thread sim/render split (M2). +// +// 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. +// +// Phase A established the snapshot hand-off with a static scene (the sim only +// 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 + +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 with runtime spawn/despawn churn"); + + const 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, + }); + struct Sprite + { + Nothofagus::BellotaId id; + glm::vec2 home; + float phase; + float age; // ms + float lifespan; // ms + }; + std::vector sprites; // sim-thread-only ownership + Lcg rng{0x1234567u}; + + 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; + + // 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) + { + simTime += dt; + + // 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.removeBellota(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); + 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); + + // 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(); + } + + // 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; + controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + canvas.close(); + }); + + 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/examples/hello_threaded_dense_land.cpp b/examples/hello_threaded_dense_land.cpp new file mode 100644 index 00000000..f8b2502e --- /dev/null +++ b/examples/hello_threaded_dense_land.cpp @@ -0,0 +1,175 @@ +// 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 + 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 + { + 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); + + // 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); + + 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/examples/hello_threaded_gamepad.cpp b/examples/hello_threaded_gamepad.cpp new file mode 100644 index 00000000..c3f9b861 --- /dev/null +++ b/examples/hello_threaded_gamepad.cpp @@ -0,0 +1,181 @@ +// 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 +// 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 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. +// +// 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 +#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}); + + // 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; + 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; }); + + // 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); + + // 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; + } + + // 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); + + 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/examples/hello_threaded_imgui.cpp b/examples/hello_threaded_imgui.cpp new file mode 100644 index 00000000..deca4543 --- /dev/null +++ b/examples/hello_threaded_imgui.cpp @@ -0,0 +1,211 @@ +// 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); + + // 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; + 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; + char noteBuf[64] = "type here (Ctrl+C/V works)"; + + 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.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)}); + }; + + 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); + + // 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]; + if (worldActive) sprite.age += dt; + const bool expired = worldActive && sprite.age >= sprite.lifespan; + const bool overTarget = static_cast(i) >= targetCount; + if (expired || overTarget) + { + canvas.removeBellota(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; + } + + // Refill toward the target — paused (with aging, above) while interacting. + if (spawning && worldActive) + { + 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; + + // 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(); + }; + + Nothofagus::Controller controller; + controller.registerAction({Nothofagus::Key::ESCAPE, Nothofagus::DiscreteTrigger::Press}, [&]() + { + 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([&]() + { + 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/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/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 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 607cdaef..fba10597 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; @@ -41,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; @@ -72,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; } @@ -101,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"); @@ -126,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; } 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 diff --git a/include/canvas.h b/include/canvas.h index 61141583..3786d256 100644 --- a/include/canvas.h +++ b/include/canvas.h @@ -114,10 +114,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); @@ -670,28 +674,133 @@ 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 + /// 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); + + /// 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); 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. + // + // 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)`. 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); + + /// Thread-safe: true until the window is closed. Drives both loop conditions. + bool isThreadedRunning() const; + + /// 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); + + /// 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); + + /// 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); + + /// 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. + /// 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; + /// 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/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/asset_registry.cpp b/source/asset_registry.cpp index ac4e7d77..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) @@ -205,6 +206,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 +331,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/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..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,13 +80,22 @@ ScreenSize HeadlessBackend::getWindowSize() const void HeadlessBackend::requestClose() { - mRunning = false; + mRunning.store(false, std::memory_order_release); } 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..b3a836f4 100644 --- a/source/backends/headless_backend.h +++ b/source/backends/headless_backend.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace Nothofagus { @@ -53,6 +54,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(); @@ -60,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 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/canvas.cpp b/source/canvas.cpp index 3c933339..f005a5a5 100644 --- a/source/canvas.cpp +++ b/source/canvas.cpp @@ -121,19 +121,19 @@ 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(); } -const ScreenSize& Canvas::screenSize() const { return mImplPtr->frameRunner.screenSize(); } +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); } void Canvas::setTargetFps(std::optional targetFps) { mImplPtr->frameRunner.setTargetFps(targetFps); } 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 @@ -155,17 +155,17 @@ 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); } -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); } @@ -173,19 +173,19 @@ 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::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::setTexture(const BellotaId bellotaId, const TextureId textureId) { mImplPtr->frameRunner.setTexture(mImplPtr->assets, bellotaId, textureId); } +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); } @@ -193,10 +193,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); } @@ -206,16 +206,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) @@ -273,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); } @@ -386,22 +405,41 @@ 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); } +void Canvas::run(std::function update, std::function uiCallback, + Controller& simController, Controller& renderController) +{ + mImplPtr->frameRunner.runThreaded(*this, mImplPtr->assets, mImplPtr->imguiRtt, mImplPtr->imguiImages, + 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, mImplPtr->imguiImages, + 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); @@ -424,6 +462,56 @@ void Canvas::close() mImplPtr->frameRunner.close(); } +// --------------------------------------------------------------------------- +// Threaded driver (two-thread sim/render split) — forward to FrameRunner +// --------------------------------------------------------------------------- + +void Canvas::beginThreadedSession(Controller& controller) +{ + mImplPtr->frameRunner.beginThreadedSession(*this, 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::commit(float deltaTime, std::function update, std::function uiCallback) +{ + 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::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); +} + +bool Canvas::imguiWantsMouse() const +{ + return mImplPtr->frameRunner.threadedWantsMouse(); +} + +bool Canvas::imguiWantsKeyboard() const +{ + return mImplPtr->frameRunner.threadedWantsKeyboard(); +} + void Canvas::requestScreenshot() { mImplPtr->frameRunner.requestScreenshot(); 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 d59cbdba..09588ac4 100644 --- a/source/frame_runner.cpp +++ b/source/frame_runner.cpp @@ -15,6 +15,7 @@ #include #include #include +#include "imgui_draw_clone.h" #include "imgui_overlay.h" #include "backends/window_backend.h" #include @@ -30,6 +31,34 @@ namespace Nothofagus { +// 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*) +{ + // 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 FrameRunner::threadedSetClipboardText(ImGuiContext*, const char* 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; // defined here so the backend headers are only included from this translation unit. struct FrameRunner::Window : public SelectedWindowBackend @@ -56,13 +85,18 @@ 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. 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 presentModeToSwapInterval(mPresentMode) ); @@ -86,9 +120,10 @@ FrameRunner::FrameRunner( // image from this size); the windowed policy ignores it and sizes the swapchain from // the surface. Passing the unscaled logical size sized the headless offscreen image // smaller than the device-sized capture region -> out-of-bounds copy at pixelSize > 1. + // (mScreenSize is atomic on this branch, so use the ctor's local screenSize param.) mBackend.initialize(mWindow->nativeHandle(), - {static_cast(mScreenSize.width * mPixelSize), - static_cast(mScreenSize.height * mPixelSize)}, mPresentMode); + {static_cast(screenSize.width * mPixelSize), + static_cast(screenSize.height * mPixelSize)}, mPresentMode); mBackend.initImGuiRenderer(); // Font setup happens after construction at the Canvas level — once `mAssets` @@ -101,6 +136,22 @@ 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; + } + + // 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) @@ -152,6 +203,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(); @@ -190,20 +249,59 @@ ScreenSize FrameRunner::windowSize() const return mWindow->getWindowSize(); } +void FrameRunner::debugCheckRenderThread(const char* op) const +{ +#ifndef NDEBUG + // 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 " + "(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 +} + void FrameRunner::requestScreenshot() { - mScreenshotArmed = true; + // 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). // Capture at the device resolution (screenSize * pixelSize), preserving the pixelSize // amplification and any sub-pixel bellota positioning it affords. Only the OS content // scale (DPI) is normalized out: the windowed capture downsamples the framebuffer // (device * osScale) to this device size, and headless runs at osScale == 1. - const glm::ivec2 gameSize{static_cast(mScreenSize.width * mPixelSize), - static_cast(mScreenSize.height * mPixelSize)}; + mScreenshotArmed = true; + const ScreenSize screen = mScreenSize.load(std::memory_order_acquire); + const glm::ivec2 gameSize{static_cast(screen.width * mPixelSize), + static_cast(screen.height * mPixelSize)}; mBackend.armScreenshot(gameSize); } std::optional FrameRunner::retrieveScreenshot() { + std::lock_guard lock(mScreenshotResultMutex); return std::exchange(mScreenshotResult, std::nullopt); } @@ -313,8 +411,10 @@ void FrameRunner::runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttMan { ZoneScopedN("runOneFrame"); - const RenderSnapshot& snapshot = buildSnapshot(canvas, assets, imguiRtt, imguiImages, deltaTimeMS, update, controller); - renderSnapshot(assets, imguiRtt, imguiImages, snapshot, deltaTimeMS, controller); + // Single-threaded frame: producer and consumer back-to-back on this thread. + const RenderSnapshot& snapshot = produce(FrameMode::Single, &canvas, assets, &imguiRtt, &imguiImages, + deltaTimeMS, std::move(update), {}, &controller); + consume(FrameMode::Single, assets, imguiRtt, &imguiImages, snapshot, deltaTimeMS, 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. @@ -325,7 +425,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; } } @@ -333,45 +436,247 @@ void FrameRunner::runOneFrame(Canvas& canvas, AssetRegistry& assets, ImguiRttMan FrameMark; } -const RenderSnapshot& FrameRunner::buildSnapshot(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, - ImguiImageManager& imguiImages, - float deltaTimeMS, std::function update, Controller& controller) +// 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, + std::function update, + std::function uiCallback, + Controller* controller) { + if (mode == FrameMode::Threaded) + { + // 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; + + // 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("FeedInput"); + feedGamepadInput(*controller); + 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.) + { + ZoneScopedN("UserUpdate"); + 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; + // 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. + { + 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 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); + + // 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. + // 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) + { + 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); + 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); + + // 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 + // 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); + + // 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()); + + // 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. + { + ZoneScopedN("DepthSort"); + buildMainDraws(assets.bellotas(), snapshot.draws); + } + { + 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(); + 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(); } - // Advance the ImGui-image clock before the user update issues imguiImage() draws. - imguiImages.beginFrame(); + // Advance the ImGui-image clock before the user update issues ImGui-image draws. + // (Single arm — imguiImages is non-null here.) + imguiImages->beginFrame(); // Drain any deferred ImGui font ops (bake-on-miss / remove) accumulated // since the previous frame. Atlas is guaranteed unlocked here — between // 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(); 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. 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"); @@ -380,19 +685,20 @@ 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; + mSnapshot.screenSize = singleScreen; if (mAutoTextureGC) for (TextureId textureId : assets.collectUnusedTextures()) @@ -411,9 +717,14 @@ const RenderSnapshot& FrameRunner::buildSnapshot(Canvas& canvas, AssetRegistry& ZoneScopedN("RttGather"); buildRttPasses(assets, mSnapshot.rttPasses); // Internal RTT passes for registered ImGui images that need (re)rendering this frame. - imguiImages.appendInternalPasses(mSnapshot.rttPasses); + 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; } @@ -442,7 +753,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) { @@ -462,21 +773,41 @@ 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::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRtt, - ImguiImageManager& imguiImages, - const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller) +void FrameRunner::renderSnapshotContents(AssetRegistry& assets, ImguiRttManager& imguiRtt, + ImguiImageManager* imguiImages, + const RenderSnapshot& snapshot) { - 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. + // 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, mLastRenderedSeq); + 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()) @@ -495,8 +826,6 @@ void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRt meshPack.syncToGpu(mBackend); } - const glm::mat3 worldTransformMat = computeWorldTransformMat(mScreenSize); - { ZoneScopedN("RttPasses"); // RTT pre-passes — render the snapshot's RTT draw lists into their render @@ -528,22 +857,207 @@ void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRt // 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. - 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); + // 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(); } + // 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); { ZoneScopedN("MainDraw"); drawItems(snapshot.draws, assets.textures(), assets.meshes(), worldTransformMat, mBackend); } +} + +void FrameRunner::consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager& imguiRtt, + ImguiImageManager* imguiImages, + const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller) +{ + 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(); + } + + // 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; + // 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); + + // 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. Capture at the device resolution (snapshot.screenSize * + // pixelSize), matching the single-threaded requestScreenshot() path (#114): the + // headless offscreen image is device-sized, so a logical size here would drive an + // out-of-bounds copy / crash at pixelSize > 1. mScreenshotArmed 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 * mPixelSize), + static_cast(threadedScreen.height * mPixelSize)}); + } + + // 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(); + // 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 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); + 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. + ImGui::SetMouseCursor(static_cast(mThreadedCursor.load(std::memory_order_acquire))); + beginMainImguiFrame(); + harvestImguiInput(); // io.MousePos/Down/Wheel + DisplaySize are valid post-NewFrame + ImGui::Render(); + } + + // 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 = [&] + { + ZoneScopedN("ImGuiRender"); + 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, 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("PreMain"); // uploads + sprite RTT + resolve + std::lock_guard assetLock(mThreadedAssetMutex); + renderSnapshotPreMain(assets, imguiImages, snapshot); + } + if (not snapshot.rttUi.empty()) // diegetic RTT: atlas draw + { + 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(); + } + + { + ZoneScopedN("SwapBuffers"); + mWindow->endFrame(controller, threadedScreen); + } + + // Snapshot the render controller's freshly-polled input (gamepad + keyboard + // + mouse) for the sim thread to replay onto its own controller next commit. + { + ZoneScopedN("HarvestInput"); + harvestGamepadInput(controller); + harvestGameInput(controller); + } + + 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, imguiImages, snapshot); if (mStats) { @@ -567,6 +1081,7 @@ void FrameRunner::renderSnapshot(AssetRegistry& assets, ImguiRttManager& imguiRt } } + void FrameRunner::run(Canvas& canvas, AssetRegistry& assets, ImguiRttManager& imguiRtt, ImguiImageManager& imguiImages, std::function update, Controller& controller) @@ -596,6 +1111,47 @@ 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 + 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([&]() + { + // 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(); + mThreadedImguiImages = nullptr; + mThreadedImguiRtt = nullptr; +} + void FrameRunner::limitFrameRate(std::chrono::steady_clock::time_point& nextDeadline) { using namespace std::chrono; @@ -642,6 +1198,504 @@ void FrameRunner::close() mWindow->requestClose(); } +// --------------------------------------------------------------------------- +// Threaded driver (M2 Phase A: snapshot hand-off; Phase B: runtime resource +// mutation guarded by mThreadedAssetMutex) +// --------------------------------------------------------------------------- + +void FrameRunner::beginThreadedSession(Canvas& canvas, Controller& controller) +{ + // Remember the canvas so produce(Threaded) can drive the explorer pre-pass. + mThreadedCanvas = &canvas; + + // 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). + mWindow->beginSession(controller); + if (!mSessionStarted) + { + mSnapshot.draws.reserve(64); + mSessionStarted = true; + } + // 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 + // 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); + 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 + // 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 + 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 + // 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; + // 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 = &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 + // 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. + auto [fbW, fbH] = mWindow->getFramebufferSize(); + const ViewportRect viewport = computeLetterboxViewport(fbW, fbH, primingScreen.width, primingScreen.height); + mBackend.beginFrame(mClearColor, viewport, fbW, fbH); + beginMainImguiFrame(); + 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); + } + + mThreadedRunning.store(true, std::memory_order_release); +} + +void FrameRunner::commitFrame(AssetRegistry& assets, float deltaTimeMS, + std::function update, std::function uiCallback) +{ + // 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, mThreadedImguiRtt, mThreadedImguiImages, 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, mThreadedCanvas, assets, mThreadedImguiRtt, mThreadedImguiImages, 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, mThreadedImguiRtt, mThreadedImguiImages, deltaTimeMS, + std::move(update), std::move(uiCallback), &simController); +} + +void FrameRunner::renderFrameThreaded(AssetRegistry& assets, ImguiRttManager& imguiRtt, Controller& controller) +{ + ZoneScopedN("renderFrameThreaded"); + + // Pick up the freshest published snapshot (keeps the previous one if none new). + mTripleBuffer.acquire(); + const RenderSnapshot& snapshot = mTripleBuffer.readSlot(); + + // dt is recomputed from the window clock inside the Threaded arm. + 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. + // Stays armed if the frame was skipped (e.g. swapchain recreation returned no image). + if (mScreenshotArmed) + { + if (std::optional pixels = mBackend.finishScreenshot()) + { + TextureData textureData(pixels->width, pixels->height, 1); + std::copy(pixels->data.begin(), pixels->data.end(), textureData.getDataSpan().begin()); + { + std::lock_guard lock(mScreenshotResultMutex); + mScreenshotResult = DirectTexture(std::move(textureData)); + } + mScreenshotArmed = false; + } + } + + FrameMark; +} + +void FrameRunner::harvestImguiInput() +{ + 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; + 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; + + // 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; + const bool isRealKey = (key < ImGuiKey_MouseLeft); + mThreadedImguiInput.keyDown[index] = + 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; + 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]); + } +} + +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(); +} + +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 + // 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::removeBellota(AssetRegistry& assets, BellotaId bellotaId) +{ + // 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); +} + +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); +} + +// 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(); + 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 1ded4a95..78647ed6 100644 --- a/source/frame_runner.h +++ b/source/frame_runner.h @@ -7,6 +7,8 @@ #include "explorer_manager.h" #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 @@ -15,9 +17,14 @@ #include #include #include +#include +#include +#include #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 { @@ -108,8 +115,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; } /// Frame-rate cap for run(); a present value <= 0 is normalized to nullopt (unlimited). void setTargetFps(std::optional targetFps) { mTargetFps = (targetFps && *targetFps > 0.0f) ? targetFps : std::nullopt; } @@ -155,37 +165,197 @@ 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, + ImguiImageManager& imguiImages, + std::function update, std::function uiCallback, + Controller& simController, Controller& renderController); + /// Arm a screenshot of the next rendered frame (captured in-frame before present). void requestScreenshot(); /// Consume the captured frame once it is ready; nullopt until the armed frame renders. std::optional retrieveScreenshot(); + // ----- Threaded driver (M2 Phase A) ----- + // The app runs two threads: `commitFrame` on a sim thread and + // `renderFrameThreaded` on the main thread. nothofagus spawns nothing. + // 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, 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); } + + /// 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; + + /// 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 + /// publish. `uiCallback` may be empty (no ImGui). No GL. + 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); + + /// 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); + + /// 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); + + // ----- 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); + 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); + 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); + + /// 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. 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 + /// 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. + 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); + + /// 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, 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, - ImguiImageManager& imguiImages, - 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, - ImguiImageManager& imguiImages, - 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`, + /// `imguiImages`, 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, ImguiImageManager* imguiImages, + 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. `imguiImages` is null in `Threaded` + /// (imguiVisual is single-threaded only for now). + void consume(FrameMode mode, AssetRegistry& assets, ImguiRttManager& imguiRtt, + ImguiImageManager* imguiImages, + const RenderSnapshot& snapshot, float deltaTimeMS, Controller& controller); + + /// 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. 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); + + /// 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 @@ -194,18 +364,25 @@ 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. 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(); + /// If a target FPS is set, wait until `nextDeadline` (hybrid sleep + short busy-spin) /// and advance it by one frame period; otherwise just refresh `nextDeadline` to now. /// Uses steady_clock directly (monotonic, no float drift over long sessions). void limitFrameRate(std::chrono::steady_clock::time_point& nextDeadline); - 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. @@ -220,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). @@ -231,21 +415,172 @@ 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. /// 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; - - int mFramebufferWidth{0}; ///< Framebuffer size captured in buildSnapshot, reused by renderSnapshot. + 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}; + // ----- 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). + 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. + 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 + /// 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) + /// 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. + // 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), + /// 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 + + // 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) + // 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) + int textCharCount{0}; + bool focused{true}; + }; + 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; + + // ----- 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 + /// the UI frame), which is the correct, expected behavior. + 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}; + + // ----- 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 + /// 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/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. 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 new file mode 100644 index 00000000..e5b44b32 --- /dev/null +++ b/source/render_snapshot.cpp @@ -0,0 +1,19 @@ +#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; + +// 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 bde3ebf7..65bccc04 100644 --- a/source/render_snapshot.h +++ b/source/render_snapshot.h @@ -3,13 +3,17 @@ #include #include #include +#include #include "texture_id.h" #include "mesh.h" // MeshId #include "render_target.h" // RenderTargetId +#include "screen_size.h" // ScreenSize 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. @@ -20,10 +24,10 @@ namespace Nothofagus * `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 @@ -55,13 +59,44 @@ 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 + /// 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; + + 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/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 +}; + +} 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(); } 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. 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