From f03da42b5eaeafc9950229071f0e59843d2604ee Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 20 May 2026 21:03:41 +0100 Subject: [PATCH] More clean up --- src/app-constants.ts | 2 +- src/audio/garden-audio-config.ts | 158 ++++--- src/audio/garden-audio-energy.ts | 13 +- src/audio/garden-audio-graph.ts | 20 +- src/audio/garden-audio-music.ts | 32 +- src/audio/garden-audio-types.ts | 1 + src/audio/garden-audio.ts | 18 +- src/audio/generative-piano.ts | 67 +-- src/audio/piano-sampler.ts | 8 +- src/config.ts | 90 +--- src/config/default-settings.ts | 7 +- src/config/runtime-controls.ts | 392 +++--------------- src/config/types.ts | 26 +- src/config/vibe-presets.ts | 106 ++--- src/game-loop/game-loop-resources.ts | 19 +- src/game-loop/game-loop.ts | 25 +- src/game-loop/simulation-frame.ts | 63 ++- src/game-loop/simulation-textures.ts | 25 +- src/game-loop/toolbar-contrast-monitor.ts | 2 +- src/index.ts | 29 +- src/page/config-pane.ts | 389 +++++++++-------- src/pipelines/agents/agent-dispatch.ts | 23 +- .../agent-generation/agent-compaction.wgsl | 24 +- .../agent-generation-pipeline.ts | 40 +- .../agents/agent-generation/agent-resize.wgsl | 5 +- .../agents/agent-generation/agent-schema.wgsl | 4 +- src/pipelines/agents/agent-pipeline.ts | 4 +- src/pipelines/agents/agent.wgsl | 33 +- src/pipelines/brush/brush-pipeline.ts | 17 +- src/pipelines/brush/brush.wgsl | 59 ++- src/pipelines/diffusion/diffuse.wgsl | 41 +- src/pipelines/diffusion/diffusion-pipeline.ts | 4 +- src/pipelines/eraser/eraser-agent-pipeline.ts | 8 +- src/pipelines/eraser/eraser-agent.wgsl | 11 +- .../eraser/eraser-texture-pipeline.ts | 7 - src/pipelines/eraser/eraser-texture.wgsl | 63 +-- src/pipelines/render/render-pipeline.ts | 7 +- src/pipelines/render/render.wgsl | 28 +- src/settings.ts | 5 - src/utils/graphics/initialize-context.ts | 2 +- src/utils/graphics/noise.ts | 6 +- src/utils/rgb-color.ts | 27 ++ src/vibes.ts | 2 +- 43 files changed, 827 insertions(+), 1085 deletions(-) diff --git a/src/app-constants.ts b/src/app-constants.ts index e7ebffb..3f67061 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -4,7 +4,7 @@ export const DISABLED_FLAG_VALUE = '0'; export const UNIT_INTERVAL_INPUT_MIN = '0'; export const UNIT_INTERVAL_INPUT_MAX = '1'; -export const DEFAULT_AUDIO_VOLUME = 0.42; +export const DEFAULT_AUDIO_VOLUME = 0.5; export const APP_STORAGE_KEYS = { audioMuted: 'fleeting-garden:audio-muted', diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index baa8eb4..1ceeef7 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,3 +1,4 @@ +import { DEFAULT_AUDIO_VOLUME } from '../app-constants'; import type { PianoNoteRole } from './garden-audio-types'; type GardenAudioChordQuality = 'major' | 'minor'; @@ -7,81 +8,106 @@ export interface GardenAudioChord { quality: GardenAudioChordQuality; } -export interface GardenAudioVibeProfile { +export interface GardenAudioVibeSettings { + idleIntensity: number; + bpm: number; + rampUpIntensity: number; + rampUpTime: number; + noteLength: number; + notePitchOffset: number; + brightness: number; +} + +export interface GardenAudioVibeProfile extends GardenAudioVibeSettings { rootMidi: number; scale: Array; - brightness: number; - delayTimeMultiplier: number; progression: Array; } -export interface GardenAudioConfig { - masterVolume: number; - fadeInSeconds: number; - updateRampSeconds: number; +export const createGardenAudioConfig = () => ({ + masterVolume: DEFAULT_AUDIO_VOLUME, + fadeInSeconds: 0.45, + updateRampSeconds: 0.08, delay: { - timeSeconds: number; - feedback: number; - wetGain: number; - erasingActivity: number; - activityFeedbackWeight: number; - feedbackMax: number; - feedbackMin: number; - outputActivityWeight: number; - outputBase: number; - outputActivityDuck: number; - timeRampSeconds: number; - }; + timeSeconds: 0.46, + feedback: 0.12, + wetGain: 0.044, + erasingActivity: 0.12, + activityFeedbackWeight: 0.08, + feedbackMax: 0.32, + feedbackMin: 0.04, + outputActivityWeight: 0.5, + outputBase: 0.65, + outputActivityDuck: 0.28, + timeRampSeconds: 0.12, + }, piano: { - maxVoices: number; - gain: number; - sustainSeconds: number; - sustainLevel: number; - releaseSeconds: number; - lowpassHz: number; - gainAttackSeconds: number; - lowpassMaxHz: number; - lowpassMinHz: number; - sustainBase: number; - sustainVelocityRange: number; - }; + maxVoices: 24, + gain: 0.48, + sustainSeconds: 0.42, + sustainLevel: 0.32, + releaseSeconds: 0.24, + lowpassHz: 7600, + gainAttackSeconds: 0.006, + lowpassMaxHz: 12000, + lowpassMinHz: 1400, + sustainBase: 0.45, + sustainVelocityRange: 0.55, + }, rhythm: { - bpm: number; - stepsPerBeat: number; - stepsPerBar: number; - sparseActivity: number; - }; + idleIntensity: 0.08, + bpm: 74, + stepsPerBeat: 4, + stepsPerBar: 16, + sparseActivity: 0.055, + }, eraser: { - minIntervalSeconds: number; - noiseGain: number; - filterMinHz: number; - filterMaxHz: number; - durationSeconds: number; - pan: number; - pianoActivity: number; - }; + minIntervalSeconds: 0.12, + noiseGain: 0.028, + filterMinHz: 650, + filterMaxHz: 3600, + durationSeconds: 0.08, + pan: 0, + pianoActivity: 0, + }, energy: { - attackSeconds: number; - decaySeconds: number; - immediateActivityScale: number; - releaseSeconds: number; - strokeDecaySeconds: number; - }; + attackSeconds: 0.08, + decaySeconds: 0.9, + immediateActivityScale: 0.85, + releaseSeconds: 1.15, + strokeDecaySeconds: 0.32, + }, graph: { - pianoBusGains: Record; - pianoBusActivityDucking: Record; - noiseBusGain: number; - }; + pianoBusGains: { + pad: 0.86, + support: 0.94, + texture: 0.88, + gesture: 1, + brush: 0.9, + stinger: 0.92, + } satisfies Record, + pianoBusActivityDucking: { + pad: 0.42, + support: 0.18, + texture: -0.06, + gesture: 0, + brush: -0.08, + stinger: 0, + } satisfies Record, + noiseBusGain: 0.72, + }, input: { - fullActivitySpeed: number; - activityNoiseFloorSpeed: number; - activityCurve: number; - activitySoftCeiling: number; - activityAttackSeconds: number; - activityReleaseSeconds: number; - minAudibleDistance: number; - manicActivityThreshold: number; - manicReleaseThreshold: number; - maniaSmoothingSeconds: number; - }; -} + fullActivitySpeed: 0.86, + activityNoiseFloorSpeed: 0.025, + activityCurve: 0.74, + activitySoftCeiling: 0.96, + activityAttackSeconds: 0.055, + activityReleaseSeconds: 0.2, + minAudibleDistance: 0.0025, + manicActivityThreshold: 0.9, + manicReleaseThreshold: 0.76, + maniaSmoothingSeconds: 0.12, + }, +}); + +export type GardenAudioConfig = ReturnType; diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts index 7ab46f2..7df3eb8 100644 --- a/src/audio/garden-audio-energy.ts +++ b/src/audio/garden-audio-energy.ts @@ -1,5 +1,5 @@ import { approach, clamp01 } from '../utils/math'; -import type { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; export class GardenAudioEnergy { private isGestureActive = false; @@ -19,13 +19,10 @@ export class GardenAudioEnergy { this.targetEnergy = 0; } - public recordStroke(strokeEnergy: number): void { + public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void { this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy); if (this.isGestureActive) { - this.energy = Math.max( - this.energy, - strokeEnergy * this.config.energy.immediateActivityScale - ); + this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity); } } @@ -34,7 +31,7 @@ export class GardenAudioEnergy { this.energy = 0; } - public update(now: number): void { + public update(now: number, profile: GardenAudioVibeProfile): void { if (this.lastEnergyUpdateAt <= 0) { this.lastEnergyUpdateAt = now; return; @@ -51,7 +48,7 @@ export class GardenAudioEnergy { if (!this.isGestureActive) { timeConstant = this.config.energy.releaseSeconds; } else if (target > this.energy) { - timeConstant = this.config.energy.attackSeconds; + timeConstant = profile.rampUpTime; } this.energy = approach(this.energy, target, elapsedSeconds, timeConstant); } diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index 628bfe0..88010ea 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -1,9 +1,17 @@ import { clamp } from '../utils/math'; -import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; +import type { GardenAudioConfig } from './garden-audio-config'; import type { PianoNoteRole } from './garden-audio-types'; type NavigatorWithAudioSession = Navigator & { - audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' }; + audioSession?: { + type: + | 'auto' + | 'playback' + | 'ambient' + | 'transient' + | 'transient-solo' + | 'play-and-record'; + }; }; const outputHighPassFrequencyHz = 45; @@ -113,19 +121,19 @@ export class GardenAudioGraph { ); } - public applyDelayProfile(profile: GardenAudioVibeProfile): void { + public applyDelayProfile(): void { if (!this.context || !this.delayNode) { return; } this.delayNode.delayTime.setTargetAtTime( - this.config.delay.timeSeconds * profile.delayTimeMultiplier, + this.config.delay.timeSeconds, this.context.currentTime, this.config.delay.timeRampSeconds ); } - public updateDelay(profile: GardenAudioVibeProfile, activity: number): void { + public updateDelay(activity: number): void { if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { return; } @@ -133,7 +141,7 @@ export class GardenAudioGraph { const now = this.context.currentTime; const normalizedActivity = clamp(activity, 0, 1); this.delayNode.delayTime.setTargetAtTime( - this.config.delay.timeSeconds * profile.delayTimeMultiplier, + this.config.delay.timeSeconds, now, this.config.delay.timeRampSeconds ); diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 695964f..67be4bf 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -1,6 +1,34 @@ import type { VibePreset } from '../vibes'; -import type { GardenAudioVibeProfile } from './garden-audio-config'; +import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config'; export const PITCH_SEMITONES_PER_OCTAVE = 12; -export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio; +const DEFAULT_PROGRESSION: ReadonlyArray = [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 7, quality: 'major' }, +]; + +const DEFAULT_ROOT_MIDI = 57; +const DEFAULT_SCALE: ReadonlyArray = [0, 2, 4, 7, 9]; + +const profileCache = new WeakMap(); + +export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => { + let profile = profileCache.get(vibe); + if (!profile) { + profile = { + ...vibe.audio, + rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset, + scale: DEFAULT_SCALE as Array, + progression: DEFAULT_PROGRESSION as Array, + }; + profileCache.set(vibe, profile); + return profile; + } + + Object.assign(profile, vibe.audio); + profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset; + return profile; +}; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 72e19ef..59aef48 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -32,6 +32,7 @@ export interface PianoNote { role?: PianoNoteRole; delaySend?: number; lowpassHz?: number; + sustainSeconds?: number; } export type PianoNoteRole = diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 668564e..54d730c 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -114,7 +114,7 @@ export class GardenAudio { this.lifecycle = 'started'; this.applyVibe(vibe); - this.pianoEngine.prime(context.currentTime); + this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe)); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); const pianoLoad = this.piano.loadIfIdle(context); @@ -122,7 +122,7 @@ export class GardenAudio { void pianoLoad .then(() => { if (this.graph.context === context && this.lifecycle !== 'destroyed') { - this.pianoEngine.cue(context.currentTime); + this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); } }) .catch((error) => { @@ -207,7 +207,8 @@ export class GardenAudio { } this.applyVibe(snapshot.vibe); - this.energy.update(context.currentTime); + const profile = getVibeProfile(snapshot.vibe); + this.energy.update(context.currentTime, profile); if (snapshot.isErasing) { this.energy.silence(); @@ -254,7 +255,8 @@ export class GardenAudio { return; } - this.energy.recordStroke(strokeEnergy); + const profile = getVibeProfile(stroke.vibe); + this.energy.recordStroke(strokeEnergy, profile); this.pianoEngine.recordStroke({ vibe: stroke.vibe, now, @@ -347,11 +349,10 @@ export class GardenAudio { return; } - const profile = getVibeProfile(snapshot.vibe); const activity = snapshot.isErasing ? this.config.delay.erasingActivity : this.energy.getLevel(); - this.graph.updateDelay(profile, activity); + this.graph.updateDelay(activity); } private applyVibe(vibe: VibePreset): void { @@ -360,7 +361,8 @@ export class GardenAudio { } this.currentVibeId = vibe.id; - this.graph.applyDelayProfile(getVibeProfile(vibe)); - this.pianoEngine.cue(this.graph.context.currentTime); + const profile = getVibeProfile(vibe); + this.graph.applyDelayProfile(); + this.pianoEngine.cue(this.graph.context.currentTime, profile); } } diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 059288c..07bce8a 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -37,10 +37,7 @@ const getChordIntervals = ( : chordVoicings.minorClosed; }; -const degreeToSemitone = ( - profile: GardenAudioVibeProfile, - degree: number -): number => { +const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => { const scaleIndex = ((degree % profile.scale.length) + profile.scale.length) % profile.scale.length; const octave = Math.floor(degree / profile.scale.length); @@ -96,6 +93,7 @@ interface BrushPhraseLayer { export class GenerativePianoEngine { private nextBeatStep: number | null = null; private timelineStartedAt: number | null = null; + private activeProfile: GardenAudioVibeProfile | null = null; private isWaitingForGestureAccent = false; private lastGestureAccentAt = Number.NEGATIVE_INFINITY; private lastStrokeAccentStep = Number.NEGATIVE_INFINITY; @@ -124,13 +122,17 @@ export class GenerativePianoEngine { return generativePianoTuning; } - public prime(now: number): void { + public prime(now: number, profile: GardenAudioVibeProfile): void { + this.activeProfile = profile; this.timelineStartedAt ??= now; this.nextBeatStep ??= 0; this.nextBrushStreamStep ??= 0; } - public cue(now: number): void { + public cue(now: number, profile?: GardenAudioVibeProfile): void { + if (profile) { + this.activeProfile = profile; + } this.nextBeatStep = 0; this.timelineStartedAt = now; this.nextBrushStreamStep = 0; @@ -148,10 +150,9 @@ export class GenerativePianoEngine { } public release(vibe: VibePreset, now: number): number { - this.prime(now); - this.isWaitingForGestureAccent = false; - const profile = getVibeProfile(vibe); + this.prime(now, profile); + this.isWaitingForGestureAccent = false; const releaseStep = this.getReleaseResolutionStep(now); const releaseStart = Math.max(now, this.getTimeForStep(releaseStep)); @@ -198,7 +199,8 @@ export class GenerativePianoEngine { activity, maniaAmount = 0, }: StrokeAccentRequest): void { - this.prime(now); + const profile = getVibeProfile(vibe); + this.prime(now, profile); const strength = clamp01(activity); const normalizedManiaAmount = clamp01(maniaAmount); const styleIndex = this.getStyleIndex(now); @@ -242,14 +244,14 @@ export class GenerativePianoEngine { activity, lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS, }: RenderLookaheadRequest): void { - this.prime(now); + const profile = getVibeProfile(vibe); + this.prime(now, profile); this.skipLateBeats(now); if (this.nextBeatStep === null) { return; } - const profile = getVibeProfile(vibe); const lookaheadEnd = now + lookaheadSeconds; while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) { const beatIndex = this.getBeatIndexForStep(this.nextBeatStep); @@ -286,7 +288,7 @@ export class GenerativePianoEngine { { baseMidi: rootMidi, offsets }, this.generation.padRegisters[index] ); - this.playNote({ + this.playProfileNote(profile, { midi, velocity: stinger.velocities[index], pan: stinger.pans[index], @@ -302,6 +304,7 @@ export class GenerativePianoEngine { public reset(): void { this.nextBeatStep = null; this.timelineStartedAt = null; + this.activeProfile = null; this.isWaitingForGestureAccent = false; this.lastGestureAccentAt = Number.NEGATIVE_INFINITY; this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY; @@ -314,6 +317,13 @@ export class GenerativePianoEngine { this.brushStreamNoteCountsByBar.clear(); } + private playProfileNote(profile: GardenAudioVibeProfile, note: PianoNote): void { + this.playNote({ + ...note, + sustainSeconds: profile.noteLength, + }); + } + private renderBeat({ profile, beatIndex, @@ -398,7 +408,7 @@ export class GenerativePianoEngine { false ); this.lastPadMidiByVoice[index] = midi; - this.playNote({ + this.playProfileNote(profile, { midi, velocity: velocity + expression * this.generation.padChord.expressionVelocityWeight, @@ -433,8 +443,13 @@ export class GenerativePianoEngine { offsetsByVoice.forEach((offsets, index) => { const register = this.generation.padRegisters[index]; - const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false); - this.playNote({ + const midi = this.chooseMidi( + { baseMidi: rootMidi, offsets }, + register, + null, + false + ); + this.playProfileNote(profile, { midi, velocity: release.velocities[index], startTime: startTime + index * release.strumSeconds, @@ -470,7 +485,7 @@ export class GenerativePianoEngine { ); this.lastMidiByStyle[styleIndex] = midi; - this.playNote({ + this.playProfileNote(profile, { midi, velocity: (this.generation.supportNote.velocityBase + @@ -516,7 +531,7 @@ export class GenerativePianoEngine { ); this.lastMidiByStyle[styleIndex] = midi; - this.playNote({ + this.playProfileNote(profile, { midi, velocity: (this.generation.textureNote.velocityBase + @@ -563,7 +578,7 @@ export class GenerativePianoEngine { ); this.lastMidiByStyle[styleIndex] = midi; - this.playNote({ + this.playProfileNote(profile, { midi, velocity: (this.generation.gestureAccent.velocityBase + @@ -608,7 +623,7 @@ export class GenerativePianoEngine { this.lastMidiByStyle[styleIndex] = midi; this.lastBrushStreamMidi = midi; - this.playNote({ + this.playProfileNote(profile, { midi, velocity: (this.generation.touchNote.velocityBase + @@ -818,7 +833,7 @@ export class GenerativePianoEngine { this.lastBrushStreamMidi = midi; this.lastMidiByStyle[styleIndex] = midi; - this.playNote({ + this.playProfileNote(profile, { midi, velocity: (this.generation.brushStream.velocityBase + @@ -851,7 +866,7 @@ export class GenerativePianoEngine { this.generation.brushStreamEcho.maxMidi ? midi + this.generation.brushStreamEcho.octaveSemitones : midi - this.generation.brushStreamEcho.octaveSemitones; - this.playNote({ + this.playProfileNote(profile, { midi: echoMidi, velocity: (this.generation.brushStreamEcho.velocityBase + @@ -1159,14 +1174,18 @@ export class GenerativePianoEngine { } private getExpression(activity: number): number { + const liftedActivity = Math.max( + activity, + this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity + ); return clamp01( - (activity - this.config.rhythm.sparseActivity) / + (liftedActivity - this.config.rhythm.sparseActivity) / (1 - this.config.rhythm.sparseActivity) ); } private getBeatDurationSeconds(): number { - return 60 / this.config.rhythm.bpm; + return 60 / (this.activeProfile?.bpm ?? this.config.rhythm.bpm); } private getStepDurationSeconds(): number { diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 744aad0..57d6648 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -72,6 +72,7 @@ export class PianoSampler { role, delaySend = 0, lowpassHz = this.config.piano.lowpassHz, + sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds, }: PianoNote): void { const { context } = this.graph; const eventBus = this.graph.getPianoBus(role); @@ -92,12 +93,11 @@ export class PianoSampler { this.config.piano.gain * noteVelocity ); const sustainSeconds = - this.config.piano.sustainSeconds * + profileSustainSeconds * (this.config.piano.sustainBase + noteVelocity * this.config.piano.sustainVelocityRange); const sustainAt = - scheduledStart + - Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); + scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); const releaseAt = sustainAt + sustainSeconds; const stopAt = releaseAt + this.config.piano.releaseSeconds; const source = context.createBufferSource(); @@ -150,7 +150,7 @@ export class PianoSampler { const releaseAt = scheduledStart + clamp( - durationSeconds + this.config.piano.sustainSeconds * 0.5, + durationSeconds + profileSustainSeconds * 0.5, pianoSamplerTuning.minDurationSeconds, pianoSamplerTuning.synthMaxDurationSeconds ); diff --git a/src/config.ts b/src/config.ts index 56c5780..5445408 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants'; +import { createGardenAudioConfig } from './audio/garden-audio-config'; import { defaultSettings } from './config/default-settings'; import { runtimeControls } from './config/runtime-controls'; import type { GardenAppConfig } from './config/types'; @@ -14,90 +15,7 @@ export type { } from './config/types'; export const appConfig = { - audio: { - masterVolume: DEFAULT_AUDIO_VOLUME, - fadeInSeconds: 0.45, - updateRampSeconds: 0.08, - delay: { - timeSeconds: 0.46, - feedback: 0.12, - wetGain: 0.044, - erasingActivity: 0.12, - activityFeedbackWeight: 0.08, - feedbackMax: 0.32, - feedbackMin: 0.04, - outputActivityWeight: 0.5, - outputBase: 0.65, - outputActivityDuck: 0.28, - timeRampSeconds: 0.12, - }, - piano: { - maxVoices: 24, - gain: 0.48, - sustainSeconds: 0.42, - sustainLevel: 0.32, - releaseSeconds: 0.24, - lowpassHz: 7600, - gainAttackSeconds: 0.006, - lowpassMaxHz: 12000, - lowpassMinHz: 1400, - sustainBase: 0.45, - sustainVelocityRange: 0.55, - }, - rhythm: { - bpm: 74, - stepsPerBeat: 4, - stepsPerBar: 16, - sparseActivity: 0.055, - }, - eraser: { - minIntervalSeconds: 0.12, - noiseGain: 0.028, - filterMinHz: 650, - filterMaxHz: 3600, - durationSeconds: 0.08, - pan: 0, - pianoActivity: 0, - }, - energy: { - attackSeconds: 0.08, - decaySeconds: 0.9, - immediateActivityScale: 0.85, - releaseSeconds: 1.15, - strokeDecaySeconds: 0.32, - }, - graph: { - pianoBusGains: { - pad: 0.86, - support: 0.94, - texture: 0.88, - gesture: 1, - brush: 0.9, - stinger: 0.92, - }, - pianoBusActivityDucking: { - pad: 0.42, - support: 0.18, - texture: -0.06, - gesture: 0, - brush: -0.08, - stinger: 0, - }, - noiseBusGain: 0.72, - }, - input: { - fullActivitySpeed: 0.86, - activityNoiseFloorSpeed: 0.025, - activityCurve: 0.74, - activitySoftCeiling: 0.96, - activityAttackSeconds: 0.055, - activityReleaseSeconds: 0.2, - minAudibleDistance: 0.0025, - manicActivityThreshold: 0.9, - manicReleaseThreshold: 0.76, - maniaSmoothingSeconds: 0.12, - }, - }, + audio: createGardenAudioConfig(), deltaTime: { maxDeltaTimeSeconds: 1 / 30, minDeltaTimeSeconds: 1 / 240, @@ -124,7 +42,7 @@ export const appConfig = { noiseHashMultiplier: 43758.5453123, noiseHashX: 12.9898, noiseHashY: 78.233, - noiseTextureFormat: 'rgba8unorm', + noiseTextureFormat: 'r8unorm', noiseTextureSize: 2048, }, brush: { @@ -261,7 +179,7 @@ export const appConfig = { }, }, tuningPane: { - expandedDepth: 1, + showFpsOverlay: import.meta.env.DEV, startHidden: true, title: 'Garden Config', }, diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index c0b5cb4..39e1e55 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -1,12 +1,11 @@ -import { colorInteractionSettings } from './color-interactions'; import type { GardenAppConfig } from './types'; export const defaultSettings: GardenAppConfig['defaultSettings'] = { selectedColorIndex: 0, - ...colorInteractionSettings, turnWhenLost: 0.8, forwardRotationScale: 0.25, + sensorOffsetAngle: 32, introNearDistanceMin: 28, introNearDistanceInner: 4, introNearSensorOffsetMultiplier: 0.75, @@ -19,7 +18,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { introStepStopDistance: 0.5, randomTimeScale: 0.34816, - diffusionRateBrush: 0.35, + diffusionRateTrails: 0.22, decayRateBrush: 18, diffusionDecayRateDivisor: 1000, diffusionNeighborDivisor: 8, @@ -33,7 +32,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { brushCurveSegmentBrushRadiusRatio: 0.65, brushSmoothingMinSampleDistance: 0.5, - brushSizeVariation: 0.5, brushAlpha: 1, brushDiscardThreshold: 0.02, brushCoarseNoiseScale: 160, @@ -58,5 +56,4 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { renderTraceNormalizationFloor: 1, renderBrushColorBase: 1.2, renderBrushColorStrengthMultiplier: 1.6, - backgroundGrainStrength: 0.018, }; diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index 6474c8e..eb3895b 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -11,380 +11,100 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { color3ToColor1: colorInteractionControl('3 -> 1'), color3ToColor2: colorInteractionControl('3 -> 2'), color3ToColor3: colorInteractionControl('3 -> 3'), - brushEffectDuration: { - folder: 'Diffusion', - min: 0.5, - max: 20, - step: 0.05, - }, + brushSize: { folder: 'Brush', + label: 'brush size', min: 1, max: 60, step: 0.25, }, + spawnPerPixel: { + folder: 'Brush', + label: 'agents per brush pixel', + min: 0.01, + max: 1, + step: 0.001, + }, brushSizeVariation: { folder: 'Brush', + label: 'brush variance', min: 0, max: 1, step: 0.01, }, - brushAlpha: { - folder: 'Brush', - min: 0, - max: 1, - step: 0.001, - }, - brushDiscardThreshold: { - folder: 'Brush', - min: 0, - max: 0.25, - step: 0.001, - }, - brushCoarseNoiseScale: { - folder: 'Brush', - min: 1, - max: 480, - step: 1, - }, - brushGrainNoiseScale: { - folder: 'Brush', - min: 1, - max: 180, - step: 1, - }, - brushGrainNoiseOffsetX: { - folder: 'Brush', - min: -2, - max: 2, - step: 0.01, - }, - brushGrainNoiseOffsetY: { - folder: 'Brush', - min: -2, - max: 2, - step: 0.01, - }, - brushGrainMinStrength: { - folder: 'Brush', - min: 0, - max: 1, - step: 0.001, - }, - brushGrainMaxStrength: { - folder: 'Brush', - min: 0, - max: 2, - step: 0.001, - }, - brushCurveResolution: { - folder: 'Brush', - integer: true, - label: 'curve resolution', - min: 1, - max: 32, - step: 1, - }, - brushSmoothingMinSampleDistance: { - folder: 'Brush', - min: 0, - max: 4, - step: 0.01, - }, - brushCurveMinSegmentSpacing: { - folder: 'Brush', - min: 0.1, - max: 32, - step: 0.1, - }, - brushCurveSegmentBrushRadiusRatio: { - folder: 'Brush', - min: 0, - max: 4, - step: 0.01, - }, - brushCurveMinBrushRadius: { - folder: 'Brush', - min: 0.1, - max: 16, - step: 0.1, - }, - brushCurveMirrorResolutionExponent: { - folder: 'Brush', - min: 0, - max: 2, - step: 0.01, - }, - clarity: { - folder: 'Render', - min: 0.00001, - max: 1, - step: 0.001, - }, - renderTraceNormalizationFloor: { - folder: 'Render', - min: 0.01, - max: 4, - step: 0.01, - }, - renderBrushColorBase: { - folder: 'Render', - min: 0, - max: 4, - step: 0.01, - }, - renderBrushColorStrengthMultiplier: { - folder: 'Render', - min: 0, - max: 4, - step: 0.01, - }, - backgroundGrainStrength: { - folder: 'Render', - min: 0, - max: 0.12, - step: 0.001, - }, - internalRenderAreaMegapixels: { - folder: 'Render', - label: 'internal area (MP)', - min: 0.5, - max: 16.6, - step: 0.1, - }, - decayRateBrush: { - folder: 'Diffusion', - min: 0.1, - max: 100, - step: 0.1, - }, - decayRateTrails: { - folder: 'Diffusion', - min: 0.1, - max: 5000, - step: 1, - }, diffusionRateBrush: { - folder: 'Diffusion', + folder: 'Brush', + label: 'brush diffusion', min: 0.001, max: 1, step: 0.001, }, - diffusionDecayRateDivisor: { - folder: 'Diffusion', - min: 1, - max: 5000, - step: 1, - }, - diffusionNeighborDivisor: { - folder: 'Diffusion', - min: 1, - max: 16, - step: 0.1, - }, - brushDecayAlphaOffset: { - folder: 'Diffusion', - min: 1, - max: 1.1, - step: 0.0001, - }, - diffusionRateTrails: { - folder: 'Diffusion', + + sensorOffsetDistance: { + folder: 'Agents', + label: 'sensor distance', min: 0, - max: 2, - step: 0.001, - }, - eraserSize: { - folder: 'Brush', - integer: true, - min: 24, - max: 240, - step: 1, - }, - eraserMaskAlphaThreshold: { - folder: 'Eraser', - min: 0, - max: 1, - step: 0.001, - }, - eraserLineDistanceEpsilon: { - folder: 'Eraser', - min: 0, - max: 0.01, - step: 0.00001, - }, - eraserClearRed: { - folder: 'Eraser', - min: 0, - max: 1, - step: 0.001, - }, - eraserClearGreen: { - folder: 'Eraser', - min: 0, - max: 1, - step: 0.001, - }, - eraserClearBlue: { - folder: 'Eraser', - min: 0, - max: 1, - step: 0.001, - }, - eraserClearAlpha: { - folder: 'Eraser', - min: 0, - max: 1, - step: 0.001, - }, - individualTrailWeight: { - folder: 'Agent', - min: 0, - max: 1, - step: 0.001, - }, - adaptiveCapInitial: { - folder: 'Agent', - integer: true, - label: 'adaptive cap initial', - min: 50_000, - max: 2_000_000, - step: 10_000, - }, - adaptiveCapMin: { - folder: 'Agent', - integer: true, - label: 'adaptive cap min', - min: 0, - max: 500_000, - step: 10_000, - }, - maxAgentCount: { - folder: 'Agent', - integer: true, - label: 'max agent count', - step: 10_000, - }, - mirrorSegmentCount: { - folder: 'Brush', - integer: true, - min: 1, - max: 12, + max: 200, step: 1, }, moveSpeed: { - folder: 'Agent', + folder: 'Agents', + label: 'move speed', min: 10, max: 500, step: 1, }, - selectedColorIndex: { - folder: 'Brush', - integer: true, - min: 0, - max: 2, - step: 1, - }, - sensorOffsetAngle: { - folder: 'Agent', - min: 0, - max: 90, - step: 1, - }, - sensorOffsetDistance: { - folder: 'Agent', - min: 0, - max: 200, - step: 1, - }, - spawnPerPixel: { - folder: 'Agent', - min: 0.01, - max: 1, - step: 0.001, - }, turnSpeed: { - folder: 'Agent', + folder: 'Agents', + label: 'turn speed', min: 1, max: 200, step: 1, }, - turnWhenLost: { - folder: 'Agent', + individualTrailWeight: { + folder: 'Agents', + label: 'individual trail weight', min: 0, max: 1, step: 0.001, }, - forwardRotationScale: { - folder: 'Agent', - min: 0, + decayRateTrails: { + folder: 'Agents', + label: 'trail decay', + min: 800, + max: 1000, + step: 1, + }, + + clarity: { + folder: 'Look', + label: 'clarity', + min: 0.00001, max: 1, step: 0.001, }, - introNearDistanceMin: { - folder: 'Agent', + backgroundGrainStrength: { + folder: 'Look', + label: 'grain strength', min: 0, - max: 100, + max: 0.12, + step: 0.001, + }, + + maxAgentCount: { + folder: 'Performance', + integer: true, + label: 'max agent count', + min: 0, + max: 2_000_000, + step: 10_000, + }, + internalRenderAreaMegapixels: { + folder: 'Performance', + label: 'internal resolution (MP)', + min: 0.5, + max: 16.6, step: 0.1, }, - introNearDistanceInner: { - folder: 'Agent', - min: 0, - max: 100, - step: 0.1, - }, - introNearSensorOffsetMultiplier: { - folder: 'Agent', - min: 0, - max: 4, - step: 0.01, - }, - introTargetAngleBlend: { - folder: 'Agent', - min: 0, - max: 1, - step: 0.001, - }, - introProgressCutoff: { - folder: 'Agent', - min: 0, - max: 1, - step: 0.001, - }, - introTurnRateMultiplier: { - folder: 'Agent', - min: 0, - max: 8, - step: 0.01, - }, - introRandomTurnMultiplier: { - folder: 'Agent', - min: 0, - max: 2, - step: 0.001, - }, - introFarMoveMultiplier: { - folder: 'Agent', - min: 0, - max: 6, - step: 0.01, - }, - introNearMoveMultiplier: { - folder: 'Agent', - min: 0, - max: 1, - step: 0.001, - }, - introStepStopDistance: { - folder: 'Agent', - min: 0, - max: 8, - step: 0.01, - }, - randomTimeScale: { - folder: 'Agent', - min: 0, - max: 4, - step: 0.00001, - }, }; diff --git a/src/config/types.ts b/src/config/types.ts index a237c17..7d2c140 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,6 +1,6 @@ import type { GardenAudioConfig, - GardenAudioVibeProfile, + GardenAudioVibeSettings, } from '../audio/garden-audio-config'; import type { AgentSettings } from '../pipelines/agents/agent-settings'; import type { BrushSettings } from '../pipelines/brush/brush-settings'; @@ -45,19 +45,29 @@ export type GardenRuntimeSettings = { DiffusionSettings & RenderSettings; -type RuntimeSettingControlConfig = { - [Key in keyof GardenRuntimeSettings]: NumberControlConfig; -}; +type RuntimeSettingControlConfig = Partial< + Record +>; type GardenVibeSettings = Pick< GardenRuntimeSettings, + | 'backgroundGrainStrength' | 'brushSize' + | 'brushSizeVariation' | 'clarity' + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' | 'decayRateTrails' - | 'diffusionRateTrails' + | 'diffusionRateBrush' | 'individualTrailWeight' | 'moveSpeed' - | 'sensorOffsetAngle' | 'sensorOffsetDistance' | 'spawnPerPixel' | 'turnSpeed' @@ -83,7 +93,7 @@ export interface VibePreset { colors: [RgbColor, RgbColor, RgbColor]; backgroundColor: RgbColor; settings: GardenVibeSettings; - audio: GardenAudioVibeProfile; + audio: GardenAudioVibeSettings; } export interface GardenAppConfig { @@ -239,7 +249,7 @@ export interface GardenAppConfig { }; }; tuningPane: { - expandedDepth: number; + showFpsOverlay: boolean; startHidden: boolean; title: string; }; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index 000e65a..e8c419d 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -1,25 +1,15 @@ -import type { GardenAudioChord } from '../audio/garden-audio-config'; +import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; +import { colorInteractionSettings } from './color-interactions'; import { VibeId, type VibePreset } from './types'; -const majorProgression: Array = [ - { rootOffset: 0, quality: 'major' }, - { rootOffset: 9, quality: 'minor' }, - { rootOffset: 5, quality: 'major' }, - { rootOffset: 7, quality: 'major' }, -]; - -const minorProgression: Array = [ - { rootOffset: 0, quality: 'minor' }, - { rootOffset: 8, quality: 'major' }, - { rootOffset: 3, quality: 'major' }, - { rootOffset: 10, quality: 'major' }, -]; - -const majorPentatonic = [0, 2, 4, 7, 9]; -const suspendedPentatonic = [0, 2, 5, 7, 9]; -const mixolydianPentatonic = [0, 2, 4, 7, 10]; -const dorianHexatonic = [0, 2, 3, 5, 7, 10]; -const darkMinorPentatonic = [0, 2, 3, 7, 10]; +const defaultAudioSettings = { + idleIntensity: 0.08, + bpm: 74, + rampUpIntensity: 0.85, + rampUpTime: 0.08, + noteLength: 0.42, + notePitchOffset: 0, +} satisfies Omit; export const defaultVibeId = VibeId.CandyRain; @@ -34,23 +24,22 @@ export const vibePresets: Array = [ ], backgroundColor: [16, 21, 31], settings: { + ...colorInteractionSettings, + backgroundGrainStrength: 0.018, brushSize: 14, + brushSizeVariation: 0.5, clarity: 0.62, decayRateTrails: 965, - diffusionRateTrails: 0.22, + diffusionRateBrush: 0.35, individualTrailWeight: 0.07, moveSpeed: 82, - sensorOffsetAngle: 34, sensorOffsetDistance: 38, spawnPerPixel: 0.22, turnSpeed: 58, }, audio: { - rootMidi: 57, - scale: majorPentatonic, + ...defaultAudioSettings, brightness: 1.04, - delayTimeMultiplier: 0.92, - progression: majorProgression, }, }, { @@ -63,28 +52,22 @@ export const vibePresets: Array = [ ], backgroundColor: [23, 32, 22], settings: { + ...colorInteractionSettings, + backgroundGrainStrength: 0.014, brushSize: 16, + brushSizeVariation: 0.35, clarity: 0.68, decayRateTrails: 975, - diffusionRateTrails: 0.18, + diffusionRateBrush: 0.28, individualTrailWeight: 0.06, moveSpeed: 70, - sensorOffsetAngle: 28, sensorOffsetDistance: 46, spawnPerPixel: 0.18, turnSpeed: 44, }, audio: { - rootMidi: 53, - scale: mixolydianPentatonic, + ...defaultAudioSettings, brightness: 0.92, - delayTimeMultiplier: 1.08, - progression: [ - { rootOffset: 0, quality: 'major' }, - { rootOffset: 7, quality: 'major' }, - { rootOffset: 9, quality: 'minor' }, - { rootOffset: 5, quality: 'major' }, - ], }, }, { @@ -97,23 +80,22 @@ export const vibePresets: Array = [ ], backgroundColor: [15, 24, 34], settings: { + ...colorInteractionSettings, + backgroundGrainStrength: 0.022, brushSize: 13, + brushSizeVariation: 0.58, clarity: 0.58, decayRateTrails: 955, - diffusionRateTrails: 0.28, + diffusionRateBrush: 0.42, individualTrailWeight: 0.055, moveSpeed: 90, - sensorOffsetAngle: 36, sensorOffsetDistance: 35, spawnPerPixel: 0.25, turnSpeed: 62, }, audio: { - rootMidi: 50, - scale: dorianHexatonic, + ...defaultAudioSettings, brightness: 1, - delayTimeMultiplier: 1.12, - progression: minorProgression, }, }, { @@ -126,23 +108,22 @@ export const vibePresets: Array = [ ], backgroundColor: [20, 18, 29], settings: { + ...colorInteractionSettings, + backgroundGrainStrength: 0.018, brushSize: 12, + brushSizeVariation: 0.45, clarity: 0.64, decayRateTrails: 968, - diffusionRateTrails: 0.2, + diffusionRateBrush: 0.32, individualTrailWeight: 0.065, moveSpeed: 76, - sensorOffsetAngle: 32, sensorOffsetDistance: 42, spawnPerPixel: 0.2, turnSpeed: 52, }, audio: { - rootMidi: 49, - scale: darkMinorPentatonic, + ...defaultAudioSettings, brightness: 0.9, - delayTimeMultiplier: 1.24, - progression: minorProgression, }, }, { @@ -155,23 +136,22 @@ export const vibePresets: Array = [ ], backgroundColor: [25, 23, 22], settings: { + ...colorInteractionSettings, + backgroundGrainStrength: 0.024, brushSize: 15, + brushSizeVariation: 0.62, clarity: 0.55, decayRateTrails: 948, - diffusionRateTrails: 0.32, + diffusionRateBrush: 0.48, individualTrailWeight: 0.05, moveSpeed: 96, - sensorOffsetAngle: 40, sensorOffsetDistance: 32, spawnPerPixel: 0.24, turnSpeed: 70, }, audio: { - rootMidi: 56, - scale: majorPentatonic, + ...defaultAudioSettings, brightness: 1.08, - delayTimeMultiplier: 0.86, - progression: majorProgression, }, }, { @@ -184,28 +164,22 @@ export const vibePresets: Array = [ ], backgroundColor: [16, 24, 32], settings: { + ...colorInteractionSettings, + backgroundGrainStrength: 0.012, brushSize: 18, + brushSizeVariation: 0.28, clarity: 0.7, decayRateTrails: 982, - diffusionRateTrails: 0.14, + diffusionRateBrush: 0.24, individualTrailWeight: 0.075, moveSpeed: 62, - sensorOffsetAngle: 26, sensorOffsetDistance: 52, spawnPerPixel: 0.16, turnSpeed: 40, }, audio: { - rootMidi: 62, - scale: suspendedPentatonic, + ...defaultAudioSettings, brightness: 0.88, - delayTimeMultiplier: 1.32, - progression: [ - { rootOffset: 0, quality: 'major' }, - { rootOffset: 5, quality: 'major' }, - { rootOffset: 9, quality: 'minor' }, - { rootOffset: 7, quality: 'major' }, - ], }, }, ]; diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 799c413..fc7e924 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -36,7 +36,6 @@ export class GameLoopResources { public readonly eraserAgentPipeline: EraserAgentPipeline; public readonly eraserTexturePipeline: EraserTexturePipeline; public readonly diffusionPipeline: DiffusionPipeline; - public readonly brushEffectDiffusionPipeline: DiffusionPipeline; public readonly renderPipeline: RenderPipeline; private readonly frameRenderer: SimulationFrameRenderer; @@ -74,7 +73,6 @@ export class GameLoopResources { ); this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.diffusionPipeline = new DiffusionPipeline(this.device); - this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device); this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { @@ -83,7 +81,6 @@ export class GameLoopResources { eraserAgentPipeline: this.eraserAgentPipeline, eraserTexturePipeline: this.eraserTexturePipeline, diffusionPipeline: this.diffusionPipeline, - brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline, renderPipeline: this.renderPipeline, }); } @@ -94,6 +91,7 @@ export class GameLoopResources { public clearSimulation(): void { this.textures.clear(); + this.frameRenderer.resetSourceMapActivity(); } public setFrameParameters({ @@ -115,6 +113,7 @@ export class GameLoopResources { this.agentPipeline.setParameters({ ...settings, deltaTime, + time, agentCount: activeAgentCount, moveSpeed: settings.moveSpeed * @@ -138,6 +137,7 @@ export class GameLoopResources { this.eraserAgentPipeline.setParameters({ agentCount: activeAgentCount, eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold, + maskSize: canvasSize, }); this.eraserTexturePipeline.setParameters({ eraserSize: eraserPixelSize, @@ -147,7 +147,6 @@ export class GameLoopResources { eraserClearBlue: settings.eraserClearBlue, eraserClearAlpha: settings.eraserClearAlpha, }); - this.setBrushEffectDiffusionParameters(); } public executeFrame( @@ -164,20 +163,8 @@ export class GameLoopResources { this.eraserAgentPipeline.destroy(); this.eraserTexturePipeline.destroy(); this.diffusionPipeline.destroy(); - this.brushEffectDiffusionPipeline.destroy(); this.renderPipeline.destroy(); this.commonState.destroy(); this.textures.destroy(); } - - private setBrushEffectDiffusionParameters(): void { - const framesToOneE = Math.max( - 1, - settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond - ); - this.brushEffectDiffusionPipeline.setParameters({ - ...settings, - decayRateTrails: Math.exp(-1 / framesToOneE) * 1000, - }); - } } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 78fb15a..6873e2a 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -29,7 +29,7 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly exportSnapshotRenderer: ExportSnapshotRenderer; private readonly framePerformance = new FramePerformance(); - private readonly devStatsOverlay: DevStatsOverlay | null; + private devStatsOverlay: DevStatsOverlay | null = null; private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seed = this.seedValue.toString(16); @@ -47,9 +47,6 @@ export default class GameLoop { ui: GardenUi ) { this.resize(); - this.devStatsOverlay = import.meta.env.DEV - ? new DevStatsOverlay(canvas.parentElement ?? document.body) - : null; this.resources = new GameLoopResources( canvas, device, @@ -103,8 +100,12 @@ export default class GameLoop { }); window.addEventListener('resize', this.resizeListener); - this.pointerInput.attach(); this.eraserPreviewController.attach(); + this.syncDevStatsOverlay(); + } + + public attachPointerInput(): void { + this.pointerInput.attach(); } public setEraseMode(isErasing: boolean): void { @@ -118,6 +119,7 @@ export default class GameLoop { public onVibeChanged(): void { this.agentPopulation.onVibeChanged(); + this.syncDevStatsOverlay(); } public setAudioMuted(isMuted: boolean): void { @@ -153,6 +155,7 @@ export default class GameLoop { this.pointerInput.detach(); this.eraserPreviewController.detach(); this.devStatsOverlay?.destroy(); + this.devStatsOverlay = null; this.toolbarContrastMonitor.destroy(); this.introPrompt.destroy(); await this.agentPopulation.waitForCompaction(); @@ -220,6 +223,18 @@ export default class GameLoop { requestAnimationFrame(this.render); }; + private syncDevStatsOverlay(): void { + if (appConfig.tuningPane.showFpsOverlay) { + this.devStatsOverlay ??= new DevStatsOverlay( + this.canvas.parentElement ?? document.body + ); + return; + } + + this.devStatsOverlay?.destroy(); + this.devStatsOverlay = null; + } + private updateAccentColor(color: RgbColor): void { const accentColor = rgbColorToCss(color); if (this.previousAccentColor === accentColor) { diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index 719f56d..f9c068f 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -13,17 +13,26 @@ interface SimulationFramePipelines { eraserAgentPipeline: EraserAgentPipeline; eraserTexturePipeline: EraserTexturePipeline; diffusionPipeline: DiffusionPipeline; - brushEffectDiffusionPipeline: DiffusionPipeline; renderPipeline: RenderPipeline; } export class SimulationFrameRenderer { + private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600; + + private sourceActiveFramesRemaining = 0; + private sourceMapsCleared = true; + public constructor( private readonly device: GPUDevice, private readonly textures: SimulationTextures, private readonly pipelines: SimulationFramePipelines ) {} + public resetSourceMapActivity(): void { + this.sourceActiveFramesRemaining = 0; + this.sourceMapsCleared = true; + } + public execute( isErasing: boolean, canvasReadbackRequest?: CanvasReadbackRequest | null @@ -31,6 +40,7 @@ export class SimulationFrameRenderer { const commandEncoder = this.device.createCommandEncoder(); this.textures.copyTrailMapAToB(commandEncoder); + let wroteSourceMap = false; if (isErasing) { if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { const eraserMask = this.textures.eraserMask.getTextureView(); @@ -38,18 +48,29 @@ export class SimulationFrameRenderer { commandEncoder, eraserMask, this.textures.sourceMapA.getTextureView(), - this.textures.influenceMapA.getTextureView(), this.textures.trailMapB.getTextureView() ); this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask); } } else { - this.pipelines.brushPipeline.executeMultiTarget( + wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget( commandEncoder, - this.textures.sourceMapA.getTextureView(), - this.textures.influenceMapA.getTextureView() + this.textures.sourceMapA.getTextureView() ); } + + if (wroteSourceMap) { + this.sourceActiveFramesRemaining = + SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE; + this.sourceMapsCleared = false; + } + + const useSourceMap = this.sourceActiveFramesRemaining > 0; + if (!useSourceMap && !this.sourceMapsCleared) { + this.textures.clearSourceMaps(commandEncoder); + this.sourceMapsCleared = true; + } + this.pipelines.agentPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), @@ -64,28 +85,24 @@ export class SimulationFrameRenderer { const canvasTexture = this.pipelines.renderPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), - this.textures.sourceMapA.getTextureView() + this.textures.sourceMapA.getTextureView(), + useSourceMap ); canvasReadbackRequest?.encode(commandEncoder, canvasTexture); + if (useSourceMap) { + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.sourceMapB.getTextureView(), + this.textures.sourceMapB.getSize() + ); + } this.device.queue.submit([commandEncoder.finish()]); canvasReadbackRequest?.afterSubmit(); - - const postRenderCommandEncoder = this.device.createCommandEncoder(); - this.pipelines.diffusionPipeline.execute( - postRenderCommandEncoder, - this.textures.sourceMapA.getTextureView(), - this.textures.sourceMapB.getTextureView(), - this.textures.sourceMapB.getSize() - ); - this.pipelines.brushEffectDiffusionPipeline.execute( - postRenderCommandEncoder, - this.textures.influenceMapA.getTextureView(), - this.textures.influenceMapB.getTextureView(), - this.textures.influenceMapB.getSize() - ); - - this.device.queue.submit([postRenderCommandEncoder.finish()]); - this.textures.swapBrushEffectMaps(); + if (useSourceMap) { + this.textures.swapSourceMaps(); + this.sourceActiveFramesRemaining -= 1; + } } } diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index fd3bc87..90eb9be 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -8,8 +8,6 @@ export class SimulationTextures { public readonly trailMapB: ResizableTexture; public sourceMapA: ResizableTexture; public sourceMapB: ResizableTexture; - public influenceMapA: ResizableTexture; - public influenceMapB: ResizableTexture; public eraserMask: ResizableTexture; public constructor( @@ -20,8 +18,6 @@ export class SimulationTextures { this.trailMapB = this.createTexture(canvasSize); this.sourceMapA = this.createTexture(canvasSize); this.sourceMapB = this.createTexture(canvasSize); - this.influenceMapA = this.createTexture(canvasSize); - this.influenceMapB = this.createTexture(canvasSize); this.eraserMask = this.createEraserMask(canvasSize); } @@ -36,8 +32,6 @@ export class SimulationTextures { this.trailMapB.resize(nextSize); this.sourceMapA.resize(nextSize); this.sourceMapB.resize(nextSize); - this.influenceMapA.resize(nextSize); - this.influenceMapB.resize(nextSize); this.eraserMask.resize(nextSize); return scale; @@ -50,8 +44,6 @@ export class SimulationTextures { this.trailMapB, this.sourceMapA, this.sourceMapB, - this.influenceMapA, - this.influenceMapB, this.eraserMask, ].forEach((texture) => { const passEncoder = commandEncoder.beginRenderPass({ @@ -79,9 +71,20 @@ export class SimulationTextures { ); } - public swapBrushEffectMaps(): void { + public clearSourceMaps(commandEncoder: GPUCommandEncoder): void { + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [this.sourceMapA, this.sourceMapB].map((texture) => ({ + view: texture.getTextureView(), + clearValue: appConfig.simulation.clearColor, + loadOp: 'clear', + storeOp: 'store', + })), + }); + passEncoder.end(); + } + + public swapSourceMaps(): void { [this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA]; - [this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA]; } public destroy(): void { @@ -89,8 +92,6 @@ export class SimulationTextures { this.trailMapB.destroy(); this.sourceMapA.destroy(); this.sourceMapB.destroy(); - this.influenceMapA.destroy(); - this.influenceMapB.destroy(); this.eraserMask.destroy(); } diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts index 42ef8f1..227901d 100644 --- a/src/game-loop/toolbar-contrast-monitor.ts +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -31,7 +31,7 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) + appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue); -export const getToolbarContrastMetrics = ( +const getToolbarContrastMetrics = ( pixels: Uint8Array, sampleCount: number, isBgra: boolean diff --git a/src/index.ts b/src/index.ts index 8552579..823b135 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { ConfigPane } from './page/config-pane'; import { FullScreenHandler } from './page/full-screen-handler'; import { MenuHider } from './page/menu-hider'; -import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings'; +import { activeVibe, applyVibeSettings, settings } from './settings'; import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { queryRequiredElement, queryRequiredElements } from './utils/dom'; @@ -148,7 +148,6 @@ const LOADING_MESSAGES = { const VIBE_CHANGE_SOURCES = { nextButton: 'next-button', previousButton: 'previous-button', - settings: 'settings', } as const; const clampEraserSize = (value: number): number => { @@ -432,6 +431,7 @@ const main = async () => { initAnalytics(); let shouldStop = false; + let hasStarted = false; let game: GameLoop | null = null; let configPane: ConfigPane | null = null; @@ -477,6 +477,7 @@ const main = async () => { const startAudioFromUserGesture = (event: Event) => { if ( + !hasStarted || isAudioMuted || (event.target instanceof Node && elements.startButton.contains(event.target)) || (event.target instanceof Node && elements.soundButton.contains(event.target)) @@ -652,28 +653,6 @@ const main = async () => { }, onOpenChange: () => undefined, onRuntimeChange: syncRuntimeUi, - onRuntimeReset: () => { - resetSettings(); - game?.onVibeChanged(); - syncRuntimeUi(); - }, - onRestart: () => game?.destroy(), - onVibeChange: (vibeId) => { - const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId); - if (!vibe) { - return; - } - - const activePreset = applyVibeSettings(vibe); - trackVibeChange({ - vibeId: activePreset.id, - vibeName: activePreset.name, - source: VIBE_CHANGE_SOURCES.settings, - }); - game?.onVibeChanged(); - syncRuntimeUi(); - game?.playVibeChangeAudio(false); - }, }); infoPageHandler.onOpen = configPane.close.bind(configPane); await fontsReady; @@ -702,6 +681,7 @@ const main = async () => { await new Promise((resolve) => { const onClick = () => { elements.startButton.removeEventListener(DOM_EVENTS.click, onClick); + hasStarted = true; game?.startAudio(true); trackStart(); elements.splash.hidden = true; @@ -731,6 +711,7 @@ const main = async () => { ) ); } + game.attachPointerInput(); await game.start(); } } catch (e) { diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 9e692b3..c601872 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -1,17 +1,32 @@ import type { BindingParams, FolderApi } from '@tweakpane/core'; import { Pane } from 'tweakpane'; +import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; import { appConfig, type GardenRuntimeSettings, type NumberControlConfig, } from '../config'; import { activeVibe, settings } from '../settings'; -import { rgbColorToCss } from '../utils/rgb-color'; -import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes'; +import { + hexColorToRgbColor, + rgbColorToCss, + rgbColorToHex, + type RgbColor, +} from '../utils/rgb-color'; type PaneContainer = Pick; type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number]; +type RuntimeControlKey = keyof GardenRuntimeSettings & string; +type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor'; +type VibeNumberKey = keyof GardenAudioVibeSettings; + +interface PaneState extends GardenAudioVibeSettings { + backgroundColor: string; + color1: string; + color2: string; + color3: string; +} const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; @@ -33,37 +48,54 @@ const colorReactionRows = [ }, ] as const; -const colorReactionKeySet = new Set( - colorReactionRows.flatMap((row) => [...row.keys]) -); +const brushControlKeys = [ + 'brushSize', + 'spawnPerPixel', + 'brushSizeVariation', + 'diffusionRateBrush', +] satisfies Array; -const isColorReactionKey = (key: string): key is ColorReactionKey => - colorReactionKeySet.has(key); +const agentControlKeys = [ + 'sensorOffsetDistance', + 'moveSpeed', + 'turnSpeed', + 'individualTrailWeight', + 'decayRateTrails', +] satisfies Array; + +const lookControlKeys = [ + 'clarity', + 'backgroundGrainStrength', +] satisfies Array; + +const performanceControlKeys = [ + 'maxAgentCount', + 'internalRenderAreaMegapixels', +] satisfies Array; + +const MUSIC_CONTROLS: ReadonlyArray<{ + key: VibeNumberKey; + label: string; + min: number; + max: number; + step: number; +}> = [ + { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 0.8, 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 }, + { key: 'noteLength', label: 'note length', min: 0.1, max: 1.8, step: 0.01 }, + { key: 'notePitchOffset', label: 'higher / lower notes', min: -12, max: 12, step: 1 }, + { key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 }, +]; interface ConfigPaneOptions { onConfigChange: () => void; onOpenChange?: (isOpen: boolean) => void; - onRestart: () => void; onRuntimeChange: () => void; - onRuntimeReset: () => void; - onVibeChange: (vibeId: VibeId) => void; settingsButton: HTMLButtonElement; } -const isPlainObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null; - -const isBindablePrimitive = (value: unknown): value is boolean | number | string => - ['boolean', 'number', 'string'].includes(typeof value); - -const toLabel = (value: string): string => - value - .replace(/\[(\d+)\]/g, ' $1') - .replace(/([A-Z])/g, ' $1') - .replace(/[-_]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - const normalizeNumber = (value: number, config: NumberControlConfig): number => { if (config.options) { const optionValues = Object.values(config.options); @@ -81,12 +113,9 @@ const normalizeNumber = (value: number, config: NumberControlConfig): number => return config.integer ? Math.round(clampedValue) : clampedValue; }; -const getNumberBindingParams = ( - key: keyof GardenRuntimeSettings & string, - config: NumberControlConfig -): BindingParams => { +const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { const params: BindingParams = { - label: config.label ?? toLabel(key), + label: config.label, options: config.options, step: config.step, }; @@ -107,8 +136,12 @@ export class ConfigPane { colorIndex: number; element: HTMLElement; }> = []; - private readonly state: { activeVibeId: VibeId } = { - activeVibeId: activeVibe.id, + private readonly state: PaneState = { + backgroundColor: rgbColorToHex(activeVibe.backgroundColor), + color1: rgbColorToHex(activeVibe.colors[0]), + color2: rgbColorToHex(activeVibe.colors[1]), + color3: rgbColorToHex(activeVibe.colors[2]), + ...activeVibe.audio, }; public constructor(private readonly options: ConfigPaneOptions) { @@ -142,12 +175,7 @@ export class ConfigPane { this.options.settingsButton.addEventListener('click', this.toggle); - const tabs = this.pane.addTab({ - pages: [{ title: 'Runtime' }, { title: 'Config' }], - }); - - this.setUpRuntimeTab(tabs.pages[0]); - this.setUpConfigTab(tabs.pages[1]); + this.setUpTuningPane(this.pane); this.syncOpenState(); } @@ -156,7 +184,7 @@ export class ConfigPane { } public refresh(): void { - this.state.activeVibeId = activeVibe.id; + this.syncVibeState(); this.pane.refresh(); this.syncColorReactionMatrix(); this.syncOpenState(); @@ -176,73 +204,116 @@ export class ConfigPane { this.syncOpenState(); } - private setUpRuntimeTab(container: PaneContainer): void { + private setUpTuningPane(container: PaneContainer): void { + this.setUpVibeSection(container); + this.addRuntimeSection(container, 'Brush', brushControlKeys, true); + this.addRuntimeSection(container, 'Agents', agentControlKeys, true); + this.addColorReactionMatrix(container); + this.addRuntimeSection(container, 'Look', lookControlKeys, true); + const performanceFolder = this.addRuntimeSection( + container, + 'Performance', + performanceControlKeys, + true + ); + this.addFpsOverlayBinding(performanceFolder); + this.setUpMusicSection(container); + this.syncColorReactionMatrix(); + } + + private setUpVibeSection(container: PaneContainer): void { + const folder = container.addFolder({ + title: 'Vibe', + expanded: true, + }); + + this.addColorBinding(folder, 'color1', 'colour 1', (color) => { + activeVibe.colors[0] = color; + }); + this.addColorBinding(folder, 'color2', 'colour 2', (color) => { + activeVibe.colors[1] = color; + }); + this.addColorBinding(folder, 'color3', 'colour 3', (color) => { + activeVibe.colors[2] = color; + }); + this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => { + activeVibe.backgroundColor = color; + }); + + if (import.meta.env.DEV) { + folder + .addButton({ title: 'Copy vibe preset' }) + .on('click', () => void this.copyVibePresetToClipboard()); + } + } + + private addColorBinding( + container: PaneContainer, + key: VibeColorKey, + label: string, + updateColor: (color: RgbColor) => void + ): void { container - .addBinding(this.state, 'activeVibeId', { - label: 'active vibe', - options: Object.fromEntries( - VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id]) - ) as Record, - }) + .addBinding(this.state, key, { + label, + view: 'color', + } as BindingParams) .on('change', ({ value }) => { - if (!isVibeId(value)) { - this.refresh(); + const color = hexColorToRgbColor(String(value)); + if (!color) { + this.syncVibeState(); + this.pane.refresh(); return; } - this.options.onVibeChange(value); - this.refresh(); - }); - container.addButton({ title: 'Reset runtime settings' }).on('click', () => { - this.options.onRuntimeReset(); - this.refresh(); - }); + updateColor(color); + this.syncColorReactionMatrix(); + this.options.onConfigChange(); + }); + } + + private addRuntimeSection( + container: PaneContainer, + title: string, + keys: ReadonlyArray, + expanded: boolean + ): PaneContainer { + const folder = container.addFolder({ title, expanded }); + keys.forEach((key) => this.addRuntimeBinding(folder, key)); + return folder; + } + + private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void { + const config = appConfig.runtimeSettings.controls[key]; + if (!config) { + return; + } + + settings[key] = normalizeNumber(settings[key], config); container - .addButton({ - title: 'Restart simulation', - }) - .on('click', () => this.options.onRestart()); - - const folders = new Map(); - let hasAddedColorReactionMatrix = false; - Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => { - const settingKey = key as keyof GardenRuntimeSettings & string; - settings[settingKey] = normalizeNumber(settings[settingKey], config); - - if (isColorReactionKey(key)) { - if (!hasAddedColorReactionMatrix) { - this.addColorReactionMatrix(container); - hasAddedColorReactionMatrix = true; + .addBinding(settings, key, getNumberBindingParams(config)) + .on('change', () => { + const nextValue = normalizeNumber(settings[key], config); + if (nextValue !== settings[key]) { + settings[key] = nextValue; + this.pane.refresh(); } - return; - } + this.options.onRuntimeChange(); + }); + } - const folder = - folders.get(config.folder) ?? - container.addFolder({ - title: config.folder, - expanded: config.folder !== 'Runtime', - }); - folders.set(config.folder, folder); - - folder - .addBinding(settings, settingKey, getNumberBindingParams(settingKey, config)) - .on('change', () => { - const nextValue = normalizeNumber(settings[settingKey], config); - if (nextValue !== settings[settingKey]) { - settings[settingKey] = nextValue; - this.pane.refresh(); - } - this.options.onRuntimeChange(); - }); - }); - this.syncColorReactionMatrix(); + private addFpsOverlayBinding(container: PaneContainer): void { + container + .addBinding(appConfig.tuningPane, 'showFpsOverlay', { + label: 'FPS overlay', + }) + .on('change', () => this.options.onConfigChange()); } private addColorReactionMatrix(container: PaneContainer): void { const folder = container.addFolder({ - title: 'Color Reactions', + title: 'Follow / Ignore / Avoid', expanded: true, }); folder.element.classList.add('color-reaction-folder'); @@ -319,6 +390,10 @@ export class ConfigPane { ); 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); @@ -341,6 +416,10 @@ export class ConfigPane { private syncColorReactionMatrix(): void { this.colorReactionSelects.forEach((select, key) => { const config = appConfig.runtimeSettings.controls[key]; + if (!config) { + return; + } + settings[key] = normalizeNumber(settings[key], config); select.value = String(settings[key]); }); @@ -350,96 +429,66 @@ export class ConfigPane { }); } - private setUpConfigTab(container: PaneContainer): void { - this.addObjectBindings( - container, - appConfig as unknown as Record, - [] - ); - } - - private addObjectBindings( - container: PaneContainer, - source: Record, - path: Array - ): void { - Object.entries(source).forEach(([key, value]) => { - if (isBindablePrimitive(value)) { - this.addPrimitiveBinding(container, source, key); - return; - } - - if (Array.isArray(value)) { - const folder = container.addFolder({ - title: toLabel(`${key}[]`), - expanded: path.length < appConfig.tuningPane.expandedDepth, - }); - value.forEach((item, index) => { - if (isBindablePrimitive(item)) { - this.addPrimitiveBinding( - folder, - value as unknown as Record, - `${index}` - ); - return; - } - - if (isPlainObject(item)) { - this.addObjectBindings( - folder.addFolder({ - title: `[${index}]`, - expanded: false, - }), - item, - [...path, key, String(index)] - ); - } - }); - return; - } - - if (isPlainObject(value)) { - this.addObjectBindings( - container.addFolder({ - title: toLabel(key), - expanded: path.length < appConfig.tuningPane.expandedDepth, - }), - value, - [...path, key] - ); - } + private setUpMusicSection(container: PaneContainer): void { + const folder = container.addFolder({ title: 'Music', expanded: true }); + MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => { + this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step }); }); } - private addPrimitiveBinding( + private addVibeNumberBinding( container: PaneContainer, - source: Record, - key: string + key: VibeNumberKey, + config: NumberControlConfig ): void { - const params: BindingParams = { - label: toLabel(key), - ...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}), - }; + this.state[key] = normalizeNumber(this.state[key], config); container - .addBinding(source, key, params) - .on('change', () => this.options.onConfigChange()); + .addBinding(this.state, key, getNumberBindingParams(config)) + .on('change', () => { + const nextValue = normalizeNumber(this.state[key], config); + if (nextValue !== this.state[key]) { + this.state[key] = nextValue; + this.pane.refresh(); + } + activeVibe.audio[key] = nextValue; + this.options.onConfigChange(); + }); } - private syncButton(): void { - this.options.settingsButton.classList.toggle('active', this.isOpen); - this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen)); - this.options.settingsButton.setAttribute( - 'aria-label', - this.isOpen ? 'Hide config overlay' : 'Show config overlay' - ); - this.options.settingsButton.title = this.isOpen - ? 'Hide config overlay' - : 'Show config overlay'; + private async copyVibePresetToClipboard(): Promise { + const settingKeys = Object.keys(activeVibe.settings) as Array< + keyof typeof activeVibe.settings + >; + const preset = { + name: `${activeVibe.name} Copy`, + colors: activeVibe.colors, + backgroundColor: activeVibe.backgroundColor, + settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])), + audio: activeVibe.audio, + }; + try { + await navigator.clipboard.writeText(JSON.stringify(preset, null, 2)); + } catch (error) { + console.warn('Could not copy vibe preset to clipboard.', error); + } + } + + private syncVibeState(): void { + this.state.color1 = rgbColorToHex(activeVibe.colors[0]); + this.state.color2 = rgbColorToHex(activeVibe.colors[1]); + this.state.color3 = rgbColorToHex(activeVibe.colors[2]); + this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor); + Object.assign(this.state, activeVibe.audio); } private syncOpenState(): void { - this.syncButton(); + const { settingsButton } = this.options; + const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay'; + settingsButton.classList.toggle('active', this.isOpen); + 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 3a91ff8..5792482 100644 --- a/src/pipelines/agents/agent-dispatch.ts +++ b/src/pipelines/agents/agent-dispatch.ts @@ -1,28 +1,19 @@ -export const AGENT_WORKGROUP_SIZE = 64; -export const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535; -export const AGENT_MAX_DISPATCHABLE_COUNT = 0xffffffff; +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; -export const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => { +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) { - return [workgroupCount, 1]; - } - - const workgroupX = Math.min( - Math.ceil(Math.sqrt(workgroupCount)), - AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION - ); - const workgroupY = Math.ceil(workgroupCount / workgroupX); - - if (workgroupY > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) { + if (workgroupCount > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) { throw new Error('Agent count exceeds dispatchable workgroup range'); } - return [workgroupX, workgroupY]; + return [workgroupCount, 1]; }; export const dispatchAgentWorkgroups = ( diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl index 845580b..4021518 100644 --- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -9,6 +9,8 @@ struct Counters { aliveAgentCount: atomic, }; +const clearCompactedTailStride = 4u; + @group(1) @binding(0) var settings: Settings; @group(1) @binding(2) var counters: Counters; @group(1) @binding(3) var compactedAgents: array; @@ -20,10 +22,9 @@ var clearAliveAgentCount: u32; @compute @workgroup_size(64) fn main( @builtin(global_invocation_id) global_id: vec3, - @builtin(local_invocation_id) local_id: vec3, - @builtin(num_workgroups) num_workgroups: vec3 + @builtin(local_invocation_id) local_id: vec3 ) { - let id = get_id(global_id, num_workgroups); + let id = get_id(global_id); if local_id.x == 0u { atomicStore(&workgroupAliveCount, 0u); @@ -63,10 +64,9 @@ fn main( @compute @workgroup_size(64) fn clearCompactedTail( @builtin(global_invocation_id) global_id: vec3, - @builtin(local_invocation_id) local_id: vec3, - @builtin(num_workgroups) num_workgroups: vec3 + @builtin(local_invocation_id) local_id: vec3 ) { - let id = get_id(global_id, num_workgroups); + let id = get_id(global_id); if local_id.x == 0u { clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount); @@ -74,11 +74,11 @@ fn clearCompactedTail( workgroupBarrier(); - if id >= settings.agentCount { - return; - } - - if id >= clearAliveAgentCount { - compactedAgents[id].colorIndex = -1.0; + let firstClearId = clearAliveAgentCount + id * clearCompactedTailStride; + for (var offset = 0u; offset < clearCompactedTailStride; offset += 1u) { + let clearId = firstClearId + offset; + if clearId < settings.agentCount { + compactedAgents[clearId].colorIndex = -1.0; + } } } diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index 890b3db..f2fafa7 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -1,21 +1,19 @@ import { vec2 } from 'gl-matrix'; import { smartCompile } from '../../../utils/graphics/smart-compile'; -import { - AGENT_MAX_DISPATCHABLE_COUNT, - dispatchAgentWorkgroups, -} from '../agent-dispatch'; +import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch'; import compactionShader from './agent-compaction.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw'; export const AGENT_FLOAT_COUNT = 8; -export const AGENT_SIZE_IN_BYTES = - AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; +const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; export class AgentGenerationPipeline { private static readonly UNIFORM_COUNT = 4; private static readonly COUNTER_COUNT = 1; + private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4; + private static readonly ALLOCATION_GROWTH_FACTOR = 1.25; private readonly bindGroupLayout: GPUBindGroupLayout; private readonly uniforms: GPUBuffer; @@ -33,21 +31,14 @@ export class AgentGenerationPipeline { private allocatedMaxAgentCount: number; private readonly countersBuffer: GPUBuffer; private readonly countersStagingBuffer: GPUBuffer; - private readonly counterClearValues = new Uint32Array( - AgentGenerationPipeline.COUNTER_COUNT - ); private readonly agentCountUniformValues = new Uint32Array( AgentGenerationPipeline.UNIFORM_COUNT ); private readonly resizeUniformBuffer = new ArrayBuffer( AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT ); - private readonly resizeUniformFloatValues = new Float32Array( - this.resizeUniformBuffer - ); - private readonly resizeUniformUintValues = new Uint32Array( - this.resizeUniformBuffer - ); + private readonly resizeUniformFloatValues = new Float32Array(this.resizeUniformBuffer); + private readonly resizeUniformUintValues = new Uint32Array(this.resizeUniformBuffer); public constructor( private readonly device: GPUDevice, @@ -173,11 +164,19 @@ export class AgentGenerationPipeline { requestedMaxAgentCount: number, activeAgentCount: number ): number { - const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount); - if (nextMaxAgentCount <= this.allocatedMaxAgentCount) { + const requestedClampedMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount); + if (requestedClampedMaxAgentCount <= this.allocatedMaxAgentCount) { return this.allocatedMaxAgentCount; } + const nextMaxAgentCount = this.clampMaxAgentCount( + Math.max( + requestedClampedMaxAgentCount, + Math.ceil( + this.allocatedMaxAgentCount * AgentGenerationPipeline.ALLOCATION_GROWTH_FACTOR + ) + ) + ); const previousActiveAgentsBuffer = this.activeAgentsBuffer; const previousMaxAgentCount = this.allocatedMaxAgentCount; this.allocatedMaxAgentCount = nextMaxAgentCount; @@ -270,16 +269,19 @@ export class AgentGenerationPipeline { this.inactiveAgentsBuffer = this.createAgentsBuffer(); this.agentCountUniformValues[0] = agentCount; - this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues); this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues); const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT); const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.compactionPipeline); passEncoder.setBindGroup(1, this.getBindGroup()); dispatchAgentWorkgroups(passEncoder, agentCount); passEncoder.setPipeline(this.clearCompactedTailPipeline); - dispatchAgentWorkgroups(passEncoder, agentCount); + dispatchAgentWorkgroups( + passEncoder, + Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE) + ); passEncoder.end(); commandEncoder.copyBufferToBuffer( diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl index bbbe120..91bdf9e 100644 --- a/src/pipelines/agents/agent-generation/agent-resize.wgsl +++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl @@ -8,10 +8,9 @@ struct ResizeSettings { @compute @workgroup_size(64) fn main( - @builtin(global_invocation_id) global_id: vec3, - @builtin(num_workgroups) num_workgroups: vec3 + @builtin(global_invocation_id) global_id: vec3 ) { - let id = get_id(global_id, num_workgroups); + let id = get_id(global_id); if id >= resizeSettings.agentCount { return; diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl index 94431be..6c5c512 100644 --- a/src/pipelines/agents/agent-generation/agent-schema.wgsl +++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl @@ -11,6 +11,6 @@ struct Agent { const agentWorkgroupSize = 64u; -fn get_id(global_id: vec3, num_workgroups: vec3) -> u32 { - return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize; +fn get_id(global_id: vec3) -> u32 { + return global_id.x; } diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index a599d32..58d2e26 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -79,10 +79,12 @@ export class AgentPipeline { introNearMoveMultiplier, introStepStopDistance, randomTimeScale, + time, agentCount, introProgress, }: AgentSettings & { deltaTime: number; + time: number; agentCount: number; introProgress?: number; }) { @@ -117,7 +119,7 @@ export class AgentPipeline { this.uniformValues[26] = introFarMoveMultiplier; this.uniformValues[27] = introNearMoveMultiplier; this.uniformValues[28] = introStepStopDistance; - this.uniformValues[29] = randomTimeScale; + this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0; writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 1a9b50d..025fe39 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -28,7 +28,7 @@ struct Settings { introFarMoveMultiplier: f32, introNearMoveMultiplier: f32, introStepStopDistance: f32, - randomTimeScale: f32, + randomTimeSeed: u32, }; @group(1) @binding(0) var settings: Settings; @@ -37,10 +37,9 @@ struct Settings { @compute @workgroup_size(64) fn main( - @builtin(global_invocation_id) global_id: vec3, - @builtin(num_workgroups) num_workgroups: vec3 + @builtin(global_invocation_id) global_id: vec3 ) { - let id = get_id(global_id, num_workgroups); + let id = get_id(global_id); if id >= settings.agentCount { return; @@ -65,7 +64,8 @@ fn main( let channelMask = get_channel_mask(colorIndex); let reactionMask = get_reaction_mask(colorIndex); - let randomSeed = random_seed(id, state.time); + let randomSeed = random_seed(id); + let maxPosition = state.size - vec2(1.0, 1.0); var rotation = 0.0; var step = vec2(0.0, 0.0); @@ -108,16 +108,18 @@ fn main( let randomTurn = random_float(randomSeed); let direction = vec2(cos(angle), sin(angle)); - let forwardSensor = sensor_position(position, direction, settings.sensorOffset); + let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition); let leftSensor = sensor_position( position, rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos), - settings.sensorOffset + settings.sensorOffset, + maxPosition ); let rightSensor = sensor_position( position, rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos), - settings.sensorOffset + settings.sensorOffset, + maxPosition ); let trailForward = textureLoad(trailMapIn, forwardSensor, 0); @@ -138,7 +140,6 @@ fn main( step = direction * settings.moveRate; } - let maxPosition = state.size - vec2(1.0, 1.0); let nextPosition = clamp(position + step, vec2(0, 0), maxPosition); if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5; @@ -155,11 +156,16 @@ fn main( agents[id].position = nextPosition; } -fn sensor_position(agentPosition: vec2, direction: vec2, sensorOffset: f32) -> vec2 { +fn sensor_position( + agentPosition: vec2, + direction: vec2, + sensorOffset: f32, + maxPosition: vec2 +) -> vec2 { return vec2(clamp( agentPosition + direction * sensorOffset, vec2(0, 0), - state.size - vec2(1, 1) + maxPosition )); } @@ -212,9 +218,8 @@ fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle)); } -fn random_seed(id: u32, time: f32) -> u32 { - let timeSeed = u32(time * settings.randomTimeScale); - return id * 747796405u + timeSeed * 2891336453u; +fn random_seed(id: u32) -> u32 { + return id * 747796405u + settings.randomTimeSeed * 2891336453u; } fn random_float(seed: u32) -> f32 { diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index b3c098e..f909861 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -25,7 +25,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number => ? pixelRatio : 1; -export const setBrushUniformValues = ( +const setBrushUniformValues = ( target: Float32Array, { brushSize, @@ -105,7 +105,7 @@ export class BrushPipeline { }); const shaderModule = smartCompile(device, CommonState.shaderCode, shader); - this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2); + this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 1); this.uniforms = this.device.createBuffer({ size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, @@ -209,12 +209,10 @@ export class BrushPipeline { public executeMultiTarget( commandEncoder: GPUCommandEncoder, - sourceMapOut: GPUTextureView, - influenceMapOut: GPUTextureView - ): void { - this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ + sourceMapOut: GPUTextureView + ): boolean { + return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ sourceMapOut, - influenceMapOut, ]); } @@ -222,9 +220,9 @@ export class BrushPipeline { commandEncoder: GPUCommandEncoder, pipeline: GPURenderPipeline, textureViews: Array - ): void { + ): boolean { if (this.lineCount === 0) { - return; + return false; } const renderPassDescriptor: GPURenderPassDescriptor = { @@ -242,6 +240,7 @@ export class BrushPipeline { passEncoder.setVertexBuffer(0, this.vertexBuffer); passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); passEncoder.end(); + return true; } public destroy() { diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index 0b6e451..e518401 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -25,7 +25,6 @@ struct VertexOutput { struct BrushTargets { @location(0) source: vec4, - @location(1) influence: vec4, } @vertex @@ -53,6 +52,22 @@ fn fragmentMrt( @location(2) @interpolate(flat) direction: vec2, @location(3) @interpolate(flat) inverseLengthSquared: f32 ) -> BrushTargets { + let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared); + + if(strength < settings.brushDiscardThreshold) { + discard; + } + + let color = brushOutput(strength); + return BrushTargets(color); +} + +fn brushStrength( + screenPosition: vec2, + start: vec2, + direction: vec2, + inverseLengthSquared: f32 +) -> f32 { let distanceSquared = distanceSquaredFromLine( screenPosition, start, @@ -60,24 +75,9 @@ fn fragmentMrt( inverseLengthSquared ); if distanceSquared > settings.brushGeometryRadiusSquared { - discard; + return 0.0; } - let strength = brushStrength(screenPosition, distanceSquared); - - if(strength < settings.brushDiscardThreshold) { - discard; - } - - let color = brushOutput(strength); - return BrushTargets(color, color); -} - -fn brushStrength( - screenPosition: vec2, - distanceSquared: f32 -) -> f32 { - let distance = sqrt(distanceSquared); let coarseNoise = textureSampleLevel( noise, noiseSampler, @@ -85,7 +85,7 @@ fn brushStrength( 0.0 ).r; let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation; - let edge = 1.0 - step(radius, distance); + let edge = 1.0 - step(radius * radius, distanceSquared); if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold { return 0.0; } @@ -112,10 +112,6 @@ fn distanceSquaredFromLine( ) -> f32 { let pa = position - start; - if inverseLengthSquared <= 0.0 { - return dot(pa, pa); - } - let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1); let nearestOffset = pa - direction * q; return dot(nearestOffset, nearestOffset); @@ -140,14 +136,13 @@ fn segment_vertex_position( } fn segment_vertex_corner(index: u32) -> vec2 { - if index == 0u { - return vec2(-1.0, 1.0); - } - if index == 1u || index == 3u { - return vec2(-1.0, -1.0); - } - if index == 2u || index == 4u { - return vec2(1.0, 1.0); - } - return vec2(1.0, -1.0); + let corners = array, 6>( + vec2(-1.0, 1.0), + vec2(-1.0, -1.0), + vec2(1.0, 1.0), + vec2(-1.0, -1.0), + vec2(1.0, 1.0), + vec2(1.0, -1.0), + ); + return corners[index]; } diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 8f341d4..c9dbc17 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -20,6 +20,7 @@ const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y; @group(0) @binding(2) var trailMapOut: texture_storage_2d; var tile: array, 324>; +var tileTrailStrength: array; @compute @workgroup_size(16, 16) fn main( @@ -28,31 +29,32 @@ fn main( @builtin(workgroup_id) workgroup_id: vec3 ) { let textureSize = vec2(textureDimensions(trailMap, 0)); + let textureSizeU32 = vec2(textureSize); let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x; - var tileIndex = localLinearIndex; - - loop { - if tileIndex >= TILE_TEXEL_COUNT { - break; - } + let workgroupOrigin = workgroup_id.xy * vec2(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y); + let isInteriorTile = + workgroupOrigin.x > 0u && + workgroupOrigin.y > 0u && + workgroupOrigin.x + WORKGROUP_SIZE_X < textureSizeU32.x && + workgroupOrigin.y + WORKGROUP_SIZE_Y < textureSizeU32.y; + for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) { let tilePosition = vec2(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X); - let sourcePixelU32 = - workgroup_id.xy * vec2(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y) + - tilePosition; - let sourcePixel = clamp( - vec2(i32(sourcePixelU32.x), i32(sourcePixelU32.y)) - vec2(1, 1), - vec2(0, 0), - textureSize - vec2(1, 1) - ); - tile[tileIndex] = textureLoad(trailMap, sourcePixel, 0); - tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y; + let unclampedSourcePixel = vec2(workgroupOrigin + tilePosition) - vec2(1, 1); + var sourcePixel = unclampedSourcePixel; + if !isInteriorTile { + sourcePixel = clamp(unclampedSourcePixel, vec2(0, 0), textureSize - vec2(1, 1)); + } + let texel = textureLoad(trailMap, sourcePixel, 0); + tile[tileIndex] = texel; + tileTrailStrength[tileIndex] = length(texel.rgb); } workgroupBarrier(); let pixel = vec2(i32(global_id.x), i32(global_id.y)); - if pixel.x >= textureSize.x || pixel.y >= textureSize.y { + let inBounds = pixel.x < textureSize.x && pixel.y < textureSize.y; + if !inBounds { return; } @@ -110,11 +112,12 @@ fn propagate( brushWeight: f32 ) -> vec4 { let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX; - let neighbour = tile[u32(neighbourIndex)]; + let neighbourTileIndex = u32(neighbourIndex); + let neighbour = tile[neighbourTileIndex]; let difference = clamp(neighbour - currentColor, vec4(0), vec4(1)); return vec4( - vec3(length(neighbour.rgb) * trailWeight), + vec3(tileTrailStrength[neighbourTileIndex] * trailWeight), neighbour.a * brushWeight ) * difference; } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index b3499ee..8d9719c 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -21,14 +21,14 @@ type DiffusionUniformSettings = Pick< | 'brushDecayAlphaOffset' >; -export const getSafeInverseDiffusionRate = (diffusionRate: number): number => +const getSafeInverseDiffusionRate = (diffusionRate: number): number => 1 / (Number.isFinite(diffusionRate) && diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate ? diffusionRate : appConfig.pipelines.diffusion.minDiffusionRate); -export const setDiffusionUniformValues = ( +const setDiffusionUniformValues = ( target: Float32Array, { diffusionRateTrails, diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index 702c047..d61ba6a 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -1,3 +1,5 @@ +import { vec2 } from 'gl-matrix'; + import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, @@ -86,9 +88,11 @@ export class EraserAgentPipeline { public setParameters({ agentCount, eraserMaskAlphaThreshold, + maskSize, }: { agentCount: number; eraserMaskAlphaThreshold: number; + maskSize: vec2; }): void { this.agentCount = agentCount; this.activeSegmentCount = this.pendingSegmentCount; @@ -96,8 +100,8 @@ export class EraserAgentPipeline { this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount)); this.uniformValues[1] = eraserMaskAlphaThreshold; - this.uniformValues[2] = 0; - this.uniformValues[3] = 0; + this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0])); + this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1])); writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 133b767..63452fe 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -1,8 +1,8 @@ struct Settings { agentCount: u32, eraserMaskAlphaThreshold: f32, - padding1: f32, - padding2: f32, + maskWidth: u32, + maskHeight: u32, }; @group(1) @binding(0) var settings: Settings; @@ -10,10 +10,9 @@ struct Settings { @compute @workgroup_size(64) fn main( - @builtin(global_invocation_id) global_id: vec3, - @builtin(num_workgroups) num_workgroups: vec3 + @builtin(global_invocation_id) global_id: vec3 ) { - let id = get_id(global_id, num_workgroups); + let id = get_id(global_id); if id >= settings.agentCount { return; @@ -24,7 +23,7 @@ fn main( return; } - let maskSize = vec2(textureDimensions(eraserMask)); + let maskSize = vec2(i32(settings.maskWidth), i32(settings.maskHeight)); let maskPosition = clamp( vec2(agents[id].position), vec2(0, 0), diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index c23a1bb..4826149 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -68,7 +68,6 @@ export class EraserTexturePipeline { 'r8unorm', 'rgba16float', 'rgba16float', - 'rgba16float', ]); this.uniforms = this.device.createBuffer({ @@ -169,7 +168,6 @@ export class EraserTexturePipeline { commandEncoder: GPUCommandEncoder, eraserMaskOut: GPUTextureView, sourceMapOut: GPUTextureView, - influenceMapOut: GPUTextureView, trailMapOut: GPUTextureView ): void { if (this.lineCount === 0) { @@ -200,11 +198,6 @@ export class EraserTexturePipeline { loadOp: 'load', storeOp: 'store', }, - { - view: influenceMapOut, - loadOp: 'load', - storeOp: 'store', - }, { view: trailMapOut, loadOp: 'load', diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl index e5675f2..c3a1517 100644 --- a/src/pipelines/eraser/eraser-texture.wgsl +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -19,17 +19,10 @@ struct VertexOutput { @location(3) @interpolate(flat) inverseLengthSquared: f32, } -struct EraserTextureTargets { - @location(0) source: vec4, - @location(1) influence: vec4, - @location(2) trail: vec4, -} - struct EraserCombinedTargets { @location(0) mask: vec4, @location(1) source: vec4, - @location(2) influence: vec4, - @location(3) trail: vec4, + @location(2) trail: vec4, } @vertex @@ -50,35 +43,6 @@ fn vertex( return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared); } -@fragment -fn fragment( - @location(0) screenPosition: vec2, - @location(1) @interpolate(flat) start: vec2, - @location(2) @interpolate(flat) direction: vec2, - @location(3) @interpolate(flat) inverseLengthSquared: f32 -) -> @location(0) vec4 { - if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) { - discard; - } - - return getEraserClearValue(); -} - -@fragment -fn fragmentMrt( - @location(0) screenPosition: vec2, - @location(1) @interpolate(flat) start: vec2, - @location(2) @interpolate(flat) direction: vec2, - @location(3) @interpolate(flat) inverseLengthSquared: f32 -) -> EraserTextureTargets { - if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) { - discard; - } - - let cleared = getEraserClearValue(); - return EraserTextureTargets(cleared, cleared, cleared); -} - @fragment fn fragmentCombined( @location(0) screenPosition: vec2, @@ -91,7 +55,7 @@ fn fragmentCombined( } let cleared = getEraserClearValue(); - return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared); + return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared); } fn getEraserMaskValue() -> vec4 { @@ -124,10 +88,6 @@ fn distanceSquaredFromLine( ) -> f32 { let pa = position - start; - if inverseLengthSquared <= 0.0 { - return dot(pa, pa); - } - let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0); let nearestOffset = pa - direction * q; return dot(nearestOffset, nearestOffset); @@ -152,14 +112,13 @@ fn segment_vertex_position( } fn segment_vertex_corner(index: u32) -> vec2 { - if index == 0u { - return vec2(-1.0, 1.0); - } - if index == 1u || index == 3u { - return vec2(-1.0, -1.0); - } - if index == 2u || index == 4u { - return vec2(1.0, 1.0); - } - return vec2(1.0, -1.0); + let corners = array, 6>( + vec2(-1.0, 1.0), + vec2(-1.0, -1.0), + vec2(1.0, 1.0), + vec2(-1.0, -1.0), + vec2(1.0, 1.0), + vec2(1.0, -1.0), + ); + return corners[index]; } diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index da58f5a..8ee09cd 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -14,6 +14,7 @@ export class RenderPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPURenderPipeline; + private readonly noSourcePipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( @@ -36,6 +37,7 @@ export class RenderPipeline { const format = navigator.gpu.getPreferredCanvasFormat(); this.pipeline = this.createPipeline(format, vertex, 'fragment'); + this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource'); this.uniforms = this.device.createBuffer({ size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, @@ -112,7 +114,8 @@ export class RenderPipeline { public execute( commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, - sourceTexture: GPUTextureView + sourceTexture: GPUTextureView, + useSourceTexture = true ): GPUTexture { const bindGroup = this.getBindGroup(colorTexture, sourceTexture); const canvasTexture = this.context.getCurrentTexture(); @@ -128,7 +131,7 @@ export class RenderPipeline { ], }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(useSourceTexture ? this.pipeline : this.noSourcePipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, bindGroup); passEncoder.draw(3, 1); diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 1961e8b..4495601 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -27,9 +27,18 @@ fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { return renderColor(traces, sources, pixel); } +@fragment +fn fragmentNoSource(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let traces = textureLoad(trailMap, pixel, 0); + return renderColor(traces, vec4(0.0), pixel); +} + fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4 { let background = getTexturedBackground(pixel); - if max(max(max(traces.r, traces.g), traces.b), max(max(sources.r, sources.g), sources.b)) <= 0.0 { + let tracesMax = maxComponent(traces.rgb); + let sourcesMax = maxComponent(sources.rgb); + if max(tracesMax, sourcesMax) <= 0.0 { return vec4(background, 1); } @@ -38,13 +47,13 @@ fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4< clarity(traces.g), clarity(traces.b) ); - if max(max(sources.r, sources.g), sources.b) <= 0.0 { + if sourcesMax <= 0.0 { let traceColor = traceStrengths.r * settings.colorA + traceStrengths.g * settings.colorB + traceStrengths.b * settings.colorC; let normalizedTraceColor = normalizeColorIntensity(traceColor); - let traceStrength = max(max(traceStrengths.r, traceStrengths.g), traceStrengths.b); + let traceStrength = maxComponent(traceStrengths); return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1); } @@ -64,7 +73,7 @@ fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4< + sourceStrengths.g * settings.colorB + sourceStrengths.b * settings.colorC; let normalizedBrushColor = normalizeColorIntensity(brushColor); - let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b); + let brushStrength = maxComponent(sourceStrengths); let brushVisibility = clamp( brushStrength * ( settings.brushColorBase + @@ -75,20 +84,27 @@ fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4< ); let color = max(normalizedTraceColor, normalizedBrushColor); - let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility); + let strength = max(maxComponent(strengths), brushVisibility); return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1); } +fn maxComponent(v: vec3) -> f32 { + return max(max(v.r, v.g), v.b); +} + fn clarity(strength: f32) -> f32 { return pow(clamp(strength, 0, 1), settings.clarity); } fn normalizeColorIntensity(color: vec3) -> vec3 { - let brightestChannel = max(max(color.r, color.g), color.b); + let brightestChannel = maxComponent(color); return color / max(settings.traceNormalizationFloor, brightestChannel); } fn getTexturedBackground(pixel: vec2) -> vec3 { + if settings.backgroundGrainStrength == 0.0 { + return clamp(settings.backgroundColor, vec3(0), vec3(1)); + } let noiseCoord = vec2(vec2(pixel) & vec2(NOISE_TEXTURE_MASK)); let grain = textureLoad(noise, noiseCoord, 0).r - 0.5; diff --git a/src/settings.ts b/src/settings.ts index c28a005..cd07f76 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -15,11 +15,6 @@ export const settings: GardenRuntimeSettings = { ...buildSettings(activeVibe), }; -export const resetSettings = (): GardenRuntimeSettings => { - Object.assign(settings, buildSettings(activeVibe)); - return settings; -}; - export const applyVibeSettings = (vibe: VibePreset) => { activeVibe = vibe; Object.assign(settings, { diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index fd78952..b714386 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -35,7 +35,7 @@ export const initializeContext = ({ device: device, format: gpu.getPreferredCanvasFormat(), usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, - alphaMode: 'premultiplied', + alphaMode: 'opaque', }); } catch (error) { throw new RuntimeError( diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts index e267a85..5edc9dd 100644 --- a/src/utils/graphics/noise.ts +++ b/src/utils/graphics/noise.ts @@ -48,9 +48,9 @@ export const generateNoise = ({ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { return vec4( random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}), - random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[1]}), - random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[2]}), - random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[3]}), + 0.0, + 0.0, + 1.0, ); }` ), diff --git a/src/utils/rgb-color.ts b/src/utils/rgb-color.ts index 5cb0465..aa9be3f 100644 --- a/src/utils/rgb-color.ts +++ b/src/utils/rgb-color.ts @@ -11,5 +11,32 @@ const clampRgbChannel = (value: number): number => export const rgbColorToCss = ([red, green, blue]: RgbColor): string => `rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`; +export const rgbColorToHex = ([red, green, blue]: RgbColor): string => + `#${[red, green, blue] + .map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0')) + .join('')}`; + +export const hexColorToRgbColor = (value: string): RgbColor | null => { + const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!match) { + return null; + } + + const shorthandOrHex = match[1]; + const hex = + shorthandOrHex.length === 3 + ? shorthandOrHex + .split('') + .map((channel) => `${channel}${channel}`) + .join('') + : shorthandOrHex; + + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + ]; +}; + export const rgbChannelToUnit = (value: number): number => Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX)); diff --git a/src/vibes.ts b/src/vibes.ts index 47a3314..2582674 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -7,7 +7,7 @@ export type { VibePreset } from './config'; export const VIBE_PRESETS: Array = appConfig.vibes.presets; const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id)); -export const isVibeId = (value: unknown): value is VibeId => +const isVibeId = (value: unknown): value is VibeId => typeof value === 'string' && VIBE_IDS.has(value as VibeId); export const getInitialVibe = (): VibePreset => {