mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 15:58:31 +00:00
refactor(h3d-carve-6): move N-section material builders to src/materials.js
Extract TXT_STYLES + 13 material builder functions (txtMat, pinchHarmonicMat,
naturalHarmonicMat, palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat, _meshMatForGhostFretDigit,
_spriteMat2MeshMat) and pool() from screen.js N-section into:
plugins/highway_3d/src/materials.js (createMaterialBuilders factory)
screen.js drops ~600 lines (15302→14705).
Surprises vs plan §4:
• DI is 4 params { getT, getTxtCache, techMatCache, techMeshMatClones } not 1
• _syncOpenStringPitchLabels cluster excluded (20+ factory-scope deps)
• _techMatCache stays in screen.js factory scope for teardown .values()/.clear()
Beyond-subst (4):
1. Factory wrapper createMaterialBuilders({...})
2. T → const T = getT() inside each function body (live accessor)
3. txtCache[k] → const cache = getTxtCache(); cache[k]
4. _techMatCache/_techMeshMatClones → DI param names techMatCache/techMeshMatClones
Tests: 16 new class-killer tests in highway_3d_materials.test.js;
pool warm tests retargeted to src/materials.js; panel_controls stub added.
Full suite: 1246/1248 pass (2 pre-existing: network + nut-labels).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
733d772154
commit
e6b2f86068
@@ -0,0 +1,670 @@
|
||||
// src/materials.js — h3d-carve-6
|
||||
// Material builders extracted from screen.js N-section.
|
||||
//
|
||||
// screen.js usage (factory scope, once per createFactory() invocation):
|
||||
// const _techMatCache = new Map();
|
||||
// const { txtMat, pinchHarmonicMat, naturalHarmonicMat,
|
||||
// palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
|
||||
// triMat, bendChevronMat, darkenHex, slideArrowMat,
|
||||
// _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool } =
|
||||
// createMaterialBuilders({
|
||||
// getT, // () => T — live accessor (T is null before loadThree resolves)
|
||||
// getTxtCache, // () => txtCache — live accessor (txtCache reassigned to {} on teardown)
|
||||
// techMatCache, // stable const Map ref (screen.js factory-scope; teardown .values()/.clear())
|
||||
// techMeshMatClones, // stable const Set ref (screen.js factory-scope; teardown .clear())
|
||||
// });
|
||||
//
|
||||
// Surprises vs plan §4:
|
||||
// • DI is 4 params, not just { getT } — declared before commit
|
||||
// • _syncOpenStringPitchLabels cluster (20+ factory-scope deps) excluded from this cut
|
||||
// • _techMatCache stays in screen.js factory scope so teardown can call .values()/.clear() directly
|
||||
//
|
||||
// Beyond-subst rewires (4):
|
||||
// 1. Factory wrapper createMaterialBuilders({...})
|
||||
// 2. T → const T = getT() at the top of each function that needs Three.js
|
||||
// 3. txtCache[k] → const cache = getTxtCache(); cache[k]
|
||||
// 4. _techMatCache → techMatCache / _techMeshMatClones → techMeshMatClones (DI param names)
|
||||
// 5. _pmXSpriteMat, _fhXSpriteMat promoted to module closure (scope change only)
|
||||
|
||||
export function createMaterialBuilders({ getT, getTxtCache, techMatCache, techMeshMatClones }) {
|
||||
// ── Private closure vars (were factory-scope lets in screen.js N-section) ─
|
||||
let _pmXSpriteMat = null;
|
||||
let _fhXSpriteMat = null;
|
||||
|
||||
// ── Text-sprite style presets ─────────────────────────────────────────────
|
||||
// Each preset describes how a class of label is rasterised.
|
||||
// Tweak per-class look here (font, outline color/width, source
|
||||
// canvas size). `wide` toggles a long aspect ratio for multi-char
|
||||
// labels (chord/section names, "↑1/2", "~~~").
|
||||
//
|
||||
// Knobs:
|
||||
// font — full CSS font shorthand (weight + size + family)
|
||||
// wideFont — same, used when caller passes wide=true
|
||||
// srcH — source-canvas height in px (square; wide=4×).
|
||||
// Keep power-of-two so WebGL1 / Three.js retain
|
||||
// mipmaps + linear-mip-linear filtering — NPOT
|
||||
// textures silently fall back to no-mipmap and
|
||||
// shimmer at distance.
|
||||
// stroke — outline color (null = no outline)
|
||||
// strokeW — outline line-width in source-canvas px
|
||||
// shadow — { color, blur, dx, dy } or null
|
||||
const TXT_STYLES = {
|
||||
// The two fret-number sets the user wants to pop hardest.
|
||||
fretRow: {
|
||||
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
srcH: 256, stroke: '#0a1018', strokeW: 18,
|
||||
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
|
||||
},
|
||||
noteFret: {
|
||||
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
srcH: 256, stroke: '#0a1018', strokeW: 18,
|
||||
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
|
||||
},
|
||||
// Ghost-fret labels on the board projection: same weight/size/outline
|
||||
// as noteFret, but uses textAlign='center'; textBaseline='middle'
|
||||
// (the standard branch in txtMat) so the glyph is truly centred on
|
||||
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
|
||||
// intentionally NOT activated for this style — that path was designed
|
||||
// for Sprites and shifts the canvas origin, which causes visible
|
||||
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
|
||||
ghostFret: {
|
||||
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
srcH: 256, stroke: '#0a1018', strokeW: 18,
|
||||
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
|
||||
},
|
||||
// Chord names — gold script-style label, lighter outline keeps
|
||||
// the colour readable.
|
||||
chord: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
// Section banners ("Verse", "Chorus") — same as chord weight.
|
||||
section: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
|
||||
technique: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
// Open-string "0" label on the note body itself.
|
||||
open: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
};
|
||||
|
||||
function txtMat(text, col, wide, style) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const sName = style || 'technique';
|
||||
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
|
||||
if (cache[k]) return cache[k];
|
||||
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
|
||||
const h = sp.srcH;
|
||||
const str = String(text);
|
||||
const font = wide ? sp.wideFont : sp.font;
|
||||
|
||||
let w = wide ? h * 4 : h;
|
||||
|
||||
if (!wide && sName === 'noteFret') {
|
||||
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
|
||||
// glyphs stay centred at (w/2, h/2) without edge clipping.
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
probe.font = font;
|
||||
const tw = probe.measureText(str).width;
|
||||
let pad = 0;
|
||||
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
|
||||
if (sp.shadow) {
|
||||
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
|
||||
}
|
||||
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
|
||||
}
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
x.font = font;
|
||||
// Fret / open-string digits: anchor from actualBoundingBox so the
|
||||
// glyph sits at the true optical centre of the canvas (fixes
|
||||
// sprites looking off-centre inside the board ghost and elsewhere).
|
||||
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
|
||||
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
|
||||
// billboard. Sprites tolerate slight canvas off-centering because Three.js
|
||||
// centres them at their world position; a Mesh does not — the digit lands
|
||||
// wherever it sits in UV space. Use the advance-width centre as the initial
|
||||
// pen position and then correct for any ink asymmetry via actualBoundingBox.
|
||||
const inkCenterGhost = !wide && sName === 'ghostFret';
|
||||
let drawX = w / 2;
|
||||
let drawY = h / 2;
|
||||
if (inkCenterFret) {
|
||||
x.textAlign = 'left';
|
||||
x.textBaseline = 'alphabetic';
|
||||
const m = x.measureText(str);
|
||||
const L = m.actualBoundingBoxLeft;
|
||||
const R = m.actualBoundingBoxRight;
|
||||
const A = m.actualBoundingBoxAscent;
|
||||
const D = m.actualBoundingBoxDescent;
|
||||
if (
|
||||
L != null && R != null && A != null && D != null &&
|
||||
Number.isFinite(L) && Number.isFinite(R) &&
|
||||
Number.isFinite(A) && Number.isFinite(D)
|
||||
) {
|
||||
const inkW = R - L;
|
||||
drawX = (w - inkW) / 2 - L;
|
||||
drawY = (h + A - D) / 2;
|
||||
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
|
||||
// small canvas nudge keeps sprites centred on the board ghost.
|
||||
if (sName === 'noteFret') drawY -= h * 0.028;
|
||||
} else {
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
drawX = w / 2;
|
||||
drawY = h / 2;
|
||||
}
|
||||
} else if (inkCenterGhost) {
|
||||
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
|
||||
// stroke) to find the true ink centre of mass without contamination
|
||||
// from the isotropic shadow blur. For Arial Black "1" the shadow from
|
||||
// the thin upper-left flag bleeds leftward and cancels part of the
|
||||
// rightward correction when we include it in the scan. Measuring fill
|
||||
// alone isolates the actual glyph shape.
|
||||
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
|
||||
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
|
||||
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
|
||||
// Max 4 unique digits (1–4) → cache-miss runs at most 4 times ever.
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
try {
|
||||
const tmpC = document.createElement('canvas');
|
||||
tmpC.width = w; tmpC.height = h;
|
||||
const tc = tmpC.getContext('2d');
|
||||
tc.font = font;
|
||||
tc.textAlign = 'center';
|
||||
tc.textBaseline = 'middle';
|
||||
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
|
||||
// and muddles the centroid; fill alone gives the cleanest reading.
|
||||
tc.fillStyle = '#ffffff';
|
||||
tc.fillText(str, w / 2, h / 2);
|
||||
const id = tc.getImageData(0, 0, w, h).data;
|
||||
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
|
||||
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
|
||||
// correct perceptual centre rather than the geometric bbox midpoint.
|
||||
let sumX = 0, sumY = 0, sumA = 0;
|
||||
for (let py = 0; py < h; py++) {
|
||||
for (let px = 0; px < w; px++) {
|
||||
const a = id[(py * w + px) * 4 + 3];
|
||||
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
|
||||
}
|
||||
}
|
||||
if (sumA > 0) {
|
||||
// shift pen so centroid → canvas centre, then add a small
|
||||
// extra rightward nudge (8 %) so the vertical stroke of
|
||||
// narrow digits like "1" sits visually at gem centre rather
|
||||
// than the advance-width centre (which may be slightly left
|
||||
// of the dominant ink mass for Arial Black numerals).
|
||||
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
|
||||
drawY = h / 2 + (h / 2 - sumY / sumA);
|
||||
}
|
||||
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
|
||||
// x (real canvas) still has textAlign='center'; textBaseline='middle'
|
||||
} else {
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
}
|
||||
if (sp.shadow) {
|
||||
x.shadowColor = sp.shadow.color;
|
||||
x.shadowBlur = sp.shadow.blur;
|
||||
x.shadowOffsetX = sp.shadow.dx;
|
||||
x.shadowOffsetY = sp.shadow.dy;
|
||||
}
|
||||
if (sp.stroke && sp.strokeW > 0) {
|
||||
x.lineJoin = 'round';
|
||||
x.miterLimit = 2;
|
||||
x.strokeStyle = sp.stroke;
|
||||
x.lineWidth = sp.strokeW;
|
||||
x.strokeText(str, drawX, drawY);
|
||||
}
|
||||
x.fillStyle = col;
|
||||
x.fillText(str, drawX, drawY);
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
// depthTest:false means later geometry never *fails* depth
|
||||
// against these sprites, but without depthWrite:false the
|
||||
// sprites still write to the depth buffer (Three.js default
|
||||
// is depthWrite:true even for SpriteMaterial). That can
|
||||
// make subsequent sprites/labels vanish — match the
|
||||
// pattern used by the other sprite materials in this file.
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
function pinchHarmonicMat(col) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const baseCol = new T.Color(col != null ? col : '#ffd84d');
|
||||
// v5 — compact concentric ellipses:
|
||||
// 1. black outer border rx=0.430h ry=0.255h
|
||||
// 2. string-color body rx=0.418h ry=0.232h
|
||||
// 3. black inner ring rx=0.407h ry=0.218h
|
||||
// 4. string-color inner rx=0.264h ry=0.218h
|
||||
// 5. black center dot rx=0.134h ry=0.120h
|
||||
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
|
||||
if (cache[k]) return cache[k];
|
||||
|
||||
const h = 512;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = h; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
const TAU = Math.PI * 2;
|
||||
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
|
||||
|
||||
x.clearRect(0, 0, h, h);
|
||||
x.save();
|
||||
x.translate(h / 2, h / 2);
|
||||
|
||||
// Form 1 — black outer border
|
||||
x.fillStyle = '#000000';
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 2 — string-color main body
|
||||
x.fillStyle = colStr;
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 3 — black inner ring
|
||||
x.fillStyle = '#000000';
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 4 — string-color inner spot (narrower)
|
||||
x.fillStyle = colStr;
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 5 — black center dot
|
||||
x.fillStyle = '#000000';
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
|
||||
|
||||
x.restore();
|
||||
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
function naturalHarmonicMat() {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
|
||||
if (cache[k]) return cache[k];
|
||||
|
||||
const h = 256;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = h; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
const cx = h / 2;
|
||||
const cy = h / 2;
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
x.clearRect(0, 0, h, h);
|
||||
|
||||
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
|
||||
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
|
||||
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
|
||||
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
x.fillStyle = glow;
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.44, 0, TAU);
|
||||
x.fill();
|
||||
|
||||
x.shadowColor = 'rgba(0,0,0,0.85)';
|
||||
x.shadowBlur = 14;
|
||||
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.31, 0, TAU);
|
||||
x.fill();
|
||||
|
||||
// Punch out the inner gap so the icon reads as a bright ring.
|
||||
x.shadowBlur = 0;
|
||||
x.globalCompositeOperation = 'destination-out';
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.20, 0, TAU);
|
||||
x.fill();
|
||||
x.globalCompositeOperation = 'source-over';
|
||||
|
||||
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
|
||||
x.shadowBlur = 10;
|
||||
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
|
||||
x.lineWidth = 8;
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.255, 0, TAU);
|
||||
x.stroke();
|
||||
|
||||
x.shadowColor = 'rgba(0,0,0,0)';
|
||||
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.12, 0, TAU);
|
||||
x.fill();
|
||||
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity: 0.96,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Only two PM/FH variants exist (palm-mute = black-on-white,
|
||||
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
|
||||
// muted chord-note per frame, so dense PM/FH passages were paying
|
||||
// for a string concat + Map lookup on every call. Hoist both
|
||||
// SpriteMaterial refs and short-circuit before touching the cache.
|
||||
// They're populated lazily on first use; teardown still reaches
|
||||
// them via the shared ``txtCache`` because muteXMat writes there.
|
||||
function palmMuteXSpriteMat() {
|
||||
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
|
||||
}
|
||||
function fretHandMuteXSpriteMat() {
|
||||
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
|
||||
}
|
||||
|
||||
function muteXMat(fillCol, strokeCol) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
|
||||
if (cache[k]) return cache[k];
|
||||
|
||||
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
|
||||
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
|
||||
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
|
||||
// using outerW/2, which is too small). With the correct pad the white
|
||||
// cap is fully inside the canvas and the border is visible at every tip.
|
||||
const h = 512;
|
||||
const outerW = 132, innerW = 114;
|
||||
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
|
||||
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
|
||||
const c = document.createElement('canvas');
|
||||
c.width = h; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
|
||||
x.clearRect(0, 0, h, h);
|
||||
x.lineCap = 'square';
|
||||
|
||||
// Draw each diagonal in its own stroke() call — caps of the two
|
||||
// diagonals don't interact, and the white outer is drawn before the
|
||||
// black inner so the border is clean at every edge and tip.
|
||||
x.strokeStyle = strokeCol;
|
||||
x.lineWidth = outerW;
|
||||
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
|
||||
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
|
||||
|
||||
x.strokeStyle = fillCol;
|
||||
x.lineWidth = innerW;
|
||||
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
|
||||
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
|
||||
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Technique-marker sprite materials (triangle / chevron). Keyed by a
|
||||
// packed NUMBER, not a string — triMat/bendChevronMat are called from
|
||||
// the drawNote hot path, so a string cache key would allocate per
|
||||
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
|
||||
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
|
||||
// step-count) so triangle and chevron entries can't collide.
|
||||
|
||||
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down)
|
||||
// with a thick border in the gem's string colour.
|
||||
function triMat(up, hex) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const key = h * 16 + (up ? 0 : 1);
|
||||
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
|
||||
if (cached) return cached;
|
||||
const S = 256, m = S * 0.15;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.beginPath();
|
||||
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
|
||||
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
|
||||
g.closePath();
|
||||
g.lineJoin = 'round';
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fill();
|
||||
g.lineWidth = S * 0.122;
|
||||
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
|
||||
g.stroke();
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c), transparent: true,
|
||||
depthTest: false, depthWrite: false,
|
||||
});
|
||||
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
|
||||
// string colour (chart-format bend notation — 1 per half-step).
|
||||
function bendChevronMat(steps, hex) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
|
||||
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
|
||||
if (cached) return cached;
|
||||
const S = 256;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
|
||||
g.lineWidth = S * 0.10;
|
||||
g.lineJoin = g.lineCap = 'round';
|
||||
const padX = S * 0.18;
|
||||
const rowH = S / steps;
|
||||
const amp = Math.min(rowH * 0.55, S * 0.24);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const cy = (i + 0.5) * rowH;
|
||||
g.beginPath();
|
||||
g.moveTo(padX, cy + amp * 0.5);
|
||||
g.lineTo(S / 2, cy - amp * 0.5);
|
||||
g.lineTo(S - padX, cy + amp * 0.5);
|
||||
g.stroke();
|
||||
}
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c), transparent: true,
|
||||
depthTest: false, depthWrite: false,
|
||||
});
|
||||
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
|
||||
// marker — full string colour is too bright next to the gem.
|
||||
function darkenHex(hex, factor) {
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const r = Math.round(((h >> 16) & 0xff) * factor);
|
||||
const g = Math.round(((h >> 8) & 0xff) * factor);
|
||||
const b = Math.round((h & 0xff) * factor);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
// Slide-direction arrow (›/‹): a filled triangle pointing toward the
|
||||
// slide's destination fret, in the gem's (darkened) string colour.
|
||||
// `hex` here is already the darkened colour — keep its own cache-key
|
||||
// nibble range (8/9) so it can't collide with triMat (0/1) or
|
||||
// bendChevronMat (3-6).
|
||||
function slideArrowMat(pointRight, hex) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const key = h * 16 + 8 + (pointRight ? 0 : 1);
|
||||
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
|
||||
if (cached) return cached;
|
||||
const S = 256, m = S * 0.18;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.beginPath();
|
||||
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
|
||||
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
|
||||
g.closePath();
|
||||
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
|
||||
g.fill();
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c), transparent: true,
|
||||
depthTest: false, depthWrite: false,
|
||||
});
|
||||
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
|
||||
return mat;
|
||||
}
|
||||
|
||||
function _meshMatForGhostFretDigit(spriteMat) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
let mb = spriteMat.userData.h3dGhostFretMeshMat;
|
||||
if (!mb) {
|
||||
mb = new T.MeshBasicMaterial({
|
||||
map: spriteMat.map,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
spriteMat.userData.h3dGhostFretMeshMat = mb;
|
||||
}
|
||||
return mb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
|
||||
* texture, so technique markers can be applied to a rotatable PlaneGeometry
|
||||
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
|
||||
*
|
||||
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
|
||||
* clone> so a recycled mesh that's used for several techniques
|
||||
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
|
||||
* clone for each one rather than disposing-and-recloning on every
|
||||
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
|
||||
* markers this collapses the per-frame allocation entirely while
|
||||
* still being bounded — the per-mesh Map has at most one entry per
|
||||
* distinct technique × colour the mesh has ever been used for.
|
||||
*/
|
||||
function _spriteMat2MeshMat(mesh, sm) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
|
||||
if (perMesh) {
|
||||
const hit = perMesh.get(sm.map);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
let base = sm.userData.h3dTechMeshMat;
|
||||
if (!base) {
|
||||
base = new T.MeshBasicMaterial({
|
||||
map: sm.map,
|
||||
transparent: true,
|
||||
// depthTest: false — cross-note Z ordering is handled by
|
||||
// per-note renderOrderForLayerAtZ(...) calls rather than the
|
||||
// depth buffer. This is necessary because close notes often use
|
||||
// mGlow (depthWrite:false), so the depth buffer can't reliably
|
||||
// occlude far markers near the hit line. With per-note renderOrder,
|
||||
// far labels render first and close note geometry renders last,
|
||||
// appearing on top without depthTest.
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
// 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;
|
||||
}
|
||||
// First conversion for this mesh: the pTechPlane pool factory gave
|
||||
// it a placeholder MeshBasicMaterial that the caller is about to
|
||||
// overwrite with the clone below. Dispose it now — once
|
||||
// mesh.material is reassigned the placeholder is orphaned and
|
||||
// teardown's scene.traverse() pass can no longer reach it, so it
|
||||
// would leak one GPU material per pooled mesh for the renderer's
|
||||
// lifetime.
|
||||
if (!perMesh && mesh.material && mesh.material !== base) {
|
||||
mesh.material.dispose?.();
|
||||
}
|
||||
if (!perMesh) {
|
||||
perMesh = new Map();
|
||||
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
|
||||
}
|
||||
const clone = base.clone();
|
||||
perMesh.set(sm.map, clone);
|
||||
techMeshMatClones.add(clone); // beyond-subst: _techMeshMatClones → techMeshMatClones
|
||||
return clone;
|
||||
}
|
||||
|
||||
function pool(parent, mk) {
|
||||
const a = [];
|
||||
let n = 0;
|
||||
return {
|
||||
get() {
|
||||
if (n < a.length) {
|
||||
const o = a[n++];
|
||||
o.visible = true;
|
||||
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
|
||||
return o;
|
||||
}
|
||||
const o = mk(); parent.add(o); a.push(o); n++; return o;
|
||||
},
|
||||
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
|
||||
// Pre-allocate `cap` slots at construction so the first dense
|
||||
// playback frames don't pay the new-Mesh allocation cost
|
||||
// mid-RAF (felt as a stall on 7/8-string charts where the
|
||||
// visible-note count outruns the lazy-grow path). Lazy growth
|
||||
// past `cap` still works — this is amortisation, not a cap.
|
||||
//
|
||||
// Coerce `cap` to a non-negative int32: a float would still
|
||||
// work but a callsite passing `Infinity` (or `NaN`) would
|
||||
// otherwise spin the while-loop until OOM. `cap | 0`
|
||||
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
|
||||
// Math.max(0, …) keeps negatives out.
|
||||
warm(cap) {
|
||||
// Local rename to avoid shadowing the pool's outer
|
||||
// `n` (the in-use index advanced by get() / reset()).
|
||||
const targetLen = Math.max(0, cap | 0);
|
||||
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
txtMat, pinchHarmonicMat, naturalHarmonicMat,
|
||||
palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
|
||||
triMat, bendChevronMat, darkenHex, slideArrowMat,
|
||||
_meshMatForGhostFretDigit, _spriteMat2MeshMat, pool,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user