diff --git a/public/og-image.jpg b/public/og-image.jpg new file mode 100644 index 0000000..98ae6a5 Binary files /dev/null and b/public/og-image.jpg differ diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 1ceeef7..6aeedc4 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,11 +1,9 @@ import { DEFAULT_AUDIO_VOLUME } from '../app-constants'; import type { PianoNoteRole } from './garden-audio-types'; -type GardenAudioChordQuality = 'major' | 'minor'; - export interface GardenAudioChord { rootOffset: number; - quality: GardenAudioChordQuality; + quality: 'major' | 'minor'; } export interface GardenAudioVibeSettings { diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 59aef48..3a0a592 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -14,10 +14,6 @@ export interface GardenAudioStroke { elapsedSeconds: number; } -export interface GardenAudioStartOptions { - userGesture?: boolean; -} - export interface LoadedPianoSample { midi: number; buffer: AudioBuffer; diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 54d730c..3f59886 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -7,11 +7,7 @@ import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGraph } from './garden-audio-graph'; import { getStrokeMetrics } from './garden-audio-input'; import { getVibeProfile } from './garden-audio-music'; -import type { - GardenAudioSnapshot, - GardenAudioStartOptions, - GardenAudioStroke, -} from './garden-audio-types'; +import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types'; import { GenerativePianoEngine } from './generative-piano'; import { NoiseBurstPlayer } from './noise-burst-player'; import { PianoSampler } from './piano-sampler'; @@ -53,7 +49,7 @@ export class GardenAudio { this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); } - public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { + public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void { const isUserGesture = options.userGesture === true; if (this.lifecycle === 'destroyed') { @@ -134,7 +130,7 @@ export class GardenAudio { } } - public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { + public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void { const previousVibeId = this.currentVibeId; this.start(vibe, options); const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id; diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 07bce8a..3ccb46c 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -46,20 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb type GardenAudioStyleIndex = 0 | 1 | 2; -interface RenderLookaheadRequest { - vibe: VibePreset; - now: number; - activity: number; - lookaheadSeconds?: number; -} - -interface StrokeAccentRequest { - vibe: VibePreset; - now: number; - activity: number; - maniaAmount?: number; -} - interface TouchDownRequest { vibe: VibePreset; now: number; @@ -118,10 +104,6 @@ export class GenerativePianoEngine { private readonly playNote: (note: PianoNote) => void ) {} - private get generation(): typeof generativePianoTuning { - return generativePianoTuning; - } - public prime(now: number, profile: GardenAudioVibeProfile): void { this.activeProfile = profile; this.timelineStartedAt ??= now; @@ -162,7 +144,7 @@ export class GenerativePianoEngine { this.brushPhraseLayers = []; this.brushStreamNoteCountsByBar.clear(); - return releaseStart + this.generation.releaseResolution.fadeAfterSeconds; + return releaseStart + generativePianoTuning.releaseResolution.fadeAfterSeconds; } private recordTouchDown({ @@ -198,7 +180,12 @@ export class GenerativePianoEngine { now, activity, maniaAmount = 0, - }: StrokeAccentRequest): void { + }: { + vibe: VibePreset; + now: number; + activity: number; + maniaAmount?: number; + }): void { const profile = getVibeProfile(vibe); this.prime(now, profile); const strength = clamp01(activity); @@ -206,12 +193,12 @@ export class GenerativePianoEngine { const styleIndex = this.getStyleIndex(now); const accentStep = this.getNextStepIndexAt( now, - this.generation.gestureAccent.quantizeStepLookahead + generativePianoTuning.gestureAccent.quantizeStepLookahead ); if ( this.isWaitingForGestureAccent && - now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds + now - this.lastGestureAccentAt >= generativePianoTuning.gestureAccentMinIntervalSeconds ) { this.recordTouchDown({ vibe, @@ -230,8 +217,8 @@ export class GenerativePianoEngine { maniaAmount: normalizedManiaAmount, }); if ( - strength >= this.generation.strokeAccentThreshold && - accentStep - this.lastStrokeAccentStep >= this.generation.strokeAccentMinSteps + strength >= generativePianoTuning.strokeAccentThreshold && + accentStep - this.lastStrokeAccentStep >= generativePianoTuning.strokeAccentMinSteps ) { this.lastStrokeAccentStep = accentStep; this.playGestureAccent(vibe, accentStep, styleIndex, strength); @@ -243,7 +230,12 @@ export class GenerativePianoEngine { now, activity, lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS, - }: RenderLookaheadRequest): void { + }: { + vibe: VibePreset; + now: number; + activity: number; + lookaheadSeconds?: number; + }): void { const profile = getVibeProfile(vibe); this.prime(now, profile); this.skipLateBeats(now); @@ -253,13 +245,14 @@ export class GenerativePianoEngine { } const lookaheadEnd = now + lookaheadSeconds; + const expression = this.getExpression(activity); while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) { const beatIndex = this.getBeatIndexForStep(this.nextBeatStep); this.renderBeat({ profile, beatIndex, startTime: this.getTimeForStep(this.nextBeatStep), - expression: this.getExpression(activity), + expression, }); this.nextBeatStep += this.config.rhythm.stepsPerBeat; } @@ -267,7 +260,7 @@ export class GenerativePianoEngine { vibe, now, lookaheadEnd, - activity, + activity: expression, }); } @@ -276,7 +269,7 @@ export class GenerativePianoEngine { const chord = this.getChord(profile, 0); const intervals = getChordIntervals(chord, true); const rootMidi = profile.rootMidi + chord.rootOffset; - const stinger = this.generation.vibeChangeStinger; + const stinger = generativePianoTuning.vibeChangeStinger; const offsetsByVoice: ReadonlyArray> = [ [0], [intervals[1], intervals[2]], @@ -286,7 +279,7 @@ export class GenerativePianoEngine { offsetsByVoice.forEach((offsets, index) => { const midi = this.chooseMidi( { baseMidi: rootMidi, offsets }, - this.generation.padRegisters[index] + generativePianoTuning.padRegisters[index] ); this.playProfileNote(profile, { midi, @@ -340,7 +333,7 @@ export class GenerativePianoEngine { const barIndex = Math.floor(beatIndex / beatsPerBar); const styleIndex = this.getStyleIndex(startTime); - if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) { + if (beatInBar === 0 && barIndex % generativePianoTuning.chordBars === 0) { this.playPadChord(profile, barIndex, startTime, expression); } @@ -349,21 +342,21 @@ export class GenerativePianoEngine { } if ( - beatInBar === this.generation.textureBeat && + beatInBar === generativePianoTuning.textureBeat && this.shouldPlayTexture(expression, barIndex) ) { this.playTextureNote(profile, barIndex, startTime, expression, styleIndex); } if ( - beatInBar === this.generation.highActivityExtraBeat && - expression >= this.generation.highActivityExtraThreshold + beatInBar === generativePianoTuning.highActivityExtraBeat && + expression >= generativePianoTuning.highActivityExtraThreshold ) { this.playTextureNote( profile, - barIndex + this.generation.highActivityExtra.barOffset, + barIndex + generativePianoTuning.highActivityExtra.barOffset, startTime, - expression * this.generation.highActivityExtra.expressionMultiplier, + expression * generativePianoTuning.highActivityExtra.expressionMultiplier, styleIndex ); } @@ -380,23 +373,23 @@ export class GenerativePianoEngine { const rootMidi = profile.rootMidi + chord.rootOffset; const durationSeconds = this.getBarDurationSeconds() * - this.generation.chordBars * - this.generation.padDurationBarScale; + generativePianoTuning.chordBars * + generativePianoTuning.padDurationBarScale; const notes = [ { source: { baseMidi: rootMidi, offsets: [0] }, - register: this.generation.padRegisters[0], - velocity: this.generation.padChord.velocities[0], + register: generativePianoTuning.padRegisters[0], + velocity: generativePianoTuning.padChord.velocities[0], }, { source: { baseMidi: rootMidi, offsets: [intervals[1]] }, - register: this.generation.padRegisters[1], - velocity: this.generation.padChord.velocities[1], + register: generativePianoTuning.padRegisters[1], + velocity: generativePianoTuning.padChord.velocities[1], }, { source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, - register: this.generation.padRegisters[2], - velocity: this.generation.padChord.velocities[2], + register: generativePianoTuning.padRegisters[2], + velocity: generativePianoTuning.padChord.velocities[2], }, ]; @@ -411,16 +404,16 @@ export class GenerativePianoEngine { this.playProfileNote(profile, { midi, velocity: - velocity + expression * this.generation.padChord.expressionVelocityWeight, + velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight, startTime, durationSeconds, pan: register.pan, role: 'pad', - delaySend: this.generation.padChord.delaySend, + delaySend: generativePianoTuning.padChord.delaySend, lowpassHz: this.getLowpassHz( profile, midi, - expression * this.generation.padChord.lowpassExpressionWeight + expression * generativePianoTuning.padChord.lowpassExpressionWeight ), }); }); @@ -434,7 +427,7 @@ export class GenerativePianoEngine { const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); const intervals = getChordIntervals(chord, true); const rootMidi = profile.rootMidi + chord.rootOffset; - const release = this.generation.releaseResolution; + const release = generativePianoTuning.releaseResolution; const offsetsByVoice: ReadonlyArray> = [ [0], [intervals[1], intervals[2]], @@ -442,7 +435,7 @@ export class GenerativePianoEngine { ]; offsetsByVoice.forEach((offsets, index) => { - const register = this.generation.padRegisters[index]; + const register = generativePianoTuning.padRegisters[index]; const midi = this.chooseMidi( { baseMidi: rootMidi, offsets }, register, @@ -469,7 +462,7 @@ export class GenerativePianoEngine { expression: number, styleIndex: GardenAudioStyleIndex ): void { - const pool = this.generation.stylePools[styleIndex]; + const pool = generativePianoTuning.stylePools[styleIndex]; const chord = this.getChord(profile, barIndex); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -488,22 +481,22 @@ export class GenerativePianoEngine { this.playProfileNote(profile, { midi, velocity: - (this.generation.supportNote.velocityBase + - expression * this.generation.supportNote.velocityExpressionWeight) * + (generativePianoTuning.supportNote.velocityBase + + expression * generativePianoTuning.supportNote.velocityExpressionWeight) * styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds: - this.generation.supportNote.durationBaseSeconds + - expression * this.generation.supportNote.durationExpressionSeconds, + generativePianoTuning.supportNote.durationBaseSeconds + + expression * generativePianoTuning.supportNote.durationExpressionSeconds, pan: this.getStylePan(styleIndex), role: 'support', delaySend: - this.generation.supportNote.delaySendBase + - expression * this.generation.supportNote.delaySendExpressionWeight, + generativePianoTuning.supportNote.delaySendBase + + expression * generativePianoTuning.supportNote.delaySendExpressionWeight, lowpassHz: this.getLowpassHz( profile, midi, - expression * this.generation.supportNote.lowpassExpressionWeight + expression * generativePianoTuning.supportNote.lowpassExpressionWeight ), }); } @@ -515,7 +508,7 @@ export class GenerativePianoEngine { expression: number, styleIndex: GardenAudioStyleIndex ): void { - const pool = this.generation.stylePools[styleIndex]; + const pool = generativePianoTuning.stylePools[styleIndex]; const chord = this.getChord(profile, barIndex); const chordIntervals = getChordIntervals(chord, false); const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex); @@ -534,18 +527,18 @@ export class GenerativePianoEngine { this.playProfileNote(profile, { midi, velocity: - (this.generation.textureNote.velocityBase + - expression * this.generation.textureNote.velocityExpressionWeight) * + (generativePianoTuning.textureNote.velocityBase + + expression * generativePianoTuning.textureNote.velocityExpressionWeight) * styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds: - this.generation.textureNote.durationBaseSeconds + - expression * this.generation.textureNote.durationExpressionSeconds, + generativePianoTuning.textureNote.durationBaseSeconds + + expression * generativePianoTuning.textureNote.durationExpressionSeconds, pan: this.getStylePan(styleIndex), role: 'texture', delaySend: - this.generation.textureNote.delaySendBase + - expression * this.generation.textureNote.delaySendExpressionWeight, + generativePianoTuning.textureNote.delaySendBase + + expression * generativePianoTuning.textureNote.delaySendExpressionWeight, lowpassHz: this.getLowpassHz(profile, midi, expression), }); } @@ -557,13 +550,13 @@ export class GenerativePianoEngine { strength: number ): void { const profile = getVibeProfile(vibe); - const pool = this.generation.stylePools[styleIndex]; + const pool = generativePianoTuning.stylePools[styleIndex]; const startTime = this.getTimeForStep(stepIndex); const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); const chordIntervals = getChordIntervals(chord, false); const degrees = this.rotate( pool.scaleDegrees, - Math.round(strength * this.generation.gestureAccent.rotationStrengthMultiplier) + Math.round(strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier) ); const midi = this.chooseMidi( @@ -581,16 +574,16 @@ export class GenerativePianoEngine { this.playProfileNote(profile, { midi, velocity: - (this.generation.gestureAccent.velocityBase + - strength * this.generation.gestureAccent.velocityStrengthWeight) * + (generativePianoTuning.gestureAccent.velocityBase + + strength * generativePianoTuning.gestureAccent.velocityStrengthWeight) * styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds: - this.generation.gestureAccent.durationBaseSeconds + - strength * this.generation.gestureAccent.durationStrengthSeconds, + generativePianoTuning.gestureAccent.durationBaseSeconds + + strength * generativePianoTuning.gestureAccent.durationStrengthSeconds, pan: this.getStylePan(styleIndex), role: 'gesture', - delaySend: this.generation.gestureAccent.delaySend, + delaySend: generativePianoTuning.gestureAccent.delaySend, lowpassHz: this.getLowpassHz(profile, midi, strength), }); } @@ -607,7 +600,7 @@ export class GenerativePianoEngine { strength: number; }): void { const profile = getVibeProfile(vibe); - const pool = this.generation.stylePools[styleIndex]; + const pool = generativePianoTuning.stylePools[styleIndex]; const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -626,22 +619,22 @@ export class GenerativePianoEngine { this.playProfileNote(profile, { midi, velocity: - (this.generation.touchNote.velocityBase + - strength * this.generation.touchNote.velocityStrengthWeight) * + (generativePianoTuning.touchNote.velocityBase + + strength * generativePianoTuning.touchNote.velocityStrengthWeight) * styleVoices[styleIndex].velocityMultiplier, startTime: now, durationSeconds: - this.generation.touchNote.durationBaseSeconds + - strength * this.generation.touchNote.durationStrengthSeconds, + generativePianoTuning.touchNote.durationBaseSeconds + + strength * generativePianoTuning.touchNote.durationStrengthSeconds, pan: this.getStylePan(styleIndex), role: 'gesture', - delaySend: this.generation.touchNote.delaySend, + delaySend: generativePianoTuning.touchNote.delaySend, lowpassHz: this.getLowpassHz( profile, midi, clamp01( - this.generation.touchNote.lowpassBaseExpression + - strength * this.generation.touchNote.lowpassStrengthWeight + generativePianoTuning.touchNote.lowpassBaseExpression + + strength * generativePianoTuning.touchNote.lowpassStrengthWeight ) ), }); @@ -661,8 +654,8 @@ export class GenerativePianoEngine { maniaAmount: number; }): void { const lifetimeSeconds = - this.generation.brushLayerBaseSeconds + - strength * this.generation.brushLayerEnergySeconds; + generativePianoTuning.brushLayerBaseSeconds + + strength * generativePianoTuning.brushLayerEnergySeconds; const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds); this.brushPhraseLayers.push({ @@ -672,13 +665,13 @@ export class GenerativePianoEngine { expiresAt, styleIndex, energy: strength, - motifOffsets: [styleIndex + this.generation.brushPhrase.initialMotifOffset], + motifOffsets: [styleIndex + generativePianoTuning.brushPhrase.initialMotifOffset], maniaAmount, }); - if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) { + if (this.brushPhraseLayers.length > generativePianoTuning.maxBrushPhraseLayers) { this.brushPhraseLayers = this.brushPhraseLayers.slice( - -this.generation.maxBrushPhraseLayers + -generativePianoTuning.maxBrushPhraseLayers ); } } @@ -704,17 +697,17 @@ export class GenerativePianoEngine { layer.styleIndex = styleIndex; layer.energy = Math.max( layer.energy * - Math.exp(-elapsedSeconds / this.generation.brushPhrase.energyDecaySeconds), + Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.energyDecaySeconds), strength ); layer.maniaAmount = Math.max( layer.maniaAmount * - Math.exp(-elapsedSeconds / this.generation.brushPhrase.maniaDecaySeconds), + Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.maniaDecaySeconds), maniaAmount ); layer.motifOffsets.push(this.getMotifOffset(strength)); - if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) { - layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps); + if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) { + layer.motifOffsets = layer.motifOffsets.slice(-generativePianoTuning.brushMotifMaxSteps); } } @@ -750,7 +743,7 @@ export class GenerativePianoEngine { const startTime = this.getTimeForStep(this.nextBrushStreamStep); const frame = this.getBrushStreamFrame(startTime, activity); if ( - frame.intensity >= this.generation.brushLayerMinIntensity && + frame.intensity >= generativePianoTuning.brushLayerMinIntensity && this.reserveBrushStreamNote(this.nextBrushStreamStep) ) { this.playBrushStreamNote({ @@ -783,22 +776,22 @@ export class GenerativePianoEngine { layer: BrushPhraseLayer | null; }): void { const profile = getVibeProfile(vibe); - const pool = this.generation.stylePools[styleIndex]; + const pool = generativePianoTuning.stylePools[styleIndex]; const maniaAmount = layer?.maniaAmount ?? clamp01( - (intensity - this.generation.brushStream.inferredManiaThreshold) / - this.generation.brushStream.inferredManiaRange + (intensity - generativePianoTuning.brushStream.inferredManiaThreshold) / + generativePianoTuning.brushStream.inferredManiaRange ); const register = this.getBiasedRegister( pool, - maniaAmount * this.generation.brushStream.registerManiaShift + maniaAmount * generativePianoTuning.brushStream.registerManiaShift ); const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; const useChordTone = - this.brushStreamNoteIndex % this.generation.brushStream.chordToneEverySteps === 0; + this.brushStreamNoteIndex % generativePianoTuning.brushStream.chordToneEverySteps === 0; const source = useChordTone ? { baseMidi: rootMidi, @@ -817,18 +810,18 @@ export class GenerativePianoEngine { const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); const pan = this.getStylePan(styleIndex); const durationSeconds = clamp( - this.generation.brushStream.durationBaseSeconds + - intensity * this.generation.brushStream.durationIntensitySeconds - - maniaAmount * this.generation.brushStream.durationManiaSeconds, - this.generation.brushStream.durationMinSeconds, - this.generation.brushStream.durationMaxSeconds + generativePianoTuning.brushStream.durationBaseSeconds + + intensity * generativePianoTuning.brushStream.durationIntensitySeconds - + maniaAmount * generativePianoTuning.brushStream.durationManiaSeconds, + generativePianoTuning.brushStream.durationMinSeconds, + generativePianoTuning.brushStream.durationMaxSeconds ); const delaySend = clamp( - this.generation.brushStream.delaySendBase + - intensity * this.generation.brushStream.delaySendIntensityWeight - - maniaAmount * this.generation.brushStream.delaySendManiaWeight, - this.generation.brushStream.delaySendMin, - this.generation.brushStream.delaySendMax + generativePianoTuning.brushStream.delaySendBase + + intensity * generativePianoTuning.brushStream.delaySendIntensityWeight - + maniaAmount * generativePianoTuning.brushStream.delaySendManiaWeight, + generativePianoTuning.brushStream.delaySendMin, + generativePianoTuning.brushStream.delaySendMax ); this.lastBrushStreamMidi = midi; @@ -836,8 +829,8 @@ export class GenerativePianoEngine { this.playProfileNote(profile, { midi, velocity: - (this.generation.brushStream.velocityBase + - intensity * this.generation.brushStream.velocityIntensityWeight) * + (generativePianoTuning.brushStream.velocityBase + + intensity * generativePianoTuning.brushStream.velocityIntensityWeight) * styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds, @@ -848,46 +841,46 @@ export class GenerativePianoEngine { profile, midi, clamp01( - this.generation.brushStream.lowpassBaseExpression + - intensity * this.generation.brushStream.lowpassIntensityWeight + - maniaAmount * this.generation.brushStream.lowpassManiaWeight + generativePianoTuning.brushStream.lowpassBaseExpression + + intensity * generativePianoTuning.brushStream.lowpassIntensityWeight + + maniaAmount * generativePianoTuning.brushStream.lowpassManiaWeight ) ), }); if ( - maniaAmount >= this.generation.brushStreamEcho.maniaThreshold && - (this.brushStreamNoteIndex % this.generation.brushStreamEcho.stepModulo === - this.generation.brushStreamEcho.stepRemainder || - intensity >= this.generation.brushStreamEcho.intensityThreshold) + maniaAmount >= generativePianoTuning.brushStreamEcho.maniaThreshold && + (this.brushStreamNoteIndex % generativePianoTuning.brushStreamEcho.stepModulo === + generativePianoTuning.brushStreamEcho.stepRemainder || + intensity >= generativePianoTuning.brushStreamEcho.intensityThreshold) ) { const echoMidi = - midi + this.generation.brushStreamEcho.octaveSemitones <= - this.generation.brushStreamEcho.maxMidi - ? midi + this.generation.brushStreamEcho.octaveSemitones - : midi - this.generation.brushStreamEcho.octaveSemitones; + midi + generativePianoTuning.brushStreamEcho.octaveSemitones <= + generativePianoTuning.brushStreamEcho.maxMidi + ? midi + generativePianoTuning.brushStreamEcho.octaveSemitones + : midi - generativePianoTuning.brushStreamEcho.octaveSemitones; this.playProfileNote(profile, { midi: echoMidi, velocity: - (this.generation.brushStreamEcho.velocityBase + - intensity * this.generation.brushStreamEcho.velocityIntensityWeight) * + (generativePianoTuning.brushStreamEcho.velocityBase + + intensity * generativePianoTuning.brushStreamEcho.velocityIntensityWeight) * styleVoices[styleIndex].velocityMultiplier, - startTime: startTime + this.generation.brushMotifCanonDelaySeconds, + startTime: startTime + generativePianoTuning.brushMotifCanonDelaySeconds, durationSeconds: Math.max( - this.generation.brushStreamEcho.durationMinSeconds, - durationSeconds * this.generation.brushStreamEcho.durationScale + generativePianoTuning.brushStreamEcho.durationMinSeconds, + durationSeconds * generativePianoTuning.brushStreamEcho.durationScale ), - pan: clamp(pan * this.generation.brushStreamEcho.panScale, -1, 1), + pan: clamp(pan * generativePianoTuning.brushStreamEcho.panScale, -1, 1), role: 'brush', delaySend: Math.max( - this.generation.brushStreamEcho.delaySendMin, - delaySend * this.generation.brushStreamEcho.delaySendScale + generativePianoTuning.brushStreamEcho.delaySendMin, + delaySend * generativePianoTuning.brushStreamEcho.delaySendScale ), lowpassHz: this.getLowpassHz( profile, echoMidi, - this.generation.brushStreamEcho.lowpassBaseExpression + - maniaAmount * this.generation.brushStreamEcho.lowpassManiaWeight + generativePianoTuning.brushStreamEcho.lowpassBaseExpression + + maniaAmount * generativePianoTuning.brushStreamEcho.lowpassManiaWeight ), }); } @@ -905,8 +898,8 @@ export class GenerativePianoEngine { intensity: layer.energy * this.getBrushPhraseFade(layer, startTime) * - (this.generation.brushPhrase.layerIntensityBase + - layer.maniaAmount * this.generation.brushPhrase.layerIntensityManiaWeight), + (generativePianoTuning.brushPhrase.layerIntensityBase + + layer.maniaAmount * generativePianoTuning.brushPhrase.layerIntensityManiaWeight), })); const dominant = layerStates.reduce<{ layer: BrushPhraseLayer; @@ -924,10 +917,10 @@ export class GenerativePianoEngine { return { intensity: clamp01( - activity * this.generation.brushPhrase.frameActivityWeight + + activity * generativePianoTuning.brushPhrase.frameActivityWeight + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * - this.generation.brushPhrase.frameManiaWeight + generativePianoTuning.brushPhrase.frameManiaWeight ), layer: dominant?.layer ?? null, }; @@ -935,11 +928,11 @@ export class GenerativePianoEngine { private getBrushStreamIntervalSteps(intensity: number): number { const intervalBeats = - intensity >= this.generation.brushStream.intenseThreshold - ? this.generation.brushStreamIntenseIntervalBeats - : intensity >= this.generation.brushStream.activeThreshold - ? this.generation.brushStreamActiveIntervalBeats - : this.generation.brushStreamIdleIntervalBeats; + intensity >= generativePianoTuning.brushStream.intenseThreshold + ? generativePianoTuning.brushStreamIntenseIntervalBeats + : intensity >= generativePianoTuning.brushStream.activeThreshold + ? generativePianoTuning.brushStreamActiveIntervalBeats + : generativePianoTuning.brushStreamIdleIntervalBeats; return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat)); } @@ -950,11 +943,11 @@ export class GenerativePianoEngine { } private getMotifOffset(strength: number): number { - return strength >= this.generation.brushMotif.highThreshold - ? this.generation.brushMotif.highOffset - : strength >= this.generation.brushMotif.mediumThreshold - ? this.generation.brushMotif.mediumOffset - : this.generation.brushMotif.lowOffset; + return strength >= generativePianoTuning.brushMotif.highThreshold + ? generativePianoTuning.brushMotif.highOffset + : strength >= generativePianoTuning.brushMotif.mediumThreshold + ? generativePianoTuning.brushMotif.mediumOffset + : generativePianoTuning.brushMotif.lowOffset; } private getBrushMotifDegrees({ @@ -986,17 +979,17 @@ export class GenerativePianoEngine { maniaAmount: number ): GardenAudioRegister { const shift = Math.round( - maniaAmount * this.generation.registerBias.maniaShiftSemitones + maniaAmount * generativePianoTuning.registerBias.maniaShiftSemitones ); const midiMin = clamp( register.midiMin + shift, - this.generation.registerBias.midiMin, - this.generation.registerBias.midiMaxForMin + generativePianoTuning.registerBias.midiMin, + generativePianoTuning.registerBias.midiMaxForMin ); const midiMax = clamp( register.midiMax + shift, - midiMin + this.generation.registerBias.minimumSpan, - this.generation.registerBias.midiMax + midiMin + generativePianoTuning.registerBias.minimumSpan, + generativePianoTuning.registerBias.midiMax ); return { @@ -1039,8 +1032,8 @@ export class GenerativePianoEngine { pitchSource.offsets.forEach((offset, preference) => { for ( - let octave = this.generation.candidateOctaveSearch.min; - octave <= this.generation.candidateOctaveSearch.max; + let octave = generativePianoTuning.candidateOctaveSearch.min; + octave <= generativePianoTuning.candidateOctaveSearch.max; octave += 1 ) { const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE; @@ -1067,38 +1060,38 @@ export class GenerativePianoEngine { return ( Math.abs(candidate.midi - previousMidi) + Math.abs(candidate.midi - register.preferredMidi) * - this.generation.noteScoreRegisterWeight + - candidate.preference * this.generation.noteScorePreferenceWeight + - candidate.chordToneDistance * this.generation.noteScoreChordToneWeight + + generativePianoTuning.noteScoreRegisterWeight + + candidate.preference * generativePianoTuning.noteScorePreferenceWeight + + candidate.chordToneDistance * generativePianoTuning.noteScoreChordToneWeight + (avoidRepeat && candidate.midi === previousMidi - ? this.generation.noteScoreRepeatPenalty + ? generativePianoTuning.noteScoreRepeatPenalty : 0) ); } private shouldPlaySupport(expression: number, barIndex: number): boolean { - if (expression >= this.generation.supportNote.expressionThreshold) { + if (expression >= generativePianoTuning.supportNote.expressionThreshold) { return true; } return ( - barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset + barIndex % generativePianoTuning.supportBarSpacing === generativePianoTuning.supportBarOffset ); } private shouldPlayTexture(expression: number, barIndex: number): boolean { const spacing = - expression < this.generation.textureNote.idleExpressionThreshold - ? this.generation.idleTextureBarSpacing - : expression < this.generation.textureNote.mediumExpressionThreshold - ? this.generation.mediumTextureBarSpacing - : this.generation.textureNote.intenseSpacing; + expression < generativePianoTuning.textureNote.idleExpressionThreshold + ? generativePianoTuning.idleTextureBarSpacing + : expression < generativePianoTuning.textureNote.mediumExpressionThreshold + ? generativePianoTuning.mediumTextureBarSpacing + : generativePianoTuning.textureNote.intenseSpacing; return ( barIndex % spacing === - (spacing === this.generation.textureNote.intenseSpacing + (spacing === generativePianoTuning.textureNote.intenseSpacing ? 0 - : this.generation.textureNote.idlePhase) + : generativePianoTuning.textureNote.idlePhase) ); } @@ -1106,14 +1099,14 @@ export class GenerativePianoEngine { chordIntervals: ReadonlyArray, styleIndex: GardenAudioStyleIndex ): Array { - return this.generation.supportNote.offsetsByStyle[styleIndex].map((offset) => + return generativePianoTuning.supportNote.offsetsByStyle[styleIndex].map((offset) => getConfiguredChordOffset(chordIntervals, offset) ); } private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord { const progressionIndex = - Math.floor(barIndex / this.generation.chordBars) % profile.progression.length; + Math.floor(barIndex / generativePianoTuning.chordBars) % profile.progression.length; return profile.progression[progressionIndex]; } @@ -1122,17 +1115,17 @@ export class GenerativePianoEngine { } private getStyleIndex(startTime: number): GardenAudioStyleIndex { - const styleCount = this.generation.stylePools.length; - const rotationBars = Math.max(1, Math.round(this.generation.styleRotationBars)); + const styleCount = generativePianoTuning.stylePools.length; + const rotationBars = Math.max(1, Math.round(generativePianoTuning.styleRotationBars)); return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) % styleCount) as GardenAudioStyleIndex; } private getStylePan(styleIndex: GardenAudioStyleIndex): number { - const pool = this.generation.stylePools[styleIndex]; + const pool = generativePianoTuning.stylePools[styleIndex]; const styleVoice = styleVoices[styleIndex]; return clamp( - pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale, + pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale, -1, 1 ); @@ -1145,13 +1138,13 @@ export class GenerativePianoEngine { ): number { const midiLift = clamp01( - (midi - this.generation.lowpass.midiBase) / this.generation.lowpass.midiRange - ) * this.generation.lowpass.midiLiftHz; + (midi - generativePianoTuning.lowpass.midiBase) / generativePianoTuning.lowpass.midiRange + ) * generativePianoTuning.lowpass.midiLiftHz; return clamp( this.config.piano.lowpassHz * profile.brightness * - (this.generation.lowpass.expressionBase + - expression * this.generation.lowpass.expressionWeight) + + (generativePianoTuning.lowpass.expressionBase + + expression * generativePianoTuning.lowpass.expressionWeight) + midiLift, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz @@ -1174,14 +1167,14 @@ export class GenerativePianoEngine { } private getExpression(activity: number): number { - const liftedActivity = Math.max( - activity, - this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity - ); - return clamp01( - (liftedActivity - this.config.rhythm.sparseActivity) / + const activityExpression = clamp01( + (activity - this.config.rhythm.sparseActivity) / (1 - this.config.rhythm.sparseActivity) ); + const idleExpression = clamp01( + this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity + ); + return Math.max(activityExpression, idleExpression); } private getBeatDurationSeconds(): number { @@ -1263,7 +1256,7 @@ export class GenerativePianoEngine { private reserveBrushStreamNote(stepIndex: number): boolean { const barIndex = this.getBarIndexForStep(stepIndex); const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0; - if (noteCount >= this.generation.maxBrushStreamNotesPerBar) { + if (noteCount >= generativePianoTuning.maxBrushStreamNotesPerBar) { return false; } diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 57d6648..142fcda 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -88,10 +88,7 @@ export class PianoSampler { const sample = this.findNearestSample(midi); if (sample) { - const noteGainValue = Math.max( - pianoSamplerTuning.minGain, - this.config.piano.gain * noteVelocity - ); + const noteGainValue = this.computeNoteGain(noteVelocity); const sustainSeconds = profileSustainSeconds * (this.config.piano.sustainBase + @@ -143,9 +140,9 @@ export class PianoSampler { return; } - const noteGainValue = Math.max( - pianoSamplerTuning.minGain, - this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale + const noteGainValue = this.computeNoteGain( + noteVelocity, + pianoSamplerTuning.synthGainScale ); const releaseAt = scheduledStart + @@ -277,6 +274,13 @@ export class PianoSampler { ); } + private computeNoteGain(velocity: number, scale = 1): number { + return Math.max( + pianoSamplerTuning.minGain, + this.config.piano.gain * velocity * scale + ); + } + private findNearestSample(midi: number): LoadedPianoSample | null { if (this.samples.length === 0) { return null; diff --git a/src/config.ts b/src/config.ts index 5445408..7e8375c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,13 +5,10 @@ import { runtimeControls } from './config/runtime-controls'; import type { GardenAppConfig } from './config/types'; import { defaultVibeId, vibePresets } from './config/vibe-presets'; -export { VibeId } from './config/types'; - export type { GardenAppConfig, GardenRuntimeSettings, NumberControlConfig, - VibePreset, } from './config/types'; export const appConfig = { diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index 8252a14..0ca5d70 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -12,17 +12,15 @@ export const colorInteractionSettings = { color3ToColor3: 1, }; -const agentInteractionOptions: Record = { - Follow: 1, - Avoid: -1, - Ignore: 0, -}; - export const colorInteractionControl = (label: string): NumberControlConfig => ({ folder: 'Color Reactions', label, min: -1, max: 1, step: 1, - options: agentInteractionOptions, + options: { + Follow: 1, + Avoid: -1, + Ignore: 0, + }, }); diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index 39e1e55..77b3b36 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -34,7 +34,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { brushAlpha: 1, brushDiscardThreshold: 0.02, - brushCoarseNoiseScale: 160, brushGrainNoiseScale: 22, brushGrainNoiseOffsetX: 0.31, brushGrainNoiseOffsetY: 0.67, diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index eb3895b..4e6a429 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -1,6 +1,8 @@ import { colorInteractionControl } from './color-interactions'; import type { GardenAppConfig } from './types'; +const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; + export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { color1ToColor1: colorInteractionControl('1 -> 1'), color1ToColor2: colorInteractionControl('1 -> 2'), @@ -26,21 +28,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 1, step: 0.001, }, - brushSizeVariation: { - folder: 'Brush', - label: 'brush variance', - min: 0, - max: 1, - step: 0.01, - }, - diffusionRateBrush: { - folder: 'Brush', - label: 'brush diffusion', - min: 0.001, - max: 1, - step: 0.001, - }, - sensorOffsetDistance: { folder: 'Agents', label: 'sensor distance', @@ -62,6 +49,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 200, step: 1, }, + forwardRotationScale: { + folder: 'Agents', + format: formatPercent, + label: 'following sensor %', + min: 0, + max: 1, + step: 0.01, + }, + turnWhenLost: { + folder: 'Agents', + label: 'turn when lost', + min: 0, + max: 6.28, + step: 0.01, + }, individualTrailWeight: { folder: 'Agents', label: 'individual trail weight', diff --git a/src/config/types.ts b/src/config/types.ts index 7d2c140..df58ec4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,13 +2,14 @@ import type { GardenAudioConfig, GardenAudioVibeSettings, } from '../audio/garden-audio-config'; -import type { AgentSettings } from '../pipelines/agents/agent-settings'; -import type { BrushSettings } from '../pipelines/brush/brush-settings'; -import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings'; -import type { RenderSettings } from '../pipelines/render/render-settings'; +import type { AgentSettings } from '../pipelines/agents/agent-pipeline'; +import type { BrushSettings } from '../pipelines/brush/brush-pipeline'; +import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline'; +import type { RenderSettings } from '../pipelines/render/render-pipeline'; import type { RgbColor } from '../utils/rgb-color'; export interface NumberControlConfig { + format?: (value: number) => string; folder: string; integer?: boolean; label?: string; @@ -53,7 +54,6 @@ type GardenVibeSettings = Pick< GardenRuntimeSettings, | 'backgroundGrainStrength' | 'brushSize' - | 'brushSizeVariation' | 'clarity' | 'color1ToColor1' | 'color1ToColor2' @@ -65,7 +65,6 @@ type GardenVibeSettings = Pick< | 'color3ToColor2' | 'color3ToColor3' | 'decayRateTrails' - | 'diffusionRateBrush' | 'individualTrailWeight' | 'moveSpeed' | 'sensorOffsetDistance' diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index e8c419d..f05b72b 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -27,10 +27,8 @@ export const vibePresets: Array = [ ...colorInteractionSettings, backgroundGrainStrength: 0.018, brushSize: 14, - brushSizeVariation: 0.5, clarity: 0.62, decayRateTrails: 965, - diffusionRateBrush: 0.35, individualTrailWeight: 0.07, moveSpeed: 82, sensorOffsetDistance: 38, @@ -55,10 +53,8 @@ export const vibePresets: Array = [ ...colorInteractionSettings, backgroundGrainStrength: 0.014, brushSize: 16, - brushSizeVariation: 0.35, clarity: 0.68, decayRateTrails: 975, - diffusionRateBrush: 0.28, individualTrailWeight: 0.06, moveSpeed: 70, sensorOffsetDistance: 46, @@ -83,10 +79,8 @@ export const vibePresets: Array = [ ...colorInteractionSettings, backgroundGrainStrength: 0.022, brushSize: 13, - brushSizeVariation: 0.58, clarity: 0.58, decayRateTrails: 955, - diffusionRateBrush: 0.42, individualTrailWeight: 0.055, moveSpeed: 90, sensorOffsetDistance: 35, @@ -111,10 +105,8 @@ export const vibePresets: Array = [ ...colorInteractionSettings, backgroundGrainStrength: 0.018, brushSize: 12, - brushSizeVariation: 0.45, clarity: 0.64, decayRateTrails: 968, - diffusionRateBrush: 0.32, individualTrailWeight: 0.065, moveSpeed: 76, sensorOffsetDistance: 42, @@ -139,10 +131,8 @@ export const vibePresets: Array = [ ...colorInteractionSettings, backgroundGrainStrength: 0.024, brushSize: 15, - brushSizeVariation: 0.62, clarity: 0.55, decayRateTrails: 948, - diffusionRateBrush: 0.48, individualTrailWeight: 0.05, moveSpeed: 96, sensorOffsetDistance: 32, @@ -167,10 +157,8 @@ export const vibePresets: Array = [ ...colorInteractionSettings, backgroundGrainStrength: 0.012, brushSize: 18, - brushSizeVariation: 0.28, clarity: 0.7, decayRateTrails: 982, - diffusionRateBrush: 0.24, individualTrailWeight: 0.075, moveSpeed: 62, sensorOffsetDistance: 52, diff --git a/src/game-loop/eraser-pointer-preview-controller.ts b/src/game-loop/eraser-pointer-preview-controller.ts deleted file mode 100644 index 38ffdee..0000000 --- a/src/game-loop/eraser-pointer-preview-controller.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { EraserPreview } from './eraser-preview'; - -interface EraserPointerPreviewControllerOptions { - canvas: HTMLCanvasElement; - eraserPreview: EraserPreview; - getIsSwipeActive: () => boolean; -} - -export class EraserPointerPreviewController { - public constructor(private readonly options: EraserPointerPreviewControllerOptions) {} - - public attach(): void { - this.canvas.addEventListener('pointerenter', this.onPointerEnter); - this.canvas.addEventListener('pointerleave', this.onPointerLeave); - this.canvas.addEventListener('pointerdown', this.onPointerDown); - this.canvas.addEventListener('pointermove', this.onPointerMove); - this.canvas.addEventListener('pointerup', this.onPointerUp); - this.canvas.addEventListener('pointercancel', this.onPointerUp); - } - - public detach(): void { - this.canvas.removeEventListener('pointerenter', this.onPointerEnter); - this.canvas.removeEventListener('pointerleave', this.onPointerLeave); - this.canvas.removeEventListener('pointerdown', this.onPointerDown); - this.canvas.removeEventListener('pointermove', this.onPointerMove); - this.canvas.removeEventListener('pointerup', this.onPointerUp); - this.canvas.removeEventListener('pointercancel', this.onPointerUp); - } - - public setEraseMode(isErasing: boolean): void { - this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive); - } - - public update(event?: PointerEvent): void { - this.options.eraserPreview.update(event, this.isSwipeActive); - } - - private get canvas(): HTMLCanvasElement { - return this.options.canvas; - } - - private get isSwipeActive(): boolean { - return this.options.getIsSwipeActive(); - } - - private readonly onPointerDown = (event: PointerEvent) => { - this.options.eraserPreview.setPointerHoveringCanvas(true); - this.update(event); - }; - - private readonly onPointerMove = (event: PointerEvent) => { - this.update(event); - }; - - private readonly onPointerUp = (event: PointerEvent) => { - this.options.eraserPreview.setPointerHoveringCanvas( - this.options.eraserPreview.isPointerInsideCanvas(event) - ); - this.update(event); - }; - - private readonly onPointerEnter = (event: PointerEvent) => { - this.options.eraserPreview.setPointerHoveringCanvas(true); - this.update(event); - }; - - private readonly onPointerLeave = () => { - this.options.eraserPreview.setPointerHoveringCanvas(false); - this.update(); - }; -} diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts index 61e6d9d..fefd93c 100644 --- a/src/game-loop/eraser-preview.ts +++ b/src/game-loop/eraser-preview.ts @@ -4,6 +4,7 @@ export class EraserPreview { private previewClientPosition: { x: number; y: number } | null = null; private isErasing = false; private isPointerHoveringCanvas = false; + private isSwipeActive = false; private previousSize: number | null = null; private previousLeft = ''; private previousTop = ''; @@ -11,19 +12,36 @@ export class EraserPreview { public constructor( private readonly canvas: HTMLCanvasElement, - private readonly element: HTMLElement + private readonly element: HTMLElement, + private readonly getIsSwipeActive: () => boolean ) {} - public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void { + public attach(): void { + this.canvas.addEventListener('pointerenter', this.onPointerEnter); + this.canvas.addEventListener('pointerleave', this.onPointerLeave); + this.canvas.addEventListener('pointerdown', this.onPointerDown); + this.canvas.addEventListener('pointermove', this.onPointerMove); + this.canvas.addEventListener('pointerup', this.onPointerUp); + this.canvas.addEventListener('pointercancel', this.onPointerUp); + } + + public detach(): void { + this.canvas.removeEventListener('pointerenter', this.onPointerEnter); + this.canvas.removeEventListener('pointerleave', this.onPointerLeave); + this.canvas.removeEventListener('pointerdown', this.onPointerDown); + this.canvas.removeEventListener('pointermove', this.onPointerMove); + this.canvas.removeEventListener('pointerup', this.onPointerUp); + this.canvas.removeEventListener('pointercancel', this.onPointerUp); + } + + public setEraseMode(isErasing: boolean): void { this.isErasing = isErasing; - this.update(undefined, isSwipeActive); + this.update(); } - public setPointerHoveringCanvas(isHovering: boolean): void { - this.isPointerHoveringCanvas = isHovering; - } + public update(event?: PointerEvent): void { + this.isSwipeActive = this.getIsSwipeActive(); - public update(event?: PointerEvent, isSwipeActive = false): void { if (event) { this.previewClientPosition = { x: event.clientX, @@ -39,7 +57,7 @@ export class EraserPreview { if ( !this.isErasing || this.previewClientPosition === null || - (!this.isPointerHoveringCanvas && !isSwipeActive) + (!this.isPointerHoveringCanvas && !this.isSwipeActive) ) { this.setVisible(false); return; @@ -59,7 +77,16 @@ export class EraserPreview { this.setVisible(true); } - public isPointerInsideCanvas(event: PointerEvent): boolean { + private setVisible(isVisible: boolean): void { + if (this.isVisible === isVisible) { + return; + } + + this.isVisible = isVisible; + this.element.classList.toggle('visible', isVisible); + } + + private isPointerInsideCanvas(event: PointerEvent): boolean { const rect = this.canvas.getBoundingClientRect(); return ( event.clientX >= rect.left && @@ -69,12 +96,27 @@ export class EraserPreview { ); } - private setVisible(isVisible: boolean): void { - if (this.isVisible === isVisible) { - return; - } + private readonly onPointerDown = (event: PointerEvent) => { + this.isPointerHoveringCanvas = true; + this.update(event); + }; - this.isVisible = isVisible; - this.element.classList.toggle('visible', isVisible); - } + private readonly onPointerMove = (event: PointerEvent) => { + this.update(event); + }; + + private readonly onPointerUp = (event: PointerEvent) => { + this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event); + this.update(event); + }; + + private readonly onPointerEnter = (event: PointerEvent) => { + this.isPointerHoveringCanvas = true; + this.update(event); + }; + + private readonly onPointerLeave = () => { + this.isPointerHoveringCanvas = false; + this.update(); + }; } diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index fc7e924..55650c2 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -23,7 +23,6 @@ interface FrameParameters extends RenderInputs { canvasPixelRatio: number; introProgress: number; selectedColorIndex: number; - isErasing: boolean; eraserPixelSize: number; } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 6873e2a..2f731af 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -7,7 +7,6 @@ import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; import { AgentPopulation } from './agent-population'; import { DevStatsOverlay } from './dev-stats-overlay'; -import { EraserPointerPreviewController } from './eraser-pointer-preview-controller'; import { EraserPreview } from './eraser-preview'; import { ExportSnapshotRenderer } from './export-snapshot-renderer'; import { FramePerformance } from './frame-performance'; @@ -25,7 +24,6 @@ export default class GameLoop { private readonly introPrompt: IntroPrompt; private readonly eraserPreview: EraserPreview; private readonly pointerInput: GardenPointerInput; - private readonly eraserPreviewController: EraserPointerPreviewController; private readonly agentPopulation: AgentPopulation; private readonly exportSnapshotRenderer: ExportSnapshotRenderer; private readonly framePerformance = new FramePerformance(); @@ -34,6 +32,7 @@ export default class GameLoop { private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seed = this.seedValue.toString(16); private readonly resizeListener = this.resize.bind(this); + private readonly _canvasSize: vec2 = vec2.create(); private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private previousAccentColor = ''; @@ -54,7 +53,6 @@ export default class GameLoop { this.framePerformance.adaptiveCapInitial ); this.introPrompt = new IntroPrompt(ui.prompt); - this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device); this.agentPopulation = new AgentPopulation( this.resources.agentGenerationPipeline, @@ -77,11 +75,11 @@ export default class GameLoop { onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(), spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to), }); - this.eraserPreviewController = new EraserPointerPreviewController({ + this.eraserPreview = new EraserPreview( canvas, - eraserPreview: this.eraserPreview, - getIsSwipeActive: () => this.pointerInput.isSwipeActive, - }); + ui.eraserPreview, + () => this.pointerInput.isSwipeActive + ); this.exportSnapshotRenderer = new ExportSnapshotRenderer({ device, renderPipeline: this.resources.renderPipeline, @@ -100,7 +98,7 @@ export default class GameLoop { }); window.addEventListener('resize', this.resizeListener); - this.eraserPreviewController.attach(); + this.eraserPreview.attach(); this.syncDevStatsOverlay(); } @@ -110,11 +108,11 @@ export default class GameLoop { public setEraseMode(isErasing: boolean): void { this.pointerInput.setEraseMode(isErasing); - this.eraserPreviewController.setEraseMode(isErasing); + this.eraserPreview.setEraseMode(isErasing); } public updateEraserPreview(event?: PointerEvent): void { - this.eraserPreviewController.update(event); + this.eraserPreview.update(event); } public onVibeChanged(): void { @@ -153,7 +151,7 @@ export default class GameLoop { window.removeEventListener('resize', this.resizeListener); this.pointerInput.detach(); - this.eraserPreviewController.detach(); + this.eraserPreview.detach(); this.devStatsOverlay?.destroy(); this.devStatsOverlay = null; this.toolbarContrastMonitor.destroy(); @@ -198,7 +196,6 @@ export default class GameLoop { canvasPixelRatio, introProgress, selectedColorIndex: settings.selectedColorIndex, - isErasing, channelColors, backgroundColor, eraserPixelSize, @@ -300,7 +297,8 @@ export default class GameLoop { } private get canvasSize(): vec2 { - return vec2.fromValues(this.canvas.width, this.canvas.height); + vec2.set(this._canvasSize, this.canvas.width, this.canvas.height); + return this._canvasSize; } private get canvasPixelRatio(): number { diff --git a/src/index.ts b/src/index.ts index 823b135..ee47f5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,59 +47,6 @@ const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off'; const MIRROR_SEGMENT_STEP = 1; const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; -const ELEMENT_TAGS = { - div: 'div', - pre: 'pre', -} as const; - -const ARIA_ATTRIBUTES = { - label: 'aria-label', - live: 'aria-live', - pressed: 'aria-pressed', - role: 'role', - valueNow: 'aria-valuenow', - valueText: 'aria-valuetext', -} as const; - -const ARIA_LIVE_VALUES = { - assertive: 'assertive', - polite: 'polite', -} as const; - -const ARIA_ROLES = { - alert: 'alert', - status: 'status', -} as const; - -const CSS_CLASSES = { - active: 'active', - errorsContainer: 'errors-container', - isLoading: 'is-loading', - muted: 'muted', - preDrawing: 'pre-drawing', -} as const; - -const CSS_VARIABLES = { - eraserControlScale: '--eraser-control-scale', - eraserProgress: '--eraser-progress', - gardenBackground: '--garden-background', - loadingProgress: '--loading-progress', - mirrorAngle: '--mirror-angle', - mirrorProgress: '--mirror-progress', - volumeProgress: '--volume-progress', -} as const; - -const DOM_EVENTS = { - click: 'click', - focus: 'focus', - input: 'input', - keydown: 'keydown', - pointerDown: 'pointerdown', - pointerUp: 'pointerup', - touchEnd: 'touchend', - touchStart: 'touchstart', -} as const; - const APP_SELECTORS = { aside: 'aside', canvas: 'canvas', @@ -132,24 +79,6 @@ const APP_SELECTORS = { volumeSlider: '.volume-slider', } as const; -const AUDIO_LABELS = { - mutedPrefix: 'Muted', - mute: 'Mute audio', - unmute: 'Unmute audio', - volumeSuffix: 'volume', -} as const; - -const LOADING_MESSAGES = { - fontsError: 'Could not load fonts.', - pianoSamplesError: 'Could not preload piano samples.', - ready: 'Ready', -} as const; - -const VIBE_CHANGE_SOURCES = { - nextButton: 'next-button', - previousButton: 'previous-button', -} as const; - const clampEraserSize = (value: number): number => { const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); @@ -197,18 +126,18 @@ type RuntimeUiError = Parameters< >[0]; const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => { - const message = document.createElement(ELEMENT_TAGS.pre); + const message = document.createElement('pre'); message.className = error.severity; message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; message.setAttribute( - ARIA_ATTRIBUTES.role, - error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status + 'role', + error.severity === Severity.ERROR ? 'alert' : 'status' ); message.setAttribute( - ARIA_ATTRIBUTES.live, + 'aria-live', error.severity === Severity.ERROR - ? ARIA_LIVE_VALUES.assertive - : ARIA_LIVE_VALUES.polite + ? 'assertive' + : 'polite' ); container.append(message); @@ -229,14 +158,14 @@ const renderStartupException = (exception: unknown) => { const container = existingContainer instanceof HTMLElement ? existingContainer - : document.createElement(ELEMENT_TAGS.div); + : document.createElement('div'); if (!(existingContainer instanceof HTMLElement)) { - container.className = CSS_CLASSES.errorsContainer; + container.className = 'errors-container'; document.body.append(container); } - container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive); + container.setAttribute('aria-live', 'assertive'); renderRuntimeMessage(container, getRuntimeUiError(exception)); }; @@ -298,10 +227,10 @@ const setLoadingStage = (label: string, ratio: number) => { const percent = Math.round(clamp01(ratio) * 100); elements.loadingStatus.textContent = label; elements.loadingProgress.style.setProperty( - CSS_VARIABLES.loadingProgress, + '--loading-progress', `${percent}%` ); - elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent)); + elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); }; let audioVolume = readInitialAudioVolume(); @@ -323,32 +252,32 @@ const renderAudioUi = (game: GameLoop | null) => { const isEffectivelyMuted = isAudioMuted || audioVolume <= 0; const volumePercent = getAudioVolumePercent(audioVolume); - elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted); - elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted)); + elements.soundButton.classList.toggle('muted', isEffectivelyMuted); + elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted)); elements.soundButton.setAttribute( - ARIA_ATTRIBUTES.label, - isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute + 'aria-label', + isEffectivelyMuted ? 'Unmute audio' : 'Mute audio' ); elements.soundButton.title = isEffectivelyMuted - ? AUDIO_LABELS.unmute - : AUDIO_LABELS.mute; + ? 'Unmute audio' + : 'Mute audio'; elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); elements.volumeSlider.value = formatStoredAudioVolume(audioVolume); elements.volumeSlider.setAttribute( - ARIA_ATTRIBUTES.valueText, + 'aria-valuetext', isEffectivelyMuted - ? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%` + ? `${'Muted'}, ${volumePercent}%` : `${volumePercent}%` ); - elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted); + elements.volumeControl.classList.toggle('muted', isEffectivelyMuted); elements.volumeControl.title = isEffectivelyMuted - ? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}` - : `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`; + ? `${'Muted'}, ${volumePercent}% ${'volume'}` + : `${volumePercent}% ${'volume'}`; elements.volumeControl.style.setProperty( - CSS_VARIABLES.volumeProgress, + '--volume-progress', `${volumePercent}%` ); @@ -360,14 +289,14 @@ const renderPaletteUi = (game: GameLoop | null) => { elements.swatches.forEach((swatch, index) => { swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); swatch.classList.toggle( - CSS_CLASSES.active, + 'active', settings.selectedColorIndex === index && !isEraserActive ); }); - elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive); + elements.eraserSizeControl.classList.toggle('active', isEraserActive); game?.setEraseMode(isEraserActive); document.documentElement.style.setProperty( - CSS_VARIABLES.gardenBackground, + '--garden-background', rgbColorToCss(activeVibe.backgroundColor) ); }; @@ -382,18 +311,18 @@ const renderEraserSizeUi = (game: GameLoop | null) => { elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString(); elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString(); elements.eraserSizeSlider.value = size.toString(); - elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`); + elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`); const ratio = getEraserSizeRatio(size); const scale = ERASER_CONTROL_SCALE_MIN + (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; elements.eraserSizeControl.style.setProperty( - CSS_VARIABLES.eraserProgress, + '--eraser-progress', `${ratio * 100}%` ); elements.eraserSizeControl.style.setProperty( - CSS_VARIABLES.eraserControlScale, + '--eraser-control-scale', scale.toFixed(3) ); game?.updateEraserPreview(); @@ -412,15 +341,15 @@ const renderMirrorSegmentUi = () => { const label = formatMirrorSegmentCount(count); const ratio = getMirrorSegmentRatio(count); - elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label); + elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label); elements.mirrorSegmentControl.title = label; - elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1); + elements.mirrorSegmentControl.classList.toggle('active', count > 1); elements.mirrorSegmentControl.style.setProperty( - CSS_VARIABLES.mirrorProgress, + '--mirror-progress', `${ratio * 100}%` ); elements.mirrorSegmentControl.style.setProperty( - CSS_VARIABLES.mirrorAngle, + '--mirror-angle', `${(360 / count).toFixed(3)}deg` ); }; @@ -437,13 +366,13 @@ const main = async () => { elements = queryAppElements(); elements.errorContainer.setAttribute( - ARIA_ATTRIBUTES.live, - ARIA_LIVE_VALUES.assertive + 'aria-live', + 'assertive' ); ErrorHandler.addOnErrorListener((error) => { renderRuntimeMessage(elements.errorContainer, error); if (error.severity === Severity.ERROR) { - document.body.classList.remove(CSS_CLASSES.isLoading); + document.body.classList.remove('is-loading'); game?.destroy(); shouldStop = true; } @@ -488,31 +417,31 @@ const main = async () => { game?.startAudio(true); }; - window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, { + window.addEventListener('touchstart', startAudioFromUserGesture, { capture: true, passive: true, }); - window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, { + window.addEventListener('pointerdown', startAudioFromUserGesture, { capture: true, passive: true, }); - window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, { + window.addEventListener('touchend', startAudioFromUserGesture, { capture: true, passive: true, }); - window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, { + window.addEventListener('pointerup', startAudioFromUserGesture, { capture: true, passive: true, }); - window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, { + window.addEventListener('click', startAudioFromUserGesture, { capture: true, }); - window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, { + window.addEventListener('keydown', startAudioFromUserGesture, { capture: true, }); - elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy()); - elements.soundButton.addEventListener(DOM_EVENTS.click, () => { + elements.restartButton.addEventListener('click', () => game?.destroy()); + elements.soundButton.addEventListener('click', () => { const shouldUnmute = isAudioMuted || audioVolume <= 0; if (shouldUnmute && audioVolume <= 0) { audioVolume = DEFAULT_AUDIO_VOLUME; @@ -524,7 +453,7 @@ const main = async () => { game?.startAudio(true); } }); - elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => { + elements.volumeSlider.addEventListener('input', () => { audioVolume = clampAudioVolume(Number(elements.volumeSlider.value)); isAudioMuted = audioVolume <= 0; persistAudioUiState(); @@ -550,16 +479,16 @@ const main = async () => { game?.playVibeChangeAudio(true); }; - elements.previousVibe.addEventListener(DOM_EVENTS.click, () => - selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton) + elements.previousVibe.addEventListener('click', () => + selectRelativeVibe(-1, 'previous-button') ); - elements.nextVibe.addEventListener(DOM_EVENTS.click, () => - selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton) + elements.nextVibe.addEventListener('click', () => + selectRelativeVibe(1, 'next-button') ); elements.swatches.forEach((swatch, index) => { - swatch.addEventListener(DOM_EVENTS.click, () => { + swatch.addEventListener('click', () => { settings.selectedColorIndex = index; isEraserActive = false; renderPaletteUi(game); @@ -572,11 +501,11 @@ const main = async () => { renderPaletteUi(game); }; - elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser); - elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser); - elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser); + elements.eraserSizeControl.addEventListener('pointerdown', activateEraser); + elements.eraserSizeControl.addEventListener('click', activateEraser); + elements.eraserSizeSlider.addEventListener('focus', activateEraser); - elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => { + elements.eraserSizeSlider.addEventListener('input', () => { settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value)); isEraserActive = true; renderEraserSizeUi(game); @@ -584,7 +513,7 @@ const main = async () => { configPane?.refresh(); }); - elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => { + elements.mirrorSegmentSlider.addEventListener('input', () => { settings.mirrorSegmentCount = clampMirrorSegmentCount( Number(elements.mirrorSegmentSlider.value) ); @@ -594,7 +523,7 @@ const main = async () => { configPane?.refresh(); }); - elements.export4k.addEventListener(DOM_EVENTS.click, async () => { + elements.export4k.addEventListener('click', async () => { if (!game || elements.export4k.disabled) { return; } @@ -620,7 +549,7 @@ const main = async () => { // the AudioContext on iOS, and gates the intro. const fontsReady = document.fonts.ready.catch((error) => { ErrorHandler.addException(error, { - fallbackMessage: LOADING_MESSAGES.fontsError, + fallbackMessage: 'Could not load fonts.', severity: Severity.WARNING, }); }); @@ -633,12 +562,12 @@ const main = async () => { }).then( () => { isPreloadComplete = true; - setLoadingStage(LOADING_MESSAGES.ready, 1); + setLoadingStage('Ready', 1); }, (error: unknown) => { isPreloadComplete = true; ErrorHandler.addException(error, { - fallbackMessage: LOADING_MESSAGES.pianoSamplesError, + fallbackMessage: 'Could not preload piano samples.', severity: Severity.WARNING, }); } @@ -651,7 +580,6 @@ const main = async () => { game?.onVibeChanged(); syncRuntimeUi(); }, - onOpenChange: () => undefined, onRuntimeChange: syncRuntimeUi, }); infoPageHandler.onOpen = configPane.close.bind(configPane); @@ -680,14 +608,14 @@ const main = async () => { elements.startButton.disabled = false; await new Promise((resolve) => { const onClick = () => { - elements.startButton.removeEventListener(DOM_EVENTS.click, onClick); + elements.startButton.removeEventListener('click', onClick); hasStarted = true; game?.startAudio(true); trackStart(); elements.splash.hidden = true; resolve(); }; - elements.startButton.addEventListener(DOM_EVENTS.click, onClick); + elements.startButton.addEventListener('click', onClick); }); if (!isPreloadComplete) { @@ -698,16 +626,16 @@ const main = async () => { } // Keep the dev stats overlay hidden until the user actually starts drawing. - document.body.classList.add(CSS_CLASSES.preDrawing); + document.body.classList.add('pre-drawing'); elements.canvas.addEventListener( - DOM_EVENTS.pointerDown, - () => document.body.classList.remove(CSS_CLASSES.preDrawing), + 'pointerdown', + () => document.body.classList.remove('pre-drawing'), { once: true } ); requestAnimationFrame(() => requestAnimationFrame(() => - document.body.classList.remove(CSS_CLASSES.isLoading) + document.body.classList.remove('is-loading') ) ); } @@ -715,7 +643,7 @@ const main = async () => { await game.start(); } } catch (e) { - document.body.classList.remove(CSS_CLASSES.isLoading); + document.body.classList.remove('is-loading'); if (hasRuntimeErrorListener) { ErrorHandler.addException(e); } else { diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index c601872..b4917a8 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -29,6 +29,11 @@ interface PaneState extends GardenAudioVibeSettings { } const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; +const COLOR_REACTION_STATES = [ + { id: 'follow', label: 'Follow', value: 1 }, + { id: 'ignore', label: 'Ignore', value: 0 }, + { id: 'avoid', label: 'Avoid', value: -1 }, +] as const; const colorReactionRows = [ { @@ -51,14 +56,14 @@ const colorReactionRows = [ const brushControlKeys = [ 'brushSize', 'spawnPerPixel', - 'brushSizeVariation', - 'diffusionRateBrush', ] satisfies Array; const agentControlKeys = [ 'sensorOffsetDistance', 'moveSpeed', 'turnSpeed', + 'forwardRotationScale', + 'turnWhenLost', 'individualTrailWeight', 'decayRateTrails', ] satisfies Array; @@ -80,7 +85,7 @@ const MUSIC_CONTROLS: ReadonlyArray<{ max: number; step: number; }> = [ - { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 0.8, step: 0.01 }, + { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 }, { key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 }, { key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 }, { key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 }, @@ -91,7 +96,6 @@ const MUSIC_CONTROLS: ReadonlyArray<{ interface ConfigPaneOptions { onConfigChange: () => void; - onOpenChange?: (isOpen: boolean) => void; onRuntimeChange: () => void; settingsButton: HTMLButtonElement; } @@ -119,6 +123,9 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { options: config.options, step: config.step, }; + if (config.format !== undefined) { + params.format = config.format; + } if (config.min !== undefined) { params.min = config.min; } @@ -128,10 +135,32 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { return params; }; +const getColorReactionStateIndex = (value: number): number => + COLOR_REACTION_STATES.findIndex((state) => state.value === value); + +const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] => + COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1]; + +const getNextColorReactionState = ( + value: number +): (typeof COLOR_REACTION_STATES)[number] => { + const index = getColorReactionStateIndex(value); + return COLOR_REACTION_STATES[ + ((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length + ]; +}; + export class ConfigPane { private readonly container: HTMLDivElement; private readonly pane: Pane; - private readonly colorReactionSelects = new Map(); + private readonly colorReactionButtons = new Map< + ColorReactionKey, + { + element: HTMLButtonElement; + sourceColorIndex: number; + targetColorIndex: number; + } + >(); private readonly colorReactionSwatches: Array<{ colorIndex: number; element: HTMLElement; @@ -379,56 +408,79 @@ export class ConfigPane { key: ColorReactionKey, sourceColorIndex: number, targetColorIndex: number - ): HTMLLabelElement { - const cell = doc.createElement('label'); + ): HTMLDivElement { + const cell = doc.createElement('div'); cell.className = 'color-reaction-matrix__cell'; - const select = doc.createElement('select'); - select.setAttribute( - 'aria-label', - `Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}` - ); - const config = appConfig.runtimeSettings.controls[key]; if (!config) { return cell; } - Object.entries(config.options ?? {}).forEach(([label, value]) => { - const option = doc.createElement('option'); - option.value = String(value); - option.textContent = label; - select.appendChild(option); - }); + const button = doc.createElement('button'); + button.className = 'color-reaction-matrix__button'; + button.type = 'button'; - select.addEventListener('change', () => { - settings[key] = normalizeNumber(Number(select.value), config); - select.value = String(settings[key]); + const icon = doc.createElement('span'); + icon.className = 'color-reaction-matrix__icon'; + button.appendChild(icon); + + button.addEventListener('click', () => { + const currentValue = normalizeNumber(settings[key], config); + const nextState = getNextColorReactionState(currentValue); + settings[key] = nextState.value; + this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex); this.options.onRuntimeChange(); }); - this.colorReactionSelects.set(key, select); - cell.appendChild(select); + this.colorReactionButtons.set(key, { + element: button, + sourceColorIndex, + targetColorIndex, + }); + cell.appendChild(button); return cell; } private syncColorReactionMatrix(): void { - this.colorReactionSelects.forEach((select, key) => { - const config = appConfig.runtimeSettings.controls[key]; - if (!config) { - return; + this.colorReactionButtons.forEach( + ({ element, sourceColorIndex, targetColorIndex }, key) => { + this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex); } - - settings[key] = normalizeNumber(settings[key], config); - select.value = String(settings[key]); - }); + ); this.colorReactionSwatches.forEach(({ colorIndex, element }) => { element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]); }); } + private syncColorReactionButton( + button: HTMLButtonElement, + key: ColorReactionKey, + sourceColorIndex: number, + targetColorIndex: number + ): void { + const config = appConfig.runtimeSettings.controls[key]; + if (!config) { + return; + } + + settings[key] = normalizeNumber(settings[key], config); + + const state = getColorReactionState(settings[key]); + const nextState = getNextColorReactionState(settings[key]); + const sourceLabel = sourceColorIndex + 1; + const targetLabel = targetColorIndex + 1; + + button.dataset.reaction = state.id; + button.setAttribute( + 'aria-label', + `Color ${sourceLabel} agents ${state.label.toLowerCase()} color ${targetLabel}; click to switch to ${nextState.label.toLowerCase()}` + ); + button.title = state.label; + } + private setUpMusicSection(container: PaneContainer): void { const folder = container.addFolder({ title: 'Music', expanded: true }); MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => { @@ -489,6 +541,5 @@ export class ConfigPane { settingsButton.setAttribute('aria-expanded', String(this.isOpen)); settingsButton.setAttribute('aria-label', label); settingsButton.title = label; - this.options.onOpenChange?.(this.isOpen); } } diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts index 5792482..3fd4477 100644 --- a/src/pipelines/agents/agent-dispatch.ts +++ b/src/pipelines/agents/agent-dispatch.ts @@ -1,25 +1,9 @@ const AGENT_WORKGROUP_SIZE = 64; -const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535; -export const AGENT_MAX_DISPATCHABLE_COUNT = - AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION * AGENT_WORKGROUP_SIZE; - -const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => { - if (!Number.isFinite(agentCount) || agentCount <= 0) { - throw new Error('Agent count must be a positive finite number'); - } - - const workgroupCount = Math.ceil(agentCount / AGENT_WORKGROUP_SIZE); - if (workgroupCount > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) { - throw new Error('Agent count exceeds dispatchable workgroup range'); - } - - return [workgroupCount, 1]; -}; +export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE; export const dispatchAgentWorkgroups = ( passEncoder: GPUComputePassEncoder, agentCount: number ): void => { - const [workgroupX, workgroupY] = getAgentDispatchWorkgroups(agentCount); - passEncoder.dispatchWorkgroups(workgroupX, workgroupY); + passEncoder.dispatchWorkgroups(Math.ceil(agentCount / AGENT_WORKGROUP_SIZE), 1); }; diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index f2fafa7..d76015f 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -1,5 +1,6 @@ import { vec2 } from 'gl-matrix'; +import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache'; import { smartCompile } from '../../../utils/graphics/smart-compile'; import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch'; import compactionShader from './agent-compaction.wgsl?raw'; @@ -17,10 +18,18 @@ export class AgentGenerationPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly uniforms: GPUBuffer; - private readonly bindGroupsByActiveBuffer = new WeakMap< - GPUBuffer, - WeakMap - >(); + private readonly bindGroupCache = createBindGroupCache( + (active, inactive) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: { buffer: active } }, + { binding: 2, resource: { buffer: this.countersBuffer } }, + { binding: 3, resource: { buffer: inactive } }, + ], + }) + ); private readonly resizePipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline; @@ -244,7 +253,6 @@ export class AgentGenerationPipeline { this.resizeUniformFloatValues[0] = scale[0]; this.resizeUniformFloatValues[1] = scale[1]; this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount)); - this.resizeUniformUintValues[3] = 0; this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer); const commandEncoder = this.device.createCommandEncoder(); @@ -314,49 +322,7 @@ export class AgentGenerationPipeline { } private getBindGroup(): GPUBindGroup { - let inactiveCache = this.bindGroupsByActiveBuffer.get(this.activeAgentsBuffer); - if (!inactiveCache) { - inactiveCache = new WeakMap(); - this.bindGroupsByActiveBuffer.set(this.activeAgentsBuffer, inactiveCache); - } - - const cached = inactiveCache.get(this.inactiveAgentsBuffer); - if (cached) { - return cached; - } - - const bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - { - binding: 1, - resource: { - buffer: this.activeAgentsBuffer, - }, - }, - { - binding: 2, - resource: { - buffer: this.countersBuffer, - }, - }, - { - binding: 3, - resource: { - buffer: this.inactiveAgentsBuffer, - }, - }, - ], - }); - - inactiveCache.set(this.inactiveAgentsBuffer, bindGroup); - return bindGroup; + return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer); } private swapAgentBuffers(): void { diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl index 91bdf9e..d43506c 100644 --- a/src/pipelines/agents/agent-generation/agent-resize.wgsl +++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl @@ -1,7 +1,6 @@ struct ResizeSettings { scale: vec2, agentCount: u32, - padding: u32, }; @group(1) @binding(0) var resizeSettings: ResizeSettings; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index 58d2e26..330fafd 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -6,9 +6,38 @@ import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; import { dispatchAgentWorkgroups } from './agent-dispatch'; import agentSchema from './agent-generation/agent-schema.wgsl?raw'; -import { AgentSettings } from './agent-settings'; import shader from './agent.wgsl?raw'; +export interface AgentSettings { + color1ToColor1: number; + color1ToColor2: number; + color1ToColor3: number; + color2ToColor1: number; + color2ToColor2: number; + color2ToColor3: number; + color3ToColor1: number; + color3ToColor2: number; + color3ToColor3: number; + moveSpeed: number; + turnSpeed: number; + sensorOffsetAngle: number; + sensorOffsetDistance: number; + turnWhenLost: number; + individualTrailWeight: number; + forwardRotationScale: number; + introNearDistanceMin: number; + introNearSensorOffsetMultiplier: number; + introTargetAngleBlend: number; + introProgressCutoff: number; + introNearDistanceInner: number; + introTurnRateMultiplier: number; + introRandomTurnMultiplier: number; + introFarMoveMultiplier: number; + introNearMoveMultiplier: number; + introStepStopDistance: number; + randomTimeScale: number; +} + export class AgentPipeline { private static readonly UNIFORM_COUNT = 30; diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts deleted file mode 100644 index 7628306..0000000 --- a/src/pipelines/agents/agent-settings.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface AgentSettings { - color1ToColor1: number; - color1ToColor2: number; - color1ToColor3: number; - color2ToColor1: number; - color2ToColor2: number; - color2ToColor3: number; - color3ToColor1: number; - color3ToColor2: number; - color3ToColor3: number; - moveSpeed: number; - turnSpeed: number; - sensorOffsetAngle: number; - sensorOffsetDistance: number; - turnWhenLost: number; - individualTrailWeight: number; - forwardRotationScale: number; - introNearDistanceMin: number; - introNearSensorOffsetMultiplier: number; - introTargetAngleBlend: number; - introProgressCutoff: number; - introNearDistanceInner: number; - introTurnRateMultiplier: number; - introRandomTurnMultiplier: number; - introFarMoveMultiplier: number; - introNearMoveMultiplier: number; - introStepStopDistance: number; - randomTimeScale: number; -} diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index f909861..d57b5a2 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -7,9 +7,19 @@ import { } from '../../utils/graphics/cached-buffer-write'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; -import { BrushSettings } from './brush-settings'; import shader from './brush.wgsl?raw'; +export interface BrushSettings { + brushSize: number; + brushAlpha: number; + brushDiscardThreshold: number; + brushGrainNoiseScale: number; + brushGrainNoiseOffsetX: number; + brushGrainNoiseOffsetY: number; + brushGrainMinStrength: number; + brushGrainMaxStrength: number; +} + interface LineSegment { from: vec2; to: vec2; @@ -29,10 +39,8 @@ const setBrushUniformValues = ( target: Float32Array, { brushSize, - brushSizeVariation, brushAlpha, brushDiscardThreshold, - brushCoarseNoiseScale, brushGrainNoiseScale, brushGrainNoiseOffsetX, brushGrainNoiseOffsetY, @@ -44,25 +52,21 @@ const setBrushUniformValues = ( ): void => { const safePixelRatio = getSafePixelRatio(pixelRatio); const brushRadius = (brushSize * safePixelRatio) / 2; - const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); - const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation); target[0] = brushRadius; - target[1] = brushRadiusVariation * 2; - target[2] = brushGeometryRadius * brushGeometryRadius; - target[3] = brushGeometryRadius; + target[1] = brushRadius * brushRadius; + target[2] = 0; + target[3] = 0; target[4] = selectedColorIndex === 0 ? 1 : 0; target[5] = selectedColorIndex === 1 ? 1 : 0; target[6] = selectedColorIndex === 2 ? 1 : 0; target[7] = brushAlpha; - target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio); - target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio); - target[10] = brushGrainNoiseOffsetX; - target[11] = brushGrainNoiseOffsetY; - target[12] = brushDiscardThreshold; - target[13] = brushGrainMinStrength; - target[14] = brushGrainMaxStrength; - target[15] = 0; + target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio); + target[9] = brushGrainNoiseOffsetX; + target[10] = brushGrainNoiseOffsetY; + target[11] = brushDiscardThreshold; + target[12] = brushGrainMinStrength; + target[13] = brushGrainMaxStrength; }; export class BrushPipeline { diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts deleted file mode 100644 index 6ea2b59..0000000 --- a/src/pipelines/brush/brush-settings.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface BrushSettings { - brushSize: number; - brushSizeVariation: number; - brushAlpha: number; - brushDiscardThreshold: number; - brushCoarseNoiseScale: number; - brushGrainNoiseScale: number; - brushGrainNoiseOffsetX: number; - brushGrainNoiseOffsetY: number; - brushGrainMinStrength: number; - brushGrainMaxStrength: number; -} diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index e518401..946e8b7 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -1,10 +1,10 @@ struct Settings { - brushSize: f32, - brushSizeVariation: f32, - brushGeometryRadiusSquared: f32, - brushGeometryRadius: f32, + brushRadius: f32, + brushRadiusSquared: f32, + // padding to 16-byte alignment for the following vec4 + _pad0: f32, + _pad1: f32, brushValue: vec4, - brushCoarseNoiseScale: f32, brushGrainNoiseScale: f32, brushGrainNoiseOffsetX: f32, brushGrainNoiseOffsetY: f32, @@ -39,7 +39,7 @@ fn vertex( if denominator > 0.0001 { inverseLengthSquared = 1.0 / denominator; } - let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius); + let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius); let uv = screenPosition / state.size; let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared); @@ -74,18 +74,11 @@ fn brushStrength( direction, inverseLengthSquared ); - if distanceSquared > settings.brushGeometryRadiusSquared { + if distanceSquared > settings.brushRadiusSquared { return 0.0; } - let coarseNoise = textureSampleLevel( - noise, - noiseSampler, - screenPosition * settings.brushCoarseNoiseScale, - 0.0 - ).r; - let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation; - let edge = 1.0 - step(radius * radius, distanceSquared); + let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared); if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold { return 0.0; } diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts index af12cd2..14a710e 100644 --- a/src/pipelines/common-state/common-state.ts +++ b/src/pipelines/common-state/common-state.ts @@ -24,9 +24,8 @@ export class CommonState { struct State { size: vec2, time: f32, - padding0: f32, }; - + @group(0) @binding(0) var state: State; @group(0) @binding(1) var noiseSampler: sampler; @group(0) @binding(2) var noise: texture_2d; @@ -101,7 +100,6 @@ export class CommonState { this.uniformValues[0] = canvasSize[0]; this.uniformValues[1] = canvasSize[1]; this.uniformValues[2] = time; - this.uniformValues[3] = 0; writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index c9dbc17..2ecdf0e 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -1,12 +1,12 @@ struct Settings { inverseDiffusionRateTrails: f32, decayRateTrails: f32, - inverseDiffusionRateBrush: f32, - decayRateBrush: f32, diffusionNeighborScale: f32, brushDecayAlphaMultiplier: f32, brushDecayAlphaSubtract: f32, + padding0: f32, padding1: f32, + padding2: f32, }; const WORKGROUP_SIZE_X = 16u; @@ -74,25 +74,16 @@ fn main( r16, settings.inverseDiffusionRateTrails ); - let brushWeight = diffusion_weight( - random, - r2, - r4, - r8, - r16, - settings.inverseDiffusionRateBrush - ); - current += ( - propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight) - + propagate(centerTileIndex, -1, 1, current, trailWeight, brushWeight) - + propagate(centerTileIndex, 1, -1, current, trailWeight, brushWeight) - + propagate(centerTileIndex, 1, 1, current, trailWeight, brushWeight) + propagate(centerTileIndex, -1, -1, current, trailWeight) + + propagate(centerTileIndex, -1, 1, current, trailWeight) + + propagate(centerTileIndex, 1, -1, current, trailWeight) + + propagate(centerTileIndex, 1, 1, current, trailWeight) - + propagate(centerTileIndex, -1, 0, current, trailWeight, brushWeight) - + propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight) - + propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight) - + propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight) + + propagate(centerTileIndex, -1, 0, current, trailWeight) + + propagate(centerTileIndex, 0, -1, current, trailWeight) + + propagate(centerTileIndex, 1, 0, current, trailWeight) + + propagate(centerTileIndex, 0, 1, current, trailWeight) ) * settings.diffusionNeighborScale; let decayed = clamp(vec4( @@ -108,8 +99,7 @@ fn propagate( offsetX: i32, offsetY: i32, currentColor: vec4, - trailWeight: f32, - brushWeight: f32 + trailWeight: f32 ) -> vec4 { let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX; let neighbourTileIndex = u32(neighbourIndex); @@ -118,7 +108,7 @@ fn propagate( return vec4( vec3(tileTrailStrength[neighbourTileIndex] * trailWeight), - neighbour.a * brushWeight + neighbour.a * trailWeight ) * difference; } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 8d9719c..75bdae3 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -1,20 +1,27 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../../config'; +import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, } from '../../utils/graphics/cached-buffer-write'; -import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count'; import { smartCompile } from '../../utils/graphics/smart-compile'; import shader from './diffuse.wgsl?raw'; -import { DiffusionSettings } from './diffusion-settings'; + +export interface DiffusionSettings { + diffusionRateTrails: number; + decayRateTrails: number; + decayRateBrush: number; + diffusionDecayRateDivisor: number; + diffusionNeighborDivisor: number; + brushDecayAlphaOffset: number; +} type DiffusionUniformSettings = Pick< DiffusionSettings, | 'diffusionRateTrails' | 'decayRateTrails' - | 'diffusionRateBrush' | 'decayRateBrush' | 'diffusionDecayRateDivisor' | 'diffusionNeighborDivisor' @@ -33,7 +40,6 @@ const setDiffusionUniformValues = ( { diffusionRateTrails, decayRateTrails, - diffusionRateBrush, decayRateBrush, diffusionDecayRateDivisor, diffusionNeighborDivisor, @@ -47,11 +53,11 @@ const setDiffusionUniformValues = ( : 1; target[0] = getSafeInverseDiffusionRate(diffusionRateTrails); target[1] = decayRateTrails / decayDivisor; - target[2] = getSafeInverseDiffusionRate(diffusionRateBrush); - target[3] = brushDecayRate; - target[4] = 1 / neighborDivisor; - target[5] = 1 + brushDecayRate; - target[6] = brushDecayAlphaOffset * brushDecayRate; + target[2] = 1 / neighborDivisor; + target[3] = 1 + brushDecayRate; + target[4] = brushDecayAlphaOffset * brushDecayRate; + target[5] = 0; + target[6] = 0; target[7] = 0; }; @@ -66,10 +72,17 @@ export class DiffusionPipeline { private readonly uniformCache = createCachedFloat32BufferWrite( DiffusionPipeline.UNIFORM_COUNT ); - private readonly bindGroupsByInput = new WeakMap< - GPUTextureView, - WeakMap - >(); + private readonly getBindGroup = createBindGroupCache( + (trailMapIn, trailMapOut) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: trailMapIn }, + { binding: 2, resource: trailMapOut }, + ], + }) + ); public constructor(private readonly device: GPUDevice) { this.bindGroupLayout = device.createBindGroupLayout( @@ -95,7 +108,6 @@ export class DiffusionPipeline { public setParameters({ diffusionRateTrails, decayRateTrails, - diffusionRateBrush, decayRateBrush, diffusionDecayRateDivisor, diffusionNeighborDivisor, @@ -104,7 +116,6 @@ export class DiffusionPipeline { setDiffusionUniformValues(this.uniformValues, { diffusionRateTrails, decayRateTrails, - diffusionRateBrush, decayRateBrush, diffusionDecayRateDivisor, diffusionNeighborDivisor, @@ -130,51 +141,12 @@ export class DiffusionPipeline { passEncoder.setPipeline(this.pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups( - getWorkgroupCount(size[0], DiffusionPipeline.WORKGROUP_SIZE), - getWorkgroupCount(size[1], DiffusionPipeline.WORKGROUP_SIZE) + Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE), + Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE) ); passEncoder.end(); } - private getBindGroup( - trailMapIn: GPUTextureView, - trailMapOut: GPUTextureView - ): GPUBindGroup { - let outputCache = this.bindGroupsByInput.get(trailMapIn); - if (!outputCache) { - outputCache = new WeakMap(); - this.bindGroupsByInput.set(trailMapIn, outputCache); - } - - const cached = outputCache.get(trailMapOut); - if (cached) { - return cached; - } - - const bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - { - binding: 1, - resource: trailMapIn, - }, - { - binding: 2, - resource: trailMapOut, - }, - ], - }); - - outputCache.set(trailMapOut, bindGroup); - return bindGroup; - } - public destroy() { this.uniforms.destroy(); } diff --git a/src/pipelines/diffusion/diffusion-settings.ts b/src/pipelines/diffusion/diffusion-settings.ts deleted file mode 100644 index bd9717d..0000000 --- a/src/pipelines/diffusion/diffusion-settings.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface DiffusionSettings { - diffusionRateTrails: number; - decayRateTrails: number; - diffusionRateBrush: number; - decayRateBrush: number; - diffusionDecayRateDivisor: number; - diffusionNeighborDivisor: number; - brushDecayAlphaOffset: number; -} diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index d61ba6a..c1dae6a 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -1,5 +1,6 @@ import { vec2 } from 'gl-matrix'; +import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, @@ -20,10 +21,17 @@ export class EraserAgentPipeline { private readonly uniformCache = createCachedFloat32BufferWrite( EraserAgentPipeline.UNIFORM_COUNT ); - private readonly bindGroupsByAgentsBuffer = new WeakMap< - GPUBuffer, - WeakMap - >(); + private readonly bindGroupCache = createBindGroupCache( + (agentsBuffer, eraserMask) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: { buffer: agentsBuffer } }, + { binding: 2, resource: eraserMask }, + ], + }) + ); private pendingSegmentCount = 0; private activeSegmentCount = 0; @@ -121,7 +129,7 @@ export class EraserAgentPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.pipeline); - passEncoder.setBindGroup(1, this.getBindGroup(eraserMask)); + passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask)); dispatchAgentWorkgroups(passEncoder, this.agentCount); passEncoder.end(); } @@ -129,43 +137,4 @@ export class EraserAgentPipeline { public destroy(): void { this.uniforms.destroy(); } - - private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup { - const agentsBuffer = this.getAgentsBuffer(); - let maskCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer); - if (!maskCache) { - maskCache = new WeakMap(); - this.bindGroupsByAgentsBuffer.set(agentsBuffer, maskCache); - } - - const cached = maskCache.get(eraserMask); - if (cached) { - return cached; - } - - const bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - { - binding: 1, - resource: { - buffer: agentsBuffer, - }, - }, - { - binding: 2, - resource: eraserMask, - }, - ], - }); - - maskCache.set(eraserMask, bindGroup); - return bindGroup; - } } diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index 4826149..2f6ac26 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -124,7 +124,6 @@ export class EraserTexturePipeline { this.uniformValues[4] = eraserClearBlue; this.uniformValues[5] = eraserClearAlpha; this.uniformValues[6] = eraserRadius; - this.uniformValues[7] = 0; writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl index c3a1517..10948ef 100644 --- a/src/pipelines/eraser/eraser-texture.wgsl +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -6,7 +6,6 @@ struct Settings { clearBlue: f32, clearAlpha: f32, eraserRadius: f32, - padding1: f32, }; @group(1) @binding(0) var settings: Settings; diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index 8ee09cd..df6b56a 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -1,3 +1,4 @@ +import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, @@ -6,9 +7,16 @@ import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color'; import { CommonState } from '../common-state/common-state'; -import { RenderSettings } from './render-settings'; import shader from './render.wgsl?raw'; +export interface RenderSettings { + clarity: number; + renderTraceNormalizationFloor: number; + renderBrushColorBase: number; + renderBrushColorStrengthMultiplier: number; + backgroundGrainStrength: number; +} + export class RenderPipeline { private static readonly UNIFORM_COUNT = 20; @@ -21,10 +29,17 @@ export class RenderPipeline { RenderPipeline.UNIFORM_COUNT ); - private readonly bindGroupsByTexture = new WeakMap< - GPUTextureView, - WeakMap - >(); + private readonly getBindGroup = createBindGroupCache( + (colorTexture, sourceTexture) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 2, resource: colorTexture }, + { binding: 3, resource: sourceTexture }, + ], + }) + ); public constructor( private readonly context: GPUCanvasContext, @@ -165,45 +180,6 @@ export class RenderPipeline { passEncoder.end(); } - private getBindGroup( - colorTexture: GPUTextureView, - sourceTexture: GPUTextureView - ): GPUBindGroup { - let sourceTextureCache = this.bindGroupsByTexture.get(colorTexture); - if (!sourceTextureCache) { - sourceTextureCache = new WeakMap(); - this.bindGroupsByTexture.set(colorTexture, sourceTextureCache); - } - - const cached = sourceTextureCache.get(sourceTexture); - if (cached) { - return cached; - } - - const bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - { - binding: 2, - resource: colorTexture, - }, - { - binding: 3, - resource: sourceTexture, - }, - ], - }); - - sourceTextureCache.set(sourceTexture, bindGroup); - return bindGroup; - } - public destroy() { this.uniforms.destroy(); } diff --git a/src/pipelines/render/render-settings.ts b/src/pipelines/render/render-settings.ts deleted file mode 100644 index 1b2d523..0000000 --- a/src/pipelines/render/render-settings.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface RenderSettings { - clarity: number; - renderTraceNormalizationFloor: number; - renderBrushColorBase: number; - renderBrushColorStrengthMultiplier: number; - backgroundGrainStrength: number; -} diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index cdd9781..d21e043 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -44,27 +44,105 @@ .color-reaction-matrix__cell { min-width: 0; + display: grid; } -.color-reaction-matrix__cell > select { +.color-reaction-matrix__button { + position: relative; + display: grid; width: 100%; min-width: 0; height: 28px; + place-items: center; border: 1px solid rgb(255 255 255 / 16%); border-radius: 4px; padding: 0 4px; background: rgb(255 255 255 / 8%); color: white; font: inherit; - font-size: 11px; + cursor: pointer; + transition: + background-color var(--transition-time), + border-color var(--transition-time), + color var(--transition-time), + transform var(--transition-time); } -.color-reaction-matrix__cell > select:focus-visible { +.color-reaction-matrix__button:hover { + transform: translateY(-1px); +} + +.color-reaction-matrix__button:focus-visible { outline: 2px solid rgb(255 255 255 / 72%); outline-offset: 1px; } -.color-reaction-matrix__cell > select > option { - background: rgb(28 31 38); - color: white; +.color-reaction-matrix__button[data-reaction='follow'] { + border-color: rgb(115 235 160 / 44%); + background: rgb(53 165 96 / 20%); + color: rgb(157 255 195 / 94%); +} + +.color-reaction-matrix__button[data-reaction='ignore'] { + border-color: rgb(255 255 255 / 18%); + background: rgb(255 255 255 / 7%); + color: rgb(235 238 245 / 72%); +} + +.color-reaction-matrix__button[data-reaction='avoid'] { + border-color: rgb(255 145 120 / 46%); + background: rgb(215 74 54 / 19%); + color: rgb(255 171 148 / 94%); +} + +.color-reaction-matrix__icon { + position: relative; + display: block; + width: 16px; + height: 16px; +} + +.color-reaction-matrix__icon::before, +.color-reaction-matrix__icon::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + border-radius: 999px; + background: currentColor; + transform: translate(-50%, -50%); +} + +.color-reaction-matrix__button[data-reaction='follow'] + > .color-reaction-matrix__icon::before, +.color-reaction-matrix__button[data-reaction='avoid'] + > .color-reaction-matrix__icon::before { + width: 14px; + height: 2px; +} + +.color-reaction-matrix__button[data-reaction='follow'] + > .color-reaction-matrix__icon::after { + width: 2px; + height: 14px; +} + +.color-reaction-matrix__button[data-reaction='avoid'] + > .color-reaction-matrix__icon::after { + display: none; +} + +.color-reaction-matrix__button[data-reaction='ignore'] > .color-reaction-matrix__icon { + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-radius: 999px; + opacity: 0.82; +} + +.color-reaction-matrix__button[data-reaction='ignore'] + > .color-reaction-matrix__icon::before, +.color-reaction-matrix__button[data-reaction='ignore'] + > .color-reaction-matrix__icon::after { + display: none; } diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts index 834744b..f91a3e0 100644 --- a/src/utils/browser-storage.ts +++ b/src/utils/browser-storage.ts @@ -1,6 +1,6 @@ export const readBrowserStorage = (key: string): string | null => { try { - return typeof localStorage === 'undefined' ? null : localStorage.getItem(key); + return localStorage.getItem(key); } catch { return null; } @@ -8,13 +8,11 @@ export const readBrowserStorage = (key: string): string | null => { export const writeBrowserStorage = (key: string, value: string): void => { try { - if (typeof localStorage !== 'undefined') { - localStorage.setItem(key, value); - } + localStorage.setItem(key, value); } catch (error) { console.warn( 'Storage can be unavailable in private browsing or embedded contexts.', - error, + error ); } }; diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts new file mode 100644 index 0000000..d4fe444 --- /dev/null +++ b/src/utils/graphics/bind-group-cache.ts @@ -0,0 +1,19 @@ +export const createBindGroupCache = ( + factory: (key1: K1, key2: K2) => GPUBindGroup +): ((key1: K1, key2: K2) => GPUBindGroup) => { + const outer = new WeakMap>(); + return (key1, key2) => { + let inner = outer.get(key1); + if (!inner) { + inner = new WeakMap(); + outer.set(key1, inner); + } + const cached = inner.get(key2); + if (cached) { + return cached; + } + const bindGroup = factory(key1, key2); + inner.set(key2, bindGroup); + return bindGroup; + }; +}; diff --git a/src/utils/graphics/get-workgroup-count.ts b/src/utils/graphics/get-workgroup-count.ts deleted file mode 100644 index eba19b3..0000000 --- a/src/utils/graphics/get-workgroup-count.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const getWorkgroupCount = ( - invocationCount: number, - workgroupSize: number -): number => { - if ( - !Number.isFinite(invocationCount) || - !Number.isFinite(workgroupSize) || - invocationCount <= 0 || - workgroupSize <= 0 - ) { - throw new Error( - 'Invocation count and workgroup size must be positive finite numbers' - ); - } - - return Math.ceil(invocationCount / workgroupSize); -}; diff --git a/src/vibes.ts b/src/vibes.ts index 2582674..b6f1576 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -1,8 +1,9 @@ -import { appConfig, type VibeId, type VibePreset } from './config'; +import { appConfig } from './config'; +import { VibeId, type VibePreset } from './config/types'; import { readBrowserStorage } from './utils/browser-storage'; -export { VibeId } from './config'; -export type { VibePreset } from './config'; +export { VibeId }; +export type { VibePreset }; export const VIBE_PRESETS: Array = appConfig.vibes.presets; const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id));