fix(highway): stop stale viz frame bleeding through after switching visualizations (#565)

Switching between the 3D drum highway (renders onto #highway) and the 3D
guitar highway (renders into its own .h3d-wrap overlay) left the previous
drum frame showing through the gap the overlay did not cover.

Core: _setRenderer now replaces #highway on a genuine viz change (keyed on
viz id via _rendererVizKey, not object identity, so benign same-viz
re-installs don't churn the canvas) as well as on a context-type change.

highway_3d: applySize pins the .h3d-wrap overlay to #highway's exact box,
derived from the same getBoundingClientRect measurements that size the
renderer (sub-pixel correct under zoom). Re-pins once the canvas lays out
(init race) and resets to the static anchor in the not-laid-out fallback.

Reviewed locally via codex (5 rounds, converged clean). CI checks are the
known org Actions billing block, not real failures.
This commit is contained in:
Byron Gamatos
2026-06-22 13:27:21 +02:00
committed by GitHub
parent 5145710a8a
commit 187d0bb978
4 changed files with 299 additions and 2 deletions
+45 -1
View File
@@ -973,6 +973,23 @@ function createHighway() {
return '2d';
}
// Logical identity of a renderer for swap detection: its viz id, not
// its object reference. app.js stamps `pluginId`/`source` (the viz
// picker id) onto every renderer it installs via _tagVizRenderer, and
// setViz()/_autoMatchViz() build a FRESH renderer object with factory()
// on every (re)apply — even when the selected viz did not change (e.g.
// Auto re-resolving to the same viz across consecutive songs). Keying
// the canvas-replacement decision on object identity would therefore
// treat those benign re-installs as a swap and needlessly clone
// #highway, dropping listeners/expando state attached to the element.
// The default renderer has no id, so it keys on its own singleton; an
// untagged custom renderer falls back to its object reference (the safe
// "treat as a swap" answer).
function _rendererVizKey(r) {
if (r === _defaultRenderer) return _defaultRenderer;
return (r && (r.pluginId || r.source)) || r;
}
// Replace the underlying <canvas> element with a fresh one because
// the next renderer needs a different context type than the current
// canvas is locked to. Preserves the DOM position, id, classes,
@@ -1038,6 +1055,11 @@ function createHighway() {
}
function _setRenderer(r) {
// Capture the outgoing renderer before _destroyCurrentIfInited /
// the `_renderer = next` assignment below overwrite it — the
// canvas-replacement decision needs to know whether we're
// actually swapping to a *different* renderer instance.
const prev = _renderer;
_destroyCurrentIfInited();
// null/undefined reverts to default. Anything else must provide
// at minimum a draw(bundle) function — without it the rAF loop
@@ -1070,7 +1092,29 @@ function createHighway() {
// destroyed by _destroyCurrentIfInited above, so it's safe to
// detach the element from the DOM here.
const nextType = _resolveRendererContextType(next);
if (nextType !== _currentCanvasContextType) {
// Replace the underlying <canvas> when (a) the new renderer needs
// a different context type than the one currently bound, OR (b)
// we're swapping to a *different* visualization while keeping the
// same context type. Case (b) prevents a stale frame from the
// previous renderer bleeding through: renderers that draw into a
// sibling overlay (e.g. 3D Highway's `.h3d-wrap`) never paint
// #highway, so whatever the *previous* renderer left on the shared
// canvas would otherwise stay visible in any area the overlay does
// not cover. The reported symptom was the 3D drum highway (which
// renders directly onto #highway) showing through the gap below
// the 3D guitar highway's overlay after switching between them —
// both are webgl2, so the type-change check alone never fired. A
// fresh canvas starts blank/transparent, so the incoming renderer
// always begins over a clean surface. The swap test keys on the
// viz id (_rendererVizKey), NOT object identity: setViz/_autoMatchViz
// build a fresh renderer object on every apply, so an object-identity
// check would clone #highway on every benign same-viz re-install
// (e.g. Auto re-resolving to the same viz across songs) and drop the
// element's listeners/expando state. Skipped when re-installing the
// same viz, and on the very first install (prev === null) where
// there is no prior frame to clear.
const _vizChanged = prev && _rendererVizKey(next) !== _rendererVizKey(prev);
if (nextType !== _currentCanvasContextType || _vizChanged) {
_replaceCanvas(nextType);
}
const bundle = _makeBundle();