-
Notifications
You must be signed in to change notification settings - Fork 3
Description
Parent: #57 — Phase 1 Architecture
Summary
Clean up the main loop by reordering phases (input before update), separating concerns in _tick() and _update_frame(), and fixing the one-frame input lag in GLFW mode. No new class hierarchies — just reorganization of existing code.
Why not a GameLoop class with fixed timestep and Platform ABC?
The original proposal introduced a GameLoop class with an accumulator-based fixed-timestep pattern, and a Platform ABC with GLFWPlatform / JupyterPlatform subclasses. Both are over-engineered for the current state:
-
Fixed timestep solves a physics problem rtxpy doesn't have. The accumulator pattern (
while accumulator >= fixed_dt) ensures deterministic physics simulation regardless of frame rate. rtxpy doesn't simulate physics — camera movement is delta-time scaled, which is correct for a visualization tool. The existingdt * speedapproach gives smooth movement at any frame rate. Fixed timestep would only matter if we added rigid body dynamics (Phase 3), and it can be added then. -
Two backends don't justify an ABC. There are exactly two platforms (GLFW and Jupyter) with very different models (callback-based vs. queue-based, OpenGL texture upload vs. JPEG-over-widget). An ABC adds a layer of indirection that makes both harder to understand, and there's no third platform on the horizon. If one appears, the abstraction can be extracted then.
-
Variable render rate already works. The current loop renders only when
_render_neededis true and_frame_dirtygates texture upload. This is already the "render only when dirty" pattern the proposal describes — it just needs clearer phase separation, not a new class.
What IS needed: the loop has real bugs and organizational problems that are worth fixing without new abstractions.
Current State
GLFW Desktop Loop (engine.py:6206-6243)
while not glfw.window_should_close(window) and self.running:
_tick() # Update (includes held-key input processing)
[drain REPL command queue] # Command processing
[upload + render if dirty] # Present
glfw.poll_events() # Input polling ← AFTER render!
[optional sleep if idle]Bug: one-frame input lag. glfw.poll_events() runs AFTER _tick() and rendering. This means key presses and mouse events from the current frame aren't processed until the next frame's _tick(). Moving poll_events() to the top of the loop fixes this.
_tick() method (engine.py:3939-3966+)
Mixes three concerns:
- Input processing: delta-time computation, held-key → movement accumulation (lines 3944-3955)
- Simulation: chunk manager update, wind particle replay (lines 3946+)
- Render decisions: AO/DOF accumulation state,
_render_neededtriggering
_update_frame() method (engine.py:5114-5238)
Mixes two concerns:
- Render: GPU ray trace, progressive accumulation, post-processing
- Present: sync previous GPU readback, async D2H copy, overlay compositing
Jupyter Loop (notebook.py:329-371)
while self.running:
[drain input queue] # Input
[drain REPL command queue] # Commands
_tick() # Update
[push JPEG if dirty] # Present
[sleep to target 15 FPS] # Rate limitingThe Jupyter loop already has better phase ordering (input before tick), but mixes rate limiting with presentation.
Proposed Changes
1. Move glfw.poll_events() before _tick()
Single-line fix that eliminates the one-frame input lag:
while not glfw.window_should_close(window) and self.running:
glfw.poll_events() # Input FIRST
self._drain_command_queue() # REPL commands
self._tick() # Update (uses fresh input)
self._present_if_dirty() # Upload texture + swap
if not self._held_keys and not self._mouse_dragging:
time.sleep(0.008)2. Split _tick() into clear sections
Not new methods for the sake of methods — just clear section comments and grouping:
def _tick(self):
# --- Delta time ---
now = time.monotonic()
dt = min(now - self._last_tick_time, 0.1)
self._last_tick_time = now
dt_scale = dt / 0.05
# --- Input → camera movement ---
if self.input.held_keys:
self.camera.apply_input(self.input.held_keys, dt_scale)
self._render_needed = True
# --- Simulation updates ---
self.geometry.load_chunks_near(self.camera.position)
self.wind.step(dt)
# --- Render if needed ---
if self._render_needed:
self._update_frame()
self._render_needed = FalseThis aligns with the composition approach from #61 — _tick() delegates to subsystem objects instead of inlining everything.
3. Extract _drain_command_queue() and _present_if_dirty()
Pull the inline REPL drain and texture upload into named methods for readability:
def _drain_command_queue(self):
"""Process REPL commands queued from the ViewerProxy thread."""
while True:
try:
cmd = self._command_queue.get_nowait()
except queue.Empty:
break
try:
cmd(self)
except Exception:
traceback.print_exc()
self._render_needed = True
def _present_if_dirty(self):
"""Upload frame texture and swap buffers if a new frame is ready."""
if self._frame_dirty and self._display_frame is not None:
# ... existing texture upload + swap_buffers code ...
self._frame_dirty = False4. Split _update_frame() render vs. present concerns
The async GPU readback naturally separates render from present:
def _update_frame(self):
# --- Sync previous frame's async readback ---
if self._pending_readback is not None:
self._sync_readback()
# --- Render: ray trace + post-process ---
self._launch_optix()
self._post_process() # AO, DOF, denoise, tonemap
# --- Kick async readback for this frame ---
self._start_async_readback()
# --- Composite overlays onto display frame ---
self._composite_overlays()
self._frame_dirty = TrueWhat NOT to change
- No fixed timestep accumulator. Delta-time scaling works correctly for camera movement. Add fixed timestep only when/if physics simulation is added (Phase 3).
- No Platform ABC. GLFW and Jupyter loops stay as separate code paths. They share
_tick()and_update_frame()but their loop structures are different enough that an ABC would be forced. - No GameLoop class. The loop is 15 lines of code in a
try/finallyblock. Wrapping it in a class adds indirection without value. - No delta-time refactor. The current
dt_scale = dt / 0.05reference-rate pattern is fine — it gives consistent movement speed. Changing to rawdt * speed_in_units_per_secondwould require retuning all speed defaults for no user-visible benefit.
Implementation Notes
- The
glfw.poll_events()reorder is a one-line move with immediate benefit — can land independently _tick()reorganization depends on Phase 1: Decompose InteractiveViewer via Composition #61 (composition) for the subsystem delegation calls_drain_command_queue()extraction is a pure refactor — no behavior change_present_if_dirty()extraction is a pure refactor — no behavior change_update_frame()split is the most involved change but follows the existing async readback boundary- Jupyter loop (notebook.py) benefits from the same
_tick()cleanup but its loop structure stays separate
Acceptance Criteria
-
glfw.poll_events()called before_tick()— no one-frame input lag -
_tick()has clear input/simulation/render sections (comments or subsystem delegation) - REPL command drain extracted to
_drain_command_queue() - Texture upload + swap extracted to
_present_if_dirty() -
_update_frame()separates render (ray trace + post-process) from present (readback + composite) - Existing
explore()functionality preserved — no user-visible behavior change - Camera movement speed unchanged at any frame rate