Skip to content

highway.js draw-gate starves an active custom renderer's draw() on an override-hide (setVisible(false)) #293

Description

@byrongamatos

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

Summary

The per-frame draw gate in static/highway.js pauses the active custom renderer's draw(bundle) whenever the highway is marked not-visible — including when a renderer set that hidden state itself via setVisible(false). That starves any custom renderer that paints its own surface while occluding the highway canvas.

Where

static/highway.js, in draw():

_emitVisibilityIfChanged();
...
if (!_lastVisible) return;      // ← starves _renderer.draw(bundle) below
...
const bundle = _renderer === _defaultRenderer ? undefined : _makeBundle();
_renderer.draw(bundle);

_lastVisible comes from _isHighwayVisible():

function _isHighwayVisible() {
    if (_visibleOverride !== null) return _visibleOverride;   // setVisible() override
    return !!(canvas && canvas.offsetParent !== null);        // #418 off-screen check
}

The conflation

_lastVisible goes false for two different reasons that should be treated differently for an active custom renderer:

  1. Genuine off-screen (_visibleOverride === null && canvas.offsetParent === null) — the player screen is navigated away / a splitscreen panel is display:none. Nothing is on screen → pausing everything is correct (Highway viz renderers keep rendering full-screen when #highway is display:none (leaks behind splitscreen + wasted GPU) #418).
  2. Override-hide (_visibleOverride === false) — a renderer called setVisible(false) because an opaque overlay covers the canvas. Its purpose is to fire the highway:visibility event so sibling overlay renderers (e.g. the 3D Highway .h3d-wrap) pause their own loops. But the active _renderer that triggered it is still painting its own visible surface (e.g. Tab View renders an alphaTab DOM tab over the hidden canvas) — it must keep getting draw().

Case 2 wrongly trips the same gate, so the active custom renderer's draw() stops the instant it hides the canvas.

Observed impact

Tab View (slopsmith-plugin-tabview) hides the highway via setVisible(false) (#499, to stop the occluded renderer wasting GPU). Its playback cursor — positioned per-frame from draw(bundle) — silently froze / never appeared in single-player on shipped builds (slopsmith#734). It only worked in splitscreen, where setVisible(false) is skipped. This is core-side and not Electron-specific (reproduces in a browser).

Worked around plugin-side in slopsmith-plugin-tabview#25 (the plugin now self-drives its cursor from its own rAF + highway.getTime()), but the underlying core trap remains for any future custom renderer that paints its own surface.

Proposed fix

Exempt the active custom renderer from the override-hide branch of the gate, while keeping genuine off-screen pausing everything and keeping the default 2D renderer paused on an override-hide (its canvas really is the occluded surface). The highway:visibility event still fires (it's emitted before the gate), so sibling overlays still pause.

if (!_lastVisible) {
    const overrideHide = _visibleOverride === false;
    const customActive = _renderer !== _defaultRenderer;
    if (!(overrideHide && customActive)) return;   // genuine off-screen, or default renderer → still pause
}

This preserves setVisible(true) (force-visible), #418 (off-screen), and #499 (default-renderer GPU saving), and only lets an active custom renderer keep painting through its own override-hide. The existing paused-frame throttle still applies afterward.

I'll open a PR with this change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions