perf(highway_3d): forceSinglePass on transparent DoubleSide quads

The retrace after the label-swap fix showed getParameters unchanged
(~2.5s / ~4% throttled main thread) — the real driver is Three r158+'s
transparent-DoubleSide two-pass path: renderBufferDirect renders such
objects back side then front side, setting material.needsUpdate BOTH
times, i.e. a full getParameters/program-cache lookup twice per object
per frame, plus double draw calls. (Found by reading the two-pass
branch in the vendored three.module.min.js right next to the
getParameters call site.)

All 18 transparent DoubleSide materials in this renderer are flat
unlit quads — technique markers, sustain rails, chord frames, lane
planes, halo bars — where the two-pass self-occlusion ordering buys
nothing. Declare forceSinglePass: true on all of them.

Also corrects the _setLabelMap comment's churn attribution (that fix
removes the label-swap contribution; this one removes the dominant
source). Plugin 3.31.1 -> 3.31.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-02 00:48:52 +02:00
co-authored by Claude Fable 5
parent 59aa70ce5a
commit 95cb51b2ad
3 changed files with 38 additions and 26 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed ### 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). - **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
- **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. - **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.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "highway_3d", "id": "highway_3d",
"name": "3D Highway", "name": "3D Highway",
"version": "3.31.1", "version": "3.31.2",
"type": "visualization", "type": "visualization",
"bundled": true, "bundled": true,
"script": "screen.js", "script": "screen.js",
+36 -24
View File
@@ -5278,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;
} }
@@ -6660,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);
@@ -6720,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
@@ -6860,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,
}); });
@@ -6887,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());
@@ -6908,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());
@@ -6922,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;
@@ -6976,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);
@@ -7089,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(
@@ -7101,7 +7111,7 @@
depthWrite: false, depthWrite: false,
depthTest: false, depthTest: false,
fog: false, fog: false,
side: T.DoubleSide, side: T.DoubleSide, forceSinglePass: true,
}), }),
)); ));
@@ -7184,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);
@@ -7264,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);
@@ -7345,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);
@@ -7427,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);
@@ -7449,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,
}), }),
)); ));
@@ -9935,12 +9945,14 @@
// Swap a pooled label sprite's cached texture WITHOUT recompiling. // Swap a pooled label sprite's cached texture WITHOUT recompiling.
// Setting material.needsUpdate bumps material.version, which forces // Setting material.needsUpdate bumps material.version, which forces
// Three.js through getParameters/getProgramCacheKey on the next // Three.js through getParameters/getProgramCacheKey on the next
// render — profiled at ~4% of throttled main-thread time from the // render. Swapping one non-null texture for another does NOT change
// per-frame label map swaps in dense charts. Swapping one non-null // the compiled program (the USE_MAP define is unchanged); only a
// texture for another does NOT change the compiled program (the // null <-> non-null transition does, and pooled label sprites are
// USE_MAP define is unchanged); only a null <-> non-null transition // constructed with a non-null map, so in practice this never
// does, and pooled label sprites are constructed with a non-null // recompiles. (Note: the DOMINANT getParameters churn turned out to
// map, so in practice this never recompiles. // 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) { function _setLabelMap(sprite, srcMat) {
const m = sprite.material; const m = sprite.material;
if (m.map === srcMat.map) return; if (m.map === srcMat.map) return;