feat(highways): per-splitscreen-panel Camera Director cameras

Make the three 3D highways read the Camera Director bridge per panel so each
splitscreen panel renders its own camera (independent orbit/height/zoom/tilt/pan),
instead of all panels sharing the focused camera.

- Add a shared `_freeCamFor(canvas)` resolver to each highway: prefer this panel's
  entry in `window.__h3dCamCtlPanels[panelIndexFor(canvas)]`, fall back to the
  global `window.__h3dCamCtl`, else null (100% stock). Defensive on the splitscreen
  global name (feedBackSplitscreen || slopsmithSplitscreen), NaN-safe, allocation-free.
- highway_3d (guitar): source `_freeCam` from the resolver (was global-only).
- keys_highway_3d: adopt the bridge for the first time — layer dolly/height/orbit +
  pan/pitch offsets onto the pan/zoom follow rig at the camera write.
- drum_highway_3d: adopt the bridge — new per-frame `applyCamera()` folds the static
  base pose + kick-pulse dip + free-cam offsets.
- In a follower (popped-out) window there is one panel, so the resolver yields
  whatever camera the plugin set in that window; no highway change needed for pop-out.

Camera Director absent → resolver returns null → renderers behave exactly as before.
Bump each plugin patch version. Existing plugin tests pass (drum 15, keys 30); the
keys "default look unchanged" test confirms the stock path is byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
This commit is contained in:
Kris Anderson
2026-07-09 12:24:29 -04:00
co-authored by Claude Fable 5
parent 950e348357
commit 5aa336961c
6 changed files with 128 additions and 8 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "drum_highway_3d",
"name": "3D Drum Highway",
"version": "0.3.1",
"version": "0.3.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+54 -2
View File
@@ -2651,6 +2651,55 @@
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
}
// Camera Director bridge resolver. Prefers THIS panel's per-panel camera
// under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back
// to the single global (window.__h3dCamCtl); null when Camera Director is
// absent → 100% stock framing. Defensive on the splitscreen global name
// (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen).
function _freeCamFor(canvas) {
const map = window.__h3dCamCtlPanels;
if (map) {
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
if (ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
if (i != null && map[i]) return map[i];
} catch (e) { /* ignore */ }
}
}
return window.__h3dCamCtl || null;
}
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
// the camera-from-target vector; pan/pitch on the look target). Runs every
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
// null/disabled bridge reproduces the stock static+pulse pose exactly.
function applyCamera() {
if (_camBaseH == null) return; // before first positionCamera()
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
const _fc = _freeCamFor(highwayCanvas);
if (_fc && _fc.enabled) {
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
_vy *= _hm; // height
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
_lx += _px * K; _ly += (_pt + _py) * K;
}
cam.position.set(_cx, _cy, _cz);
cam.lookAt(_lx, _ly, _lz);
}
function buildLanes(_floorW, floorD) {
laneGroup = new T.Group();
laneStripeMats = [];
@@ -3431,15 +3480,18 @@
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
} catch (_) { /* visual-only */ }
}
// Kick pulse decays each frame; it drives the floor flash and,
// via applyCamera(), the camera Y dip.
if (_kickPulse > 0.001) {
_kickPulse *= Math.exp(-fdt * 7);
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
} else if (_kickPulse !== 0) {
_kickPulse = 0;
cam.position.y = _camBaseH;
if (_floorFlash) _floorFlash.material.opacity = 0;
}
// Write the camera every frame: static base pose + kick dip +
// Camera Director free-cam offsets (per-panel-aware).
applyCamera();
}
// Approach highlight: raise each lane stripe toward its next
// note (accumulated by the rebuildNotes walk above).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.31.3",
"version": "3.31.4",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+25 -2
View File
@@ -2600,6 +2600,26 @@
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
return (idx == null) ? 'main' : 'panel' + idx;
}
// Camera Director bridge resolver. Prefers THIS panel's per-panel camera
// under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to
// the single global (window.__h3dCamCtl); returns null when Camera Director
// is absent → 100% stock framing. Defensive on the splitscreen global name
// (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). Mirrors
// the panel resolution in _bgPanelKey.
function _freeCamFor(canvas) {
const map = window.__h3dCamCtlPanels;
if (map) {
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
if (ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
if (i != null && map[i]) return map[i];
} catch (e) { /* ignore */ }
}
}
return window.__h3dCamCtl || null;
}
// In-memory fallback for when localStorage is blocked (private mode,
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
// value here unconditionally, so it always reflects the most recent
@@ -14664,7 +14684,10 @@
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
// Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = _freeCamFor(highwayCanvas);
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
@@ -14697,7 +14720,7 @@
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl;
// _freeCam resolved above (per-panel-aware Camera Director bridge).
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.2.0",
"version": "0.2.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
+46 -1
View File
@@ -1849,6 +1849,25 @@
_rigOut.lookZ = _camPreset.lookZ;
return _rigOut;
}
// Camera Director bridge resolver. Prefers THIS panel's per-panel camera
// under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back
// to the single global (window.__h3dCamCtl); null when Camera Director is
// absent → 100% stock framing. Defensive on the splitscreen global name
// (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen).
function _freeCamFor(canvas) {
const map = window.__h3dCamCtlPanels;
if (map) {
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
if (ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
if (i != null && map[i]) return map[i];
} catch (e) { /* ignore */ }
}
}
return window.__h3dCamCtl || null;
}
// Per-key approach glow: a key lights in its pitch-class color ONLY while a
// note is heading for it, ramping up the closer that note gets to the hit-line.
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
@@ -3259,7 +3278,33 @@
}
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
{
const r = _rig();
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
// Camera Director free-cam offsets (per-panel-aware), layered on top
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
// orbit act on the camera-from-target vector; pan/pitch shift the
// look target. NaN-safe; null/disabled bridge → stock.
const _fc = _freeCamFor(highwayCanvas);
if (_fc && _fc.enabled) {
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
_vy *= _hm; // height
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
_lx += _px * K; _ly += (_pt + _py) * K;
}
cam.position.set(_cx, _cy, _cz);
cam.lookAt(_lx, _ly, _lz);
}
for (const km of keyMeshes.values()) km.userData.glow = 0;
for (const { mesh, note, len, label } of noteMeshes) {