diff --git a/CHANGELOG.md b/CHANGELOG.md index 6244b4f..d1da562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed -- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. +- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). ### Added - **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass. diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 24bce88..c394b44 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.31.0", + "version": "3.31.1", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 5b37147..fd9249f 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -3983,6 +3983,10 @@ let _laneRailBoundsRefTpl = null; let _laneRailBoundsRefNotes = null; let _lastHwW = 0, _lastHwH = 0; + // Frame counter for throttling the CSS-box drift check in draw() + // (getBoundingClientRect is a forced layout read; see the comment + // at the check). + let _boxCheckCountdown = 0; // Last logical (CSS px) size handed to applySize(). #highway is a // flex:1 item, so its real rendered box (canvasSize()) can change as // the player layout settles after a song opens WITHOUT the backing @@ -9928,6 +9932,23 @@ // draw() after each init. // txtMat() rasterises into the unbounded cache these draws hit // anyway; ren.initTexture() forces the GPU upload now. + // Swap a pooled label sprite's cached texture WITHOUT recompiling. + // Setting material.needsUpdate bumps material.version, which forces + // Three.js through getParameters/getProgramCacheKey on the next + // render — profiled at ~4% of throttled main-thread time from the + // per-frame label map swaps in dense charts. Swapping one non-null + // texture for another does NOT change the compiled program (the + // USE_MAP define is unchanged); only a null <-> non-null transition + // does, and pooled label sprites are constructed with a non-null + // map, so in practice this never recompiles. + function _setLabelMap(sprite, srcMat) { + const m = sprite.material; + if (m.map === srcMat.map) return; + const nullnessChanged = (m.map == null) !== (srcMat.map == null); + m.map = srcMat.map; + if (nullnessChanged) m.needsUpdate = true; + } + let _chartPrewarmed = false; function _prewarmTex(mat) { if (mat && mat.map && ren) ren.initTexture(mat.map); @@ -11967,7 +11988,7 @@ const lblW = 28 * K, lblH = 9 * K; const lbl = pChordLbl.get(); const mat = txtMat(chordName, '#e8d080', true, 'chord'); - if (lbl.material.map !== mat.map) { lbl.material.map = mat.map; lbl.material.needsUpdate = true; } + _setLabelMap(lbl, mat); lbl.material.opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul; // Gold chord name: slight +X shift from flush-left so it sits farther right. const lblWS = lblW * _textSizeMul; @@ -12003,7 +12024,7 @@ if (!text) return; const s = pChordLbl.get(); const m = txtMat(text, colorHex, true, 'chord'); - if (s.material.map !== m.map) { s.material.map = m.map; s.material.needsUpdate = true; } + _setLabelMap(s, m); s.material.opacity = opacity; s.position.set(baseX, hy, z); s.scale.set(hlW, hlH, 1); @@ -12127,10 +12148,7 @@ _seenChordFrets.add(f); const lbl = pNoteFretLabel.get(); const mat = txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret'); - if (lbl.material.map !== mat.map) { - lbl.material.map = mat.map; - lbl.material.needsUpdate = true; - } + _setLabelMap(lbl, mat); lbl.position.set(xFretMid(f), yMinF, z); lbl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRET_LABEL'); const _flS = 7.0 * K * (1 + 0.4 * chDt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f); @@ -12753,10 +12771,7 @@ const color = '#888888'; const sp = pFretColMarker.get(); const m = txtMat(f, color, false, 'noteFret'); - if (sp.material.map !== m.map) { - sp.material.map = m.map; - sp.material.needsUpdate = true; - } + _setLabelMap(sp, m); sp.material.opacity = 0.85 * _colFadeIn; sp.position.set(xFretMid(f), labelY, z); // Z-proportional: sits between chord frame and note gem @@ -14039,10 +14054,7 @@ _frameLabeledKeys.add(_flFrameKey); const fretLabel = pNoteFretLabel.get(); const cachedMat = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret'); - if (fretLabel.material.map !== cachedMat.map) { - fretLabel.material.map = cachedMat.map; - fretLabel.material.needsUpdate = true; - } + _setLabelMap(fretLabel, cachedMat); fretLabel.position.set(x, labelY, noteZ); fretLabel.renderOrder = renderOrderForLayerAtZ(noteZ, _isArpNote @@ -14067,10 +14079,7 @@ if (!text) return; const spr = pTeachMarkLbl.get(); const m = txtMat(text, colorHex, false, cacheKey); - if (spr.material.map !== m.map) { - spr.material.map = m.map; - spr.material.needsUpdate = true; - } + _setLabelMap(spr, m); spr.position.set(x + dx, labelY, noteZ); spr.renderOrder = renderOrderForLayerAtZ(noteZ, _isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL'); @@ -14104,10 +14113,7 @@ const _isArp2 = arpBounds !== null; const fl2 = pNoteFretLabel.get(); const cm2 = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret'); - if (fl2.material.map !== cm2.map) { - fl2.material.map = cm2.map; - fl2.material.needsUpdate = true; - } + _setLabelMap(fl2, cm2); fl2.position.set(x, _labelY2, noteZ); fl2.renderOrder = renderOrderForLayerAtZ(noteZ, _isArp2 @@ -15163,25 +15169,40 @@ // for the pre-settle (too-tall) size and crops the near strings // / fret numbers until the user un/re-maximizes the window. if (highwayCanvas) { - const box = canvasSize(highwayCanvas); - if (highwayCanvas.width !== _lastHwW || highwayCanvas.height !== _lastHwH) { - _lastHwW = highwayCanvas.width; - _lastHwH = highwayCanvas.height; - if (box.w > 0 && box.h > 0) applySize(box.w, box.h); - } else if (box.w > 0 && box.h > 0 && - (Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) { - applySize(box.w, box.h); - } else if (!_wrapPinned && box.w > 0 && box.h > 0 && - highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) { - // 3. The overlay pin couldn't be applied at init because - // #highway had no layout yet (offsetWidth/Height === 0), - // so applySize() only set the wrap height. The canvas has - // now laid out but to the same logical size, so neither - // drift branch above fires — re-run applySize to pin the - // wrap to the canvas box now that its offsets are real. - // Otherwise the overlay stays at top:0;left:0;right:0 and - // a strip of #highway is exposed on first load / split. - applySize(box.w, box.h); + // Backing-store drift (branch 1) is detected with cheap + // property reads every frame. The CSS-box checks (branches + // 2/3) need canvasSize() → getBoundingClientRect(), a + // forced layout read — profiled at ~1.2% of throttled + // main-thread time when run per frame. Throttle the box + // read to every 10th frame (plus whenever the backing + // store changed or the wrap isn't pinned yet): the layout + // settle it exists to catch plays out over hundreds of ms + // right after a song opens, so a ~166 ms detection cadence + // loses nothing visible. + const _bsChanged = highwayCanvas.width !== _lastHwW + || highwayCanvas.height !== _lastHwH; + _boxCheckCountdown = (_boxCheckCountdown + 1) % 10; + if (_bsChanged || !_wrapPinned || _boxCheckCountdown === 0) { + const box = canvasSize(highwayCanvas); + if (_bsChanged) { + _lastHwW = highwayCanvas.width; + _lastHwH = highwayCanvas.height; + if (box.w > 0 && box.h > 0) applySize(box.w, box.h); + } else if (box.w > 0 && box.h > 0 && + (Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) { + applySize(box.w, box.h); + } else if (!_wrapPinned && box.w > 0 && box.h > 0 && + highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) { + // 3. The overlay pin couldn't be applied at init because + // #highway had no layout yet (offsetWidth/Height === 0), + // so applySize() only set the wrap height. The canvas has + // now laid out but to the same logical size, so neither + // drift branch above fires — re-run applySize to pin the + // wrap to the canvas box now that its offsets are real. + // Otherwise the overlay stays at top:0;left:0;right:0 and + // a strip of #highway is exposed on first load / split. + applySize(box.w, box.h); + } } } update(bundle); diff --git a/static/app.js b/static/app.js index 4e33e79..256a465 100644 --- a/static/app.js +++ b/static/app.js @@ -9777,6 +9777,12 @@ async function startSongCountIn() { // Time display + highway sync let lastAudioTime = 0; +// hud-time write cache: the 60 Hz tick below used to rewrite textContent +// (and getElementById) every tick even though the mm:ss display only +// changes once a second — each write invalidates layout. Write-on-change +// with a cached element ref (re-resolved if detached). +let _hudTimeEl = null; +let _hudTimeLast = ''; setInterval(() => { let ct = _audioTime(); const dur = _audioDuration(); @@ -9804,7 +9810,12 @@ setInterval(() => { ct = lastAudioTime; } lastAudioTime = ct; - document.getElementById('hud-time').textContent = `${formatTime(ct)} / ${formatTime(dur)}`; + const hudText = `${formatTime(ct)} / ${formatTime(dur)}`; + if (hudText !== _hudTimeLast) { + if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time'); + if (_hudTimeEl) _hudTimeEl.textContent = hudText; + _hudTimeLast = hudText; + } if (dur) { _maybeRefreshSectionPracticeDuration(dur); } diff --git a/tests/js/highway_3d_resize_reframe.test.js b/tests/js/highway_3d_resize_reframe.test.js index 3eca7b3..955e21f 100644 --- a/tests/js/highway_3d_resize_reframe.test.js +++ b/tests/js/highway_3d_resize_reframe.test.js @@ -61,10 +61,25 @@ test('draw() reads the live canvas box once per frame', () => { test('backing-store drift branch is preserved (splitscreen path)', () => { // The original check that catches the splitscreen hw.resize override // resizing the element without calling renderer.resize() must remain. + // The comparison is hoisted into _bsChanged (checked with cheap property + // reads every frame, and it forces the throttled box read to run on the + // same frame); the branch body is unchanged. assert.match( src, - /if\s*\(\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/, - 'the backing-store (canvas.width/height) drift branch must still re-apply', + /const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/, + 'the backing-store (canvas.width/height) comparison must run every frame', + ); + assert.match( + src, + /if\s*\(\s*_bsChanged\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/, + 'the backing-store drift branch must still re-apply', + ); + // The throttle must never delay the backing-store path: _bsChanged is + // part of the gate that forces the box read on the same frame. + assert.match( + src, + /if\s*\(\s*_bsChanged\s*\|\|\s*!_wrapPinned\s*\|\|\s*_boxCheckCountdown\s*===\s*0\s*\)/, + 'the box-read gate must include _bsChanged so backing-store changes re-apply immediately', ); });