feat(drum_highway_3d): foundation for guitar-highway visual parity (D1) (#695)

- Consume host adaptive-quality bundle.renderScale; fold into DPR like
  highway_3d (splitscreen proxy: >1 instance caps baseDPR at 1.25)
- Bloom: port _bloomEnsure/_bloomDispose (UnrealBloomPass 0.65/0.5/0.82,
  MSAA HalfFloat target, ACES<->None tone-mapping switch); rebuilt across
  the kit-change renderer recreation; direct render is the degrade path
- FX settings scaffold: FX_DEFAULTS + readFxSettings + drumH3dSetFx
  (drum_h3d_bg_* keys), Graphics section in settings.html (bloom toggle,
  default ON, live-applies)
- _applyLaneFlashes dead-code comment updated (visual consumer lands in
  the hit-FX PR)
- __test export + tests/data_layer.test.js (vm harness, 8 tests) —
  picked up by the CI glob from the bundling PR; README refreshed

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-02 00:52:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1e2d5a6162
commit a7ea719652
5 changed files with 376 additions and 16 deletions
+20 -7
View File
@@ -49,15 +49,28 @@ a few bars, plus a tom roll down the kit. Best read of what's possible.
All settings persist in `localStorage` under the `drum_h3d_*` prefix.
## What this mockup is *not* doing yet
## What this plugin is *not* doing yet
(Historical note: this started as a pure-visual mockup; it now reads
`bundle.drumTab` + `bundle.currentTime` and scores MIDI hits against the
chart, so the old "no song-data wiring" caveats are gone.)
- Reading `bundle.notes` / `bundle.chords` / `bundle.currentTime`
- Hit detection / scoring / note_detect integration
- Real drum chart parsing (the chart doesn't ship drum charts; this
would need a new arrangement format or a Guitar Pro/MIDI source)
- Sustain trails (drums don't sustain meaningfully)
- Sticking labels, double kick, foot hi-hat, cross-stick, rolls — see
the TODOs in `screen.js` for the variant backlog
- Sticking labels, double kick, rolls — see the TODOs in `screen.js`
for the variant backlog
## Ported helpers (keep in sync with highway_3d)
Visual-parity code copied from `plugins/highway_3d/screen.js` — same
function names, signatures, and constants on purpose, marked with
`PORTED FROM highway_3d` comments at each site. If the guitar highway
tunes one of these, mirror the change here (and in `keys_highway_3d`):
- `_bloomEnsure()` / `_bloomDispose()` — EffectComposer + UnrealBloomPass
(0.65/0.5/0.82) on a multisampled HalfFloat target, ACES↔None tone-
mapping switch in `draw()`; addons dynamic-imported from
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
graceful degrade)
## Why a separate plugin (vs. drum mode inside highway_3d)?
+237 -9
View File
@@ -1054,6 +1054,72 @@
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { cameraAngle: c } }));
};
// Host splitscreen state (PORTED FROM highway_3d _ssActive, minus the
// focus-API checks the guitar needs for input routing — here it only
// gates GPU cost, so "is a split active at all" is the right question;
// a mixed split (this viz + another renderer) must count too).
function _ssActive() {
const ss = window.feedBackSplitscreen;
return !!(ss && typeof ss.isActive === 'function' && ss.isActive());
}
/* ======================================================================
* Visual-FX settings — guitar-highway parity controls
* ====================================================================== */
// Defaults for the graphics/FX controls this plugin exposes. Keys mirror
// the guitar highway's `h3d_bg_*` vocabulary under this plugin's own
// `drum_h3d_bg_*` localStorage prefix; later parity PRs (sparks, themes,
// background styles, score FX) extend this object with their own keys.
// Everything defaults ON — the settings screen is the opt-out.
const FX_DEFAULTS = {
bloom: true,
};
const FX_LS_PREFIX = 'drum_h3d_bg_';
function readFxSettings() {
const fx = Object.assign({}, FX_DEFAULTS);
try {
for (const k of Object.keys(FX_DEFAULTS)) {
const raw = localStorage.getItem(FX_LS_PREFIX + k);
if (raw === null) continue;
if (typeof FX_DEFAULTS[k] === 'boolean') {
// Explicit values only — anything else (corrupt/foreign
// write) keeps the default rather than silently
// disabling an effect.
if (raw === '1' || raw === 'true') fx[k] = true;
else if (raw === '0' || raw === 'false') fx[k] = false;
} else {
const n = parseFloat(raw);
if (Number.isFinite(n)) fx[k] = n;
}
}
} catch (_) { /* localStorage unavailable — use defaults */ }
return fx;
}
// Single setter for every FX key — settings.html calls
// window.drumH3dSetFx('bloom', checked). Coerces to the default's type
// so a slider string can't poison a boolean toggle.
window.drumH3dSetFx = function (key, value) {
if (!(key in FX_DEFAULTS)) return;
let v;
if (typeof FX_DEFAULTS[key] === 'boolean') {
// Same accepted representations as readFxSettings so the
// setter/reader round-trip is consistent ('0'/'false' → false).
v = value === true || value === 1 || value === '1' || value === 'true';
} else {
v = Number(value);
if (!Number.isFinite(v)) return;
}
try {
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
} catch (_) {}
try {
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { fx: { [key]: v } } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
/* ======================================================================
* Renderer factory
* ====================================================================== */
@@ -1069,6 +1135,18 @@
// Settings snapshot — mutated by 'drum_h3d:settings' event.
let settings = readSettings();
let activePalette = PALETTES[settings.palette];
let fx = readFxSettings();
// Host adaptive-quality scale (bundle.renderScale, 0.251) —
// multiplied into the device pixel ratio like highway_3d does.
let _renderScale = 1;
// Bloom composer state (PORTED FROM highway_3d/screen.js — keep in sync).
let _composer = null;
let _bloomPass = null;
let _bloomLoad = null;
let _bloomW = 0, _bloomH = 0;
let _bloomGen = 0; // bumped by _bloomDispose so stale loads no-op
// Scene groups / pooled meshes.
let laneGroup = null; // lane stripes + dividers
@@ -1152,7 +1230,9 @@
_hudEl = document.createElement('div');
_hudEl.className = 'drum-h3d-hud';
_hudEl.style.cssText = [
'position:absolute', 'top:10px', 'left:14px',
// Below the host's top-left song-info block (title /
// arrangement / tuning, ~3 lines) so the two never overlap.
'position:absolute', 'top:96px', 'left:14px',
'font-family:system-ui,sans-serif', 'font-size:13px',
'color:#e2e8f0', 'pointer-events:none', 'z-index:6',
'text-shadow:0 1px 2px rgba(0,0,0,0.8)',
@@ -1194,16 +1274,90 @@
const FLASH_MS = 300;
function _applyLaneFlashes() {
// No longer used for visual feedback (chart notes now turn
// green/red on hit/miss via placeNote). Still drop expired
// entries from the buffer so the array doesn't grow forever
// if MIDI hits arrive while no chart is loaded.
// The visual consumer (pooled additive lane-flash quads) lands in
// the hit-FX parity PR; chart notes already turn green/red on
// hit/miss via placeNote. Until then just drop expired entries so
// the buffer doesn't grow forever if MIDI hits arrive while no
// chart is loaded.
const now = performance.now();
while (_laneFlashes.length && now - _laneFlashes[0].wall > FLASH_MS) {
_laneFlashes.shift();
}
}
/* ── Bloom (PORTED FROM highway_3d/screen.js _bloomEnsure — keep in
* sync; deliberate delta: this copy tracks pixel-ratio changes via
* composer.setPixelRatio (here and in applySize) because renderScale
* changes the ratio at runtime — the upstream composer never learns
* about ratio changes after construction, a candidate fix to port
* back to highway_3d) ── */
// Lazy-load the vendored postprocessing addons and build an
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES).
// Returns the composer once ready, or null (caller falls back to a
// direct render — also the permanent path if the addons are missing,
// e.g. an older self-hosted core without static/vendor/three/addons).
function _bloomEnsure() {
if (_composer) return _composer;
if (_bloomLoad || !ren || !scene || !cam) return null;
const A = '/static/vendor/three/addons/';
const myGen = _bloomGen; // superseded by any _bloomDispose()
_bloomLoad = Promise.all([
import(A + 'postprocessing/EffectComposer.js'),
import(A + 'postprocessing/RenderPass.js'),
import(A + 'postprocessing/UnrealBloomPass.js'),
import(A + 'postprocessing/OutputPass.js'),
]).then(([EC, RP, UB, OP]) => {
try {
// Torn down or superseded mid-load (a dispose clears
// _bloomLoad, letting a NEW load start against the new
// renderer — this stale completion must not also build
// and orphan a composer).
if (myGen !== _bloomGen || _composer) return;
if (!ren || !scene || !cam || !highwayCanvas) return;
const w = Math.max(2, (highwayCanvas.clientWidth || highwayCanvas.width || 1280) | 0);
const h = Math.max(2, (highwayCanvas.clientHeight || highwayCanvas.height || 720) | 0);
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
// survives the bloom path — EffectComposer's default target has
// no `samples`.
const rt = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
const comp = new EC.EffectComposer(ren, rt);
comp.setPixelRatio(ren.getPixelRatio());
comp.addPass(new RP.RenderPass(scene, cam));
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
comp.addPass(_bloomPass);
comp.addPass(new OP.OutputPass());
comp.setSize(w, h);
_bloomW = w; _bloomH = h; _composer = comp;
} catch (e) { console.warn('[Drum-Hwy3D] bloom init failed', e); _composer = null; }
}).catch((e) => console.warn('[Drum-Hwy3D] bloom modules failed', e));
return null;
}
// Drop the composer + its render targets. Called from teardown() AND
// _disposeScene() — the kit-change path recreates the renderer, and a
// composer bound to the dead renderer would draw into nothing.
// Nulling _bloomLoad lets _bloomEnsure rebuild lazily against the new
// renderer.
function _bloomDispose() {
if (_composer) {
// EffectComposer.dispose() only frees its own read/write
// buffers — passes own additional GPU resources (UnrealBloom
// keeps several render targets + materials, OutputPass a
// material), so dispose each pass explicitly first.
try {
for (const p of _composer.passes || []) {
if (p && typeof p.dispose === 'function') p.dispose();
}
} catch (_) {}
try { _composer.dispose(); } catch (_) {}
}
_composer = null;
_bloomPass = null;
_bloomLoad = null;
_bloomW = 0; _bloomH = 0;
_bloomGen++; // invalidate any in-flight addon load
}
function _resetScoring() {
_hitKeys.clear();
_missedKeys.clear();
@@ -1296,6 +1450,13 @@
settings.cameraAngle = Math.min(1, Math.max(0, detail.cameraAngle));
positionCamera();
}
if (detail.fx) {
// FX toggles are consumed per-frame in draw() — no rebuild
// needed; the bloom composer stays cached while toggled off.
for (const k of Object.keys(detail.fx)) {
if (k in FX_DEFAULTS) fx[k] = detail.fx[k];
}
}
}
function rebuildPaletteMaterials() {
@@ -1902,8 +2063,25 @@
if (!ren || !cam || !highwayCanvas) return;
const W = Math.max(1, Math.round(w || highwayCanvas.clientWidth || highwayCanvas.width || 1));
const H = Math.max(1, Math.round(h || highwayCanvas.clientHeight || highwayCanvas.height || 1));
ren.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
// Splitscreen: cap the base DPR harder (1.25 vs 2, mirroring
// highway_3d) so two panels don't double the fill cost. Checks
// the host split state (covers a mixed split with another
// renderer) plus our own instance count (covers multi-instance
// without host state). _renderScale is the host's adaptive
// quality scale from bundle.renderScale.
const baseDPR = (_ssActive() || _instances.size > 1)
? Math.min(window.devicePixelRatio || 1, 1.25)
: Math.min(window.devicePixelRatio || 1, 2);
ren.setPixelRatio(_renderScale * baseDPR);
ren.setSize(W, H, false);
if (_composer) {
// EffectComposer snapshots the renderer's pixelRatio — it must
// be told about both ratio and box changes or bloom renders at
// the wrong resolution.
_composer.setPixelRatio(ren.getPixelRatio());
_composer.setSize(W, H);
_bloomW = W; _bloomH = H;
}
cam.aspect = W / H;
cam.updateProjectionMatrix();
}
@@ -1917,6 +2095,7 @@
// false when WebGL re-init failed — callers must NOT proceed to
// initScene() on false (the scene would draw into nothing).
function _disposeScene() {
_bloomDispose(); // composer is bound to the renderer we're about to replace
if (notesGroup) {
while (notesGroup.children.length) {
disposeMeshTree(notesGroup.children.pop());
@@ -1984,6 +2163,12 @@
}
function teardown() {
_bloomDispose();
// HUD cleanup lives here (not only destroy): init() re-runs
// teardown() for renderer re-initialization, possibly against a
// different canvas — a stale _hudEl would both linger in the old
// parent and make the next _injectHud() an early-return no-op.
_removeHud();
if (_settingsHandler) {
window.removeEventListener('drum_h3d:settings', _settingsHandler);
_settingsHandler = null;
@@ -2045,6 +2230,7 @@
highwayCanvas = canvas;
settings = readSettings();
activePalette = PALETTES[settings.palette];
fx = readFxSettings();
loadThree().then(() => {
if (!highwayCanvas) return; // destroyed before load resolved
@@ -2100,6 +2286,17 @@
// immediately at init.
_instances.add(instance);
_activeInstance = instance;
// Re-apply size now that this instance is counted in
// _instances: the applySize above ran before the add, so
// its splitscreen DPR check (size > 1) undercounted — the
// second panel of a splitscreen mount would otherwise keep
// full 2x DPR until some later resize. The already-mounted
// panel is corrected by the host's own layout resize when
// the split activates (panels change box size), same as on
// split teardown.
if (_instances.size > 1 || _ssActive()) {
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
}
_midiInit();
_synthInit();
_midiResume();
@@ -2112,10 +2309,32 @@
draw(bundle) {
if (!_isReady || !ren || !scene || !cam) return;
// Host adaptive quality: consume bundle.renderScale like
// highway_3d — the host lowers it under GPU load ("Quality"
// + "Min res" controls) and applySize folds it into the DPR.
const newScale = (bundle && bundle.renderScale) || 1;
if (newScale !== _renderScale) {
_renderScale = newScale;
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
}
rebuildNotes(bundle);
_applyLaneFlashes();
_refreshHud();
ren.render(scene, cam);
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
// mapping when enabled and single-instance; direct render
// otherwise (including the frames while addons stream in).
const comp = (fx.bloom && _instances.size === 1 && !_ssActive()) ? _bloomEnsure() : null;
if (comp) {
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
if (w > 0 && h > 0 && (w !== _bloomW || h !== _bloomH)) {
comp.setSize(w, h); _bloomW = w; _bloomH = h;
}
if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping;
comp.render();
} else {
if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping;
ren.render(scene, cam);
}
},
resize(w, h) {
@@ -2135,8 +2354,7 @@
for (const inst of _instances) { _activeInstance = inst; break; }
}
if (_instances.size === 0) _midiReleaseSession();
_removeHud();
teardown();
teardown(); // includes _removeHud()
highwayCanvas = null;
},
// Exposed for module-level MIDI router. The receiver runs on
@@ -2181,4 +2399,14 @@
if (songInfo.has_notation) return false;
return !/\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr);
};
// Pure helpers exposed for the node:test suite (tests/ — screen.js is
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
// free to call).
window.slopsmithViz_drum_highway_3d.__test = {
_variantForHit,
readFxSettings,
FX_DEFAULTS,
MIDI_TO_PIECE,
HIT_TOLERANCE_S,
};
})();
+24
View File
@@ -42,6 +42,20 @@
<p class="text-xs text-gray-500 mt-1">0 = down the lanes · 1 = top-down</p>
</div>
<!-- Graphics -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<input type="checkbox" id="drumh3d-fx-bloom" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
Glow (bloom)
</label>
<p class="text-xs text-gray-500 mt-1">
Soft light-bleed around the hit line and bright notes. Applies
live; turn off to reclaim GPU headroom on weak machines.
</p>
</div>
<!-- MIDI input -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">MIDI input</h4>
@@ -422,6 +436,16 @@
cam.value = String(c);
camVal.textContent = c.toFixed(2);
}
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
// Only explicit values override; absent/corrupt keys keep the
// default (ON), matching screen.js readFxSettings.
const storedBloom = localStorage.getItem('drum_h3d_bg_bloom');
if (storedBloom === '1' || storedBloom === 'true') {
document.getElementById('drumh3d-fx-bloom').checked = true;
} else if (storedBloom === '0' || storedBloom === 'false') {
document.getElementById('drumh3d-fx-bloom').checked = false;
}
} catch (e) {
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
}
@@ -0,0 +1,94 @@
// Pure data-layer tests: load screen.js in a bare vm window and exercise the
// __test exports (no DOM, no WebGL, no network). Doubles as a lint that no
// module-scope code touches document/localStorage outside a try/catch —
// the vm window deliberately provides neither.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window.slopsmithViz_drum_highway_3d;
}
test('module loads in a bare vm (no DOM / localStorage at module scope)', () => {
const factory = load();
assert.equal(typeof factory, 'function');
assert.equal(factory.contextType, 'webgl2');
});
test('_variantForHit: ghost > flam > bell > accent > normal precedence', () => {
const { _variantForHit } = load().__test;
assert.equal(_variantForHit({ g: true, f: true, v: 120 }), 'ghost');
assert.equal(_variantForHit({ f: true, v: 120 }), 'flam');
assert.equal(_variantForHit({ p: 'ride_bell' }), 'bell');
assert.equal(_variantForHit({ v: 100 }), 'accent');
assert.equal(_variantForHit({ v: 127 }), 'accent');
assert.equal(_variantForHit({ v: 99 }), 'normal');
// Missing velocity defaults to 100 → accent.
assert.equal(_variantForHit({}), 'accent');
});
test('matchesArrangement: claims drum arrangements', () => {
const matches = load().matchesArrangement;
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drums' }), true);
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drum Kit' }), true);
assert.equal(matches({ has_drum_tab: true, arrangement: 'Percussion' }), true);
});
test('matchesArrangement: never claims without a drum tab', () => {
const matches = load().matchesArrangement;
assert.equal(matches(null), false);
assert.equal(matches({}), false);
assert.equal(matches({ arrangement: 'Drums' }), false);
});
test('matchesArrangement: steal-guard — guitar arrangements stay with highway_3d', () => {
const matches = load().matchesArrangement;
// Full-band pack (drum_tab present) playing a guitar-family part:
// first-match-wins Auto order must not hand these to the drum highway.
for (const arr of ['Lead', 'Rhythm', 'Bass', 'Combo', 'Guitar 22', 'Alt. Lead']) {
assert.equal(matches({ has_drum_tab: true, arrangement: arr }), false, arr);
}
// Keys notation present → the keys/staff viz take it.
assert.equal(matches({ has_drum_tab: true, has_notation: true, arrangement: 'Piano' }), false);
});
test('matchesArrangement: claims packs nothing more specific can render', () => {
const matches = load().matchesArrangement;
// Drum tab + nondescript arrangement, no notation → drummable, claim it.
assert.equal(matches({ has_drum_tab: true, arrangement: '' }), true);
assert.equal(matches({ has_drum_tab: true }), true);
// Word-boundary check: "BasslineKeys"-style names don't contain a
// guitar-family word as a whole word.
assert.equal(matches({ has_drum_tab: true, arrangement: 'Bassline' }), true);
});
test('readFxSettings: defaults survive a localStorage-less environment', () => {
const { readFxSettings, FX_DEFAULTS } = load().__test;
// The vm window has no localStorage — the try/catch must eat the
// ReferenceError and hand back pure defaults (everything ON).
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
assert.equal(FX_DEFAULTS.bloom, true);
});
test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
const { MIDI_TO_PIECE, HIT_TOLERANCE_S } = load().__test;
assert.equal(MIDI_TO_PIECE[46], 'hh_open');
assert.equal(MIDI_TO_PIECE[42], 'hh_closed');
assert.equal(MIDI_TO_PIECE[35], 'kick');
assert.equal(MIDI_TO_PIECE[36], 'kick');
// ±50 ms window matches the 2D drums plugin.
assert.equal(HIT_TOLERANCE_S, 0.05);
});