Static-analysis follow-ups to the trace-backed fixes; each is cheap insurance on machines where the profiled headroom doesn't exist. - highway.js: _makeBundle now mutates one persistent per-instance object instead of allocating a fresh ~35-field bundle every rAF frame (xN under splitscreen). Object identity is stable and meaningless; array fields still swap reference on chart changes, which field-identity caches rely on. Contract documented in both CLAUDE.mds. - highway.js: new bsearchTime (lower-bound on .time) windows the default 2D renderer's beat-line scan (was O(all beats) per frame); bundle.lowerBoundT / bundle.lowerBoundTime expose the searches to custom viz so they stop reimplementing visible-window culling. - highway_3d: localStorage 'h3d_full_sus' polled at ~1 Hz instead of every frame (synchronous storage read on the hot path). - highway_3d: drawLyrics caches the measureText row layout keyed on (lyrics ref, line index, shown count, font size, width) — per-frame work is now just drawing over cached widths. - tests/js: bundle source-shape assertions widened to accept the assignment form ([:=]) alongside the old object-literal form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
37 KiB
3D Highway Plugin — AI Maintainer Guide
This guide tells future AI assistants where each visual element lives in screen.js, what controls it, and the gotchas to watch for. The goal is for small polishes (color tweaks, sizing, animation timing, add/remove a label) to land in the right place on the first try without grep spelunking.
The whole renderer is one file — screen.js, wrapped in an IIFE, registered as window.feedBackViz_highway_3d (a feedBack#36 setRenderer factory). No imports beyond Three.js loaded from the vendored /static/vendor/three/three.module.min.js (pinned r170; swapped from CDN when bundled into core).
Styling (feedBack styles capability). This plugin owns its Tailwind CSS: it ships assets/plugin.css and declares "styles": "assets/plugin.css" in plugin.json, so core's prebuilt static/tailwind.min.css no longer scans it (it's excluded from core's content globs). The frontend injects assets/plugin.css as a <link> when the renderer activates. This is the one maintainer-time build step: after you add/change a Tailwind class in screen.js or settings.html, run bash build-tailwind.sh (pinned tailwindcss@3.4.19, corePlugins.preflight=false — utilities only) and bump the version in plugin.json so the injected <link>'s ?v= cache-buster fetches the fresh file. The generated assets/plugin.css is committed; end users never build. See docs/plugin-styles.md.
Navigation note: This guide references functions by name and uses the existing banner comments (
/* ── Scene initialisation ─ */, etc.) as section anchors. Line numbers are deliberately avoided so this stays correct as the file evolves. UseGrepfor the function name or banner text to jump to a section.
File structure at a glance
The file is laid out top-to-bottom as:
- Constants block — palette (
S_COL), scale (SCALE,K), fret/string counts, geometry sizes, camera, fog - Pure helpers —
fretX,fretMid,dZ,computeBPM - Three.js loader —
loadThree()(loads vendored/static/vendor/three/three.module.min.js, memoized) - Splitscreen helpers —
_ssActive,_ssIsCanvasFocused(readwindow.feedBackSplitscreen) createFactory()— the rest of the file is one big closure- Per-instance state (Three.js refs, pools, camera state, lifecycle flags)
txtMat()text-sprite cache,pool()factorydrawChordDiagram()— 2D canvas chord diagram (top-left overlay)drawLyrics()— 2D canvas lyrics renderer (top centre)initScene()— one-time WebGL setup: scene, camera, lights, materials, poolsbuildBoard()— static fretboard geometry: strings, fret wires, fret dots, board planeupdateStringHighlights()— per-frame string emissive glow + opacityupdate(bundle)— the big per-frame function: notes, chords, beats, lane, fret labelsdrawNote()— single note: outline, body, sustain, drop line, technique labels, projectioncamUpdate()— smooth camera lerp + self-correcting NDC look-atapplySize()— DPR + canvas size + aspect clampingteardown()— dispose all GPU resources + reset statecanvasSize()— resilient canvas-dimension lookup- Returned API —
init / draw / resize / destroy(setRenderer contract)
Coordinate system
- +X runs along the fretboard (low frets → high frets,
fretX(f)andfretMid(f)). - +Y is up (string Y is
sY(s), low strings have lower Y when not inverted). - +Z is toward the camera. Notes spawn at negative Z and approach Z=0 (the hit line). Past notes would be at positive Z, but
noteZis clamped viaMath.min(0, dZ(dt))indrawNote()so they stop at the string plane. - Camera sits at roughly
(curX + 20*K, h*0.95, dist*0.75)— positive Z, slightly above and behind the play line, looking toward(curX, curLookY, -FOCUS_D * 0.35).
dZ(dt) = -dt * TS — the closer to "now," the closer to Z=0. TS = 200*K is the world-units-per-second scroll rate.
The K scale and why everything is multiplied by it
SCALE = 2.25, K = SCALE / 300 ≈ 0.0075. Almost every world-space dimension is expressed as N * K so the whole scene scales as one unit. Tweaking SCALE alone resizes the entire highway. If you change a literal world dimension, write it as N * K to keep it consistent — naked numeric literals in Three.js geometry creation calls (e.g. inside BoxGeometry) are an obvious smell.
Concrete sizes (search the constants block for the names):
| Const | Value (world units) | Meaning |
|---|---|---|
STR_THICK |
0.25 * K |
String thickness |
S_BASE / S_GAP |
3 * K / 4 * K |
Lowest-string Y / inter-string gap |
NW, NH, ND |
5 * K, 3 * K, 0.5 * K |
Note width / height / depth |
TS |
200 * K |
Scroll speed (world units per second) |
AHEAD / BEHIND |
3.0 / 0.5 |
Seconds visible ahead / behind hit line |
CAM_DIST_BASE / CAM_H_BASE |
240 * K / 150 * K |
Reference camera distance / height |
FOG_START / FOG_END |
200 * K / 670 * K |
Fog kicks in past hit line, swallows by the horizon |
"I want to change X" — quick lookup
Each entry names the function or banner you should grep for, plus key sub-blocks (also marked with banner comments inside the function).
Strings
- String colors →
S_COLarray in the top-level constants block. Eight-element vibrant palette; indexsis the string (0 = high E for guitar).MAX_RENDER_STRINGSkeys offS_COL.length. - String count for the active arrangement →
resolveStringCount(bundle)(top-level helper). Readsbundle.stringCount(feedBack#93) with abass-name fallback. Don't reintroducetuning.length— see Pitfall #4. - String thickness / gap / base Y →
STR_THICK,S_BASE,S_GAPconstants. - String-to-Y mapping (respects invert) → the
sY(s)arrow function insidecreateFactory(). Single source of truth for "where on Y is string s." - Static string mesh creation →
buildBoard(), the// Thin Line strings (glow layer)and// BoxGeometry strings — emissive glow ...comment blocks. Two layers: low-opacityLinefor soft glow,BoxGeometrymesh per string with its own material clone (kept instringLines[]for live emissive updates). - Live string glow / pulse →
updateStringHighlights(noteState). Tunables:BASE_GLOW,MAX_GLOW,IDLE_OP. Driven bynoteState.stringSustainandnoteState.stringAnticipation.
Fretboard
- Fret count →
NFRETSconstant. Increasing requires nothing else. - Fret X positioning →
fretX(f)andfretMid(f)(top-level helpers). Logarithmic guitar-fret spacing withinSCALE. - Fretboard plane / fret wires / fret dots →
buildBoard(), separate banner-style comment blocks (// Fret wires,// Fret dots). The dark background plane is the first thing built; main fret wires use0xbbbbff/ opacity 0.8, minor wires0x666688/ opacity 0.4. Single/double dots:DOTSarray +DDOTSset in the constants block. - Fret-row label colors / sizing (the heat-coloured row of fret numbers below the board) →
update(),// ── Dynamic fret number row ──block. Active =#ffe84d, inactive =#9ab8cc, opacity / scale driven bynoteState.fretHeat[f]. Text rendering (font, outline, shadow) is governed by the'fretRow'preset inTXT_STYLES— see "Tweaking text-sprite styling". - Active-fret cooldown →
FRET_COOLDOWNconstant. How long after the last note in a fret it stays in the active set.
Notes
- Single-note rendering →
drawNote(). Handles outline, core body, open-string variant, sustain trail, lane drop line, all technique labels, fret connector label, and the board projection. Each visual block has its own banner comment (// ── Outline ──,// ── Core (filled note body) ──,// ── Sustain trail ──,// ── Lane drop line ──,// ── Technique labels ──,// ── Per-note fret connector label ──,// ── Board projection ──). - Note geometry / size →
gNote = new T.BoxGeometry(NW, NH, ND)ininitScene(). Per-note scale tweaks happen insidedrawNote(). - Note approach rotation (vertical → horizontal) → search
approachRotinsidedrawNote(). Mapsdt / AHEADto[0, π/2]. Open strings skip the rotation. - Note color →
mStr[s](idle) /mGlow[s](hit), built ininitScene(). Hit material is white-with-emissive, idle is dim emissive of the string color. - Sustain trail →
// ── Sustain trail ──block indrawNote(). Geometry: scaledgSus(BoxGeometry(1,1,1)). WidthNW * 0.85, heightNH * 0.12. Outline mesh + colored core mesh. - Lane drop line →
// ── Lane drop line ──block indrawNote(). Vertical line from each upcoming note down to the fretboard plane in the string's color. - Per-note fret connector label →
// ── Per-note fret connector label ──block indrawNote(). Number below the board with a thin line up to the note. Be careful withreplace_allon the0.5and0.4floats in the alpha formula — they're separate constants. Uses the'noteFret'preset inTXT_STYLES(also applied to the on-body fret number whenshowFretOnNoteis enabled). - Technique markers (bend, slide, hammer/pull/tap, accent, tremolo, palm-mute, pinch harmonic) →
// ── Technique labels ──block indrawNote(). Most are small if-blocks usingtxtMat(text, color, wide, style)(cached sprite material;'technique'preset inTXT_STYLES). Exceptions: a bend draws a string-coloured chevron strength stack (bendChevronMat, one chevron per half-step), and hammer-on / pull-off draw a white ▲/▼ triangle with a string-coloured border (triMat) — both pinned to the gem; the bend ribbon's up→hold→down contour is driven bybendSemisAtTime. - Open-string note → special-cased throughout
drawNote():n.f === 0. Wider/flatter geometry, "0" label sprite, usesopenX(the chord's open-string centroid) when supplied. - Board projection ("ghost" preview) →
// ── Board projection ──block indrawNote(). Two meshes per string (projMeshArr,projGlowArr), one visible per frame for the next note. Linger windowPROJ_WIN. Gated on theprojectionVisiblesetting (BG_DEFAULTS /h3dBgSetProjectionVisible/ the "Show note preview on the fretboard" checkbox insettings.html) — when off, the block is skipped andupdate()'s per-framem.visible = falsereset leaves the ghost hidden. The glow hasrenderOrder = -1which fights the strings — see Pitfall #6. - Note-hit "sizzle" (feedBack#254) →
drawNotedetectSizzle()(called from thelyricsCtxblock indraw(), just beforedrawNotedetectLabels()). For each confirmed hit/active note (_ndGoodindrawNote()pushes{x, y, z, s, alpha, color}onto the per-frame_ndSizzlearray —alphais the provider's clamped fade,coloran optional palette override), it projects the note's world point through the up-to-datecam, sizes the burst from a fretboard-X-axis offset projection (reliable even when the note's rotated flat at the line), and twinkles a few short crackling ellipse-arc segments + tiny dots hugging the note's rectangle — re-randomised every frame, contained to ≲1.4× the note, half white / half the string colour (or the provider'scolorwhen given). Every dot/arc'sglobalAlphaandshadowBlurare scaled by the entry'salpha, and the per-element "off-this-frame" probability rises asalphadecays, so a struck-note glow visibly thins and fades. Also:_ndGoodswaps the note's outline tomGlow[s](bright string-tinted, not green). Knobs are inline: arc/dot count, base on-probability, line widths,shadowBlur, spread radii. Lives entirely on the 2D overlay layer — no Three.js geometry/disposal.
Chords
- Chord rendering loop →
update(),// ── Chords ──block. Iteratesbundle.chords, callsdrawNote()per chord-note, then draws the frame box, name label, and barre indicator. - Chord linger after hit → the
0.55-second value passed as thelingerarg todrawNote()from inside the chord loop, and used in the chord-frame Z clamp + opacity formulas. - Chord frame-box (rectangle around frets in the chord) → inside the chord loop, search for the
drawEdgehelper. Four edges + a low-opacity fill.isRepeathalves the height + dims it. - Chord name label (gold) → in the same chord loop, search
chordName. Cached viatxtMat(chordName, '#e8d080', true). Anchored above the chord box. - Barre indicator (white vertical line at the barre fret during linger) → in the chord loop, gated on
/barre/i.test(chordName) && chDt <= 0. Position isfretMid(bFret)wherebFretis the lowest fretted string. - Repeat-chord detection →
prevChordSig/prevChordTimeinside the chord loop. Same shape within 0.5 s →isRepeat = true(suppresses note bodies, dims frame). - Chord diagram (top-left 2D overlay) →
drawChordDiagram(), called from thelyricsCtxblock at the bottom of the returneddraw(). The chord-to-display is selected inupdate()under// ── Chord diagram: track most recently hit chord ──and stashed in_diagChord(most recently hit named chord within the 0.55 s linger window).
Camera
- Reference values →
CAM_H_BASE,CAM_DIST_BASE,REF_ASPECT,FOCUS_D,CAM_LERP_BASEin the constants block. - Smooth lerp + look-at →
camUpdate(). BPM-scaled lerp speed (CAM_LERP_BASE * bpm/120). - Self-correcting framing → bottom half of
camUpdate(). Projects the fretboard mid-Y to NDC, nudgestgtLookYuntil that point sits at NDC Y ≈DESIRED_NDC_Y(lower third of frame). This is what lets the camera adapt automatically to ultra-wide split-screen panels. - Aspect compensation →
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5))inapplySize(). Clamped to ≥ 1 so wide panels keep baseline depth (don't dolly in flat). Removing theMath.max(1, …)is the bug we already fixed; don't reintroduce it.
Beats and sections
- Beat lines (downbeats highlighted) →
update(),// ── Beat lines ──block.mBeatM(full opacity 0.25) for measure starts,mBeatQ(0.07) for other beats. - Section labels →
update(),// ── Section labels ──block. Cyan (#00cccc) sprite at fret 12, above the highest string.
Scene colors (two independent axes: Background + Highway)
- Scene-color themes →
BG_THEMEStable near the top ofcreateFactory(). One combined table is the single source of truth, but it drives two independent axes that share the same id-set:- Background axis — setting key
bgTheme, setterwindow.h3dBgSetBgTheme, statebgThemeId. Ownsclear(WebGL clear color) +fog. - Highway axis — setting key
hwTheme, setterwindow.h3dBgSetHwTheme, statehwThemeId. Ownsboard(fretboard/highway-surface plane) + optionallane/laneDim(the lit lane strip). Any background id can mix with any highway id; picking the same id in both gives the original "matched" look. Per-axis accessors are_bgBackgroundColors(id)/_bgHighwayColors(id)(both alias_bgThemeColors). Both axes default to'default'(byte-identical to the original look).
- Background axis — setting key
- Applying a theme →
_applyBgTheme(). Background half sets clear+fog frombgThemeId(skipped under the venue scene); highway half sets the board plane + lane materials (mLaneOdd/mLaneEven) fromhwThemeId. Re-run on init,buildBoard(), and the settings listener (which fires for bothbgThemeandhwTheme), so changing either dropdown retints only its half live. - Backward-compat migration →
_bgLoadSettings(): the first time it loads with no storedhwTheme(_bgHasStoredfalse), it seedshwThemeIdfrombgThemeIdand persistshwThemeonce (a one-time backfill, written without_bgEmitChange). So a pre-split single-bgThemepick is byte-identical right after the upgrade, and from then on the two axes are fully independent — changing the Background dropdown never drags the Highway surface, and the settings UI's Highway value can't disagree with what's rendered. settings.html shows the same first-load value viastoredHwTheme == null ? bgTheme : coerceHwTheme(...). - Adding/removing a theme → edit
BG_THEMES(the colors) ANDsettings.html'sSCENE_THEMESarray (the{id,label}list — the single source the two dropdowns'<option>s and theVALID_BG_THEMESvalidator are both generated from). Keep the two id-sets aligned.
Highway lane (the highlighted strip under active frets)
- Lane drawing →
update(),// ── Dynamic highway lane ──block.pLaneis a single quad on the fretboard plane;pLaneDivideris thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets. - Lane intensity →
highwayIntensityaccumulated from upcoming notes (further notes dim it, near notes light it). - Lane color → the lit quad color is
mLaneOdd.color(stockHWY_LANE_STRIPE_ODD_HEX = 0x103B5C), the dimmer alternating rowmLaneEven.color(HWY_LANE_STRIPE_EVEN_HEX = 0x08283C). These are now theme-aware:_applyBgTheme()recolors them from the active HIGHWAY theme's optionallane/laneDimfields, falling back to the stock hexes when a highway theme omits them. (_laneTargetColor, set ininitScene(), is kept in sync with the lit color but has no live consumer today.)
Lyrics & overlays
- Lyrics overlay →
drawLyrics(). 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim). - Chord diagram overlay →
drawChordDiagram()(see "Chords" above). 2D canvas, top-left, fades over the 0.55 s linger window. Respectsinverted(column 0 is high-e when inverted, low-E otherwise). - The
lyricsCanvasis created ininitScene()withz-index:1, appended towrapafterren.domElement— this is the empirically-correct stacking order for all browsers/contexts (including splitscreen panels withposition:relative; overflow:hidden). Don't reorder; see Pitfall #5.
Splitscreen
- Focus dim →
_isFocusedflag, manipulated by_updateFocusState(). Fades ambient + directional light intensity in non-focused panels. - Per-panel resize fallback → search
_lastHwWin the returneddraw(). The renderer self-detects when the highway canvas backing-store dimensions change and re-runsapplySize(). Needed because the splitscreen plugin overrideshw.resizeand never callsrenderer.resize(). - Reduced DPR in split →
applySize()clamps DPR to 1.25 when splitscreen is active vs 2 otherwise (searchbaseDPR). Keeps four-panel quad layout from melting GPUs.
Splitscreen panel controls/settings
- Per-panel background overrides use
localStoragekeys shaped ash3d_bg_panel<N>_<key>. When present, they override the globalh3d_bg_<key>value for panelN; when absent, the global value still applies. - Keep per-panel keys to
BG_DEFAULTSentries that_bgLoadSettings()reads. Do not add panel-only keys outside that load path. panelControlsis a static, host-readable, curated descriptor list for controls a host can expose per panel. It documents the supported per-panel surface; the renderer still loads values through_bgLoadSettings().- Asset/background image keys remain global-only. Do not make uploaded or selected asset references panel-scoped unless that contract is explicitly widened.
- Host refresh nudges that call toggle setters must pass real booleans, not strings such as
'false', so setters can distinguishtruefromfalse.
The bundle object
Every per-frame renderer call receives a bundle from feedBack core. Fields used by this plugin:
currentTime— playback time in seconds (drivesdtfor everything)notes,chords,beats,sections— chart arrays (already difficulty-filtered by core)chordTemplates— array indexed bych.id; each{ name, frets: [N] }lyrics— syllable array[{ w, t, d }, …]inverted— display flag honored viasY(s)(low-string-on-top vs the default low-string-on-bottom)lyricsVisible— gate for lyrics overlayrenderScale— pixel-ratio multiplier from the user's quality settingsongInfo.arrangement— only field ofsongInfothis plugin reads, used as the bass-name fallback inresolveStringCount()stringCount— feedBack#93; always prefer this over deriving from tuning/arrangementlefty— display flag consumed by this renderer frombundle.lefty. Captured into_leftyCachedbefore each frame soxFret(),xFretMid(),boardSpanX(), board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrorscurX/tgtXplus the lookahead camera X cache so the camera does not drift across the neck.getNoteState(note, chartTime)— feedBack#254; per-note judgment from a scorer (note_detect). Captured each frame into_ndGetNoteStateat the top ofupdate()and consulted indrawNote()AFTER the event-driven_ndHitMarks/_ndMissMarkslookup AND over the proximity-basedhitheuristic, both of which it overrides when it has a verdict:'hit'/'active'→mGlow[s]outline (bright string-tinted, not green) +mGlow[s]body +mGlow[s]sustain trail + a queue entry fordrawNotedetectSizzle(so a held sustain keeps glowing/sparkling as long as the provider keeps returning'active');'miss'→mMissOutlineand_showHit = false(suppresses the bright body even if the note is near the line). Called with the note's chart time (n.t), which is how note_detect keys itsnoteResultsmap — notnow. Returns null on cores without the API or songs with no scorer — then the event path /hitheuristic drive feedback for older note_detect builds. notedetect ≥1.13 object verdicts additionally carry{ points, mult, popKey }(game-scoring layer):pointsis the note's awarded score,multthe multiplier tier it landed at, andpopKeya dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn indrawNote()(see Score FX below); all three are absent on older notedetect builds, so guard with!== undefined.
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)
- "+N" score pops →
_fxSpawnPop()fromdrawNote()(just after the provider verdict-override block), drawn bydrawScoreFx()(called from thelyricsCtxblock indraw(), right afterdrawNotedetectLabels()). Fixed 24-slot pool (_fxPops), deduped perpopKeyvia the TTL'd_fxSeenmap (pruned indrawScoreFx). Pops rise/fade over 700 ms; font size scales with the multiplier tier. - Session FX →
notedetect:fxevents ({ fxType: 'multiplier'|'milestone'|'streakBreak', ... }). notedetect dispatches each detail object twice in the same task: onwindow(unscoped, first) and as a bubbling CustomEvent from its per-panel instanceRoot (scoped, second). The listener (_fxOnFx, bound with the other notedetect listeners) treats element-targeted copies as authoritative — accepted only when their root lives in this panel's container — and defers the window copy by a task (setTimeout 0): if the element copy (same detail reference) arrived meanwhile it's dropped as a duplicate, otherwise it's the compat fallback for a detector whose root isn't in the DOM. This keeps splitscreen panels from rendering each other's FX even for the first event of a session. Effects: milestone → particle burst from a 4-slot Float32Array pool (_fxBursts), multiplier tier-up → expanding ring pulse at the strike-line centre, streak break → brief red wash. - Skin palette →
_fxResolvePalette()readslocalStorage['feedBack_notedetect_skin'](neon/esports/metal→_FX_PALETTES) at listener-bind time and on thenotedetect:skinbus event. The display fonts are document-loaded by notedetect's stylesheet, so the overlay canvas can reference the family names directly. - Everything lives on the 2D overlay layer — no Three.js geometry, no
txtMat()cache traffic, nothing to dispose;teardown()deactivates the pools and removes both listeners. - This block is the reference implementation for other renderer plugins (drum highway, piano, custom highways) that want score pops / session FX: copy the
_fxOnFxdedup+scoping listener, thepopKey-keyed seen-map (cleared on backward seek), and the_FX_PALETTESskin mapping. The full consumer contract (events, payloads, provider verdict fields, theming variables) is documented in feedBack-plugin-notedetect'sCLAUDE.md.
If you need a bundle field that isn't here yet, check _makeBundle() in static/highway.js in the feedBack core repo — this is the plugin repo, static/highway.js is not here. The full path in the parent feedBack checkout is feedBack/static/highway.js.
Per-string state arrays
Several frame-local arrays are sized to nStr:
const noteState = {
stringSustain: new Array(nStr).fill(false),
stringAnticipation: new Array(nStr).fill(0),
fretHeat: new Array(NFRETS + 1).fill(0),
strGlow: new Array(nStr).fill(0.5),
};
Anything that indexes a per-string array MUST be guarded by validString(s). The function checks that s is an integer in [0, nStr) (returning false otherwise so the caller can skip), warns once when an out-of-range index is seen, and keeps the mStr / mGlow / mSus / projMeshArr lookups safe. It does NOT clamp — out-of-range strings are dropped, not silently mapped to a valid one. filterValidNotes(notes) is the chord-note equivalent (allocates only when something would actually be dropped).
Object pools
Pools live as closure refs (pNote, pSus, pLbl, pBeat, pSec, pFretLbl, pLane, pLaneDivider, pChordBox, pChordLbl, pBarreLine, pNoteFretLabel, pConnectorLine, pDropLine, pSusOutline).
The pool factory pool(parent, mk) returns { get(), reset() }. Every pool MUST be .reset()-ed at the top of update() — otherwise objects from the previous frame stay visible. When you add a new pool, add the reset call too. Search for the existing block of .reset() calls at the top of update() to find where to add yours.
If a pool's mesh has per-instance state (its own material clone, its own texture map), set those fields each get() call so a recycled instance picks up the right values. The "first context wins" trap is real — recycled sprites that retain a stale material.map from a previous frame won't repaint. The chord-name label loops on this (search lbl.material.map !== mat.map) by checking before swapping.
Key gotchas / pitfalls
- Adding a new pool? Reset it. The reset block at the top of
update()is easy to miss when adding a new pool elsewhere. txtMat()is cache-keyed by(style, text, color, wide). Calling it with a numerictextworks (it's coerced viaString(...)), but new label content creates a new texture forever. Don't generate dynamic per-frame text (e.g. interpolated values) throughtxtMat()or you'll leak GPU memory. For static labels that change occasionally (chord names, fret numbers), the cache is fine. Thestylearg picks a preset from theTXT_STYLEStable — see "Tweaking text-sprite styling" below.- Disposal in
teardown()matters. Three.js doesn't garbage-collect GPU resources. Everymaterial.dispose(),geometry.dispose(),map.dispose(), andren.dispose()call there is load-bearing.teardown()is called frominit()(when re-initing),destroy()(setRenderer swap orhighway.stop()), and on init failure. - Don't use
tuning.lengthfor string count.bundle.tuning(andarr.tuningserver-side) is always 6 elements even for bass — feedBack pre-fills the array with zeros for unused strings. Usebundle.stringCount(feedBack#93), with/bass/i.test(arrangement)as the only acceptable fallback. There's a comment inresolveStringCount()documenting this. - lyricsCanvas DOM order. The 2D overlay canvas is appended to
wrapAFTERren.domElementand givenz-index:1. This is the empirically-correct order — earlier versions had it before the WebGL canvas, which broke in splitscreen panels withposition:relative; overflow:hidden. Don't reorder without testing both modes. - Projection glow
renderOrder = -1ininitScene(). This is a known-suboptimal setting — it forces the glow to draw before the strings in the transparent queue, so the string visibly cuts through the preview. Removing the line lets natural Z-sort layer it correctly. Plus the projection's world-Y matches the string Y, which after perspective projection puts the preview slightly screen-lower than the string; bumpingprojY = y + NH * 0.4recenters it. (Both fixes live on thefix/preview-stackingbranch.) renderOrderon transparent objects is sticky. Three.js sorts the transparent queue byrenderOrderfirst, then back-to-front. A straym.renderOrder = -1on something will pull it under everything regardless of Z. When in doubt, leaverenderOrderat the default 0 and rely on Z position.- Corollary:
depthTest: falsealone does NOT make a sprite "always on top." It removes the sprite from depth-buffer comparison, but draw order in the transparent queue is still determined byrenderOrderthen Z. Anything rendered after adepthTest: falsesprite will still overdraw it. For HUD-style overlays that must always be visible (fret-row labels — issue #35, technique callouts), setrenderOrder = 1000AND keepdepthTest: false. Both knobs together is the contract; either alone leaves the door open to occlusion.
- Corollary:
ch.idmay be missing. Some chord events lack anid(or it doesn't index intochordTemplates). Always optional-chain:bundle.chordTemplates?.[ch.id]?.name. The chord diagram + name label both gate on a non-empty result.- The
aspectScaleclamp (Math.max(1, …)). Without it, ultra-wide split-screen panels (top/bottom layout, ~5:1 aspect) yield aspectScale ≈ 0.33, which dollies the camera way in and kills highway depth. The clamp keeps wide panels at baseline depth and only allows narrow panels to dolly the camera back. - The
_oobStringWarnedflag is reset onnStrchange in the returneddraw()— switching from guitar (6) to bass (4) re-arms the warning so a malformed bass chart still gets logged. renderOrdervalues for the lane and dividers are explicit inupdate()(lane.renderOrder = 1,div.renderOrder = 2). The lane plane needs to draw above the static fretboard plane (which has no renderOrder), and dividers need to draw above the lane.
Tweaking colors safely
The eight-color palette S_COL is the single source of truth for per-string color. Don't hardcode hex values inside drawNote() or update() — every per-string color reference is either an entry in S_COL or one of the per-string material arrays (mStr, mGlow, mSus, mProj, mProjGlow) built from it.
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
Non-string colors (the stock lane hexes HWY_LANE_STRIPE_ODD_HEX/_EVEN_HEX — now overridable per Highway theme, see "Scene colors" above; fret-row label colors #ffe84d / #9ab8cc, fret-dot color 0x556677, lyrics box rgba, chord-name gold #e8d080, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
Tweaking text-sprite styling
Every text label in the 3D scene is rasterised by txtMat(text, color, wide, style) and the look (font, outline, drop-shadow, source-canvas resolution) is driven by a preset in the TXT_STYLES table at the top of createFactory(). Do not edit the body of txtMat() to change a single label class — change the relevant preset entry instead, so the rest stay unaffected.
Current presets and their callers:
| Preset | Used by | Default look |
|---|---|---|
fretRow |
Fret-number row under the board (update(), fret-row block) |
Arial Black 900, 256px source canvas, 18px dark outline + soft drop-shadow — designed to pop against any background |
noteFret |
Per-note connector numbers + on-body fret label (drawNote()) |
Same heavy treatment as fretRow |
chord |
3D chord-name labels above chord boxes | bold sans, 128px source, 6px outline (lighter so the gold reads) |
section |
Section banners ("Verse", "Chorus") at fret 12 | bold sans, 128px source, 6px outline |
technique |
Bend / slide / H / P / T / PH / PM / accent / tremolo / open-string overlay | bold sans, 128px source, 6px outline |
open |
The "0" label on open-string note bodies | bold sans, 128px source, 6px outline |
Style fields:
font/wideFont— full CSS font shorthand (weight + size + family);wideFontis used whenwide=true(long-aspect labels: chord names, section names, "↑1/2", "~~~"). Keep both in sync if you change weight or family.srcH— source-canvas height in px. Wide labels usesrcH * 4for width. LargersrcHkeeps glyph strokes crisp after bilinear downsampling onto small sprites — bumping it from 128 → 256 was the difference between thin-and-blurry and crisp on the fret-number presets. KeepsrcHpower-of-two (128, 256, 512, …): WebGL1 and Three.js silently disable mipmap generation on NPOT textures and fall back to a non-mipmap min-filter, which causes shimmer/aliasing on labels far down the highway. The 4× width derivation preserves POT-ness too (e.g. 256 → 1024 wide).stroke/strokeW— outline color and line-width in source-canvas px. Setstroke: nullorstrokeW: 0to skip the outline (faster cache rasterisation, no contrast halo).shadow—{ color, blur, dx, dy }ornull. Drawn via canvas 2DshadowColor/shadowBlur/shadowOffsetX/Ybefore the stroke and fill, so it haloes the whole glyph.
Cache key includes the preset name (style|wide|text|color), so two presets with otherwise-identical text produce two distinct cached materials. Adding a new preset is safe — just add the entry to TXT_STYLES and pass its name as the 4th arg at the call site. Forgetting to pass style falls back to 'technique' (the broadest, most generic preset) and is the right default for a brand-new label class.
Don't generate per-frame distinct text through txtMat() (e.g. interpolated values, tick counters). The cache is unbounded and will leak GPU memory across the session — see Pitfall #2.
Lifecycle (setRenderer contract)
Per feedBack#36, the factory returns { init, draw, resize, destroy }:
init(canvas, bundle)tears down any prior state, setshighwayCanvas, lazily loads Three.js, runsinitScene(), callsapplySize()(with aretrySizerAF loop fallback if the canvas isn't laid out yet).draw(bundle)is gated on_isReady. Re-resolvesnStr/ inverted / renderScale, thenupdate(bundle) → camUpdate(bundle) → ren.render → 2D overlays. The_lastHwW/_lastHwHcheck at the top auto-resizes when the splitscreen plugin bypassesresize().resize(w, h)is gated on_isReady. Just callsapplySize().destroy()is idempotent. Sets flags, runsteardown(), dropshighwayCanvas. Tolerates being called on an instance that's been destroyed and re-init'd already (resets_lastHwW/H,_diagChord, etc.).
The factory returns a fresh instance per call, so splitscreen's per-panel setRenderer(feedBackViz_highway_3d()) gets independent state per panel — important because the chord diagram, projection meshes, etc. are all per-instance.
Branching / PR conventions
- Feature branches off
main, descriptive name (e.g.fix/preview-stacking,feat/palette-picker). - PR target: target the contributor's own fork by default unless they ask otherwise; confirm before opening a PR upstream. Run
git remote -vin this directory to see the remotes that are configured locally. - Commit messages: short imperative subject, optional body explaining why. Don't summarize the diff — the diff already does that.
- This plugin is bundled in-tree at
plugins/highway_3d/inside thegot-feedback/feedBackrepository (not a gitlink/submodule). It ships with the default container image. Changes go through the normal feedBack PR process — no separate upstream repo to sync.
When in doubt
screen.jsis one file —Grepfor the function name or banner text before guessing.- The constants block at the top is intentionally exhaustive; scan it before introducing a new magic number.
- If a "polish" feels like it should be one or two lines but stretches into restructuring, double-check whether a per-frame state field, pool reset, or
validString()guard already covers your case.