Skip to content

Thread aware phase2 unification - #104

Draft
dantros wants to merge 61 commits into
mainfrom
thread_aware_phase2_unification
Draft

Thread aware phase2 unification#104
dantros wants to merge 61 commits into
mainfrom
thread_aware_phase2_unification

Conversation

@dantros

@dantros dantros commented Jun 22, 2026

Copy link
Copy Markdown
Owner

No description provided.

dantros added 10 commits June 19, 2026 19:14
…presence

The sim-UI context enables keyboard nav, which made ImGui set
WantCaptureKeyboard true whenever a panel had nav focus — pinning
imguiWantsKeyboard() at true and making it useless for gating game input.
Set ConfigNavCaptureKeyboard = false so capture reflects an actively-edited
widget or open modal; nav (arrows/Tab/Enter), InputText, and clipboard are
unaffected.
Collapse the duplicated buildSnapshot/renderSnapshot (run/tick) and
commitFrame/renderFrameThreaded (commit/renderFrame) orchestration into
one produce(FrameMode) + consume(FrameMode), gated by Single/Threaded.
Extract the shared main-context ImGui setup into beginMainImguiFrame().
Entry points become thin call sites; dead buildSnapshot/renderSnapshot
removed. No public API or behavior change.

Goldens 17/17 (release+debug), OpenGL/Vulkan/SDL3 build, threaded demos
TSan/ASan clean.
@dantros
dantros changed the base branch from main to thread_aware_phase2 June 22, 2026 04:03
dantros added 5 commits June 21, 2026 21:41
Resolve frame_runner conflicts: thread main's new ImguiImageManager
(imguiVisual feature, #102) through this branch's unified produce/consume/
renderSnapshotContents methods. imguiImages is passed as a pointer (matching
the branch's Single-only dependency convention) — non-null on the
single-threaded run/tick path, nullptr on the threaded commit/render path
where imguiVisual is not yet wired (resolveAndGarbageCollect is guarded).
@dantros
dantros changed the base branch from thread_aware_phase2 to main June 22, 2026 07:27
dantros added 12 commits June 22, 2026 01:17
…Bellota/removeBellota that's safe in both single-threaded and live-threaded-session contexts
…m the sim thread during a live threaded session.
# Conflicts:
#	source/frame_runner.cpp
…cursor is marshalled to the render thread and applied before its main-context NewFrame, so I-beam/resize cursors work over threaded ImGui (B5). Reuses the platform backend's existing cursor handling — no backend changes; render-backend-agnostic.
…amepad on the main context (so the platform backend populates the ImGuiKey_Gamepad* keys that are already harvested+replayed) and on the sim-UI context (+HasGamepad) to consume them. Threaded-only (set in beginThreadedSession); single-threaded unchanged. Digital D-pad/face-button nav; analog stick nav is a documented follow-up (bool-only marshal).
…d getClipboardText/setClipboardText to the WindowBackend concept (GLFW glfwGet/SetClipboardString, SDL3 SDL_Get/SetClipboardText, Headless no-op). The sim-UI clipboard callbacks (sim thread) read/write a mutex-guarded marshal; the render thread flushes sim->OS writes immediately and refreshes the OS->sim cache throttled (~0.25s) to avoid a per-frame X11 round-trip. Replaces the in-process-only clipboard.
RenderSnapshot gains a ScreenSize field, stamped in both produce() arms.
consume(Threaded) and renderSnapshotContents now derive the viewport, world
transform, and endFrame size from the snapshot rather than the live mScreenSize
atomic, so the render thread draws each snapshot's explorer pool in the exact
logical size it was committed at. This removes the 1-frame letterbox transient
that A4's threaded setScreenSize otherwise produced (render was using the live
size while drawing the previous snapshot's pool).

Priming / never-produced triple-buffer slots are default-constructed with
screenSize {0,0}; reading that directly divides by a zero aspect ratio in
computeLetterboxViewport / computeWorldTransformMat (caught as a UBSan error in
verification). Both read sites fall back to the live atomic when the snapshot
size is degenerate. Single mode always stamps a valid size, so the fallback is
a no-op there.

Verified: builds across glfw/sdl3 x opengl/vulkan + headless; SwiftShader
goldens 21/21; nonvisual tests pass; TSan clean on GLFW and SDL3 (no
snapshot-path race; only environmental driver/libc noise); ASan/UBSan 0 errors.
B6 enabled ImGui gamepad nav on the threaded path but only marshalled digital
key state (bool keyDown via IsKeyDown/AddKeyEvent), so the analog gamepad keys
(L2/R2 triggers, L/R stick directions) crossed the render->sim boundary as mere
on/off — losing ImGui's smooth, continuous gamepad nav.

ThreadedImguiInput gains a float keyAnalog[] side-channel (same indexing as
keyDown). harvestImguiInput now captures io.KeysData[index].AnalogValue (the
public per-key state array) alongside the down bit, and the sim-side replay
routes the analog gamepad keys through AddKeyAnalogEvent instead of AddKeyEvent
(selected by a small isAnalogNavKey helper covering GamepadL2/R2 and the
GamepadL/RStick* range). Digital keys still replay via AddKeyEvent.

Render-backend-independent (the platform backend already populates the analog
values on the main context). Verified: builds across glfw/sdl3 x opengl/vulkan
+ headless; SwiftShader goldens 21/21; TSan clean on GLFW and SDL3 (0 races in
our code); ASan/UBSan 0 errors.
…es (C8)

On the threaded path the stats overlay was drawn on the render-thread main
context, but when an app commits its own ImGui the sim-UI clone is what gets
presented — so stats only showed for apps with no uiCallback (e.g. the gamepad
demo). Move the stats window into the sim-UI frame (produce, after the user
uiCallback, before Render) so it lands in the cloned draw data and shows
uniformly, with or without app ImGui.

The overlay now reports both rates: the render cadence is marshalled to the sim
via mRenderFps/mRenderMs atomics (written each consume from the existing
mThreadedPerfMonitor), and the sim commit rate comes from a new sim-side
PerformanceMonitor fed by an accumulated commit clock (the sim thread has no
window clock). The render-thread main-context stats block is removed.

hello_threaded_imgui now sets canvas.stats() = true to demonstrate the case
(a uiCallback app) that previously hid the overlay.

Verified: builds across glfw/sdl3 x opengl/vulkan + headless; SwiftShader
goldens 21/21; TSan clean on GLFW and SDL3 (0 races in our code — the sim
PerformanceMonitor is sim-exclusive, the render cadence crosses via atomics);
ASan/UBSan 0 errors.
@dantros
dantros force-pushed the thread_aware_phase2_unification branch from cdf83da to ab23b6f Compare June 24, 2026 05:39
dantros added 30 commits July 4, 2026 20:10
Window/monitor ops require the main thread in every mode (GLFW/SDL), so the
render-thread guard no longer needs the mThreadedRunning gate. Anchor
mRenderThreadId at construction (the Canvas ctor runs on the main thread, where
the window is created) and assert unconditionally, so a window op invoked off
the main thread is caught single-threaded and outside a session too — not only
during a live threaded session.

The sim-thread guard stays session-scoped: a distinct sim thread only exists
while a threaded session is live, and the captured sim id would be stale during
post-session teardown (where main-thread cleanup legitimately touches the
scene), so gating it on mThreadedRunning is the correct, race-free predicate.
beginThreadedSession drops its now-redundant render-id capture (still resets the
sim id per session).

Verified (debug, guards active): single-thread window op on the main thread
passes; the same op from the sim thread trips with the message; all threaded +
single-thread demos run clean. Release compiles the guards out; goldens 21/21.
Feeds the sim controller AND runs the ImGui uiCallback in one commit (produce
already supports both). Backs the run(...) convenience.
…enience

FrameRunner::runThreaded owns the sim thread + both loops (commit on the sim
thread, renderFrame on the main thread, join on close); internal PerformanceMonitor
(steady_clock) + limitFrameRate. Makes the two-thread split a one-liner.
…erController)

ImGui moved from update to a ui callback; controller split into simController
(W/S/A/D/SPACE/Q game input) and renderController (F fullscreen, ESC close).
Now a genuine two-thread app via the run(...) convenience.
…nification

# Conflicts:
#	source/canvas.cpp
#	source/frame_runner.cpp
#	source/frame_runner.h
Wave A: hello_animation, hello_text, hello_render_to_texture,
hello_nested_render_targets. renderTo passes ride the frame snapshot,
so they work threaded; empty ui callback (no ImGui/controllers).
Wave B: hello_tint, hello_mesh, hello_imgui_overlay, hello_custom_font.
Scene logic stays in update (sim); ImGui moves to the ui callback.
Flags shared between the two are plain (both run on the sim thread).
…oller demos

Wave C: hello_layers, hello_direct_texture, hello_animation_state_machine,
test_create_destroy, hello_tilemap, test_gamepad. Game input + gamepad ->
simController; ESC/window ops -> renderController; display ImGui -> ui.
hello_screenshot deferred: threaded screenshot needs cross-thread
request/result marshaling that isn't built yet.
…, ui, sim, render)

