Skip to content

3D Highway render loop runs unconditionally (no playback gate) — perf issues, mainly on Windows/high-refresh #499

Description

@mogul

Restored from slopsmith/slopsmith#654 — original issue, opened by @byrongamatos on 2026-06-01.
[restored-from: slopsmith/slopsmith#654]

Summary

Several users — mainly on Windows, several with multi-monitor setups — report performance issues (stutter, fan spin-up, and in the desktop app audio dropouts) while on the player, especially with the 3D Highway visualization. The root cause is in core: the highway render loop runs unconditionally and is never gated on playback state, so an expensive renderer (3D Highway / Three.js WebGL) does a full scene render every animation frame even while playback is paused. Windows-specific browser/compositor behavior amplifies this, which is why it largely doesn't reproduce on Linux at 60 Hz.

Root cause — the render loop only gates visibility and ready, never playback

static/highway.js draw() (~line 1024) re-arms itself every frame and only skips work on two conditions:

function draw() {
    animFrame = requestAnimationFrame(draw);   // always re-arms
    if (!canvas || !_renderer) return;
    _emitVisibilityIfChanged();
    if (!_lastVisible) return;                  // gate 1: offsetParent / setVisible override
    if (!ready) return;                         // gate 2: WS ready
    ... _renderer.draw(bundle) ...              // expensive 3D render
}
  • The loop starts on the first ready (~line 2965) and the only cancelAnimationFrame is in highway.stop() (~line 3244).
  • There is no play/pause gate. While a song is loaded, the player screen is up, and the canvas is visible, _renderer.draw(bundle) runs on every frame — including while paused.
  • For the default 2D renderer this is cheap and went unnoticed. For the 3D Highway (byrongamatos/slopsmith-plugin-3dhighway, host-driven draw()ren.render(scene, cam)) it is a full WebGL scene render at the display's refresh rate, continuously.

Leaving the player is handled correctly: .screen { display:none } (static/style.css:13-14) trips offsetParent === null_lastVisible false → draw bails, and highway.stop() is called on the stop paths. So the waste is specifically: on the player, song loaded, canvas visible, but not actively playing.

Why it hits Windows (and not Linux/60 Hz)

  • Chromium paces rAF to the highest-refresh attached monitor (DWM-tied) regardless of which monitor the window is on. If any connected display is 120/144/165 Hz, the unconditional loop runs at that rate everywhere — 2–2.7× the work — even on a 60 Hz panel. This is the leading explanation for the platform split.
  • WebGL on Windows goes through ANGLE → Direct3D 11, heavier and differently paced than native GL on Linux.
  • DWM multi-monitor present/composite adds overhead and historically causes mixed/independent-vblank stutter.
  • The desktop app is Electron (same Chromium engine) and shares the machine with the JUCE audio engine, so GPU contention surfaces as audio glitches.

Sync is not a concern for gating/throttling

Note position is clock-derived, not frame-accumulated: currentTime is the audio-aligned clock (highway.js:61-63, interpolated via performance.now() at 3054-3071) and notes are drawn as n.t - currentTime every frame (1456-1459, 1553, 1654). At any frame rate, a note is placed at the correct time-based position for the instant it's drawn. Gating while paused, or throttling frame rate / render resolution during playback, changes smoothness, never audio/visual sync.

Related: the visibility:hidden overlay gap

Core exposes window.slopsmith.highway.setVisible(true|false|null) (highway.js:~3100) precisely for hosts that hide the highway via visibility:hidden / opacity:0 (which offsetParent can't detect). Findings:

  • No viz plugin calls setVisible() today.
  • tabview hides the host canvas with visibility:hidden (screen.js:488) but never calls setVisible(false), so the underlying renderer (e.g. the 3D Highway's .h3d-wrap WebGL overlay) keeps rendering full-tilt behind the opaque tab view.
  • Only jumpingtab participates in the visibility protocol (listens to highway:visibility); the 3D Highway plugin has no listener to hide its .h3d-wrap.

Proposed fixes (separate PRs, all reference this issue)

  1. Core slopsmith/slopsmith — playback pause gate in highway.js: skip _renderer.draw when the audio clock is stalled/paused, rendering one frame on pause→play / seek transitions so the static frame stays correct. Highest-impact, no-downside, helps every renderer.
  2. slopsmith-plugin-3dhighway — adaptive _renderScale reduction under load (keeps native-refresh smoothness while cutting per-frame GPU cost — the better lever than an fps cap, avoids non-integer-refresh judder), a debug fps/frame-time readout, and a highway:visibility listener that hides .h3d-wrap when the host goes invisible.
  3. slopsmith-plugin-tabview — call highway.setVisible(false) when hiding the host canvas and setVisible(null) on teardown/restore, so the gate trips and highway:visibility fires for overlay renderers.

How a reporter can confirm

  • Report each monitor's actual refresh rate (Windows → Display → Advanced display); set all displays to 60 Hz or unplug the high-refresh one → does it clear up?
  • Does it occur while paused, not just during playback? (confirms the unconditional loop)
  • chrome://gpu / about:support → compositing + vsync info; compare window.devicePixelRatio per monitor.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions