feat(highway_3d): wire flash is a lightning strike, not a lingering glow

The flash was instant-on with a 0.32 s exponential tail, and a held
sustain kept re-feeding it — wires stayed lit for the whole note. The
requested feel is a shock: light hits the frets, they jolt, it's over.

The flash is now a one-shot pulse triggered on the input's rising edge:
a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped
(1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting
into the fall (the electric shudder — the crack itself stays clean),
then hard zero. A held 'active' verdict keeps the input high
continuously, which by construction triggers nothing new: one strike
per hit, and the wires go dark while the note rings on. A re-strike
after the provider goes silent re-triggers cleanly.

Seeking backward or a long stall clears all pulse state, and a pulse
whose strike time lands ahead of the playhead after a seek is
discarded. The outer-pair bracket rule is unchanged — it now selects
across pulses instead of decay tails.

Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH
(replacing FRET_WIRE_HIT_DECAY).

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa 2026-07-18 20:30:03 -04:00
parent 9a5ec36f47
commit e05d90e949
2 changed files with 58 additions and 19 deletions

View File

@ -55,7 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
survives only as the fallback on anchor-less charts). At most **two wires are ever lit at
once**: when overlapping decay tails (fast passages) would light a run of wires, the
flash collapses to the outermost pair of the lit span — one bracket, never a picket
fence. The gem's rim joins in: on a confirmed hit the outline flashes in the
fence. The flash itself is a one-shot "lightning strike": a near-instant crack up, a
fast shocked fall with an electric flicker biting into it, then hard dark — a held
sustain triggers exactly one strike and the wires go out while the note rings on. The gem's rim joins in: on a confirmed hit the outline flashes in the
string's own colour with the same intensity treatment as the wires, fading with the
scorer's alpha. With no scorer attached, nothing changes.

View File

@ -921,7 +921,13 @@
// The provider already fades its own `alpha` on a struck note; this tail
// just keeps the hand-off from popping, and smooths the frame-to-frame
// jitter of a held sustain (whose alpha tracks live input level).
const FRET_WIRE_HIT_DECAY = 0.32;
// The flash is a one-shot "lightning strike" pulse, not a lingering glow:
// a near-instant crack up, then a fast shocked fall with an electric
// flicker, then HARD ZERO — a held sustain does not keep the wires lit.
const FRET_WIRE_HIT_RISE = 0.025; // s, the crack — near-instant fade-in
const FRET_WIRE_HIT_FALL = 0.16; // s, the shock dying out; dark after
const FRET_WIRE_HIT_FLICKER_HZ = 26; // shudder rate during the fall
const FRET_WIRE_HIT_FLICKER_DEPTH = 0.35; // how deep the shudder bites (0..1)
const S_BASE = 3 * K;
const S_GAP = 4 * K;
@ -4784,11 +4790,18 @@
const _scrStringAnticipation = new Array(MAX_RENDER_STRINGS).fill(0);
const _scrFretHeat = new Array(NFRETS + 1).fill(0);
// Fret-wire hit flash. _fwHitIn is per-frame (cleared with the rest of
// the frame state, written by drawNote when a provider confirms a note);
// _fwHitGlow persists across frames so the flash can decay smoothly
// rather than snapping off the frame the provider goes quiet.
// the frame state, written by drawNote when a provider confirms a note).
// A RISING EDGE on it triggers a one-shot pulse: _fwPulseT0 records the
// strike's chart time, _fwPulseAmp its amplitude, _fwPrevIn detects the
// edge (a held 'active' verdict keeps the input high continuously, so a
// sustain triggers exactly one pulse — the wire goes dark while the
// note rings on). _fwHitGlow holds each wire's evaluated envelope value
// this frame, for the outer-pair selection below.
const _fwHitIn = new Float32Array(NFRETS + 1);
const _fwHitGlow = new Float32Array(NFRETS + 1);
const _fwPulseT0 = new Float32Array(NFRETS + 1).fill(-Infinity);
const _fwPulseAmp = new Float32Array(NFRETS + 1);
const _fwPrevIn = new Float32Array(NFRETS + 1);
// Per-frame chord accumulator. A chord flashes only the OUTERMOST wires
// of its shape, but drawNote() sees one chord note at a time and can't
// know the span — so hits accumulate here keyed by chord, and the flash
@ -12700,23 +12713,47 @@
}
const _fwDt = now - _fwHitPrevTime;
if (!(_fwDt >= 0) || _fwDt > 1) _fwHitGlow.fill(0); // first frame, seek, or long stall
const _fwDecay = (_fwDt > 0 && _fwDt <= 1)
? Math.exp(-_fwDt / FRET_WIRE_HIT_DECAY)
: 0;
if (!(_fwDt >= 0) || _fwDt > 1) { // first frame, seek, or long stall
_fwHitGlow.fill(0);
_fwPulseT0.fill(-Infinity);
_fwPulseAmp.fill(0);
_fwPrevIn.fill(0);
}
_fwHitPrevTime = now;
// Decay EVERY wire's glow state, but flash only the OUTERMOST
// pair of lit wires. Fast passages overlap their decay tails, so
// without this a run of consecutive notes lights a picket fence
// of wires at once; collapsing to the outer pair keeps the whole
// lit span reading as ONE bracket — the same rule chords already
// follow, applied across everything currently glowing. Interior
// wires keep decaying invisibly (the base tier loop re-seeds
// their materials each frame), so the bracket tightens naturally
// as outer tails expire.
// Evaluate every wire's pulse, but flash only the OUTERMOST pair
// of lit wires (one bracket, never a picket fence — the chord
// rule applied across everything currently pulsing). The pulse
// itself is the lightning strike: a rising edge on the input
// triggers it, it cracks up over RISE, falls over FALL with a
// high-rate flicker biting into it (the shock), and is then
// hard-zero — a held sustain keeps the input high but triggers
// nothing new, so the wires go dark while the note rings.
const _fwSpan = FRET_WIRE_HIT_RISE + FRET_WIRE_HIT_FALL;
let _fwLo = -1, _fwHi = -1;
for (let _f = 0; _f <= NFRETS; _f++) {
const _g = Math.max(_fwHitIn[_f], _fwHitGlow[_f] * _fwDecay);
const _in = _fwHitIn[_f];
if (_in > 0.004 && _fwPrevIn[_f] <= 0.004) {
_fwPulseT0[_f] = now; // strike
_fwPulseAmp[_f] = _in;
}
_fwPrevIn[_f] = _in;
let _g = 0;
const _pt = now - _fwPulseT0[_f];
if (_pt >= 0 && _pt < _fwSpan) {
if (_pt < FRET_WIRE_HIT_RISE) {
_g = _pt / FRET_WIRE_HIT_RISE;
} else {
const _u = (_pt - FRET_WIRE_HIT_RISE) / FRET_WIRE_HIT_FALL;
_g = (1 - _u) * (1 - _u); // fast drop, easing out
// Electric shudder during the fall only — the crack
// itself stays clean.
_g *= 1 - FRET_WIRE_HIT_FLICKER_DEPTH
* (0.5 + 0.5 * Math.sin(_pt * 6.2832 * FRET_WIRE_HIT_FLICKER_HZ));
}
_g *= _fwPulseAmp[_f];
} else if (_pt < 0) {
_fwPulseT0[_f] = -Infinity; // seek landed before the strike
}
_fwHitGlow[_f] = _g;
if (_g < 0.004) continue; // below perceptible
if (_fwLo < 0) _fwLo = _f;