Wave D: hello_dense_land, hello_sparse_land. Camera pan + streaming/edit
storm -> update; interactive ImGui (incl. addChunk/removeChunk/rebuild/
setScreenSize asset ops) -> ui; WASD -> simController, ESC -> renderController.
TSan clean on hello_dense_land (sim-thread sharing race-free).
…recate run(update, Controller&)

run() and run(update) now forward to the sim/render split (empty ui +
controllers); the single-controller run(update, Controller&) is marked
[[deprecated]] but kept as the single-thread survivor. The six demos whose
threaded support isn't built yet (registered ImGui images, diegetic
renderImguiTo, scheduled screenshot) are parked on it with TODOs; each
emits the intended deprecation warning. Goldens 25/25.
Thread ImguiImageManager through runThreaded/commitFrame/renderFrameThreaded,
call beginFrame + appendInternalPasses in the Threaded produce arm, guard the
registry under the asset mutex, and defer GPU handle/RTT frees to the render
thread. Migrate hello_imgui_visual / hello_imgui_image_registry / hello_markdown
to run(update, ui).
…1 verification

Registered-image threading verified end-to-end:
- Builds --parallel across glfw-opengl, glfw-vulkan, sdl3-vulkan, sdl3-opengl
  (library + the 3 migrated demos) and the headless-vulkan test lib.
