diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 19f0efd..6f50cb8 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -138,6 +138,22 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { expect(browserFailures).toEqual([]); }); +test('syncs the selected vibe with the URI', async ({ page }) => { + const browserFailures = collectLocalBrowserFailures(page); + + await disableWebGpu(page); + await page.goto('/?vibe=Bone%20Archive'); + + await expect(page).toHaveURL(/vibe=bone-archive/); + + await page.locator('.next-vibe').click(); + await expect(page).toHaveURL(/vibe=pelagic-caustics/); + + await page.goBack(); + await expect(page).toHaveURL(/vibe=bone-archive/); + expect(browserFailures).toEqual([]); +}); + test('keeps audio focus outlines scoped to the active control', async ({ page }) => { await disableWebGpu(page); await page.goto('/'); diff --git a/package.json b/package.json index b8cbfec..9d07a0f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@playwright/test": "^1.60.0", - "@tweakpane/core": "^2.0.5", + "@tweakpane/core": "~2.0.5", "@types/node": "^25.6.0", "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-basic-ssl": "^2.3.0", @@ -71,6 +71,6 @@ }, "dependencies": { "@plausible-analytics/tracker": "^0.4.5", - "tweakpane": "^4.0.5" + "tweakpane": "~4.0.5" } } diff --git a/public/og-image.jpg b/public/og-image.jpg index 03c8939..0ced8fc 100644 Binary files a/public/og-image.jpg and b/public/og-image.jpg differ diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index d5c8d70..bc6b599 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,9 +1,11 @@ import { DEFAULT_AUDIO_VOLUME } from '../consts'; import type { PianoNoteRole } from './garden-audio-types'; +type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4'; + export interface GardenAudioChord { rootOffset: number; - quality: 'major' | 'minor'; + quality: GardenAudioChordQuality; } export interface GardenAudioVibeSettings { @@ -14,6 +16,8 @@ export interface GardenAudioVibeSettings { noteLength: number; notePitchOffset: number; brightness: number; + scale?: Array; + progression?: Array; } export interface GardenAudioVibeProfile extends GardenAudioVibeSettings { @@ -37,7 +41,9 @@ export const createGardenAudioConfig = () => ({ fadeInSeconds: 0.45, updateRampSeconds: 0.08, delay: { - timeSeconds: 0.405, + timeBeats: 0.5, + timeMinSeconds: 0.18, + timeMaxSeconds: 0.72, feedback: 0.12, wetGain: 0.044, erasingActivity: 0.12, diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index 88010ea..d992b95 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -121,19 +121,19 @@ export class GardenAudioGraph { ); } - public applyDelayProfile(): void { + public applyDelayProfile(bpm: number): void { if (!this.context || !this.delayNode) { return; } this.delayNode.delayTime.setTargetAtTime( - this.config.delay.timeSeconds, + this.getDelayTimeSecondsForBpm(bpm), this.context.currentTime, this.config.delay.timeRampSeconds ); } - public updateDelay(activity: number): void { + public updateDelay(activity: number, bpm: number): void { if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { return; } @@ -141,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, + this.getDelayTimeSecondsForBpm(bpm), now, this.config.delay.timeRampSeconds ); @@ -214,7 +214,7 @@ export class GardenAudioGraph { const feedbackLowPass = context.createBiquadFilter(); const returnLowPass = context.createBiquadFilter(); - delayNode.delayTime.value = this.config.delay.timeSeconds; + delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm); delayFeedback.gain.value = this.config.delay.feedback; delayOutput.gain.value = this.config.delay.wetGain; feedbackHighPass.type = 'highpass'; @@ -283,6 +283,15 @@ export class GardenAudioGraph { }); } + private getDelayTimeSecondsForBpm(bpm: number): number { + const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm; + return clamp( + (60 / safeBpm) * this.config.delay.timeBeats, + this.config.delay.timeMinSeconds, + this.config.delay.timeMaxSeconds + ); + } + private createNoiseBuffer(context: AudioContext): AudioBuffer { const buffer = context.createBuffer( 1, diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 67be4bf..0d056e7 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -15,14 +15,24 @@ const DEFAULT_SCALE: ReadonlyArray = [0, 2, 4, 7, 9]; const profileCache = new WeakMap(); +const getProfileScale = (vibe: VibePreset): Array => { + const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE; + return [...scale]; +}; + +const getProfileProgression = (vibe: VibePreset): Array => + (vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map( + (chord) => ({ ...chord }) + ); + 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, + scale: getProfileScale(vibe), + progression: getProfileProgression(vibe), }; profileCache.set(vibe, profile); return profile; @@ -30,5 +40,7 @@ export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => { Object.assign(profile, vibe.audio); profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset; + profile.scale = getProfileScale(vibe); + profile.progression = getProfileProgression(vibe); return profile; }; diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index fa92c10..5d6131c 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,7 +1,7 @@ import { ErrorHandler, Severity } from '../utils/error-handler'; import { clamp01 } from '../utils/math'; import type { VibeId, VibePreset } from '../vibes'; -import type { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGraph } from './garden-audio-graph'; @@ -155,11 +155,12 @@ export class GardenAudio { ): void { this.lifecycle = 'started'; this.currentVibeId = vibe.id; - this.graph.applyDelayProfile(); + const profile = getVibeProfile(vibe); + this.graph.applyDelayProfile(profile.bpm); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); if (cuePiano) { - this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); + this.pianoEngine.cue(context.currentTime, profile); } } @@ -245,7 +246,7 @@ export class GardenAudio { if (!this.isGestureActive && this.isReleasingPiano) { this.updatePianoRelease(snapshot.vibe, context.currentTime); - this.updateDelay(snapshot); + this.updateDelay(snapshot, profile); return; } @@ -256,7 +257,7 @@ export class GardenAudio { ? this.config.eraser.pianoActivity : this.energy.getLevel(), }); - this.updateDelay(snapshot); + this.updateDelay(snapshot, profile); } public stroke(stroke: GardenAudioStroke): void { @@ -371,7 +372,10 @@ export class GardenAudio { } } - private updateDelay(snapshot: GardenAudioSnapshot): void { + private updateDelay( + snapshot: GardenAudioSnapshot, + profile: GardenAudioVibeProfile + ): void { const context = this.graph.context; if (!context) { return; @@ -380,7 +384,7 @@ export class GardenAudio { const activity = snapshot.isErasing ? this.config.delay.erasingActivity : this.energy.getLevel(); - this.graph.updateDelay(activity); + this.graph.updateDelay(activity, profile.bpm); } private applyVibe(vibe: VibePreset): void { @@ -390,7 +394,7 @@ export class GardenAudio { this.currentVibeId = vibe.id; const profile = getVibeProfile(vibe); - this.graph.applyDelayProfile(); + this.graph.applyDelayProfile(profile.bpm); this.pianoEngine.cue(this.graph.context.currentTime, profile); } } diff --git a/src/audio/generative-piano-tuning.ts b/src/audio/generative-piano-tuning.ts index 185cbee..e76d6f4 100644 --- a/src/audio/generative-piano-tuning.ts +++ b/src/audio/generative-piano-tuning.ts @@ -152,6 +152,12 @@ interface GenerativePianoTuning { min: number; max: number; }; + stereoWidth: { + idle: number; + active: number; + intense: number; + intenseThreshold: number; + }; stylePanOffsetScale: number; lowpass: { midiBase: number; @@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = { min: -3, max: 3, }, + stereoWidth: { + idle: 0.46, + active: 0.9, + intense: 1.16, + intenseThreshold: 0.72, + }, stylePanOffsetScale: 0.35, lowpass: { midiBase: 48, diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index e2505c5..5743964 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -18,23 +18,34 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler'; const GENERATIVE_LOOKAHEAD_SECONDS = 0.3; const GENERATIVE_START_DELAY_SECONDS = 0.02; -const chordVoicings = { - majorOpen: [0, 7, 12, 16], - minorOpen: [0, 7, 12, 15], - majorClosed: [0, 4, 7, 12, 16], - minorClosed: [0, 3, 7, 12, 15], +const chordVoicings: Record< + GardenAudioChord['quality'], + { closed: Array; open: Array } +> = { + major: { + closed: [0, 4, 7, 12, 16], + open: [0, 7, 12, 16], + }, + minor: { + closed: [0, 3, 7, 12, 15], + open: [0, 7, 12, 15], + }, + sus2: { + closed: [0, 2, 7, 12, 14], + open: [0, 7, 12, 14], + }, + sus4: { + closed: [0, 5, 7, 12, 17], + open: [0, 7, 12, 17], + }, }; const getChordIntervals = ( chord: GardenAudioChord, openVoicing: boolean ): Array => { - if (openVoicing) { - return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen; - } - return chord.quality === 'major' - ? chordVoicings.majorClosed - : chordVoicings.minorClosed; + const voicing = chordVoicings[chord.quality]; + return openVoicing ? voicing.open : voicing.closed; }; const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => { @@ -406,7 +417,7 @@ export class GenerativePianoEngine { velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight, startTime, durationSeconds, - pan: register.pan, + pan: this.getActivityPan(register.pan, expression), role: 'pad', delaySend: generativePianoTuning.padChord.delaySend, lowpassHz: this.getLowpassHz( @@ -446,7 +457,7 @@ export class GenerativePianoEngine { velocity: release.velocities[index], startTime: startTime + index * release.strumSeconds, durationSeconds: release.durationSeconds, - pan: register.pan, + pan: this.getActivityPan(register.pan, 0), role: 'pad', delaySend: release.delaySend, lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression), @@ -487,7 +498,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.supportNote.durationBaseSeconds + expression * generativePianoTuning.supportNote.durationExpressionSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, expression), role: 'support', delaySend: generativePianoTuning.supportNote.delaySendBase + @@ -533,7 +544,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.textureNote.durationBaseSeconds + expression * generativePianoTuning.textureNote.durationExpressionSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, expression), role: 'texture', delaySend: generativePianoTuning.textureNote.delaySendBase + @@ -582,7 +593,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.gestureAccent.durationBaseSeconds + strength * generativePianoTuning.gestureAccent.durationStrengthSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, strength), role: 'gesture', delaySend: generativePianoTuning.gestureAccent.delaySend, lowpassHz: this.getLowpassHz(profile, midi, strength), @@ -627,7 +638,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.touchNote.durationBaseSeconds + strength * generativePianoTuning.touchNote.durationStrengthSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, strength), role: 'gesture', delaySend: generativePianoTuning.touchNote.delaySend, lowpassHz: this.getLowpassHz( @@ -813,7 +824,7 @@ export class GenerativePianoEngine { chordOffsets: this.getChordOffsets(chord, chordIntervals), }; const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); - const pan = this.getStylePan(styleIndex); + const pan = this.getStylePan(styleIndex, intensity); const durationSeconds = clamp( generativePianoTuning.brushStream.durationBaseSeconds + intensity * generativePianoTuning.brushStream.durationIntensitySeconds - @@ -1128,16 +1139,29 @@ export class GenerativePianoEngine { styleCount) as GardenAudioStyleIndex; } - private getStylePan(styleIndex: GardenAudioStyleIndex): number { + private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number { const pool = generativePianoTuning.stylePools[styleIndex]; const styleVoice = styleVoices[styleIndex]; - return clamp( + return this.getActivityPan( pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale, - -1, - 1 + activity ); } + private getActivityPan(pan: number, activity: number): number { + const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth; + const normalizedActivity = clamp01(activity); + const safeThreshold = clamp(intenseThreshold, 0.001, 0.999); + const width = + normalizedActivity < safeThreshold + ? idle + ((active - idle) * normalizedActivity) / safeThreshold + : active + + ((intense - active) * (normalizedActivity - safeThreshold)) / + (1 - safeThreshold); + + return clamp(pan * width, -1, 1); + } + private getLowpassHz( profile: GardenAudioVibeProfile, midi: number, diff --git a/src/config.ts b/src/config.ts index d458f1e..473dbf8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -107,8 +107,7 @@ export const appConfig = { titleStrokeWidthRatio: 0.11, verticalAnchor: 0.47, }, - introMoveSpeedBaseMultiplier: 1.8, - introMoveSpeedProgressMultiplier: 0.35, + introMoveSpeed: 280, stroke: { densityMultiplier: 110, maxAgentCount: 2_400, @@ -129,7 +128,7 @@ export const appConfig = { step: 1, }, mirror: { - default: 1, + default: 8, fallbackSegmentName: 'slices', max: 12, min: 1, diff --git a/src/config/brush-size.test.ts b/src/config/brush-size.test.ts new file mode 100644 index 0000000..9039e62 --- /dev/null +++ b/src/config/brush-size.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS, + getBrushRenderQualityScale, + getRenderQualityBrushSize, +} from './brush-size'; + +describe('render-quality brush sizing', () => { + it('keeps brush sizes unchanged at the 7.3 MP baseline', () => { + expect( + getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS) + ).toBe(21); + }); + + it('scales linear brush size with the square root of render area', () => { + const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4; + + expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2); + expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5); + }); + + it('falls back to baseline scaling for invalid render areas', () => { + expect(getBrushRenderQualityScale(0)).toBe(1); + expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5); + }); +}); diff --git a/src/config/brush-size.ts b/src/config/brush-size.ts new file mode 100644 index 0000000..fc77697 --- /dev/null +++ b/src/config/brush-size.ts @@ -0,0 +1,19 @@ +export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3; + +const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number => + Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0 + ? renderAreaMegapixels + : BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS; + +export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number => + Math.sqrt( + getSafeRenderAreaMegapixels(renderAreaMegapixels) / + BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS + ); + +export const getRenderQualityBrushSize = ( + brushSize: number, + renderAreaMegapixels: number +): number => + Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) * + getBrushRenderQualityScale(renderAreaMegapixels); diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index 4e3ebc6..c84e61d 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -1,17 +1,5 @@ import type { NumberControlConfig } from './types'; -export const colorInteractionSettings = { - color1ToColor1: 1, - color1ToColor2: 0, - color1ToColor3: 0, - color2ToColor1: 0, - color2ToColor2: 1, - color2ToColor3: 0, - color3ToColor1: 0, - color3ToColor2: 0, - color3ToColor3: 1, -}; - export const colorInteractionControl = (label: string): NumberControlConfig => ({ folder: 'Color Reactions', label, diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index 033a723..bdcf049 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -1,4 +1,3 @@ -import { colorInteractionSettings } from './color-interactions'; import { runtimeControls } from './runtime-controls'; import type { GardenAppConfig } from './types'; @@ -27,12 +26,8 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => { }; export const defaultSettings: GardenAppConfig['defaultSettings'] = { - ...colorInteractionSettings, selectedColorIndex: 0, - turnWhenLost: 0.8, - forwardRotationScale: 0.25, - sensorOffsetAngle: 32, introNearDistanceMin: 28, introNearDistanceInner: 4, introNearSensorOffsetMultiplier: 0.75, @@ -40,8 +35,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { introProgressCutoff: 0.999, introTurnRateMultiplier: 3.4, introRandomTurnMultiplier: 0.18, - introFarMoveMultiplier: 2.65, - introNearMoveMultiplier: 0.01, introStepStopDistance: 0.5, randomTimeScale: 0.34816, @@ -58,7 +51,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { brushCurveMirrorResolutionExponent: 0.5, brushCurveSegmentBrushRadiusRatio: 0.65, brushSmoothingMinSampleDistance: 0.5, - strokeAngleJitterRadians: Math.PI * 0.7, brushAlpha: 1, brushDiscardThreshold: 0.02, @@ -78,7 +70,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { adaptiveCapInitial: 1_000_000, adaptiveCapMin: 50_000, internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(), - maxAgentCount: 700_000, + maxAgentCount: 1_500_000, renderTraceNormalizationFloor: 1, renderBrushColorBase: 1.2, diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index a839746..82278b0 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -4,6 +4,16 @@ import type { GardenAppConfig } from './types'; const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; const formatRadiansAsDegrees = (value: number): string => `${Math.round((value * 180) / Math.PI)} deg`; +const formatCompactNumber = (value: number): string => { + if (value >= 1_000_000) { + const millions = value / 1_000_000; + return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`; + } + if (value >= 1_000) { + return `${Math.round(value / 1_000)}k`; + } + return `${value}`; +}; export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'), @@ -20,14 +30,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { folder: 'Brush', label: 'Brush Size', min: 1, - max: 60, + max: 36, step: 0.25, }, spawnPerPixel: { folder: 'Brush', label: 'Density', min: 0.01, - max: 1, + max: 0.38, step: 0.001, }, strokeAngleJitterRadians: { @@ -35,7 +45,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { format: formatRadiansAsDegrees, label: 'Spawn Spread', min: 0, - max: Math.PI * 2, + max: Math.PI, step: 0.01, }, sensorOffsetDistance: { @@ -81,6 +91,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 1, step: 0.001, }, + diffusionRateTrails: { + folder: 'Movement', + label: 'Diffusion Rate', + min: 0.01, + max: 1, + step: 0.01, + }, decayRateTrails: { folder: 'Movement', label: 'Trail Fade', @@ -106,6 +123,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { maxAgentCount: { folder: 'Performance', + format: formatCompactNumber, integer: true, label: 'Population Limit', min: 0, diff --git a/src/config/types.ts b/src/config/types.ts index ad80e83..1c6bedf 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -52,17 +52,30 @@ type RuntimeSettingControlConfig = Partial< Record >; -type GardenVibeSettings = Pick< +export type GardenVibeSettings = Pick< GardenRuntimeSettings, | 'backgroundGrainStrength' | 'brushSize' | 'clarity' + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' | 'decayRateTrails' + | 'forwardRotationScale' | 'individualTrailWeight' | 'moveSpeed' + | 'sensorOffsetAngle' | 'sensorOffsetDistance' | 'spawnPerPixel' + | 'strokeAngleJitterRadians' | 'turnSpeed' + | 'turnWhenLost' >; type GardenDefaultSettings = Omit< @@ -72,10 +85,8 @@ type GardenDefaultSettings = Omit< export enum VibeId { AuroraMycelium = 'aurora-mycelium', - EmberCircuit = 'ember-circuit', VelvetObservatory = 'velvet-observatory', LichenSignal = 'lichen-signal', - UltravioletSiren = 'ultraviolet-siren', TidepoolLantern = 'tidepool-lantern', PaperLanternFog = 'paper-lantern-fog', ChromePollen = 'chrome-pollen', @@ -179,8 +190,7 @@ export interface GardenAppConfig { titleStrokeWidthRatio: number; verticalAnchor: number; }; - introMoveSpeedBaseMultiplier: number; - introMoveSpeedProgressMultiplier: number; + introMoveSpeed: number; stroke: { densityMultiplier: number; maxAgentCount: number; diff --git a/src/config/vibe-presets.test.ts b/src/config/vibe-presets.test.ts new file mode 100644 index 0000000..4052166 --- /dev/null +++ b/src/config/vibe-presets.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { vibePresets } from './vibe-presets'; + +const FINAL_VIBE_NAMES = [ + 'Aurora Mycelium', + 'Velvet Observatory', + 'Lichen Signal', + 'Tidepool Lantern', + 'Paper Lantern Fog Copy', + 'Chrome Pollen', +]; + +describe('vibePresets', () => { + it('keeps the classic preset set distinct', () => { + expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES); + + const ids = vibePresets.map((preset) => preset.id); + expect(new Set(ids).size).toBe(vibePresets.length); + }); + + it('includes both blended and visibly particulate styles', () => { + const blendedNames = vibePresets + .filter( + (preset) => preset.settings.brushSize >= 17 && preset.settings.clarity <= 0.56 + ) + .map((preset) => preset.name); + const softParticleNames = vibePresets + .filter( + (preset) => preset.settings.brushSize <= 5 && preset.settings.clarity <= 0.2 + ) + .map((preset) => preset.name); + + expect(blendedNames).toEqual([ + 'Aurora Mycelium', + 'Tidepool Lantern', + ]); + expect(softParticleNames).toEqual(['Chrome Pollen']); + }); + + it('stays inside interactive performance guardrails', () => { + const violations = vibePresets.flatMap((preset) => { + const { name, settings } = preset; + const presetViolations: Array = []; + + if (settings.spawnPerPixel > 0.38) { + presetViolations.push(`${name} density exceeds 0.38`); + } + if (settings.brushSize > 36) { + presetViolations.push(`${name} brush size exceeds 36`); + } + if ( + settings.spawnPerPixel >= 0.28 && + (settings.decayRateTrails > 940 || + settings.brushSize > 14 || + settings.individualTrailWeight > 0.055) + ) { + presetViolations.push(`${name} combines high density with too much persistence`); + } + + return presetViolations; + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index a95a0a3..e25911a 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -1,5 +1,136 @@ -import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config'; -import { VibeId, type VibePreset } from './types'; +import { + defaultGardenAudioVibeSettings, + type GardenAudioChord, +} from '../audio/garden-audio-config'; +import { VibeId, type GardenVibeSettings, type VibePreset } from './types'; + +type ColorReactionSettings = Pick< + GardenVibeSettings, + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' +>; + +const colorReactions = { + auroraMycelium: { + color1ToColor1: 1, + color1ToColor2: 1, + color1ToColor3: 0, + color2ToColor1: 0, + color2ToColor2: 1, + color2ToColor3: 1, + color3ToColor1: 1, + color3ToColor2: 0, + color3ToColor3: 1, + }, + velvetObservatory: { + color1ToColor1: 1, + color1ToColor2: -1, + color1ToColor3: -1, + color2ToColor1: -1, + color2ToColor2: 1, + color2ToColor3: -1, + color3ToColor1: -1, + color3ToColor2: -1, + color3ToColor3: 1, + }, + lichenSignal: { + color1ToColor1: 0, + color1ToColor2: -1, + color1ToColor3: 1, + color2ToColor1: -1, + color2ToColor2: 0, + color2ToColor3: -1, + color3ToColor1: 1, + color3ToColor2: -1, + color3ToColor3: 1, + }, + tidepoolLantern: { + color1ToColor1: 0, + color1ToColor2: 1, + color1ToColor3: 0, + color2ToColor1: 0, + color2ToColor2: 0, + color2ToColor3: 1, + color3ToColor1: 1, + color3ToColor2: 0, + color3ToColor3: 0, + }, + paperLanternFog: { + color1ToColor1: 1, + color1ToColor2: 1, + color1ToColor3: 1, + color2ToColor1: 1, + color2ToColor2: 1, + color2ToColor3: 1, + color3ToColor1: 1, + color3ToColor2: 1, + color3ToColor3: 1, + }, + chromePollen: { + color1ToColor1: 1, + color1ToColor2: 0, + color1ToColor3: 1, + color2ToColor1: -1, + color2ToColor2: 1, + color2ToColor3: 0, + color3ToColor1: 1, + color3ToColor2: 0, + color3ToColor3: 1, + }, +} satisfies Record; + +const musicScales = { + dorian: [0, 2, 3, 5, 7, 9, 10], + lydian: [0, 2, 4, 6, 7, 9, 11], + mixolydian: [0, 2, 4, 5, 7, 9, 10], + naturalMinor: [0, 2, 3, 5, 7, 8, 10], +} satisfies Record>; + +const musicProgressions = { + aurora: [ + { rootOffset: 0, quality: 'sus2' }, + { rootOffset: 7, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'sus4' }, + ], + chrome: [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 2, quality: 'major' }, + { rootOffset: 7, quality: 'sus2' }, + { rootOffset: 9, quality: 'minor' }, + ], + lichen: [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 10, quality: 'major' }, + { rootOffset: 3, quality: 'major' }, + ], + paperLantern: [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 8, quality: 'major' }, + { rootOffset: 5, quality: 'minor' }, + { rootOffset: 10, quality: 'sus4' }, + ], + tidepool: [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 10, quality: 'major' }, + { rootOffset: 5, quality: 'sus2' }, + { rootOffset: 9, quality: 'minor' }, + ], + velvet: [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 8, quality: 'major' }, + { rootOffset: 3, quality: 'major' }, + { rootOffset: 5, quality: 'sus4' }, + ], +} satisfies Record>; export const defaultVibeId = VibeId.AuroraMycelium; @@ -14,15 +145,20 @@ export const vibePresets: Array = [ ], backgroundColor: [6, 13, 22], settings: { - backgroundGrainStrength: 0.016, - brushSize: 20, + ...colorReactions.auroraMycelium, + backgroundGrainStrength: 0.014, + brushSize: 21, clarity: 0.52, decayRateTrails: 988, - individualTrailWeight: 0.085, + forwardRotationScale: 0.28, + individualTrailWeight: 0.082, moveSpeed: 54, - sensorOffsetDistance: 72, - spawnPerPixel: 0.13, - turnSpeed: 35, + sensorOffsetAngle: 36, + sensorOffsetDistance: 76, + spawnPerPixel: 0.14, + strokeAngleJitterRadians: 1.45, + turnSpeed: 34, + turnWhenLost: 0.75, }, audio: { ...defaultGardenAudioVibeSettings, @@ -33,130 +169,84 @@ export const vibePresets: Array = [ noteLength: 0.86, notePitchOffset: -2, brightness: 0.84, - }, - }, - { - id: VibeId.EmberCircuit, - name: 'Ember Circuit', - colors: [ - [255, 95, 38], - [255, 43, 132], - [43, 219, 255], - ], - backgroundColor: [17, 10, 8], - settings: { - backgroundGrainStrength: 0.03, - brushSize: 8, - clarity: 0.82, - decayRateTrails: 918, - individualTrailWeight: 0.04, - moveSpeed: 150, - sensorOffsetDistance: 24, - spawnPerPixel: 0.31, - turnSpeed: 130, - }, - audio: { - ...defaultGardenAudioVibeSettings, - idleIntensity: 0.03, - bpm: 124, - rampUpIntensity: 1.35, - rampUpTime: 0.04, - noteLength: 0.18, - notePitchOffset: 7, - brightness: 1.34, + scale: musicScales.lydian, + progression: musicProgressions.aurora, }, }, { id: VibeId.VelvetObservatory, name: 'Velvet Observatory', colors: [ - [72, 98, 255], - [255, 89, 176], - [235, 236, 255], + [178, 76, 62], + [2, 174, 255], + [213, 193, 9], ], - backgroundColor: [7, 8, 20], + backgroundColor: [7, 4, 22], settings: { - backgroundGrainStrength: 0.01, - brushSize: 24, - clarity: 0.45, - decayRateTrails: 992, - individualTrailWeight: 0.095, - moveSpeed: 45, - sensorOffsetDistance: 86, - spawnPerPixel: 0.1, - turnSpeed: 24, + ...colorReactions.velvetObservatory, + backgroundGrainStrength: 0.005, + brushSize: 9.75, + clarity: 0.437, + decayRateTrails: 915, + forwardRotationScale: 2.0816681711721685e-17, + individualTrailWeight: 0.1, + moveSpeed: 216, + sensorOffsetAngle: 24, + sensorOffsetDistance: 17, + spawnPerPixel: 0.24, + strokeAngleJitterRadians: 0.16999999999999993, + turnSpeed: 33, + turnWhenLost: 0.42000000000000004, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.14, - bpm: 56, - rampUpIntensity: 0.6, - rampUpTime: 0.16, - noteLength: 1.15, - notePitchOffset: -5, - brightness: 0.72, + idleIntensity: 0.55, + bpm: 72, + rampUpIntensity: 1.42, + rampUpTime: 0.07000000000000002, + noteLength: 0.7000000000000001, + notePitchOffset: 0, + brightness: 0.94, + scale: musicScales.naturalMinor, + progression: musicProgressions.velvet, }, }, { id: VibeId.LichenSignal, name: 'Lichen Signal', colors: [ - [174, 205, 91], - [71, 162, 126], - [229, 117, 71], + [183, 216, 92], + [65, 166, 128], + [238, 120, 76], ], - backgroundColor: [18, 24, 17], - settings: { - backgroundGrainStrength: 0.028, - brushSize: 17, - clarity: 0.66, - decayRateTrails: 974, - individualTrailWeight: 0.065, - moveSpeed: 68, - sensorOffsetDistance: 52, - spawnPerPixel: 0.19, - turnSpeed: 38, - }, - audio: { - ...defaultGardenAudioVibeSettings, - idleIntensity: 0.1, - bpm: 68, - rampUpIntensity: 0.8, - rampUpTime: 0.1, - noteLength: 0.62, - notePitchOffset: -3, - brightness: 0.82, - }, - }, - { - id: VibeId.UltravioletSiren, - name: 'Ultraviolet Siren', - colors: [ - [184, 75, 255], - [0, 224, 255], - [214, 255, 72], - ], - backgroundColor: [13, 9, 31], + backgroundColor: [0, 0, 0], settings: { + ...colorReactions.lichenSignal, backgroundGrainStrength: 0.02, - brushSize: 11, - clarity: 0.72, - decayRateTrails: 946, - individualTrailWeight: 0.052, - moveSpeed: 118, - sensorOffsetDistance: 30, - spawnPerPixel: 0.28, - turnSpeed: 96, + brushSize: 6.5, + clarity: 0.74, + decayRateTrails: 962, + forwardRotationScale: 0.3, + individualTrailWeight: 0.052000000000000005, + moveSpeed: 72, + sensorOffsetAngle: 42, + sensorOffsetDistance: 54, + spawnPerPixel: 0.15999999999999998, + strokeAngleJitterRadians: 3.1399999999999997, + turnSpeed: 44, + turnWhenLost: 0.9200000000000002, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.04, - bpm: 112, - rampUpIntensity: 1.2, - rampUpTime: 0.05, - noteLength: 0.25, - notePitchOffset: 5, - brightness: 1.22, + idleIntensity: 0.13, + bpm: 68, + rampUpIntensity: 1.46, + rampUpTime: 0.10000000000000002, + noteLength: 0.6, + notePitchOffset: -3, + brightness: 1.21, + scale: musicScales.dorian, + progression: musicProgressions.lichen, }, }, { @@ -167,89 +257,110 @@ export const vibePresets: Array = [ [61, 118, 255], [255, 191, 91], ], - backgroundColor: [5, 20, 28], + backgroundColor: [4, 18, 29], settings: { + ...colorReactions.tidepoolLantern, backgroundGrainStrength: 0.018, - brushSize: 15, - clarity: 0.6, - decayRateTrails: 963, - individualTrailWeight: 0.058, + brushSize: 17, + clarity: 0.56, + decayRateTrails: 968, + forwardRotationScale: 0.38, + individualTrailWeight: 0.06, moveSpeed: 88, - sensorOffsetDistance: 44, + sensorOffsetAngle: 64, + sensorOffsetDistance: 46, spawnPerPixel: 0.22, - turnSpeed: 60, + strokeAngleJitterRadians: 1.8, + turnSpeed: 66, + turnWhenLost: 1.05, }, audio: { ...defaultGardenAudioVibeSettings, idleIntensity: 0.08, - bpm: 82, + bpm: 84, rampUpIntensity: 0.95, rampUpTime: 0.08, - noteLength: 0.48, + noteLength: 0.46, notePitchOffset: 0, brightness: 0.98, + scale: musicScales.mixolydian, + progression: musicProgressions.tidepool, }, }, { id: VibeId.PaperLanternFog, - name: 'Paper Lantern Fog', + name: 'Paper Lantern Fog Copy', colors: [ - [255, 174, 104], - [242, 102, 107], - [132, 211, 185], + [255, 176, 108], + [239, 90, 108], + [128, 213, 184], ], - backgroundColor: [31, 23, 20], + backgroundColor: [30, 23, 20], settings: { - backgroundGrainStrength: 0.036, - brushSize: 22, - clarity: 0.5, - decayRateTrails: 984, - individualTrailWeight: 0.08, - moveSpeed: 56, - sensorOffsetDistance: 64, - spawnPerPixel: 0.14, - turnSpeed: 32, + ...colorReactions.paperLanternFog, + backgroundGrainStrength: 0.038, + brushSize: 3.5, + clarity: 1, + decayRateTrails: 999, + forwardRotationScale: 0.24, + individualTrailWeight: 0.937, + moveSpeed: 28, + sensorOffsetAngle: 34, + sensorOffsetDistance: 66, + spawnPerPixel: 0.05499999999999998, + strokeAngleJitterRadians: 0, + turnSpeed: 30, + turnWhenLost: 1.52, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.13, - bpm: 64, - rampUpIntensity: 0.72, - rampUpTime: 0.12, - noteLength: 0.9, - notePitchOffset: -4, - brightness: 0.76, + idleIntensity: 0.33, + bpm: 127, + rampUpIntensity: 0.66, + rampUpTime: 0.03000000000000001, + noteLength: 0.92, + notePitchOffset: 10, + brightness: 1.42, + scale: musicScales.naturalMinor, + progression: musicProgressions.paperLantern, }, }, { id: VibeId.ChromePollen, name: 'Chrome Pollen', colors: [ - [235, 255, 238], + [178, 34, 34], [255, 214, 48], [77, 240, 157], ], - backgroundColor: [9, 13, 12], + backgroundColor: [7, 12, 11], settings: { + ...colorReactions.chromePollen, backgroundGrainStrength: 0.012, - brushSize: 10, - clarity: 0.9, - decayRateTrails: 935, - individualTrailWeight: 0.045, - moveSpeed: 104, - sensorOffsetDistance: 36, - spawnPerPixel: 0.24, - turnSpeed: 78, + brushSize: 4.5, + clarity: 0.1, + decayRateTrails: 922, + forwardRotationScale: 0.5, + individualTrailWeight: 0.026000000000000002, + moveSpeed: 86, + sensorOffsetAngle: 46, + sensorOffsetDistance: 14, + spawnPerPixel: 0.36, + strokeAngleJitterRadians: 3, + turnSpeed: 34, + turnWhenLost: 1.35, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.05, - bpm: 96, - rampUpIntensity: 1.05, - rampUpTime: 0.07, - noteLength: 0.3, - notePitchOffset: 3, - brightness: 1.18, + idleIntensity: 0.11, + bpm: 150, + rampUpIntensity: 2, + rampUpTime: 0.06000000000000001, + noteLength: 1.8, + notePitchOffset: -12, + brightness: 0.5, + scale: musicScales.lydian, + progression: musicProgressions.chrome, }, }, ]; diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index c00d7c7..68e05c0 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -1,6 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; +import { getRenderQualityBrushSize } from '../config/brush-size'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; @@ -162,7 +163,11 @@ export class AgentPopulation { } const baseAngle = Math.atan2(deltaY, deltaX); - const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio()); + const spread = + getRenderQualityBrushSize( + settings.brushSize, + settings.internalRenderAreaMegapixels + ) * getSafePixelRatio(this.getCanvasPixelRatio()); const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT; if (batchCapacity <= 0) { return; diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts index 80dd4b9..3d45283 100644 --- a/src/game-loop/brush-stroke-smoother.ts +++ b/src/game-loop/brush-stroke-smoother.ts @@ -1,6 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; +import { getRenderQualityBrushSize } from '../config/brush-size'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { settings } from '../settings'; import { type StrokeSegment } from './game-loop-types'; @@ -90,9 +91,13 @@ export class BrushStrokeSmoother { ): Array { const curveLength = vec2.distance(start, control) + vec2.distance(control, end); const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio()); + const brushSize = getRenderQualityBrushSize( + settings.brushSize, + settings.internalRenderAreaMegapixels + ); const brushRadius = Math.max( settings.brushCurveMinBrushRadius * canvasPixelRatio, - (settings.brushSize * canvasPixelRatio) / 2 + (brushSize * canvasPixelRatio) / 2 ); const segmentSpacing = Math.max( settings.brushCurveMinSegmentSpacing * canvasPixelRatio, diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 492204f..14588f9 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -137,12 +137,8 @@ export class GameLoopResources { deltaTime, time, agentCount: activeAgentCount, - moveSpeed: - settings.moveSpeed * - (introProgress >= 1 - ? 1 - : appConfig.simulation.introMoveSpeedBaseMultiplier + - introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier), + moveSpeed: settings.moveSpeed, + introMoveSpeed: appConfig.simulation.introMoveSpeed, introProgress, }); this.brushPipeline.setParameters({ diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index 538ed45..ec4b9e3 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -46,16 +46,19 @@ export class SimulationFrameRenderer { const commandEncoder = this.device.createCommandEncoder(); this.gpuProfiler?.beginFrame(); - this.textures.copyTrailMapAToB(commandEncoder); + // Clear the deposit map up-front so agents write fresh deposits each frame + // and diffuse sees only this frame's contributions added to trailMapA. + this.textures.clearDepositMap(commandEncoder); let wroteSourceMap = false; if (isErasing) { if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { const eraserMask = this.textures.eraserMask.getTextureView(); + // Erase trailMapA directly — it's what agent and diffuse will read. this.pipelines.eraserTexturePipeline.executeCombined( commandEncoder, eraserMask, this.textures.sourceMapA.getTextureView(), - this.textures.trailMapB.getTextureView(), + this.textures.trailMapA.getTextureView(), this.gpuProfiler?.timestampWrites('eraserTexture') ); this.pipelines.eraserAgentPipeline.execute( @@ -86,19 +89,20 @@ export class SimulationFrameRenderer { this.pipelines.agentPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView(), + this.textures.depositMap.getTextureView(), this.gpuProfiler?.timestampWrites('agent') ); this.pipelines.diffusionPipeline.execute( commandEncoder, - this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getSize(), + this.textures.depositMap.getTextureView(), this.gpuProfiler?.timestampWrites('trailDiffusion') ); const canvasTexture = this.pipelines.renderPipeline.execute( commandEncoder, - this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), this.textures.sourceMapA.getTextureView(), useSourceMap, this.gpuProfiler?.timestampWrites('render') @@ -111,6 +115,7 @@ export class SimulationFrameRenderer { this.textures.sourceMapA.getTextureView(), this.textures.sourceMapB.getTextureView(), this.textures.sourceMapB.getSize(), + null, this.gpuProfiler?.timestampWrites('sourceDiffusion') ); } @@ -118,6 +123,10 @@ export class SimulationFrameRenderer { this.device.queue.submit([commandEncoder.finish()]); afterGpuProfileSubmit?.(); canvasReadbackRequest?.afterSubmit(); + // After this frame's diffuse, trailMapB holds the fresh trail; swap so + // trailMapA is "current trail" again for the next frame and any external + // readers (e.g. export snapshot). + this.textures.swapTrailMaps(); 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 7a07aa0..7832469 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -8,10 +8,16 @@ import { } from '../utils/graphics/resizable-texture'; export class SimulationTextures { - public readonly trailMapA: ResizableTexture; - public readonly trailMapB: ResizableTexture; + // trailMapA holds the current trail (read by agent and diffuse). trailMapB + // receives the diffuse output; the two swap each frame so the freshly + // diffused texture becomes trailMapA for the next frame. + public trailMapA: ResizableTexture; + public trailMapB: ResizableTexture; + // Per-frame deposit accumulator: cleared each frame, written sparsely by + // agents, then read by diffuse alongside trailMapA. Replaces the previous + // full-resolution copyTrailMapAToB seed. + public readonly depositMap: ResizableTexture; public readonly eraserMask: ResizableTexture; - // A/B are swapped each frame to ping-pong the diffusion pass. public sourceMapA: ResizableTexture; public sourceMapB: ResizableTexture; @@ -21,6 +27,7 @@ export class SimulationTextures { ) { this.trailMapA = this.createTexture(canvasSize); this.trailMapB = this.createTexture(canvasSize); + this.depositMap = this.createTexture(canvasSize); this.sourceMapA = this.createTexture(canvasSize); this.sourceMapB = this.createTexture(canvasSize); this.eraserMask = this.createEraserMask(canvasSize); @@ -36,6 +43,7 @@ export class SimulationTextures { const resizes = [ this.trailMapA, this.trailMapB, + this.depositMap, this.sourceMapA, this.sourceMapB, this.eraserMask, @@ -67,6 +75,7 @@ export class SimulationTextures { [ this.trailMapA, this.trailMapB, + this.depositMap, this.sourceMapA, this.sourceMapB, this.eraserMask, @@ -86,14 +95,25 @@ export class SimulationTextures { this.device.queue.submit([commandEncoder.finish()]); } - public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void { - const size = this.trailMapA.getSize(); + public clearDepositMap(commandEncoder: GPUCommandEncoder): void { + // Hardware fast-clear via a render pass with loadOp 'clear' and an empty + // body. Cheaper than copyTextureToTexture and writes no actual color data + // on tile-based GPUs. + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.depositMap.getTextureView(), + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + } - commandEncoder.copyTextureToTexture( - { texture: this.trailMapA.getTexture() }, - { texture: this.trailMapB.getTexture() }, - { width: size[0], height: size[1] } - ); + public swapTrailMaps(): void { + [this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA]; } public clearSourceMaps(commandEncoder: GPUCommandEncoder): void { @@ -119,6 +139,7 @@ export class SimulationTextures { public destroy(): void { this.trailMapA.destroy(); this.trailMapB.destroy(); + this.depositMap.destroy(); this.sourceMapA.destroy(); this.sourceMapB.destroy(); this.eraserMask.destroy(); diff --git a/src/index.ts b/src/index.ts index fa06833..db0425f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,12 +120,12 @@ const main = async () => { new FullScreenHandler(fullScreenButton, document.documentElement); new VibeNavigator({ - onChange: ({ vibeId, vibeName, source }) => { + onChange: ({ vibeId, vibeName, source, userGesture }) => { trackVibeChange({ vibeId, vibeName, source }); game?.onVibeChanged(); syncRuntimeUi(); configPane?.refresh(); - game?.playVibeChangeAudio(true); + game?.playVibeChangeAudio(userGesture); }, }); diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 52bf872..030f631 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -20,7 +20,11 @@ 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; +type NumberPropertyKey = { + [Key in keyof T]-?: T[Key] extends number ? Key : never; +}[keyof T] & + string; +type VibeNumberKey = NumberPropertyKey; interface PaneState extends GardenAudioVibeSettings { backgroundColor: string; diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts index 2e37feb..f784e78 100644 --- a/src/page/vibe-navigator.ts +++ b/src/page/vibe-navigator.ts @@ -1,9 +1,11 @@ -import { activeVibe, applyVibeSettings } from '../settings'; +import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings'; import { queryRequiredElement } from '../utils/dom'; -import { VIBE_PRESETS, type VibeId } from '../vibes'; +import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri'; +import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes'; interface VibeSelection { source: string; + userGesture: boolean; vibeId: VibeId; vibeName: string; } @@ -20,18 +22,51 @@ export class VibeNavigator { private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement); public constructor(private readonly options: VibeNavigatorOptions) { + rememberActiveVibeSelection(); + writeCurrentVibeUri(activeVibe.id, 'replace'); + this.previousButton.addEventListener('click', () => this.select(-1, 'previous-button') ); this.nextButton.addEventListener('click', () => this.select(1, 'next-button')); + window.addEventListener('popstate', () => this.selectFromCurrentUri()); } private select(offset: number, source: string): void { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); + const currentIndex = current >= 0 ? current : 0; const vibe = - VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; + VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; const activePreset = applyVibeSettings(vibe); + writeCurrentVibeUri(activePreset.id, 'push'); + this.notifyChange(activePreset, source, true); + } + + private selectFromCurrentUri(): void { + const vibeId = getCurrentUriVibeId(); + if (!vibeId || vibeId === activeVibe.id) { + writeCurrentVibeUri(activeVibe.id, 'replace'); + return; + } + + const vibe = getVibeById(vibeId); + if (!vibe) { + writeCurrentVibeUri(activeVibe.id, 'replace'); + return; + } + + const activePreset = applyVibeSettings(vibe); + writeCurrentVibeUri(activePreset.id, 'replace'); + this.notifyChange(activePreset, 'uri-popstate', false); + } + + private notifyChange( + activePreset: typeof activeVibe, + source: string, + userGesture: boolean + ): void { this.options.onChange({ + userGesture, vibeId: activePreset.id, vibeName: activePreset.name, source, diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts index a5f97ff..158d556 100644 --- a/src/pipelines/agents/agent-dispatch.ts +++ b/src/pipelines/agents/agent-dispatch.ts @@ -1,15 +1,43 @@ -// Use the device's max workgroup size so we get full SIMD/wave occupancy on -// hardware that supports more than the WebGPU minimum of 256. -export const getAgentWorkgroupSize = (device: GPUDevice): number => - device.limits.maxComputeInvocationsPerWorkgroup; +const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const; + +export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number]; + +const AGENT_WORKGROUP_SIZE_TARGETS = { + // Keep shader-specific targets conservative. Using the device maximum can + // hurt occupancy and makes compaction's workgroup scan more expensive. + simulation: 256, + eraser: 256, + resize: 256, + compaction: 256, +} satisfies Record; + +export const getAgentWorkgroupSize = ( + device: GPUDevice, + kind: AgentWorkgroupKind = 'simulation' +): number => { + const deviceLimit = Math.max( + 1, + Math.floor( + Math.min( + device.limits.maxComputeInvocationsPerWorkgroup, + device.limits.maxComputeWorkgroupSizeX + ) + ) + ); + return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit); +}; + +export const getMinAgentWorkgroupSize = (device: GPUDevice): number => + Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind))); export const substituteAgentWorkgroupSize = ( device: GPUDevice, - shaderCode: string + shaderCode: string, + kind: AgentWorkgroupKind = 'simulation' ): string => shaderCode.replaceAll( '__AGENT_WORKGROUP_SIZE__', - String(getAgentWorkgroupSize(device)) + String(getAgentWorkgroupSize(device, kind)) ); export const dispatchAgentWorkgroups = ( diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index b747e02..9b62fbb 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -36,7 +36,8 @@ export class AgentGenerationPipeline { private readonly resizePipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline; private readonly clearCompactedTailPipeline: GPUComputePipeline; - private readonly workgroupSize: number; + private readonly resizeWorkgroupSize: number; + private readonly compactionWorkgroupSize: number; private activeAgentsBuffer: GPUBuffer; private inactiveAgentsBuffer: GPUBuffer; @@ -110,20 +111,26 @@ export class AgentGenerationPipeline { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.workgroupSize = getAgentWorkgroupSize(device); - const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema); + this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize'); + this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction'); + const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize'); + const compactionSchema = substituteAgentWorkgroupSize( + device, + agentSchema, + 'compaction' + ); this.resizePipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], }), compute: { - module: smartCompile(device, sizedSchema, resizeShader), + module: smartCompile(device, resizeSchema, resizeShader), entryPoint: 'main', }, }); - const compactionModule = smartCompile(device, sizedSchema, compactionShader); + const compactionModule = smartCompile(device, compactionSchema, compactionShader); this.compactionPipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ @@ -248,7 +255,7 @@ export class AgentGenerationPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.resizePipeline); passEncoder.setBindGroup(1, this.getBindGroup()); - dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount); + dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount); passEncoder.end(); this.device.queue.submit([commandEncoder.finish()]); @@ -267,11 +274,11 @@ export class AgentGenerationPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.compactionPipeline); passEncoder.setBindGroup(1, this.getBindGroup()); - dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount); + dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount); passEncoder.setPipeline(this.clearCompactedTailPipeline); dispatchAgentWorkgroups( passEncoder, - this.workgroupSize, + this.compactionWorkgroupSize, Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE) ); passEncoder.end(); diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts index fdf379a..405b37d 100644 --- a/src/pipelines/agents/agent-limits.ts +++ b/src/pipelines/agents/agent-limits.ts @@ -1,4 +1,4 @@ -import { getAgentWorkgroupSize } from './agent-dispatch'; +import { getMinAgentWorkgroupSize } from './agent-dispatch'; export const AGENT_FLOAT_COUNT = 8; export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; @@ -58,7 +58,7 @@ export const getMaxSupportedAgentCount = ( Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES), Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * - getAgentWorkgroupSize(device) + getMinAgentWorkgroupSize(device) ) ); }; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index e647f77..4d4dd0d 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -38,19 +38,27 @@ export interface AgentSettings { introNearDistanceInner: number; introTurnRateMultiplier: number; introRandomTurnMultiplier: number; - introFarMoveMultiplier: number; - introNearMoveMultiplier: number; introStepStopDistance: number; randomTimeScale: number; } -const UNIFORM_COUNT = 30; +// The Settings struct in WGSL starts with a mat3x3 reactionMatrix. +// In uniform layout each of its 3 columns is stored as a vec3 padded to +// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused +// padding). Remaining scalars pack tightly from float 12 onward. +const UNIFORM_COUNT = 32; +const REACTION_MATRIX_COL0 = 0; +const REACTION_MATRIX_COL1 = 4; +const REACTION_MATRIX_COL2 = 8; +const SCALAR_BASE = 12; export class AgentPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; - private readonly pipeline: GPUComputePipeline; + private readonly pipelineFull: GPUComputePipeline; + private readonly pipelineSteady: GPUComputePipeline; private readonly uniforms: GPUBuffer; private readonly workgroupSize: number; + private useSteadyPipeline = false; private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); private readonly uniformCache = createCachedBufferWrite( @@ -104,23 +112,30 @@ export class AgentPipeline { ], }); - this.workgroupSize = getAgentWorkgroupSize(device); + this.workgroupSize = getAgentWorkgroupSize(device, 'simulation'); const shaderModule = smartCompile( device, CommonState.shaderCode, - substituteAgentWorkgroupSize(device, agentSchema), + substituteAgentWorkgroupSize(device, agentSchema, 'simulation'), shader ); const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], }); - this.pipeline = device.createComputePipeline({ + this.pipelineFull = device.createComputePipeline({ layout: pipelineLayout, compute: { module: shaderModule, entryPoint: 'main', }, }); + this.pipelineSteady = device.createComputePipeline({ + layout: pipelineLayout, + compute: { + module: shaderModule, + entryPoint: 'mainSteady', + }, + }); this.uniforms = device.createBuffer({ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, @@ -153,8 +168,7 @@ export class AgentPipeline { introProgressCutoff, introTurnRateMultiplier, introRandomTurnMultiplier, - introFarMoveMultiplier, - introNearMoveMultiplier, + introMoveSpeed, introStepStopDistance, randomTimeScale, time, @@ -164,40 +178,46 @@ export class AgentPipeline { deltaTime: number; time: number; agentCount: number; + introMoveSpeed: number; introProgress?: number; }) { this.agentCount = agentCount; - this.uniformValues[0] = moveSpeed * deltaTime; - this.uniformValues[1] = turnSpeed * deltaTime; + const resolvedIntroProgress = introProgress ?? 1; + // Once the intro target phase ends nothing reads intro fields again, so the + // steady-only pipeline can replace the full one for the rest of the session. + this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff; + // Reaction matrix: column N holds the weights for source colorIndex == N. + this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1; + this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2; + this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3; + this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1; + this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2; + this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3; + this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1; + this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2; + this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3; + this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime; + this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; - this.uniformValues[2] = Math.sin(sensorAngle); - this.uniformValues[3] = Math.cos(sensorAngle); - this.uniformValues[4] = sensorOffsetDistance; - this.uniformValues[5] = turnWhenLost; - this.uniformValues[6] = individualTrailWeight; - this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount)); - this.uniformValues[8] = introProgress ?? 1; - this.uniformValues[9] = color1ToColor1; - this.uniformValues[10] = color1ToColor2; - this.uniformValues[11] = color1ToColor3; - this.uniformValues[12] = color2ToColor1; - this.uniformValues[13] = color2ToColor2; - this.uniformValues[14] = color2ToColor3; - this.uniformValues[15] = color3ToColor1; - this.uniformValues[16] = color3ToColor2; - this.uniformValues[17] = color3ToColor3; - this.uniformValues[18] = forwardRotationScale; - this.uniformValues[19] = introNearDistanceInner; - this.uniformValues[20] = introNearDistanceMin; - this.uniformValues[21] = introNearSensorOffsetMultiplier; - this.uniformValues[22] = introTargetAngleBlend; - this.uniformValues[23] = introProgressCutoff; - this.uniformValues[24] = introTurnRateMultiplier; - this.uniformValues[25] = introRandomTurnMultiplier; - this.uniformValues[26] = introFarMoveMultiplier; - this.uniformValues[27] = introNearMoveMultiplier; - this.uniformValues[28] = introStepStopDistance; - this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0; + this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle); + this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle); + this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance; + this.uniformValues[SCALAR_BASE + 5] = turnWhenLost; + this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight; + this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount)); + this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress; + this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale; + this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner; + this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin; + this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier; + this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend; + this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff; + this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier; + this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier; + this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime; + this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance; + this.uniformUintValues[SCALAR_BASE + 19] = + Math.max(0, Math.floor(time * randomTimeScale)) >>> 0; writeBufferIfChanged( this.device, this.uniforms, @@ -219,7 +239,9 @@ export class AgentPipeline { const passEncoder = commandEncoder.beginComputePass( timestampWrites ? { timestampWrites } : undefined ); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline( + this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull + ); this.commonState.execute(passEncoder); passEncoder.setBindGroup( 1, diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 43fe63f..591fa10 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -2,7 +2,16 @@ const PI: f32 = 3.14159265359; const TAU: f32 = 6.28318530718; const INV_TAU: f32 = 0.15915494309; +const CHANNEL_MASKS = array, 3>( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + vec3(0.0, 0.0, 1.0), +); + struct Settings { + // Columns are indexed by source colorIndex; each column holds the per-target + // weights (colorXToColor1, colorXToColor2, colorXToColor3). + reactionMatrix: mat3x3, moveRate: f32, turnRate: f32, sensorAngleSin: f32, @@ -12,15 +21,6 @@ struct Settings { individualTrailWeight: f32, agentCount: u32, introProgress: f32, - color1ToColor1: f32, - color1ToColor2: f32, - color1ToColor3: f32, - color2ToColor1: f32, - color2ToColor2: f32, - color2ToColor3: f32, - color3ToColor1: f32, - color3ToColor2: f32, - color3ToColor3: f32, forwardRotationScale: f32, introNearDistanceInner: f32, introNearDistanceMin: f32, @@ -29,8 +29,7 @@ struct Settings { introProgressCutoff: f32, introTurnRateMultiplier: f32, introRandomTurnMultiplier: f32, - introFarMoveMultiplier: f32, - introNearMoveMultiplier: f32, + introMoveRate: f32, introStepStopDistance: f32, randomTimeSeed: u32, }; @@ -39,6 +38,11 @@ struct Settings { @group(1) @binding(2) var trailMapIn: texture_2d; @group(1) @binding(3) var trailMapOut: texture_storage_2d; +struct AgentMovement { + rotation: f32, + step: vec2, +} + @compute @workgroup_size(agentWorkgroupSize) fn main( @builtin(global_invocation_id) global_id: vec3 @@ -54,8 +58,8 @@ fn main( return; } - var position = agents[id].position; - var angle = agents[id].angle; + let position = agents[id].position; + let angle = agents[id].angle; var targetPosition = vec2(-1.0, -1.0); var hasIntroTarget = false; if settings.introProgress < settings.introProgressCutoff { @@ -70,92 +74,153 @@ fn main( let reactionMask = get_reaction_mask(colorIndex); 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); + var movement = AgentMovement(0.0, vec2(0.0, 0.0)); if hasIntroTarget { - let introTargetOffset = targetPosition - position; - let introTargetDistance = length(introTargetOffset); - let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x); - let nearTitle = 1.0 - smoothstep( - settings.introNearDistanceInner, - max( - settings.introNearDistanceMin, - settings.sensorOffset * settings.introNearSensorOffsetMultiplier - ), - introTargetDistance - ); - let desiredAngle = mix( - targetAngle, - agents[id].targetAngle, - nearTitle * settings.introTargetAngleBlend - ); - let introTurn = angle_delta(angle, desiredAngle); - - rotation = clamp( - introTurn, - -settings.turnRate * settings.introTurnRateMultiplier, - settings.turnRate * settings.introTurnRateMultiplier - ) - + (random_float(randomSeed + 1013904223u) - 0.5) * - settings.turnWhenLost * - settings.introRandomTurnMultiplier; - let moveRate = min( - settings.moveRate * - mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle), - introTargetDistance - ); - if introTargetDistance > settings.introStepStopDistance { - step = introTargetOffset / introTargetDistance * moveRate; - } + movement = intro_decide(id, position, angle, targetPosition, randomSeed); } else { - let randomTurn = random_float(randomSeed); - let direction = vec2(cos(angle), sin(angle)); - - let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition); - let leftSensor = sensor_position( - position, - rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos), - settings.sensorOffset, - maxPosition - ); - let rightSensor = sensor_position( - position, - rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos), - settings.sensorOffset, - maxPosition - ); - - let trailForward = textureLoad(trailMapIn, forwardSensor, 0); - let trailLeft = textureLoad(trailMapIn, leftSensor, 0); - let trailRight = textureLoad(trailMapIn, rightSensor, 0); - - let weightForward = dot(trailForward.rgb, reactionMask); - let weightLeft = dot(trailLeft.rgb, reactionMask); - let weightRight = dot(trailRight.rgb, reactionMask); - - rotation = (randomTurn - 0.5) * settings.turnWhenLost; - if weightForward >= weightLeft && weightForward >= weightRight { - rotation = rotation * settings.forwardRotationScale; - } else { - rotation += sign(weightLeft - weightRight) * settings.turnRate; - } - - step = direction * settings.moveRate; + movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition); } - let nextPosition = clamp(position + step, vec2(0, 0), maxPosition); + agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement); +} + +// Steady-state-only entry point used after introProgress >= introProgressCutoff. +// Drops the intro target reads, atan2/smoothstep math, and introDelay check — +// once intro completes those paths are dead for the rest of the session. +@compute @workgroup_size(agentWorkgroupSize) +fn mainSteady( + @builtin(global_invocation_id) global_id: vec3 +) { + let id = get_id(global_id); + + if id >= settings.agentCount { + return; + } + + let colorIndex = agents[id].colorIndex; + if colorIndex < 0.0 || colorIndex >= 2.5 { + return; + } + + let position = agents[id].position; + let angle = agents[id].angle; + let channelMask = get_channel_mask(colorIndex); + let reactionMask = get_reaction_mask(colorIndex); + let randomSeed = random_seed(id); + let maxPosition = state.size - vec2(1.0, 1.0); + + let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition); + agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement); +} + +fn steady_decide( + position: vec2, + angle: f32, + reactionMask: vec3, + randomSeed: u32, + maxPosition: vec2 +) -> AgentMovement { + let randomTurn = random_float(randomSeed); + let direction = vec2(cos(angle), sin(angle)); + + let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition); + let leftSensor = sensor_position( + position, + rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + let rightSensor = sensor_position( + position, + rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + + let trailForward = textureLoad(trailMapIn, forwardSensor, 0); + let trailLeft = textureLoad(trailMapIn, leftSensor, 0); + let trailRight = textureLoad(trailMapIn, rightSensor, 0); + + let weightForward = dot(trailForward.rgb, reactionMask); + let weightLeft = dot(trailLeft.rgb, reactionMask); + let weightRight = dot(trailRight.rgb, reactionMask); + + var rotation = (randomTurn - 0.5) * settings.turnWhenLost; + if weightForward >= weightLeft && weightForward >= weightRight { + rotation = rotation * settings.forwardRotationScale; + } else { + rotation += sign(weightLeft - weightRight) * settings.turnRate; + } + + return AgentMovement(rotation, direction * settings.moveRate); +} + +fn intro_decide( + id: u32, + position: vec2, + angle: f32, + targetPosition: vec2, + randomSeed: u32 +) -> AgentMovement { + let introTargetOffset = targetPosition - position; + let introTargetDistance = length(introTargetOffset); + let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x); + let nearTitle = 1.0 - smoothstep( + settings.introNearDistanceInner, + max( + settings.introNearDistanceMin, + settings.sensorOffset * settings.introNearSensorOffsetMultiplier + ), + introTargetDistance + ); + let desiredAngle = mix( + targetAngle, + agents[id].targetAngle, + nearTitle * settings.introTargetAngleBlend + ); + let introTurn = angle_delta(angle, desiredAngle); + + let rotation = clamp( + introTurn, + -settings.turnRate * settings.introTurnRateMultiplier, + settings.turnRate * settings.introTurnRateMultiplier + ) + + (random_float(randomSeed + 1013904223u) - 0.5) * + settings.turnWhenLost * + settings.introRandomTurnMultiplier; + let moveRate = min(settings.introMoveRate, introTargetDistance); + var step = vec2(0.0, 0.0); + if introTargetDistance > settings.introStepStopDistance { + step = introTargetOffset / introTargetDistance * moveRate; + } + return AgentMovement(rotation, step); +} + +fn agent_finalize( + id: u32, + position: vec2, + angle: f32, + channelMask: vec3, + randomSeed: u32, + maxPosition: vec2, + movement: AgentMovement +) { + let nextPosition = clamp(position + movement.step, vec2(0, 0), maxPosition); + var rotation = movement.rotation; if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { rotation = PI + random_float(randomSeed + 22695477u) - 0.5; } - var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0); - trailBelow = vec4( - trailBelow.rgb + channelMask * settings.individualTrailWeight, - max(trailBelow.a, 0.0) + // Writes only the deposit into a per-frame-cleared depositMap. The diffusion + // pass sums trailMap + depositMap at tile-load time, so the previous trail + // value is no longer needed here. Alpha stays 0 in depositMap — diffuse's + // alpha decay reads it from trailMap (where deposit alpha contributes 0). + textureStore( + trailMapOut, + vec2(nextPosition), + vec4(channelMask * settings.individualTrailWeight, 0.0) ); - - textureStore(trailMapOut, vec2(nextPosition), trailBelow); agents[id].angle = angle + rotation; agents[id].position = nextPosition; } @@ -181,41 +246,11 @@ fn rotate_direction(direction: vec2, angleSin: f32, angleCos: f32) -> vec2< } fn get_channel_mask(colorIndex: f32) -> vec3 { - if colorIndex < 0.5 { - return vec3(1, 0, 0); - } - if colorIndex < 1.5 { - return vec3(0, 1, 0); - } - if colorIndex < 2.5 { - return vec3(0, 0, 1); - } - return vec3(0.0, 0.0, 0.0); + return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))]; } fn get_reaction_mask(colorIndex: f32) -> vec3 { - if colorIndex < 0.5 { - return vec3( - settings.color1ToColor1, - settings.color1ToColor2, - settings.color1ToColor3 - ); - } - if colorIndex < 1.5 { - return vec3( - settings.color2ToColor1, - settings.color2ToColor2, - settings.color2ToColor3 - ); - } - if colorIndex < 2.5 { - return vec3( - settings.color3ToColor1, - settings.color3ToColor2, - settings.color3ToColor3 - ); - } - return vec3(0.0, 0.0, 0.0); + return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))]; } fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index 32584fa..7302f14 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -1,6 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../../config'; +import { getRenderQualityBrushSize } from '../../config/brush-size'; import { createCachedBufferWrite, writeBufferIfChanged, @@ -28,6 +29,7 @@ export interface BrushSettings { } interface BrushParameters extends BrushSettings { + internalRenderAreaMegapixels: number; pixelRatio?: number; selectedColorIndex: number; } @@ -50,12 +52,16 @@ const setBrushUniformValues = ( brushGrainNoiseOffsetY, brushGrainMinStrength, brushGrainMaxStrength, + internalRenderAreaMegapixels, selectedColorIndex, pixelRatio, }: BrushParameters ): void => { const safePixelRatio = getSafePixelRatio(pixelRatio); - const brushRadius = (brushSize * safePixelRatio) / 2; + const brushRadius = + (getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) * + safePixelRatio) / + 2; target[0] = brushRadius; target[1] = brushRadius * brushRadius; diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 684b37a..57bf139 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -27,6 +27,10 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10; @group(0) @binding(0) var settings: Settings; @group(0) @binding(1) var trailMap: texture_2d; @group(0) @binding(2) var trailMapOut: texture_storage_2d; +// Per-frame deposit accumulator written sparsely by agents. Summed with +// trailMap at tile-load so deposits propagate through the diffusion kernel +// in the same frame. +@group(0) @binding(3) var depositMap: texture_2d; var tile: array, 324>; var tileTrailStrength: array; @@ -49,7 +53,8 @@ fn main( vec2(0, 0), textureBound ); - let texel = textureLoad(trailMap, sourcePixel, 0); + let texel = textureLoad(trailMap, sourcePixel, 0) + + textureLoad(depositMap, sourcePixel, 0); tile[tileIndex] = texel; tileTrailStrength[tileIndex] = length(texel.rgb); } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index eb92128..373e736 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -1,7 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../../config'; -import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; +import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache'; import { createCachedBufferWrite, writeBufferIfChanged, @@ -69,20 +69,29 @@ export class DiffusionPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; private readonly uniforms: GPUBuffer; + // 1x1 zero texture used as the depositMap binding when callers don't supply + // one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for + // out-of-bounds coordinates, so the diffusion shader sums in zeros. + private readonly emptyDepositTexture: GPUTexture; + private readonly emptyDepositTextureView: GPUTextureView; private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedBufferWrite( DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT ); - private readonly getBindGroup = createBindGroupCache( - (trailMapIn, trailMapOut) => - this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { binding: 0, resource: { buffer: this.uniforms } }, - { binding: 1, resource: trailMapIn }, - { binding: 2, resource: trailMapOut }, - ], - }) + private readonly getBindGroup = createBindGroupCache3< + GPUTextureView, + GPUTextureView, + GPUTextureView + >((trailMapIn, trailMapOut, depositMap) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: trailMapIn }, + { binding: 2, resource: trailMapOut }, + { binding: 3, resource: depositMap }, + ], + }) ); public constructor(private readonly device: GPUDevice) { @@ -104,6 +113,26 @@ export class DiffusionPipeline { size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); + + this.emptyDepositTexture = device.createTexture({ + format: TRAIL_SOURCE_TEXTURE_FORMAT, + size: { width: 1, height: 1 }, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT, + }); + this.emptyDepositTextureView = this.emptyDepositTexture.createView(); + const clearEncoder = device.createCommandEncoder(); + const clearPass = clearEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.emptyDepositTextureView, + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + clearPass.end(); + device.queue.submit([clearEncoder.finish()]); } public setParameters({ @@ -135,9 +164,14 @@ export class DiffusionPipeline { trailMapIn: GPUTextureView, trailMapOut: GPUTextureView, size: vec2, + depositMap: GPUTextureView | null, timestampWrites?: GPUComputePassTimestampWrites ) { - const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); + const bindGroup = this.getBindGroup( + trailMapIn, + trailMapOut, + depositMap ?? this.emptyDepositTextureView + ); const passEncoder = commandEncoder.beginComputePass( timestampWrites ? { timestampWrites } : undefined @@ -153,6 +187,7 @@ export class DiffusionPipeline { public destroy() { this.uniforms.destroy(); + this.emptyDepositTexture.destroy(); } private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { @@ -180,6 +215,13 @@ export class DiffusionPipeline { format: TRAIL_SOURCE_TEXTURE_FORMAT, }, }, + { + binding: 3, + visibility: GPUShaderStage.COMPUTE, + texture: { + sampleType: 'float', + }, + }, ], }; } diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index faef27c..4a85c14 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -86,7 +86,7 @@ export class EraserAgentPipeline { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.workgroupSize = getAgentWorkgroupSize(device); + this.workgroupSize = getAgentWorkgroupSize(device, 'eraser'); this.pipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], @@ -94,7 +94,7 @@ export class EraserAgentPipeline { compute: { module: smartCompile( device, - substituteAgentWorkgroupSize(device, agentSchema), + substituteAgentWorkgroupSize(device, agentSchema, 'eraser'), shader ), entryPoint: 'main', diff --git a/src/settings.ts b/src/settings.ts index ee3d541..49e2518 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -43,6 +43,10 @@ export const settings: GardenRuntimeSettings = { ...buildSettings(activeVibe), }; +export const rememberActiveVibeSelection = (): void => { + writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id); +}; + export const applyVibeSettings = (vibe: VibePreset) => { activeVibe = cloneVibePreset(vibe); const nextSettings = buildSettings(activeVibe); @@ -59,7 +63,7 @@ export const applyVibeSettings = (vibe: VibePreset) => { normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls) ); - writeBrowserStorage(appConfig.storage.vibeKey, vibe.id); + rememberActiveVibeSelection(); return activeVibe; }; diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index f677b4b..71ed186 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -41,6 +41,9 @@ touch-action: pan-y; -webkit-overflow-scrolling: touch; + // Tweakpane v4 internal classes — re-verify on upgrade. + // No public theming hook exists for label padding or the slider/number + // flex ratio; if a fourth override appears here, switch to a custom plugin. .tp-lblv_l { padding-right: 10px; } @@ -139,6 +142,7 @@ font-size: 11px; + // Tweakpane v4 internal class — re-verify on upgrade. .tp-sldtxtv_t { flex-basis: 48px; } diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss index 6bae351..e11a841 100644 --- a/src/style/toolbar/_layout.scss +++ b/src/style/toolbar/_layout.scss @@ -5,16 +5,20 @@ --toolbar-background-strength: 0; --toolbar-divider-space: clamp(6px, 1.8vw, 14px); --toolbar-top-max-width: 594px; + --vibe-button-hit-size: 64px; display: grid; grid-template-areas: 'previous controls next' 'previous divider next' 'previous buttons next'; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: + var(--vibe-button-hit-size) + minmax(0, var(--toolbar-top-max-width)) + var(--vibe-button-hit-size); align-items: stretch; justify-content: center; - width: 100%; + width: fit-content; max-width: 100%; margin: 0 auto; padding-inline: clamp(8px, 1.4vw, 14px); @@ -85,9 +89,9 @@ position: relative; display: grid; place-items: center; - width: 52px; + width: var(--vibe-button-hit-size); height: auto; - min-height: 66px; + min-height: 72px; flex: 0 0 auto; padding: 0; border-radius: 0; diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss index 5cd2963..658c40b 100644 --- a/src/style/toolbar/_responsive.scss +++ b/src/style/toolbar/_responsive.scss @@ -4,6 +4,7 @@ @include on-small-screen { --toolbar-divider-space: 4px; --toolbar-top-max-width: 329px; + --vibe-button-hit-size: 44px; grid-template-areas: 'previous controls next' @@ -15,7 +16,7 @@ row-gap: 0; > .vibe-button { - width: 36px; + width: var(--vibe-button-hit-size); min-height: 44px; &::before { diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts new file mode 100644 index 0000000..6cc602a --- /dev/null +++ b/src/vibe-uri.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { VibeId } from './config/types'; +import { createVibeUri, getVibeIdFromUri } from './vibe-uri'; + +describe('vibe URI handling', () => { + it('loads vibes from slug IDs and display names', () => { + expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe( + VibeId.AuroraMycelium + ); + expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe( + VibeId.AuroraMycelium + ); + expect(getVibeIdFromUri('https://example.test/?vibe=velvet%20observatory')).toBe( + VibeId.VelvetObservatory + ); + }); + + it('uses query values before path or hash fallbacks', () => { + expect( + getVibeIdFromUri( + 'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium' + ) + ).toBe(VibeId.LichenSignal); + }); + + it('accepts explicit path segments and hash fallbacks', () => { + expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe( + VibeId.TidepoolLantern + ); + expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe( + VibeId.PaperLanternFog + ); + }); + + it('ignores unknown or malformed vibe values', () => { + expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull(); + expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull(); + expect(getVibeIdFromUri('not a url')).toBeNull(); + }); + + it('creates a canonical query URI without dropping other URL parts', () => { + expect( + createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen) + ).toBe('/garden?debug=1&vibe=chrome-pollen#panel'); + + expect( + createVibeUri( + 'https://example.test/garden?vibe=aurora-mycelium&debug=1', + VibeId.LichenSignal + ) + ).toBe('/garden?vibe=lichen-signal&debug=1'); + }); +}); diff --git a/src/vibe-uri.ts b/src/vibe-uri.ts new file mode 100644 index 0000000..c8ee804 --- /dev/null +++ b/src/vibe-uri.ts @@ -0,0 +1,148 @@ +import type { VibeId } from './config/types'; +import { vibePresets } from './config/vibe-presets'; + +const VIBE_URI_QUERY_PARAM = 'vibe'; +const FALLBACK_URL_ORIGIN = 'https://fleeting.garden'; + +const slugifyVibeName = (value: string): string => + value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +const safeDecodeURIComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const normalizeVibeIdentifier = (value: string): string => + slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, '')); + +const vibeIdByIdentifier = new Map(); + +for (const vibe of vibePresets) { + vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id); + vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id); +} + +const toUrl = (url: string | URL): URL | null => { + try { + return new URL(url, FALLBACK_URL_ORIGIN); + } catch { + return null; + } +}; + +const resolveVibeId = (value: string | null | undefined): VibeId | null => { + if (!value) { + return null; + } + + return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null; +}; + +const getHashSearchParam = (hash: string): string | null => { + const hashValue = hash.replace(/^#/, ''); + if (!hashValue.includes('=')) { + return null; + } + + const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue; + try { + return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM); + } catch { + return null; + } +}; + +const getPathVibeCandidates = (pathname: string): Array => { + const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean); + const explicitVibeIndex = segments.findIndex((segment) => + ['vibe', 'vibes'].includes(segment.toLowerCase()) + ); + + return [ + explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined, + segments.at(-1), + ].filter((candidate): candidate is string => typeof candidate === 'string'); +}; + +export const getVibeIdFromUri = (url: string | URL): VibeId | null => { + const parsedUrl = toUrl(url); + if (!parsedUrl) { + return null; + } + + const candidates = [ + parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM), + getHashSearchParam(parsedUrl.hash), + ...getPathVibeCandidates(parsedUrl.pathname), + parsedUrl.hash.replace(/^#/, ''), + ]; + + for (const candidate of candidates) { + const vibeId = resolveVibeId(candidate); + if (vibeId) { + return vibeId; + } + } + + return null; +}; + +export const getCurrentUriVibeId = (): VibeId | null => { + if (typeof window === 'undefined') { + return null; + } + + return getVibeIdFromUri(window.location.href); +}; + +const getVibeSlug = (vibeId: VibeId): string => { + const vibe = vibePresets.find((preset) => preset.id === vibeId); + return vibe ? slugifyVibeName(vibe.name) : vibeId; +}; + +export const createVibeUri = (url: string | URL, vibeId: VibeId): string => { + const parsedUrl = toUrl(url); + if (!parsedUrl) { + return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`; + } + + parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId)); + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; +}; + +export const writeCurrentVibeUri = ( + vibeId: VibeId, + mode: 'push' | 'replace' = 'replace' +): void => { + if (typeof window === 'undefined') { + return; + } + + const nextUri = createVibeUri(window.location.href, vibeId); + const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextUri === currentUri) { + return; + } + + const nextState = + typeof window.history.state === 'object' && window.history.state !== null + ? { ...window.history.state, vibeId } + : { vibeId }; + + if (mode === 'push') { + window.history.pushState(nextState, '', nextUri); + return; + } + + window.history.replaceState(nextState, '', nextUri); +}; diff --git a/src/vibes.ts b/src/vibes.ts index b6f1576..def4584 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -1,6 +1,7 @@ import { appConfig } from './config'; import { VibeId, type VibePreset } from './config/types'; import { readBrowserStorage } from './utils/browser-storage'; +import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri'; export { VibeId }; export type { VibePreset }; @@ -11,11 +12,17 @@ const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id)); const isVibeId = (value: unknown): value is VibeId => typeof value === 'string' && VIBE_IDS.has(value as VibeId); -export const getInitialVibe = (): VibePreset => { - const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey); - const initialVibeId = isVibeId(storedVibeId) - ? storedVibeId - : appConfig.vibes.defaultVibeId; +export const getVibeById = (vibeId: VibeId): VibePreset | undefined => + VIBE_PRESETS.find((vibe) => vibe.id === vibeId); - return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0]; +export const getInitialVibe = (): VibePreset => { + const uriVibeId = getCurrentUriVibeId(); + const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey); + const storedOrLegacyVibeId = isVibeId(storedVibeId) + ? storedVibeId + : getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`); + const initialVibeId = + uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId; + + return getVibeById(initialVibeId) ?? VIBE_PRESETS[0]; }; diff --git a/vite.config.ts b/vite.config.ts index 1818b2a..da6207c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,6 @@ export default defineConfig(({ command }) => ({ cssMinify: 'lightningcss', }, server: { - open: true, host: true, hmr: false, },