Skip to content

runtime: per-frame render event as a frame-counter signal (alternative to #89) - #93

Merged
ResurrectedTrader merged 3 commits into
mainfrom
runtime-render-signal
Oct 8, 2026
Merged

ResurrectedTrader merged 3 commits into
mainfrom
runtime-render-signal

Conversation

@ResurrectedTrader

@ResurrectedTrader ResurrectedTrader commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

An alternative to #89 with the same user-facing render event, done as a signal instead of a queued event. Pick one of the two; they conflict.

Design

  • GameLoop::OnDraw (the backend's onDraw callback, once per rendered frame, in and out of game) calls events::FrameCounter::Increment(): one relaxed atomic increment, before Drawable::DrawAll. It queues nothing, takes no lock, and never touches a script.
  • Each pass of a script's event loop (Script::ExecuteEvents, which delay() runs) calls Script::FireRenderEventIfNewFrame() right after pumping the V8 task queue. If FrameCounter::Current() is past the frame this script last fired for (lastRenderFrame_) and the script has a render listener, it fires the listeners once, synchronously, with that frame number, and only then records it.
  • No wake-up is needed. The loop already wakes every IdleSleepIntervalMs (default 10 ms) to service cross-thread posted events, so a waiting script sees a new frame with that same latency, without busy-polling.

Coalescing comes for free. A slow script fires once and sees a bigger jump in frame. There is no backlog, and no per-script pending state to manage.

How it differs from #89

  • RenderLatch (arm / disarm / deliver, the per-script delivery timestamp) is gone. The per-script state is one uint64_t lastRenderFrame_, touched only on the script thread.
  • The game thread does no per-frame work beyond the increment. It has no ListenerCount probe, no ForEachScript walk, no event allocation, no PostEvent, and no failure path. runtime: add a per-frame render script event #89 walks the scripts and posts an event to each listener every frame.
  • Payload: just frame, the latest rendered frame number. elapsed is dropped; scripts can use getTickCount().
  • render fires only from the event loop (delay() and the other ExecuteEvents waits). runtime: add a per-frame render script event #89's posted event is also run only by PumpMessageLoop inside the same loop, so scripts see no difference.
  • The diff is smaller: 17 files, +183/-6, against 16 files, +209/-5. Most of both is tests and docs.

Tests

Verification

  • build.ps1 Release (Win32): 0 warnings, 0 errors.
  • build.ps1 test: 174/174 test cases passed.
  • build.ps1 check-format: clean.
  • extract_api.py and then gen_dts.py emit addEventListener(event: "render", callback: (frame: number) => void), and tsc --noEmit --lib es2020 on the d.ts exits 0.

Not live-tested in game. This is #89's example script, using getTickCount() in place of elapsed:

function main() {
    var label = new Text("render event", 100, 100, 4, 1);
    var x = 100;
    var last = getTickCount();
    addEventListener("render", function (frame) {
        var now = getTickCount();
        x += (now - last) * 0.1;     // ~100 px/s regardless of frame rate
        last = now;
        if (x > 700) x = 100;
        label.x = x;
        label.text = "frame " + frame;
    });
    while (true) delay(1000);
}

🤖 Generated with Claude Code

ResurrectedTrader and others added 2 commits October 8, 2026 20:36
addEventListener("render", (frame) => ...) runs at most once per rendered
frame, in and out of game, so scripts can move or restyle drawables every
frame. GameLoop::OnDraw only increments a global frame counter
(events::RenderFrames) before Drawable::DrawAll; it queues nothing and never
waits on a script. Each pass of a script's event loop (Script::ExecuteEvents,
inside delay()) fires its render listeners once when the counter moved since
it last did, so a slow script sees a bigger jump in frame rather than a
backlog, and no script listening costs nothing beyond the increment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
RenderFrames becomes FrameCounter (a uint64_t count, Increment / Current)
and RenderEventDispatch goes: GameLoop::OnDraw increments the counter
itself. Script::FireRenderEventIfNewFrame fires when the current frame is
past the one it last fired for and the script listens for "render", and
only then records it, so a listener added later fires on the next pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A render handler that calls delay() runs the event loop again, and a frame
drawn during that delay would fire render inside itself, one level deeper
every frame until the stack runs out. A per-thread flag (each script has
its own thread) skips render while its handler runs; the frames drawn
meanwhile fold into the next call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ResurrectedTrader
ResurrectedTrader merged commit 974eb77 into main Oct 8, 2026
1 check passed
@ResurrectedTrader
ResurrectedTrader deleted the runtime-render-signal branch October 8, 2026 19:54
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