- xvfb-run smoke of hello_imgui_visual / hello_imgui_image_registry /
  hello_markdown: render live, clean close (exit 124 timeout, no asserts).
- TSan (glfw-opengl) on hello_imgui_image_registry + hello_imgui_visual under
  xvfb: 0 ThreadSanitizer warnings — the per-frame updateImguiImage -> render
  resolveImages sharing and the teardown retire-drain are race-free.
- SwiftShader goldens unchanged: 25/25 (50 assertions).

Flip the registered-image row to Supported in THREADED_DIEGETIC_IMGUI.md and
update the THREADED_MODE.md per-demo caveats; diegetic renderImguiTo stays the
only deferred ImGui feature.
Generalize the main-context ImGui clone to N secondary RTT contexts. Split
ImguiRttManager::flushPending into produceClones (sim: run each renderImguiTo
callback on its secondary context, deep-clone its draw data into the snapshot's
new rttUi list) and replayClones (render: lazy backend init + RenderDrawData of
the clones, no user code). Wire the RTT manager through the threaded produce arm
(mThreadedImguiRtt); the render consume now holds the ImGui mutex outer of the
asset mutex so the RTT-clone atlas access matches the sim's imgui>asset order and
can't deadlock. Migrate hello_imgui_rtt / hello_dpi_scaling to run(update, ui).
… verification

Diegetic renderImguiTo threading verified end-to-end:
- Builds --parallel across glfw-opengl, glfw-vulkan, sdl3-vulkan, sdl3-opengl
  (all examples). Only remaining deprecation warning is hello_screenshot (its
  screenshot request/result handoff is a separate, still-deferred gap).
- xvfb-run smoke of hello_imgui_rtt / hello_dpi_scaling threaded on glfw-opengl,
  glfw-vulkan, sdl3-vulkan: render, clean close; raw hello_threaded_imgui
  (nullptr manager path) unaffected. No Vulkan validation output.
- Single-thread parity: the refactored produce/replay path renders the diegetic
  demos unchanged, and a headless content check confirms the RTT panel draws
  actual ImGui pixels (not a blank target).
- TSan on hello_imgui_rtt + hello_dpi_scaling threaded: 0 warnings — the per-RTT
  secondary-context sharing and shared-atlas access under the imgui>asset lock
  order are race-free.
- SwiftShader goldens unchanged: 25/25 (50 assertions), incl. the imguiImage
  single-mode tests that exercise the refactored retire-queue path.

As-built the two ImGui features in THREADED_DIEGETIC_IMGUI.md / THREADED_MODE.md
and note threaded support in CLAUDE.md; the deferred bucket is now only
hello_screenshot.
… renderImguiTo

The Phase 2 render consume held the ImGui mutex outer of the asset mutex around
renderSnapshotContents unconditionally, which serialized the render draw against
the sim UI-build for EVERY threaded frame — regressing sim/render overlap even
for apps that never use diegetic RTT ImGui. Only replayClones (empty-guarded)
touches the shared atlas, and registered-image resolve is asset-mutex-covered, so
gate the coarse lock on snapshot.rttUi being non-empty: no-clone frames keep the
pre-diegetic asset-only renderSnapshotContents + short imgui main draw (full
overlap); only renderImguiTo frames pay the coarse lock. Neither path takes
asset>imgui, so the choice stays deadlock-safe.

