From 2fe3c699632a3211d91c9c88c51221fdcdba3812 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 20 May 2026 21:37:30 +0100 Subject: [PATCH] simplify more --- public/og-image.jpg | Bin 0 -> 53367 bytes src/audio/garden-audio-config.ts | 4 +- src/audio/garden-audio-types.ts | 4 - src/audio/garden-audio.ts | 10 +- src/audio/generative-piano.ts | 347 +++++++++--------- src/audio/piano-sampler.ts | 18 +- src/config.ts | 3 - src/config/color-interactions.ts | 12 +- src/config/default-settings.ts | 1 - src/config/runtime-controls.ts | 32 +- src/config/types.ts | 11 +- src/config/vibe-presets.ts | 12 - .../eraser-pointer-preview-controller.ts | 71 ---- src/game-loop/eraser-preview.ts | 74 +++- src/game-loop/game-loop-resources.ts | 1 - src/game-loop/game-loop.ts | 24 +- src/index.ts | 204 ++++------ src/page/config-pane.ts | 117 ++++-- src/pipelines/agents/agent-dispatch.ts | 20 +- .../agent-generation-pipeline.ts | 62 +--- .../agents/agent-generation/agent-resize.wgsl | 1 - src/pipelines/agents/agent-pipeline.ts | 31 +- src/pipelines/agents/agent-settings.ts | 29 -- src/pipelines/brush/brush-pipeline.ts | 36 +- src/pipelines/brush/brush-settings.ts | 12 - src/pipelines/brush/brush.wgsl | 23 +- src/pipelines/common-state/common-state.ts | 4 +- src/pipelines/diffusion/diffuse.wgsl | 34 +- src/pipelines/diffusion/diffusion-pipeline.ts | 84 ++--- src/pipelines/diffusion/diffusion-settings.ts | 9 - src/pipelines/eraser/eraser-agent-pipeline.ts | 57 +-- .../eraser/eraser-texture-pipeline.ts | 1 - src/pipelines/eraser/eraser-texture.wgsl | 1 - src/pipelines/render/render-pipeline.ts | 64 +--- src/pipelines/render/render-settings.ts | 7 - src/style/_config-pane.scss | 90 ++++- src/utils/browser-storage.ts | 8 +- src/utils/graphics/bind-group-cache.ts | 19 + src/utils/graphics/get-workgroup-count.ts | 17 - src/vibes.ts | 7 +- 40 files changed, 689 insertions(+), 872 deletions(-) create mode 100644 public/og-image.jpg delete mode 100644 src/game-loop/eraser-pointer-preview-controller.ts delete mode 100644 src/pipelines/agents/agent-settings.ts delete mode 100644 src/pipelines/brush/brush-settings.ts delete mode 100644 src/pipelines/diffusion/diffusion-settings.ts delete mode 100644 src/pipelines/render/render-settings.ts create mode 100644 src/utils/graphics/bind-group-cache.ts delete mode 100644 src/utils/graphics/get-workgroup-count.ts diff --git a/public/og-image.jpg b/public/og-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..98ae6a571b4d55dc8085758483fd7d5ada038e27 GIT binary patch literal 53367 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

-NdSJZCpDvH2CPr!gZfi-yJota)w6XDl6^y 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 z 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 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));