Restored from slopsmith/slopsmith#246 — original issue, opened by @topkoa on 2026-05-10.
[restored-from: slopsmith/slopsmith#246]
Summary
When the highway <canvas> is hidden via display:none but the createHighway() instance is not stopped, the active renderer keeps running: the rAF loop keeps firing, renderer.draw(bundle) keeps being called, and ren.render() keeps drawing a full WebGL scene that nobody can see. For renderers like 3D Highway that mount their own DOM as a sibling of #highway (a <div class="h3d-wrap"> overlay holding the WebGL canvas), display:none on #highway doesn't even hide the visible output — the overlay keeps painting full-screen.
Where it bites today: splitscreen
The splitscreen plugin, on activate, does document.getElementById('highway').style.display = 'none' and builds its own per-panel highways on top in #splitscreen-wrap (z-index 3). But:
- It never stops the main
window.highway. With the 3D Highway viz selected for the main player, that renderer's .h3d-wrap (z-index 2) keeps rendering full-screen behind the panels and bleeds through the panels' (slightly translucent) bottom bars — a visible "sliver of a full-screen 3D highway behind the panels."
- Even after the splitscreen plugin works around the visual leak (hiding the sibling
.h3d-wrap), the main highway's renderer is still running an entire WebGL render loop every frame for nothing. With N panels each running their own 3D renderer, that's N+1 WebGL contexts churning while only N are visible — ~33% wasted GPU/CPU at N=2, more for quad. On weak GPUs that's real.
(The splitscreen plugin is shipping a local workaround for the visual part — hiding #highway's sibling overlays on activate and re-binding panel.canvas after canvas swaps — but the render-loop waste can only be fixed in core.)
Proposed fix (core-level, transparent to all consumers)
In createHighway()'s rAF loop:
- Cheap per-frame visibility check:
canvas.offsetParent === null (catches display:none on the canvas or any ancestor — exactly the splitscreen case). When not displayed, skip renderer.draw(bundle) and the default 2D draw that frame.
- When the displayed-state flips, emit a
highway:visibility event ({ visible: boolean }) on window.slopsmith so renderers that mount their own DOM (3D Highway's .h3d-wrap) can hide/show it — same listener pattern they already use for highway:canvas-replaced. A renderer can also use this to drop its own per-frame work, free GPU buffers, etc.
Why this shape:
- Transparent. No consumer changes needed — splitscreen (and the popup-follower window, and any future host that hides the highway) gets render-loop pausing + overlay hiding for free. No follow-up PR in the splitscreen plugin.
- One place. Visibility logic lives in core, not duplicated (and subtly wrong) across every viz plugin.
offsetParent === null won't catch visibility:hidden / opacity:0 / "occluded by an opaque sibling" — those are rare; an optional explicit highway.setVisible(bool) override could cover them later if anyone needs it, but it's not required for v1.
Notes / docs
- Update the setRenderer-contract section in
CLAUDE.md: renderers that mount their own DOM should listen for highway:visibility and mirror the canvas's displayed state (just like the existing highway:canvas-replaced guidance).
draw(bundle) already isn't called during the loading/reconnect window; this just extends "don't draw" to "and don't draw while not displayed."
🤖 Filed via Claude Code while debugging the splitscreen sliver.
Summary
When the highway
<canvas>is hidden viadisplay:nonebut thecreateHighway()instance is not stopped, the active renderer keeps running: the rAF loop keeps firing,renderer.draw(bundle)keeps being called, andren.render()keeps drawing a full WebGL scene that nobody can see. For renderers like 3D Highway that mount their own DOM as a sibling of#highway(a<div class="h3d-wrap">overlay holding the WebGL canvas),display:noneon#highwaydoesn't even hide the visible output — the overlay keeps painting full-screen.Where it bites today: splitscreen
The splitscreen plugin, on activate, does
document.getElementById('highway').style.display = 'none'and builds its own per-panel highways on top in#splitscreen-wrap(z-index 3). But:window.highway. With the 3D Highway viz selected for the main player, that renderer's.h3d-wrap(z-index 2) keeps rendering full-screen behind the panels and bleeds through the panels' (slightly translucent) bottom bars — a visible "sliver of a full-screen 3D highway behind the panels.".h3d-wrap), the main highway's renderer is still running an entire WebGL render loop every frame for nothing. With N panels each running their own 3D renderer, that's N+1 WebGL contexts churning while only N are visible — ~33% wasted GPU/CPU at N=2, more for quad. On weak GPUs that's real.(The splitscreen plugin is shipping a local workaround for the visual part — hiding
#highway's sibling overlays on activate and re-bindingpanel.canvasafter canvas swaps — but the render-loop waste can only be fixed in core.)Proposed fix (core-level, transparent to all consumers)
In
createHighway()'s rAF loop:canvas.offsetParent === null(catchesdisplay:noneon the canvas or any ancestor — exactly the splitscreen case). When not displayed, skiprenderer.draw(bundle)and the default 2D draw that frame.highway:visibilityevent ({ visible: boolean }) onwindow.slopsmithso renderers that mount their own DOM (3D Highway's.h3d-wrap) can hide/show it — same listener pattern they already use forhighway:canvas-replaced. A renderer can also use this to drop its own per-frame work, free GPU buffers, etc.Why this shape:
offsetParent === nullwon't catchvisibility:hidden/opacity:0/ "occluded by an opaque sibling" — those are rare; an optional explicithighway.setVisible(bool)override could cover them later if anyone needs it, but it's not required for v1.Notes / docs
CLAUDE.md: renderers that mount their own DOM should listen forhighway:visibilityand mirror the canvas's displayed state (just like the existinghighway:canvas-replacedguidance).draw(bundle)already isn't called during the loading/reconnect window; this just extends "don't draw" to "and don't draw while not displayed."🤖 Filed via Claude Code while debugging the splitscreen sliver.