mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
Merge pull request #694 from got-feedback/perf/highway-frame-hotspots
perf: eliminate per-frame layout thrash, shader-compile spikes, and per-frame allocations in the player
This commit is contained in:
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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. 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). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
||||||
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
||||||
|
|||||||
@@ -231,6 +231,18 @@ window.feedBackViz_my_viz = function () {
|
|||||||
// toneChanges, toneBase, mastery, hasPhraseData, inverted,
|
// toneChanges, toneBase, mastery, hasPhraseData, inverted,
|
||||||
// lefty, renderScale, lyricsVisible, the 2D coordinate
|
// lefty, renderScale, lyricsVisible, the 2D coordinate
|
||||||
// helpers project and fretX, and getNoteState (see below).
|
// helpers project and fretX, and getNoteState (see below).
|
||||||
|
// The bundle OBJECT is reused across frames (mutated in
|
||||||
|
// place — no per-frame allocation): never cache it or
|
||||||
|
// compare its identity between frames; field values are
|
||||||
|
// only valid for the current draw call. Array FIELDS still
|
||||||
|
// swap reference when chart data changes, so field-identity
|
||||||
|
// caches (`myRef !== bundle.chords`) remain valid.
|
||||||
|
// Windowed-iteration helpers (stable fn refs): bundle
|
||||||
|
// .lowerBoundT(arr, time) is a lower-bound binary search on
|
||||||
|
// `.t` (notes/chords); bundle.lowerBoundTime(arr, time) on
|
||||||
|
// `.time` (beats/anchors/sections). Use these to cull to
|
||||||
|
// the visible window instead of full-scanning chart arrays
|
||||||
|
// per frame.
|
||||||
// `stringCount` is the active arrangement's string count (4
|
// `stringCount` is the active arrangement's string count (4
|
||||||
// for bass, 6 for guitar, 7+ for extended-range GP imports —
|
// for bass, 6 for guitar, 7+ for extended-range GP imports —
|
||||||
// size string-indexed geometry against this, not a hardcoded
|
// size string-indexed geometry against this, not a hardcoded
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
|
|||||||
|
|
||||||
`tuning` and `capo` aren't consumed by this plugin.
|
`tuning` and `capo` aren't consumed by this plugin.
|
||||||
|
|
||||||
|
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
|
||||||
|
|
||||||
### Score FX (notedetect game-scoring layer)
|
### Score FX (notedetect game-scoring layer)
|
||||||
|
|
||||||
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.30.2",
|
"version": "3.31.2",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
|
|||||||
+243
-89
@@ -1236,6 +1236,11 @@
|
|||||||
|
|
||||||
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
|
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
|
||||||
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
|
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
|
||||||
|
// Byte-identical to core's bundle.lowerBoundT — kept as a local because this
|
||||||
|
// plugin must run on downlevel hosts whose bundles don't carry the helper
|
||||||
|
// (it's called from ~30 sites incl. top-level helpers that don't receive a
|
||||||
|
// bundle). New code that already holds a bundle should prefer
|
||||||
|
// bundle.lowerBoundT / bundle.lowerBoundTime.
|
||||||
function lowerBoundT(arr, t) {
|
function lowerBoundT(arr, t) {
|
||||||
let lo = 0, hi = arr.length;
|
let lo = 0, hi = arr.length;
|
||||||
while (lo < hi) {
|
while (lo < hi) {
|
||||||
@@ -3978,6 +3983,10 @@
|
|||||||
let _laneRailBoundsRefTpl = null;
|
let _laneRailBoundsRefTpl = null;
|
||||||
let _laneRailBoundsRefNotes = null;
|
let _laneRailBoundsRefNotes = null;
|
||||||
let _lastHwW = 0, _lastHwH = 0;
|
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
|
// Last logical (CSS px) size handed to applySize(). #highway is a
|
||||||
// flex:1 item, so its real rendered box (canvasSize()) can change as
|
// flex:1 item, so its real rendered box (canvasSize()) can change as
|
||||||
// the player layout settles after a song opens WITHOUT the backing
|
// the player layout settles after a song opens WITHOUT the backing
|
||||||
@@ -4677,10 +4686,12 @@
|
|||||||
// the full look (re-enable the rail bloom) per browser, no rebuild:
|
// the full look (re-enable the rail bloom) per browser, no rebuild:
|
||||||
// localStorage.h3d_full_sus = '1' // re-enable rail bloom halo
|
// localStorage.h3d_full_sus = '1' // re-enable rail bloom halo
|
||||||
// delete localStorage.h3d_full_sus // back to lean default
|
// delete localStorage.h3d_full_sus // back to lean default
|
||||||
// Read once per frame at the top of update() so the flag takes effect
|
// Polled at ~1 Hz at the top of update() (perf: localStorage reads
|
||||||
// live. The bloom pool/material/gaussian texture are kept intact
|
// are synchronous) so the console flag still takes effect live.
|
||||||
|
// The bloom pool/material/gaussian texture are kept intact
|
||||||
// (still pinned by the bloom unit tests and used by the opt-out path).
|
// (still pinned by the bloom unit tests and used by the opt-out path).
|
||||||
let _leanSus = true;
|
let _leanSus = true;
|
||||||
|
let _leanSusPollCounter = 0;
|
||||||
|
|
||||||
// Lifecycle flags
|
// Lifecycle flags
|
||||||
let _isReady = false;
|
let _isReady = false;
|
||||||
@@ -5267,7 +5278,17 @@
|
|||||||
// appearing on top without depthTest.
|
// appearing on top without depthTest.
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: T.DoubleSide,
|
// forceSinglePass accompanies EVERY transparent DoubleSide
|
||||||
|
// material in this file: without it, Three r158+ renders
|
||||||
|
// each such object in TWO passes (back side then front),
|
||||||
|
// setting material.needsUpdate on both — which forces a
|
||||||
|
// full getParameters/program-cache lookup per object per
|
||||||
|
// frame (profiled at ~4% of throttled main-thread time)
|
||||||
|
// and doubles the draw calls. The two-pass path exists to
|
||||||
|
// fix self-occlusion sorting on closed transparent meshes;
|
||||||
|
// all our DoubleSide materials are flat unlit quads
|
||||||
|
// (labels, rails, frames, lanes) where it buys nothing.
|
||||||
|
side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
sm.userData.h3dTechMeshMat = base;
|
sm.userData.h3dTechMeshMat = base;
|
||||||
}
|
}
|
||||||
@@ -6250,6 +6271,12 @@
|
|||||||
return boxH;
|
return boxH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lyrics layout cache — measureText per syllable + row wrapping
|
||||||
|
// only changes when the displayed line(s), font size, or canvas
|
||||||
|
// width change, not per frame. Keyed below; the per-frame work is
|
||||||
|
// just drawing over the cached widths.
|
||||||
|
let _lyrRowsCache = null;
|
||||||
|
|
||||||
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
||||||
if (!lyrics._lines) {
|
if (!lyrics._lines) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@@ -6297,37 +6324,51 @@
|
|||||||
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
|
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
|
||||||
|
|
||||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
const spaceWidth = ctx.measureText(' ').width;
|
let rows, spaceWidth, bgWidth;
|
||||||
const maxWidth = W * 0.8;
|
const _lc = _lyrRowsCache;
|
||||||
|
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
|
||||||
|
&& _lc.shown === linesToShow.length
|
||||||
|
&& _lc.fontSize === fontSize && _lc.W === W) {
|
||||||
|
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
|
||||||
|
} else {
|
||||||
|
spaceWidth = ctx.measureText(' ').width;
|
||||||
|
const maxWidth = W * 0.8;
|
||||||
|
|
||||||
const rows = [];
|
rows = [];
|
||||||
for (const authoredLine of linesToShow) {
|
for (const authoredLine of linesToShow) {
|
||||||
let row = [], rowWidth = 0;
|
let row = [], rowWidth = 0;
|
||||||
for (const wordSyls of authoredLine.words) {
|
for (const wordSyls of authoredLine.words) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let wordWidth = 0;
|
let wordWidth = 0;
|
||||||
for (const s of wordSyls) {
|
for (const s of wordSyls) {
|
||||||
const text = sylText(s);
|
const text = sylText(s);
|
||||||
const w = ctx.measureText(text).width;
|
const w = ctx.measureText(text).width;
|
||||||
parts.push({ syl: s, text, width: w });
|
parts.push({ syl: s, text, width: w });
|
||||||
wordWidth += w;
|
wordWidth += w;
|
||||||
|
}
|
||||||
|
const advance = wordWidth + spaceWidth;
|
||||||
|
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
|
||||||
|
row.push({ parts, advance });
|
||||||
|
rowWidth += advance;
|
||||||
}
|
}
|
||||||
const advance = wordWidth + spaceWidth;
|
if (row.length) rows.push(row);
|
||||||
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
|
|
||||||
row.push({ parts, advance });
|
|
||||||
rowWidth += advance;
|
|
||||||
}
|
}
|
||||||
if (row.length) rows.push(row);
|
|
||||||
|
bgWidth = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||||
|
if (rw > bgWidth) bgWidth = rw;
|
||||||
|
}
|
||||||
|
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||||
|
_lyrRowsCache = {
|
||||||
|
lyricsRef: lyrics, idx: currentIdx,
|
||||||
|
shown: linesToShow.length, fontSize, W,
|
||||||
|
rows, spaceWidth, bgWidth,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowHeight = fontSize + 6;
|
const rowHeight = fontSize + 6;
|
||||||
const totalHeight = rows.length * rowHeight + 10;
|
const totalHeight = rows.length * rowHeight + 10;
|
||||||
let bgWidth = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
|
||||||
if (rw > bgWidth) bgWidth = rw;
|
|
||||||
}
|
|
||||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -6629,7 +6670,7 @@
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: true,
|
depthTest: true,
|
||||||
blending: T.AdditiveBlending,
|
blending: T.AdditiveBlending,
|
||||||
side: T.DoubleSide,
|
side: T.DoubleSide, forceSinglePass: true,
|
||||||
fog: true,
|
fog: true,
|
||||||
}));
|
}));
|
||||||
mAccentHaloNear = mkAccentHaloMats(ACCENT_HALO_OP_NEAR);
|
mAccentHaloNear = mkAccentHaloMats(ACCENT_HALO_OP_NEAR);
|
||||||
@@ -6689,7 +6730,7 @@
|
|||||||
new T.MeshBasicMaterial({
|
new T.MeshBasicMaterial({
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
transparent: true, opacity: 1.0, depthWrite: false,
|
transparent: true, opacity: 1.0, depthWrite: false,
|
||||||
blending: T.AdditiveBlending, side: T.DoubleSide, fog: false,
|
blending: T.AdditiveBlending, side: T.DoubleSide, forceSinglePass: true, fog: false,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
// Notedetect feedback outline (issue #9): hot magenta-red (0xff0066, hue
|
// Notedetect feedback outline (issue #9): hot magenta-red (0xff0066, hue
|
||||||
@@ -6829,7 +6870,7 @@
|
|||||||
emissiveIntensity: 0.9,
|
emissiveIntensity: 0.9,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
side: T.DoubleSide,
|
side: T.DoubleSide, forceSinglePass: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
});
|
});
|
||||||
@@ -6856,7 +6897,7 @@
|
|||||||
color: CHORD_BOX_TEAL_HEX,
|
color: CHORD_BOX_TEAL_HEX,
|
||||||
transparent: true, opacity: 0.85,
|
transparent: true, opacity: 0.85,
|
||||||
depthTest: false, depthWrite: false,
|
depthTest: false, depthWrite: false,
|
||||||
fog: false, side: T.DoubleSide,
|
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
pSusRail = pool(noteG, () => {
|
pSusRail = pool(noteG, () => {
|
||||||
const m = new T.Mesh(gSusRail, mSusRailBase.clone());
|
const m = new T.Mesh(gSusRail, mSusRailBase.clone());
|
||||||
@@ -6877,7 +6918,7 @@
|
|||||||
transparent: true, opacity: 0.55,
|
transparent: true, opacity: 0.55,
|
||||||
blending: T.AdditiveBlending,
|
blending: T.AdditiveBlending,
|
||||||
depthTest: false, depthWrite: false,
|
depthTest: false, depthWrite: false,
|
||||||
fog: false, side: T.DoubleSide,
|
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
pSusRailBloom = pool(noteG, () => {
|
pSusRailBloom = pool(noteG, () => {
|
||||||
const m = new T.Mesh(gSusRailBloom, mSusRailBloomBase.clone());
|
const m = new T.Mesh(gSusRailBloom, mSusRailBloomBase.clone());
|
||||||
@@ -6891,7 +6932,7 @@
|
|||||||
gTechPlane = new T.PlaneGeometry(1, 1);
|
gTechPlane = new T.PlaneGeometry(1, 1);
|
||||||
pTechPlane = pool(noteG, () => {
|
pTechPlane = pool(noteG, () => {
|
||||||
const m = new T.Mesh(gTechPlane, new T.MeshBasicMaterial({
|
const m = new T.Mesh(gTechPlane, new T.MeshBasicMaterial({
|
||||||
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
|
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
}));
|
}));
|
||||||
m.renderOrder = 1000;
|
m.renderOrder = 1000;
|
||||||
return m;
|
return m;
|
||||||
@@ -6945,7 +6986,7 @@
|
|||||||
uniforms: { map: { value: spriteMat.map } },
|
uniforms: { map: { value: spriteMat.map } },
|
||||||
vertexShader: _imTechVert,
|
vertexShader: _imTechVert,
|
||||||
fragmentShader: _imTechFrag,
|
fragmentShader: _imTechFrag,
|
||||||
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
|
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
const im = new T.InstancedMesh(geo, mat, IM_TECH_CAP);
|
const im = new T.InstancedMesh(geo, mat, IM_TECH_CAP);
|
||||||
im.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
im.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||||
@@ -7058,7 +7099,7 @@
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
fog: false,
|
fog: false,
|
||||||
side: T.DoubleSide,
|
side: T.DoubleSide, forceSinglePass: true,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
pChordBox = pool(noteG, () => new T.Mesh(
|
pChordBox = pool(noteG, () => new T.Mesh(
|
||||||
@@ -7070,7 +7111,7 @@
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
fog: false,
|
fog: false,
|
||||||
side: T.DoubleSide,
|
side: T.DoubleSide, forceSinglePass: true,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -7153,7 +7194,7 @@
|
|||||||
_imPMXFillMat = new T.ShaderMaterial({
|
_imPMXFillMat = new T.ShaderMaterial({
|
||||||
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
|
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
|
||||||
transparent: true, depthTest: false, depthWrite: false,
|
transparent: true, depthTest: false, depthWrite: false,
|
||||||
fog: false, side: T.DoubleSide,
|
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
imPMXFill = new T.InstancedMesh(gPMXFill, _imPMXFillMat, IM_STRUM_CAP);
|
imPMXFill = new T.InstancedMesh(gPMXFill, _imPMXFillMat, IM_STRUM_CAP);
|
||||||
imPMXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
imPMXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||||
@@ -7233,7 +7274,7 @@
|
|||||||
_imFHXFillMat = new T.ShaderMaterial({
|
_imFHXFillMat = new T.ShaderMaterial({
|
||||||
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
|
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
|
||||||
transparent: true, depthTest: false, depthWrite: false,
|
transparent: true, depthTest: false, depthWrite: false,
|
||||||
fog: false, side: T.DoubleSide,
|
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
imFHXFill = new T.InstancedMesh(gFHXFill, _imFHXFillMat, IM_STRUM_CAP);
|
imFHXFill = new T.InstancedMesh(gFHXFill, _imFHXFillMat, IM_STRUM_CAP);
|
||||||
imFHXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
imFHXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||||
@@ -7314,7 +7355,7 @@
|
|||||||
_imPMXLinesMat = new T.ShaderMaterial({
|
_imPMXLinesMat = new T.ShaderMaterial({
|
||||||
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
|
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
|
||||||
transparent: true, depthTest: false, depthWrite: false,
|
transparent: true, depthTest: false, depthWrite: false,
|
||||||
fog: false, side: T.DoubleSide,
|
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
imPMXLines = new T.InstancedMesh(gPMXLines, _imPMXLinesMat, IM_STRUM_CAP);
|
imPMXLines = new T.InstancedMesh(gPMXLines, _imPMXLinesMat, IM_STRUM_CAP);
|
||||||
imPMXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
imPMXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||||
@@ -7396,7 +7437,7 @@
|
|||||||
_imFHXLinesMat = new T.ShaderMaterial({
|
_imFHXLinesMat = new T.ShaderMaterial({
|
||||||
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
|
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
|
||||||
transparent: true, depthTest: false, depthWrite: false,
|
transparent: true, depthTest: false, depthWrite: false,
|
||||||
fog: false, side: T.DoubleSide,
|
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
});
|
});
|
||||||
imFHXLines = new T.InstancedMesh(gFHXLines, _imFHXLinesMat, IM_STRUM_CAP);
|
imFHXLines = new T.InstancedMesh(gFHXLines, _imFHXLinesMat, IM_STRUM_CAP);
|
||||||
imFHXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
imFHXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||||
@@ -7418,28 +7459,28 @@
|
|||||||
gPMXFill,
|
gPMXFill,
|
||||||
new T.MeshBasicMaterial({
|
new T.MeshBasicMaterial({
|
||||||
color: 0x000000, transparent: true, opacity: 1,
|
color: 0x000000, transparent: true, opacity: 1,
|
||||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
pFHXFill = pool(noteG, () => new T.Mesh(
|
pFHXFill = pool(noteG, () => new T.Mesh(
|
||||||
gFHXFill,
|
gFHXFill,
|
||||||
new T.MeshBasicMaterial({
|
new T.MeshBasicMaterial({
|
||||||
color: 0x000000, transparent: true, opacity: 1,
|
color: 0x000000, transparent: true, opacity: 1,
|
||||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
pMuteXLines = pool(noteG, () => new T.Mesh(
|
pMuteXLines = pool(noteG, () => new T.Mesh(
|
||||||
gPMXLines,
|
gPMXLines,
|
||||||
new T.MeshBasicMaterial({
|
new T.MeshBasicMaterial({
|
||||||
color: 0xffffff, transparent: true, opacity: 1,
|
color: 0xffffff, transparent: true, opacity: 1,
|
||||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
pFHXLines = pool(noteG, () => new T.Mesh(
|
pFHXLines = pool(noteG, () => new T.Mesh(
|
||||||
gFHXLines,
|
gFHXLines,
|
||||||
new T.MeshBasicMaterial({
|
new T.MeshBasicMaterial({
|
||||||
color: 0xffffff, transparent: true, opacity: 1,
|
color: 0xffffff, transparent: true, opacity: 1,
|
||||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -9886,6 +9927,105 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Per-frame rendering ─────────────────────────────────────────── */
|
/* ── Per-frame rendering ─────────────────────────────────────────── */
|
||||||
|
// ── GPU pre-warm (perf: first-appearance hitches) ─────────────────
|
||||||
|
// Three.js compiles a material's shader program and uploads a
|
||||||
|
// texture the first frame the owning object renders — profiled as
|
||||||
|
// mid-song frame spikes (getParameters / texSubImage2D). Pay those
|
||||||
|
// costs during init (load spinner) instead:
|
||||||
|
// _prewarmStatic() — ren.compile() over the fully-built scene
|
||||||
|
// + deterministic label textures (fret
|
||||||
|
// numbers in every per-frame style/colour
|
||||||
|
// combo).
|
||||||
|
// _prewarmChart(bundle) — chart-dependent labels (chord template
|
||||||
|
// names, section names); needs the ready
|
||||||
|
// bundle, so it runs once from the first
|
||||||
|
// 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. 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. (Note: the DOMINANT getParameters churn turned out to
|
||||||
|
// be Three's transparent-DoubleSide two-pass path — see the
|
||||||
|
// forceSinglePass comment in _spriteMat2MeshMat — this helper
|
||||||
|
// removes the label-swap contribution on top of that.)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function _prewarmStatic() {
|
||||||
|
// MAINTENANCE NOTE: this list must cover every deterministic
|
||||||
|
// (chart-independent) material/texture the per-frame paths can
|
||||||
|
// request lazily. Adding a new label style or sprite factory to
|
||||||
|
// drawNote()/update() without warming it here silently
|
||||||
|
// reintroduces a first-appearance texSubImage2D/compile spike
|
||||||
|
// mid-song. Chart-dependent labels (chord names, section names)
|
||||||
|
// live in _prewarmChart.
|
||||||
|
try {
|
||||||
|
if (ren && scene && cam) ren.compile(scene, cam);
|
||||||
|
} catch (e) { console.warn('[3D-Hwy] prewarm compile:', e); }
|
||||||
|
try {
|
||||||
|
// Fret-number labels in the per-frame style/colour combos.
|
||||||
|
for (let f = 0; f <= NFRETS; f++) {
|
||||||
|
_prewarmTex(txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret'));
|
||||||
|
_prewarmTex(txtMat(f, FRET_LABEL_GOLD_HEX, false, 'fretRow'));
|
||||||
|
_prewarmTex(txtMat(f, FRET_LABEL_IDLE_HEX, false, 'fretRow'));
|
||||||
|
_prewarmTex(txtMat(f, '#ffffff', false, 'ghostFret'));
|
||||||
|
}
|
||||||
|
// Teaching marks (drawNote _drawTeachMark): finger hints
|
||||||
|
// T/1-4 (teachFg) and scale degrees 0-11 (teachSd).
|
||||||
|
_prewarmTex(txtMat('T', '#7fd1ff', false, 'teachFg'));
|
||||||
|
for (let i = 1; i <= 4; i++) _prewarmTex(txtMat(String(i), '#7fd1ff', false, 'teachFg'));
|
||||||
|
for (let i = 0; i <= 11; i++) _prewarmTex(txtMat(String(i), '#ffcc66', false, 'teachSd'));
|
||||||
|
// Technique sprite factories (own caches, keyed by packed
|
||||||
|
// number): PM/FH mute X, hammer/pull triangles, bend
|
||||||
|
// chevron stacks, slide direction arrows — per string
|
||||||
|
// colour of the active palette.
|
||||||
|
_prewarmTex(palmMuteXSpriteMat());
|
||||||
|
_prewarmTex(fretHandMuteXSpriteMat());
|
||||||
|
const _nWarm = Math.min(
|
||||||
|
Math.max(nStr, 6),
|
||||||
|
(activePalette && activePalette.length) || 0);
|
||||||
|
for (let s = 0; s < _nWarm; s++) {
|
||||||
|
const hex = activePalette[s] || 0xffffff;
|
||||||
|
_prewarmTex(triMat(true, hex));
|
||||||
|
_prewarmTex(triMat(false, hex));
|
||||||
|
for (let st = 1; st <= 4; st++) _prewarmTex(bendChevronMat(st, hex));
|
||||||
|
const arrowHex = darkenHex(hex, 0.55);
|
||||||
|
_prewarmTex(slideArrowMat(true, arrowHex));
|
||||||
|
_prewarmTex(slideArrowMat(false, arrowHex));
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[3D-Hwy] prewarm labels:', e); }
|
||||||
|
}
|
||||||
|
function _prewarmChart(bundle) {
|
||||||
|
try {
|
||||||
|
const tpls = bundle && bundle.chordTemplates;
|
||||||
|
if (Array.isArray(tpls)) {
|
||||||
|
for (const tpl of tpls) {
|
||||||
|
if (tpl && tpl.name) _prewarmTex(txtMat(tpl.name, '#e8d080', true, 'chord'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secs = bundle && bundle.sections;
|
||||||
|
if (Array.isArray(secs)) {
|
||||||
|
for (const s of secs) {
|
||||||
|
if (s && s.name) _prewarmTex(txtMat(s.name, '#00cccc', true, 'section'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[3D-Hwy] prewarm chart labels:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
function update(bundle) {
|
function update(bundle) {
|
||||||
pbBeg(0);
|
pbBeg(0);
|
||||||
// [verdict glow] Apply the level-driven verdict brightness captured
|
// [verdict glow] Apply the level-driven verdict brightness captured
|
||||||
@@ -9909,10 +10049,14 @@
|
|||||||
// Lean sustain rendering is the default (see declaration above):
|
// Lean sustain rendering is the default (see declaration above):
|
||||||
// the trail/ribbon outline always draws; only the additive rail
|
// the trail/ribbon outline always draws; only the additive rail
|
||||||
// bloom halo is dropped. The full look (with bloom) is an opt-out.
|
// bloom halo is dropped. The full look (with bloom) is an opt-out.
|
||||||
// Cheap per-frame read so the console flag takes effect live.
|
// localStorage.getItem is a synchronous storage read — polled at
|
||||||
try {
|
// ~1 Hz instead of every frame; the console flag still takes
|
||||||
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
|
// effect live (within a second).
|
||||||
} catch (_) { _leanSus = true; }
|
if ((_leanSusPollCounter++ % 60) === 0) {
|
||||||
|
try {
|
||||||
|
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
|
||||||
|
} catch (_) { _leanSus = true; }
|
||||||
|
}
|
||||||
// Materialize the text-size multiplier from the user's slider.
|
// Materialize the text-size multiplier from the user's slider.
|
||||||
// textSize ∈ [0,1]; _textSizeMul ∈ [0.5, 1.5] with 0.5 ↦ 1.0×
|
// textSize ∈ [0,1]; _textSizeMul ∈ [0.5, 1.5] with 0.5 ↦ 1.0×
|
||||||
// so default behaviour matches what the renderer did pre-slider.
|
// so default behaviour matches what the renderer did pre-slider.
|
||||||
@@ -11856,7 +12000,7 @@
|
|||||||
const lblW = 28 * K, lblH = 9 * K;
|
const lblW = 28 * K, lblH = 9 * K;
|
||||||
const lbl = pChordLbl.get();
|
const lbl = pChordLbl.get();
|
||||||
const mat = txtMat(chordName, '#e8d080', true, 'chord');
|
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;
|
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.
|
// Gold chord name: slight +X shift from flush-left so it sits farther right.
|
||||||
const lblWS = lblW * _textSizeMul;
|
const lblWS = lblW * _textSizeMul;
|
||||||
@@ -11892,7 +12036,7 @@
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
const s = pChordLbl.get();
|
const s = pChordLbl.get();
|
||||||
const m = txtMat(text, colorHex, true, 'chord');
|
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.material.opacity = opacity;
|
||||||
s.position.set(baseX, hy, z);
|
s.position.set(baseX, hy, z);
|
||||||
s.scale.set(hlW, hlH, 1);
|
s.scale.set(hlW, hlH, 1);
|
||||||
@@ -12016,10 +12160,7 @@
|
|||||||
_seenChordFrets.add(f);
|
_seenChordFrets.add(f);
|
||||||
const lbl = pNoteFretLabel.get();
|
const lbl = pNoteFretLabel.get();
|
||||||
const mat = txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
const mat = txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
||||||
if (lbl.material.map !== mat.map) {
|
_setLabelMap(lbl, mat);
|
||||||
lbl.material.map = mat.map;
|
|
||||||
lbl.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
lbl.position.set(xFretMid(f), yMinF, z);
|
lbl.position.set(xFretMid(f), yMinF, z);
|
||||||
lbl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRET_LABEL');
|
lbl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRET_LABEL');
|
||||||
const _flS = 7.0 * K * (1 + 0.4 * chDt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f);
|
const _flS = 7.0 * K * (1 + 0.4 * chDt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f);
|
||||||
@@ -12642,10 +12783,7 @@
|
|||||||
const color = '#888888';
|
const color = '#888888';
|
||||||
const sp = pFretColMarker.get();
|
const sp = pFretColMarker.get();
|
||||||
const m = txtMat(f, color, false, 'noteFret');
|
const m = txtMat(f, color, false, 'noteFret');
|
||||||
if (sp.material.map !== m.map) {
|
_setLabelMap(sp, m);
|
||||||
sp.material.map = m.map;
|
|
||||||
sp.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
sp.material.opacity = 0.85 * _colFadeIn;
|
sp.material.opacity = 0.85 * _colFadeIn;
|
||||||
sp.position.set(xFretMid(f), labelY, z);
|
sp.position.set(xFretMid(f), labelY, z);
|
||||||
// Z-proportional: sits between chord frame and note gem
|
// Z-proportional: sits between chord frame and note gem
|
||||||
@@ -13928,10 +14066,7 @@
|
|||||||
_frameLabeledKeys.add(_flFrameKey);
|
_frameLabeledKeys.add(_flFrameKey);
|
||||||
const fretLabel = pNoteFretLabel.get();
|
const fretLabel = pNoteFretLabel.get();
|
||||||
const cachedMat = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
const cachedMat = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
||||||
if (fretLabel.material.map !== cachedMat.map) {
|
_setLabelMap(fretLabel, cachedMat);
|
||||||
fretLabel.material.map = cachedMat.map;
|
|
||||||
fretLabel.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
fretLabel.position.set(x, labelY, noteZ);
|
fretLabel.position.set(x, labelY, noteZ);
|
||||||
fretLabel.renderOrder = renderOrderForLayerAtZ(noteZ,
|
fretLabel.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||||
_isArpNote
|
_isArpNote
|
||||||
@@ -13956,10 +14091,7 @@
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
const spr = pTeachMarkLbl.get();
|
const spr = pTeachMarkLbl.get();
|
||||||
const m = txtMat(text, colorHex, false, cacheKey);
|
const m = txtMat(text, colorHex, false, cacheKey);
|
||||||
if (spr.material.map !== m.map) {
|
_setLabelMap(spr, m);
|
||||||
spr.material.map = m.map;
|
|
||||||
spr.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
spr.position.set(x + dx, labelY, noteZ);
|
spr.position.set(x + dx, labelY, noteZ);
|
||||||
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
|
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||||
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
|
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
|
||||||
@@ -13993,10 +14125,7 @@
|
|||||||
const _isArp2 = arpBounds !== null;
|
const _isArp2 = arpBounds !== null;
|
||||||
const fl2 = pNoteFretLabel.get();
|
const fl2 = pNoteFretLabel.get();
|
||||||
const cm2 = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
const cm2 = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
||||||
if (fl2.material.map !== cm2.map) {
|
_setLabelMap(fl2, cm2);
|
||||||
fl2.material.map = cm2.map;
|
|
||||||
fl2.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
fl2.position.set(x, _labelY2, noteZ);
|
fl2.position.set(x, _labelY2, noteZ);
|
||||||
fl2.renderOrder = renderOrderForLayerAtZ(noteZ,
|
fl2.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||||
_isArp2
|
_isArp2
|
||||||
@@ -14962,6 +15091,12 @@
|
|||||||
_invertedForBoard = _invertedCached;
|
_invertedForBoard = _invertedCached;
|
||||||
_leftyForBoard = _leftyCached;
|
_leftyForBoard = _leftyCached;
|
||||||
if (!initScene()) { _unsubscribeFocus(); _rejectReady(new Error('initScene failed')); return; }
|
if (!initScene()) { _unsubscribeFocus(); _rejectReady(new Error('initScene failed')); return; }
|
||||||
|
// Pre-compile shaders + upload deterministic label
|
||||||
|
// textures while the load spinner is still up; the
|
||||||
|
// chart-dependent half runs on first draw() (bundle
|
||||||
|
// arrays are only guaranteed populated post-ready).
|
||||||
|
_prewarmStatic();
|
||||||
|
_chartPrewarmed = false;
|
||||||
const sz = canvasSize(highwayCanvas);
|
const sz = canvasSize(highwayCanvas);
|
||||||
// Mark ready before RAF so any resize(w,h) calls that arrive
|
// Mark ready before RAF so any resize(w,h) calls that arrive
|
||||||
// in the meantime (e.g. from sizeCanvases()) are applied directly.
|
// in the meantime (e.g. from sizeCanvases()) are applied directly.
|
||||||
@@ -15000,6 +15135,10 @@
|
|||||||
|
|
||||||
draw(bundle) {
|
draw(bundle) {
|
||||||
if (!_isReady) return;
|
if (!_isReady) return;
|
||||||
|
if (!_chartPrewarmed) {
|
||||||
|
_chartPrewarmed = true;
|
||||||
|
_prewarmChart(bundle);
|
||||||
|
}
|
||||||
_invertedCached = !!bundle.inverted;
|
_invertedCached = !!bundle.inverted;
|
||||||
_leftyCached = !!bundle.lefty;
|
_leftyCached = !!bundle.lefty;
|
||||||
const newNStr = resolveStringCount(bundle);
|
const newNStr = resolveStringCount(bundle);
|
||||||
@@ -15042,25 +15181,40 @@
|
|||||||
// for the pre-settle (too-tall) size and crops the near strings
|
// for the pre-settle (too-tall) size and crops the near strings
|
||||||
// / fret numbers until the user un/re-maximizes the window.
|
// / fret numbers until the user un/re-maximizes the window.
|
||||||
if (highwayCanvas) {
|
if (highwayCanvas) {
|
||||||
const box = canvasSize(highwayCanvas);
|
// Backing-store drift (branch 1) is detected with cheap
|
||||||
if (highwayCanvas.width !== _lastHwW || highwayCanvas.height !== _lastHwH) {
|
// property reads every frame. The CSS-box checks (branches
|
||||||
_lastHwW = highwayCanvas.width;
|
// 2/3) need canvasSize() → getBoundingClientRect(), a
|
||||||
_lastHwH = highwayCanvas.height;
|
// forced layout read — profiled at ~1.2% of throttled
|
||||||
if (box.w > 0 && box.h > 0) applySize(box.w, box.h);
|
// main-thread time when run per frame. Throttle the box
|
||||||
} else if (box.w > 0 && box.h > 0 &&
|
// read to every 10th frame (plus whenever the backing
|
||||||
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
|
// store changed or the wrap isn't pinned yet): the layout
|
||||||
applySize(box.w, box.h);
|
// settle it exists to catch plays out over hundreds of ms
|
||||||
} else if (!_wrapPinned && box.w > 0 && box.h > 0 &&
|
// right after a song opens, so a ~166 ms detection cadence
|
||||||
highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
|
// loses nothing visible.
|
||||||
// 3. The overlay pin couldn't be applied at init because
|
const _bsChanged = highwayCanvas.width !== _lastHwW
|
||||||
// #highway had no layout yet (offsetWidth/Height === 0),
|
|| highwayCanvas.height !== _lastHwH;
|
||||||
// so applySize() only set the wrap height. The canvas has
|
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
|
||||||
// now laid out but to the same logical size, so neither
|
if (_bsChanged || !_wrapPinned || _boxCheckCountdown === 0) {
|
||||||
// drift branch above fires — re-run applySize to pin the
|
const box = canvasSize(highwayCanvas);
|
||||||
// wrap to the canvas box now that its offsets are real.
|
if (_bsChanged) {
|
||||||
// Otherwise the overlay stays at top:0;left:0;right:0 and
|
_lastHwW = highwayCanvas.width;
|
||||||
// a strip of #highway is exposed on first load / split.
|
_lastHwH = highwayCanvas.height;
|
||||||
applySize(box.w, box.h);
|
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);
|
update(bundle);
|
||||||
|
|||||||
+12
-1
@@ -9777,6 +9777,12 @@ async function startSongCountIn() {
|
|||||||
|
|
||||||
// Time display + highway sync
|
// Time display + highway sync
|
||||||
let lastAudioTime = 0;
|
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(() => {
|
setInterval(() => {
|
||||||
let ct = _audioTime();
|
let ct = _audioTime();
|
||||||
const dur = _audioDuration();
|
const dur = _audioDuration();
|
||||||
@@ -9804,7 +9810,12 @@ setInterval(() => {
|
|||||||
ct = lastAudioTime;
|
ct = lastAudioTime;
|
||||||
}
|
}
|
||||||
lastAudioTime = ct;
|
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) {
|
if (dur) {
|
||||||
_maybeRefreshSectionPracticeDuration(dur);
|
_maybeRefreshSectionPracticeDuration(dur);
|
||||||
}
|
}
|
||||||
|
|||||||
+169
-92
@@ -122,6 +122,24 @@ function createHighway() {
|
|||||||
// where offsetParent === null isn't enough.
|
// where offsetParent === null isn't enough.
|
||||||
let _visibleOverride = null;
|
let _visibleOverride = null;
|
||||||
let _lastVisible = null;
|
let _lastVisible = null;
|
||||||
|
// Throttled DOM visibility sampling. Reading canvas.offsetParent
|
||||||
|
// every rAF frame forces a style/layout recalc — profiled at ~0.5 s
|
||||||
|
// main-thread self-time over a 63 s session. The displayed state
|
||||||
|
// changes rarely (navigate / splitscreen panel toggle), so the DOM
|
||||||
|
// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached
|
||||||
|
// value serves the frames in between (worst-case transition latency
|
||||||
|
// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal).
|
||||||
|
// Set _domVisSampledFrame to NaN to force a fresh sample on the next
|
||||||
|
// check (done on init, canvas replace, resize, and override-clear so
|
||||||
|
// deliberate transitions don't wait out the throttle window).
|
||||||
|
// NOTE those manual resets are LATENCY optimizations, not correctness
|
||||||
|
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
|
||||||
|
// frames regardless, so a visibility-affecting path that forgets to
|
||||||
|
// reset self-heals within ~10 frames — stale visibility can never be
|
||||||
|
// served indefinitely.
|
||||||
|
const _DOM_VIS_CHECK_FRAMES = 10;
|
||||||
|
let _domVisCached = false;
|
||||||
|
let _domVisSampledFrame = NaN;
|
||||||
let animFrame = null;
|
let animFrame = null;
|
||||||
// Paused-render throttle (feedBack#654). The rAF loop runs
|
// Paused-render throttle (feedBack#654). The rAF loop runs
|
||||||
// unconditionally and only gates on visibility + ready, never on
|
// unconditionally and only gates on visibility + ready, never on
|
||||||
@@ -733,103 +751,121 @@ function createHighway() {
|
|||||||
// on the freshly-mounted canvas.
|
// on the freshly-mounted canvas.
|
||||||
let _currentCanvasContextType = '2d';
|
let _currentCanvasContextType = '2d';
|
||||||
|
|
||||||
|
// One persistent bundle object per createHighway() instance —
|
||||||
|
// _makeBundle() mutates its fields in place each call instead of
|
||||||
|
// allocating a fresh ~35-field object per rAF frame (steady GC churn
|
||||||
|
// on weak hardware, ×N under splitscreen). Consequences for
|
||||||
|
// consumers: the bundle OBJECT's identity is stable across frames
|
||||||
|
// and carries no meaning; its field values are only valid for the
|
||||||
|
// duration of the current draw call. Array fields (`notes`,
|
||||||
|
// `chords`, `handShapes`, `chordTemplates`, ...) still swap
|
||||||
|
// reference whenever chart data changes — field-identity caches
|
||||||
|
// (e.g. highway_3d's merge caches) rely on that invariant.
|
||||||
|
const _bundleReused = {};
|
||||||
|
|
||||||
function _makeBundle() {
|
function _makeBundle() {
|
||||||
// Snapshot of current factory state passed to each renderer call.
|
// Snapshot of current factory state passed to each renderer call.
|
||||||
// Arrays and songInfo are LIVE references, not copies — the bundle
|
// Arrays and songInfo are LIVE references, not copies — the
|
||||||
// itself is rebuilt each frame but its `notes`, `chords`,
|
// bundle's `notes`, `chords`, `anchors`, `beats`, etc. point at
|
||||||
// `anchors`, `beats`, etc. point at closure state. Renderers
|
// closure state. Renderers MUST NOT mutate these; treat them as
|
||||||
// MUST NOT mutate these; treat them as read-only. We don't
|
// read-only. We don't Object.freeze or deep-copy for per-frame
|
||||||
// Object.freeze or deep-copy for per-frame allocation cost reasons.
|
// cost reasons.
|
||||||
return {
|
const b = _bundleReused;
|
||||||
// Timing
|
// Timing
|
||||||
currentTime,
|
b.currentTime = currentTime;
|
||||||
songInfo,
|
b.songInfo = songInfo;
|
||||||
isReady: ready,
|
b.isReady = ready;
|
||||||
// True while the chart clock is actively advancing; false when
|
// True while the chart clock is actively advancing; false when
|
||||||
// audio is paused / stalled / mid-seek (setTime has kept getting
|
// audio is paused / stalled / mid-seek (setTime has kept getting
|
||||||
// the same t for > _CHART_MAX_INTERP_MS). This is the same
|
// the same t for > _CHART_MAX_INTERP_MS). This is the same
|
||||||
// predicate getTime() uses to decide raw-vs-interpolated, and the
|
// predicate getTime() uses to decide raw-vs-interpolated, and the
|
||||||
// no-anchor boot state reads as not-playing — matching getTime()
|
// no-anchor boot state reads as not-playing — matching getTime()
|
||||||
// returning raw chartTime there. Renderers that run their own
|
// returning raw chartTime there. Renderers that run their own
|
||||||
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
|
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
|
||||||
// back to raw instead of extrapolating forward against a frozen
|
// back to raw instead of extrapolating forward against a frozen
|
||||||
// audio sample. Undefined on downlevel hosts → those renderers
|
// audio sample. Undefined on downlevel hosts → those renderers
|
||||||
// keep their own staleness-based fallback.
|
// keep their own staleness-based fallback.
|
||||||
isPlaying: !Number.isNaN(_chartAnchorPerfNow)
|
b.isPlaying = !Number.isNaN(_chartAnchorPerfNow)
|
||||||
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS,
|
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS;
|
||||||
|
|
||||||
// Chart content (filter-aware — difficulty-filtered arrays
|
// Chart content (filter-aware — difficulty-filtered arrays
|
||||||
// preferred; raw arrays are the fallback when no ladder data).
|
// preferred; raw arrays are the fallback when no ladder data).
|
||||||
notes: _filteredNotes !== null ? _filteredNotes : notes,
|
b.notes = _filteredNotes !== null ? _filteredNotes : notes;
|
||||||
chords: _filteredChords !== null ? _filteredChords : chords,
|
b.chords = _filteredChords !== null ? _filteredChords : chords;
|
||||||
anchors: _filteredAnchors !== null ? _filteredAnchors : anchors,
|
b.anchors = _filteredAnchors !== null ? _filteredAnchors : anchors;
|
||||||
beats,
|
b.beats = beats;
|
||||||
sections,
|
b.sections = sections;
|
||||||
chordTemplates,
|
b.chordTemplates = chordTemplates;
|
||||||
stringCount,
|
b.stringCount = stringCount;
|
||||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||||
// instrument’s standard open-string layout). Live reference.
|
// instrument’s standard open-string layout). Live reference.
|
||||||
tuning: songInfo?.tuning,
|
b.tuning = songInfo?.tuning;
|
||||||
capo: songInfo?.capo,
|
b.capo = songInfo?.capo;
|
||||||
lyrics,
|
b.lyrics = lyrics;
|
||||||
lyricsSource,
|
b.lyricsSource = lyricsSource;
|
||||||
toneChanges,
|
b.toneChanges = toneChanges;
|
||||||
toneBase,
|
b.toneBase = toneBase;
|
||||||
// Drum tab payload (or null when the active arrangement has
|
// Drum tab payload (or null when the active arrangement has
|
||||||
// no drum_tab). Live reference — renderers MUST treat as
|
// no drum_tab). Live reference — renderers MUST treat as
|
||||||
// read-only. Plugins should prefer this over decoding the
|
// read-only. Plugins should prefer this over decoding the
|
||||||
// standard `notes` stream when present; absence is the
|
// standard `notes` stream when present; absence is the
|
||||||
// signal to fall back to legacy MIDI-encoded drums.
|
// signal to fall back to legacy MIDI-encoded drums.
|
||||||
drumTab,
|
b.drumTab = drumTab;
|
||||||
|
|
||||||
// Master-difficulty (feedBack#48)
|
// Master-difficulty (feedBack#48)
|
||||||
mastery: _mastery,
|
b.mastery = _mastery;
|
||||||
hasPhraseData: !!(_phrases && _phrases.length > 0),
|
b.hasPhraseData = !!(_phrases && _phrases.length > 0);
|
||||||
// When phrase data authored ANY handshape, respect the filtered
|
// When phrase data authored ANY handshape, respect the filtered
|
||||||
// list strictly (even when this difficulty leaves it empty) —
|
// list strictly (even when this difficulty leaves it empty) —
|
||||||
// otherwise low-mastery levels would surface arp hints that
|
// otherwise low-mastery levels would surface arp hints that
|
||||||
// don't belong. Only fall back to the flat list when the
|
// don't belong. Only fall back to the flat list when the
|
||||||
// phrase data carries no handshapes at all (common on DLC
|
// phrase data carries no handshapes at all (common on DLC
|
||||||
// where handshapes ship on the arrangement root).
|
// where handshapes ship on the arrangement root).
|
||||||
handShapes: (_filteredHandShapes !== null && _phrasesHaveHandShapes)
|
b.handShapes = (_filteredHandShapes !== null && _phrasesHaveHandShapes)
|
||||||
? _filteredHandShapes
|
? _filteredHandShapes
|
||||||
: handShapes,
|
: handShapes;
|
||||||
|
|
||||||
// Display flags
|
// Display flags
|
||||||
inverted: _inverted,
|
b.inverted = _inverted;
|
||||||
lefty: _lefty,
|
b.lefty = _lefty;
|
||||||
renderScale: _effectiveRenderScale(),
|
b.renderScale = _effectiveRenderScale();
|
||||||
lyricsVisible: showLyrics,
|
b.lyricsVisible = showLyrics;
|
||||||
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
||||||
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
|
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
|
||||||
// finger-hint pref rides alongside (default on, independently hideable).
|
// finger-hint pref rides alongside (default on, independently hideable).
|
||||||
teachingMarksVisible: _showTeachingMarks,
|
b.teachingMarksVisible = _showTeachingMarks;
|
||||||
fingerHintsVisible: _showFingerHints,
|
b.fingerHintsVisible = _showFingerHints;
|
||||||
|
|
||||||
// 2D-style helpers (renderers that don't need these can ignore).
|
// 2D-style helpers (renderers that don't need these can ignore).
|
||||||
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
||||||
// the factory-level version writes to the default renderer's
|
// the factory-level version writes to the default renderer's
|
||||||
// closure ctx, which is null for custom renderers. Renderers
|
// closure ctx, which is null for custom renderers. Renderers
|
||||||
// that need lefty-aware text should check `bundle.lefty` and
|
// that need lefty-aware text should check `bundle.lefty` and
|
||||||
// apply the mirror transform themselves on their own context.
|
// apply the mirror transform themselves on their own context.
|
||||||
project,
|
b.project = project;
|
||||||
fretX,
|
b.fretX = fretX;
|
||||||
|
// Windowed-iteration helpers (stable references): lower-bound
|
||||||
|
// binary searches so custom viz don't full-scan chart arrays per
|
||||||
|
// frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime
|
||||||
|
// keys on `.time` (beats / anchors / sections).
|
||||||
|
b.lowerBoundT = bsearch;
|
||||||
|
b.lowerBoundTime = bsearchTime;
|
||||||
|
|
||||||
// Per-note judgment overlay (feedBack#254). Renderers call
|
// Per-note judgment overlay (feedBack#254). Renderers call
|
||||||
// this per visible note / chord-note to find out whether a
|
// this per visible note / chord-note to find out whether a
|
||||||
// scorer (note_detect) has flagged it hit / actively-held /
|
// scorer (note_detect) has flagged it hit / actively-held /
|
||||||
// missed, so the gem itself can light up instead of relying
|
// missed, so the gem itself can light up instead of relying
|
||||||
// on an overlay ring. Returns null when no provider is set
|
// on an overlay ring. Returns null when no provider is set
|
||||||
// or it reports nothing for this note; otherwise
|
// or it reports nothing for this note; otherwise
|
||||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||||
getNoteState: _noteState, // stable reference — no per-frame allocation
|
b.getNoteState = _noteState; // stable reference
|
||||||
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
||||||
// attached" apart from "no provider, getNoteState always
|
// attached" apart from "no provider, getNoteState always
|
||||||
// returns null" — `getNoteState` always exists on the bundle
|
// returns null" — `getNoteState` always exists on the bundle
|
||||||
// so its presence alone isn't a useful "detect mode" signal.
|
// so its presence alone isn't a useful "detect mode" signal.
|
||||||
// Renderers gate verdict-window cull / draw extensions on this.
|
// Renderers gate verdict-window cull / draw extensions on this.
|
||||||
getNoteStateProvider: _getNoteStateProvider, // stable — see above
|
b.getNoteStateProvider = _getNoteStateProvider; // stable — see above
|
||||||
};
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _defaultRenderer = {
|
const _defaultRenderer = {
|
||||||
@@ -1047,6 +1083,7 @@ function createHighway() {
|
|||||||
// suppress the first transition. Reset to null so the next
|
// suppress the first transition. Reset to null so the next
|
||||||
// rAF tick re-emits unconditionally.
|
// rAF tick re-emits unconditionally.
|
||||||
_lastVisible = null;
|
_lastVisible = null;
|
||||||
|
_domVisSampledFrame = NaN; // fresh canvas → fresh DOM sample
|
||||||
// Defensive notify for plugins / overlays that cache the
|
// Defensive notify for plugins / overlays that cache the
|
||||||
// canvas element across events. Lazy lookups via
|
// canvas element across events. Lazy lookups via
|
||||||
// getElementById('highway') do not need this — they'll pick
|
// getElementById('highway') do not need this — they'll pick
|
||||||
@@ -1246,7 +1283,14 @@ function createHighway() {
|
|||||||
// hosts that need those use setVisible() instead.
|
// hosts that need those use setVisible() instead.
|
||||||
function _isHighwayVisible() {
|
function _isHighwayVisible() {
|
||||||
if (_visibleOverride !== null) return _visibleOverride;
|
if (_visibleOverride !== null) return _visibleOverride;
|
||||||
return !!(canvas && canvas.offsetParent !== null);
|
// Throttled offsetParent read — see _DOM_VIS_CHECK_FRAMES above.
|
||||||
|
if (Number.isNaN(_domVisSampledFrame)
|
||||||
|
|| ((_frameIdx - _domVisSampledFrame) | 0) >= _DOM_VIS_CHECK_FRAMES
|
||||||
|
|| ((_frameIdx - _domVisSampledFrame) | 0) < 0) {
|
||||||
|
_domVisCached = !!(canvas && canvas.offsetParent !== null);
|
||||||
|
_domVisSampledFrame = _frameIdx;
|
||||||
|
}
|
||||||
|
return _domVisCached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit only on transition so renderer-side listeners aren't woken
|
// Emit only on transition so renderer-side listeners aren't woken
|
||||||
@@ -1513,7 +1557,13 @@ function createHighway() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawBeats(W, H) {
|
function drawBeats(W, H) {
|
||||||
for (const beat of beats) {
|
// Window the beat scan — a long song carries thousands of beats
|
||||||
|
// and iterating (and projecting) all of them per frame was pure
|
||||||
|
// waste; project() culling stays as the safety net.
|
||||||
|
const lo = bsearchTime(beats, currentTime - 0.25);
|
||||||
|
const hi = bsearchTime(beats, currentTime + VISIBLE_SECONDS + 0.25);
|
||||||
|
for (let i = lo; i < hi; i++) {
|
||||||
|
const beat = beats[i];
|
||||||
const tOff = beat.time - currentTime;
|
const tOff = beat.time - currentTime;
|
||||||
const p = project(tOff);
|
const p = project(tOff);
|
||||||
if (!p || p.scale < 0.06) continue;
|
if (!p || p.scale < 0.06) continue;
|
||||||
@@ -2624,6 +2674,19 @@ function createHighway() {
|
|||||||
}
|
}
|
||||||
return lo;
|
return lo;
|
||||||
}
|
}
|
||||||
|
// Lower-bound binary search for `.time`-keyed arrays (beats, anchors,
|
||||||
|
// sections) — bsearch/bsearchChords key on `.t` and would compare
|
||||||
|
// against undefined here. Exposed to custom viz as
|
||||||
|
// bundle.lowerBoundTime.
|
||||||
|
function bsearchTime(arr, time) {
|
||||||
|
let lo = 0, hi = arr.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (arr[mid].time < time) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
|
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
|
||||||
//
|
//
|
||||||
@@ -2956,6 +3019,7 @@ function createHighway() {
|
|||||||
init(canvasEl, container) {
|
init(canvasEl, container) {
|
||||||
canvas = canvasEl;
|
canvas = canvasEl;
|
||||||
_resizeContainer = container || null;
|
_resizeContainer = container || null;
|
||||||
|
_domVisSampledFrame = NaN; // new mount → fresh DOM sample
|
||||||
// Size the canvas BEFORE installing the renderer so
|
// Size the canvas BEFORE installing the renderer so
|
||||||
// _setRenderer's init/resize calls see the real dimensions
|
// _setRenderer's init/resize calls see the real dimensions
|
||||||
// instead of the default 300x150 backing store. Otherwise
|
// instead of the default 300x150 backing store. Otherwise
|
||||||
@@ -2997,6 +3061,9 @@ function createHighway() {
|
|||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
// Layout just changed (window resize / container swap) —
|
||||||
|
// re-sample DOM visibility on the next check.
|
||||||
|
_domVisSampledFrame = NaN;
|
||||||
let w, h;
|
let w, h;
|
||||||
if (_resizeContainer) {
|
if (_resizeContainer) {
|
||||||
const rect = _resizeContainer.getBoundingClientRect();
|
const rect = _resizeContainer.getBoundingClientRect();
|
||||||
@@ -3771,6 +3838,10 @@ function createHighway() {
|
|||||||
// rAF tick.
|
// rAF tick.
|
||||||
setVisible(v) {
|
setVisible(v) {
|
||||||
_visibleOverride = (v === null || v === undefined) ? null : !!v;
|
_visibleOverride = (v === null || v === undefined) ? null : !!v;
|
||||||
|
// Clearing the override resumes DOM-based detection — force a
|
||||||
|
// fresh offsetParent sample so the resulting transition (if
|
||||||
|
// any) emits now, not after the throttle window.
|
||||||
|
if (_visibleOverride === null) _domVisSampledFrame = NaN;
|
||||||
_emitVisibilityIfChanged();
|
_emitVisibilityIfChanged();
|
||||||
},
|
},
|
||||||
// Snapshot of the current visibility state (the override if
|
// Snapshot of the current visibility state (the override if
|
||||||
@@ -3779,6 +3850,12 @@ function createHighway() {
|
|||||||
// can call this once to sync their initial state — the event
|
// can call this once to sync their initial state — the event
|
||||||
// is transition-only and won't re-fire for late subscribers.
|
// is transition-only and won't re-fire for late subscribers.
|
||||||
isVisible() {
|
isVisible() {
|
||||||
|
// Force a fresh DOM sample — this is a documented "live DOM
|
||||||
|
// check" for late subscribers seeding initial state, so it
|
||||||
|
// must not serve the rAF loop's throttled cache (up to
|
||||||
|
// ~166 ms stale). Called rarely; the layout-read cost that
|
||||||
|
// motivated the throttle only matters per-frame.
|
||||||
|
_domVisSampledFrame = NaN;
|
||||||
return _isHighwayVisible();
|
return _isHighwayVisible();
|
||||||
},
|
},
|
||||||
getNotes() { return notes; },
|
getNotes() { return notes; },
|
||||||
|
|||||||
+78
-14
@@ -24,6 +24,18 @@
|
|||||||
let rafId = null, running = false;
|
let rafId = null, running = false;
|
||||||
let lastMove = 0, lastUpNext = 0;
|
let lastMove = 0, lastUpNext = 0;
|
||||||
let openPop = null; // { btn, pop }
|
let openPop = null; // { btn, pop }
|
||||||
|
// Hover state over #player-controls, maintained by mouseenter/mouseleave
|
||||||
|
// (wired in start()). tickIdle previously called matches(':hover') every
|
||||||
|
// rAF frame, which forces a style recalc — profiled hot (feedBack perf).
|
||||||
|
let overControls = false;
|
||||||
|
const _onControlsEnter = () => { overControls = true; };
|
||||||
|
const _onControlsLeave = () => { overControls = false; };
|
||||||
|
// Up-Next pill: cached element refs (re-resolved when detached) and
|
||||||
|
// last-written values, so the 6 Hz recompute only touches the DOM when
|
||||||
|
// something actually changed — unconditional textContent/width writes
|
||||||
|
// re-triggered layout every tick.
|
||||||
|
let upnextEls = null; // { pill, nm, eta, fill }
|
||||||
|
let upnextLast = { name: null, eta: null, prog: -1, hidden: null };
|
||||||
|
|
||||||
// ── v3 UI signal + plugin-control slot API ───────────────────────────────
|
// ── v3 UI signal + plugin-control slot API ───────────────────────────────
|
||||||
// Lets plugins detect v3 (window.feedBack.uiVersion === 'v3') and mount
|
// Lets plugins detect v3 (window.feedBack.uiVersion === 'v3') and mount
|
||||||
@@ -169,28 +181,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Up Next pill ─────────────────────────────────────────────────────────
|
// ── Up Next pill ─────────────────────────────────────────────────────────
|
||||||
|
function _upnextRefs() {
|
||||||
|
// Cache refs; re-resolve only when a node detached (screen re-mount).
|
||||||
|
if (!upnextEls || !upnextEls.pill || !upnextEls.pill.isConnected) {
|
||||||
|
const pill = $('v3-upnext');
|
||||||
|
if (!pill) return null;
|
||||||
|
upnextEls = {
|
||||||
|
pill,
|
||||||
|
nm: $('v3-upnext-name'),
|
||||||
|
eta: $('v3-upnext-eta'),
|
||||||
|
fill: $('v3-upnext-bar-fill'),
|
||||||
|
};
|
||||||
|
// Fresh nodes → forget last-written state so everything re-syncs.
|
||||||
|
upnextLast = { name: null, eta: null, prog: -1, hidden: null };
|
||||||
|
}
|
||||||
|
return upnextEls;
|
||||||
|
}
|
||||||
|
function _upnextSetHidden(els, hidden) {
|
||||||
|
if (upnextLast.hidden === hidden) return;
|
||||||
|
upnextLast.hidden = hidden;
|
||||||
|
els.pill.classList.toggle('hidden', hidden);
|
||||||
|
}
|
||||||
function updateUpNext() {
|
function updateUpNext() {
|
||||||
const pill = $('v3-upnext');
|
const els = _upnextRefs();
|
||||||
if (!pill) return;
|
if (!els) return;
|
||||||
// Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON).
|
// Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON).
|
||||||
if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; }
|
if (window.feedBack && window.feedBack.showUpNext === false) { _upnextSetHidden(els, true); return; }
|
||||||
const hw = window.highway;
|
const hw = window.highway;
|
||||||
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
|
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
|
||||||
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
|
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
|
||||||
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { pill.classList.add('hidden'); return; }
|
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { _upnextSetHidden(els, true); return; }
|
||||||
let next = null;
|
let next = null;
|
||||||
for (let i = 0; i < secs.length; i++) {
|
for (let i = 0; i < secs.length; i++) {
|
||||||
if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; }
|
if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; }
|
||||||
}
|
}
|
||||||
if (!next) { pill.classList.add('hidden'); return; }
|
if (!next) { _upnextSetHidden(els, true); return; }
|
||||||
const dt = Math.max(0, next.time - t);
|
const dt = Math.max(0, next.time - t);
|
||||||
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
|
// Coarsened eta (whole seconds from 10 s out, 1 decimal inside) +
|
||||||
if (nm) nm.textContent = next.name || '—';
|
// write-on-change: drops textContent writes (each a layout pass)
|
||||||
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
|
// from ~6/s to ~1/s.
|
||||||
|
const name = next.name || '—';
|
||||||
|
const etaText = 'in ' + (dt >= 10 ? Math.round(dt) + '' : dt.toFixed(1)) + 's';
|
||||||
|
if (els.nm && name !== upnextLast.name) { upnextLast.name = name; els.nm.textContent = name; }
|
||||||
|
if (els.eta && etaText !== upnextLast.eta) { upnextLast.eta = etaText; els.eta.textContent = etaText; }
|
||||||
// Progress bar: fraction of the current section elapsed toward `next`.
|
// Progress bar: fraction of the current section elapsed toward `next`.
|
||||||
// Previous boundary is the last section at/before now (else song start).
|
// Previous boundary is the last section at/before now (else song start).
|
||||||
const fill = $('v3-upnext-bar-fill');
|
if (els.fill) {
|
||||||
if (fill) {
|
|
||||||
let prevT = 0;
|
let prevT = 0;
|
||||||
for (let i = 0; i < secs.length; i++) {
|
for (let i = 0; i < secs.length; i++) {
|
||||||
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
|
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
|
||||||
@@ -198,9 +234,15 @@
|
|||||||
}
|
}
|
||||||
const span = next.time - prevT;
|
const span = next.time - prevT;
|
||||||
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
|
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
|
||||||
fill.style.width = (prog * 100).toFixed(1) + '%';
|
const q = Math.round(prog * 1000) / 1000;
|
||||||
|
if (q !== upnextLast.prog) {
|
||||||
|
upnextLast.prog = q;
|
||||||
|
// scaleX is compositor-only — width writes re-ran layout.
|
||||||
|
// Pairs with transform-origin:left on #v3-upnext-bar-fill.
|
||||||
|
els.fill.style.transform = 'scaleX(' + q + ')';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pill.classList.remove('hidden');
|
_upnextSetHidden(els, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
|
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
|
||||||
@@ -230,8 +272,8 @@
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
const playBtn = $('btn-play');
|
const playBtn = $('btn-play');
|
||||||
const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true';
|
const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true';
|
||||||
const controls = $('player-controls');
|
// overControls maintained by mouseenter/mouseleave (see start()) —
|
||||||
const overControls = controls && typeof controls.matches === 'function' && controls.matches(':hover');
|
// matches(':hover') here forced a per-frame style recalc.
|
||||||
// Keep the transport up while paused, hovering it, or a popover is open.
|
// Keep the transport up while paused, hovering it, or a popover is open.
|
||||||
if (openPop || overControls || !playing) { lastMove = now(); return; }
|
if (openPop || overControls || !playing) { lastMove = now(); return; }
|
||||||
if (now() - lastMove > IDLE_MS) {
|
if (now() - lastMove > IDLE_MS) {
|
||||||
@@ -249,6 +291,16 @@
|
|||||||
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
|
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
|
||||||
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
|
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
|
||||||
syncLyricsIcon();
|
syncLyricsIcon();
|
||||||
|
// Reconcile the edge-driven hover flag against ground truth at
|
||||||
|
// this throttled cadence (~6 Hz, not per frame). Covers both
|
||||||
|
// failure modes of pure mouseenter/mouseleave tracking: a
|
||||||
|
// missed mouseleave (controls hidden/detached under the
|
||||||
|
// pointer → flag stuck true, transport never auto-hides) and
|
||||||
|
// a re-created #player-controls node whose listeners were
|
||||||
|
// lost (flag stuck false-ish / dead). matches(':hover') on a
|
||||||
|
// detached node is simply false, so this also self-clears.
|
||||||
|
const c = $('player-controls');
|
||||||
|
overControls = !!(c && typeof c.matches === 'function' && c.matches(':hover'));
|
||||||
}
|
}
|
||||||
tickIdle();
|
tickIdle();
|
||||||
rafId = requestAnimationFrame(loop);
|
rafId = requestAnimationFrame(loop);
|
||||||
@@ -263,6 +315,12 @@
|
|||||||
wireRail();
|
wireRail();
|
||||||
p.addEventListener('mousemove', revealChrome);
|
p.addEventListener('mousemove', revealChrome);
|
||||||
p.addEventListener('touchstart', revealChrome, { passive: true });
|
p.addEventListener('touchstart', revealChrome, { passive: true });
|
||||||
|
const c = $('player-controls');
|
||||||
|
if (c) {
|
||||||
|
overControls = typeof c.matches === 'function' && c.matches(':hover');
|
||||||
|
c.addEventListener('mouseenter', _onControlsEnter);
|
||||||
|
c.addEventListener('mouseleave', _onControlsLeave);
|
||||||
|
}
|
||||||
const s = $('speed-slider');
|
const s = $('speed-slider');
|
||||||
if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); }
|
if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); }
|
||||||
updateSpeedViz();
|
updateSpeedViz();
|
||||||
@@ -281,6 +339,12 @@
|
|||||||
p.removeEventListener('touchstart', revealChrome);
|
p.removeEventListener('touchstart', revealChrome);
|
||||||
p.classList.remove('chrome-active', 'chrome-idle');
|
p.classList.remove('chrome-active', 'chrome-idle');
|
||||||
}
|
}
|
||||||
|
const c = $('player-controls');
|
||||||
|
if (c) {
|
||||||
|
c.removeEventListener('mouseenter', _onControlsEnter);
|
||||||
|
c.removeEventListener('mouseleave', _onControlsLeave);
|
||||||
|
}
|
||||||
|
overControls = false;
|
||||||
closePop();
|
closePop();
|
||||||
}
|
}
|
||||||
function syncActivation() {
|
function syncActivation() {
|
||||||
|
|||||||
+7
-2
@@ -367,10 +367,15 @@ input, textarea, select,
|
|||||||
}
|
}
|
||||||
#player-hud .v3-upnext .v3-upnext-bar-fill {
|
#player-hud .v3-upnext .v3-upnext-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0%;
|
/* Fill is driven via transform: scaleX(0..1) from player-chrome.js —
|
||||||
|
compositor-only, unlike the previous width writes which re-ran
|
||||||
|
layout on every update tick. */
|
||||||
|
width: 100%;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
|
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
|
||||||
transition: width .12s linear;
|
transition: transform .12s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* — Live performance HUD (top-right, read-only) — */
|
/* — Live performance HUD (top-right, read-only) — */
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
|||||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/\bhandShapes:\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function src(file) {
|
|||||||
test('highway renderer bundles surface the core lefty flag', () => {
|
test('highway renderer bundles surface the core lefty flag', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
src(HIGHWAY_JS),
|
src(HIGHWAY_JS),
|
||||||
/lefty\s*:\s*_lefty/,
|
/lefty\s*[:=]\s*_lefty/,
|
||||||
'custom renderer bundles must include lefty: _lefty',
|
'custom renderer bundles must include lefty: _lefty',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,10 +61,25 @@ test('draw() reads the live canvas box once per frame', () => {
|
|||||||
test('backing-store drift branch is preserved (splitscreen path)', () => {
|
test('backing-store drift branch is preserved (splitscreen path)', () => {
|
||||||
// The original check that catches the splitscreen hw.resize override
|
// The original check that catches the splitscreen hw.resize override
|
||||||
// resizing the element without calling renderer.resize() must remain.
|
// 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(
|
assert.match(
|
||||||
src,
|
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*;/,
|
/const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/,
|
||||||
'the backing-store (canvas.width/height) drift branch must still re-apply',
|
'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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
|||||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||||
const fn = extractBlock(src, 'function _makeBundle()');
|
const fn = extractBlock(src, 'function _makeBundle()');
|
||||||
// Field present in the bundle.
|
// Field present in the bundle.
|
||||||
assert.match(fn, /\bisPlaying\s*:/, 'bundle must expose isPlaying');
|
assert.match(fn, /\bisPlaying\s*[:=]/, 'bundle must expose isPlaying');
|
||||||
// It is computed from the same anchor/advance state getTime() uses, not a
|
// It is computed from the same anchor/advance state getTime() uses, not a
|
||||||
// hardcoded literal — anchor must exist AND the clock must have advanced
|
// hardcoded literal — anchor must exist AND the clock must have advanced
|
||||||
// within the interp cap.
|
// within the interp cap.
|
||||||
assert.match(
|
assert.match(
|
||||||
fn,
|
fn,
|
||||||
/isPlaying\s*:\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ test('draw() only adapts during active playback and feeds the HUD', () => {
|
|||||||
|
|
||||||
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
|
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
|
||||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||||
assert.match(src, /renderScale:\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
||||||
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
|
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)',
|
|||||||
const fn = extractBlock(src, 'function _makeBundle()');
|
const fn = extractBlock(src, 'function _makeBundle()');
|
||||||
// The bundle field must point straight at _noteState — not a fresh
|
// The bundle field must point straight at _noteState — not a fresh
|
||||||
// arrow each frame (the per-frame allocation the review flagged).
|
// arrow each frame (the per-frame allocation the review flagged).
|
||||||
assert.match(fn, /getNoteState:\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
||||||
@@ -64,7 +64,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
|||||||
// identity-based guards in renderer code.
|
// identity-based guards in renderer code.
|
||||||
assert.match(
|
assert.match(
|
||||||
fn,
|
fn,
|
||||||
/getNoteStateProvider:\s*_getNoteStateProvider\b/,
|
/getNoteStateProvider\s*[:=]\s*_getNoteStateProvider\b/,
|
||||||
'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)'
|
'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)'
|
||||||
);
|
);
|
||||||
// Sanity: the stable accessor exists per-createHighway-instance
|
// Sanity: the stable accessor exists per-createHighway-instance
|
||||||
|
|||||||
Reference in New Issue
Block a user