Verified: builds glfw-opengl/glfw-vulkan; xvfb smoke of diegetic + non-diegetic
threaded demos (opengl + vulkan, no VUID); TSan 0 on both lock-discipline paths
(hello_imgui_rtt non-empty, hello_threaded_imgui + hello_threaded_dense_land
empty); SwiftShader goldens 25/25.
The diegetic gate (f8ab7ca) still dragged the whole render — GPU uploads, sprite
RTT passes, resolveImages, main sprite draw — under the ImGui mutex on frames that
use renderImguiTo, so none of it overlapped the sim UI-build. Only the actual
shared-atlas work needs that lock. Split renderSnapshotContents at the replayClones
seam into renderSnapshotPreMain (uploads + sprite RTT passes + resolveImages) and
renderSnapshotMain (main sprite draw), both atlas-free, and drive the phases from
consume(Threaded) with per-phase locks:
  preMain            -> asset only   (overlaps sim)
  replayClones       -> imgui>asset  (only when rttUi non-empty)
  main sprite draw   -> asset only   (overlaps sim)
  main endFrame      -> imgui
drainPendingFrees moves under the existing render-side imgui block (imgui>asset):
its render-target branch tears down secondary ImGui contexts (releaseContext ->
DestroyContext), which races the sim's produceClones on ImGui globals. Releasing
the asset mutex between phases is safe — GPU frees are deferred and each phase
re-looks-up by id. No path takes asset>imgui, so still deadlock-free. Single mode
is a thin orchestrator over the same phases (byte-identical order, no locks).

Verified: 4 backends build; xvfb smoke of diegetic + non-diegetic + explorer demos
(opengl/vulkan, no VUID); TSan 0 on hello_imgui_rtt/_dpi_scaling (diegetic phase
churn + releaseContext-under-imgui) and hello_threaded_imgui/_dense_land
(asset-only); headless content check still draws the RTT panel; goldens 25/25.
… and remove it

Both ImGui features (registered images, diegetic renderImguiTo) are shipped, so the
scoping doc is obsolete. Absorb its as-built essentials — the produceClones/replayClones
split, the finer-grained imgui/asset lock phases, and the no-input v1 limitation — into
THREADED_MODE.md's Done section, refresh the stale demo-migration status (all demos ported
except hello_headless/hello_screenshot), and repoint the two CLAUDE.md links.
hello_screenshot was lumped under 'demos that deliberately stay off the threaded
path' next to hello_headless, implying it was intentional like the manual-tick
reference. It isn't — it's blocked by an unclosed gap. Split it out into a new
Pending/deferred work section with the fix approach (marshal the screenshot
request + result across the boundary via the existing harvest->POD->feed / atomic
channels, then migrate the demo and drop the last deprecated caller). hello_headless
stays as the only by-design single-thread demo.
…oundary

requestScreenshot() previously armed the render-owned backend directly and
finishScreenshot() wrote mScreenshotResult on the render thread while
retrieveScreenshot() read it on the sim thread, all unsynchronized. Route the
request sim->render via a new std::atomic<bool> mScreenshotRequested (the render
thread observes it in consume(Threaded) and does armScreenshot there, sized to
the snapshot), and guard mScreenshotResult with mScreenshotResultMutex for the
render-write / sim-read handoff. mScreenshotArmed is now render-thread-local on
the threaded path. Single-threaded keeps the eager arm (byte-identical).
…nification

# Conflicts:
#	source/frame_runner.cpp
… by TSan)

close() from a run(update, ...) callback runs on the sim thread and calls
HeadlessBackend::requestClose(), which wrote the plain bool mRunning while the
render thread read it via isRunning() in consume() (mThreadedRunning refresh).
TSan (under SwiftShader headless) flagged this as a data race. Make mRunning a
std::atomic<bool> (acquire/release), matching mScreenSize / mThreadedRunning.
Adds tools/sanitizers/: a small self-closing threaded workload (threaded_smoke.cpp)
exercising sim-thread scene mutation, asset create/destroy, threaded ImGui, and the
scheduled-screenshot channel; a TSan suppressions file scoped to third-party
(SwiftShader ICD / validation layer / ImGui-Vulkan init-teardown) noise only; and
run_sanitizers.sh which builds + runs TSan and ASan+UBSan against the SwiftShader
Vulkan ICD (Mesa is buggy under sanitizers). Gated by NOTHOFAGUS_BUILD_SANITIZER_FIXTURE
(default OFF). Both sanitizers report 0 issues on the current threaded path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant