From 0920f53185ec5cf4e7877e1249cdffd59b36d5c0 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:00 +0200 Subject: [PATCH 01/16] fix(examples): level-match the effect chains and leave headroom for one-shots --- examples/audio-basics/audio-buses.js | 2 +- examples/audio-basics/audio-buses.ts | 2 +- examples/audio-fx/ducking.js | 2 +- examples/audio-fx/ducking.ts | 2 +- examples/audio-fx/effect-chains.js | 8 +++++++- examples/audio-fx/effect-chains.ts | 13 ++++++++++++- examples/audio-fx/reverb-and-delay.js | 2 +- examples/audio-fx/reverb-and-delay.ts | 2 +- 8 files changed, 25 insertions(+), 8 deletions(-) diff --git a/examples/audio-basics/audio-buses.js b/examples/audio-basics/audio-buses.js index f9f5c03e4..008e5df64 100644 --- a/examples/audio-basics/audio-buses.js +++ b/examples/audio-basics/audio-buses.js @@ -55,7 +55,7 @@ class AudioBusesScene extends Scene { this.panel.addButton({ label: 'Play sound effect', onClick: () => { - audio.play(this.sfx, { bus: audio.sound }); + audio.play(this.sfx, { bus: audio.sound, volume: 0.5 }); }, }); this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.6 }); diff --git a/examples/audio-basics/audio-buses.ts b/examples/audio-basics/audio-buses.ts index 290106e48..658e0d05f 100644 --- a/examples/audio-basics/audio-buses.ts +++ b/examples/audio-basics/audio-buses.ts @@ -69,7 +69,7 @@ class AudioBusesScene extends Scene { this.panel.addButton({ label: 'Play sound effect', onClick: () => { - audio.play(this.sfx, { bus: audio.sound }); + audio.play(this.sfx, { bus: audio.sound, volume: 0.5 }); }, }); diff --git a/examples/audio-fx/ducking.js b/examples/audio-fx/ducking.js index acc99b1e1..8f6023423 100644 --- a/examples/audio-fx/ducking.js +++ b/examples/audio-fx/ducking.js @@ -71,7 +71,7 @@ class DuckingScene extends Scene { if (app.audio.locked) { return; } - app.audio.play(this.voice, { bus: this.voiceBus }); + app.audio.play(this.voice, { bus: this.voiceBus, volume: 0.4 }); this.hud.setStatus('Voice playing — music ducked'); }); // Core defers playback until the AudioContext unlocks on the first diff --git a/examples/audio-fx/ducking.ts b/examples/audio-fx/ducking.ts index e59962403..73561808c 100644 --- a/examples/audio-fx/ducking.ts +++ b/examples/audio-fx/ducking.ts @@ -94,7 +94,7 @@ class DuckingScene extends Scene { return; } - app.audio.play(this.voice, { bus: this.voiceBus }); + app.audio.play(this.voice, { bus: this.voiceBus, volume: 0.4 }); this.hud.setStatus('Voice playing — music ducked'); }); diff --git a/examples/audio-fx/effect-chains.js b/examples/audio-fx/effect-chains.js index 8ea8db474..17408f9ad 100644 --- a/examples/audio-fx/effect-chains.js +++ b/examples/audio-fx/effect-chains.js @@ -16,6 +16,7 @@ const CHAINS = [ { name: 'Telephone', note: 'Band-limit the sound before adding saturation.', + trim: 0.14, stages: [ { name: 'Highpass', create: () => new HighpassFilter({ frequency: 800 }) }, { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 2500 }) }, @@ -25,6 +26,7 @@ const CHAINS = [ { name: 'Underwater', note: 'Remove highs, then add a slow chorus and room tail.', + trim: 2, stages: [ { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 420, resonance: 2 }) }, { name: 'Chorus', create: () => new ChorusEffect({ delayMs: 25, depthMs: 8, rateHz: 0.6, wet: 0.6 }) }, @@ -34,6 +36,7 @@ const CHAINS = [ { name: 'Arcade', note: 'Quantize the samples, then warm the edges with distortion.', + trim: 0.35, stages: [ { name: 'BitCrusher', create: () => new BitCrusherEffect({ bits: 5, frequencyReduction: 0.35 }) }, { name: 'Distortion', create: () => new DistortionEffect({ drive: 0.25, wet: 0.6 }) }, @@ -42,6 +45,7 @@ const CHAINS = [ { name: 'Space Echo', note: 'Stereo repeats follow a short room reflection.', + trim: 0.35, stages: [ { name: 'Reverb', create: () => new ReverbEffect({ decay: 1.5, wet: 0.25 }) }, { name: 'PingPongDelay', create: () => new PingPongDelayEffect({ delayTime: 0.32, feedback: 0.5, wet: 0.45 }) }, @@ -93,7 +97,7 @@ class EffectChainsScene extends Scene { label: 'Play impact', onClick: () => { if (!audio.locked) { - audio.play(this.impact); + audio.play(this.impact, { volume: 0.4 }); } }, }); @@ -126,6 +130,7 @@ class EffectChainsScene extends Scene { } }); this.hud.setHint(chain.note); + master.volume = this.bypass ? 1 : chain.trim; if (this.bypass) { this.hud.setStatus(`${chain.name} bypassed; dry signal.`); return; @@ -178,6 +183,7 @@ class EffectChainsScene extends Scene { effect.destroy(); } this.active = []; + this.app.audio.master.volume = 1; this.analyser?.destroy(); this.panel?.dispose(); this.hud?.dispose(); diff --git a/examples/audio-fx/effect-chains.ts b/examples/audio-fx/effect-chains.ts index dc5d8013b..7fa4aba7d 100644 --- a/examples/audio-fx/effect-chains.ts +++ b/examples/audio-fx/effect-chains.ts @@ -20,6 +20,10 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; interface Chain { name: string; note: string; + // Output gain that brings the chain back to roughly the dry loudness. + // Without it the louder chain always sounds "better", so an A/B comparison + // judges level rather than the effect. + trim: number; stages: Array<{ name: string; create(): AudioEffect }>; } @@ -27,6 +31,7 @@ const CHAINS: Chain[] = [ { name: 'Telephone', note: 'Band-limit the sound before adding saturation.', + trim: 0.14, stages: [ { name: 'Highpass', create: () => new HighpassFilter({ frequency: 800 }) }, { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 2500 }) }, @@ -36,6 +41,7 @@ const CHAINS: Chain[] = [ { name: 'Underwater', note: 'Remove highs, then add a slow chorus and room tail.', + trim: 2, stages: [ { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 420, resonance: 2 }) }, { name: 'Chorus', create: () => new ChorusEffect({ delayMs: 25, depthMs: 8, rateHz: 0.6, wet: 0.6 }) }, @@ -45,6 +51,7 @@ const CHAINS: Chain[] = [ { name: 'Arcade', note: 'Quantize the samples, then warm the edges with distortion.', + trim: 0.35, stages: [ { name: 'BitCrusher', create: () => new BitCrusherEffect({ bits: 5, frequencyReduction: 0.35 }) }, { name: 'Distortion', create: () => new DistortionEffect({ drive: 0.25, wet: 0.6 }) }, @@ -53,6 +60,7 @@ const CHAINS: Chain[] = [ { name: 'Space Echo', note: 'Stereo repeats follow a short room reflection.', + trim: 0.35, stages: [ { name: 'Reverb', create: () => new ReverbEffect({ decay: 1.5, wet: 0.25 }) }, { name: 'PingPongDelay', create: () => new PingPongDelayEffect({ delayTime: 0.32, feedback: 0.5, wet: 0.45 }) }, @@ -107,7 +115,7 @@ class EffectChainsScene extends Scene { label: 'Play impact', onClick: () => { if (!audio.locked) { - audio.play(this.impact); + audio.play(this.impact, { volume: 0.4 }); } }, }); @@ -147,6 +155,8 @@ class EffectChainsScene extends Scene { }); this.hud.setHint(chain.note); + master.volume = this.bypass ? 1 : chain.trim; + if (this.bypass) { this.hud.setStatus(`${chain.name} bypassed; dry signal.`); @@ -209,6 +219,7 @@ class EffectChainsScene extends Scene { } this.active = []; + this.app.audio.master.volume = 1; this.analyser?.destroy(); this.panel?.dispose(); this.hud?.dispose(); diff --git a/examples/audio-fx/reverb-and-delay.js b/examples/audio-fx/reverb-and-delay.js index e74a2e554..19f99c83f 100644 --- a/examples/audio-fx/reverb-and-delay.js +++ b/examples/audio-fx/reverb-and-delay.js @@ -32,7 +32,7 @@ class ReverbAndDelayScene extends Scene { label: 'Play impact', onClick: () => { if (!audio.locked) { - audio.play(this.sound, { bus: audio.sound }); + audio.play(this.sound, { bus: audio.sound, volume: 0.4 }); } }, }); diff --git a/examples/audio-fx/reverb-and-delay.ts b/examples/audio-fx/reverb-and-delay.ts index 9bd2eb93c..3875cf63c 100644 --- a/examples/audio-fx/reverb-and-delay.ts +++ b/examples/audio-fx/reverb-and-delay.ts @@ -34,7 +34,7 @@ class ReverbAndDelayScene extends Scene { label: 'Play impact', onClick: () => { if (!audio.locked) { - audio.play(this.sound, { bus: audio.sound }); + audio.play(this.sound, { bus: audio.sound, volume: 0.4 }); } }, }); From f1be8c164cfce6dd61f6f5bc76478be3689def85 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:08 +0200 Subject: [PATCH 02/16] fix(examples): keep the dry voice out of the vocoder mix --- examples/audio-fx/vocoder.js | 18 ++++++++++++++---- examples/audio-fx/vocoder.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/examples/audio-fx/vocoder.js b/examples/audio-fx/vocoder.js index 4e6fd07d7..61d388f2f 100644 --- a/examples/audio-fx/vocoder.js +++ b/examples/audio-fx/vocoder.js @@ -10,6 +10,7 @@ const PHRASES = [ { key: 'objective', label: 'Objective achieved', asset: assets.demo.voice.objectiveAchieved }, ]; class VocoderScene extends Scene { + sidechainBus; modulatorBus; vocoder; level; @@ -29,8 +30,12 @@ class VocoderScene extends Scene { const app = this.app; const { width, height } = app; // The spoken voice is the modulator: route every phrase onto its own bus - // so the vocoder can read its spectral envelope. - this.modulatorBus = new AudioBus('modulator'); + // so the vocoder can read its spectral envelope. The vocoder taps that + // bus's output, so parenting it to a muted bus keeps the dry voice out of + // the mix without starving the effect. + this.sidechainBus = new AudioBus('vocoder-sidechain', { muted: true }); + app.audio.registerBus(this.sidechainBus); + this.modulatorBus = new AudioBus('modulator', { parent: this.sidechainBus }); app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { // phrase.asset is a widened `string` (not a path literal), so the @@ -40,6 +45,9 @@ class VocoderScene extends Scene { } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); app.audio.sound.addEffect(this.vocoder); + // The voice samples are mastered close to full scale, dry or vocoded; + // the sound bus leaves headroom for both. + app.audio.sound.volume = 0.4; // Measured downstream of the vocoder, so the pulse tracks the robot // voice actually reaching the speakers, not the dry carrier. this.level = new AudioAnalyser({ source: app.audio.sound, smoothingTimeConstant: 0.6 }); @@ -77,7 +85,7 @@ class VocoderScene extends Scene { this.processed = value; this.vocoder.wet = value ? 1 : 0; if (this.carrierVoice) { - this.carrierVoice.volume = value ? 0.45 : 0; + this.carrierVoice.volume = value ? 0.4 : 0; } this.hud.setStatus(value ? 'Processed voice: phrase drives the carrier.' : 'Dry voice: phrase plays directly.'); }, @@ -95,7 +103,7 @@ class VocoderScene extends Scene { startCarrier = () => { this.carrierVoice ??= this.app.audio.play(new AudioGenerator({ frequency: 110, type: 'sawtooth' }), { bus: this.app.audio.sound, - volume: this.processed ? 0.45 : 0, + volume: this.processed ? 0.4 : 0, }); if (this.pendingPhrase) { this.pendingPhrase = false; @@ -140,9 +148,11 @@ class VocoderScene extends Scene { this.app.audio.onUnlock.remove(this.startCarrier); this.carrierVoice?.stop(); this.app.audio.sound.removeEffect(this.vocoder); + this.app.audio.sound.volume = 1; this.vocoder?.destroy(); this.level?.destroy(); this.app.audio.unregisterBus(this.modulatorBus); + this.app.audio.unregisterBus(this.sidechainBus); this.panel?.dispose(); this.hud?.dispose(); this.tapPrompt?.destroy(); diff --git a/examples/audio-fx/vocoder.ts b/examples/audio-fx/vocoder.ts index e7c166599..33ea1bf8f 100644 --- a/examples/audio-fx/vocoder.ts +++ b/examples/audio-fx/vocoder.ts @@ -24,6 +24,7 @@ const PHRASES: Array<{ key: string; label: string; asset: string }> = [ ]; class VocoderScene extends Scene { + private sidechainBus!: AudioBus; private modulatorBus!: AudioBus; private vocoder!: VocoderEffect; private level!: AudioAnalyser; @@ -45,8 +46,12 @@ class VocoderScene extends Scene { const { width, height } = app; // The spoken voice is the modulator: route every phrase onto its own bus - // so the vocoder can read its spectral envelope. - this.modulatorBus = new AudioBus('modulator'); + // so the vocoder can read its spectral envelope. The vocoder taps that + // bus's output, so parenting it to a muted bus keeps the dry voice out of + // the mix without starving the effect. + this.sidechainBus = new AudioBus('vocoder-sidechain', { muted: true }); + app.audio.registerBus(this.sidechainBus); + this.modulatorBus = new AudioBus('modulator', { parent: this.sidechainBus }); app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { @@ -58,6 +63,9 @@ class VocoderScene extends Scene { this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); app.audio.sound.addEffect(this.vocoder); + // The voice samples are mastered close to full scale, dry or vocoded; + // the sound bus leaves headroom for both. + app.audio.sound.volume = 0.4; // Measured downstream of the vocoder, so the pulse tracks the robot // voice actually reaching the speakers, not the dry carrier. this.level = new AudioAnalyser({ source: app.audio.sound, smoothingTimeConstant: 0.6 }); @@ -99,7 +107,7 @@ class VocoderScene extends Scene { this.vocoder.wet = value ? 1 : 0; if (this.carrierVoice) { - this.carrierVoice.volume = value ? 0.45 : 0; + this.carrierVoice.volume = value ? 0.4 : 0; } this.hud.setStatus(value ? 'Processed voice: phrase drives the carrier.' : 'Dry voice: phrase plays directly.'); @@ -121,7 +129,7 @@ class VocoderScene extends Scene { private readonly startCarrier = (): void => { this.carrierVoice ??= this.app.audio.play(new AudioGenerator({ frequency: 110, type: 'sawtooth' }), { bus: this.app.audio.sound, - volume: this.processed ? 0.45 : 0, + volume: this.processed ? 0.4 : 0, }); if (this.pendingPhrase) { @@ -181,9 +189,11 @@ class VocoderScene extends Scene { this.app.audio.onUnlock.remove(this.startCarrier); this.carrierVoice?.stop(); this.app.audio.sound.removeEffect(this.vocoder); + this.app.audio.sound.volume = 1; this.vocoder?.destroy(); this.level?.destroy(); this.app.audio.unregisterBus(this.modulatorBus); + this.app.audio.unregisterBus(this.sidechainBus); this.panel?.dispose(); this.hud?.dispose(); this.tapPrompt?.destroy(); From 617dbaeee64866e9d13a7badc6339c30cb763047 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:15 +0200 Subject: [PATCH 03/16] feat(examples): place the spatial audio source with a mono pulse --- examples/spatial-audio/listener-and-source.js | 233 ++++++++++------ examples/spatial-audio/listener-and-source.ts | 264 +++++++++++------- 2 files changed, 306 insertions(+), 191 deletions(-) diff --git a/examples/spatial-audio/listener-and-source.js b/examples/spatial-audio/listener-and-source.js index 77e1de430..9441c3d0b 100644 --- a/examples/spatial-audio/listener-and-source.js +++ b/examples/spatial-audio/listener-and-source.js @@ -1,14 +1,18 @@ // Auto-generated from listener-and-source.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; // Spatial parameters tuned to the canvas so attenuation is visible across the -// wide 1280px canvas. These mirror the Web Audio `linear` model (see +// wide 1280px canvas. These mirror the Web Audio distance models (see // DistanceModel in src/audio/Sound.ts) so the on-screen readout matches what // you hear. const REF_DISTANCE = 50; const MAX_DISTANCE = 560; const ROLLOFF = 1; const SOURCE_RADIUS = 24; +// Measured so the pulses still leave headroom with the source on top of the +// listener, where no falloff applies. +const SOURCE_VOLUME = 0.6; +const MODELS = ['linear', 'inverse', 'exponential']; const attenuation = (model, distance) => { if (distance <= REF_DISTANCE) { return 1; @@ -21,6 +25,28 @@ const attenuation = (model, distance) => { } return Math.pow(distance / REF_DISTANCE, -ROLLOFF); }; +// A one-second loop of four short noise bursts, the first one accented. Noise +// covers the whole spectrum, which is what the ear uses to place a sound; a +// mixed music track smears direction and distance across its own panning and +// dynamics. Mono, so every bit of left/right difference comes from the panner. +const createPulseLoop = () => { + const sampleRate = 48000; + const buffer = new AudioBuffer({ length: sampleRate, sampleRate, numberOfChannels: 1 }); + const data = buffer.getChannelData(0); + let seed = 1; + let previous = 0; + for (let i = 0; i < data.length; i++) { + const t = (i % (sampleRate / 4)) / sampleRate; + const accent = i < sampleRate / 4 ? 1 : 0.55; + seed = (seed * 1664525 + 1013904223) >>> 0; + const noise = seed / 2 ** 31 - 1; + // First-order high-pass keeps the burst crisp instead of boomy. + const crisp = noise - previous * 0.6; + previous = noise; + data[i] = crisp * accent * Math.min(1, t / 0.002) * Math.exp(-t / 0.03) * 0.8; + } + return buffer; +}; class ListenerAndSourceScene extends Scene { sound; voice = null; @@ -28,26 +54,24 @@ class ListenerAndSourceScene extends Scene { dragging = false; listener; graphics; - label; + readout; tapPrompt; hud; + panel; mode = 'drag'; model = 'linear'; + playing = true; angle = 0; - async load() { + init() { const app = this.app; const { width, height } = app; - // A continuous music loop, not a one-shot: spatialization is only - // audible while there is sustained signal to pan/attenuate. The derived - // Sound below reads .audioBuffer synchronously, so await load() instead - // of the deferred get() (whose placeholder audioBuffer is null until fill). - const source = await this.loader.load(Asset.type('sound', 'audio/demo-loop-main.ogg')); - this.sound = new Sound(source.audioBuffer); + this.sound = new Sound(createPulseLoop()); this.listener = { x: width / 2, y: height / 2 }; app.audio.listener.target = this.listener; this.graphics = new Graphics(); - this.label = new Text('', { fillColor: Color.white, fontSize: 17 }); - this.label.setPosition(20, 20); + this.readout = new Text('', { fillColor: new Color(220, 228, 245), fontSize: 18, align: 'center' }) + .setAnchor(0.5, 0) + .setPosition(width / 2, 18); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the loop starts. this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -55,78 +79,86 @@ class ListenerAndSourceScene extends Scene { .setPosition(width / 2, height - 48); this.hud = mountControls({ title: 'Spatial Audio', - controls: [{ keys: 'Drag', action: 'move the red source in drag mode' }], + controls: [{ keys: 'Drag', action: 'move the source in drag mode' }], status: 'Click or press any key to start…', - hint: 'Move the source or let it orbit, then compare distance models with the live volume readout.', + hint: 'Best with headphones. Left/right follows the direction to the source; loudness follows the falloff model.', }); - const panel = mountControlPanel({ title: 'Source and falloff' }); - panel.addButton({ - label: 'Drag source', - onClick: () => { - this.mode = 'drag'; + this.panel = mountControlPanel({ title: 'Source and falloff' }); + this.panel.addCycle({ + label: 'Movement', + options: ['Drag', 'Orbit'], + onChange: index => { + this.mode = index === 0 ? 'drag' : 'orbit'; this.dragging = false; + this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); }, }); - panel.addButton({ - label: 'Orbit source', - onClick: () => { - this.mode = 'orbit'; - this.dragging = false; - this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); + this.panel.addCycle({ + label: 'Falloff', + options: [...MODELS], + onChange: index => { + this.model = MODELS[index]; + if (this.voice) { + this.voice.distanceModel = this.model; + } + }, + }); + this.panel.addToggle({ + label: 'Sound', + value: true, + onChange: value => { + this.playing = value; + if (this.voice) { + this.voice.volume = value ? SOURCE_VOLUME : 0; + } }, }); - for (const model of ['linear', 'inverse', 'exponential']) { - panel.addButton({ - label: model, - onClick: () => { - this.model = model; - if (this.voice) { - this.voice.distanceModel = model; - } - }, - }); - } this.source.x = width / 2 + 220; this.source.y = height / 2; - app.input.onPointerDown.add(pointer => { - const dx = pointer.x - this.source.x; - const dy = pointer.y - this.source.y; - // Generous grab radius so the source is easy to pick up. - if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { - this.dragging = true; - } - }); - app.input.onPointerMove.add(pointer => { - if (!this.dragging) { - return; - } - this.source.x = pointer.x; - this.source.y = pointer.y; - if (this.voice) { - this.voice.position = this.source; - } - }); - app.input.onPointerUp.add(() => { - this.dragging = false; - }); + app.input.onPointerDown.add(this.onPointerDown); + app.input.onPointerMove.add(this.onPointerMove); + app.input.onPointerUp.add(this.onPointerUp); // A Sound played while audio is still locked is a no-op: a suspended // AudioContext's clock stands still, so nothing can be scheduled // honestly. Start the loop from the unlock gesture instead. Subscribing // is safe even if audio unlocked earlier - onUnlock replays. // play() returns the narrow Voice interface; Sound voices are spatializable. - app.audio.onUnlock.add(() => { - this.voice = app.audio.play(this.sound, { - loop: true, - volume: 1, - position: this.source, - distanceModel: this.model, - refDistance: REF_DISTANCE, - maxDistance: MAX_DISTANCE, - rolloffFactor: ROLLOFF, - }); - this.hud.setStatus('Drag the red source to move it'); - }); + app.audio.onUnlock.add(this.start); } + // Application input outlives the scene, so these handlers are removed in destroy(). + onPointerDown = (_pointer, x, y) => { + const dx = x - this.source.x; + const dy = y - this.source.y; + // Generous grab radius so the source is easy to pick up. + if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { + this.dragging = true; + } + }; + onPointerMove = (_pointer, x, y) => { + if (!this.dragging) { + return; + } + this.source.x = x; + this.source.y = y; + if (this.voice) { + this.voice.position = this.source; + } + }; + onPointerUp = () => { + this.dragging = false; + }; + start = () => { + this.voice ??= this.app.audio.play(this.sound, { + loop: true, + volume: this.playing ? SOURCE_VOLUME : 0, + position: this.source, + distanceModel: this.model, + refDistance: REF_DISTANCE, + maxDistance: MAX_DISTANCE, + rolloffFactor: ROLLOFF, + }); + this.hud.setStatus(this.mode === 'drag' ? 'Drag the source around the listener' : 'The source orbits the listener'); + }; update(delta) { if (this.mode !== 'orbit') { return; @@ -140,37 +172,58 @@ class ListenerAndSourceScene extends Scene { } draw(context) { const app = this.app; - const source = this.source; - const dx = source.x - this.listener.x; - const dy = source.y - this.listener.y; + const { source, listener } = this; + const dx = source.x - listener.x; + const dy = source.y - listener.y; const dist = Math.sqrt(dx * dx + dy * dy); const volume = attenuation(this.model, dist); // Equalpower pans by direction, not by horizontal offset: a source on the // listener's horizontal line is hard left/right, one straight above or below it is centred. const pan = dist > 0 ? Math.asin(dx / dist) / (Math.PI / 2) : 0; - const panText = pan < -0.05 ? `L ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `R ${pan.toFixed(2)}` : 'center'; - this.label.text = `${this.mode} · ${this.model} · distance ${dist.toFixed(0)} px · volume ${(volume * 100).toFixed(0)}% · pan ${panText}`; - this.graphics.clear(); - // Reference + max distance rings around the listener. - this.graphics.fillColor = new Color(50, 60, 60); - this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE); - this.graphics.fillColor = new Color(0, 0, 0); - this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE - 2); - // Listener. - this.graphics.fillColor = new Color(120, 255, 160); - this.graphics.drawCircle(this.listener.x, this.listener.y, 14); - // Source - brightness tracks attenuation so volume reads visually too. - const glow = Math.floor(80 + volume * 175); - this.graphics.fillColor = new Color(glow, Math.floor(80 + volume * 60), Math.floor(80 + volume * 60)); - this.graphics.drawCircle(source.x, source.y, SOURCE_RADIUS); - context.render(this.graphics); - context.render(this.label); + const panText = pan < -0.05 ? `left ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `right ${pan.toFixed(2)}` : 'centre'; + this.readout.text = `${this.model} falloff · distance ${dist.toFixed(0)} px · gain ${(volume * 100).toFixed(0)}% · pan ${panText}`; + const g = this.graphics.clear(); + // Reference and maximum distance rings around the listener. + g.lineWidth = 2; + g.lineColor = new Color(70, 90, 110); + g.fillColor = new Color(0, 0, 0, 0); + g.drawCircle(listener.x, listener.y, MAX_DISTANCE); + g.lineColor = new Color(110, 140, 160); + g.drawCircle(listener.x, listener.y, REF_DISTANCE); + // Line of sight, brighter the louder the source arrives. + g.lineColor = new Color(255, 180, 120, 0.15 + volume * 0.6); + g.drawLine(listener.x, listener.y, source.x, source.y); + g.lineWidth = 0; + // Listener: a head facing the top of the screen, with left/right ears that + // light up with the pan. + g.fillColor = new Color(120, 255, 160); + g.drawCircle(listener.x, listener.y, 14); + g.fillColor = new Color(80, 180, 255, 0.35 + Math.max(0, -pan) * 0.65); + g.drawCircle(listener.x - 18, listener.y, 6); + g.fillColor = new Color(80, 180, 255, 0.35 + Math.max(0, pan) * 0.65); + g.drawCircle(listener.x + 18, listener.y, 6); + g.fillColor = new Color(120, 255, 160); + g.drawPolygon([listener.x - 6, listener.y - 12, listener.x, listener.y - 22, listener.x + 6, listener.y - 12]); + // Source: halo and brightness track the attenuation, so volume reads visually too. + g.fillColor = new Color(255, 120, 90, 0.12 + volume * 0.2); + g.drawCircle(source.x, source.y, SOURCE_RADIUS + 10 + volume * 14); + g.fillColor = new Color(Math.floor(110 + volume * 145), Math.floor(80 + volume * 60), Math.floor(70 + volume * 40)); + g.drawCircle(source.x, source.y, SOURCE_RADIUS); + context.render(g); + context.render(this.readout); if (app.audio.locked) { context.render(this.tapPrompt); } } destroy() { + const input = this.app.input; + input.onPointerDown.remove(this.onPointerDown); + input.onPointerMove.remove(this.onPointerMove); + input.onPointerUp.remove(this.onPointerUp); + this.app.audio.onUnlock.remove(this.start); this.voice?.stop(); + this.panel.dispose(); + this.hud.dispose(); super.destroy(); } } @@ -182,7 +235,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(8, 10, 16), loader: { basePath: 'assets/', }, diff --git a/examples/spatial-audio/listener-and-source.ts b/examples/spatial-audio/listener-and-source.ts index fd7a23a15..78d957d09 100644 --- a/examples/spatial-audio/listener-and-source.ts +++ b/examples/spatial-audio/listener-and-source.ts @@ -1,16 +1,20 @@ import type { RenderingContext, Seconds, Spatializable, Voice } from '@codexo/exojs'; -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; // Spatial parameters tuned to the canvas so attenuation is visible across the -// wide 1280px canvas. These mirror the Web Audio `linear` model (see +// wide 1280px canvas. These mirror the Web Audio distance models (see // DistanceModel in src/audio/Sound.ts) so the on-screen readout matches what // you hear. const REF_DISTANCE = 50; const MAX_DISTANCE = 560; const ROLLOFF = 1; const SOURCE_RADIUS = 24; -type FalloffModel = 'linear' | 'inverse' | 'exponential'; +// Measured so the pulses still leave headroom with the source on top of the +// listener, where no falloff applies. +const SOURCE_VOLUME = 0.6; +const MODELS = ['linear', 'inverse', 'exponential'] as const; +type FalloffModel = (typeof MODELS)[number]; const attenuation = (model: FalloffModel, distance: number): number => { if (distance <= REF_DISTANCE) { @@ -28,6 +32,31 @@ const attenuation = (model: FalloffModel, distance: number): number => { return Math.pow(distance / REF_DISTANCE, -ROLLOFF); }; +// A one-second loop of four short noise bursts, the first one accented. Noise +// covers the whole spectrum, which is what the ear uses to place a sound; a +// mixed music track smears direction and distance across its own panning and +// dynamics. Mono, so every bit of left/right difference comes from the panner. +const createPulseLoop = (): AudioBuffer => { + const sampleRate = 48000; + const buffer = new AudioBuffer({ length: sampleRate, sampleRate, numberOfChannels: 1 }); + const data = buffer.getChannelData(0); + let seed = 1; + let previous = 0; + + for (let i = 0; i < data.length; i++) { + const t = (i % (sampleRate / 4)) / sampleRate; + const accent = i < sampleRate / 4 ? 1 : 0.55; + seed = (seed * 1664525 + 1013904223) >>> 0; + const noise = seed / 2 ** 31 - 1; + // First-order high-pass keeps the burst crisp instead of boomy. + const crisp = noise - previous * 0.6; + previous = noise; + data[i] = crisp * accent * Math.min(1, t / 0.002) * Math.exp(-t / 0.03) * 0.8; + } + + return buffer; +}; + class ListenerAndSourceScene extends Scene { private sound!: Sound; private voice: (Voice & Spatializable) | null = null; @@ -35,29 +64,27 @@ class ListenerAndSourceScene extends Scene { private dragging = false; private listener!: { x: number; y: number }; private graphics!: Graphics; - private label!: Text; + private readout!: Text; private tapPrompt!: Text; private hud!: ReturnType; + private panel!: ReturnType; private mode: 'drag' | 'orbit' = 'drag'; private model: FalloffModel = 'linear'; + private playing = true; private angle = 0; - override async load(): Promise { + override init(): void { const app = this.app; const { width, height } = app; - // A continuous music loop, not a one-shot: spatialization is only - // audible while there is sustained signal to pan/attenuate. The derived - // Sound below reads .audioBuffer synchronously, so await load() instead - // of the deferred get() (whose placeholder audioBuffer is null until fill). - const source = await this.loader.load(Asset.type('sound', 'audio/demo-loop-main.ogg')); - this.sound = new Sound(source.audioBuffer); + this.sound = new Sound(createPulseLoop()); this.listener = { x: width / 2, y: height / 2 }; app.audio.listener.target = this.listener; this.graphics = new Graphics(); - this.label = new Text('', { fillColor: Color.white, fontSize: 17 }); - this.label.setPosition(20, 20); + this.readout = new Text('', { fillColor: new Color(220, 228, 245), fontSize: 18, align: 'center' }) + .setAnchor(0.5, 0) + .setPosition(width / 2, 18); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the loop starts. @@ -67,87 +94,99 @@ class ListenerAndSourceScene extends Scene { this.hud = mountControls({ title: 'Spatial Audio', - controls: [{ keys: 'Drag', action: 'move the red source in drag mode' }], + controls: [{ keys: 'Drag', action: 'move the source in drag mode' }], status: 'Click or press any key to start…', - hint: 'Move the source or let it orbit, then compare distance models with the live volume readout.', + hint: 'Best with headphones. Left/right follows the direction to the source; loudness follows the falloff model.', }); - const panel = mountControlPanel({ title: 'Source and falloff' }); - panel.addButton({ - label: 'Drag source', - onClick: () => { - this.mode = 'drag'; + this.panel = mountControlPanel({ title: 'Source and falloff' }); + this.panel.addCycle({ + label: 'Movement', + options: ['Drag', 'Orbit'], + onChange: index => { + this.mode = index === 0 ? 'drag' : 'orbit'; this.dragging = false; + this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); }, }); - panel.addButton({ - label: 'Orbit source', - onClick: () => { - this.mode = 'orbit'; - this.dragging = false; - this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); + this.panel.addCycle({ + label: 'Falloff', + options: [...MODELS], + onChange: index => { + this.model = MODELS[index]; + + if (this.voice) { + this.voice.distanceModel = this.model; + } + }, + }); + this.panel.addToggle({ + label: 'Sound', + value: true, + onChange: value => { + this.playing = value; + + if (this.voice) { + this.voice.volume = value ? SOURCE_VOLUME : 0; + } }, }); - - for (const model of ['linear', 'inverse', 'exponential'] as const) { - panel.addButton({ - label: model, - onClick: () => { - this.model = model; - - if (this.voice) { - this.voice.distanceModel = model; - } - }, - }); - } this.source.x = width / 2 + 220; this.source.y = height / 2; - app.input.onPointerDown.add(pointer => { - const dx = pointer.x - this.source.x; - const dy = pointer.y - this.source.y; - - // Generous grab radius so the source is easy to pick up. - if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { - this.dragging = true; - } - }); - app.input.onPointerMove.add(pointer => { - if (!this.dragging) { - return; - } - - this.source.x = pointer.x; - this.source.y = pointer.y; - - if (this.voice) { - this.voice.position = this.source; - } - }); - app.input.onPointerUp.add(() => { - this.dragging = false; - }); + app.input.onPointerDown.add(this.onPointerDown); + app.input.onPointerMove.add(this.onPointerMove); + app.input.onPointerUp.add(this.onPointerUp); // A Sound played while audio is still locked is a no-op: a suspended // AudioContext's clock stands still, so nothing can be scheduled // honestly. Start the loop from the unlock gesture instead. Subscribing // is safe even if audio unlocked earlier - onUnlock replays. // play() returns the narrow Voice interface; Sound voices are spatializable. - app.audio.onUnlock.add(() => { - this.voice = app.audio.play(this.sound, { - loop: true, - volume: 1, - position: this.source, - distanceModel: this.model, - refDistance: REF_DISTANCE, - maxDistance: MAX_DISTANCE, - rolloffFactor: ROLLOFF, - }) as Voice & Spatializable; - this.hud.setStatus('Drag the red source to move it'); - }); + app.audio.onUnlock.add(this.start); } + // Application input outlives the scene, so these handlers are removed in destroy(). + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { + const dx = x - this.source.x; + const dy = y - this.source.y; + + // Generous grab radius so the source is easy to pick up. + if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { + this.dragging = true; + } + }; + + private readonly onPointerMove = (_pointer: unknown, x: number, y: number): void => { + if (!this.dragging) { + return; + } + + this.source.x = x; + this.source.y = y; + + if (this.voice) { + this.voice.position = this.source; + } + }; + + private readonly onPointerUp = (): void => { + this.dragging = false; + }; + + private readonly start = (): void => { + this.voice ??= this.app.audio.play(this.sound, { + loop: true, + volume: this.playing ? SOURCE_VOLUME : 0, + position: this.source, + distanceModel: this.model, + refDistance: REF_DISTANCE, + maxDistance: MAX_DISTANCE, + rolloffFactor: ROLLOFF, + }) as Voice & Spatializable; + this.hud.setStatus(this.mode === 'drag' ? 'Drag the source around the listener' : 'The source orbits the listener'); + }; + override update(delta: Seconds): void { if (this.mode !== 'orbit') { return; @@ -164,36 +203,51 @@ class ListenerAndSourceScene extends Scene { override draw(context: RenderingContext): void { const app = this.app; - const source = this.source; - const dx = source.x - this.listener.x; - const dy = source.y - this.listener.y; + const { source, listener } = this; + const dx = source.x - listener.x; + const dy = source.y - listener.y; const dist = Math.sqrt(dx * dx + dy * dy); const volume = attenuation(this.model, dist); // Equalpower pans by direction, not by horizontal offset: a source on the // listener's horizontal line is hard left/right, one straight above or below it is centred. const pan = dist > 0 ? Math.asin(dx / dist) / (Math.PI / 2) : 0; - const panText = pan < -0.05 ? `L ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `R ${pan.toFixed(2)}` : 'center'; - this.label.text = `${this.mode} · ${this.model} · distance ${dist.toFixed(0)} px · volume ${(volume * 100).toFixed(0)}% · pan ${panText}`; - - this.graphics.clear(); - - // Reference + max distance rings around the listener. - this.graphics.fillColor = new Color(50, 60, 60); - this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE); - this.graphics.fillColor = new Color(0, 0, 0); - this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE - 2); - - // Listener. - this.graphics.fillColor = new Color(120, 255, 160); - this.graphics.drawCircle(this.listener.x, this.listener.y, 14); - - // Source - brightness tracks attenuation so volume reads visually too. - const glow = Math.floor(80 + volume * 175); - this.graphics.fillColor = new Color(glow, Math.floor(80 + volume * 60), Math.floor(80 + volume * 60)); - this.graphics.drawCircle(source.x, source.y, SOURCE_RADIUS); - - context.render(this.graphics); - context.render(this.label); + const panText = pan < -0.05 ? `left ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `right ${pan.toFixed(2)}` : 'centre'; + this.readout.text = `${this.model} falloff · distance ${dist.toFixed(0)} px · gain ${(volume * 100).toFixed(0)}% · pan ${panText}`; + + const g = this.graphics.clear(); + + // Reference and maximum distance rings around the listener. + g.lineWidth = 2; + g.lineColor = new Color(70, 90, 110); + g.fillColor = new Color(0, 0, 0, 0); + g.drawCircle(listener.x, listener.y, MAX_DISTANCE); + g.lineColor = new Color(110, 140, 160); + g.drawCircle(listener.x, listener.y, REF_DISTANCE); + + // Line of sight, brighter the louder the source arrives. + g.lineColor = new Color(255, 180, 120, 0.15 + volume * 0.6); + g.drawLine(listener.x, listener.y, source.x, source.y); + g.lineWidth = 0; + + // Listener: a head facing the top of the screen, with left/right ears that + // light up with the pan. + g.fillColor = new Color(120, 255, 160); + g.drawCircle(listener.x, listener.y, 14); + g.fillColor = new Color(80, 180, 255, 0.35 + Math.max(0, -pan) * 0.65); + g.drawCircle(listener.x - 18, listener.y, 6); + g.fillColor = new Color(80, 180, 255, 0.35 + Math.max(0, pan) * 0.65); + g.drawCircle(listener.x + 18, listener.y, 6); + g.fillColor = new Color(120, 255, 160); + g.drawPolygon([listener.x - 6, listener.y - 12, listener.x, listener.y - 22, listener.x + 6, listener.y - 12]); + + // Source: halo and brightness track the attenuation, so volume reads visually too. + g.fillColor = new Color(255, 120, 90, 0.12 + volume * 0.2); + g.drawCircle(source.x, source.y, SOURCE_RADIUS + 10 + volume * 14); + g.fillColor = new Color(Math.floor(110 + volume * 145), Math.floor(80 + volume * 60), Math.floor(70 + volume * 40)); + g.drawCircle(source.x, source.y, SOURCE_RADIUS); + + context.render(g); + context.render(this.readout); if (app.audio.locked) { context.render(this.tapPrompt); @@ -201,7 +255,15 @@ class ListenerAndSourceScene extends Scene { } override destroy(): void { + const input = this.app.input; + + input.onPointerDown.remove(this.onPointerDown); + input.onPointerMove.remove(this.onPointerMove); + input.onPointerUp.remove(this.onPointerUp); + this.app.audio.onUnlock.remove(this.start); this.voice?.stop(); + this.panel.dispose(); + this.hud.dispose(); super.destroy(); } } @@ -214,7 +276,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(8, 10, 16), loader: { basePath: 'assets/', }, From 4be3bd9025d8687768054f3eca12cf3b8b18ee58 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:24 +0200 Subject: [PATCH 04/16] feat(examples): choose convolution rooms from a panel --- examples/audio-fx/convolution-rooms.js | 20 ++++++++++++++++++-- examples/audio-fx/convolution-rooms.ts | 22 ++++++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/examples/audio-fx/convolution-rooms.js b/examples/audio-fx/convolution-rooms.js index 46fe2140f..1a9bb0fad 100644 --- a/examples/audio-fx/convolution-rooms.js +++ b/examples/audio-fx/convolution-rooms.js @@ -1,7 +1,7 @@ // Auto-generated from convolution-rooms.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // Every impulse response in the AK-SROOMS set, shortest tail first. The // duration is what actually decides the character: a few milliseconds only // colours the sound, ~30-150 ms reads as a cupboard or a shaft, and past @@ -78,6 +78,8 @@ class ConvolutionRoomsScene extends Scene { detail; tapPrompt; hud; + panel; + roomControl; init() { const { width, height } = this.app; this.listener = { x: width / 2, y: height / 2 }; @@ -128,16 +130,23 @@ class ConvolutionRoomsScene extends Scene { title: 'Convolution rooms', controls: [ { keys: 'Click', action: 'strike a pad, panned to its position' }, - { keys: 'Right-click', action: 'next room' }, + { keys: 'Right-click', action: 'next room (or use the panel)' }, ], status: 'Click or press any key to start…', hint: 'Same four materials every time — only the room around them changes.', }); + this.panel = mountControlPanel({ title: 'Room' }); + this.roomControl = this.panel.addCycle({ + label: 'Impulse', + options: ROOMS.map((room, i) => `${i + 1} · ${room.ms} ms`), + onChange: index => this.select(index), + }); this.select(0); } /** Swap the impulse response. The handle heals in place, so the effect picks it up once decoded. */ select(next) { this.index = (next + ROOMS.length) % ROOMS.length; + this.roomControl.set(this.index); const room = ROOMS[this.index]; const ir = this.loader.get(`audio/ir/${room.file}.wav`); this.label.text = `${this.index + 1} / ${ROOMS.length} — ${character(room.ms)}`; @@ -180,6 +189,13 @@ class ConvolutionRoomsScene extends Scene { } context.render(this.root); } + destroy() { + this.app.audio.sound.removeEffect(this.convolution); + this.convolution.destroy(); + this.panel.dispose(); + this.hud.dispose(); + super.destroy(); + } } const application = new Application({ canvas: { diff --git a/examples/audio-fx/convolution-rooms.ts b/examples/audio-fx/convolution-rooms.ts index a1db23eba..6b549bd73 100644 --- a/examples/audio-fx/convolution-rooms.ts +++ b/examples/audio-fx/convolution-rooms.ts @@ -10,7 +10,7 @@ import { Text, } from '@codexo/exojs'; import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { type ControlBinding, mountControlPanel, mountControls } from '@examples/runtime'; // Every impulse response in the AK-SROOMS set, shortest tail first. The // duration is what actually decides the character: a few milliseconds only @@ -108,6 +108,8 @@ class ConvolutionRoomsScene extends Scene { private detail!: Text; private tapPrompt!: Text; private hud!: ReturnType; + private panel!: ReturnType; + private roomControl!: ControlBinding; override init(): void { const { width, height } = this.app; @@ -170,18 +172,26 @@ class ConvolutionRoomsScene extends Scene { title: 'Convolution rooms', controls: [ { keys: 'Click', action: 'strike a pad, panned to its position' }, - { keys: 'Right-click', action: 'next room' }, + { keys: 'Right-click', action: 'next room (or use the panel)' }, ], status: 'Click or press any key to start…', hint: 'Same four materials every time — only the room around them changes.', }); + this.panel = mountControlPanel({ title: 'Room' }); + this.roomControl = this.panel.addCycle({ + label: 'Impulse', + options: ROOMS.map((room, i) => `${i + 1} · ${room.ms} ms`), + onChange: index => this.select(index), + }); + this.select(0); } /** Swap the impulse response. The handle heals in place, so the effect picks it up once decoded. */ private select(next: number): void { this.index = (next + ROOMS.length) % ROOMS.length; + this.roomControl.set(this.index); const room = ROOMS[this.index]!; const ir = this.loader.get(`audio/ir/${room.file}.wav`); @@ -235,6 +245,14 @@ class ConvolutionRoomsScene extends Scene { context.render(this.root); } + + override destroy(): void { + this.app.audio.sound.removeEffect(this.convolution); + this.convolution.destroy(); + this.panel.dispose(); + this.hud.dispose(); + super.destroy(); + } } const application = new Application({ From 1ed85883fefff8e59a6b9d5f6559c89d1f0b34c3 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:31 +0200 Subject: [PATCH 05/16] fix(examples): start the spaceship engine after the audio unlock --- examples/showcase/gamepad-spaceship.js | 194 +++++++++++++++++------ examples/showcase/gamepad-spaceship.ts | 211 +++++++++++++++++++------ 2 files changed, 317 insertions(+), 88 deletions(-) diff --git a/examples/showcase/gamepad-spaceship.js b/examples/showcase/gamepad-spaceship.js index b48ad8adf..60fd56b24 100644 --- a/examples/showcase/gamepad-spaceship.js +++ b/examples/showcase/gamepad-spaceship.js @@ -2,7 +2,9 @@ import { ActionMap, Application, + AudioBus, AudioGenerator, + BlendModes, ButtonAction, Color, FixedResolutionCanvasSizing, @@ -10,14 +12,30 @@ import { GamepadButton, Graphics, Keyboard, + LowpassFilter, + Random, Scene, Sprite, Text, Vector, VectorAction, } from '@codexo/exojs'; -import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; +import { + AlphaFadeOverLifetime, + BurstSpawn, + ColorGradient, + ColorOverLifetime, + ConeDirection, + Constant, + Curve, + Drag, + particlesExtension, + ParticleSystem, + Range, + ScaleOverLifetime, +} from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; +const random = new Random(7); class GamepadSpaceshipScene extends Scene { ship; velocity = new Vector(0, 0); @@ -34,9 +52,13 @@ class GamepadSpaceshipScene extends Scene { fire: new ButtonAction([Keyboard.Space, GamepadButton.RightTrigger]), }); facing = -Math.PI / 2; - engine; + thrust = 0; + engineBus; + engineFilter; + engine = null; bullets = []; asteroids = []; + stars; fx; particles; burst; @@ -47,31 +69,69 @@ class GamepadSpaceshipScene extends Scene { init() { const app = this.app; const { width, height } = app; + this.stars = new Graphics(); + for (let i = 0; i < 160; i++) { + const brightness = random.next(0.25, 0.9); + this.stars.fillColor = new Color(200, 215, 255, brightness); + this.stars.drawCircle(random.next(0, width), random.next(0, height), random.next(0.6, 1.8)); + } this.ship = new Sprite(this.loader.get(assets.demo.textures.shipA)) .setAnchor(0.5) - .setScale(0.5) + .setScale(0.75) .setPosition(width / 2, height / 2); - this.engine = app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 90 }), { volume: 0 }); this.inputs.attach(this.actions); + // A raw sawtooth is harsh; a low-pass on its own bus turns it into a + // rumble, and opening the filter with thrust reads as the engine spooling up. + this.engineBus = new AudioBus('engine', { parent: app.audio.sound }); + app.audio.registerBus(this.engineBus); + this.engineFilter = new LowpassFilter({ frequency: 300, resonance: 1.5 }); + this.engineBus.addEffect(this.engineFilter); + // An oscillator started while audio is still locked is dropped, so the + // engine starts from the unlock gesture; onUnlock replays if audio is + // already unlocked. + app.audio.onUnlock.add(this.startEngine); this.fx = new Graphics(); this.particles = new ParticleSystem(this.loader.get(assets.demo.textures.particleSpark), { capacity: 4000 }); + this.particles.setBlendMode(BlendModes.Additive); this.systems.add(this.particles); this.burst = new BurstSpawn({ - schedule: [{ time: 0, count: 60 }], - lifetime: new Constant(0.5), + schedule: [{ time: 0, count: 70 }], + lifetime: new Range(0.35, 0.8), position: new Constant(new Vector(0, 0)), - velocity: ConeDirection.omni(120, 320), - scale: new Constant(new Vector(0.16, 0.16)), + velocity: ConeDirection.omni(80, 420), + scale: new Constant(new Vector(0.08, 0.08)), }); this.particles.addSpawnModule(this.burst); + this.particles.addUpdateModule(new Drag(2.5)); + this.particles.addUpdateModule( + new ColorOverLifetime( + new ColorGradient([ + { t: 0, color: new Color(255, 250, 220, 1) }, + { t: 0.35, color: new Color(255, 170, 70, 1) }, + { t: 1, color: new Color(200, 60, 30, 1) }, + ]), + ), + ); + this.particles.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.08 }, + { t: 1, v: 0.02 }, + ]), + ), + ); this.particles.addUpdateModule(new AlphaFadeOverLifetime()); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { this.asteroids.push(this.spawnAsteroid(width, height)); } this.pad = app.input.getGamepad(0); - this.connectPrompt = new Text('WASD / Arrows to fly · Space to fire', { fillColor: Color.white, fontSize: 24, align: 'center' }) + this.connectPrompt = new Text('WASD / Arrows to fly · Space to fire', { + fillColor: new Color(200, 210, 235), + fontSize: 22, + align: 'center', + }) .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + .setPosition(width / 2, height - 40); this.hud = mountControls({ title: 'Fly a Spaceship', controls: [ @@ -81,22 +141,29 @@ class GamepadSpaceshipScene extends Scene { status: 'Score: 0', }); } + startEngine = () => { + this.engine ??= this.app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 55 }), { bus: this.engineBus, volume: 0 }); + }; spawnAsteroid(width, height) { - const fromLeft = Math.random() < 0.5; + const fromLeft = random.next() < 0.5; + const radius = random.next(20, 38); return { - x: fromLeft ? -20 : width + 20, - y: Math.random() * height, - vx: (fromLeft ? 1 : -1) * (40 + Math.random() * 60), - vy: (Math.random() - 0.5) * 80, - radius: 18 + Math.random() * 16, + x: fromLeft ? -40 : width + 40, + y: random.next(0, height), + vx: (fromLeft ? 1 : -1) * random.next(40, 100), + vy: random.next(-40, 40), + radius, + angle: random.next(0, Math.PI * 2), + spin: random.next(-0.8, 0.8), + shape: Array.from({ length: 9 }, () => random.next(0.72, 1.05)), }; } fire() { this.bullets.push({ - x: this.ship.position.x + Math.cos(this.facing) * 26, - y: this.ship.position.y + Math.sin(this.facing) * 26, - vx: Math.cos(this.facing) * 640, - vy: Math.sin(this.facing) * 640, + x: this.ship.position.x + Math.cos(this.facing) * 30, + y: this.ship.position.y + Math.sin(this.facing) * 30, + vx: Math.cos(this.facing) * 680 + this.velocity.x, + vy: Math.sin(this.facing) * 680 + this.velocity.y, life: 1.1, }); if (this.pad?.canVibrate) { @@ -115,29 +182,32 @@ class GamepadSpaceshipScene extends Scene { update(delta) { const app = this.app; const { width, height } = app; - const thrust = this.actions.move.value; - const mag = Math.min(1, Math.hypot(thrust.x, thrust.y)); + const input = this.actions.move.value; + const mag = Math.min(1, Math.hypot(input.x, input.y)); if (this.actions.fire.pressed) { this.fire(); } if (mag > 0.05) { - this.facing = Math.atan2(thrust.y, thrust.x); + this.facing = Math.atan2(input.y, input.x); this.ship.setRotation((this.facing * 180) / Math.PI + 90); this.velocity.x += Math.cos(this.facing) * mag * 420 * delta; this.velocity.y += Math.sin(this.facing) * mag * 420 * delta; - this.engine.volume = 0.08 + mag * 0.3; - } else { - this.engine.volume = 0; + } + this.thrust += ((mag > 0.05 ? mag : 0) - this.thrust) * Math.min(1, delta * 10); + if (this.engine) { + this.engine.volume = this.thrust * 0.22; + this.engineFilter.frequency = 300 + this.thrust * 900; } this.ship.move(this.velocity.x * delta, this.velocity.y * delta); const damping = Math.pow(0.985, delta * 60); this.velocity.x *= damping; this.velocity.y *= damping; - this.wrap(this.ship.position, width, height); + this.wrap(this.ship.position, width, height, 32); for (const asteroid of this.asteroids) { asteroid.x += asteroid.vx * delta; asteroid.y += asteroid.vy * delta; - this.wrap(asteroid, width, height); + asteroid.angle += asteroid.spin * delta; + this.wrap(asteroid, width, height, asteroid.radius + 8); } for (let i = this.bullets.length - 1; i >= 0; i--) { const bullet = this.bullets[i]; @@ -159,28 +229,59 @@ class GamepadSpaceshipScene extends Scene { } } } - wrap(point, width, height) { - if (point.x < -24) { - point.x = width + 24; - } else if (point.x > width + 24) { - point.x = -24; + wrap(point, width, height, margin) { + if (point.x < -margin) { + point.x = width + margin; + } else if (point.x > width + margin) { + point.x = -margin; } - if (point.y < -24) { - point.y = height + 24; - } else if (point.y > height + 24) { - point.y = -24; + if (point.y < -margin) { + point.y = height + margin; + } else if (point.y > height + margin) { + point.y = -margin; } } draw(context) { - this.fx.clear(); - this.fx.fillColor = new Color(120, 130, 150); + const fx = this.fx.clear(); + fx.lineWidth = 2; + fx.lineColor = new Color(150, 160, 180); + fx.fillColor = new Color(70, 74, 88); for (const asteroid of this.asteroids) { - this.fx.drawCircle(asteroid.x, asteroid.y, asteroid.radius); + const points = []; + asteroid.shape.forEach((factor, index) => { + const angle = asteroid.angle + (index / asteroid.shape.length) * Math.PI * 2; + points.push(asteroid.x + Math.cos(angle) * asteroid.radius * factor, asteroid.y + Math.sin(angle) * asteroid.radius * factor); + }); + fx.drawPolygon(points); + } + fx.lineWidth = 0; + // Engine flame behind the ship, sized by the smoothed thrust. + if (this.thrust > 0.05) { + const back = this.facing + Math.PI; + const flicker = 0.85 + Math.random() * 0.3; + const length = (14 + this.thrust * 26) * flicker; + const baseX = this.ship.position.x + Math.cos(back) * 20; + const baseY = this.ship.position.y + Math.sin(back) * 20; + const side = this.facing + Math.PI / 2; + fx.fillColor = new Color(255, 150, 60, 0.85); + fx.drawPolygon([ + baseX + Math.cos(side) * 7, + baseY + Math.sin(side) * 7, + baseX + Math.cos(back) * length, + baseY + Math.sin(back) * length, + baseX - Math.cos(side) * 7, + baseY - Math.sin(side) * 7, + ]); + fx.fillColor = new Color(255, 240, 200, 0.9); + fx.drawCircle(baseX, baseY, 4); } - this.fx.fillColor = new Color(120, 230, 255); for (const bullet of this.bullets) { - this.fx.drawCircle(bullet.x, bullet.y, 4); + fx.fillColor = new Color(120, 230, 255, 0.35); + fx.drawCircle(bullet.x, bullet.y, 7); + fx.fillColor = new Color(220, 250, 255); + fx.drawCircle(bullet.x, bullet.y, 3); } + context.render(this.stars); context.render(this.fx); context.render(this.particles); context.render(this.ship); @@ -189,8 +290,13 @@ class GamepadSpaceshipScene extends Scene { } } destroy() { + this.app.audio.onUnlock.remove(this.startEngine); this.engine?.stop(); + this.engineBus.removeEffect(this.engineFilter); + this.engineFilter.destroy(); + this.app.audio.unregisterBus(this.engineBus); this.hud?.dispose(); + this.stars?.destroy(); this.fx?.destroy(); this.ship?.destroy(); this.connectPrompt?.destroy(); @@ -205,7 +311,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: new Color(8, 10, 18), + clearColor: new Color(6, 8, 16), extensions: [particlesExtension], }); await app.start(GamepadSpaceshipScene); diff --git a/examples/showcase/gamepad-spaceship.ts b/examples/showcase/gamepad-spaceship.ts index 0ffe6db06..56db2ad7e 100644 --- a/examples/showcase/gamepad-spaceship.ts +++ b/examples/showcase/gamepad-spaceship.ts @@ -1,7 +1,9 @@ import { ActionMap, Application, + AudioBus, AudioGenerator, + BlendModes, ButtonAction, Color, FixedResolutionCanvasSizing, @@ -10,6 +12,8 @@ import { GamepadButton, Graphics, Keyboard, + LowpassFilter, + Random, type RenderingContext, Scene, type Seconds, @@ -19,7 +23,20 @@ import { VectorAction, type Voice, } from '@codexo/exojs'; -import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; +import { + AlphaFadeOverLifetime, + BurstSpawn, + ColorGradient, + ColorOverLifetime, + ConeDirection, + Constant, + Curve, + Drag, + particlesExtension, + ParticleSystem, + Range, + ScaleOverLifetime, +} from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; interface Bullet { @@ -36,8 +53,14 @@ interface Asteroid { vx: number; vy: number; radius: number; + angle: number; + spin: number; + // Per-vertex radius factors give every rock its own silhouette. + shape: number[]; } +const random = new Random(7); + class GamepadSpaceshipScene extends Scene { private ship!: Sprite; private velocity = new Vector(0, 0); @@ -54,9 +77,13 @@ class GamepadSpaceshipScene extends Scene { fire: new ButtonAction([Keyboard.Space, GamepadButton.RightTrigger]), }); private facing = -Math.PI / 2; - private engine!: Voice; + private thrust = 0; + private engineBus!: AudioBus; + private engineFilter!: LowpassFilter; + private engine: Voice | null = null; private bullets: Bullet[] = []; private asteroids: Asteroid[] = []; + private stars!: Graphics; private fx!: Graphics; private particles!: ParticleSystem; private burst!: BurstSpawn; @@ -69,35 +96,78 @@ class GamepadSpaceshipScene extends Scene { const app = this.app; const { width, height } = app; + this.stars = new Graphics(); + + for (let i = 0; i < 160; i++) { + const brightness = random.next(0.25, 0.9); + + this.stars.fillColor = new Color(200, 215, 255, brightness); + this.stars.drawCircle(random.next(0, width), random.next(0, height), random.next(0.6, 1.8)); + } + this.ship = new Sprite(this.loader.get(assets.demo.textures.shipA)) .setAnchor(0.5) - .setScale(0.5) + .setScale(0.75) .setPosition(width / 2, height / 2); - this.engine = app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 90 }), { volume: 0 }); this.inputs.attach(this.actions); + // A raw sawtooth is harsh; a low-pass on its own bus turns it into a + // rumble, and opening the filter with thrust reads as the engine spooling up. + this.engineBus = new AudioBus('engine', { parent: app.audio.sound }); + app.audio.registerBus(this.engineBus); + this.engineFilter = new LowpassFilter({ frequency: 300, resonance: 1.5 }); + this.engineBus.addEffect(this.engineFilter); + + // An oscillator started while audio is still locked is dropped, so the + // engine starts from the unlock gesture; onUnlock replays if audio is + // already unlocked. + app.audio.onUnlock.add(this.startEngine); + this.fx = new Graphics(); this.particles = new ParticleSystem(this.loader.get(assets.demo.textures.particleSpark), { capacity: 4000 }); + this.particles.setBlendMode(BlendModes.Additive); this.systems.add(this.particles); this.burst = new BurstSpawn({ - schedule: [{ time: 0, count: 60 }], - lifetime: new Constant(0.5), + schedule: [{ time: 0, count: 70 }], + lifetime: new Range(0.35, 0.8), position: new Constant(new Vector(0, 0)), - velocity: ConeDirection.omni(120, 320), - scale: new Constant(new Vector(0.16, 0.16)), + velocity: ConeDirection.omni(80, 420), + scale: new Constant(new Vector(0.08, 0.08)), }); this.particles.addSpawnModule(this.burst); + this.particles.addUpdateModule(new Drag(2.5)); + this.particles.addUpdateModule( + new ColorOverLifetime( + new ColorGradient([ + { t: 0, color: new Color(255, 250, 220, 1) }, + { t: 0.35, color: new Color(255, 170, 70, 1) }, + { t: 1, color: new Color(200, 60, 30, 1) }, + ]), + ), + ); + this.particles.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.08 }, + { t: 1, v: 0.02 }, + ]), + ), + ); this.particles.addUpdateModule(new AlphaFadeOverLifetime()); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { this.asteroids.push(this.spawnAsteroid(width, height)); } this.pad = app.input.getGamepad(0); - this.connectPrompt = new Text('WASD / Arrows to fly · Space to fire', { fillColor: Color.white, fontSize: 24, align: 'center' }) + this.connectPrompt = new Text('WASD / Arrows to fly · Space to fire', { + fillColor: new Color(200, 210, 235), + fontSize: 22, + align: 'center', + }) .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + .setPosition(width / 2, height - 40); this.hud = mountControls({ title: 'Fly a Spaceship', @@ -109,24 +179,32 @@ class GamepadSpaceshipScene extends Scene { }); } + private readonly startEngine = (): void => { + this.engine ??= this.app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 55 }), { bus: this.engineBus, volume: 0 }); + }; + private spawnAsteroid(width: number, height: number): Asteroid { - const fromLeft = Math.random() < 0.5; + const fromLeft = random.next() < 0.5; + const radius = random.next(20, 38); return { - x: fromLeft ? -20 : width + 20, - y: Math.random() * height, - vx: (fromLeft ? 1 : -1) * (40 + Math.random() * 60), - vy: (Math.random() - 0.5) * 80, - radius: 18 + Math.random() * 16, + x: fromLeft ? -40 : width + 40, + y: random.next(0, height), + vx: (fromLeft ? 1 : -1) * random.next(40, 100), + vy: random.next(-40, 40), + radius, + angle: random.next(0, Math.PI * 2), + spin: random.next(-0.8, 0.8), + shape: Array.from({ length: 9 }, () => random.next(0.72, 1.05)), }; } private fire(): void { this.bullets.push({ - x: this.ship.position.x + Math.cos(this.facing) * 26, - y: this.ship.position.y + Math.sin(this.facing) * 26, - vx: Math.cos(this.facing) * 640, - vy: Math.sin(this.facing) * 640, + x: this.ship.position.x + Math.cos(this.facing) * 30, + y: this.ship.position.y + Math.sin(this.facing) * 30, + vx: Math.cos(this.facing) * 680 + this.velocity.x, + vy: Math.sin(this.facing) * 680 + this.velocity.y, life: 1.1, }); @@ -150,33 +228,38 @@ class GamepadSpaceshipScene extends Scene { const app = this.app; const { width, height } = app; - const thrust = this.actions.move.value; - const mag = Math.min(1, Math.hypot(thrust.x, thrust.y)); + const input = this.actions.move.value; + const mag = Math.min(1, Math.hypot(input.x, input.y)); if (this.actions.fire.pressed) { this.fire(); } if (mag > 0.05) { - this.facing = Math.atan2(thrust.y, thrust.x); + this.facing = Math.atan2(input.y, input.x); this.ship.setRotation((this.facing * 180) / Math.PI + 90); this.velocity.x += Math.cos(this.facing) * mag * 420 * delta; this.velocity.y += Math.sin(this.facing) * mag * 420 * delta; - this.engine.volume = 0.08 + mag * 0.3; - } else { - this.engine.volume = 0; + } + + this.thrust += ((mag > 0.05 ? mag : 0) - this.thrust) * Math.min(1, delta * 10); + + if (this.engine) { + this.engine.volume = this.thrust * 0.22; + this.engineFilter.frequency = 300 + this.thrust * 900; } this.ship.move(this.velocity.x * delta, this.velocity.y * delta); const damping = Math.pow(0.985, delta * 60); this.velocity.x *= damping; this.velocity.y *= damping; - this.wrap(this.ship.position, width, height); + this.wrap(this.ship.position, width, height, 32); for (const asteroid of this.asteroids) { asteroid.x += asteroid.vx * delta; asteroid.y += asteroid.vy * delta; - this.wrap(asteroid, width, height); + asteroid.angle += asteroid.spin * delta; + this.wrap(asteroid, width, height, asteroid.radius + 8); } for (let i = this.bullets.length - 1; i >= 0; i--) { @@ -204,35 +287,70 @@ class GamepadSpaceshipScene extends Scene { } } - private wrap(point: { x: number; y: number }, width: number, height: number): void { - if (point.x < -24) { - point.x = width + 24; - } else if (point.x > width + 24) { - point.x = -24; + private wrap(point: { x: number; y: number }, width: number, height: number, margin: number): void { + if (point.x < -margin) { + point.x = width + margin; + } else if (point.x > width + margin) { + point.x = -margin; } - if (point.y < -24) { - point.y = height + 24; - } else if (point.y > height + 24) { - point.y = -24; + if (point.y < -margin) { + point.y = height + margin; + } else if (point.y > height + margin) { + point.y = -margin; } } override draw(context: RenderingContext): void { - this.fx.clear(); + const fx = this.fx.clear(); - this.fx.fillColor = new Color(120, 130, 150); + fx.lineWidth = 2; + fx.lineColor = new Color(150, 160, 180); + fx.fillColor = new Color(70, 74, 88); for (const asteroid of this.asteroids) { - this.fx.drawCircle(asteroid.x, asteroid.y, asteroid.radius); + const points: number[] = []; + + asteroid.shape.forEach((factor, index) => { + const angle = asteroid.angle + (index / asteroid.shape.length) * Math.PI * 2; + + points.push(asteroid.x + Math.cos(angle) * asteroid.radius * factor, asteroid.y + Math.sin(angle) * asteroid.radius * factor); + }); + fx.drawPolygon(points); } - this.fx.fillColor = new Color(120, 230, 255); + fx.lineWidth = 0; + + // Engine flame behind the ship, sized by the smoothed thrust. + if (this.thrust > 0.05) { + const back = this.facing + Math.PI; + const flicker = 0.85 + Math.random() * 0.3; + const length = (14 + this.thrust * 26) * flicker; + const baseX = this.ship.position.x + Math.cos(back) * 20; + const baseY = this.ship.position.y + Math.sin(back) * 20; + const side = this.facing + Math.PI / 2; + + fx.fillColor = new Color(255, 150, 60, 0.85); + fx.drawPolygon([ + baseX + Math.cos(side) * 7, + baseY + Math.sin(side) * 7, + baseX + Math.cos(back) * length, + baseY + Math.sin(back) * length, + baseX - Math.cos(side) * 7, + baseY - Math.sin(side) * 7, + ]); + fx.fillColor = new Color(255, 240, 200, 0.9); + fx.drawCircle(baseX, baseY, 4); + } for (const bullet of this.bullets) { - this.fx.drawCircle(bullet.x, bullet.y, 4); + fx.fillColor = new Color(120, 230, 255, 0.35); + fx.drawCircle(bullet.x, bullet.y, 7); + fx.fillColor = new Color(220, 250, 255); + fx.drawCircle(bullet.x, bullet.y, 3); } + context.render(this.stars); context.render(this.fx); context.render(this.particles); context.render(this.ship); @@ -243,8 +361,13 @@ class GamepadSpaceshipScene extends Scene { } override destroy(): void { + this.app.audio.onUnlock.remove(this.startEngine); this.engine?.stop(); + this.engineBus.removeEffect(this.engineFilter); + this.engineFilter.destroy(); + this.app.audio.unregisterBus(this.engineBus); this.hud?.dispose(); + this.stars?.destroy(); this.fx?.destroy(); this.ship?.destroy(); this.connectPrompt?.destroy(); @@ -260,7 +383,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: new Color(8, 10, 18), + clearColor: new Color(6, 8, 16), extensions: [particlesExtension], }); From cd788277d511c51e4a7bd8f6b11f7a596e073a70 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:42 +0200 Subject: [PATCH 06/16] feat(examples): replace the placeholder ship and announce the hello world interaction --- examples/assets/demo/textures/ship-a.png | Bin 505 -> 3601 bytes examples/assets/image/ship-a.png | Bin 505 -> 3601 bytes examples/getting-started/hello-world.js | 14 ++++++++++---- examples/getting-started/hello-world.ts | 16 ++++++++++++---- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/examples/assets/demo/textures/ship-a.png b/examples/assets/demo/textures/ship-a.png index a1ee3a8c4bc0c09fa6e015321c7913b5cb41b86e..bbab4247237ad226d04d0b5047b865e0a80edb36 100644 GIT binary patch literal 3601 zcmV+s4({=ZP)E z3v^V~)&9<%WHKSI$xNOQ$YYWa2mwh#gaGn}Jfwgi!8Ko(2;Dp^q>uVWN8=hz8{vGywGB39E#p+QG#2L5ZFQ9TWg`y7)jL zVM9j71f-{@gX_#Ae>{E-3IM`KtcDOFk36yp%kr0j>%zye8iSAq0e~o9df*?KF=HB% zM~^~$LOdp9WI#ZA1c>s5hm*$v2#)k!0wF?wkpEz3sP`;f2fq@$CQ5f>K+>RZ{#^iaj40O-RLZU@BJ?|t`PxF&(QSy{vqNi3I|UQm79P< z437bz4>wHXg)S#&US}wUg+(YXF6nGfo-!3A6DG^TGs$BBI3(#i8jS{1r%aY3D=)9W znzcW{#*GE2uC9`K5CA5~fbc1g8@xPa!Nk45{<(M$*jrxNx}^XG)xp z!foSIStMSojfsH=l1BiLG~P2fmdP{&xZJ(xk9hRaA0aV7g3)9_!t@+`_`zXt8ar+r z2rQw}cu(i{t)}kwJOTiOBQqpKkGR-q*zFE{`srWcr`02Bh!>g~%?L?M!|~(C;cz%G zeE4t#1O~!EI^CUBqAe+5w7X3Kk(thKenQ~uufKuK#}5syK%kmt00CeXK4|kAg35{t zKmbWeNmK~uY6)eN;s68%jR>X?lE#fop#u2ot5O6+j6hLkJzA`7@(gHdG(n>uiu32s z%Q6WGiCiXWwJ};KoD>H@_^QVWA*7V#(Q+skX#4Z>*W!8_gI;sV5T?#Dmdg@KWbEx97at|EgULeT&`)vNB_70i^&A5*#yZ;jr#hl zvP?{DtX!%bPF=a7P^LHl4!ZfFp&`&}gX9pJnwnsPgch?I)@Bphm@Fn*00m401`BDm zS_A|Hz#)VT9`lqU#Q`A4N5>oDsBm5_Ev>NIY!tYWhybAA83c#T3ai!X6ry}sR21cK zJ~v!bh!h8a7WLTZ=n-Aqcfi(aX%D^~1eyi5))txt*Tf-m%$cRqw zeSLii0Gj%I*NNJgECd1pqS0JC5)26m0qLrO!s3T_Dl1S?6adLf8p=LLM96oOs|X4T zMDrEe{?467(}gc#tSE-5@(fz*tKjeNC(CfA)9bi^q_W92YU!Zz9VjT-Aq{+0rukBH6iR^fBKPB-%H-)%$IyxDz?-)Dcu#EDq9 zVIycw7%AJPG5S=x0fAcvfHpk-E+M=>iH?a5`@^2Mkd}Je4Y<*}Xky|>6g>MJ!XqQY zcnlt^ca8q{-Vy+UBMhrll6WsQb)4Uy-rI@TnCSj{&`(KzHQxBMXJJT8@Z+&~%zmo& zDmG96gf1ffMM+4Tv$N)?e!p`&bh==d0c&e(arJ7w3$H&7{?*vf(BL9Uj{%;1dOgx7 zWDo|@W*(aa=qCf^H+c;JfEZ0kMEovEl75{3&;w%Yt1r=!x5mYd%F0Xl@S{)Vb3m7l zel+Y3J5C%shHtN2agkQj(fR5}*J5r~w&1bZ2Ml1FyeR;9OX96bQmO7MSpNhzZFmxr z%GK1@-<-o|fB#%w2=#i`S>Y1Vbs9l|LAae3z{0{Wap}@U7bzhmEPjAqpcdZ`K|ZiA z?30U7KO3%I_d|)dBHofTzM3gpfBiC+FUxb&DlR#LQ>Rbk&YUc0wOY6S{xbaOP-If( zWR#Yc;==iJZfbL~vaxd28hESKH@!{X5CEOqoL)C__uIckPWGH`PC0P$mOcTGJ@F)+AO+skHra0gchwc00 zJ4jD+-RAgA|Fge;fy?yF-kCkmZ9Q=F{012ORyc9eB-GbkMR8Ffc&x7KhWG^hblnDo zhDY4+HrX!#g4?&bw6t-4`~UQg{DGsZ12(%IAAj-}SS&5bnmZfbbm-Bw|5nshDlepG zWWd_mihrIy<+iZ$0m@Hlhln*8ZfKkA3xLjTE;oCQ>b>9ZfL^C_iHFbe4jul8?zj(T z&X|Voi=m6~02`7dfxjK9)jl|R;uvggHW!P$W~_be35-uoS4p%r>u8H6fnK-CUIEZY zB;-l7&8^7K6Wg|K1}%JK@7iUtSg`N?LkOo2om1$$pAfx{bxm;~H5!BeQxqJm!|`KZ zz-pyWOkM1$)oLtX`7mbAo+H?IeT2b%mF^uH zLOjy>wU-?0Vprj+Kzu?1h7OCuv7=wmqi4aea2G z1O+-RbCUBvOH_wM8BcM(^LzInlHd2oB#-LFys}**qN8CjB;Xj$gt(|sx21t2)wkPIP0Cldfd2&%Rd8L6Sy+dHOAqQRYCaELW% zS43naMvoqYi&d9pAA^JZG0G5y8Iw}*ot#-9W~dwsdQR5`KoWwH!{kio4CtyJHEbB7 zqK0;54QicFkk!7<1J$mMWK2ktn;o&pjc(a>0U+e2^Yh>JOuw7%e^itIXwlsvLtJ$G zIv}QZ@9St^4*;6<=zRV=ggZ({N98}LY%(^xc{4=ggE3>qKma_Osc;iPj{wNeO#AcC zD*WcvL3YQr@EaN$QB-^ehd%fiuWx@71eFvW2_`k?|=wZyxT7aaK+cA3VM9j-whBPomnc~G6jrbN0aib}Lasl!d^J|BeHuSm_X0M&@G4&2z6XAR&KEa|>Dot!E*|v= z03ghuG;`kFm^p6&mgcX*`hw^2o7dko9X$MTNpbO+0}b^JTVQuQZnxXN>#(mc@fVI_U ze^gUl_Vc>xv+vece{4)5mp|zjr@>^q6xNoH|`72fzJ|w<&lh4I4TXk+hFHgWoR) z|Nj01C?f!h+wVRh@B%=#`WUTicXxp5E}z{-{dOX^>v{mV3Em8Y@0@cM{JKZ=pr9Z= z?(VT~_pYAreviTfU@8g#-#LQ)Ii3YC{^DhrjEydI+xQI3nKj3S$1-pIZU;U4MPOaF zF@X5`R8<%N;A&Ob)-a7&nHc2Qo)KZ&J2%1WvxO6MMT=*}R;;;yT0Ilj7fc8);y#vP zS=J%l*ZPcLfC7pGK>j(3m*{pM`To#tyYC2|X&k)oF;06>ko&I*UM#b$Rj!)i05)o5~<>CnHvstQDM zop`WX%_=L&w4$ZPn{*Tn-D%t-0E7&QY(|@{QPteWe-EvJv7uHpUam&nrAj8bPCNpq z2CXF1W@(gloIWa=djtScaHI`&)>>FwE~D9c31&+C05#sY@eru^^XG3jZ`MEh`0>m8_n&Xwdi?h7C!jSC z9stdK`Sj^)ph3@{zkTuI-J3Tb9zT9{@7}YwZ$AP}K7ao9$#&~mK(j?lg8YKxA_Z>QemYcddE__)tCVh`({{I~!9U+-Mg;W~k-|6RS! z#U;j`-m|m998LHN8kl8%%x_pJ$~_|~LQBY?b;H}be+|#Mj~D)`WcH8Ov9E z3v^V~)&9<%WHKSI$xNOQ$YYWa2mwh#gaGn}Jfwgi!8Ko(2;Dp^q>uVWN8=hz8{vGywGB39E#p+QG#2L5ZFQ9TWg`y7)jL zVM9j71f-{@gX_#Ae>{E-3IM`KtcDOFk36yp%kr0j>%zye8iSAq0e~o9df*?KF=HB% zM~^~$LOdp9WI#ZA1c>s5hm*$v2#)k!0wF?wkpEz3sP`;f2fq@$CQ5f>K+>RZ{#^iaj40O-RLZU@BJ?|t`PxF&(QSy{vqNi3I|UQm79P< z437bz4>wHXg)S#&US}wUg+(YXF6nGfo-!3A6DG^TGs$BBI3(#i8jS{1r%aY3D=)9W znzcW{#*GE2uC9`K5CA5~fbc1g8@xPa!Nk45{<(M$*jrxNx}^XG)xp z!foSIStMSojfsH=l1BiLG~P2fmdP{&xZJ(xk9hRaA0aV7g3)9_!t@+`_`zXt8ar+r z2rQw}cu(i{t)}kwJOTiOBQqpKkGR-q*zFE{`srWcr`02Bh!>g~%?L?M!|~(C;cz%G zeE4t#1O~!EI^CUBqAe+5w7X3Kk(thKenQ~uufKuK#}5syK%kmt00CeXK4|kAg35{t zKmbWeNmK~uY6)eN;s68%jR>X?lE#fop#u2ot5O6+j6hLkJzA`7@(gHdG(n>uiu32s z%Q6WGiCiXWwJ};KoD>H@_^QVWA*7V#(Q+skX#4Z>*W!8_gI;sV5T?#Dmdg@KWbEx97at|EgULeT&`)vNB_70i^&A5*#yZ;jr#hl zvP?{DtX!%bPF=a7P^LHl4!ZfFp&`&}gX9pJnwnsPgch?I)@Bphm@Fn*00m401`BDm zS_A|Hz#)VT9`lqU#Q`A4N5>oDsBm5_Ev>NIY!tYWhybAA83c#T3ai!X6ry}sR21cK zJ~v!bh!h8a7WLTZ=n-Aqcfi(aX%D^~1eyi5))txt*Tf-m%$cRqw zeSLii0Gj%I*NNJgECd1pqS0JC5)26m0qLrO!s3T_Dl1S?6adLf8p=LLM96oOs|X4T zMDrEe{?467(}gc#tSE-5@(fz*tKjeNC(CfA)9bi^q_W92YU!Zz9VjT-Aq{+0rukBH6iR^fBKPB-%H-)%$IyxDz?-)Dcu#EDq9 zVIycw7%AJPG5S=x0fAcvfHpk-E+M=>iH?a5`@^2Mkd}Je4Y<*}Xky|>6g>MJ!XqQY zcnlt^ca8q{-Vy+UBMhrll6WsQb)4Uy-rI@TnCSj{&`(KzHQxBMXJJT8@Z+&~%zmo& zDmG96gf1ffMM+4Tv$N)?e!p`&bh==d0c&e(arJ7w3$H&7{?*vf(BL9Uj{%;1dOgx7 zWDo|@W*(aa=qCf^H+c;JfEZ0kMEovEl75{3&;w%Yt1r=!x5mYd%F0Xl@S{)Vb3m7l zel+Y3J5C%shHtN2agkQj(fR5}*J5r~w&1bZ2Ml1FyeR;9OX96bQmO7MSpNhzZFmxr z%GK1@-<-o|fB#%w2=#i`S>Y1Vbs9l|LAae3z{0{Wap}@U7bzhmEPjAqpcdZ`K|ZiA z?30U7KO3%I_d|)dBHofTzM3gpfBiC+FUxb&DlR#LQ>Rbk&YUc0wOY6S{xbaOP-If( zWR#Yc;==iJZfbL~vaxd28hESKH@!{X5CEOqoL)C__uIckPWGH`PC0P$mOcTGJ@F)+AO+skHra0gchwc00 zJ4jD+-RAgA|Fge;fy?yF-kCkmZ9Q=F{012ORyc9eB-GbkMR8Ffc&x7KhWG^hblnDo zhDY4+HrX!#g4?&bw6t-4`~UQg{DGsZ12(%IAAj-}SS&5bnmZfbbm-Bw|5nshDlepG zWWd_mihrIy<+iZ$0m@Hlhln*8ZfKkA3xLjTE;oCQ>b>9ZfL^C_iHFbe4jul8?zj(T z&X|Voi=m6~02`7dfxjK9)jl|R;uvggHW!P$W~_be35-uoS4p%r>u8H6fnK-CUIEZY zB;-l7&8^7K6Wg|K1}%JK@7iUtSg`N?LkOo2om1$$pAfx{bxm;~H5!BeQxqJm!|`KZ zz-pyWOkM1$)oLtX`7mbAo+H?IeT2b%mF^uH zLOjy>wU-?0Vprj+Kzu?1h7OCuv7=wmqi4aea2G z1O+-RbCUBvOH_wM8BcM(^LzInlHd2oB#-LFys}**qN8CjB;Xj$gt(|sx21t2)wkPIP0Cldfd2&%Rd8L6Sy+dHOAqQRYCaELW% zS43naMvoqYi&d9pAA^JZG0G5y8Iw}*ot#-9W~dwsdQR5`KoWwH!{kio4CtyJHEbB7 zqK0;54QicFkk!7<1J$mMWK2ktn;o&pjc(a>0U+e2^Yh>JOuw7%e^itIXwlsvLtJ$G zIv}QZ@9St^4*;6<=zRV=ggZ({N98}LY%(^xc{4=ggE3>qKma_Osc;iPj{wNeO#AcC zD*WcvL3YQr@EaN$QB-^ehd%fiuWx@71eFvW2_`k?|=wZyxT7aaK+cA3VM9j-whBPomnc~G6jrbN0aib}Lasl!d^J|BeHuSm_X0M&@G4&2z6XAR&KEa|>Dot!E*|v= z03ghuG;`kFm^p6&mgcX*`hw^2o7dko9X$MTNpbO+0}b^JTVQuQZnxXN>#(mc@fVI_U ze^gUl_Vc>xv+vece{4)5mp|zjr@>^q6xNoH|`72fzJ|w<&lh4I4TXk+hFHgWoR) z|Nj01C?f!h+wVRh@B%=#`WUTicXxp5E}z{-{dOX^>v{mV3Em8Y@0@cM{JKZ=pr9Z= z?(VT~_pYAreviTfU@8g#-#LQ)Ii3YC{^DhrjEydI+xQI3nKj3S$1-pIZU;U4MPOaF zF@X5`R8<%N;A&Ob)-a7&nHc2Qo)KZ&J2%1WvxO6MMT=*}R;;;yT0Ilj7fc8);y#vP zS=J%l*ZPcLfC7pGK>j(3m*{pM`To#tyYC2|X&k)oF;06>ko&I*UM#b$Rj!)i05)o5~<>CnHvstQDM zop`WX%_=L&w4$ZPn{*Tn-D%t-0E7&QY(|@{QPteWe-EvJv7uHpUam&nrAj8bPCNpq z2CXF1W@(gloIWa=djtScaHI`&)>>FwE~D9c31&+C05#sY@eru^^XG3jZ`MEh`0>m8_n&Xwdi?h7C!jSC z9stdK`Sj^)ph3@{zkTuI-J3Tb9zT9{@7}YwZ$AP}K7ao9$#&~mK(j?lg8YKxA_Z>QemYcddE__)tCVh`({{I~!9U+-Mg;W~k-|6RS! z#U;j`-m|m998LHN8kl8%%x_pJ$~_|~LQBY?b;H}be+|#Mj~D)`WcH8Ov9 { this.playing = !this.playing; this.sprite.setTint(this.playing ? Color.white : new Color(150, 170, 190)); + this.hint.text = this.playing ? 'Click the ship to pause it' : 'Paused - click the ship to resume'; }); - this.root.addChild(this.sprite); + this.hint = new Text('Click the ship to pause it', { fillColor: new Color(170, 188, 225), fontSize: 22 }); + this.hint.setAnchor(0.5, 0.5); + this.hint.setPosition(width / 2, height - 90); + this.root.addChild(this.sprite, this.hint); } update(delta) { if (this.playing) { - this.sprite.rotate(delta * 120); + this.sprite.rotate(delta * 90); } } } @@ -32,7 +38,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(12, 16, 30), loader: { basePath: 'assets/', }, diff --git a/examples/getting-started/hello-world.ts b/examples/getting-started/hello-world.ts index 74f49cb1c..6a0e31b2f 100644 --- a/examples/getting-started/hello-world.ts +++ b/examples/getting-started/hello-world.ts @@ -1,8 +1,9 @@ -import { Application, Color, FixedResolutionCanvasSizing, Scene, type Seconds, Sprite } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, type Seconds, Sprite, Text } from '@codexo/exojs'; // #region guide:first-scene class HelloWorldScene extends Scene { private sprite!: Sprite; + private hint!: Text; private playing = true; override init(): void { @@ -11,18 +12,25 @@ class HelloWorldScene extends Scene { this.sprite = new Sprite(this.loader.get('image/ship-a.png')); this.sprite.setAnchor(0.5); + this.sprite.setScale(1.5); this.sprite.setPosition(width / 2, height / 2); this.sprite.interactive = true; this.sprite.onPointerTap.add(() => { this.playing = !this.playing; this.sprite.setTint(this.playing ? Color.white : new Color(150, 170, 190)); + this.hint.text = this.playing ? 'Click the ship to pause it' : 'Paused - click the ship to resume'; }); - this.root.addChild(this.sprite); + + this.hint = new Text('Click the ship to pause it', { fillColor: new Color(170, 188, 225), fontSize: 22 }); + this.hint.setAnchor(0.5, 0.5); + this.hint.setPosition(width / 2, height - 90); + + this.root.addChild(this.sprite, this.hint); } override update(delta: Seconds): void { if (this.playing) { - this.sprite.rotate(delta * 120); + this.sprite.rotate(delta * 90); } } } @@ -36,7 +44,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(12, 16, 30), loader: { basePath: 'assets/', }, From 5693a630491d5a37b3680ddf8e18ad931b86bd74 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:59 +0200 Subject: [PATCH 07/16] feat(examples): add an audio signal graph showcase --- examples/audio-fx/signal-graph.js | 357 +++++++++++++ examples/audio-fx/signal-graph.ts | 471 ++++++++++++++++++ examples/examples.json | 11 + .../src/content/guide/audio/audio-effects.mdx | 5 + 4 files changed, 844 insertions(+) create mode 100644 examples/audio-fx/signal-graph.js create mode 100644 examples/audio-fx/signal-graph.ts diff --git a/examples/audio-fx/signal-graph.js b/examples/audio-fx/signal-graph.js new file mode 100644 index 000000000..061a26da4 --- /dev/null +++ b/examples/audio-fx/signal-graph.js @@ -0,0 +1,357 @@ +// Auto-generated from signal-graph.ts - edit the .ts source, not this file. +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser, CompressorEffect, EqualizerEffect, LimiterEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +const NODE_W = 180; +const NODE_H = 86; +const LANE_COLORS = { + music: new Color(110, 190, 255), + sound: new Color(255, 170, 90), + master: new Color(150, 240, 170), +}; +/** Peak and RMS of one analyser window, in dBFS. */ +const measure = samples => { + let peak = 0; + let sum = 0; + for (const value of samples) { + peak = Math.max(peak, Math.abs(value)); + sum += value * value; + } + const toDb = linear => (linear > 0 ? 20 * Math.log10(linear) : -Infinity); + return { peak: toDb(peak), rms: toDb(Math.sqrt(sum / samples.length)) }; +}; +class SignalGraphScene extends Scene { + music; + impact; + musicVoice; + musicPlaying = true; + musicVolume = 0.7; + impactVolume = 0.3; + autoImpact = true; + impactTimer = 0; + eq; + compressor; + reverb; + limiter; + nodes = []; + edges = []; + selected = 'compressor'; + analysers = new Map(); + levels = { music: -Infinity, sound: -Infinity, master: -Infinity }; + masterPeak = -Infinity; + samples = new Float32Array(1024); + waveform = new Float32Array(1024); + flow = 0; + graph = new Graphics(); + panel = null; + hud; + async load() { + this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + this.impact = this.loader.get('audio/impact-light.ogg'); + } + init() { + const audio = this.app.audio; + // The real processing graph: two lanes with their own effects, both + // feeding the master bus, which ends in a soft limiter before the output. + this.eq = new EqualizerEffect({ low: 2, mid: 0, high: -1 }); + this.compressor = new CompressorEffect({ threshold: -24, ratio: 3, attack: 0.01, release: 0.2 }); + this.reverb = new ReverbEffect({ decay: 2.2, wet: 0.35 }); + // A safety stage, not a brickwall: fast transients can still pass it. + this.limiter = new LimiterEffect({ threshold: -6 }); + const node = (id, title, kind, lane, x, y, extra = {}) => { + const status = new Text('', { fillColor: new Color(170, 185, 210), fontSize: 14 }).setPosition(x + 12, y + 46); + const label = new Text(title, { fillColor: Color.white, fontSize: 17 }).setPosition(x + 12, y + 14); + this.root.addChild(label, status); + return { id, title, kind, lane, x, y, enabled: true, status, ...extra }; + }; + this.nodes = [ + node('music', 'Music loop', 'source', 'music', 40, 150), + node('impact', 'Impact SFX', 'source', 'sound', 40, 430), + node('eq', 'Equalizer', 'effect', 'music', 270, 150, { bus: audio.music, effect: this.eq }), + node('compressor', 'Compressor', 'effect', 'music', 500, 150, { bus: audio.music, effect: this.compressor }), + node('reverb', 'Reverb', 'effect', 'sound', 385, 430, { bus: audio.sound, effect: this.reverb }), + node('master', 'Master bus', 'bus', 'master', 760, 290, { bus: audio.master }), + node('limiter', 'Limiter', 'effect', 'master', 760, 440, { bus: audio.master, effect: this.limiter }), + node('output', 'Output', 'output', 'master', 1010, 290), + ]; + this.edges = [ + { from: 'music', to: 'eq', lane: 'music' }, + { from: 'eq', to: 'compressor', lane: 'music' }, + { from: 'compressor', to: 'master', lane: 'music' }, + { from: 'impact', to: 'reverb', lane: 'sound' }, + { from: 'reverb', to: 'master', lane: 'sound' }, + { from: 'master', to: 'limiter', lane: 'master' }, + { from: 'limiter', to: 'output', lane: 'master' }, + ]; + for (const bus of [audio.music, audio.sound, audio.master]) { + this.rebuildChain(bus); + } + // Each meter reads the bus output, after its effects and volume. + this.analysers.set('music', new AudioAnalyser({ source: audio.music, fftSize: 1024 })); + this.analysers.set('sound', new AudioAnalyser({ source: audio.sound, fftSize: 1024 })); + this.analysers.set('master', new AudioAnalyser({ source: audio.master, fftSize: 1024 })); + this.hud = mountControls({ + title: 'Audio Signal Graph', + controls: [{ keys: 'Click a node', action: 'edit its parameters' }], + status: 'Click or press any key to start audio…', + hint: 'Every box is a real source, effect or bus; every wire is a real connection. Meters show each bus output.', + corner: 'top-right', + }); + this.app.input.onPointerDown.add(this.onPointerDown); + // A stream is deferred until the unlock gesture, so it can be started now. + this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: this.musicVolume }); + this.select(this.selected); + } + // Application input outlives the scene, so the handler is removed in destroy(). + onPointerDown = (_pointer, x, y) => { + const hit = this.nodes.find(n => x >= n.x && x <= n.x + NODE_W && y >= n.y && y <= n.y + NODE_H); + if (hit) { + this.select(hit.id); + } + }; + /** Re-attach a bus's enabled effects in graph order; bypassed ones are left out. */ + rebuildChain(bus) { + const lane = this.nodes.filter(n => n.bus === bus && n.effect); + for (const n of lane) { + bus.removeEffect(n.effect); + } + for (const n of lane) { + if (n.enabled) { + bus.addEffect(n.effect); + } + } + } + setEnabled(n, enabled) { + n.enabled = enabled; + this.rebuildChain(n.bus); + } + fireImpact() { + if (!this.app.audio.locked) { + this.app.audio.play(this.impact, { bus: this.app.audio.sound, volume: this.impactVolume }); + } + } + select(id) { + const n = this.nodes.find(candidate => candidate.id === id); + const audio = this.app.audio; + this.selected = id; + this.panel?.dispose(); + const panel = mountControlPanel({ title: n.title }); + this.panel = panel; + if (n.effect) { + panel.addToggle({ label: 'Active', value: n.enabled, onChange: value => this.setEnabled(n, value) }); + } + switch (id) { + case 'music': + panel.addToggle({ + label: 'Playing', + value: this.musicPlaying, + onChange: value => { + this.musicPlaying = value; + this.musicVoice.volume = value ? this.musicVolume : 0; + }, + }); + panel.addSlider({ + label: 'Gain', + value: this.musicVolume, + onChange: value => { + this.musicVolume = value; + this.musicVoice.volume = this.musicPlaying ? value : 0; + }, + }); + break; + case 'impact': + panel.addButton({ label: 'Play impact', onClick: () => this.fireImpact() }); + panel.addToggle({ label: 'Repeat', value: this.autoImpact, onChange: value => (this.autoImpact = value) }); + panel.addSlider({ label: 'Gain', value: this.impactVolume, onChange: value => (this.impactVolume = value) }); + break; + case 'eq': + panel.addSlider({ label: 'Low dB', min: -12, max: 12, step: 0.5, value: this.eq.low, onChange: value => (this.eq.low = value) }); + panel.addSlider({ label: 'Mid dB', min: -12, max: 12, step: 0.5, value: this.eq.mid, onChange: value => (this.eq.mid = value) }); + panel.addSlider({ label: 'High dB', min: -12, max: 12, step: 0.5, value: this.eq.high, onChange: value => (this.eq.high = value) }); + break; + case 'compressor': + panel.addSlider({ + label: 'Threshold', + min: -50, + max: 0, + step: 1, + value: this.compressor.threshold, + onChange: value => (this.compressor.threshold = value), + }); + panel.addSlider({ + label: 'Ratio', + min: 1, + max: 20, + step: 0.5, + value: this.compressor.ratio, + onChange: value => (this.compressor.ratio = value), + }); + break; + case 'reverb': + panel.addSlider({ + label: 'Decay s', + min: 0.3, + max: 5, + step: 0.1, + value: this.reverb.decay, + onChange: value => (this.reverb.decay = value), + }); + panel.addSlider({ label: 'Wet', value: this.reverb.wet, onChange: value => (this.reverb.wet = value) }); + break; + case 'master': + panel.addSlider({ label: 'Volume', value: audio.master.volume, onChange: value => (audio.master.volume = value) }); + break; + case 'limiter': + panel.addSlider({ + label: 'Threshold', + min: -24, + max: 0, + step: 0.5, + value: this.limiter.threshold, + onChange: value => (this.limiter.threshold = value), + }); + break; + default: + panel.addButton({ label: 'Play impact', onClick: () => this.fireImpact() }); + } + } + update(delta) { + this.flow = (this.flow + delta * 0.6) % 1; + if (this.autoImpact && !this.app.audio.locked) { + this.impactTimer += delta; + if (this.impactTimer >= 1.6) { + this.impactTimer = 0; + this.fireImpact(); + } + } + for (const [lane, analyser] of this.analysers) { + const { peak, rms } = measure(analyser.getWaveformFloat(lane === 'master' ? this.waveform : this.samples)); + // Fast attack, slow release, so short hits stay readable on the meter. + this.levels[lane] = Math.max(rms, this.levels[lane] - delta * 20); + if (lane === 'master') { + this.masterPeak = Math.max(peak, this.masterPeak - delta * 10); + } + } + const fmt = db => (Number.isFinite(db) && db > -90 ? `${db.toFixed(1)} dBFS` : 'silent'); + for (const n of this.nodes) { + switch (n.id) { + case 'music': + n.status.text = this.musicPlaying ? `gain ${this.musicVolume.toFixed(2)}` : 'stopped'; + break; + case 'impact': + n.status.text = this.autoImpact ? 'repeats every 1.6 s' : 'on demand'; + break; + case 'compressor': + n.status.text = n.enabled ? `reduction ${this.compressor.reduction.toFixed(1)} dB` : 'bypassed'; + break; + case 'master': + n.status.text = `rms ${fmt(this.levels.master)}`; + break; + case 'output': + n.status.text = `peak ${fmt(this.masterPeak)}`; + break; + default: + n.status.text = n.enabled ? 'active' : 'bypassed'; + } + } + this.hud.setStatus( + this.app.audio.locked + ? 'Click or press any key to start audio…' + : `Music bus ${fmt(this.levels.music)} · SFX bus ${fmt(this.levels.sound)}`, + ); + } + draw(context) { + const g = this.graph.clear(); + const byId = id => this.nodes.find(n => n.id === id); + // Wires: a cubic Bézier from the right edge of one node to the left edge of + // the next, brighter and thicker the louder the bus it carries. + for (const edge of this.edges) { + const a = byId(edge.from); + const b = byId(edge.to); + const vertical = a.id === 'master' && b.id === 'limiter'; + const [x0, y0] = vertical ? [a.x + NODE_W / 2, a.y + NODE_H] : [a.x + NODE_W, a.y + NODE_H / 2]; + const [x1, y1] = vertical ? [b.x + NODE_W / 2, b.y] : [b.x, b.y + NODE_H / 2]; + const bend = vertical ? 0 : Math.max(40, (x1 - x0) * 0.5); + const level = Math.max(0, Math.min(1, (this.levels[edge.lane] + 60) / 60)); + const color = LANE_COLORS[edge.lane]; + g.lineWidth = 2 + level * 4; + g.lineColor = new Color(color.r, color.g, color.b, 0.25 + level * 0.75); + g.moveTo(x0, y0); + if (vertical) { + g.lineTo(x1, y1); + } else { + g.bezierCurveTo(x0 + bend, y0, x1 - bend, y1, x1, y1); + } + // A dot travelling along the wire shows the direction of the signal. + const t = this.flow; + const u = 1 - t; + const px = vertical ? x0 : u * u * u * x0 + 3 * u * u * t * (x0 + bend) + 3 * u * t * t * (x1 - bend) + t * t * t * x1; + const py = vertical ? y0 + (y1 - y0) * t : u * u * u * y0 + 3 * u * u * t * y0 + 3 * u * t * t * y1 + t * t * t * y1; + g.lineWidth = 0; + g.fillColor = new Color(color.r, color.g, color.b, 0.3 + level * 0.7); + g.drawCircle(px, py, 3 + level * 3); + } + for (const n of this.nodes) { + const accent = LANE_COLORS[n.lane]; + const selected = n.id === this.selected; + g.lineWidth = selected ? 3 : 1.5; + g.lineColor = selected ? Color.white : new Color(accent.r, accent.g, accent.b, n.enabled ? 0.8 : 0.3); + g.fillColor = n.enabled ? new Color(22, 28, 42) : new Color(22, 24, 30); + g.drawRoundedRectangle(n.x, n.y, NODE_W, NODE_H, 12); + g.lineWidth = 0; + g.fillColor = new Color(accent.r, accent.g, accent.b, n.enabled ? 0.9 : 0.25); + g.drawRoundedRectangle(n.x, n.y, 6, NODE_H, 3); + } + // Level meters at each bus output. + const meter = (x, y, db, color) => { + const level = Math.max(0, Math.min(1, (db + 60) / 60)); + g.fillColor = new Color(40, 46, 60); + g.drawRectangle(x, y, 140, 8); + g.fillColor = color; + g.drawRectangle(x, y, 140 * level, 8); + }; + meter(512, 246, this.levels.music, LANE_COLORS.music); + meter(397, 526, this.levels.sound, LANE_COLORS.sound); + meter(772, 386, this.levels.master, LANE_COLORS.master); + // Output waveform: the last analyser window of the master bus. + const output = byId('output'); + const points = []; + for (let i = 0; i < this.waveform.length; i += 8) { + points.push(output.x + 12 + (i / this.waveform.length) * (NODE_W - 24), output.y + NODE_H + 50 + this.waveform[i] * 40); + } + g.lineWidth = 1.5; + g.lineColor = LANE_COLORS.master; + g.drawPath(points); + g.lineWidth = 0; + context.render(g); + super.draw(context); + } + destroy() { + const audio = this.app.audio; + this.app.input.onPointerDown.remove(this.onPointerDown); + this.musicVoice?.stop(); + for (const n of this.nodes) { + if (n.bus && n.effect) { + n.bus.removeEffect(n.effect); + } + } + for (const effect of [this.eq, this.compressor, this.reverb, this.limiter]) { + effect.destroy(); + } + for (const analyser of this.analysers.values()) { + analyser.destroy(); + } + audio.master.volume = 1; + this.panel?.dispose(); + this.hud.dispose(); + this.graph.destroy(); + super.destroy(); + } +} +const app = new Application({ + scenes: { SignalGraphScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(10, 12, 20), + loader: { basePath: 'assets/' }, +}); +await app.start(SignalGraphScene); diff --git a/examples/audio-fx/signal-graph.ts b/examples/audio-fx/signal-graph.ts new file mode 100644 index 000000000..f7e169d16 --- /dev/null +++ b/examples/audio-fx/signal-graph.ts @@ -0,0 +1,471 @@ +import { + Application, + Asset, + type AudioBus, + type AudioEffect, + type AudioStream, + Color, + FixedResolutionCanvasSizing, + Graphics, + type RenderingContext, + Scene, + type Seconds, + type Sound, + Text, + type Voice, +} from '@codexo/exojs'; +import { AudioAnalyser, CompressorEffect, EqualizerEffect, LimiterEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +type NodeKind = 'source' | 'effect' | 'bus' | 'output'; +type Lane = 'music' | 'sound' | 'master'; + +/** + * One box on the canvas. Effect nodes point at the real effect instance and + * the bus it is attached to, so the drawing and the audio graph cannot drift + * apart: bypassing a node removes exactly that effect from exactly that bus. + */ +interface GraphNode { + id: string; + title: string; + kind: NodeKind; + lane: Lane; + x: number; + y: number; + bus?: AudioBus; + effect?: AudioEffect; + enabled: boolean; + status: Text; +} + +interface GraphEdge { + from: string; + to: string; + // Edges carry the colour of the bus whose signal flows along them. + lane: Lane; +} + +const NODE_W = 180; +const NODE_H = 86; +const LANE_COLORS = { + music: new Color(110, 190, 255), + sound: new Color(255, 170, 90), + master: new Color(150, 240, 170), +}; + +/** Peak and RMS of one analyser window, in dBFS. */ +const measure = (samples: Float32Array): { peak: number; rms: number } => { + let peak = 0; + let sum = 0; + + for (const value of samples) { + peak = Math.max(peak, Math.abs(value)); + sum += value * value; + } + + const toDb = (linear: number): number => (linear > 0 ? 20 * Math.log10(linear) : -Infinity); + + return { peak: toDb(peak), rms: toDb(Math.sqrt(sum / samples.length)) }; +}; + +class SignalGraphScene extends Scene { + private music!: AudioStream; + private impact!: Sound; + private musicVoice!: Voice; + private musicPlaying = true; + private musicVolume = 0.7; + private impactVolume = 0.3; + private autoImpact = true; + private impactTimer = 0; + + private eq!: EqualizerEffect; + private compressor!: CompressorEffect; + private reverb!: ReverbEffect; + private limiter!: LimiterEffect; + + private nodes: GraphNode[] = []; + private edges: GraphEdge[] = []; + private selected = 'compressor'; + private analysers = new Map(); + private levels = { music: -Infinity, sound: -Infinity, master: -Infinity }; + private masterPeak = -Infinity; + private readonly samples = new Float32Array(1024); + private readonly waveform = new Float32Array(1024); + private flow = 0; + + private graph = new Graphics(); + private panel: ReturnType | null = null; + private hud!: ReturnType; + + override async load(): Promise { + this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + this.impact = this.loader.get('audio/impact-light.ogg'); + } + + override init(): void { + const audio = this.app.audio; + + // The real processing graph: two lanes with their own effects, both + // feeding the master bus, which ends in a soft limiter before the output. + this.eq = new EqualizerEffect({ low: 2, mid: 0, high: -1 }); + this.compressor = new CompressorEffect({ threshold: -24, ratio: 3, attack: 0.01, release: 0.2 }); + this.reverb = new ReverbEffect({ decay: 2.2, wet: 0.35 }); + // A safety stage, not a brickwall: fast transients can still pass it. + this.limiter = new LimiterEffect({ threshold: -6 }); + + const node = ( + id: string, + title: string, + kind: NodeKind, + lane: Lane, + x: number, + y: number, + extra: Partial = {}, + ): GraphNode => { + const status = new Text('', { fillColor: new Color(170, 185, 210), fontSize: 14 }).setPosition(x + 12, y + 46); + const label = new Text(title, { fillColor: Color.white, fontSize: 17 }).setPosition(x + 12, y + 14); + + this.root.addChild(label, status); + + return { id, title, kind, lane, x, y, enabled: true, status, ...extra }; + }; + + this.nodes = [ + node('music', 'Music loop', 'source', 'music', 40, 150), + node('impact', 'Impact SFX', 'source', 'sound', 40, 430), + node('eq', 'Equalizer', 'effect', 'music', 270, 150, { bus: audio.music, effect: this.eq }), + node('compressor', 'Compressor', 'effect', 'music', 500, 150, { bus: audio.music, effect: this.compressor }), + node('reverb', 'Reverb', 'effect', 'sound', 385, 430, { bus: audio.sound, effect: this.reverb }), + node('master', 'Master bus', 'bus', 'master', 760, 290, { bus: audio.master }), + node('limiter', 'Limiter', 'effect', 'master', 760, 440, { bus: audio.master, effect: this.limiter }), + node('output', 'Output', 'output', 'master', 1010, 290), + ]; + this.edges = [ + { from: 'music', to: 'eq', lane: 'music' }, + { from: 'eq', to: 'compressor', lane: 'music' }, + { from: 'compressor', to: 'master', lane: 'music' }, + { from: 'impact', to: 'reverb', lane: 'sound' }, + { from: 'reverb', to: 'master', lane: 'sound' }, + { from: 'master', to: 'limiter', lane: 'master' }, + { from: 'limiter', to: 'output', lane: 'master' }, + ]; + + for (const bus of [audio.music, audio.sound, audio.master]) { + this.rebuildChain(bus); + } + + // Each meter reads the bus output, after its effects and volume. + this.analysers.set('music', new AudioAnalyser({ source: audio.music, fftSize: 1024 })); + this.analysers.set('sound', new AudioAnalyser({ source: audio.sound, fftSize: 1024 })); + this.analysers.set('master', new AudioAnalyser({ source: audio.master, fftSize: 1024 })); + + this.hud = mountControls({ + title: 'Audio Signal Graph', + controls: [{ keys: 'Click a node', action: 'edit its parameters' }], + status: 'Click or press any key to start audio…', + hint: 'Every box is a real source, effect or bus; every wire is a real connection. Meters show each bus output.', + corner: 'top-right', + }); + + this.app.input.onPointerDown.add(this.onPointerDown); + + // A stream is deferred until the unlock gesture, so it can be started now. + this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: this.musicVolume }); + this.select(this.selected); + } + + // Application input outlives the scene, so the handler is removed in destroy(). + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { + const hit = this.nodes.find(n => x >= n.x && x <= n.x + NODE_W && y >= n.y && y <= n.y + NODE_H); + + if (hit) { + this.select(hit.id); + } + }; + + /** Re-attach a bus's enabled effects in graph order; bypassed ones are left out. */ + private rebuildChain(bus: AudioBus): void { + const lane = this.nodes.filter(n => n.bus === bus && n.effect); + + for (const n of lane) { + bus.removeEffect(n.effect!); + } + + for (const n of lane) { + if (n.enabled) { + bus.addEffect(n.effect!); + } + } + } + + private setEnabled(n: GraphNode, enabled: boolean): void { + n.enabled = enabled; + this.rebuildChain(n.bus!); + } + + private fireImpact(): void { + if (!this.app.audio.locked) { + this.app.audio.play(this.impact, { bus: this.app.audio.sound, volume: this.impactVolume }); + } + } + + private select(id: string): void { + const n = this.nodes.find(candidate => candidate.id === id)!; + const audio = this.app.audio; + + this.selected = id; + this.panel?.dispose(); + const panel = mountControlPanel({ title: n.title }); + this.panel = panel; + + if (n.effect) { + panel.addToggle({ label: 'Active', value: n.enabled, onChange: value => this.setEnabled(n, value) }); + } + + switch (id) { + case 'music': + panel.addToggle({ + label: 'Playing', + value: this.musicPlaying, + onChange: value => { + this.musicPlaying = value; + this.musicVoice.volume = value ? this.musicVolume : 0; + }, + }); + panel.addSlider({ + label: 'Gain', + value: this.musicVolume, + onChange: value => { + this.musicVolume = value; + this.musicVoice.volume = this.musicPlaying ? value : 0; + }, + }); + break; + case 'impact': + panel.addButton({ label: 'Play impact', onClick: () => this.fireImpact() }); + panel.addToggle({ label: 'Repeat', value: this.autoImpact, onChange: value => (this.autoImpact = value) }); + panel.addSlider({ label: 'Gain', value: this.impactVolume, onChange: value => (this.impactVolume = value) }); + break; + case 'eq': + panel.addSlider({ label: 'Low dB', min: -12, max: 12, step: 0.5, value: this.eq.low, onChange: value => (this.eq.low = value) }); + panel.addSlider({ label: 'Mid dB', min: -12, max: 12, step: 0.5, value: this.eq.mid, onChange: value => (this.eq.mid = value) }); + panel.addSlider({ label: 'High dB', min: -12, max: 12, step: 0.5, value: this.eq.high, onChange: value => (this.eq.high = value) }); + break; + case 'compressor': + panel.addSlider({ + label: 'Threshold', + min: -50, + max: 0, + step: 1, + value: this.compressor.threshold, + onChange: value => (this.compressor.threshold = value), + }); + panel.addSlider({ + label: 'Ratio', + min: 1, + max: 20, + step: 0.5, + value: this.compressor.ratio, + onChange: value => (this.compressor.ratio = value), + }); + break; + case 'reverb': + panel.addSlider({ + label: 'Decay s', + min: 0.3, + max: 5, + step: 0.1, + value: this.reverb.decay, + onChange: value => (this.reverb.decay = value), + }); + panel.addSlider({ label: 'Wet', value: this.reverb.wet, onChange: value => (this.reverb.wet = value) }); + break; + case 'master': + panel.addSlider({ label: 'Volume', value: audio.master.volume, onChange: value => (audio.master.volume = value) }); + break; + case 'limiter': + panel.addSlider({ + label: 'Threshold', + min: -24, + max: 0, + step: 0.5, + value: this.limiter.threshold, + onChange: value => (this.limiter.threshold = value), + }); + break; + default: + panel.addButton({ label: 'Play impact', onClick: () => this.fireImpact() }); + } + } + + override update(delta: Seconds): void { + this.flow = (this.flow + delta * 0.6) % 1; + + if (this.autoImpact && !this.app.audio.locked) { + this.impactTimer += delta; + + if (this.impactTimer >= 1.6) { + this.impactTimer = 0; + this.fireImpact(); + } + } + + for (const [lane, analyser] of this.analysers) { + const { peak, rms } = measure(analyser.getWaveformFloat(lane === 'master' ? this.waveform : this.samples)); + + // Fast attack, slow release, so short hits stay readable on the meter. + this.levels[lane] = Math.max(rms, this.levels[lane] - delta * 20); + + if (lane === 'master') { + this.masterPeak = Math.max(peak, this.masterPeak - delta * 10); + } + } + + const fmt = (db: number): string => (Number.isFinite(db) && db > -90 ? `${db.toFixed(1)} dBFS` : 'silent'); + + for (const n of this.nodes) { + switch (n.id) { + case 'music': + n.status.text = this.musicPlaying ? `gain ${this.musicVolume.toFixed(2)}` : 'stopped'; + break; + case 'impact': + n.status.text = this.autoImpact ? 'repeats every 1.6 s' : 'on demand'; + break; + case 'compressor': + n.status.text = n.enabled ? `reduction ${this.compressor.reduction.toFixed(1)} dB` : 'bypassed'; + break; + case 'master': + n.status.text = `rms ${fmt(this.levels.master)}`; + break; + case 'output': + n.status.text = `peak ${fmt(this.masterPeak)}`; + break; + default: + n.status.text = n.enabled ? 'active' : 'bypassed'; + } + } + + this.hud.setStatus( + this.app.audio.locked + ? 'Click or press any key to start audio…' + : `Music bus ${fmt(this.levels.music)} · SFX bus ${fmt(this.levels.sound)}`, + ); + } + + override draw(context: RenderingContext): void { + const g = this.graph.clear(); + const byId = (id: string): GraphNode => this.nodes.find(n => n.id === id)!; + + // Wires: a cubic Bézier from the right edge of one node to the left edge of + // the next, brighter and thicker the louder the bus it carries. + for (const edge of this.edges) { + const a = byId(edge.from); + const b = byId(edge.to); + const vertical = a.id === 'master' && b.id === 'limiter'; + const [x0, y0] = vertical ? [a.x + NODE_W / 2, a.y + NODE_H] : [a.x + NODE_W, a.y + NODE_H / 2]; + const [x1, y1] = vertical ? [b.x + NODE_W / 2, b.y] : [b.x, b.y + NODE_H / 2]; + const bend = vertical ? 0 : Math.max(40, (x1 - x0) * 0.5); + const level = Math.max(0, Math.min(1, (this.levels[edge.lane] + 60) / 60)); + const color = LANE_COLORS[edge.lane]; + + g.lineWidth = 2 + level * 4; + g.lineColor = new Color(color.r, color.g, color.b, 0.25 + level * 0.75); + g.moveTo(x0, y0); + + if (vertical) { + g.lineTo(x1, y1); + } else { + g.bezierCurveTo(x0 + bend, y0, x1 - bend, y1, x1, y1); + } + + // A dot travelling along the wire shows the direction of the signal. + const t = this.flow; + const u = 1 - t; + const px = vertical ? x0 : u * u * u * x0 + 3 * u * u * t * (x0 + bend) + 3 * u * t * t * (x1 - bend) + t * t * t * x1; + const py = vertical ? y0 + (y1 - y0) * t : u * u * u * y0 + 3 * u * u * t * y0 + 3 * u * t * t * y1 + t * t * t * y1; + + g.lineWidth = 0; + g.fillColor = new Color(color.r, color.g, color.b, 0.3 + level * 0.7); + g.drawCircle(px, py, 3 + level * 3); + } + + for (const n of this.nodes) { + const accent = LANE_COLORS[n.lane]; + const selected = n.id === this.selected; + + g.lineWidth = selected ? 3 : 1.5; + g.lineColor = selected ? Color.white : new Color(accent.r, accent.g, accent.b, n.enabled ? 0.8 : 0.3); + g.fillColor = n.enabled ? new Color(22, 28, 42) : new Color(22, 24, 30); + g.drawRoundedRectangle(n.x, n.y, NODE_W, NODE_H, 12); + g.lineWidth = 0; + g.fillColor = new Color(accent.r, accent.g, accent.b, n.enabled ? 0.9 : 0.25); + g.drawRoundedRectangle(n.x, n.y, 6, NODE_H, 3); + } + + // Level meters at each bus output. + const meter = (x: number, y: number, db: number, color: Color): void => { + const level = Math.max(0, Math.min(1, (db + 60) / 60)); + + g.fillColor = new Color(40, 46, 60); + g.drawRectangle(x, y, 140, 8); + g.fillColor = color; + g.drawRectangle(x, y, 140 * level, 8); + }; + + meter(512, 246, this.levels.music, LANE_COLORS.music); + meter(397, 526, this.levels.sound, LANE_COLORS.sound); + meter(772, 386, this.levels.master, LANE_COLORS.master); + + // Output waveform: the last analyser window of the master bus. + const output = byId('output'); + const points: number[] = []; + + for (let i = 0; i < this.waveform.length; i += 8) { + points.push(output.x + 12 + (i / this.waveform.length) * (NODE_W - 24), output.y + NODE_H + 50 + this.waveform[i] * 40); + } + + g.lineWidth = 1.5; + g.lineColor = LANE_COLORS.master; + g.drawPath(points); + g.lineWidth = 0; + + context.render(g); + super.draw(context); + } + + override destroy(): void { + const audio = this.app.audio; + + this.app.input.onPointerDown.remove(this.onPointerDown); + this.musicVoice?.stop(); + + for (const n of this.nodes) { + if (n.bus && n.effect) { + n.bus.removeEffect(n.effect); + } + } + + for (const effect of [this.eq, this.compressor, this.reverb, this.limiter]) { + effect.destroy(); + } + + for (const analyser of this.analysers.values()) { + analyser.destroy(); + } + + audio.master.volume = 1; + this.panel?.dispose(); + this.hud.dispose(); + this.graph.destroy(); + super.destroy(); + } +} + +const app = new Application({ + scenes: { SignalGraphScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(10, 12, 20), + loader: { basePath: 'assets/' }, +}); + +await app.start(SignalGraphScene); diff --git a/examples/examples.json b/examples/examples.json index 826653a77..f5718856e 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -913,6 +913,17 @@ "capabilities": ["pointer", "audio"], "level": "intermediate" }, + { + "slug": "signal-graph", + "path": "audio-fx/signal-graph.js", + "language": "typescript", + "title": "Audio Signal Graph", + "description": "See the real routing from sources through bus effects to the master output, edit effect parameters, bypass stages and watch each bus level.", + "backend": "core", + "tags": ["audio", "bus", "effects", "graphics"], + "capabilities": ["pointer", "audio"], + "level": "advanced" + }, { "slug": "vocoder", "path": "audio-fx/vocoder.js", diff --git a/site/src/content/guide/audio/audio-effects.mdx b/site/src/content/guide/audio/audio-effects.mdx index df92c70db..db7a97907 100644 --- a/site/src/content/guide/audio/audio-effects.mdx +++ b/site/src/content/guide/audio/audio-effects.mdx @@ -361,6 +361,11 @@ Interactive compressor with live gain-reduction meter — drag sliders to adjust Reverb and delay filters on the SFX bus, with live wet/dry and delay-time sliders. + + + +The whole routing as a graph: a music and an effects lane, each with its own bus effects, both feeding a master bus with a limiter. Select a node to change its parameters or bypass it, and read each bus output on its meter. + ## Where to go next Continue with [Beat detection](/ExoJS/en/guide/audio/beat-detection/), which covers tempo tracking and beat analysis — how to sync game logic to the rhythm of your music. From c91b96fc6e36c353d627c9edea3c21b0e8cefe20 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:27:13 +0200 Subject: [PATCH 08/16] fix(examples): size particle effects in texture scale --- examples/particles/cursor-attractor-particles.js | 2 +- examples/particles/cursor-attractor-particles.ts | 2 +- examples/particles/emitter-basics.js | 8 ++++---- examples/particles/emitter-basics.ts | 8 ++++---- examples/showcase/audio-reactive-particles.js | 2 +- examples/showcase/audio-reactive-particles.ts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/particles/cursor-attractor-particles.js b/examples/particles/cursor-attractor-particles.js index 20fcaa957..6981ae941 100644 --- a/examples/particles/cursor-attractor-particles.js +++ b/examples/particles/cursor-attractor-particles.js @@ -32,7 +32,7 @@ class CursorAttractorParticlesScene extends Scene { lifetime: new Constant(2.6), position: new Constant(new Vector(0, 0)), velocity: new ConeDirection(0, Math.PI, 10, 100), - scale: new Constant(new Vector(0.18, 0.18)), + scale: new Constant(new Vector(0.05, 0.05)), }), ); // Both force fields target the same cursor point. We swap behaviour by diff --git a/examples/particles/cursor-attractor-particles.ts b/examples/particles/cursor-attractor-particles.ts index 180c46cad..f84e494c5 100644 --- a/examples/particles/cursor-attractor-particles.ts +++ b/examples/particles/cursor-attractor-particles.ts @@ -36,7 +36,7 @@ class CursorAttractorParticlesScene extends Scene { lifetime: new Constant(2.6), position: new Constant(new Vector(0, 0)), velocity: new ConeDirection(0, Math.PI, 10, 100), - scale: new Constant(new Vector(0.18, 0.18)), + scale: new Constant(new Vector(0.05, 0.05)), }), ); diff --git a/examples/particles/emitter-basics.js b/examples/particles/emitter-basics.js index 1eef169c3..1d4ec68dc 100644 --- a/examples/particles/emitter-basics.js +++ b/examples/particles/emitter-basics.js @@ -24,12 +24,12 @@ class EmitterBasicsScene extends Scene { this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); // Rate, lifetime, and a cone-shaped velocity spread: a fountain that - // shoots upward (-π/2) with a ±36° spread and 70-180 px/s speed. + // shoots upward (-π/2) with a ±36° spread and 280-460 px/s speed. this.system.addSpawnModule( new RateSpawn({ rate: new Constant(180), lifetime: new Range(0.6, 1.4), - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 70, 180), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 280, 460), }), ); // Gravity pulls the fountain back down. @@ -40,8 +40,8 @@ class EmitterBasicsScene extends Scene { this.system.addUpdateModule( new ScaleOverLifetime( new Curve([ - { t: 0, v: 0.15 }, - { t: 0.2, v: 0.55 }, + { t: 0, v: 0.04 }, + { t: 0.2, v: 0.09 }, { t: 1, v: 0 }, ]), ), diff --git a/examples/particles/emitter-basics.ts b/examples/particles/emitter-basics.ts index 327f2aedb..d6163bef8 100644 --- a/examples/particles/emitter-basics.ts +++ b/examples/particles/emitter-basics.ts @@ -27,12 +27,12 @@ class EmitterBasicsScene extends Scene { this.system.setPosition(width / 2, height - 80); // Rate, lifetime, and a cone-shaped velocity spread: a fountain that - // shoots upward (-π/2) with a ±36° spread and 70-180 px/s speed. + // shoots upward (-π/2) with a ±36° spread and 280-460 px/s speed. this.system.addSpawnModule( new RateSpawn({ rate: new Constant(180), lifetime: new Range(0.6, 1.4), - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 70, 180), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 280, 460), }), ); @@ -45,8 +45,8 @@ class EmitterBasicsScene extends Scene { this.system.addUpdateModule( new ScaleOverLifetime( new Curve([ - { t: 0, v: 0.15 }, - { t: 0.2, v: 0.55 }, + { t: 0, v: 0.04 }, + { t: 0.2, v: 0.09 }, { t: 1, v: 0 }, ]), ), diff --git a/examples/showcase/audio-reactive-particles.js b/examples/showcase/audio-reactive-particles.js index c66a1e407..546541c42 100644 --- a/examples/showcase/audio-reactive-particles.js +++ b/examples/showcase/audio-reactive-particles.js @@ -41,7 +41,7 @@ class AudioReactiveParticlesScene extends Scene { lifetime: new Constant(0.9), position: new Constant(new Vector(0, 0)), velocity: this.cone, - scale: new Constant(new Vector(0.22, 0.22)), + scale: new Constant(new Vector(0.06, 0.06)), tint: new Constant(colors[0]), }); this.ps.addSpawnModule(this.spawn); diff --git a/examples/showcase/audio-reactive-particles.ts b/examples/showcase/audio-reactive-particles.ts index ec0504040..a14e4c1e4 100644 --- a/examples/showcase/audio-reactive-particles.ts +++ b/examples/showcase/audio-reactive-particles.ts @@ -59,7 +59,7 @@ class AudioReactiveParticlesScene extends Scene { lifetime: new Constant(0.9), position: new Constant(new Vector(0, 0)), velocity: this.cone, - scale: new Constant(new Vector(0.22, 0.22)), + scale: new Constant(new Vector(0.06, 0.06)), tint: new Constant(colors[0]), }); this.ps.addSpawnModule(this.spawn); From bd895d511e549e8d1e786f745c28406f34904fe5 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:27:23 +0200 Subject: [PATCH 09/16] feat(examples): layer the fireworks and campfire effects --- examples/particles/bonfire.js | 239 ++++++++++++++++++++++------- examples/particles/bonfire.ts | 264 ++++++++++++++++++++++++-------- examples/particles/fireworks.js | 212 +++++++++++++++++++------ examples/particles/fireworks.ts | 238 +++++++++++++++++++++------- 4 files changed, 729 insertions(+), 224 deletions(-) diff --git a/examples/particles/bonfire.js b/examples/particles/bonfire.js index 5a2a01e82..e8f63588a 100644 --- a/examples/particles/bonfire.js +++ b/examples/particles/bonfire.js @@ -1,99 +1,192 @@ // Auto-generated from bonfire.ts - edit the .ts source, not this file. -import { Application, BlendModes, Color, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; +import { Application, BlendModes, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite, Vector } from '@codexo/exojs'; import { ApplyForce, ColorGradient, ColorOverLifetime, ConeDirection, Constant, + Curve, particlesExtension, ParticleSystem, Range, RateSpawn, + ScaleOverLifetime, + Turbulence, VectorRange, } from '@codexo/exojs-particles'; import { mountControlPanel, mountControls } from '@examples/runtime'; class BonfireScene extends Scene { - fireSystem; - smokeSystem; + // Back to front: smoke behind the flames, embers in front. + smoke; + flames; + core; + embers; ground = new Graphics(); - fireRate = new Constant(50); - wind = new ApplyForce(0, 0); - smokeWind = new ApplyForce(0, 0); + glow; + flameRate = new Constant(42); + coreRate = new Constant(28); + emberRate = new Constant(5); + winds = []; + intensity = 1; + elapsed = 0; + fireX = 0; + fireY = 0; panel; hud; init() { const app = this.app; const { width, height } = app; - const centerX = width / 2; - const fireY = height * 0.75; - this.ground.fillColor = new Color(31, 42, 36); - this.ground.drawRectangle(0, fireY + 75, width, height - fireY - 75); - this.ground.fillColor = new Color(255, 143, 59, 0.18); - this.ground.drawCircle(centerX, fireY + 25, 155); - this.ground.fillColor = new Color(91, 90, 80); - for (const x of [-125, -80, 80, 125]) { - this.ground.drawCircle(centerX + x, fireY + 28, 25); - } - this.ground.fillColor = new Color(92, 54, 35); - this.ground.drawPolygon([centerX - 110, fireY + 35, centerX - 98, fireY + 4, centerX + 105, fireY + 43, centerX + 95, fireY + 70]); - this.ground.drawPolygon([centerX - 105, fireY + 45, centerX + 97, fireY + 5, centerX + 110, fireY + 35, centerX - 95, fireY + 72]); - this.fireSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleFlame)); - this.systems.add(this.fireSystem); - this.fireSystem.setPosition(width * 0.5, height * 0.75); - this.fireSystem.setBlendMode(BlendModes.Additive); - this.fireSystem.addSpawnModule( + const flame = this.loader.get(assets.demo.textures.particleFlame); + const smoke = this.loader.get(assets.demo.textures.particleSmoke); + const star = this.loader.get(assets.demo.textures.particleStar); + this.fireX = width / 2; + this.fireY = height * 0.74; + this.drawGround(width, height); + // Firelight on the ground: the soft light texture squashed into an + // ellipse, so it has no hard edge. + this.glow = new Sprite(this.loader.get(assets.demo.textures.particleLight)) + .setAnchor(0.5) + .setBlendMode(BlendModes.Additive) + .setPosition(this.fireX, this.fireY + 30); + // Smoke: grey, normal blending, slow, grows and drifts as it rises. + this.smoke = new ParticleSystem(smoke, { capacity: 256 }); + this.smoke.setPosition(this.fireX, this.fireY - 90); + this.smoke.addSpawnModule( + new RateSpawn({ + rate: new Constant(7), + lifetime: new Range(3, 4.5), + position: new VectorRange(-18, 18, -6, 6), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 14, 30, 50), + scale: new Constant(new Vector(0.22, 0.22)), + }), + ); + // ScaleOverLifetime sets the absolute scale, so every curve here is in texture scale units. + this.smoke.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.12 }, + { t: 1, v: 0.45 }, + ]), + ), + ); + this.smoke.addUpdateModule( + new ColorOverLifetime( + new ColorGradient([ + { t: 0, color: new Color(90, 82, 76, 0) }, + { t: 0.2, color: new Color(90, 82, 76, 0.3) }, + { t: 1, color: new Color(50, 50, 55, 0) }, + ]), + ), + ); + this.smoke.addUpdateModule(new Turbulence(25, 0.012, 0.4)); + // Flames: the body of the fire. Additive, tapering as they rise and cool. + this.flames = new ParticleSystem(flame, { capacity: 512 }); + this.flames.setPosition(this.fireX, this.fireY); + this.flames.setBlendMode(BlendModes.Additive); + this.flames.addSpawnModule( new RateSpawn({ - rate: this.fireRate, - lifetime: new Range(0.6, 1.2), - position: new VectorRange(-50, 50, -10, 10), - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80), + rate: this.flameRate, + lifetime: new Range(0.7, 1.15), + position: new VectorRange(-38, 38, -6, 6), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 30, 70, 110), + scale: new Constant(new Vector(0.26, 0.26)), }), ); - this.fireSystem.addUpdateModule( + this.flames.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.14 }, + { t: 0.25, v: 0.24 }, + { t: 1, v: 0.07 }, + ]), + ), + ); + this.flames.addUpdateModule( new ColorOverLifetime( new ColorGradient([ - { t: 0, color: new Color(255, 218, 95, 1) }, - { t: 1, color: new Color(206, 53, 26, 0) }, + { t: 0, color: new Color(255, 190, 90, 0.9) }, + { t: 0.45, color: new Color(240, 100, 35, 0.55) }, + { t: 1, color: new Color(120, 25, 10, 0) }, ]), ), ); - this.fireSystem.addUpdateModule(this.wind); - this.smokeSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleSmoke)); - this.systems.add(this.smokeSystem); - this.smokeSystem.setPosition(width * 0.5, height * 0.75 - 40); - this.smokeSystem.setBlendMode(BlendModes.Normal); - this.smokeSystem.addSpawnModule( + this.flames.addUpdateModule(new Turbulence(40, 0.02, 1.4)); + // Core: small, short-lived and nearly white where the fire is hottest. + this.core = new ParticleSystem(flame, { capacity: 256 }); + this.core.setPosition(this.fireX, this.fireY + 4); + this.core.setBlendMode(BlendModes.Additive); + this.core.addSpawnModule( new RateSpawn({ - rate: new Constant(8), - lifetime: new Range(2.5, 4), - position: new VectorRange(-30, 30, -5, 5), - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 12, 20, 35), + rate: this.coreRate, + lifetime: new Range(0.35, 0.6), + position: new VectorRange(-20, 20, -3, 3), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 40, 50, 80), + scale: new Constant(new Vector(0.15, 0.15)), }), ); - this.smokeSystem.addUpdateModule( + this.core.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.13 }, + { t: 1, v: 0.05 }, + ]), + ), + ); + this.core.addUpdateModule( new ColorOverLifetime( new ColorGradient([ - { t: 0, color: new Color(120, 100, 80, 0.4) }, - { t: 1, color: new Color(60, 55, 50, 0) }, + { t: 0, color: new Color(255, 245, 210, 0.9) }, + { t: 1, color: new Color(255, 170, 60, 0) }, + ]), + ), + ); + // Embers: a few sparks that escape the fire and wander upwards. + this.embers = new ParticleSystem(star, { capacity: 128 }); + this.embers.setPosition(this.fireX, this.fireY - 10); + this.embers.setBlendMode(BlendModes.Additive); + this.embers.addSpawnModule( + new RateSpawn({ + rate: this.emberRate, + lifetime: new Range(1.4, 2.6), + position: new VectorRange(-30, 30, -4, 4), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 8, 90, 170), + scale: new Constant(new Vector(0.022, 0.022)), + tint: new Constant(new Color(255, 170, 70)), + }), + ); + this.embers.addUpdateModule(new Turbulence(70, 0.03, 1)); + this.embers.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.06 }, + { t: 1, v: 0.02 }, ]), ), ); - this.smokeSystem.addUpdateModule(this.smokeWind); + for (const system of [this.smoke, this.flames, this.core, this.embers]) { + const wind = new ApplyForce(0, 0); + system.addUpdateModule(wind); + this.winds.push(wind); + this.systems.add(system); + } this.hud = mountControls({ title: 'Campfire', - status: 'Flame and smoke respond to wind and intensity.', - hint: 'The logs and stones give the particles a visible source.', + status: 'Four particle layers: smoke, flames, a hot core and embers.', + hint: 'Each layer has its own texture, lifetime, size and colour curve. Wind pushes all of them.', }); - this.panel = mountControlPanel({ title: 'Campfire' }); + this.panel = mountControlPanel({ title: 'Fire controls' }); this.panel.addSlider({ label: 'Intensity', - min: 20, - max: 100, - step: 1, - value: 50, + min: 0.3, + max: 1.6, + step: 0.05, + value: 1, onChange: value => { - this.fireRate.value = value; + this.intensity = value; + this.flameRate.value = 42 * value; + this.coreRate.value = 28 * value; + this.emberRate.value = 5 * value * value; }, }); this.panel.addSlider({ @@ -103,20 +196,50 @@ class BonfireScene extends Scene { step: 5, value: 0, onChange: value => { - this.wind.accelerationX = value; - this.smokeWind.accelerationX = value; + // Light particles drift further than the dense flame body. + this.winds[0].accelerationX = value * 0.8; + this.winds[1].accelerationX = value * 0.5; + this.winds[2].accelerationX = value * 0.3; + this.winds[3].accelerationX = value; }, }); } + drawGround(width, height) { + const { fireX, fireY } = this; + const g = this.ground; + g.fillColor = new Color(22, 28, 26); + g.drawRectangle(0, fireY + 30, width, height - fireY - 30); + // Stone ring, then crossed logs at the base of the flames. + g.fillColor = new Color(70, 70, 66); + for (const x of [-120, -82, 82, 120]) { + g.drawEllipse(fireX + x, fireY + 34, 22, 14); + } + g.fillColor = new Color(78, 46, 30); + g.drawPolygon([fireX - 96, fireY + 22, fireX - 88, fireY + 4, fireX + 92, fireY + 30, fireX + 84, fireY + 46]); + g.fillColor = new Color(96, 58, 36); + g.drawPolygon([fireX - 92, fireY + 34, fireX + 86, fireY + 2, fireX + 96, fireY + 20, fireX - 84, fireY + 52]); + } + update(delta) { + this.elapsed += delta; + } draw(context) { + // Light on the ground flickers with the fire instead of sitting as a disc. + const flicker = 0.85 + Math.sin(this.elapsed * 13) * 0.06 + Math.sin(this.elapsed * 7.3) * 0.05; + const strength = Math.min(1.2, this.intensity) * flicker; + this.glow.setScale(0.75 * strength, 0.16 * strength); + this.glow.setTint(new Color(255, 130, 50, 0.35 * strength)); context.render(this.ground); - context.render(this.smokeSystem); - context.render(this.fireSystem); + context.render(this.glow); + context.render(this.smoke); + context.render(this.flames); + context.render(this.core); + context.render(this.embers); } destroy() { this.panel?.dispose(); this.hud?.dispose(); this.ground.destroy(); + this.glow.destroy(); super.destroy(); } } @@ -128,7 +251,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(8, 9, 14), extensions: [particlesExtension], }); await app.start(BonfireScene); diff --git a/examples/particles/bonfire.ts b/examples/particles/bonfire.ts index 17e8b552c..c4285ad76 100644 --- a/examples/particles/bonfire.ts +++ b/examples/particles/bonfire.ts @@ -1,110 +1,213 @@ -import { Application, BlendModes, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; +import { + Application, + BlendModes, + Color, + FixedResolutionCanvasSizing, + Graphics, + type RenderingContext, + Scene, + type Seconds, + Sprite, + Vector, +} from '@codexo/exojs'; import { ApplyForce, ColorGradient, ColorOverLifetime, ConeDirection, Constant, + Curve, particlesExtension, ParticleSystem, Range, RateSpawn, + ScaleOverLifetime, + Turbulence, VectorRange, } from '@codexo/exojs-particles'; import { mountControlPanel, mountControls } from '@examples/runtime'; class BonfireScene extends Scene { - private fireSystem!: ParticleSystem; - private smokeSystem!: ParticleSystem; + // Back to front: smoke behind the flames, embers in front. + private smoke!: ParticleSystem; + private flames!: ParticleSystem; + private core!: ParticleSystem; + private embers!: ParticleSystem; private ground = new Graphics(); - private fireRate = new Constant(50); - private wind = new ApplyForce(0, 0); - private smokeWind = new ApplyForce(0, 0); + private glow!: Sprite; + private flameRate = new Constant(42); + private coreRate = new Constant(28); + private emberRate = new Constant(5); + private winds: ApplyForce[] = []; + private intensity = 1; + private elapsed = 0; + private fireX = 0; + private fireY = 0; private panel!: ReturnType; private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; - const centerX = width / 2; - const fireY = height * 0.75; - - this.ground.fillColor = new Color(31, 42, 36); - this.ground.drawRectangle(0, fireY + 75, width, height - fireY - 75); - this.ground.fillColor = new Color(255, 143, 59, 0.18); - this.ground.drawCircle(centerX, fireY + 25, 155); - this.ground.fillColor = new Color(91, 90, 80); - - for (const x of [-125, -80, 80, 125]) { - this.ground.drawCircle(centerX + x, fireY + 28, 25); - } + const flame = this.loader.get(assets.demo.textures.particleFlame); + const smoke = this.loader.get(assets.demo.textures.particleSmoke); + const star = this.loader.get(assets.demo.textures.particleStar); - this.ground.fillColor = new Color(92, 54, 35); - this.ground.drawPolygon([centerX - 110, fireY + 35, centerX - 98, fireY + 4, centerX + 105, fireY + 43, centerX + 95, fireY + 70]); - this.ground.drawPolygon([centerX - 105, fireY + 45, centerX + 97, fireY + 5, centerX + 110, fireY + 35, centerX - 95, fireY + 72]); + this.fireX = width / 2; + this.fireY = height * 0.74; + this.drawGround(width, height); - this.fireSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleFlame)); - this.systems.add(this.fireSystem); - this.fireSystem.setPosition(width * 0.5, height * 0.75); - this.fireSystem.setBlendMode(BlendModes.Additive); + // Firelight on the ground: the soft light texture squashed into an + // ellipse, so it has no hard edge. + this.glow = new Sprite(this.loader.get(assets.demo.textures.particleLight)) + .setAnchor(0.5) + .setBlendMode(BlendModes.Additive) + .setPosition(this.fireX, this.fireY + 30); - this.fireSystem.addSpawnModule( + // Smoke: grey, normal blending, slow, grows and drifts as it rises. + this.smoke = new ParticleSystem(smoke, { capacity: 256 }); + this.smoke.setPosition(this.fireX, this.fireY - 90); + this.smoke.addSpawnModule( new RateSpawn({ - rate: this.fireRate, - lifetime: new Range(0.6, 1.2), - position: new VectorRange(-50, 50, -10, 10), - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80), + rate: new Constant(7), + lifetime: new Range(3, 4.5), + position: new VectorRange(-18, 18, -6, 6), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 14, 30, 50), + scale: new Constant(new Vector(0.22, 0.22)), }), ); - - this.fireSystem.addUpdateModule( + // ScaleOverLifetime sets the absolute scale, so every curve here is in texture scale units. + this.smoke.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.12 }, + { t: 1, v: 0.45 }, + ]), + ), + ); + this.smoke.addUpdateModule( new ColorOverLifetime( new ColorGradient([ - { t: 0, color: new Color(255, 218, 95, 1) }, - { t: 1, color: new Color(206, 53, 26, 0) }, + { t: 0, color: new Color(90, 82, 76, 0) }, + { t: 0.2, color: new Color(90, 82, 76, 0.3) }, + { t: 1, color: new Color(50, 50, 55, 0) }, ]), ), ); - this.fireSystem.addUpdateModule(this.wind); - - this.smokeSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleSmoke)); - this.systems.add(this.smokeSystem); - this.smokeSystem.setPosition(width * 0.5, height * 0.75 - 40); - this.smokeSystem.setBlendMode(BlendModes.Normal); + this.smoke.addUpdateModule(new Turbulence(25, 0.012, 0.4)); - this.smokeSystem.addSpawnModule( + // Flames: the body of the fire. Additive, tapering as they rise and cool. + this.flames = new ParticleSystem(flame, { capacity: 512 }); + this.flames.setPosition(this.fireX, this.fireY); + this.flames.setBlendMode(BlendModes.Additive); + this.flames.addSpawnModule( new RateSpawn({ - rate: new Constant(8), - lifetime: new Range(2.5, 4), - position: new VectorRange(-30, 30, -5, 5), - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 12, 20, 35), + rate: this.flameRate, + lifetime: new Range(0.7, 1.15), + position: new VectorRange(-38, 38, -6, 6), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 30, 70, 110), + scale: new Constant(new Vector(0.26, 0.26)), }), ); + this.flames.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.14 }, + { t: 0.25, v: 0.24 }, + { t: 1, v: 0.07 }, + ]), + ), + ); + this.flames.addUpdateModule( + new ColorOverLifetime( + new ColorGradient([ + { t: 0, color: new Color(255, 190, 90, 0.9) }, + { t: 0.45, color: new Color(240, 100, 35, 0.55) }, + { t: 1, color: new Color(120, 25, 10, 0) }, + ]), + ), + ); + this.flames.addUpdateModule(new Turbulence(40, 0.02, 1.4)); - this.smokeSystem.addUpdateModule( + // Core: small, short-lived and nearly white where the fire is hottest. + this.core = new ParticleSystem(flame, { capacity: 256 }); + this.core.setPosition(this.fireX, this.fireY + 4); + this.core.setBlendMode(BlendModes.Additive); + this.core.addSpawnModule( + new RateSpawn({ + rate: this.coreRate, + lifetime: new Range(0.35, 0.6), + position: new VectorRange(-20, 20, -3, 3), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 40, 50, 80), + scale: new Constant(new Vector(0.15, 0.15)), + }), + ); + this.core.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.13 }, + { t: 1, v: 0.05 }, + ]), + ), + ); + this.core.addUpdateModule( new ColorOverLifetime( new ColorGradient([ - { t: 0, color: new Color(120, 100, 80, 0.4) }, - { t: 1, color: new Color(60, 55, 50, 0) }, + { t: 0, color: new Color(255, 245, 210, 0.9) }, + { t: 1, color: new Color(255, 170, 60, 0) }, ]), ), ); - this.smokeSystem.addUpdateModule(this.smokeWind); + + // Embers: a few sparks that escape the fire and wander upwards. + this.embers = new ParticleSystem(star, { capacity: 128 }); + this.embers.setPosition(this.fireX, this.fireY - 10); + this.embers.setBlendMode(BlendModes.Additive); + this.embers.addSpawnModule( + new RateSpawn({ + rate: this.emberRate, + lifetime: new Range(1.4, 2.6), + position: new VectorRange(-30, 30, -4, 4), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 8, 90, 170), + scale: new Constant(new Vector(0.022, 0.022)), + tint: new Constant(new Color(255, 170, 70)), + }), + ); + this.embers.addUpdateModule(new Turbulence(70, 0.03, 1)); + this.embers.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.06 }, + { t: 1, v: 0.02 }, + ]), + ), + ); + + for (const system of [this.smoke, this.flames, this.core, this.embers]) { + const wind = new ApplyForce(0, 0); + + system.addUpdateModule(wind); + this.winds.push(wind); + this.systems.add(system); + } this.hud = mountControls({ title: 'Campfire', - status: 'Flame and smoke respond to wind and intensity.', - hint: 'The logs and stones give the particles a visible source.', + status: 'Four particle layers: smoke, flames, a hot core and embers.', + hint: 'Each layer has its own texture, lifetime, size and colour curve. Wind pushes all of them.', }); - this.panel = mountControlPanel({ title: 'Campfire' }); + this.panel = mountControlPanel({ title: 'Fire controls' }); this.panel.addSlider({ label: 'Intensity', - min: 20, - max: 100, - step: 1, - value: 50, + min: 0.3, + max: 1.6, + step: 0.05, + value: 1, onChange: value => { - this.fireRate.value = value; + this.intensity = value; + this.flameRate.value = 42 * value; + this.coreRate.value = 28 * value; + this.emberRate.value = 5 * value * value; }, }); this.panel.addSlider({ @@ -114,22 +217,59 @@ class BonfireScene extends Scene { step: 5, value: 0, onChange: value => { - this.wind.accelerationX = value; - this.smokeWind.accelerationX = value; + // Light particles drift further than the dense flame body. + this.winds[0].accelerationX = value * 0.8; + this.winds[1].accelerationX = value * 0.5; + this.winds[2].accelerationX = value * 0.3; + this.winds[3].accelerationX = value; }, }); } + private drawGround(width: number, height: number): void { + const { fireX, fireY } = this; + const g = this.ground; + + g.fillColor = new Color(22, 28, 26); + g.drawRectangle(0, fireY + 30, width, height - fireY - 30); + + // Stone ring, then crossed logs at the base of the flames. + g.fillColor = new Color(70, 70, 66); + + for (const x of [-120, -82, 82, 120]) { + g.drawEllipse(fireX + x, fireY + 34, 22, 14); + } + + g.fillColor = new Color(78, 46, 30); + g.drawPolygon([fireX - 96, fireY + 22, fireX - 88, fireY + 4, fireX + 92, fireY + 30, fireX + 84, fireY + 46]); + g.fillColor = new Color(96, 58, 36); + g.drawPolygon([fireX - 92, fireY + 34, fireX + 86, fireY + 2, fireX + 96, fireY + 20, fireX - 84, fireY + 52]); + } + + override update(delta: Seconds): void { + this.elapsed += delta; + } + override draw(context: RenderingContext): void { + // Light on the ground flickers with the fire instead of sitting as a disc. + const flicker = 0.85 + Math.sin(this.elapsed * 13) * 0.06 + Math.sin(this.elapsed * 7.3) * 0.05; + const strength = Math.min(1.2, this.intensity) * flicker; + this.glow.setScale(0.75 * strength, 0.16 * strength); + this.glow.setTint(new Color(255, 130, 50, 0.35 * strength)); + context.render(this.ground); - context.render(this.smokeSystem); - context.render(this.fireSystem); + context.render(this.glow); + context.render(this.smoke); + context.render(this.flames); + context.render(this.core); + context.render(this.embers); } override destroy(): void { this.panel?.dispose(); this.hud?.dispose(); this.ground.destroy(); + this.glow.destroy(); super.destroy(); } } @@ -142,7 +282,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(8, 9, 14), extensions: [particlesExtension], }); diff --git a/examples/particles/fireworks.js b/examples/particles/fireworks.js index 57bdc0c08..5baa9d64e 100644 --- a/examples/particles/fireworks.js +++ b/examples/particles/fireworks.js @@ -4,6 +4,7 @@ import { BlendModes, Color, FixedResolutionCanvasSizing, + Graphics, Random, Scene, Size, @@ -19,37 +20,51 @@ import { ConeDirection, Constant, Curve, + Drag, particlesExtension, ParticleSystem, Range, + ScaleOverLifetime, + Turbulence, + VectorRange, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; -const random = new Random(); -const autoLaunchInterval = Time.seconds(2.2); -const tailDuration = 2.5; -const particlesPerExplosion = 375; +const random = new Random(2026); +const autoLaunchInterval = Time.seconds(1.6); // Upward launch speed range (px/s, negative = up). Higher = higher apex. -const launchSpeedMin = 360; -const launchSpeedMax = 460; -// Gravity applied to rockets (px/s²). Matches the burst gravity so the shower -// drifts down at the same rate the rocket decelerated rising. -const rocketGravity = 200; +const launchSpeedMin = 420; +const launchSpeedMax = 560; +const rocketGravity = 260; +const trailLength = 10; const fireworkColors = [ - new Color(100, 255, 135), - new Color(175, 255, 135), - new Color(85, 190, 255), - new Color(255, 145, 255), - new Color(100, 100, 255), - new Color(140, 250, 190), - new Color(255, 135, 135), - new Color(240, 255, 135), - new Color(245, 215, 80), + new Color(255, 120, 110), + new Color(255, 200, 90), + new Color(120, 220, 255), + new Color(160, 255, 150), + new Color(255, 140, 230), + new Color(190, 160, 255), ]; +/** + * One layer of the explosion: its own texture, motion and fade. Every layer + * bursts from the same point, so the effect reads as a single event while each + * layer keeps a separate role. + */ +const createLayer = (texture, burst, options) => { + const system = new ParticleSystem(texture, options); + system.setBlendMode(BlendModes.Additive); + system.addSpawnModule(burst); + return system; +}; class FireworksScene extends Scene { canvasSize; - explosions; - burstPosition; - burst; + // A burst schedule also fires once when its system starts; park the burst + // point off-screen until the first rocket detonates. + burstPosition = new Vector(-1000, -1000); + flashBurst; + sparkBurst; + emberBurst; + layers = []; + trails = new Graphics(); autoLaunchTimer; rocketTexture; rockets = []; @@ -58,80 +73,154 @@ class FireworksScene extends Scene { init() { const app = this.app; const { width, height } = app; + const star = this.loader.get(assets.demo.textures.particleStar); + const light = this.loader.get(assets.demo.textures.particleLight); this.canvasSize = new Size(width, height); - this.rocketTexture = this.loader.get(assets.demo.textures.particleStar); - // Single explosion system shared by every detonation. We reposition and - // re-tint a single BurstSpawn, then reset() it to fire one burst. - this.explosions = new ParticleSystem(this.rocketTexture, { capacity: 16384 }); - this.systems.add(this.explosions); - this.explosions.setBlendMode(BlendModes.Additive); - this.burstPosition = new Vector(0, 0); - this.burst = new BurstSpawn({ - schedule: [{ time: 0, count: particlesPerExplosion }], - lifetime: new Range(tailDuration * 0.7, tailDuration), + this.rocketTexture = star; + // 1. Flash: one large, very short-lived glow that sells the detonation. + this.flashBurst = new BurstSpawn({ + schedule: [{ time: 0, count: 1 }], + lifetime: new Constant(0.22), position: new Constant(this.burstPosition), - velocity: ConeDirection.omni(20, 70), - scale: new Constant(new Vector(0.95, 0.95)), + velocity: new VectorRange(0, 0, 0, 0), + scale: new Constant(new Vector(0.55, 0.55)), + tint: new Constant(Color.white), + }); + const flash = createLayer(light, this.flashBurst, { capacity: 32 }); + // ScaleOverLifetime sets the absolute scale, so the curve is in texture scale units. + flash.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.3 }, + { t: 1, v: 0.75 }, + ]), + ), + ); + flash.addUpdateModule( + new AlphaFadeOverLifetime( + new Curve([ + { t: 0, v: 0.9 }, + { t: 1, v: 0 }, + ]), + ), + ); + // 2. Sparks: fast glints that brake against the air and fall. + this.sparkBurst = new BurstSpawn({ + schedule: [{ time: 0, count: 180 }], + lifetime: new Range(1.1, 1.8), + position: new Constant(this.burstPosition), + // A narrow speed band puts the sparks on one expanding shell. + velocity: ConeDirection.omni(200, 320), + scale: new Constant(new Vector(0.1, 0.1)), tint: new Constant(fireworkColors[0]), }); - this.explosions.addSpawnModule(this.burst); - this.explosions.addUpdateModule(new ApplyForce(0, 30)); - this.explosions.addUpdateModule( + const sparks = createLayer(star, this.sparkBurst, { capacity: 4096 }); + sparks.addUpdateModule(new Drag(1.3)); + sparks.addUpdateModule(new ApplyForce(0, 70)); + sparks.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.12 }, + { t: 1, v: 0.04 }, + ]), + ), + ); + sparks.addUpdateModule( new AlphaFadeOverLifetime( new Curve([ { t: 0, v: 1 }, + { t: 0.55, v: 0.85 }, + { t: 1, v: 0 }, + ]), + ), + ); + // 3. Afterglow: a few slow, warm embers that linger after the sparks are gone. + this.emberBurst = new BurstSpawn({ + schedule: [{ time: 0, count: 40 }], + lifetime: new Range(2.2, 3.2), + position: new Constant(this.burstPosition), + velocity: ConeDirection.omni(15, 80), + scale: new Constant(new Vector(0.06, 0.06)), + tint: new Constant(new Color(255, 214, 150)), + }); + const embers = createLayer(star, this.emberBurst, { capacity: 1024 }); + embers.addUpdateModule(new Drag(0.9)); + embers.addUpdateModule(new ApplyForce(0, 28)); + embers.addUpdateModule(new Turbulence(18, 0.02, 0.6)); + embers.addUpdateModule( + new AlphaFadeOverLifetime( + new Curve([ + { t: 0, v: 0 }, + { t: 0.15, v: 0.9 }, { t: 1, v: 0 }, ]), ), ); + // Drawn back to front: embers under sparks under the flash. + this.layers = [embers, sparks, flash]; + for (const layer of this.layers) { + this.systems.add(layer); + } // Click anywhere to launch a rocket from the bottom at that x. - app.input.onPointerDown.add(pointer => this.launchRocket(pointer.x)); + app.input.onPointerDown.add(this.onPointerDown); // Ambient fallback: keep the sky alive even without clicks. this.autoLaunchTimer = new Timer(autoLaunchInterval, true); this.hud = mountControls({ title: 'Fireworks', controls: [{ keys: 'Click', action: 'launch a rocket' }], status: 'Launched: 0 · in flight: 0', - hint: 'Each rocket rises and bursts at its apex. Rockets also auto-launch every couple of seconds.', + hint: 'Three layers per burst: a short flash, sparks that brake and fall, and slow embers that linger.', }); + this.launchRocket(width * 0.5); } + // Application input outlives the scene, so the handler is removed in destroy(). + onPointerDown = (_pointer, x) => this.launchRocket(x); launchRocket(x) { - const color = fireworkColors[(random.next(0, fireworkColors.length - 1) + 0.5) | 0]; + const color = fireworkColors[Math.floor(random.next(0, fireworkColors.length)) % fireworkColors.length]; const sprite = new Sprite(this.rocketTexture); - sprite.setAnchor(0.5).setBlendMode(BlendModes.Additive).setTint(color).setScale(0.7); - const position = new Vector(x, this.canvasSize.height); + sprite.setAnchor(0.5).setBlendMode(BlendModes.Additive).setTint(color).setScale(0.09); + const position = new Vector(x, this.canvasSize.height + 10); sprite.setPosition(position.x, position.y); this.rockets.push({ sprite, position, velocityY: -random.next(launchSpeedMin, launchSpeedMax), color, + trail: [], }); this.launchCount++; this.updateStatus(); } detonate(rocket) { this.burstPosition.set(rocket.position.x, rocket.position.y); - this.burst.config.tint = new Constant(rocket.color); - this.burst.reset(); + this.sparkBurst.config.tint = new Constant(rocket.color); + this.flashBurst.config.tint = new Constant( + new Color(Math.round(128 + rocket.color.r / 2), Math.round(128 + rocket.color.g / 2), Math.round(128 + rocket.color.b / 2)), + ); + this.flashBurst.reset(); + this.sparkBurst.reset(); + this.emberBurst.reset(); } updateStatus() { this.hud.setStatus(`Launched: ${this.launchCount} · in flight: ${this.rockets.length}`); } update(delta) { - const dt = delta; if (this.autoLaunchTimer.expired) { - this.launchRocket(random.next(80, this.canvasSize.width - 80)); + this.launchRocket(random.next(120, this.canvasSize.width - 120)); this.autoLaunchTimer.restart(); } // Integrate rockets; detonate the instant a rocket's upward velocity // turns over (its apex), then retire it. for (let i = this.rockets.length - 1; i >= 0; i--) { const rocket = this.rockets[i]; - rocket.velocityY += rocketGravity * dt; - rocket.position.y += rocket.velocityY * dt; + rocket.velocityY += rocketGravity * delta; + rocket.position.y += rocket.velocityY * delta; rocket.sprite.setPosition(rocket.position.x, rocket.position.y); - if (rocket.velocityY >= 0 || rocket.position.y <= 0) { + rocket.trail.push(rocket.position.x, rocket.position.y); + if (rocket.trail.length > trailLength * 2) { + rocket.trail.splice(0, 2); + } + if (rocket.velocityY >= 0 || rocket.position.y <= 40) { this.detonate(rocket); rocket.sprite.destroy(); this.rockets.splice(i, 1); @@ -140,11 +229,34 @@ class FireworksScene extends Scene { } } draw(context) { - context.render(this.explosions); + // Each rocket leaves a short fading streak; older segments are fainter. + const trails = this.trails.clear(); + trails.lineWidth = 2; + for (const rocket of this.rockets) { + const points = rocket.trail.length / 2; + for (let i = 1; i < points; i++) { + const alpha = (i / points) * 0.7; + trails.lineColor = new Color(rocket.color.r, rocket.color.g, rocket.color.b, alpha); + trails.drawLine(rocket.trail[(i - 1) * 2], rocket.trail[(i - 1) * 2 + 1], rocket.trail[i * 2], rocket.trail[i * 2 + 1]); + } + } + context.render(trails); + for (const layer of this.layers) { + context.render(layer); + } for (const rocket of this.rockets) { context.render(rocket.sprite); } } + destroy() { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); + this.trails.destroy(); + for (const rocket of this.rockets) { + rocket.sprite.destroy(); + } + super.destroy(); + } } const app = new Application({ scenes: { FireworksScene }, @@ -154,7 +266,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(4, 6, 14), extensions: [particlesExtension], }); await app.start(FireworksScene); diff --git a/examples/particles/fireworks.ts b/examples/particles/fireworks.ts index 5f5602ffb..5b90d75bb 100644 --- a/examples/particles/fireworks.ts +++ b/examples/particles/fireworks.ts @@ -3,6 +3,7 @@ import { BlendModes, Color, FixedResolutionCanvasSizing, + Graphics, Random, type RenderingContext, Scene, @@ -21,32 +22,31 @@ import { ConeDirection, Constant, Curve, + Drag, particlesExtension, ParticleSystem, + type ParticleSystemOptions, Range, + ScaleOverLifetime, + Turbulence, + VectorRange, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; -const random = new Random(); -const autoLaunchInterval = Time.seconds(2.2); -const tailDuration = 2.5; -const particlesPerExplosion = 375; +const random = new Random(2026); +const autoLaunchInterval = Time.seconds(1.6); // Upward launch speed range (px/s, negative = up). Higher = higher apex. -const launchSpeedMin = 360; -const launchSpeedMax = 460; -// Gravity applied to rockets (px/s²). Matches the burst gravity so the shower -// drifts down at the same rate the rocket decelerated rising. -const rocketGravity = 200; +const launchSpeedMin = 420; +const launchSpeedMax = 560; +const rocketGravity = 260; +const trailLength = 10; const fireworkColors = [ - new Color(100, 255, 135), - new Color(175, 255, 135), - new Color(85, 190, 255), - new Color(255, 145, 255), - new Color(100, 100, 255), - new Color(140, 250, 190), - new Color(255, 135, 135), - new Color(240, 255, 135), - new Color(245, 215, 80), + new Color(255, 120, 110), + new Color(255, 200, 90), + new Color(120, 220, 255), + new Color(160, 255, 150), + new Color(255, 140, 230), + new Color(190, 160, 255), ]; /** A rising firework shell. Detonates once its upward motion turns over (apex). */ @@ -55,13 +55,33 @@ interface Rocket { position: Vector; velocityY: number; color: Color; + trail: number[]; } +/** + * One layer of the explosion: its own texture, motion and fade. Every layer + * bursts from the same point, so the effect reads as a single event while each + * layer keeps a separate role. + */ +const createLayer = (texture: Texture, burst: BurstSpawn, options: ParticleSystemOptions): ParticleSystem => { + const system = new ParticleSystem(texture, options); + + system.setBlendMode(BlendModes.Additive); + system.addSpawnModule(burst); + + return system; +}; + class FireworksScene extends Scene { private canvasSize!: Size; - private explosions!: ParticleSystem; - private burstPosition!: Vector; - private burst!: BurstSpawn; + // A burst schedule also fires once when its system starts; park the burst + // point off-screen until the first rocket detonates. + private burstPosition = new Vector(-1000, -1000); + private flashBurst!: BurstSpawn; + private sparkBurst!: BurstSpawn; + private emberBurst!: BurstSpawn; + private layers: ParticleSystem[] = []; + private trails = new Graphics(); private autoLaunchTimer!: Timer; private rocketTexture!: Texture; private rockets: Rocket[] = []; @@ -71,39 +91,103 @@ class FireworksScene extends Scene { override init(): void { const app = this.app; const { width, height } = app; + const star = this.loader.get(assets.demo.textures.particleStar); + const light = this.loader.get(assets.demo.textures.particleLight); this.canvasSize = new Size(width, height); - this.rocketTexture = this.loader.get(assets.demo.textures.particleStar); - - // Single explosion system shared by every detonation. We reposition and - // re-tint a single BurstSpawn, then reset() it to fire one burst. - this.explosions = new ParticleSystem(this.rocketTexture, { capacity: 16384 }); - this.systems.add(this.explosions); - this.explosions.setBlendMode(BlendModes.Additive); - - this.burstPosition = new Vector(0, 0); - this.burst = new BurstSpawn({ - schedule: [{ time: 0, count: particlesPerExplosion }], - lifetime: new Range(tailDuration * 0.7, tailDuration), + this.rocketTexture = star; + + // 1. Flash: one large, very short-lived glow that sells the detonation. + this.flashBurst = new BurstSpawn({ + schedule: [{ time: 0, count: 1 }], + lifetime: new Constant(0.22), position: new Constant(this.burstPosition), - velocity: ConeDirection.omni(20, 70), - scale: new Constant(new Vector(0.95, 0.95)), - tint: new Constant(fireworkColors[0]), + velocity: new VectorRange(0, 0, 0, 0), + scale: new Constant(new Vector(0.55, 0.55)), + tint: new Constant(Color.white), }); + const flash = createLayer(light, this.flashBurst, { capacity: 32 }); + // ScaleOverLifetime sets the absolute scale, so the curve is in texture scale units. + flash.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.3 }, + { t: 1, v: 0.75 }, + ]), + ), + ); + flash.addUpdateModule( + new AlphaFadeOverLifetime( + new Curve([ + { t: 0, v: 0.9 }, + { t: 1, v: 0 }, + ]), + ), + ); - this.explosions.addSpawnModule(this.burst); - this.explosions.addUpdateModule(new ApplyForce(0, 30)); - this.explosions.addUpdateModule( + // 2. Sparks: fast glints that brake against the air and fall. + this.sparkBurst = new BurstSpawn({ + schedule: [{ time: 0, count: 180 }], + lifetime: new Range(1.1, 1.8), + position: new Constant(this.burstPosition), + // A narrow speed band puts the sparks on one expanding shell. + velocity: ConeDirection.omni(200, 320), + scale: new Constant(new Vector(0.1, 0.1)), + tint: new Constant(fireworkColors[0]), + }); + const sparks = createLayer(star, this.sparkBurst, { capacity: 4096 }); + sparks.addUpdateModule(new Drag(1.3)); + sparks.addUpdateModule(new ApplyForce(0, 70)); + sparks.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 0.12 }, + { t: 1, v: 0.04 }, + ]), + ), + ); + sparks.addUpdateModule( new AlphaFadeOverLifetime( new Curve([ { t: 0, v: 1 }, + { t: 0.55, v: 0.85 }, + { t: 1, v: 0 }, + ]), + ), + ); + + // 3. Afterglow: a few slow, warm embers that linger after the sparks are gone. + this.emberBurst = new BurstSpawn({ + schedule: [{ time: 0, count: 40 }], + lifetime: new Range(2.2, 3.2), + position: new Constant(this.burstPosition), + velocity: ConeDirection.omni(15, 80), + scale: new Constant(new Vector(0.06, 0.06)), + tint: new Constant(new Color(255, 214, 150)), + }); + const embers = createLayer(star, this.emberBurst, { capacity: 1024 }); + embers.addUpdateModule(new Drag(0.9)); + embers.addUpdateModule(new ApplyForce(0, 28)); + embers.addUpdateModule(new Turbulence(18, 0.02, 0.6)); + embers.addUpdateModule( + new AlphaFadeOverLifetime( + new Curve([ + { t: 0, v: 0 }, + { t: 0.15, v: 0.9 }, { t: 1, v: 0 }, ]), ), ); + // Drawn back to front: embers under sparks under the flash. + this.layers = [embers, sparks, flash]; + + for (const layer of this.layers) { + this.systems.add(layer); + } + // Click anywhere to launch a rocket from the bottom at that x. - app.input.onPointerDown.add(pointer => this.launchRocket(pointer.x)); + app.input.onPointerDown.add(this.onPointerDown); // Ambient fallback: keep the sky alive even without clicks. this.autoLaunchTimer = new Timer(autoLaunchInterval, true); @@ -112,17 +196,22 @@ class FireworksScene extends Scene { title: 'Fireworks', controls: [{ keys: 'Click', action: 'launch a rocket' }], status: 'Launched: 0 · in flight: 0', - hint: 'Each rocket rises and bursts at its apex. Rockets also auto-launch every couple of seconds.', + hint: 'Three layers per burst: a short flash, sparks that brake and fall, and slow embers that linger.', }); + + this.launchRocket(width * 0.5); } + // Application input outlives the scene, so the handler is removed in destroy(). + private readonly onPointerDown = (_pointer: unknown, x: number): void => this.launchRocket(x); + private launchRocket(x: number): void { - const color = fireworkColors[(random.next(0, fireworkColors.length - 1) + 0.5) | 0]; + const color = fireworkColors[Math.floor(random.next(0, fireworkColors.length)) % fireworkColors.length]; const sprite = new Sprite(this.rocketTexture); - sprite.setAnchor(0.5).setBlendMode(BlendModes.Additive).setTint(color).setScale(0.7); + sprite.setAnchor(0.5).setBlendMode(BlendModes.Additive).setTint(color).setScale(0.09); - const position = new Vector(x, this.canvasSize.height); + const position = new Vector(x, this.canvasSize.height + 10); sprite.setPosition(position.x, position.y); @@ -131,6 +220,7 @@ class FireworksScene extends Scene { position, velocityY: -random.next(launchSpeedMin, launchSpeedMax), color, + trail: [], }); this.launchCount++; @@ -139,8 +229,13 @@ class FireworksScene extends Scene { private detonate(rocket: Rocket): void { this.burstPosition.set(rocket.position.x, rocket.position.y); - this.burst.config.tint = new Constant(rocket.color); - this.burst.reset(); + this.sparkBurst.config.tint = new Constant(rocket.color); + this.flashBurst.config.tint = new Constant( + new Color(Math.round(128 + rocket.color.r / 2), Math.round(128 + rocket.color.g / 2), Math.round(128 + rocket.color.b / 2)), + ); + this.flashBurst.reset(); + this.sparkBurst.reset(); + this.emberBurst.reset(); } private updateStatus(): void { @@ -148,10 +243,8 @@ class FireworksScene extends Scene { } override update(delta: Seconds): void { - const dt = delta; - if (this.autoLaunchTimer.expired) { - this.launchRocket(random.next(80, this.canvasSize.width - 80)); + this.launchRocket(random.next(120, this.canvasSize.width - 120)); this.autoLaunchTimer.restart(); } @@ -160,11 +253,16 @@ class FireworksScene extends Scene { for (let i = this.rockets.length - 1; i >= 0; i--) { const rocket = this.rockets[i]; - rocket.velocityY += rocketGravity * dt; - rocket.position.y += rocket.velocityY * dt; + rocket.velocityY += rocketGravity * delta; + rocket.position.y += rocket.velocityY * delta; rocket.sprite.setPosition(rocket.position.x, rocket.position.y); + rocket.trail.push(rocket.position.x, rocket.position.y); - if (rocket.velocityY >= 0 || rocket.position.y <= 0) { + if (rocket.trail.length > trailLength * 2) { + rocket.trail.splice(0, 2); + } + + if (rocket.velocityY >= 0 || rocket.position.y <= 40) { this.detonate(rocket); rocket.sprite.destroy(); this.rockets.splice(i, 1); @@ -174,12 +272,44 @@ class FireworksScene extends Scene { } override draw(context: RenderingContext): void { - context.render(this.explosions); + // Each rocket leaves a short fading streak; older segments are fainter. + const trails = this.trails.clear(); + + trails.lineWidth = 2; + + for (const rocket of this.rockets) { + const points = rocket.trail.length / 2; + + for (let i = 1; i < points; i++) { + const alpha = (i / points) * 0.7; + + trails.lineColor = new Color(rocket.color.r, rocket.color.g, rocket.color.b, alpha); + trails.drawLine(rocket.trail[(i - 1) * 2], rocket.trail[(i - 1) * 2 + 1], rocket.trail[i * 2], rocket.trail[i * 2 + 1]); + } + } + + context.render(trails); + + for (const layer of this.layers) { + context.render(layer); + } for (const rocket of this.rockets) { context.render(rocket.sprite); } } + + override destroy(): void { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); + this.trails.destroy(); + + for (const rocket of this.rockets) { + rocket.sprite.destroy(); + } + + super.destroy(); + } } const app = new Application({ @@ -190,7 +320,7 @@ const app = new Application({ mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, + clearColor: new Color(4, 6, 14), extensions: [particlesExtension], }); From 950387f9df0fc2fcc0336c2230de5cd344d70757 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:27:32 +0200 Subject: [PATCH 10/16] feat(examples): compare texture sampling modes side by side --- examples/examples.json | 2 +- examples/sprites-textures/texture-sampling.js | 165 +++++++++++----- examples/sprites-textures/texture-sampling.ts | 177 +++++++++++++----- .../guide/rendering/pixel-snapping.mdx | 2 +- 4 files changed, 243 insertions(+), 103 deletions(-) diff --git a/examples/examples.json b/examples/examples.json index f5718856e..e376203f0 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -304,7 +304,7 @@ "path": "sprites-textures/texture-sampling.js", "language": "typescript", "title": "Texture Sampling and Pixel Snapping", - "description": "Compare nearest, linear and mipmapped sampling against a fixed reference while zooming and toggling render-only pixel snapping.", + "description": "Compare nearest, linear and mipmapped sampling side by side on a magnified and a minified texture, and toggle render-only pixel snapping.", "backend": "core", "tags": ["sprite", "texture", "sampling", "pixel-snap"], "capabilities": ["keyboard", "pointer"], diff --git a/examples/sprites-textures/texture-sampling.js b/examples/sprites-textures/texture-sampling.js index a36590d40..5e9f79096 100644 --- a/examples/sprites-textures/texture-sampling.js +++ b/examples/sprites-textures/texture-sampling.js @@ -11,16 +11,20 @@ import { Text, Texture, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // Colour textures are stored as sRGB and filtered in linear light, so the mips -// of the checker average to the correct mid-grey rather than a darkened one. +// of a checker average to the correct mid-grey rather than a darkened one. // Data textures (`colorSpace: 'none'`) filter their raw numeric values instead. const MODES = [ { label: 'Nearest', scaleMode: ScaleModes.Nearest, mipmaps: false }, { label: 'Linear', scaleMode: ScaleModes.Linear, mipmaps: false }, { label: 'Linear + mipmaps', scaleMode: ScaleModes.LinearMipmapLinear, mipmaps: true }, ]; -const makePattern = () => { +const COLUMN_X = [250, 520, 790]; +const MAGNIFIED_Y = 230; +const MINIFIED_Y = 490; +// Small pixel-art tile: magnifying it shows how each mode fills in between texels. +const makePixelArt = () => { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; @@ -35,81 +39,142 @@ const makePattern = () => { ctx.fillRect(9, 9, 14, 14); return canvas; }; +// Zone plate: rings whose spacing shrinks towards the edge until it is finer +// than a pixel. Drawn smaller than its size, everything past that point +// aliases into false patterns unless mipmaps pre-average it. +const makeZonePlate = () => { + const size = 256; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(size, size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const dx = x - size / 2; + const dy = y - size / 2; + // Ring frequency grows with the radius but stays below the texture's own + // Nyquist limit, so the source itself is alias-free. + const v = 0.5 + 0.5 * Math.cos((Math.PI * (dx * dx + dy * dy)) / (2 * size)); + const i = (y * size + x) * 4; + image.data[i] = 40 + v * 215; + image.data[i + 1] = 60 + v * 190; + image.data[i + 2] = 90 + v * 150; + image.data[i + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + return canvas; +}; class TextureSamplingScene extends Scene { - reference; - sample; - referenceTexture; - sampleTexture; - referenceLabel; - sampleLabel; + textures = []; + magnified = []; + minified = []; hud; - mode = 0; - zoom = 8; + panel; + zoom = 6; + minify = 0.35; snap = false; + moving = true; elapsed = 0; onWheel = (_deltaX, deltaY) => { - this.zoom = Math.max(0.3, Math.min(12, this.zoom * Math.exp(-deltaY * 0.002))); + this.zoom = Math.max(1, Math.min(8, this.zoom * Math.exp(-deltaY * 0.002))); + this.zoomControl.set(this.zoom); this.updateHud(); }; + zoomControl; + snapControl; init() { - const source = makePattern(); - this.referenceTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); - this.sampleTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); - this.reference = new Sprite(this.referenceTexture).setAnchor(0.5); - this.sample = new Sprite(this.sampleTexture).setAnchor(0.5); - this.reference.pixelSnapMode = PixelSnapMode.Geometry; - this.referenceLabel = new Text('Reference: nearest + snap', { fillColor: Color.white, fontSize: 20 }); - this.sampleLabel = new Text('', { fillColor: Color.white, fontSize: 20 }); - this.referenceLabel.setPosition(this.app.width * 0.18, this.app.height * 0.78); - this.sampleLabel.setPosition(this.app.width * 0.6, this.app.height * 0.78); + const pixelArt = makePixelArt(); + const zonePlate = makeZonePlate(); + // One texture per mode and pattern: every column draws the same source + // with the same transform, so the sampler is the only difference. + for (const [column, mode] of MODES.entries()) { + const magnifiedTexture = new Texture(pixelArt, { scaleMode: mode.scaleMode, generateMipMap: mode.mipmaps }); + const minifiedTexture = new Texture(zonePlate, { scaleMode: mode.scaleMode, generateMipMap: mode.mipmaps }); + this.textures.push(magnifiedTexture, minifiedTexture); + this.magnified.push(new Sprite(magnifiedTexture).setAnchor(0.5)); + this.minified.push(new Sprite(minifiedTexture).setAnchor(0.5)); + this.root.addChild( + this.magnified[column], + this.minified[column], + new Text(mode.label, { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5, 0.5) + .setPosition(COLUMN_X[column], 46), + ); + } this.hud = mountControls({ title: 'Texture Sampling and Pixel Snapping', controls: [ - { keys: 'Space', action: 'cycle sample filtering' }, - { keys: 'P', action: 'toggle sample snapping' }, - { keys: 'Wheel', action: 'zoom both images' }, + { keys: 'Wheel', action: 'magnify the top row' }, + { keys: 'P', action: 'toggle pixel snapping' }, + { keys: 'Space', action: 'pause the motion' }, ], - hint: 'Compare the right image with the fixed nearest-filtered reference while zooming in and out.', + hint: 'Top row magnified, bottom row minified. Same texture, transform and time in every column; only the sampler differs.', + corner: 'top-right', }); - this.inputs.onTrigger(Keyboard.Space, () => this.selectMode((this.mode + 1) % MODES.length)); - this.inputs.onTrigger(Keyboard.P, () => { - this.snap = !this.snap; - this.sample.pixelSnapMode = this.snap ? PixelSnapMode.Geometry : PixelSnapMode.None; - this.updateHud(); + this.panel = mountControlPanel({ title: 'Sampling lab', corner: 'bottom-right' }); + this.zoomControl = this.panel.addSlider({ + label: 'Magnify', + min: 1, + max: 8, + step: 0.1, + value: this.zoom, + onChange: value => { + this.zoom = value; + this.updateHud(); + }, }); + this.panel.addSlider({ + label: 'Minify', + min: 0.15, + max: 0.7, + step: 0.01, + value: this.minify, + onChange: value => { + this.minify = value; + this.updateHud(); + }, + }); + this.snapControl = this.panel.addToggle({ label: 'Pixel snap', value: this.snap, onChange: value => this.setSnap(value) }); + this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => (this.moving = value) }); + this.inputs.onTrigger(Keyboard.P, () => this.setSnap(!this.snap)); + this.inputs.onTrigger(Keyboard.Space, () => (this.moving = !this.moving)); this.app.input.onMouseWheel.add(this.onWheel); this.updateHud(); } - selectMode(index) { - this.mode = index; - this.sampleTexture.setScaleMode(MODES[index].scaleMode); - this.sampleTexture.setGenerateMipMap(MODES[index].mipmaps); + setSnap(snap) { + this.snap = snap; + this.snapControl.set(snap); + for (const sprite of this.magnified) { + sprite.pixelSnapMode = snap ? PixelSnapMode.Geometry : PixelSnapMode.None; + } this.updateHud(); } updateHud() { - this.sampleLabel.text = `Sample: ${MODES[this.mode].label}${this.snap ? ' + snap' : ''}`; - this.hud.setStatus(`Zoom ${this.zoom.toFixed(2)}x · ${MODES[this.mode].label} · snap ${this.snap ? 'on' : 'off'}`); + this.hud.setStatus(`Magnify ${this.zoom.toFixed(1)}x · minify ${this.minify.toFixed(2)}x · snap ${this.snap ? 'on' : 'off'}`); } update(delta) { - this.elapsed += delta; + if (this.moving) { + this.elapsed += delta; + } + // A slow sub-pixel drift shows snapping in the top row; a gentle scale + // pulse makes aliasing crawl in the bottom row. const drift = Math.sin(this.elapsed * 1.8) * 0.45; - this.reference.setPosition(this.app.width * 0.3 + drift, this.app.height * 0.54 + drift); - this.sample.setPosition(this.app.width * 0.7 + drift, this.app.height * 0.54 + drift); - this.reference.setScale(this.zoom); - this.sample.setScale(this.zoom); - } - draw(context) { - context.render(this.reference); - context.render(this.sample); - context.render(this.referenceLabel); - context.render(this.sampleLabel); + const pulse = 1 + Math.sin(this.elapsed * 0.7) * 0.12; + for (let column = 0; column < MODES.length; column++) { + this.magnified[column].setPosition(COLUMN_X[column] + drift, MAGNIFIED_Y + drift).setScale(this.zoom); + this.minified[column].setPosition(COLUMN_X[column], MINIFIED_Y).setScale(this.minify * pulse); + } } destroy() { this.app.input.onMouseWheel.remove(this.onWheel); + this.panel.dispose(); this.hud.dispose(); super.destroy(); - this.referenceTexture.destroy(); - this.sampleTexture.destroy(); + for (const texture of this.textures) { + texture.destroy(); + } } } const app = new Application({ diff --git a/examples/sprites-textures/texture-sampling.ts b/examples/sprites-textures/texture-sampling.ts index 8d91bab42..a9f7e6366 100644 --- a/examples/sprites-textures/texture-sampling.ts +++ b/examples/sprites-textures/texture-sampling.ts @@ -4,7 +4,6 @@ import { FixedResolutionCanvasSizing, Keyboard, PixelSnapMode, - type RenderingContext, ScaleModes, Scene, type Seconds, @@ -12,18 +11,22 @@ import { Text, Texture, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { type ControlBinding, mountControlPanel, mountControls } from '@examples/runtime'; // Colour textures are stored as sRGB and filtered in linear light, so the mips -// of the checker average to the correct mid-grey rather than a darkened one. +// of a checker average to the correct mid-grey rather than a darkened one. // Data textures (`colorSpace: 'none'`) filter their raw numeric values instead. const MODES = [ { label: 'Nearest', scaleMode: ScaleModes.Nearest, mipmaps: false }, { label: 'Linear', scaleMode: ScaleModes.Linear, mipmaps: false }, { label: 'Linear + mipmaps', scaleMode: ScaleModes.LinearMipmapLinear, mipmaps: true }, ] as const; +const COLUMN_X = [250, 520, 790]; +const MAGNIFIED_Y = 230; +const MINIFIED_Y = 490; -const makePattern = (): HTMLCanvasElement => { +// Small pixel-art tile: magnifying it shows how each mode fills in between texels. +const makePixelArt = (): HTMLCanvasElement => { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; @@ -42,88 +45,160 @@ const makePattern = (): HTMLCanvasElement => { return canvas; }; +// Zone plate: rings whose spacing shrinks towards the edge until it is finer +// than a pixel. Drawn smaller than its size, everything past that point +// aliases into false patterns unless mipmaps pre-average it. +const makeZonePlate = (): HTMLCanvasElement => { + const size = 256; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const image = ctx.createImageData(size, size); + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const dx = x - size / 2; + const dy = y - size / 2; + // Ring frequency grows with the radius but stays below the texture's own + // Nyquist limit, so the source itself is alias-free. + const v = 0.5 + 0.5 * Math.cos((Math.PI * (dx * dx + dy * dy)) / (2 * size)); + const i = (y * size + x) * 4; + image.data[i] = 40 + v * 215; + image.data[i + 1] = 60 + v * 190; + image.data[i + 2] = 90 + v * 150; + image.data[i + 3] = 255; + } + } + + ctx.putImageData(image, 0, 0); + + return canvas; +}; + class TextureSamplingScene extends Scene { - private reference!: Sprite; - private sample!: Sprite; - private referenceTexture!: Texture; - private sampleTexture!: Texture; - private referenceLabel!: Text; - private sampleLabel!: Text; + private textures: Texture[] = []; + private magnified: Sprite[] = []; + private minified: Sprite[] = []; private hud!: ReturnType; - private mode = 0; - private zoom = 8; + private panel!: ReturnType; + private zoom = 6; + private minify = 0.35; private snap = false; + private moving = true; private elapsed = 0; private readonly onWheel = (_deltaX: number, deltaY: number): void => { - this.zoom = Math.max(0.3, Math.min(12, this.zoom * Math.exp(-deltaY * 0.002))); + this.zoom = Math.max(1, Math.min(8, this.zoom * Math.exp(-deltaY * 0.002))); + this.zoomControl.set(this.zoom); this.updateHud(); }; + private zoomControl!: ControlBinding; + private snapControl!: ControlBinding; override init(): void { - const source = makePattern(); - this.referenceTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); - this.sampleTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); - this.reference = new Sprite(this.referenceTexture).setAnchor(0.5); - this.sample = new Sprite(this.sampleTexture).setAnchor(0.5); - this.reference.pixelSnapMode = PixelSnapMode.Geometry; - this.referenceLabel = new Text('Reference: nearest + snap', { fillColor: Color.white, fontSize: 20 }); - this.sampleLabel = new Text('', { fillColor: Color.white, fontSize: 20 }); - this.referenceLabel.setPosition(this.app.width * 0.18, this.app.height * 0.78); - this.sampleLabel.setPosition(this.app.width * 0.6, this.app.height * 0.78); + const pixelArt = makePixelArt(); + const zonePlate = makeZonePlate(); + + // One texture per mode and pattern: every column draws the same source + // with the same transform, so the sampler is the only difference. + for (const [column, mode] of MODES.entries()) { + const magnifiedTexture = new Texture(pixelArt, { scaleMode: mode.scaleMode, generateMipMap: mode.mipmaps }); + const minifiedTexture = new Texture(zonePlate, { scaleMode: mode.scaleMode, generateMipMap: mode.mipmaps }); + + this.textures.push(magnifiedTexture, minifiedTexture); + this.magnified.push(new Sprite(magnifiedTexture).setAnchor(0.5)); + this.minified.push(new Sprite(minifiedTexture).setAnchor(0.5)); + this.root.addChild( + this.magnified[column], + this.minified[column], + new Text(mode.label, { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5, 0.5) + .setPosition(COLUMN_X[column], 46), + ); + } this.hud = mountControls({ title: 'Texture Sampling and Pixel Snapping', controls: [ - { keys: 'Space', action: 'cycle sample filtering' }, - { keys: 'P', action: 'toggle sample snapping' }, - { keys: 'Wheel', action: 'zoom both images' }, + { keys: 'Wheel', action: 'magnify the top row' }, + { keys: 'P', action: 'toggle pixel snapping' }, + { keys: 'Space', action: 'pause the motion' }, ], - hint: 'Compare the right image with the fixed nearest-filtered reference while zooming in and out.', + hint: 'Top row magnified, bottom row minified. Same texture, transform and time in every column; only the sampler differs.', + corner: 'top-right', + }); + this.panel = mountControlPanel({ title: 'Sampling lab', corner: 'bottom-right' }); + this.zoomControl = this.panel.addSlider({ + label: 'Magnify', + min: 1, + max: 8, + step: 0.1, + value: this.zoom, + onChange: value => { + this.zoom = value; + this.updateHud(); + }, }); - this.inputs.onTrigger(Keyboard.Space, () => this.selectMode((this.mode + 1) % MODES.length)); - this.inputs.onTrigger(Keyboard.P, () => { - this.snap = !this.snap; - this.sample.pixelSnapMode = this.snap ? PixelSnapMode.Geometry : PixelSnapMode.None; - this.updateHud(); + this.panel.addSlider({ + label: 'Minify', + min: 0.15, + max: 0.7, + step: 0.01, + value: this.minify, + onChange: value => { + this.minify = value; + this.updateHud(); + }, }); + this.snapControl = this.panel.addToggle({ label: 'Pixel snap', value: this.snap, onChange: value => this.setSnap(value) }); + this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => (this.moving = value) }); + + this.inputs.onTrigger(Keyboard.P, () => this.setSnap(!this.snap)); + this.inputs.onTrigger(Keyboard.Space, () => (this.moving = !this.moving)); this.app.input.onMouseWheel.add(this.onWheel); this.updateHud(); } - private selectMode(index: number): void { - this.mode = index; - this.sampleTexture.setScaleMode(MODES[index].scaleMode); - this.sampleTexture.setGenerateMipMap(MODES[index].mipmaps); + private setSnap(snap: boolean): void { + this.snap = snap; + this.snapControl.set(snap); + + for (const sprite of this.magnified) { + sprite.pixelSnapMode = snap ? PixelSnapMode.Geometry : PixelSnapMode.None; + } + this.updateHud(); } private updateHud(): void { - this.sampleLabel.text = `Sample: ${MODES[this.mode].label}${this.snap ? ' + snap' : ''}`; - this.hud.setStatus(`Zoom ${this.zoom.toFixed(2)}x · ${MODES[this.mode].label} · snap ${this.snap ? 'on' : 'off'}`); + this.hud.setStatus(`Magnify ${this.zoom.toFixed(1)}x · minify ${this.minify.toFixed(2)}x · snap ${this.snap ? 'on' : 'off'}`); } override update(delta: Seconds): void { - this.elapsed += delta; + if (this.moving) { + this.elapsed += delta; + } + + // A slow sub-pixel drift shows snapping in the top row; a gentle scale + // pulse makes aliasing crawl in the bottom row. const drift = Math.sin(this.elapsed * 1.8) * 0.45; - this.reference.setPosition(this.app.width * 0.3 + drift, this.app.height * 0.54 + drift); - this.sample.setPosition(this.app.width * 0.7 + drift, this.app.height * 0.54 + drift); - this.reference.setScale(this.zoom); - this.sample.setScale(this.zoom); - } + const pulse = 1 + Math.sin(this.elapsed * 0.7) * 0.12; - override draw(context: RenderingContext): void { - context.render(this.reference); - context.render(this.sample); - context.render(this.referenceLabel); - context.render(this.sampleLabel); + for (let column = 0; column < MODES.length; column++) { + this.magnified[column].setPosition(COLUMN_X[column] + drift, MAGNIFIED_Y + drift).setScale(this.zoom); + this.minified[column].setPosition(COLUMN_X[column], MINIFIED_Y).setScale(this.minify * pulse); + } } override destroy(): void { this.app.input.onMouseWheel.remove(this.onWheel); + this.panel.dispose(); this.hud.dispose(); super.destroy(); - this.referenceTexture.destroy(); - this.sampleTexture.destroy(); + + for (const texture of this.textures) { + texture.destroy(); + } } } diff --git a/site/src/content/guide/rendering/pixel-snapping.mdx b/site/src/content/guide/rendering/pixel-snapping.mdx index ad469ddfc..8bab50bfb 100644 --- a/site/src/content/guide/rendering/pixel-snapping.mdx +++ b/site/src/content/guide/rendering/pixel-snapping.mdx @@ -103,6 +103,6 @@ Chunk origins snap on the shared device grid, and a chunk's internal tile geomet -Compare texture sampling modes and render-only snapping against a fixed reference while changing zoom. +Compare nearest, linear and mipmapped sampling side by side while magnifying and minifying the same texture, and toggle render-only snapping on the magnified row. The next section, [Effects](/ExoJS/en/guide/effects/filters/), builds directly on the rendering chapters — filters, particles, and post-processing chains that layer mood and motion onto a scene. From 297167dc91022a7de2f9018f40cb7c1ac87274eb Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:27:40 +0200 Subject: [PATCH 11/16] feat(examples): show focus, hover and pressed states on example controls --- examples/shared/runtime.js | 38 ++++++++++++++++++++++++++++++++ examples/shared/runtime.ts | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/examples/shared/runtime.js b/examples/shared/runtime.js index 20ab246b4..097f961fb 100644 --- a/examples/shared/runtime.js +++ b/examples/shared/runtime.js @@ -149,6 +149,26 @@ export const mountControls = (options = {}) => { }, }; }; +const CONTROL_CLASS = 'exo-example-control'; +// Hover, pressed and keyboard-focus states need pseudo-classes, which inline +// styles cannot express, so one shared rule set is injected on first use. +const ensureControlStyles = () => { + if (document.getElementById(`${CONTROL_CLASS}-styles`)) { + return; + } + const style = document.createElement('style'); + style.id = `${CONTROL_CLASS}-styles`; + style.textContent = ` +.${CONTROL_CLASS} { transition: filter 120ms ease; } +.${CONTROL_CLASS}:hover { filter: brightness(1.3); } +.${CONTROL_CLASS}:active { filter: brightness(1.55); } +.${CONTROL_CLASS}:focus-visible { outline: 2px solid #9fd0ff; outline-offset: 2px; } +.${CONTROL_CLASS}:disabled { opacity: 0.45; cursor: not-allowed; filter: none; } +input[type='range'].${CONTROL_CLASS} { accent-color: #7fb8ff; } +@media (prefers-reduced-motion: reduce) { .${CONTROL_CLASS} { transition: none; } } +`; + document.head.append(style); +}; const createControlRowContainer = () => { const row = document.createElement('div'); Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '10px', margin: '6px 0' }); @@ -168,8 +188,13 @@ const createControlLabel = text => { export const mountControlPanel = (options = {}) => { const { title = '', corner = 'bottom-left' } = options; const panel = createPanel(corner); + ensureControlStyles(); panel.style.pointerEvents = 'auto'; panel.style.minWidth = '230px'; + panel.setAttribute('role', 'group'); + if (title) { + panel.setAttribute('aria-label', title); + } if (title) { const titleEl = document.createElement('strong'); titleEl.textContent = title; @@ -186,6 +211,8 @@ export const mountControlPanel = (options = {}) => { const input = document.createElement('input'); const readout = document.createElement('span'); input.type = 'range'; + input.className = CONTROL_CLASS; + input.setAttribute('aria-label', label); input.min = String(min); input.max = String(max); input.step = String(step); @@ -220,6 +247,9 @@ export const mountControlPanel = (options = {}) => { const row = createControlRowContainer(); const button = document.createElement('button'); let state = value; + button.type = 'button'; + button.className = CONTROL_CLASS; + button.setAttribute('aria-label', label); Object.assign(button.style, { flex: '1 1 auto', padding: '5px 10px', @@ -232,6 +262,7 @@ export const mountControlPanel = (options = {}) => { }); const render = () => { button.textContent = state ? 'On' : 'Off'; + button.setAttribute('aria-pressed', String(state)); button.style.background = state ? 'rgba(120, 220, 160, 0.22)' : 'rgba(154, 193, 255, 0.12)'; }; render(); @@ -256,6 +287,8 @@ export const mountControlPanel = (options = {}) => { const readout = document.createElement('span'); let current = index; for (const button of [prev, next]) { + button.type = 'button'; + button.className = CONTROL_CLASS; Object.assign(button.style, { flex: '0 0 auto', width: '26px', @@ -270,6 +303,9 @@ export const mountControlPanel = (options = {}) => { } prev.textContent = '‹'; next.textContent = '›'; + prev.setAttribute('aria-label', `Previous ${label}`); + next.setAttribute('aria-label', `Next ${label}`); + readout.setAttribute('aria-live', 'polite'); Object.assign(readout.style, { flex: '1 1 auto', textAlign: 'center', color: '#9fd0ff', font: `600 12px ${FONT_STACK}` }); const render = () => { readout.textContent = String(choices[current]); @@ -293,6 +329,8 @@ export const mountControlPanel = (options = {}) => { }, addButton({ label, onClick }) { const button = document.createElement('button'); + button.type = 'button'; + button.className = CONTROL_CLASS; button.textContent = label; Object.assign(button.style, { display: 'block', diff --git a/examples/shared/runtime.ts b/examples/shared/runtime.ts index 10d5f985a..f9083a4ff 100644 --- a/examples/shared/runtime.ts +++ b/examples/shared/runtime.ts @@ -313,6 +313,30 @@ export const mountControls = (options: MountControlsOptions = {}): ControlsHandl }; }; +const CONTROL_CLASS = 'exo-example-control'; + +// Hover, pressed and keyboard-focus states need pseudo-classes, which inline +// styles cannot express, so one shared rule set is injected on first use. +const ensureControlStyles = (): void => { + if (document.getElementById(`${CONTROL_CLASS}-styles`)) { + return; + } + + const style = document.createElement('style'); + + style.id = `${CONTROL_CLASS}-styles`; + style.textContent = ` +.${CONTROL_CLASS} { transition: filter 120ms ease; } +.${CONTROL_CLASS}:hover { filter: brightness(1.3); } +.${CONTROL_CLASS}:active { filter: brightness(1.55); } +.${CONTROL_CLASS}:focus-visible { outline: 2px solid #9fd0ff; outline-offset: 2px; } +.${CONTROL_CLASS}:disabled { opacity: 0.45; cursor: not-allowed; filter: none; } +input[type='range'].${CONTROL_CLASS} { accent-color: #7fb8ff; } +@media (prefers-reduced-motion: reduce) { .${CONTROL_CLASS} { transition: none; } } +`; + document.head.append(style); +}; + const createControlRowContainer = (): HTMLElement => { const row = document.createElement('div'); @@ -341,9 +365,16 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr const panel = createPanel(corner); + ensureControlStyles(); panel.style.pointerEvents = 'auto'; panel.style.minWidth = '230px'; + panel.setAttribute('role', 'group'); + + if (title) { + panel.setAttribute('aria-label', title); + } + if (title) { const titleEl = document.createElement('strong'); @@ -367,6 +398,8 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr const readout = document.createElement('span'); input.type = 'range'; + input.className = CONTROL_CLASS; + input.setAttribute('aria-label', label); input.min = String(min); input.max = String(max); input.step = String(step); @@ -410,6 +443,9 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr let state = value; + button.type = 'button'; + button.className = CONTROL_CLASS; + button.setAttribute('aria-label', label); Object.assign(button.style, { flex: '1 1 auto', padding: '5px 10px', @@ -423,6 +459,7 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr const render = () => { button.textContent = state ? 'On' : 'Off'; + button.setAttribute('aria-pressed', String(state)); button.style.background = state ? 'rgba(120, 220, 160, 0.22)' : 'rgba(154, 193, 255, 0.12)'; }; @@ -453,6 +490,8 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr let current = index; for (const button of [prev, next]) { + button.type = 'button'; + button.className = CONTROL_CLASS; Object.assign(button.style, { flex: '0 0 auto', width: '26px', @@ -468,6 +507,9 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr prev.textContent = '‹'; next.textContent = '›'; + prev.setAttribute('aria-label', `Previous ${label}`); + next.setAttribute('aria-label', `Next ${label}`); + readout.setAttribute('aria-live', 'polite'); Object.assign(readout.style, { flex: '1 1 auto', textAlign: 'center', color: '#9fd0ff', font: `600 12px ${FONT_STACK}` }); @@ -499,6 +541,8 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr addButton({ label, onClick }) { const button = document.createElement('button'); + button.type = 'button'; + button.className = CONTROL_CLASS; button.textContent = label; Object.assign(button.style, { From 76492ed300ae3d91d4c6779ee1680bc2b68df535 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:27:47 +0200 Subject: [PATCH 12/16] test(site): capture examples at fixed times in the smoke run --- site/scripts/smoke-examples.ts | 40 ++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/site/scripts/smoke-examples.ts b/site/scripts/smoke-examples.ts index 345b1d2fb..74f501cd5 100644 --- a/site/scripts/smoke-examples.ts +++ b/site/scripts/smoke-examples.ts @@ -28,6 +28,9 @@ * ... --headed # force headed mode for any browser * ... --color-scheme dark # emulate dark-mode OS preference * ... --shots # write one PNG per example, pass or fail + * ... --shot-times 2,5,10 # with --shots: also capture each example at these + * # seconds after its canvas mounted (wall clock); + * # review evidence only, never part of the verdict * * Chromium (default): new headless, WebGPU via Dawn (SwiftShader backend). * WebGPU adapter is available without --use-angle=swiftshader, which would @@ -762,6 +765,7 @@ const runExample = async ( timeoutMs: number, attempt: 'first' | 'retry' = 'first', shotDir: string | null = null, + shotTimes: readonly number[] = [], ): Promise => { const capabilities = entry.capabilities ?? []; const result: Result = { @@ -836,6 +840,7 @@ const runExample = async ( await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); const previewFrame = await waitForPreviewCanvas(page, timeoutMs); + const mountedAt = Date.now(); // Let async load()/init() settle so deferred rejections surface too. Keep // observing during that window: transient examples may intentionally draw @@ -935,10 +940,35 @@ const runExample = async ( .boundingBox() .catch(() => null); const shot = box ? await page.screenshot({ clip: box }).catch(() => null) : null; + const shotBase = join(shotDir, entry.path.replaceAll('/', '__').replace(/\.js$/, '')); if (shot) { await mkdir(shotDir, { recursive: true }); - await writeFile(join(shotDir, `${entry.path.replaceAll('/', '__').replace(/\.js$/, '')}.png`), shot); + await writeFile(`${shotBase}.png`, shot); + } + + // Timed captures show how an example develops - a first frame cannot tell + // a settled effect from one that never started. Wall clock, not + // simulation frames: a loaded host captures fewer frames per second. + // They are evidence for review, not part of the verdict: a page that + // dies during the extra wait only ends the capture series. + if (box && previewFrame) { + try { + for (const seconds of shotTimes) { + const remaining = mountedAt + seconds * 1000 - Date.now(); + + if (remaining > 0) { + await page.waitForTimeout(remaining); + } + + const timed = await page.screenshot({ clip: box }); + + await mkdir(shotDir, { recursive: true }); + await writeFile(`${shotBase}@${String(seconds)}s.png`, timed); + } + } catch { + // Capture series ended early; the verdict above stands. + } } } } catch (error) { @@ -1006,6 +1036,7 @@ const main = async (): Promise => { headed: { type: 'boolean' }, // force headed (any browser) 'color-scheme': { type: 'string' }, // 'light' (default) | 'dark' shots: { type: 'string' }, // directory to write one capture per example into + 'shot-times': { type: 'string' }, // comma-separated seconds after mount for extra captures }, allowPositionals: false, }); @@ -1059,6 +1090,11 @@ const main = async (): Promise => { const requestedConcurrency = Math.max(1, Number.parseInt(values.concurrency ?? '', 10) || defaultConcurrency); const timeoutMs = Math.max(4000, Number.parseInt(values['timeout-ms'] ?? '15000', 10) || 15000); const shotDir = values.shots === undefined ? null : resolve(repoRoot, values.shots); + const shotTimes = (values['shot-times'] ?? '') + .split(',') + .map(value => Number.parseFloat(value)) + .filter(value => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); const { port, server } = await startServer(distDir); const baseUrl = `http://127.0.0.1:${port}`; @@ -1123,7 +1159,7 @@ const main = async (): Promise => { } const entry = entries[index]; - const result = await runExample(pool, baseUrl, entry, index, graphics, timeoutMs, 'first', shotDir); + const result = await runExample(pool, baseUrl, entry, index, graphics, timeoutMs, 'first', shotDir, shotTimes); results[index] = result; const tag = result.status.toUpperCase().padEnd(7); From 8aa4e360f5a9dbbdedd1c86d65132a8a080c5452 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:20 +0200 Subject: [PATCH 13/16] fix(examples): strike convolution pads on quick clicks --- examples/audio-fx/convolution-rooms.js | 32 +++++++++++++-------- examples/audio-fx/convolution-rooms.ts | 39 +++++++++++++++++--------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/examples/audio-fx/convolution-rooms.js b/examples/audio-fx/convolution-rooms.js index 1a9bb0fad..8a5089d43 100644 --- a/examples/audio-fx/convolution-rooms.js +++ b/examples/audio-fx/convolution-rooms.js @@ -114,18 +114,8 @@ class ConvolutionRoomsScene extends Scene { new Text(pad.label, { fillColor: Color.white, fontSize: 20 }).setAnchor(0.5, 0.5).setPosition(pad.x + pad.w / 2, pad.y + pad.h + 26), ); this.root.addChild(this.gfx, this.label, this.detail, this.tapPrompt, ...materialLabels); - this.app.input.onPointerDown.add(pointer => { - if (!(pointer.buttons & 1)) { - return; - } - for (const pad of this.pads) { - if (pointer.x >= pad.x && pointer.x <= pad.x + pad.w && pointer.y >= pad.y && pointer.y <= pad.y + pad.h) { - this.strike(pad); - return; - } - } - }); - this.app.input.onContextMenu.add(() => this.select(this.index + 1)); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onContextMenu.add(this.onContextMenu); this.hud = mountControls({ title: 'Convolution rooms', controls: [ @@ -143,6 +133,22 @@ class ConvolutionRoomsScene extends Scene { }); this.select(0); } + // Application input outlives the scene, so both handlers are removed in destroy(). + onPointerDown = (pointer, x, y) => { + // Presses are delivered at the next frame while `buttons` is live, so a + // quick click may already read as released. Only a held right button is + // skipped; right-click switches rooms instead. + if (pointer.buttons & 2) { + return; + } + for (const pad of this.pads) { + if (x >= pad.x && x <= pad.x + pad.w && y >= pad.y && y <= pad.y + pad.h) { + this.strike(pad); + return; + } + } + }; + onContextMenu = () => this.select(this.index + 1); /** Swap the impulse response. The handle heals in place, so the effect picks it up once decoded. */ select(next) { this.index = (next + ROOMS.length) % ROOMS.length; @@ -190,6 +196,8 @@ class ConvolutionRoomsScene extends Scene { context.render(this.root); } destroy() { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onContextMenu.remove(this.onContextMenu); this.app.audio.sound.removeEffect(this.convolution); this.convolution.destroy(); this.panel.dispose(); diff --git a/examples/audio-fx/convolution-rooms.ts b/examples/audio-fx/convolution-rooms.ts index 6b549bd73..1c9c979f3 100644 --- a/examples/audio-fx/convolution-rooms.ts +++ b/examples/audio-fx/convolution-rooms.ts @@ -3,6 +3,7 @@ import { Color, FixedResolutionCanvasSizing, Graphics, + type Pointer, type RenderingContext, Scene, type Seconds, @@ -153,20 +154,8 @@ class ConvolutionRoomsScene extends Scene { this.root.addChild(this.gfx, this.label, this.detail, this.tapPrompt, ...materialLabels); - this.app.input.onPointerDown.add(pointer => { - if (!(pointer.buttons & 1)) { - return; - } - - for (const pad of this.pads) { - if (pointer.x >= pad.x && pointer.x <= pad.x + pad.w && pointer.y >= pad.y && pointer.y <= pad.y + pad.h) { - this.strike(pad); - - return; - } - } - }); - this.app.input.onContextMenu.add(() => this.select(this.index + 1)); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onContextMenu.add(this.onContextMenu); this.hud = mountControls({ title: 'Convolution rooms', @@ -188,6 +177,26 @@ class ConvolutionRoomsScene extends Scene { this.select(0); } + // Application input outlives the scene, so both handlers are removed in destroy(). + private readonly onPointerDown = (pointer: Pointer, x: number, y: number): void => { + // Presses are delivered at the next frame while `buttons` is live, so a + // quick click may already read as released. Only a held right button is + // skipped; right-click switches rooms instead. + if (pointer.buttons & 2) { + return; + } + + for (const pad of this.pads) { + if (x >= pad.x && x <= pad.x + pad.w && y >= pad.y && y <= pad.y + pad.h) { + this.strike(pad); + + return; + } + } + }; + + private readonly onContextMenu = (): void => this.select(this.index + 1); + /** Swap the impulse response. The handle heals in place, so the effect picks it up once decoded. */ private select(next: number): void { this.index = (next + ROOMS.length) % ROOMS.length; @@ -247,6 +256,8 @@ class ConvolutionRoomsScene extends Scene { } override destroy(): void { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onContextMenu.remove(this.onContextMenu); this.app.audio.sound.removeEffect(this.convolution); this.convolution.destroy(); this.panel.dispose(); From ad7a397d2c89a69d44c19691a778eb84c1f70f3a Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:26:32 +0200 Subject: [PATCH 14/16] fix(examples): keep the texture sampling motion toggle in step with the space key --- examples/sprites-textures/texture-sampling.js | 9 +++++++-- examples/sprites-textures/texture-sampling.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/examples/sprites-textures/texture-sampling.js b/examples/sprites-textures/texture-sampling.js index 5e9f79096..92905c252 100644 --- a/examples/sprites-textures/texture-sampling.js +++ b/examples/sprites-textures/texture-sampling.js @@ -84,6 +84,7 @@ class TextureSamplingScene extends Scene { }; zoomControl; snapControl; + motionControl; init() { const pixelArt = makePixelArt(); const zonePlate = makeZonePlate(); @@ -137,12 +138,16 @@ class TextureSamplingScene extends Scene { }, }); this.snapControl = this.panel.addToggle({ label: 'Pixel snap', value: this.snap, onChange: value => this.setSnap(value) }); - this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => (this.moving = value) }); + this.motionControl = this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => this.setMoving(value) }); this.inputs.onTrigger(Keyboard.P, () => this.setSnap(!this.snap)); - this.inputs.onTrigger(Keyboard.Space, () => (this.moving = !this.moving)); + this.inputs.onTrigger(Keyboard.Space, () => this.setMoving(!this.moving)); this.app.input.onMouseWheel.add(this.onWheel); this.updateHud(); } + setMoving(moving) { + this.moving = moving; + this.motionControl.set(moving); + } setSnap(snap) { this.snap = snap; this.snapControl.set(snap); diff --git a/examples/sprites-textures/texture-sampling.ts b/examples/sprites-textures/texture-sampling.ts index a9f7e6366..6721287e2 100644 --- a/examples/sprites-textures/texture-sampling.ts +++ b/examples/sprites-textures/texture-sampling.ts @@ -94,6 +94,7 @@ class TextureSamplingScene extends Scene { }; private zoomControl!: ControlBinding; private snapControl!: ControlBinding; + private motionControl!: ControlBinding; override init(): void { const pixelArt = makePixelArt(); @@ -151,14 +152,19 @@ class TextureSamplingScene extends Scene { }, }); this.snapControl = this.panel.addToggle({ label: 'Pixel snap', value: this.snap, onChange: value => this.setSnap(value) }); - this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => (this.moving = value) }); + this.motionControl = this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => this.setMoving(value) }); this.inputs.onTrigger(Keyboard.P, () => this.setSnap(!this.snap)); - this.inputs.onTrigger(Keyboard.Space, () => (this.moving = !this.moving)); + this.inputs.onTrigger(Keyboard.Space, () => this.setMoving(!this.moving)); this.app.input.onMouseWheel.add(this.onWheel); this.updateHud(); } + private setMoving(moving: boolean): void { + this.moving = moving; + this.motionControl.set(moving); + } + private setSnap(snap: boolean): void { this.snap = snap; this.snapControl.set(snap); From ef7fb09ddd331d01737c0947ac19b7d8e4da4a41 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:26:40 +0200 Subject: [PATCH 15/16] fix(examples): keep a quickly released right click from striking a convolution pad --- examples/audio-fx/convolution-rooms.js | 16 ++++-- examples/audio-fx/convolution-rooms.ts | 17 +++++-- examples/shared/runtime.d.ts | 18 +++++++ examples/shared/runtime.js | 34 +++++++++++++ examples/shared/runtime.ts | 51 +++++++++++++++++++ test/examples/press-buttons.test.ts | 69 ++++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 test/examples/press-buttons.test.ts diff --git a/examples/audio-fx/convolution-rooms.js b/examples/audio-fx/convolution-rooms.js index 8a5089d43..adccf42b9 100644 --- a/examples/audio-fx/convolution-rooms.js +++ b/examples/audio-fx/convolution-rooms.js @@ -1,7 +1,7 @@ // Auto-generated from convolution-rooms.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls, trackPressButtons } from '@examples/runtime'; // Every impulse response in the AK-SROOMS set, shortest tail first. The // duration is what actually decides the character: a few milliseconds only // colours the sound, ~30-150 ms reads as a cupboard or a shaft, and past @@ -79,6 +79,7 @@ class ConvolutionRoomsScene extends Scene { tapPrompt; hud; panel; + pressButtons = null; roomControl; init() { const { width, height } = this.app; @@ -114,6 +115,9 @@ class ConvolutionRoomsScene extends Scene { new Text(pad.label, { fillColor: Color.white, fontSize: 20 }).setAnchor(0.5, 0.5).setPosition(pad.x + pad.w / 2, pad.y + pad.h + 26), ); this.root.addChild(this.gfx, this.label, this.detail, this.tapPrompt, ...materialLabels); + if (this.app.canvas instanceof HTMLCanvasElement) { + this.pressButtons = trackPressButtons(this.app.canvas); + } this.app.input.onPointerDown.add(this.onPointerDown); this.app.input.onContextMenu.add(this.onContextMenu); this.hud = mountControls({ @@ -135,10 +139,11 @@ class ConvolutionRoomsScene extends Scene { } // Application input outlives the scene, so both handlers are removed in destroy(). onPointerDown = (pointer, x, y) => { - // Presses are delivered at the next frame while `buttons` is live, so a - // quick click may already read as released. Only a held right button is - // skipped; right-click switches rooms instead. - if (pointer.buttons & 2) { + // Presses are delivered at the next frame, when `pointer.buttons` already + // reads the live state, so a right click released before then looks like a + // main click. The button recorded at press time decides: right-click + // switches rooms instead of striking. + if ((this.pressButtons?.take(pointer.id) ?? 0) !== 0) { return; } for (const pad of this.pads) { @@ -198,6 +203,7 @@ class ConvolutionRoomsScene extends Scene { destroy() { this.app.input.onPointerDown.remove(this.onPointerDown); this.app.input.onContextMenu.remove(this.onContextMenu); + this.pressButtons?.dispose(); this.app.audio.sound.removeEffect(this.convolution); this.convolution.destroy(); this.panel.dispose(); diff --git a/examples/audio-fx/convolution-rooms.ts b/examples/audio-fx/convolution-rooms.ts index 1c9c979f3..2638a89e7 100644 --- a/examples/audio-fx/convolution-rooms.ts +++ b/examples/audio-fx/convolution-rooms.ts @@ -11,7 +11,7 @@ import { Text, } from '@codexo/exojs'; import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { type ControlBinding, mountControlPanel, mountControls } from '@examples/runtime'; +import { type ControlBinding, mountControlPanel, mountControls, type PressButtonTracker, trackPressButtons } from '@examples/runtime'; // Every impulse response in the AK-SROOMS set, shortest tail first. The // duration is what actually decides the character: a few milliseconds only @@ -110,6 +110,7 @@ class ConvolutionRoomsScene extends Scene { private tapPrompt!: Text; private hud!: ReturnType; private panel!: ReturnType; + private pressButtons: PressButtonTracker | null = null; private roomControl!: ControlBinding; override init(): void { @@ -154,6 +155,10 @@ class ConvolutionRoomsScene extends Scene { this.root.addChild(this.gfx, this.label, this.detail, this.tapPrompt, ...materialLabels); + if (this.app.canvas instanceof HTMLCanvasElement) { + this.pressButtons = trackPressButtons(this.app.canvas); + } + this.app.input.onPointerDown.add(this.onPointerDown); this.app.input.onContextMenu.add(this.onContextMenu); @@ -179,10 +184,11 @@ class ConvolutionRoomsScene extends Scene { // Application input outlives the scene, so both handlers are removed in destroy(). private readonly onPointerDown = (pointer: Pointer, x: number, y: number): void => { - // Presses are delivered at the next frame while `buttons` is live, so a - // quick click may already read as released. Only a held right button is - // skipped; right-click switches rooms instead. - if (pointer.buttons & 2) { + // Presses are delivered at the next frame, when `pointer.buttons` already + // reads the live state, so a right click released before then looks like a + // main click. The button recorded at press time decides: right-click + // switches rooms instead of striking. + if ((this.pressButtons?.take(pointer.id) ?? 0) !== 0) { return; } @@ -258,6 +264,7 @@ class ConvolutionRoomsScene extends Scene { override destroy(): void { this.app.input.onPointerDown.remove(this.onPointerDown); this.app.input.onContextMenu.remove(this.onContextMenu); + this.pressButtons?.dispose(); this.app.audio.sound.removeEffect(this.convolution); this.convolution.destroy(); this.panel.dispose(); diff --git a/examples/shared/runtime.d.ts b/examples/shared/runtime.d.ts index 41700cfb8..1b5d45a35 100644 --- a/examples/shared/runtime.d.ts +++ b/examples/shared/runtime.d.ts @@ -117,6 +117,24 @@ export declare const formatErrorMessage: (error: unknown) => string; * the status/controls and to remove the panel. */ export declare const mountControls: (options?: MountControlsOptions) => ControlsHandle; +/** Per-pointer record of which button started each press, in press order. */ +export interface PressButtonTracker { + /** + * Button of the oldest press by `pointerId` not yet taken: 0 for the main + * button, touch contact or pen contact, 1 auxiliary, 2 secondary. 0 when + * nothing is recorded, so an unknown press is treated as a main press. + */ + take(pointerId: number): number; + dispose(): void; +} +/** + * Records `PointerEvent.button` for every press on `canvas` at the moment it + * happens. Engine pointer signals are delivered at the next frame, when + * `Pointer.buttons` already reads the live state, so a press released before + * delivery cannot be told apart from a main press there; `take` hands the + * recorded button back to the signal handler in the order the presses occurred. + */ +export declare const trackPressButtons: (canvas: HTMLCanvasElement) => PressButtonTracker; /** * Mount a predictable DOM control panel over the canvas - sliders, toggles, * cycles, and buttons - so interactive examples expose their parameters in a diff --git a/examples/shared/runtime.js b/examples/shared/runtime.js index 097f961fb..b850f4d39 100644 --- a/examples/shared/runtime.js +++ b/examples/shared/runtime.js @@ -149,6 +149,40 @@ export const mountControls = (options = {}) => { }, }; }; +/** + * Records `PointerEvent.button` for every press on `canvas` at the moment it + * happens. Engine pointer signals are delivered at the next frame, when + * `Pointer.buttons` already reads the live state, so a press released before + * delivery cannot be told apart from a main press there; `take` hands the + * recorded button back to the signal handler in the order the presses occurred. + */ +export const trackPressButtons = canvas => { + const queues = new Map(); + const root = canvas.ownerDocument; + const onDown = event => { + if (event.target !== canvas) { + return; + } + const { pointerId, button } = event; + const queue = queues.get(pointerId); + if (queue) { + queue.push(button); + } else { + queues.set(pointerId, [button]); + } + }; + // The engine listens on the canvas in the capture phase and stops immediate + // propagation of the presses it handles, so a listener on the canvas itself + // never sees them; the document's capture phase runs before it. + root.addEventListener('pointerdown', onDown, { capture: true }); + return { + take: pointerId => queues.get(pointerId)?.shift() ?? 0, + dispose: () => { + root.removeEventListener('pointerdown', onDown, { capture: true }); + queues.clear(); + }, + }; +}; const CONTROL_CLASS = 'exo-example-control'; // Hover, pressed and keyboard-focus states need pseudo-classes, which inline // styles cannot express, so one shared rule set is injected on first use. diff --git a/examples/shared/runtime.ts b/examples/shared/runtime.ts index f9083a4ff..1a0b51c5b 100644 --- a/examples/shared/runtime.ts +++ b/examples/shared/runtime.ts @@ -313,6 +313,57 @@ export const mountControls = (options: MountControlsOptions = {}): ControlsHandl }; }; +/** Per-pointer record of which button started each press, in press order. */ +export interface PressButtonTracker { + /** + * Button of the oldest press by `pointerId` not yet taken: 0 for the main + * button, touch contact or pen contact, 1 auxiliary, 2 secondary. 0 when + * nothing is recorded, so an unknown press is treated as a main press. + */ + take(pointerId: number): number; + dispose(): void; +} + +/** + * Records `PointerEvent.button` for every press on `canvas` at the moment it + * happens. Engine pointer signals are delivered at the next frame, when + * `Pointer.buttons` already reads the live state, so a press released before + * delivery cannot be told apart from a main press there; `take` hands the + * recorded button back to the signal handler in the order the presses occurred. + */ +export const trackPressButtons = (canvas: HTMLCanvasElement): PressButtonTracker => { + const queues = new Map(); + const root = canvas.ownerDocument; + + const onDown = (event: Event): void => { + if (event.target !== canvas) { + return; + } + + const { pointerId, button } = event as PointerEvent; + const queue = queues.get(pointerId); + + if (queue) { + queue.push(button); + } else { + queues.set(pointerId, [button]); + } + }; + + // The engine listens on the canvas in the capture phase and stops immediate + // propagation of the presses it handles, so a listener on the canvas itself + // never sees them; the document's capture phase runs before it. + root.addEventListener('pointerdown', onDown, { capture: true }); + + return { + take: pointerId => queues.get(pointerId)?.shift() ?? 0, + dispose: () => { + root.removeEventListener('pointerdown', onDown, { capture: true }); + queues.clear(); + }, + }; +}; + const CONTROL_CLASS = 'exo-example-control'; // Hover, pressed and keyboard-focus states need pseudo-classes, which inline diff --git a/test/examples/press-buttons.test.ts b/test/examples/press-buttons.test.ts new file mode 100644 index 000000000..a716a1d7d --- /dev/null +++ b/test/examples/press-buttons.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; + +import { trackPressButtons } from '../../examples/shared/runtime'; + +const press = (target: EventTarget, pointerId: number, button: number): void => { + target.dispatchEvent(Object.assign(new Event('pointerdown', { bubbles: true }), { pointerId, button })); +}; + +const mountCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + + document.body.append(canvas); + + return canvas; +}; + +describe('trackPressButtons', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('hands each press its own button in press order, per pointer', () => { + const canvas = mountCanvas(); + const tracker = trackPressButtons(canvas); + + press(canvas, 1, 2); + press(canvas, 1, 0); + press(canvas, 7, 1); + + expect(tracker.take(1)).toBe(2); + expect(tracker.take(1)).toBe(0); + expect(tracker.take(7)).toBe(1); + }); + + it('still sees presses a canvas listener consumes before any later listener', () => { + const canvas = mountCanvas(); + const tracker = trackPressButtons(canvas); + + canvas.addEventListener('pointerdown', event => event.stopImmediatePropagation(), { capture: true }); + press(canvas, 1, 2); + + expect(tracker.take(1)).toBe(2); + }); + + it('ignores presses elsewhere in the document', () => { + const canvas = mountCanvas(); + const tracker = trackPressButtons(canvas); + + press(document.body, 1, 2); + + expect(tracker.take(1)).toBe(0); + }); + + it('treats a press nobody recorded as a main press', () => { + expect(trackPressButtons(mountCanvas()).take(3)).toBe(0); + }); + + it('stops recording and drops pending presses on dispose', () => { + const canvas = mountCanvas(); + const tracker = trackPressButtons(canvas); + + press(canvas, 1, 2); + tracker.dispose(); + press(canvas, 1, 2); + + expect(tracker.take(1)).toBe(0); + }); +}); From c62545544f32b14565501d4315d3b99a9abc9d58 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:26:52 +0200 Subject: [PATCH 16/16] test(site): record when each timed capture was actually taken --- site/scripts/smoke-examples.ts | 65 ++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/site/scripts/smoke-examples.ts b/site/scripts/smoke-examples.ts index 74f501cd5..3a506153c 100644 --- a/site/scripts/smoke-examples.ts +++ b/site/scripts/smoke-examples.ts @@ -29,8 +29,10 @@ * ... --color-scheme dark # emulate dark-mode OS preference * ... --shots # write one PNG per example, pass or fail * ... --shot-times 2,5,10 # with --shots: also capture each example at these - * # seconds after its canvas mounted (wall clock); - * # review evidence only, never part of the verdict + * # seconds after its canvas mounted (monotonic clock); + * # .captures.json records the observed times and + * # any missing capture. Review evidence only, never + * # part of the verdict * * Chromium (default): new headless, WebGPU via Dawn (SwiftShader backend). * WebGPU adapter is available without --use-angle=swiftshader, which would @@ -51,7 +53,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; import { availableParallelism } from 'node:os'; -import { dirname, extname, join, resolve } from 'node:path'; +import { basename, dirname, extname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; @@ -840,7 +842,7 @@ const runExample = async ( await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); const previewFrame = await waitForPreviewCanvas(page, timeoutMs); - const mountedAt = Date.now(); + const mountedAt = performance.now(); // Let async load()/init() settle so deferred rejections surface too. Keep // observing during that window: transient examples may intentionally draw @@ -948,27 +950,60 @@ const runExample = async ( } // Timed captures show how an example develops - a first frame cannot tell - // a settled effect from one that never started. Wall clock, not - // simulation frames: a loaded host captures fewer frames per second. - // They are evidence for review, not part of the verdict: a page that - // dies during the extra wait only ends the capture series. - if (box && previewFrame) { - try { - for (const seconds of shotTimes) { - const remaining = mountedAt + seconds * 1000 - Date.now(); + // a settled effect from one that never started. Monotonic wall clock, not + // simulation frames: a loaded host captures fewer frames per second, and + // the settle checks above can run past an early request. The sidecar + // records when each capture was actually taken so file names are never + // read as exact times. Evidence for review, not part of the verdict: a + // page that dies during the extra wait ends the series, and the sidecar + // lists what is missing. + if (box && previewFrame && shotTimes.length > 0) { + const captures: Array<{ + requestedSeconds: number; + observedSeconds: number | null; + latenessSeconds: number | null; + file: string | null; + error?: string; + }> = []; + + for (const seconds of shotTimes) { + const file = `${shotBase}@${String(seconds)}s.png`; + + try { + const remaining = mountedAt + seconds * 1000 - performance.now(); if (remaining > 0) { await page.waitForTimeout(remaining); } const timed = await page.screenshot({ clip: box }); + const observedSeconds = (performance.now() - mountedAt) / 1000; await mkdir(shotDir, { recursive: true }); - await writeFile(`${shotBase}@${String(seconds)}s.png`, timed); + await writeFile(file, timed); + captures.push({ + requestedSeconds: seconds, + observedSeconds: Number(observedSeconds.toFixed(3)), + latenessSeconds: Number(Math.max(0, observedSeconds - seconds).toFixed(3)), + file: basename(file), + }); + } catch (error) { + captures.push({ + requestedSeconds: seconds, + observedSeconds: null, + latenessSeconds: null, + file: null, + error: error instanceof Error ? error.message : String(error), + }); } - } catch { - // Capture series ended early; the verdict above stands. } + + await mkdir(shotDir, { recursive: true }); + await writeFile( + `${shotBase}.captures.json`, + `${JSON.stringify({ example: entry.path, complete: captures.every(capture => capture.file !== null), captures }, null, 2)} +`, + ); } } } catch (error) {