This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
parent 34ac200437
commit 39b0160064
136 changed files with 7144 additions and 1965 deletions

View file

@ -1,3 +1,5 @@
import { appConfig } from '../config';
export type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
@ -7,7 +9,6 @@ export interface GardenAudioChord {
export interface GardenAudioColorVoice {
scaleDegreeOffset: number;
octaveOffset: number;
velocityMultiplier: number;
panOffset: number;
}
@ -21,19 +22,11 @@ export interface GardenAudioVibeProfile {
}
export interface GardenAudioConfig {
enabled: boolean;
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
highPassFrequencyHz: number;
fallbackVibeId: string;
startup: {
calmDurationSeconds: number;
initialTempoMultiplier: number;
initialEnergyMultiplier: number;
initialActivityCeiling: number;
initialTapIntervalMultiplier: number;
};
compressor: {
thresholdDb: number;
kneeDb: number;
@ -42,7 +35,6 @@ export interface GardenAudioConfig {
releaseSeconds: number;
};
delay: {
enabled: boolean;
timeSeconds: number;
feedback: number;
wetGain: number;
@ -54,7 +46,6 @@ export interface GardenAudioConfig {
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
preloadOnStart: boolean;
};
input: {
pressureFallback: number;
@ -64,25 +55,10 @@ export interface GardenAudioConfig {
stepsPerBeat: number;
stepsPerBar: number;
lookaheadSeconds: number;
swing: number;
minTailSeconds: number;
maxTailSeconds: number;
tailDistanceForMaxPixels: number;
tailDurationForMaxSeconds: number;
tailDecayPower: number;
minTapIntervalSeconds: number;
speedForFullEnergyPixelsPerSecond: number;
sparseActivity: number;
arpeggioActivity: number;
fullChordActivity: number;
bassActivity: number;
melodySteps: Array<number>;
chordSteps: Array<number>;
bassSteps: Array<number>;
melodyPattern: Array<number>;
};
eraser: {
enabled: boolean;
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
@ -92,163 +68,4 @@ export interface GardenAudioConfig {
vibes: Record<string, GardenAudioVibeProfile>;
}
const majorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const minorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
];
const majorPentatonic = [0, 2, 4, 7, 9];
const minorPentatonic = [0, 3, 5, 7, 10];
export const gardenAudioConfig: GardenAudioConfig = {
enabled: true,
masterVolume: 0.32,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
fallbackVibeId: 'candy-rain',
startup: {
calmDurationSeconds: 6,
initialTempoMultiplier: 1.18,
initialEnergyMultiplier: 0.62,
initialActivityCeiling: 0.52,
initialTapIntervalMultiplier: 2.2,
},
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.4,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
delay: {
enabled: true,
timeSeconds: 0.42,
feedback: 0.12,
wetGain: 0.048,
},
piano: {
maxVoices: 32,
gain: 0.42,
sustainSeconds: 0.52,
sustainLevel: 0.34,
releaseSeconds: 0.16,
lowpassHz: 9000,
preloadOnStart: true,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 82,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.14,
swing: 0.08,
minTailSeconds: 0.45,
maxTailSeconds: 7.2,
tailDistanceForMaxPixels: 1400,
tailDurationForMaxSeconds: 3.8,
tailDecayPower: 1.85,
minTapIntervalSeconds: 0.16,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.1,
arpeggioActivity: 0.32,
fullChordActivity: 0.62,
bassActivity: 0.48,
melodySteps: [0, 3, 6, 10, 12, 14],
chordSteps: [0, 8],
bassSteps: [0],
melodyPattern: [0, 2, 4, 5, 4, 2, 1, 3],
},
eraser: {
enabled: true,
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
},
colorVoices: [
{
scaleDegreeOffset: 0,
octaveOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
octaveOffset: 0,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
octaveOffset: 1,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
],
vibes: {
'candy-rain': {
rootMidi: 57,
scale: majorPentatonic,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
},
'sunlit-moss': {
rootMidi: 53,
scale: majorPentatonic,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
},
'coral-tide': {
rootMidi: 50,
scale: minorPentatonic,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
},
'moon-orchid': {
rootMidi: 49,
scale: minorPentatonic,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
'peach-neon': {
rootMidi: 56,
scale: majorPentatonic,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
'frost-bloom': {
rootMidi: 62,
scale: majorPentatonic,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
},
},
};
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { GardenAudioEnergy } from './garden-audio-energy';
describe('GardenAudioEnergy', () => {
it('suspends activity but keeps a fading level when the gesture ends', () => {
const energy = new GardenAudioEnergy();
energy.beginGesture(0);
energy.recordStroke(0.8, 0.1);
energy.update(0.1);
energy.update(0.2);
const levelBeforeLift = energy.getLevel();
expect(energy.getActivity()).toBeGreaterThan(0);
energy.endGesture();
expect(energy.getActivity()).toBe(0);
expect(energy.getLevel()).toBe(levelBeforeLift);
energy.update(0.3);
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
expect(energy.getLevel()).toBeGreaterThan(0);
});
it('uses recent stroke intensity rather than gesture duration alone', () => {
const energy = new GardenAudioEnergy();
energy.beginGesture(0);
energy.recordStroke(1, 0.1);
energy.update(0.1);
energy.update(0.2);
const activeLevel = energy.getActivity();
energy.update(1.2);
expect(energy.getActivity()).toBeLessThan(activeLevel);
});
it('raises activity immediately when a stroke is recorded', () => {
const energy = new GardenAudioEnergy();
energy.beginGesture(0);
energy.recordStroke(0.12, 0.05);
expect(energy.getActivity()).toBeGreaterThan(0.09);
});
});

View file

@ -1,71 +1,40 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
interface GardenGestureState {
startedAt: number;
lastAt: number;
distancePixels: number;
peakEnergy: number;
isErasing: boolean;
}
interface GestureTail {
startedAt: number;
durationSeconds: number;
level: number;
}
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
private currentGesture: GardenGestureState | null = null;
private gestureTails: Array<GestureTail> = [];
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.currentGesture = createGesture(now, false);
this.lastEnergyUpdateAt = now;
}
public endGesture(now: number): void {
if (this.currentGesture && !this.currentGesture.isErasing) {
this.addGestureTail(this.currentGesture, now);
}
public endGesture(): void {
this.isGestureActive = false;
this.currentGesture = null;
this.targetEnergy = 0;
}
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
if (!this.currentGesture || this.currentGesture.isErasing) {
this.currentGesture = createGesture(now, false);
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
}
this.currentGesture.lastAt = now;
this.currentGesture.distancePixels += distancePixels;
this.currentGesture.peakEnergy = Math.max(
this.currentGesture.peakEnergy,
strokeEnergy
);
this.lastEnergyUpdateAt ||= now;
}
public recordEraserStroke(now: number): void {
this.currentGesture = this.currentGesture ?? createGesture(now, true);
this.currentGesture.isErasing = true;
}
public raiseTarget(strokeEnergy: number): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
public recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
this.targetEnergy = 0;
this.gestureTails = [];
this.energy = 0;
}
public update(now: number): void {
@ -76,28 +45,31 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
this.trimGestureTails(now);
this.targetEnergy *= Math.exp(
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
);
const activeGestureFloor =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
: 0;
const target = Math.max(activeGestureFloor, this.targetEnergy);
const timeConstant = target > this.energy ? 0.08 : 0.55;
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = appConfig.audioEngine.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
}
public getActivityAt(time: number): number {
const activeGesture =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
: 0;
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return clamp01(
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
);
return this.getLevel();
}
public getLevel(): number {
return clamp01(this.energy);
}
public reset(): void {
@ -105,72 +77,5 @@ export class GardenAudioEnergy {
this.energy = 0;
this.targetEnergy = 0;
this.lastEnergyUpdateAt = 0;
this.currentGesture = null;
this.gestureTails = [];
}
private addGestureTail(gesture: GardenGestureState, now: number): void {
const durationAmount = clamp01(
(gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9);
const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26));
if (tailAmount <= 0.015) {
return;
}
this.gestureTails.push({
startedAt: now,
durationSeconds:
this.rhythm.minTailSeconds +
(this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount,
level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28),
});
this.trimGestureTails(now);
}
private getGestureAmount(gesture: GardenGestureState, now: number): number {
const durationAmount = clamp01(
(now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
return clamp01(
distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14
);
}
private getTailActivityAt(time: number): number {
const decayPower = Math.max(0.2, this.rhythm.tailDecayPower);
return this.gestureTails.reduce((activity, tail) => {
const elapsedSeconds = time - tail.startedAt;
if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) {
return activity;
}
const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds);
return Math.max(activity, tail.level * Math.pow(remaining, decayPower));
}, 0);
}
private trimGestureTails(now: number): void {
this.gestureTails = this.gestureTails.filter(
(tail) => now - tail.startedAt < tail.durationSeconds
);
}
}
const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({
startedAt: now,
lastAt: now,
distancePixels: 0,
peakEnergy: 0,
isErasing,
});

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
@ -11,6 +12,7 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
public constructor(private readonly config: GardenAudioConfig) {}
@ -50,6 +52,26 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
// a buffer source has been started inside a user-gesture handler. Calling
// resume() alone leaves the context "running" but silent.
public unlock(): void {
if (!this.context || this.hasUnlocked) {
return;
}
const buffer = this.context.createBuffer(
1,
appConfig.audioEngine.graph.unlockBufferLength,
appConfig.audioEngine.graph.unlockSampleRate
);
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
source.start(0);
this.hasUnlocked = true;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@ -70,7 +92,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
0.12
appConfig.audioEngine.graph.delayTimeRampSeconds
);
}
@ -83,17 +105,22 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
0.12
appConfig.audioEngine.graph.delayTimeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
this.config.delay.enabled
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
: 0,
clamp(
this.config.delay.feedback +
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
appConfig.audioEngine.graph.delayFeedbackMin,
appConfig.audioEngine.graph.delayFeedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0,
this.config.delay.wetGain *
(appConfig.audioEngine.graph.delayOutputBase +
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
now,
this.config.updateRampSeconds
);
@ -106,7 +133,11 @@ export class GardenAudioGraph {
}
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015);
this.masterGain.gain.setTargetAtTime(
appConfig.audioEngine.graph.closeGain,
context.currentTime,
appConfig.audioEngine.graph.closeRampSeconds
);
}
this.clearNodes();
@ -123,8 +154,8 @@ export class GardenAudioGraph {
const delayOutput = context.createGain();
delayNode.delayTime.value = this.config.delay.timeSeconds;
delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0;
delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0;
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
delayInput.connect(delayNode);
delayNode.connect(delayFeedback);
@ -140,7 +171,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = 1;
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@ -149,7 +180,10 @@ export class GardenAudioGraph {
const data = buffer.getChannelData(0);
for (let index = 0; index < data.length; index++) {
data[index] = Math.random() * 2 - 1;
data[index] =
appConfig.audioEngine.graph.noiseMin +
Math.random() *
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
}
return buffer;
@ -164,5 +198,6 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.hasUnlocked = false;
}
}

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
@ -19,8 +20,16 @@ export const getStrokeMetrics = (
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
const pressure = getPressureAmount(stroke, fallbackPressure);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22);
const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75);
const strokeEnergy = clamp01(
appConfig.audioEngine.input.strokeEnergyBase +
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
);
const effectiveEnergy =
strokeEnergy *
(appConfig.audioEngine.input.distanceEnergyBase +
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
appConfig.audioEngine.input.distanceEnergyScale);
return {
distancePixels,
@ -39,7 +48,7 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
return stroke.velocityPixelsPerSecond;
}
return distancePixels / (1 / 60);
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
};
const getPressureAmount = (
@ -55,6 +64,6 @@ const getPressureAmount = (
}
return stroke.pointerType === 'pen'
? Math.max(0.56, clamp01(fallbackPressure))
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};

View file

@ -1,282 +0,0 @@
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
clampMidi,
degreeToSemitone,
getChordAtStep,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import {
GardenAudioColorIndex,
GardenAudioStroke,
PianoNote,
} from './garden-audio-types';
interface RhythmStepRequest {
vibe: VibePreset;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}
interface StrokeTapRequest {
stroke: GardenAudioStroke;
energy: number;
now: number;
selectedColorIndex: GardenAudioColorIndex;
stepIndex: number;
rhythmAnchorTime: number;
stepDurationSeconds: number;
}
export class GardenAudioScore {
public constructor(
private readonly config: GardenAudioConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public playRhythmStep({
vibe,
stepIndex,
startTime,
activity,
selectedColorIndex,
}: RhythmStepRequest): void {
const profile = getVibeProfile(this.config, vibe);
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
if (activity < this.config.rhythm.sparseActivity) {
return;
}
const chord = getChordAtStep(this.config, profile, stepIndex);
if (stepInBar === 0 && activity < this.config.rhythm.bassActivity) {
this.playRootAnchor(profile, chord, startTime, activity);
}
if (
this.config.rhythm.bassSteps.includes(stepInBar) &&
activity >= this.config.rhythm.bassActivity
) {
this.playBassNote(profile, chord, startTime, activity);
}
if (
this.config.rhythm.chordSteps.includes(stepInBar) &&
activity >= this.config.rhythm.arpeggioActivity
) {
this.playBrokenChord(profile, chord, startTime, activity, stepInBar === 0);
}
if (this.shouldPlayMelodyStep(stepInBar, activity)) {
this.playMelodyNote(
profile,
chord,
stepIndex,
startTime,
activity,
selectedColorIndex
);
}
}
public playStrokeTap({
stroke,
energy,
now,
selectedColorIndex,
stepIndex,
rhythmAnchorTime,
stepDurationSeconds,
}: StrokeTapRequest): void {
const profile = getVibeProfile(this.config, stroke.vibe);
const colorVoice = this.config.colorVoices[selectedColorIndex];
const chord = getChordAtStep(this.config, profile, stepIndex);
const intervals = getChordIntervals(chord, false);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const y = 1 - clamp01(stroke.to[1] / Math.max(1, stroke.canvasSize[1]));
const rawRegister = y < 0.35 ? 0 : y > 0.72 ? 2 : 1;
const register =
energy < this.config.rhythm.arpeggioActivity
? Math.min(rawRegister, 1)
: rawRegister;
const interval =
intervals[
(register + colorVoice.scaleDegreeOffset + selectedColorIndex) % intervals.length
];
this.playNote({
midi: clampMidi(
profile.rootMidi +
chord.rootOffset +
interval +
(energy < this.config.rhythm.arpeggioActivity ? 5 : 12) +
register * 5 +
(energy >= this.config.rhythm.fullChordActivity
? colorVoice.octaveOffset * 7
: 0),
50,
energy < this.config.rhythm.arpeggioActivity ? 76 : 88
),
velocity: (0.26 + energy * 0.34) * colorVoice.velocityMultiplier,
durationSeconds: 1.05 + energy * 0.45,
pan: clamp(x * 2 - 1 + colorVoice.panOffset * 0.6, -0.85, 0.85),
delaySend: 0.014,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: this.getNearGridTime(now, rhythmAnchorTime, stepDurationSeconds),
});
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const chord = profile.progression[0];
const intervals = getChordIntervals(chord, true);
intervals.forEach((interval, index) => {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset + interval, 48, 84),
velocity: 0.3 * Math.pow(0.9, index),
durationSeconds: 1.4,
pan: clamp(-0.22 + index * 0.14, -0.5, 0.5),
delaySend: 0.016,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: now + index * 0.045,
});
});
}
private shouldPlayMelodyStep(stepInBar: number, activity: number): boolean {
if (activity < this.config.rhythm.sparseActivity) {
return false;
}
if (activity < this.config.rhythm.arpeggioActivity) {
return stepInBar === 10;
}
if (activity < this.config.rhythm.fullChordActivity) {
return stepInBar === 6 || stepInBar === 10;
}
return this.config.rhythm.melodySteps.includes(stepInBar);
}
private playRootAnchor(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number
): void {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset, 43, 64),
velocity: 0.13 + activity * 0.14,
durationSeconds: 1.35,
pan: -0.06,
delaySend: 0.004,
lowpassHz: this.config.piano.lowpassHz * profile.brightness * 0.78,
startTime,
});
}
private playBassNote(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number
): void {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset - 12, 36, 58),
velocity: 0.24 + activity * 0.18,
durationSeconds: 1.8,
pan: -0.08,
delaySend: 0.006,
startTime,
});
}
private playBrokenChord(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number,
isAccent: boolean
): void {
const intervals = getChordIntervals(chord, true);
const velocity = (isAccent ? 0.22 : 0.16) + activity * 0.16;
const baseMidi = profile.rootMidi + chord.rootOffset;
const isFullChord = activity >= this.config.rhythm.fullChordActivity;
const noteCount = isFullChord ? intervals.length : activity >= 0.46 ? 3 : 2;
const staggerSeconds = isFullChord ? 0.018 : 0.056;
intervals.slice(0, noteCount).forEach((interval, index) => {
this.playNote({
midi: clampMidi(baseMidi + interval, 48, 78),
velocity: velocity * Math.pow(0.9, index),
durationSeconds: isFullChord ? (isAccent ? 1.9 : 1.35) : 1.08,
pan: clamp(-0.16 + index * 0.1, -0.45, 0.45),
delaySend: 0.012,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: startTime + index * staggerSeconds,
});
});
}
private playMelodyNote(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
stepIndex: number,
startTime: number,
activity: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const colorVoice = this.config.colorVoices[selectedColorIndex];
const patternIndex =
Math.floor(stepIndex / 2) + colorVoice.scaleDegreeOffset + selectedColorIndex;
const scaleDegree =
this.config.rhythm.melodyPattern[
patternIndex % this.config.rhythm.melodyPattern.length
] + colorVoice.scaleDegreeOffset;
const stepInBeat = stepIndex % this.config.rhythm.stepsPerBeat;
const semitoneOffset =
stepInBeat === 0
? chord.rootOffset +
getChordIntervals(chord, false)[(patternIndex + selectedColorIndex) % 3]
: degreeToSemitone(profile, scaleDegree);
const registerLift = activity < this.config.rhythm.arpeggioActivity ? 0 : 12;
const colorOctaveLift =
activity >= this.config.rhythm.fullChordActivity ? colorVoice.octaveOffset * 12 : 0;
this.playNote({
midi: clampMidi(
profile.rootMidi + semitoneOffset + registerLift + colorOctaveLift,
activity < this.config.rhythm.arpeggioActivity ? 50 : 57,
activity < this.config.rhythm.arpeggioActivity ? 76 : 91
),
velocity:
(0.2 + activity * 0.32) *
colorVoice.velocityMultiplier *
(stepInBeat === 0 ? 1.08 : 1),
durationSeconds: 0.92 + activity * 0.38,
pan: colorVoice.panOffset,
delaySend: 0.018,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime,
});
}
private getNearGridTime(
now: number,
rhythmAnchorTime: number,
stepDurationSeconds: number
): number {
const stepCount = Math.ceil((now - rhythmAnchorTime) / stepDurationSeconds);
const gridTime = rhythmAnchorTime + Math.max(0, stepCount) * stepDurationSeconds;
return gridTime - now <= 0.1 ? gridTime : now + 0.012;
}
}

View file

@ -4,16 +4,8 @@ export type GardenAudioColorIndex = 0 | 1 | 2;
export interface GardenAudioSnapshot {
vibe: VibePreset;
activeAgentCount: number;
agentBudgetMax: number;
selectedColorIndex: number;
isErasing: boolean;
introProgress: number;
moveSpeed: number;
diffusionRateTrails: number;
decayRateTrails: number;
brushEffectDuration: number;
clarity: number;
}
export interface GardenAudioStroke {
@ -41,6 +33,7 @@ export interface LoadedPianoSample {
export interface ActivePianoVoice {
gain: GainNode;
source: AudioBufferSourceNode;
startAt: number;
stopAt: number;
}

View file

@ -82,6 +82,18 @@ class FakeAudioContext {
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
}
public createBufferSource(): AudioBufferSourceNode {
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
buffer: AudioBuffer | null;
start: () => void;
stop: () => void;
};
node.buffer = null;
node.start = vi.fn();
node.stop = vi.fn();
return node;
}
public async resume(): Promise<void> {
calls.resumed += 1;
contextState = 'running';
@ -90,10 +102,6 @@ class FakeAudioContext {
const makeConfig = (): GardenAudioConfig => ({
...gardenAudioConfig,
piano: {
...gardenAudioConfig.piano,
preloadOnStart: false,
},
});
describe('GardenAudio startup policy', () => {
@ -102,6 +110,7 @@ describe('GardenAudio startup policy', () => {
calls.resumed = 0;
contextState = 'suspended';
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
});
afterEach(() => {

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
@ -5,13 +6,13 @@ import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import { GardenAudioScore } from './garden-audio-score';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
@ -26,18 +27,14 @@ export class GardenAudio {
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly score: GardenAudioScore;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private rhythmAnchorTime: number | null = null;
private startedAt: number | null = null;
private nextStepAt = 0;
private stepIndex = 0;
private lastTapAt = Number.NEGATIVE_INFINITY;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -45,12 +42,12 @@ export class GardenAudio {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config.rhythm);
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
this.energy = new GardenAudioEnergy();
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
if (this.isDestroyed || this.isMuted) {
return;
}
@ -59,6 +56,10 @@ export class GardenAudio {
return;
}
if (options.userGesture === true) {
this.graph.unlock();
}
if (context.state === 'suspended') {
if (options.userGesture !== true) {
return;
@ -67,17 +68,11 @@ export class GardenAudio {
}
this.hasStarted = true;
this.rhythmAnchorTime ??= context.currentTime;
this.startedAt ??= context.currentTime;
this.applyVibe(vibe);
if (this.nextStepAt <= 0) {
this.nextStepAt = context.currentTime + 0.02;
}
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
if (this.config.piano.preloadOnStart) {
void this.piano.load(context);
}
void this.piano.load(context);
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -100,8 +95,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? 0.0001 : this.config.masterVolume,
isMuted ? 0.02 : this.config.fadeInSeconds
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -111,20 +106,21 @@ export class GardenAudio {
return;
}
this.isGestureActive = true;
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture(context.currentTime);
}
public endGesture(): void {
const context = this.graph.context;
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
if (!context) {
return;
}
this.energy.endGesture(context.currentTime);
}
public rememberColor(colorIndex: number): void {
this.selectedColorIndex = normalizeColorIndex(colorIndex);
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
}
public update(snapshot: GardenAudioSnapshot): void {
@ -139,17 +135,27 @@ export class GardenAudio {
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(context.currentTime);
this.piano.fadeActive(
context.currentTime,
appConfig.audioEngine.gestureFadeSeconds
);
this.updateDelay(snapshot);
return;
}
this.scheduleRhythm(snapshot.vibe);
if (this.isGestureActive) {
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: this.energy.getActivity(),
selectedColorIndex: this.selectedColorIndex,
});
}
this.updateDelay(snapshot);
}
public stroke(stroke: GardenAudioStroke): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
if (this.isDestroyed || this.isMuted) {
return;
}
@ -158,6 +164,9 @@ export class GardenAudio {
if (!context) {
return;
}
if (!this.isGestureActive) {
return;
}
const metrics = getStrokeMetrics(
stroke,
@ -169,32 +178,14 @@ export class GardenAudio {
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
if (stroke.isErasing) {
this.energy.recordEraserStroke(now);
this.energy.recordEraserStroke();
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
return;
}
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
if (metrics.distancePixels >= 2.5) {
this.energy.raiseTarget(strokeEnergy);
}
if (
metrics.distancePixels >= 2.5 &&
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
) {
this.lastTapAt = now;
this.score.playStrokeTap({
stroke,
energy: strokeEnergy,
now,
selectedColorIndex: this.selectedColorIndex,
stepIndex: this.stepIndex,
rhythmAnchorTime: this.getRhythmAnchorTime(now),
stepDurationSeconds: this.getStepDurationSeconds(now),
});
}
const strokeEnergy = metrics.effectiveEnergy;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.wake(now);
}
public async destroy(): Promise<void> {
@ -203,50 +194,15 @@ export class GardenAudio {
this.piano.reset();
this.energy.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
this.rhythmAnchorTime = null;
this.startedAt = null;
this.nextStepAt = 0;
this.stepIndex = 0;
this.lastTapAt = Number.NEGATIVE_INFINITY;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
private scheduleRhythm(vibe: VibePreset): void {
const context = this.graph.context;
if (!context || !this.graph.eventBus) {
return;
}
const now = context.currentTime;
const stepSeconds = this.getStepDurationSeconds(now);
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
this.nextStepAt = now + 0.02;
}
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
const swingOffset =
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
const startTime = this.nextStepAt + swingOffset;
this.score.playRhythmStep({
vibe,
stepIndex: this.stepIndex,
startTime,
activity: this.getSettledActivity(
this.energy.getActivityAt(startTime),
startTime
),
selectedColorIndex: this.selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
@ -254,12 +210,15 @@ export class GardenAudio {
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < 0.45) {
if (
now - this.lastVibeStingerAt <
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
) {
return;
}
this.lastVibeStingerAt = now;
this.score.playVibeChangeStinger(vibe, now);
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(
@ -268,27 +227,38 @@ export class GardenAudio {
pressure: number,
now: number
): void {
if (!this.config.eraser.enabled || !this.graph.context) {
if (!this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
clamp01(
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
);
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: 0.08,
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
(appConfig.audioEngine.eraser.gainBase +
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
});
@ -303,11 +273,8 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? 0.12
: this.getSettledActivity(
this.energy.getActivityAt(context.currentTime),
context.currentTime
);
? appConfig.audioEngine.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -319,68 +286,4 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
}
private getStepDurationSeconds(time: number): number {
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
return baseStepSeconds * this.getStartupTempoMultiplier(time);
}
private getRhythmAnchorTime(now: number): number {
this.rhythmAnchorTime ??= now;
return this.rhythmAnchorTime;
}
private getTapIntervalSeconds(time: number): number {
return (
this.config.rhythm.minTapIntervalSeconds *
this.getStartupTapIntervalMultiplier(time)
);
}
private getSettledActivity(activity: number, time: number): number {
return Math.min(activity, this.getStartupActivityCeiling(time));
}
private getStartupEnergyScale(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialEnergyMultiplier,
1,
time
);
}
private getStartupActivityCeiling(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialActivityCeiling,
1,
time
);
}
private getStartupTempoMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTempoMultiplier,
1,
time
);
}
private getStartupTapIntervalMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTapIntervalMultiplier,
1,
time
);
}
private interpolateStartupValue(from: number, to: number, time: number): number {
const durationSeconds = this.config.startup.calmDurationSeconds;
if (this.startedAt === null || durationSeconds <= 0) {
return to;
}
const progress = clamp01((time - this.startedAt) / durationSeconds);
const easedProgress = progress * progress * (3 - 2 * progress);
return from + (to - from) * easedProgress;
}
}

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { VIBE_PRESETS } from '../vibes';
import { gardenAudioConfig } from './garden-audio-config';
import { PianoNote } from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
const makeEngine = () => {
const notes: Array<PianoNote> = [];
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
notes.push(note);
});
return { engine, notes };
};
const renderBars = (
engine: GenerativePianoEngine,
activity: number,
selectedColorIndex = 0,
bars = 4
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds:
(60 / gardenAudioConfig.rhythm.bpm) *
Math.round(
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
) *
bars,
});
};
const average = (values: Array<number>): number =>
values.reduce((sum, value) => sum + value, 0) / values.length;
describe('GenerativePianoEngine', () => {
it('does not emit notes below the sparse activity threshold', () => {
const { engine, notes } = makeEngine();
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
expect(notes).toHaveLength(0);
});
it('keeps drawing notes on beat starts', () => {
const { engine, notes } = makeEngine();
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
const startDelaySeconds = 0.02;
renderBars(engine, 1, 1);
expect(notes.length).toBeGreaterThan(0);
notes.forEach((note) => {
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
});
});
it('varies density with activity without exceeding one note per beat', () => {
const low = makeEngine();
const high = makeEngine();
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
renderBars(high.engine, 1, 1);
expect(high.notes.length).toBeGreaterThan(low.notes.length);
expect(high.notes.length).toBeLessThanOrEqual(16);
});
it('wakes every color with a prompt first note at low activity', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
const now = 4;
engine.beginGesture(1);
engine.wake(now);
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
selectedColorIndex,
lookaheadSeconds: 0.08,
});
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
});
});
it('uses different color roles for register and pan', () => {
const anchor = makeEngine();
const spark = makeEngine();
renderBars(anchor.engine, 1, 0);
renderBars(spark.engine, 1, 2);
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
average(anchor.notes.map((note) => note.midi))
);
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
average(anchor.notes.map((note) => note.pan))
);
});
it('is deterministic for the same phrase inputs', () => {
const first = makeEngine();
const second = makeEngine();
renderBars(first.engine, 0.78, 2);
renderBars(second.engine, 0.78, 2);
expect(second.notes).toEqual(first.notes);
});
});

View file

@ -0,0 +1,674 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
clampMidi,
degreeToSemitone,
getChordAtStep,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
lookaheadSeconds?: number;
}
interface PianoRole {
name: string;
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
delaySend: number;
velocityBase: number;
velocityActivityScale: number;
durationBase: number;
durationActivityScale: number;
downbeatDurationBoost: number;
lowBeats: ReadonlyArray<number>;
mediumBeats: ReadonlyArray<number>;
highBeats: ReadonlyArray<number>;
lowPhraseOffset: number;
lowPhraseSpacing: number;
lowKeepChance: number;
mediumKeepChance: number;
highKeepChance: number;
}
interface PitchCandidate {
midi: number;
preference: number;
}
const COLOR_ROLES: [PianoRole, PianoRole, PianoRole] = [
{
name: 'anchor',
midiMin: 45,
midiMax: 64,
preferredMidi: 53,
pan: -0.28,
delaySend: 0.012,
velocityBase: 0.12,
velocityActivityScale: 0.15,
durationBase: 1.2,
durationActivityScale: 0.75,
downbeatDurationBoost: 0.32,
lowBeats: [0, 2],
mediumBeats: [0, 2],
highBeats: [0, 2],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 1,
mediumKeepChance: 0.92,
highKeepChance: 1,
},
{
name: 'body',
midiMin: 55,
midiMax: 74,
preferredMidi: 64,
pan: 0,
delaySend: 0.009,
velocityBase: 0.115,
velocityActivityScale: 0.18,
durationBase: 0.86,
durationActivityScale: 0.54,
downbeatDurationBoost: 0.18,
lowBeats: [0, 2],
mediumBeats: [0, 2],
highBeats: [0, 1, 2, 3],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 0.86,
mediumKeepChance: 0.86,
highKeepChance: 0.9,
},
{
name: 'spark',
midiMin: 67,
midiMax: 84,
preferredMidi: 76,
pan: 0.28,
delaySend: 0.018,
velocityBase: 0.09,
velocityActivityScale: 0.14,
durationBase: 0.48,
durationActivityScale: 0.32,
downbeatDurationBoost: 0,
lowBeats: [0, 2],
mediumBeats: [1, 3],
highBeats: [0, 1, 2, 3],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 0.76,
mediumKeepChance: 0.8,
highKeepChance: 0.78,
},
];
const PHRASE_BAR_COUNT = 4;
const PACE_MIN = 0.96;
const PACE_MAX = 1.08;
const PACE_RAMP_SECONDS = 2.8;
const FIRST_NOTE_MAX_WAIT_SECONDS = 0.09;
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
const STINGER_SPACING_SECONDS = 0.07;
const STINGER_DURATION_SECONDS = 1.45;
const PHRASE_CONTOURS: ReadonlyArray<ReadonlyArray<number>> = [
[0, 0, 1, 0, -1, 0, 1, 0],
[0, 1, 2, 1, 0, -1, 0, 1],
[1, 0, -1, 0, 1, 2, 1, 0],
[0, -1, 0, 1, 0, 1, 2, 1],
];
export class GenerativePianoEngine {
private nextStepAt: number | null = null;
private stepIndex = 0;
private pace = 1;
private lastPaceUpdateAt: number | null = null;
private isWaitingForFirstStrokeNote = false;
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
null,
null,
null,
];
public constructor(
private readonly config: GardenAudioConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public prime(now: number): void {
if (this.nextStepAt === null) {
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
}
this.lastPaceUpdateAt ??= now;
}
public beginGesture(now: number): void {
this.isWaitingForFirstStrokeNote = true;
this.wake(now);
}
public endGesture(): void {
this.isWaitingForFirstStrokeNote = false;
}
public wake(now: number): void {
this.lastPaceUpdateAt ??= now;
if (
!this.isWaitingForFirstStrokeNote &&
this.nextStepAt !== null &&
this.nextStepAt - now <= FIRST_NOTE_MAX_WAIT_SECONDS
) {
return;
}
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
this.stepIndex = this.getNextDownbeatStepIndex();
}
public renderLookahead({
vibe,
now,
activity,
selectedColorIndex,
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void {
this.prime(now);
const normalizedActivity = clamp01(activity);
this.updatePace(now, normalizedActivity);
const stepSeconds = this.getStepDurationSeconds();
this.skipLateSteps(now, stepSeconds);
if (this.nextStepAt === null) {
return;
}
const profile = getVibeProfile(this.config, vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
this.renderStep({
vibe,
profile,
stepIndex: this.stepIndex,
startTime: this.nextStepAt,
activity: normalizedActivity,
selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const chord = profile.progression[0];
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: clampMidi(rootMidi + intervals[0], 48, 60),
velocity: 0.16,
pan: -0.24,
delaySend: 0.014,
},
{
midi: clampMidi(rootMidi + intervals[2], 60, 72),
velocity: 0.13,
pan: 0.02,
delaySend: 0.016,
},
{
midi: clampMidi(rootMidi + intervals[3], 67, 84),
velocity: 0.105,
pan: 0.26,
delaySend: 0.02,
},
];
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: STINGER_DURATION_SECONDS,
lowpassHz: this.getLowpassHz(profile, note.midi, 0.28),
startTime: now + index * STINGER_SPACING_SECONDS,
});
});
}
public reset(): void {
this.nextStepAt = null;
this.stepIndex = 0;
this.pace = 1;
this.lastPaceUpdateAt = null;
this.isWaitingForFirstStrokeNote = false;
this.lastMidiByColor[0] = null;
this.lastMidiByColor[1] = null;
this.lastMidiByColor[2] = null;
}
private renderStep({
vibe,
profile,
stepIndex,
startTime,
activity,
selectedColorIndex,
}: {
vibe: VibePreset;
profile: GardenAudioVibeProfile;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
if (
activity < this.config.rhythm.sparseActivity ||
stepIndex % this.config.rhythm.stepsPerBeat !== 0
) {
return;
}
const role = COLOR_ROLES[selectedColorIndex];
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
const beatsPerBar = this.getBeatsPerBar();
const beatInBar =
Math.floor(stepInBar / this.config.rhythm.stepsPerBeat) % beatsPerBar;
const barIndex = Math.floor(stepIndex / this.config.rhythm.stepsPerBar);
const phraseIndex = Math.floor(barIndex / PHRASE_BAR_COUNT);
const barInPhrase = barIndex % PHRASE_BAR_COUNT;
if (
!this.shouldPlayBeat({
vibeId: vibe.id,
role,
activity,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
})
) {
return;
}
const note = this.createNote({
vibeId: vibe.id,
profile,
role,
stepIndex,
startTime,
activity,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
this.lastMidiByColor[selectedColorIndex] = note.midi;
this.isWaitingForFirstStrokeNote = false;
this.playNote(note);
}
private createNote({
vibeId,
profile,
role,
stepIndex,
startTime,
activity,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
role: PianoRole;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): PianoNote {
const colorVoice = this.config.colorVoices[selectedColorIndex];
const expression = this.getExpression(activity);
const midi = this.chooseMidi({
vibeId,
profile,
role,
stepIndex,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
const beatAccent = beatInBar === 0 ? 1.08 : beatInBar === 2 ? 0.97 : 0.9;
const phrasePosition =
(barInPhrase * beatsPerBar + beatInBar) / (PHRASE_BAR_COUNT * beatsPerBar);
const phraseSwell = 0.88 + Math.sin(phrasePosition * Math.PI) * 0.12;
return {
midi,
velocity: clamp(
(role.velocityBase + expression * role.velocityActivityScale) *
colorVoice.velocityMultiplier *
beatAccent *
phraseSwell,
0.06,
0.52
),
durationSeconds:
role.durationBase +
expression * role.durationActivityScale +
(beatInBar === 0 ? role.downbeatDurationBoost : 0),
pan: clamp(role.pan + colorVoice.panOffset * 0.45, -1, 1),
delaySend: clamp(role.delaySend * (1.15 - expression * 0.4), 0, 0.04),
lowpassHz: this.getLowpassHz(profile, midi, expression),
startTime,
};
}
private chooseMidi({
vibeId,
profile,
role,
stepIndex,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
role: PianoRole;
stepIndex: number;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): number {
const chord = getChordAtStep(this.config, profile, stepIndex);
const rootMidi = profile.rootMidi + chord.rootOffset;
const offsets = this.getPitchOffsets({
vibeId,
profile,
chord,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
const previousMidi = this.lastMidiByColor[selectedColorIndex] ?? role.preferredMidi;
const candidates = this.getCandidates(rootMidi, offsets, role);
if (candidates.length === 0) {
return clampMidi(rootMidi + offsets[0], role.midiMin, role.midiMax);
}
return candidates.reduce((best, candidate) =>
this.scoreCandidate(candidate, role, previousMidi) <
this.scoreCandidate(best, role, previousMidi)
? candidate
: best
).midi;
}
private getPitchOffsets({
vibeId,
profile,
chord,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
chord: GardenAudioChord;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): Array<number> {
const chordIntervals = getChordIntervals(chord, false);
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
const contour = this.getPhraseContour(
vibeId,
phraseIndex,
selectedColorIndex,
phraseBeat
);
const colorVoice = this.config.colorVoices[selectedColorIndex];
if (selectedColorIndex === 0) {
return beatInBar === 0 ? [0, 7, 12] : [7, 0, 12];
}
if (selectedColorIndex === 1) {
return [
chordIntervals[1],
chordIntervals[2],
degreeToSemitone(profile, 2 + colorVoice.scaleDegreeOffset + contour),
chordIntervals[1] + 12,
];
}
const degreeBase = 2 + colorVoice.scaleDegreeOffset + contour;
return [
degreeToSemitone(profile, degreeBase),
degreeToSemitone(profile, degreeBase + 2),
12 + degreeToSemitone(profile, degreeBase - 1),
12 + degreeToSemitone(profile, degreeBase + 1),
];
}
private getCandidates(
rootMidi: number,
offsets: ReadonlyArray<number>,
role: PianoRole
): Array<PitchCandidate> {
const candidates: Array<PitchCandidate> = [];
offsets.forEach((offset, preference) => {
for (let octave = -3; octave <= 3; octave += 1) {
const midi = rootMidi + offset + octave * 12;
if (midi >= role.midiMin && midi <= role.midiMax) {
candidates.push({ midi: Math.round(midi), preference });
}
}
});
return candidates;
}
private scoreCandidate(
candidate: PitchCandidate,
role: PianoRole,
previousMidi: number
): number {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - role.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT
);
}
private shouldPlayBeat({
vibeId,
role,
activity,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
role: PianoRole;
activity: number;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): boolean {
const expression = this.getExpression(activity);
const beats =
expression < 0.34
? role.lowBeats
: expression < 0.68
? role.mediumBeats
: role.highBeats;
if (!beats.includes(beatInBar)) {
return false;
}
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
if (
expression < 0.34 &&
phraseBeat % role.lowPhraseSpacing !== role.lowPhraseOffset
) {
return false;
}
const keepChance =
expression < 0.34
? role.lowKeepChance
: expression < 0.68
? role.mediumKeepChance
: role.highKeepChance;
const gate = hashUnit(vibeId, role.name, phraseIndex, barInPhrase, beatInBar);
if (this.isWaitingForFirstStrokeNote && beatInBar === 0) {
return true;
}
if (beatInBar === 0 && role.name !== 'spark' && expression >= 0.52) {
return true;
}
return gate <= keepChance;
}
private getPhraseContour(
vibeId: string,
phraseIndex: number,
selectedColorIndex: GardenAudioColorIndex,
phraseBeat: number
): number {
if (selectedColorIndex === 0) {
return 0;
}
const contourIndex =
hashInt(vibeId, phraseIndex, selectedColorIndex) % PHRASE_CONTOURS.length;
const contour = PHRASE_CONTOURS[contourIndex];
return contour[phraseBeat % contour.length];
}
private getLowpassHz(
profile: GardenAudioVibeProfile,
midi: number,
expression: number
): number {
const midiLift = clamp01((midi - 48) / 36) * 1100;
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.44 + expression * 0.44) +
midiLift,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
);
}
private updatePace(now: number, activity: number): void {
if (this.lastPaceUpdateAt === null) {
this.lastPaceUpdateAt = now;
return;
}
const elapsedSeconds = Math.max(0, now - this.lastPaceUpdateAt);
this.lastPaceUpdateAt = now;
const targetPace = PACE_MIN + (PACE_MAX - PACE_MIN) * this.getExpression(activity);
const amount = 1 - Math.exp(-elapsedSeconds / PACE_RAMP_SECONDS);
this.pace += (targetPace - this.pace) * amount;
}
private skipLateSteps(now: number, stepSeconds: number): void {
if (this.nextStepAt === null) {
return;
}
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
if (this.nextStepAt >= earliestStart) {
return;
}
const skippedSteps = Math.floor((earliestStart - this.nextStepAt) / stepSeconds) + 1;
this.nextStepAt += skippedSteps * stepSeconds;
this.stepIndex += skippedSteps;
}
private getExpression(activity: number): number {
return clamp01(
(activity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
}
private getStepDurationSeconds(): number {
return 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat / this.pace;
}
private getNextDownbeatStepIndex(): number {
const stepInBar = this.stepIndex % this.config.rhythm.stepsPerBar;
if (stepInBar === 0) {
return this.stepIndex;
}
return this.stepIndex + this.config.rhythm.stepsPerBar - stepInBar;
}
private getBeatsPerBar(): number {
return Math.max(
1,
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
);
}
}
const hashUnit = (...parts: Array<number | string>): number =>
hashInt(...parts) / 0xffffffff;
const hashInt = (...parts: Array<number | string>): number => {
let hash = 2166136261;
const input = parts.join(':');
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { GardenAudioGraph } from './garden-audio-graph';
import { NoiseBurst } from './garden-audio-types';
@ -10,7 +11,10 @@ export class NoiseBurstPlayer {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
@ -20,20 +24,29 @@ export class NoiseBurstPlayer {
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = 1.4;
envelope.gain.setValueAtTime(0.0001, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(0.0001, gain),
scheduledStart + 0.004
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
scheduledStart
);
envelope.gain.exponentialRampToValueAtTime(
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
stopAt
);
envelope.gain.exponentialRampToValueAtTime(0.0001, stopAt);
panner.pan.setValueAtTime(pan, scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panner);
panner.connect(eventBus);
source.start(scheduledStart, Math.random() * 0.4);
source.start(
scheduledStart,
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
);
source.stop(stopAt);
source.addEventListener(
'ended',

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@ -22,6 +23,9 @@ export class PianoSampler {
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
@ -56,12 +60,22 @@ export class PianoSampler {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
const noteGainValue = Math.max(
appConfig.audioEngine.piano.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
const sustainAt = scheduledStart + Math.max(0.08, durationSeconds);
this.config.piano.sustainSeconds *
(appConfig.audioEngine.piano.sustainBase +
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -75,26 +89,55 @@ export class PianoSampler {
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
oldest?.source.stop(scheduledStart + 0.05);
oldest?.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
scheduledStart,
appConfig.audioEngine.piano.voiceStealFadeSeconds
);
oldest?.source.stop(
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / 12),
Math.pow(
2,
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
filter.Q.value = 0.7;
gain.gain.setValueAtTime(0.0001, scheduledStart);
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
gain.gain.setTargetAtTime(
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
sustainAt,
Math.max(0.04, sustainSeconds * 0.45)
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = appConfig.audioEngine.piano.filterQ;
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
appConfig.audioEngine.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
sustainSeconds * appConfig.audioEngine.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
releaseAt,
releaseSeconds
);
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
@ -102,7 +145,7 @@ export class PianoSampler {
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && this.config.delay.enabled && delaySend > 0) {
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
@ -110,8 +153,8 @@ export class PianoSampler {
}
source.start(scheduledStart);
source.stop(stopAt + 0.05);
this.activeVoices.push({ gain, source, stopAt });
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.addEventListener(
'ended',
@ -127,13 +170,35 @@ export class PianoSampler {
);
}
public fadeActive(now: number): void {
public fadeActive(
now: number,
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
): void {
this.activeVoices.forEach((voice) => {
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
if (voice.startAt > now) {
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
voice.stopAt = now;
try {
voice.source.stop(now);
} catch {
// The source may already have a stop time scheduled.
}
return;
}
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
voice.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
now,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
)
);
voice.stopAt = stopAt;
try {
voice.source.stop(now + 0.28);
voice.source.stop(stopAt);
} catch {
// The source may already have a stop time scheduled.
}

View file

@ -6,36 +6,36 @@ export interface PianoSampleDefinition {
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
const sampleFiles: Array<[fileName: string, midi: number]> = [
['A0v12.ogg', 21],
['C1v12.ogg', 24],
['Dsharp1v12.ogg', 27],
['Fsharp1v12.ogg', 30],
['A1v12.ogg', 33],
['C2v12.ogg', 36],
['Dsharp2v12.ogg', 39],
['Fsharp2v12.ogg', 42],
['A2v12.ogg', 45],
['C3v12.ogg', 48],
['Dsharp3v12.ogg', 51],
['Fsharp3v12.ogg', 54],
['A3v12.ogg', 57],
['C4v12.ogg', 60],
['Dsharp4v12.ogg', 63],
['Fsharp4v12.ogg', 66],
['A4v12.ogg', 69],
['C5v12.ogg', 72],
['Dsharp5v12.ogg', 75],
['Fsharp5v12.ogg', 78],
['A5v12.ogg', 81],
['C6v12.ogg', 84],
['Dsharp6v12.ogg', 87],
['Fsharp6v12.ogg', 90],
['A6v12.ogg', 93],
['C7v12.ogg', 96],
['Dsharp7v12.ogg', 99],
['Fsharp7v12.ogg', 102],
['A7v12.ogg', 105],
['C8v12.ogg', 108],
['A0v12.m4a', 21],
['C1v12.m4a', 24],
['Dsharp1v12.m4a', 27],
['Fsharp1v12.m4a', 30],
['A1v12.m4a', 33],
['C2v12.m4a', 36],
['Dsharp2v12.m4a', 39],
['Fsharp2v12.m4a', 42],
['A2v12.m4a', 45],
['C3v12.m4a', 48],
['Dsharp3v12.m4a', 51],
['Fsharp3v12.m4a', 54],
['A3v12.m4a', 57],
['C4v12.m4a', 60],
['Dsharp4v12.m4a', 63],
['Fsharp4v12.m4a', 66],
['A4v12.m4a', 69],
['C5v12.m4a', 72],
['Dsharp5v12.m4a', 75],
['Fsharp5v12.m4a', 78],
['A5v12.m4a', 81],
['C6v12.m4a', 84],
['Dsharp6v12.m4a', 87],
['Fsharp6v12.m4a', 90],
['A6v12.m4a', 93],
['C7v12.m4a', 96],
['Dsharp7v12.m4a', 99],
['Fsharp7v12.m4a', 102],
['A7v12.m4a', 105],
['C8v12.m4a', 108],
];
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles

915
src/config.ts Normal file
View file

@ -0,0 +1,915 @@
import type {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './audio/garden-audio-config';
import type { GameLoopSettings } from './game-loop/game-loop-settings';
import type { AgentSettings } from './pipelines/agents/agent-settings';
import type { BrushSettings } from './pipelines/brush/brush-settings';
import type { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from './pipelines/render/render-settings';
export type GardenRuntimeSettings = GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
export type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
| 'brushSize'
| 'clarity'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>
>;
export interface VibePreset {
id: string;
name: string;
colors: [string, string, string];
backgroundColor: string;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
export interface NumberControlConfig {
folder: string;
integer?: boolean;
label?: string;
max: number;
min: number;
step?: number;
}
export type RuntimeSettingControlConfig = {
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
};
export interface GardenAppConfig {
audio: GardenAudioConfig;
audioEngine: {
energy: {
attackSeconds: number;
decaySeconds: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
eraser: {
canvasWidthRatioForFullSize: number;
defaultSizePixels: number;
durationSeconds: number;
filterPressureWeight: number;
filterSizeWeight: number;
filterSpeedWeight: number;
gainBase: number;
gainPressureWeight: number;
gainSizeWeight: number;
gainSpeedWeight: number;
};
delay: {
erasingActivity: number;
};
gestureFadeSeconds: number;
graph: {
closeGain: number;
closeRampSeconds: number;
delayActivityFeedbackWeight: number;
delayFeedbackMax: number;
delayFeedbackMin: number;
delayOutputActivityWeight: number;
delayOutputBase: number;
delayTimeRampSeconds: number;
eventBusGain: number;
noiseMax: number;
noiseMin: number;
unlockBufferLength: number;
unlockSampleRate: number;
};
input: {
distanceEnergyBase: number;
distanceEnergyScale: number;
distanceForFullEnergyPixels: number;
fallbackFrameSeconds: number;
penMinPressure: number;
strokeEnergyBase: number;
strokeEnergyPressureWeight: number;
strokeEnergySpeedWeight: number;
};
muteGain: number;
muteRampSeconds: number;
noiseBurst: {
attackSeconds: number;
filterQ: number;
offsetRandomSeconds: number;
scheduleAheadSeconds: number;
silentGain: number;
};
piano: {
fadeStopExtraSeconds: number;
defaultFadeSeconds: number;
fadeTimeConstantRatio: number;
filterQ: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
minDurationSeconds: number;
minFadeSeconds: number;
minGain: number;
pitchSemitonesPerOctave: number;
scheduleAheadSeconds: number;
sustainBase: number;
sustainVelocityRange: number;
tailStopExtraSeconds: number;
voiceStealFadeSeconds: number;
voiceStealStopSeconds: number;
};
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
};
deltaTime: {
fpsExponentialDecayStrength: number;
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
export4k: {
bytesPerPixel: number;
height: number;
jsHeapSafetyMultiplier: number;
lowMemoryDeviceGiB: number;
lowMemoryExportFraction: number;
rowAlignmentBytes: number;
width: number;
};
menuHider: {
bottomRevealDistancePx: number;
intervalMs: number;
timeToLiveMs: number;
};
pipelines: {
brush: {
maxLineCount: number;
};
diffusion: {
minDiffusionRate: number;
};
eraser: {
maxSegmentCount: number;
maxTextureLineCount: number;
segmentFloatCount: number;
workgroupSize: number;
};
};
runtimeSettings: {
controls: RuntimeSettingControlConfig;
defaults: GardenRuntimeSettings;
};
simulation: {
budget: {
fpsHeadroom: number;
fpsSmoothingNew: number;
fpsSmoothingRetain: number;
initialTargetAgentBudget: number;
rampAgentsPerSecond: number;
refreshTargetDecay: number;
};
brushEffectFramesPerSecond: number;
globalAgentCap: number;
initialAgentCount: number;
intro: {
angleJitterRadians: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintClass: string;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
fontScaleDown: number;
initialFontHeightRatio: number;
initialFontWidthRatio: number;
letterSpacingEm: number;
maskAlphaThreshold: number;
maskGradientThreshold: number;
maskSampleDensity: number;
maxHeightRatio: number;
maxWidthRatio: number;
minEntryJitterPx: number;
minFontSizePx: number;
minTargetJitterPx: number;
radialJitterRatio: number;
targetDelayDistanceMultiplier: number;
targetDelayMax: number;
targetDelayRandomMultiplier: number;
targetJitterSideRatio: number;
title: string;
titleColorCutLetters: [number, number];
titleRadiusMultiplier: number;
titleStrokeWidthMinPx: number;
titleStrokeWidthRatio: number;
verticalAnchor: number;
};
introCameraZoom: number;
introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number;
maxMirrorSegmentCount: number;
stroke: {
angleJitterRadians: number;
densityMultiplier: number;
maxAgentCount: number;
minAgentCount: number;
};
};
storage: {
audioMutedKey: string;
vibeKey: string;
};
telemetry: {
enabled: boolean;
intervalMs: number;
};
toolbar: {
eraser: {
controlScaleMax: number;
controlScaleMin: number;
default: number;
max: number;
min: number;
step: number;
};
mirror: {
default: number;
max: number;
min: number;
names: Record<number, string>;
step: number;
};
};
tuningPane: {
expandedDepth: number;
startHidden: boolean;
title: string;
};
vibes: {
defaultVibeId: string;
presets: Array<VibePreset>;
};
}
const majorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const minorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
];
const majorPentatonic = [0, 2, 4, 7, 9];
const minorPentatonic = [0, 3, 5, 7, 10];
const defaultVibeId = 'candy-rain';
const vibePresets: Array<VibePreset> = [
{
id: 'candy-rain',
name: 'Candy Rain',
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 14,
clarity: 0.62,
decayRateTrails: 965,
diffusionRateTrails: 0.22,
individualTrailWeight: 0.07,
moveSpeed: 82,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
},
audio: {
rootMidi: 57,
scale: majorPentatonic,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
},
},
{
id: 'sunlit-moss',
name: 'Sunlit Moss',
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016',
settings: {
agentBudgetMax: 900_000,
brushSize: 16,
clarity: 0.68,
decayRateTrails: 975,
diffusionRateTrails: 0.18,
individualTrailWeight: 0.06,
moveSpeed: 70,
sensorOffsetAngle: 28,
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
},
audio: {
rootMidi: 53,
scale: majorPentatonic,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
},
},
{
id: 'coral-tide',
name: 'Coral Tide',
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 13,
clarity: 0.58,
decayRateTrails: 955,
diffusionRateTrails: 0.28,
individualTrailWeight: 0.055,
moveSpeed: 90,
sensorOffsetAngle: 36,
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
},
audio: {
rootMidi: 50,
scale: minorPentatonic,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
},
},
{
id: 'moon-orchid',
name: 'Moon Orchid',
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d',
settings: {
agentBudgetMax: 850_000,
brushSize: 12,
clarity: 0.64,
decayRateTrails: 968,
diffusionRateTrails: 0.2,
individualTrailWeight: 0.065,
moveSpeed: 76,
sensorOffsetAngle: 32,
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
},
audio: {
rootMidi: 49,
scale: minorPentatonic,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
},
{
id: 'peach-neon',
name: 'Peach Neon',
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 15,
clarity: 0.55,
decayRateTrails: 948,
diffusionRateTrails: 0.32,
individualTrailWeight: 0.05,
moveSpeed: 96,
sensorOffsetAngle: 40,
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
},
audio: {
rootMidi: 56,
scale: majorPentatonic,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
},
{
id: 'frost-bloom',
name: 'Frost Bloom',
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820',
settings: {
agentBudgetMax: 750_000,
brushSize: 18,
clarity: 0.7,
decayRateTrails: 982,
diffusionRateTrails: 0.14,
individualTrailWeight: 0.075,
moveSpeed: 62,
sensorOffsetAngle: 26,
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
},
audio: {
rootMidi: 62,
scale: majorPentatonic,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
},
},
];
const audioVibes = Object.fromEntries(
vibePresets.map((vibe) => [vibe.id, vibe.audio])
) as Record<string, GardenAudioVibeProfile>;
export const appConfig: GardenAppConfig = {
audio: {
masterVolume: 0.32,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
fallbackVibeId: defaultVibeId,
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.4,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
delay: {
timeSeconds: 0.42,
feedback: 0.12,
wetGain: 0.048,
},
piano: {
maxVoices: 32,
gain: 0.42,
sustainSeconds: 0.52,
sustainLevel: 0.34,
releaseSeconds: 0.16,
lowpassHz: 9000,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 82,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.18,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
},
colorVoices: [
{
scaleDegreeOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
],
vibes: audioVibes,
},
audioEngine: {
energy: {
attackSeconds: 0.08,
decaySeconds: 0.9,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
eraser: {
canvasWidthRatioForFullSize: 0.18,
defaultSizePixels: 96,
durationSeconds: 0.08,
filterPressureWeight: 0.26,
filterSizeWeight: 0.16,
filterSpeedWeight: 0.58,
gainBase: 0.45,
gainPressureWeight: 0.24,
gainSizeWeight: 0.18,
gainSpeedWeight: 0.38,
},
delay: {
erasingActivity: 0.12,
},
gestureFadeSeconds: 1.35,
graph: {
closeGain: 0.0001,
closeRampSeconds: 0.015,
delayActivityFeedbackWeight: 0.08,
delayFeedbackMax: 0.32,
delayFeedbackMin: 0.04,
delayOutputActivityWeight: 0.5,
delayOutputBase: 0.65,
delayTimeRampSeconds: 0.12,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
unlockBufferLength: 1,
unlockSampleRate: 22050,
},
input: {
distanceEnergyBase: 0.34,
distanceEnergyScale: 0.66,
distanceForFullEnergyPixels: 140,
fallbackFrameSeconds: 1 / 60,
penMinPressure: 0.56,
strokeEnergyBase: 0.18,
strokeEnergyPressureWeight: 0.22,
strokeEnergySpeedWeight: 0.62,
},
muteGain: 0.0001,
muteRampSeconds: 0.02,
noiseBurst: {
attackSeconds: 0.004,
filterQ: 1.4,
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
},
piano: {
fadeStopExtraSeconds: 0.05,
defaultFadeSeconds: 0.9,
fadeTimeConstantRatio: 0.3,
filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
pitchSemitonesPerOctave: 12,
scheduleAheadSeconds: 0.002,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
},
startDelaySeconds: 0.02,
vibeChangeStingerMinIntervalSeconds: 0.45,
},
deltaTime: {
fpsExponentialDecayStrength: 0.01,
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
export4k: {
bytesPerPixel: 4,
height: 2160,
jsHeapSafetyMultiplier: 1.5,
lowMemoryDeviceGiB: 2,
lowMemoryExportFraction: 0.08,
rowAlignmentBytes: 256,
width: 3840,
},
menuHider: {
bottomRevealDistancePx: 96,
intervalMs: 50,
timeToLiveMs: 3500,
},
pipelines: {
brush: {
maxLineCount: 240,
},
diffusion: {
minDiffusionRate: 0.000001,
},
eraser: {
maxSegmentCount: 384,
maxTextureLineCount: 384,
segmentFloatCount: 4,
workgroupSize: 64,
},
},
runtimeSettings: {
defaults: {
agentBudgetMax: 1_000_000,
agentCount: 0,
selectedColorIndex: 0,
spawnPerPixel: 0.22,
moveSpeed: 82,
turnSpeed: 58,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
turnWhenLost: 0.8,
individualTrailWeight: 0.07,
diffusionRateTrails: 0.22,
decayRateTrails: 965,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
brushEffectDuration: 8,
clarity: 0.62,
brushSize: 14,
eraserSize: 96,
mirrorSegmentCount: 1,
brushSizeVariation: 0.5,
startColorHue: 200,
renderSpeed: 1,
simulatedDelayMs: 0,
},
controls: {
agentBudgetMax: {
folder: 'Runtime',
integer: true,
min: 1_000,
max: 1_000_000,
step: 1_000,
},
agentCount: {
folder: 'Runtime',
integer: true,
min: 0,
max: 1_000_000,
step: 1_000,
},
brushEffectDuration: {
folder: 'Diffusion',
min: 0.5,
max: 20,
step: 0.05,
},
brushSize: {
folder: 'Brush',
min: 1,
max: 60,
step: 0.25,
},
brushSizeVariation: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.01,
},
clarity: {
folder: 'Render',
min: 0.00001,
max: 1,
step: 0.001,
},
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
max: 100,
step: 0.1,
},
decayRateTrails: {
folder: 'Diffusion',
min: 0.1,
max: 5000,
step: 1,
},
diffusionRateBrush: {
folder: 'Diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Diffusion',
min: 0,
max: 2,
step: 0.001,
},
eraserSize: {
folder: 'Brush',
integer: true,
min: 24,
max: 240,
step: 1,
},
individualTrailWeight: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
min: 1,
max: 12,
step: 1,
},
moveSpeed: {
folder: 'Agent',
min: 10,
max: 500,
step: 1,
},
renderSpeed: {
folder: 'Runtime',
integer: true,
min: 1,
max: 10,
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,
},
simulatedDelayMs: {
folder: 'Runtime',
integer: true,
min: 0,
max: 2000,
step: 1,
},
spawnPerPixel: {
folder: 'Agent',
min: 0.01,
max: 1,
step: 0.001,
},
startColorHue: {
folder: 'Render',
min: 0,
max: 360,
step: 1,
},
turnSpeed: {
folder: 'Agent',
min: 1,
max: 200,
step: 1,
},
turnWhenLost: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
},
},
simulation: {
budget: {
fpsHeadroom: 0.82,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
initialTargetAgentBudget: 20_000,
rampAgentsPerSecond: 20_000,
refreshTargetDecay: 0.995,
},
brushEffectFramesPerSecond: 60,
globalAgentCap: 1_000_000,
initialAgentCount: 180_000,
intro: {
angleJitterRadians: Math.PI * 0.08,
circleMaxSideRatio: 0.46,
circleMinSideRatio: 0.32,
drawHintClass: 'draw-hint',
drawHintDelayMs: 3000,
durationSeconds: 4,
entryJitterSideRatio: 0.035,
fontScaleDown: 0.94,
initialFontHeightRatio: 0.28,
initialFontWidthRatio: 0.19,
letterSpacingEm: 0.07,
maskAlphaThreshold: 32,
maskGradientThreshold: 8,
maskSampleDensity: 540,
maxHeightRatio: 0.25,
maxWidthRatio: 0.76,
minEntryJitterPx: 6,
minFontSizePx: 18,
minTargetJitterPx: 1,
radialJitterRatio: 0.35,
targetDelayDistanceMultiplier: 0.12,
targetDelayMax: 0.22,
targetDelayRandomMultiplier: 0.06,
targetJitterSideRatio: 0.0035,
title: 'Fleeting',
titleColorCutLetters: [2, 5],
titleRadiusMultiplier: 1.55,
titleStrokeWidthMinPx: 6,
titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47,
},
introCameraZoom: 0.12,
introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35,
maxMirrorSegmentCount: 12,
stroke: {
angleJitterRadians: Math.PI * 0.7,
densityMultiplier: 110,
maxAgentCount: 2_400,
minAgentCount: 140,
},
},
storage: {
audioMutedKey: 'fleeting-garden:audio-muted',
vibeKey: 'fleeting-garden:vibe',
},
telemetry: {
enabled: false,
intervalMs: 1000,
},
toolbar: {
eraser: {
controlScaleMax: 1.34,
controlScaleMin: 0.74,
default: 96,
max: 240,
min: 24,
step: 1,
},
mirror: {
default: 1,
max: 12,
min: 1,
names: {
2: 'halves',
3: 'thirds',
4: 'quarters',
5: 'fifths',
6: 'sixths',
7: 'sevenths',
8: 'eighths',
9: 'ninths',
10: 'tenths',
11: 'elevenths',
12: 'twelfths',
},
step: 1,
},
},
tuningPane: {
expandedDepth: 1,
startHidden: true,
title: 'Garden Config',
},
vibes: {
defaultVibeId,
presets: vibePresets,
},
};

View file

@ -0,0 +1,181 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { createIntroTitleAgents } from './intro-title-agents';
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
export class AgentPopulation {
private activeCount = 0;
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
private replacementCursor = 0;
private shouldCompactAfterErase = false;
private isCompacting = false;
private readonly strokeAgentData = new Float32Array(
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
);
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
public get activeAgentCount(): number {
return this.activeCount;
}
public get targetAgentBudget(): number {
return this.targetBudget;
}
public get maxAgentCount(): number {
return this.pipeline.maxAgentCount;
}
public initializeIntroAgents(canvasSize: vec2): void {
this.targetBudget = Math.min(
this.pipeline.maxAgentCount,
settings.agentBudgetMax,
INITIAL_AGENT_COUNT
);
this.writeAgentBatch(
createIntroTitleAgents({
count: this.targetBudget,
width: canvasSize[0],
height: canvasSize[1],
})
);
}
public onVibeChanged(): void {
this.targetBudget = Math.min(
this.targetBudget,
settings.agentBudgetMax,
this.pipeline.maxAgentCount
);
}
public growBudget(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): void {
const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
if (
this.targetBudget < cap &&
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
) {
this.targetBudget = Math.min(
cap,
this.targetBudget +
Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime)
);
}
}
public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale);
}
public requestCompactionAfterErase(): void {
this.shouldCompactAfterErase = true;
}
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
return;
}
this.shouldCompactAfterErase = false;
if (this.activeCount === 0) {
return;
}
this.isCompacting = true;
try {
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
this.activeCount = compactedAgentCount;
this.replacementCursor =
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
this.targetBudget = Math.max(this.targetBudget, compactedAgentCount);
} finally {
this.isCompacting = false;
}
}
public spawnStrokeAgents(from: vec2, to: vec2): void {
const length = Math.max(1, vec2.dist(from, to));
const count = Math.max(
MIN_STROKE_AGENT_COUNT,
Math.min(
MAX_STROKE_AGENT_COUNT,
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
)
);
const direction = vec2.sub(vec2.create(), to, from);
const baseAngle = Math.atan2(direction[1], direction[0]);
for (let i = 0; i < count; i++) {
const t = count === 1 ? 1 : i / (count - 1);
const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t;
const angle =
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
this.strokeAgentData[base + 2] = angle;
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
this.strokeAgentData[base + 4] = -1;
this.strokeAgentData[base + 5] = -1;
this.strokeAgentData[base + 6] = angle;
this.strokeAgentData[base + 7] = 0;
}
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
}
private writeAgentBatch(data: Float32Array): void {
if (data.length === 0) {
return;
}
const count = data.length / AGENT_FLOAT_COUNT;
const available = Math.max(0, this.targetBudget - this.activeCount);
const appendCount = Math.min(count, available);
if (appendCount > 0) {
this.pipeline.writeAgents(
this.activeCount,
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
);
this.activeCount += appendCount;
}
let sourceAgentOffset = appendCount;
while (sourceAgentOffset < count && this.activeCount > 0) {
const targetAgentOffset = this.replacementCursor % this.activeCount;
const chunkAgentCount = Math.min(
count - sourceAgentOffset,
this.activeCount - targetAgentOffset
);
this.pipeline.writeAgents(
targetAgentOffset,
data.subarray(
sourceAgentOffset * AGENT_FLOAT_COUNT,
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
)
);
sourceAgentOffset += chunkAgentCount;
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
}

View file

@ -0,0 +1,80 @@
import { settings } from '../settings';
export class EraserPreview {
private previewClientPosition: { x: number; y: number } | null = null;
private isErasing = false;
private isPointerHoveringCanvas = false;
private previousSize: number | null = null;
private previousLeft = '';
private previousTop = '';
private isVisible = false;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly element: HTMLElement
) {}
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
this.isErasing = isErasing;
this.update(undefined, isSwipeActive);
}
public setPointerHoveringCanvas(isHovering: boolean): void {
this.isPointerHoveringCanvas = isHovering;
}
public update(event?: PointerEvent, isSwipeActive = false): void {
if (event) {
this.previewClientPosition = {
x: event.clientX,
y: event.clientY,
};
}
if (this.previousSize !== settings.eraserSize) {
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
this.previousSize = settings.eraserSize;
}
if (
!this.isErasing ||
this.previewClientPosition === null ||
(!this.isPointerHoveringCanvas && !isSwipeActive)
) {
this.setVisible(false);
return;
}
const rect = this.canvas.getBoundingClientRect();
const left = `${this.previewClientPosition.x - rect.left}px`;
const top = `${this.previewClientPosition.y - rect.top}px`;
if (this.previousLeft !== left) {
this.element.style.left = left;
this.previousLeft = left;
}
if (this.previousTop !== top) {
this.element.style.top = top;
this.previousTop = top;
}
this.setVisible(true);
}
public isPointerInsideCanvas(event: PointerEvent): boolean {
const rect = this.canvas.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
private setVisible(isVisible: boolean): void {
if (this.isVisible === isVisible) {
return;
}
this.isVisible = isVisible;
this.element.classList.toggle('visible', isVisible);
}
}

View file

@ -0,0 +1,194 @@
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import {
estimateExport4KMemory,
getAspectFitExport4KDimensions,
getBrowserExportMemoryInfo,
getExport4KPreflightError,
} from './export-4k';
interface Export4KRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
statusElement: HTMLElement;
seed: string;
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
getVibeId: () => string;
}
export class Export4KRenderer {
private isExporting = false;
public constructor(private readonly options: Export4KRendererOptions) {}
public async export(): Promise<void> {
if (this.isExporting) {
this.statusElement.textContent = '4K upscale already rendering...';
return;
}
this.isExporting = true;
this.statusElement.textContent = 'Rendering 4K upscale...';
try {
const sourceSize = this.options.getSourceSize();
const exportDimensions = getAspectFitExport4KDimensions(
sourceSize.width,
sourceSize.height
);
const estimate = estimateExport4KMemory(
exportDimensions.width,
exportDimensions.height
);
const preflightError = getExport4KPreflightError({
limits: this.device.limits,
memoryInfo: getBrowserExportMemoryInfo(),
estimate,
});
if (preflightError) {
this.statusElement.textContent = '4K upscale unavailable';
throw preflightError;
}
await this.renderExport(estimate);
this.statusElement.textContent = '';
} finally {
this.isExporting = false;
}
}
private async renderExport(
estimate: ReturnType<typeof estimateExport4KMemory>
): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
const format = navigator.gpu.getPreferredCanvasFormat();
let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null;
let isOutputMapped = false;
try {
texture = this.device.createTexture({
size: { width, height },
format,
usage:
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.TEXTURE_BINDING,
});
output = this.device.createBuffer({
size: estimate.readbackBufferBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const commandEncoder = this.device.createCommandEncoder();
this.options.renderPipeline.executeToView(
commandEncoder,
this.options.getColorTextureView(),
this.options.getSourceTextureView(),
texture.createView()
);
commandEncoder.copyTextureToBuffer(
{ texture },
{ buffer: output, bytesPerRow, rowsPerImage: height },
{ width, height }
);
this.device.queue.submit([commandEncoder.finish()]);
await output.mapAsync(GPUMapMode.READ);
isOutputMapped = true;
const pixels = readExportPixels({
mapped: new Uint8Array(output.getMappedRange()),
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra: format === 'bgra8unorm',
});
output.unmap();
isOutputMapped = false;
output.destroy();
output = null;
texture.destroy();
texture = null;
await this.downloadPixels(pixels, width, height);
} catch (error) {
this.statusElement.textContent = '4K upscale failed';
throw error;
} finally {
if (output && isOutputMapped) {
output.unmap();
}
output?.destroy();
texture?.destroy();
}
}
private async downloadPixels(
pixels: Uint8ClampedArray<ArrayBuffer>,
width: number,
height: number
): Promise<void> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create export canvas');
}
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
link.download = `fleeting-garden_${this.options.getVibeId()}_${
this.options.seed
}_${width}x${height}-upscale.png`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
}
}
private get device(): GPUDevice {
return this.options.device;
}
private get statusElement(): HTMLElement {
return this.options.statusElement;
}
}
const readExportPixels = ({
mapped,
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra,
}: {
mapped: Uint8Array;
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
isBgra: boolean;
}): Uint8ClampedArray<ArrayBuffer> => {
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
unpaddedBytesPerRow * height
);
for (let y = 0; y < height; y++) {
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
const source = sourceOffset + x * 4;
const target = targetOffset + x * 4;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
pixels[target + 3] = mapped[source + 3];
}
}
return pixels;
};

View file

@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import {
estimateExport4KMemory,
formatByteSize,
getAspectFitExport4KDimensions,
getExport4KPreflightError,
} from './export-4k';
const generousLimits = {
maxBufferSize: Number.MAX_SAFE_INTEGER,
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
};
describe('4K export preflight', () => {
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
width: 3840,
height: 2160,
});
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
width: 2880,
height: 2160,
});
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
width: 1620,
height: 2160,
});
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
width: 2160,
height: 2160,
});
});
it('estimates padded readback and temporary memory for the export', () => {
const estimate = estimateExport4KMemory();
expect(estimate.width).toBe(3840);
expect(estimate.height).toBe(2160);
expect(estimate.bytesPerRow % 256).toBe(0);
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
});
it('rejects GPUs that cannot allocate the export texture', () => {
const error = getExport4KPreflightError({
limits: {
maxBufferSize: Number.MAX_SAFE_INTEGER,
maxTextureDimension2D: 2048,
},
});
expect(error?.code).toBe('export-4k-texture-too-large');
});
it('rejects GPUs that cannot allocate the readback buffer', () => {
const estimate = estimateExport4KMemory();
const error = getExport4KPreflightError({
limits: {
maxBufferSize: estimate.readbackBufferBytes - 1,
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
},
estimate,
});
expect(error?.code).toBe('export-4k-readback-too-large');
});
it('rejects browser-reported low-memory devices', () => {
const error = getExport4KPreflightError({
limits: generousLimits,
memoryInfo: {
deviceMemoryBytes: 2 * 1024 ** 3,
},
});
expect(error?.code).toBe('export-4k-low-device-memory');
});
it('allows export when memory hints are unavailable', () => {
expect(
getExport4KPreflightError({
limits: generousLimits,
})
).toBeNull();
});
});

222
src/game-loop/export-4k.ts Normal file
View file

@ -0,0 +1,222 @@
import { appConfig } from '../config';
import { RuntimeError } from '../utils/error-handler';
export const EXPORT_4K_WIDTH = appConfig.export4k.width;
export const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
const GIBIBYTE = 1024 ** 3;
const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
export interface Export4KMemoryEstimate {
width: number;
height: number;
bytesPerPixel: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
textureBytes: number;
readbackBufferBytes: number;
pixelBytes: number;
canvasBytes: number;
encoderSafetyBytes: number;
estimatedJsHeapBytes: number;
estimatedPeakBytes: number;
}
export interface Export4KDimensions {
width: number;
height: number;
}
export interface BrowserMemoryInfo {
deviceMemoryBytes?: number;
jsHeapSizeLimitBytes?: number;
usedJsHeapSizeBytes?: number;
}
export interface Export4KPreflightOptions {
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
memoryInfo?: BrowserMemoryInfo;
estimate?: Export4KMemoryEstimate;
}
const alignTo = (value: number, alignment: number): number =>
Math.ceil(value / alignment) * alignment;
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
export const formatByteSize = (bytes: number): string =>
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
export const getAspectFitExport4KDimensions = (
sourceWidth: number,
sourceHeight: number,
maxWidth = EXPORT_4K_WIDTH,
maxHeight = EXPORT_4K_HEIGHT
): Export4KDimensions => {
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth <= 0 ||
sourceHeight <= 0
) {
return { width: maxWidth, height: maxHeight };
}
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
return {
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
};
};
export const estimateExport4KMemory = (
width = EXPORT_4K_WIDTH,
height = EXPORT_4K_HEIGHT
): Export4KMemoryEstimate => {
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
const textureBytes = unpaddedBytesPerRow * height;
const readbackBufferBytes = bytesPerRow * height;
const pixelBytes = textureBytes;
const canvasBytes = textureBytes;
const encoderSafetyBytes = textureBytes * 2;
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
return {
width,
height,
bytesPerPixel: BYTES_PER_PIXEL,
unpaddedBytesPerRow,
bytesPerRow,
textureBytes,
readbackBufferBytes,
pixelBytes,
canvasBytes,
encoderSafetyBytes,
estimatedJsHeapBytes,
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
};
};
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
const navigatorWithMemory =
typeof navigator === 'undefined'
? undefined
: (navigator as Navigator & { deviceMemory?: number });
const performanceWithMemory =
typeof performance === 'undefined'
? undefined
: (performance as Performance & {
memory?: {
jsHeapSizeLimit?: number;
usedJSHeapSize?: number;
};
});
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
performanceWithMemory?.memory?.jsHeapSizeLimit
);
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
performanceWithMemory?.memory?.usedJSHeapSize
);
return {
...(deviceMemoryGib === undefined
? {}
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
};
};
export const getExport4KPreflightError = ({
limits,
memoryInfo = {},
estimate = estimateExport4KMemory(),
}: Export4KPreflightOptions): RuntimeError | null => {
if (
estimate.width > limits.maxTextureDimension2D ||
estimate.height > limits.maxTextureDimension2D
) {
return new RuntimeError(
'export-4k-texture-too-large',
'This GPU cannot create a 3840x2160 export texture.',
{
details: {
exportWidth: estimate.width,
exportHeight: estimate.height,
maxTextureDimension2D: limits.maxTextureDimension2D,
},
}
);
}
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
return new RuntimeError(
'export-4k-readback-too-large',
'This GPU cannot allocate the 4K export readback buffer.',
{
details: {
readbackBufferBytes: estimate.readbackBufferBytes,
maxBufferSize: limits.maxBufferSize,
},
}
);
}
if (
memoryInfo.deviceMemoryBytes !== undefined &&
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
estimate.estimatedPeakBytes >
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
) {
return new RuntimeError(
'export-4k-low-device-memory',
`4K upscale export needs about ${formatByteSize(
estimate.estimatedPeakBytes
)} of temporary memory, which is not safe on this low-memory device.`,
{
details: {
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
estimatedPeakBytes: estimate.estimatedPeakBytes,
},
}
);
}
if (
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
memoryInfo.usedJsHeapSizeBytes !== undefined
) {
const availableJsHeapBytes =
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
if (
availableJsHeapBytes <
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
) {
return new RuntimeError(
'export-4k-low-js-heap',
`4K upscale export needs about ${formatByteSize(
estimate.estimatedJsHeapBytes
)} of JavaScript heap, and this browser does not report enough free heap.`,
{
details: {
availableJsHeapBytes,
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
},
}
);
}
}
return null;
};

View file

@ -0,0 +1,73 @@
import { appConfig } from '../config';
interface TelemetrySnapshot {
frameCpuStartedAt: number;
encodeCpuMs: number;
activeAgentCount: number;
targetAgentBudget: number;
canvas: HTMLCanvasElement;
devicePixelRatio: number;
renderSpeed: number;
}
export class FramePerformance {
public latestFps = 60;
public smoothedFps = 60;
public refreshTargetFps = 60;
private lastTelemetryAt = 0;
public markCpuStart(): number {
return appConfig.telemetry.enabled ? performance.now() : 0;
}
public measureSince(startedAt: number): number {
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
}
public update(deltaTime: number): void {
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
this.latestFps = fps;
this.refreshTargetFps = Math.max(
this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay,
fps
);
this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
fps * appConfig.simulation.budget.fpsSmoothingNew;
}
public renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount,
targetAgentBudget,
canvas,
devicePixelRatio,
renderSpeed,
}: TelemetrySnapshot): void {
if (!appConfig.telemetry.enabled) {
return;
}
const now = performance.now();
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
return;
}
this.lastTelemetryAt = now;
console.debug('Fleeting Garden telemetry', {
fps: Math.round(this.latestFps),
smoothedFps: Math.round(this.smoothedFps),
refreshTargetFps: Math.round(this.refreshTargetFps),
activeAgentCount,
targetAgentBudget,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
dpr: devicePixelRatio,
renderSpeed,
frameCpuMs: now - frameCpuStartedAt,
encodeCpuMs,
});
}
}

View file

@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const simulationFrameSource = readFileSync(
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
'utf8'
);
const simulationTexturesSource = readFileSync(
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
'utf8'
);
const getRenderStepSource = () => {
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
if (start < 0 || end < 0) {
throw new Error('Could not find the render-speed simulation loop');
}
return simulationFrameSource.slice(start, end);
};
describe('GameLoop ping-pong texture flow', () => {
it('copies only the trail map and swaps source/influence references after diffusion', () => {
const renderStepSource = getRenderStepSource();
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
expect(renderStepSource).toMatch(
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/
);
expect(renderStepSource).toMatch(
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
);
});
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain(
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
);
expect(simulationTexturesSource).toContain(
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
);
});
});

View file

@ -0,0 +1,192 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CommonState } from '../pipelines/common-state/common-state';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context';
import { GLOBAL_AGENT_CAP } from './agent-population';
import { RenderInputs } from './game-loop-types';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
interface FrameParameters extends RenderInputs {
time: number;
deltaTime: number;
canvasSize: vec2;
activeAgentCount: number;
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
cameraCenter: [number, number];
cameraZoom: number;
eraserPixelSize: number;
}
export class GameLoopResources {
public readonly textures: SimulationTextures;
public readonly commonState: CommonState;
public readonly copyPipeline: CopyPipeline;
public readonly agentGenerationPipeline: AgentGenerationPipeline;
public readonly agentPipeline: AgentPipeline;
public readonly brushPipeline: BrushPipeline;
public readonly eraserAgentPipeline: EraserAgentPipeline;
public readonly eraserTexturePipeline: EraserTexturePipeline;
public readonly diffusionPipeline: DiffusionPipeline;
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
public readonly renderPipeline: RenderPipeline;
private readonly frameRenderer: SimulationFrameRenderer;
public constructor(
canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
canvasSize: vec2
) {
const context = initializeContext({ device, canvas });
this.textures = new SimulationTextures(this.device, canvasSize);
this.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize,
time: 0,
deltaTime: 0,
});
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
this.commonState,
GLOBAL_AGENT_CAP
);
this.agentPipeline = new AgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
);
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.eraserAgentPipeline = new EraserAgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
);
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
this.device,
this.commonState
);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
copyPipeline: this.copyPipeline,
agentPipeline: this.agentPipeline,
brushPipeline: this.brushPipeline,
eraserAgentPipeline: this.eraserAgentPipeline,
eraserTexturePipeline: this.eraserTexturePipeline,
diffusionPipeline: this.diffusionPipeline,
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
renderPipeline: this.renderPipeline,
});
}
public resizeSimulationTo(nextSize: vec2): vec2 | null {
return this.textures.resizeTo(nextSize);
}
public setFrameParameters({
time,
deltaTime,
canvasSize,
activeAgentCount,
introProgress,
selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
}: FrameParameters): void {
this.commonState.setParameters({
canvasSize,
time,
deltaTime,
});
this.agentPipeline.setParameters({
...settings,
deltaTime,
agentCount: activeAgentCount,
moveSpeed:
settings.moveSpeed *
(introProgress >= 1
? 1
: appConfig.simulation.introMoveSpeedBaseMultiplier +
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
introProgress,
});
this.brushPipeline.setParameters({
...settings,
selectedColorIndex,
isErasing,
});
this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({
...settings,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
});
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
});
this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize,
});
this.setBrushEffectDiffusionParameters();
}
public executeFrame(renderSpeed: number, isErasing: boolean): void {
this.frameRenderer.execute(renderSpeed, isErasing);
}
public clearSwipes(): void {
this.frameRenderer.clearSwipes();
}
public destroy(): void {
this.copyPipeline.destroy();
this.agentGenerationPipeline.destroy();
this.agentPipeline.destroy();
this.brushPipeline.destroy();
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,
});
}
}

View file

@ -0,0 +1,17 @@
import { vec2 } from 'gl-matrix';
export interface GardenUi {
prompt: HTMLElement;
eraserPreview: HTMLElement;
exportStatus: HTMLElement;
}
export interface RenderInputs {
channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number];
}
export interface StrokeSegment {
from: vec2;
to: vec2;
}

View file

@ -1,258 +1,244 @@
import { vec2 } from 'gl-matrix';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CommonState } from '../pipelines/common-state/common-state';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { initializeContext } from '../utils/graphics/initialize-context';
import { ResizableTexture } from '../utils/graphics/resizable-texture';
import { sleep } from '../utils/sleep';
import { GamePresentation } from './game-presentation';
import { GameRules } from './game-rules';
import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-renderer';
import { FramePerformance } from './frame-performance';
import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { IntroPrompt } from './intro-prompt';
import { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
export default class GameLoop {
private readonly trailMapA: ResizableTexture;
private readonly trailMapB: ResizableTexture;
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private readonly commonState: CommonState;
private readonly copyPipeline: CopyPipeline;
private readonly agentGenerationPipeline: AgentGenerationPipeline;
private readonly agentPipeline: AgentPipeline;
private readonly renderPipeline: RenderPipeline;
private readonly brushPipeline: BrushPipeline;
private readonly diffusionPipeline: DiffusionPipeline;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
private readonly renderInputs = new RenderInputCache();
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
private activePointerId: number | null = null;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
device: GPUDevice,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
private readonly gameRules: GameRules
ui: GardenUi
) {
const context = initializeContext({ device, canvas });
this.trailMapA = new ResizableTexture(this.device, this.canvasSize);
this.trailMapB = new ResizableTexture(this.device, this.canvasSize);
this.resize();
this.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize: this.canvasSize,
time: 0,
deltaTime: 0,
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
audio: this.audio,
brushPipeline: this.resources.brushPipeline,
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.introPrompt.complete();
},
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
this.export4KRenderer = new Export4KRenderer({
device,
renderPipeline: this.resources.renderPipeline,
statusElement: ui.exportStatus,
seed: this.seed,
getSourceSize: () => ({
width: this.canvas.width,
height: this.canvas.height,
}),
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
});
this.keydownListener = (event: KeyboardEvent) => {
this.audio.start(activeVibe, { userGesture: event.isTrusted });
this.introPrompt.complete();
};
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
this.commonState,
settings.maxAgentCountUpperLimit
);
this.agentGenerationPipeline.spawnFirstGeneration();
this.agentPipeline = new AgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
);
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
window.addEventListener('resize', this.resize.bind(this));
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
window.addEventListener('resize', this.resizeListener);
window.addEventListener('keydown', this.keydownListener, { once: true });
this.pointerInput.attach();
}
private onPointerDown(event: PointerEvent) {
if (this.activePointerId !== null) {
return;
}
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.brushPipeline.clearSwipes();
this.addSwipeAt(event);
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
}
private onPointerMove(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
public updateEraserPreview(event?: PointerEvent): void {
this.pointerInput.updateEraserPreview(event);
}
private onPointerUp(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate();
}
private addSwipeAt(event: PointerEvent) {
const position = vec2.fromValues(
event.clientX * this.devicePixelRatio,
this.canvas.height - event.clientY * this.devicePixelRatio
);
this.brushPipeline.addSwipe(position);
public setAudioMuted(isMuted: boolean): void {
this.audio.setMuted(isMuted);
}
private get isSwipeActive(): boolean {
return this.activePointerId !== null;
public startAudio(userGesture = false): void {
this.audio.start(activeVibe, { userGesture });
}
public playVibeChangeAudio(userGesture = false): void {
this.audio.changeVibe(activeVibe, { userGesture });
}
public async start(): Promise<void> {
requestAnimationFrame(this.render.bind(this));
requestAnimationFrame(this.updateCounts.bind(this));
requestAnimationFrame(this.render);
return this.finished.promise;
}
private async updateCounts(): Promise<void> {
if (this.hasFinished) {
return;
}
const generationCounts = await this.agentGenerationPipeline.countAgents(
settings.agentCount
);
this.gameRules.updateGenerationCounts(generationCounts);
requestAnimationFrame(this.updateCounts.bind(this));
}
public get aliveAgentCounts(): {
currentGenerationCount: number;
nextGenerationCount: number;
} {
return this.gameRules.generationCounts;
}
public get maxAgentCount(): number {
return this.agentGenerationPipeline.maxAgentCount;
return this.agentPopulation.maxAgentCount;
}
private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
}
private async render(time: DOMHighResTimeStamp) {
public async destroy(): Promise<void> {
this.hasFinished = true;
await this.finished.promise;
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
}
private readonly render = async (time: DOMHighResTimeStamp) => {
if (this.hasFinished) {
this.finished.resolve();
return;
}
const accentColor = GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
);
document.documentElement.style.setProperty(
'--accent-color',
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
);
const frameCpuStartedAt = this.framePerformance.markCpuStart();
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
time *= settings.renderSpeed;
const timeInSeconds = time / 1000;
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
[
this.commonState,
this.agentPipeline,
this.brushPipeline,
this.diffusionPipeline,
this.renderPipeline,
].forEach((pipeline) =>
pipeline.setParameters({
time,
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
infectionProbability: this.gameRules.getInfectionProbability(),
deltaTime,
canvasSize: this.canvasSize,
brushColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
),
evenGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 0
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
oddGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 1
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
...settings,
center: spawnAction.position,
radius: spawnAction.radius,
})
this.framePerformance.update(deltaTime);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.introPrompt.update();
this.resize();
this.resizeSimulationToCanvas();
for (let i = 0; i < settings.renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
];
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
this.audio.update({
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
});
this.copyPipeline.execute(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
this.agentPipeline.execute(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.diffusionPipeline.execute(
commandEncoder,
this.trailMapB.getTextureView(),
this.trailMapA.getTextureView()
);
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
this.resources.setFrameParameters({
time: scaledTime,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
this.device.queue.submit([commandEncoder.finish()]);
}
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(settings.renderSpeed, isErasing);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
if (!this.isSwipeActive) {
this.brushPipeline.clearSwipes();
}
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.framePerformance.renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount: this.agentPopulation.activeAgentCount,
targetAgentBudget: this.agentPopulation.targetAgentBudget,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
// avoid resizing during rendering
this.trailMapA.resize(this.canvasSize);
this.trailMapB.resize(this.canvasSize);
requestAnimationFrame(this.render);
};
requestAnimationFrame(this.render.bind(this));
private resize(): void {
const width = Math.max(
1,
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
);
const height = Math.max(
1,
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
);
if (this.canvas.width === width && this.canvas.height === height) {
return;
}
this.canvas.width = width;
this.canvas.height = height;
}
public async destroy() {
this.hasFinished = true;
await this.finished.promise;
private resizeSimulationToCanvas(): void {
const scale = this.resources.resizeSimulationTo(this.canvasSize);
if (!scale) {
return;
}
this.copyPipeline?.destroy();
this.agentGenerationPipeline?.destroy();
this.agentPipeline?.destroy();
this.brushPipeline?.destroy();
this.diffusionPipeline?.destroy();
this.renderPipeline?.destroy();
this.commonState?.destroy();
this.trailMapA?.destroy();
this.trailMapB?.destroy();
this.agentPopulation.resizeAgents(scale);
this.pointerInput.scaleLastPointerPosition(scale);
}
private get canvasSize(): vec2 {
@ -260,6 +246,14 @@ export default class GameLoop {
}
private get devicePixelRatio(): number {
return window.devicePixelRatio || 1;
const ratio = window.devicePixelRatio;
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
: 1;
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
}
}

View file

@ -0,0 +1,80 @@
import { appConfig } from '../config';
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
export class IntroPrompt {
private introComplete = false;
private introStartedAt = performance.now();
private introCompletedAt: number | null = null;
private hasStartedDrawing = false;
private isDrawHintVisible = false;
public constructor(private readonly prompt: HTMLElement) {}
public get progress(): number {
return this.introComplete
? 1
: Math.min(1, (performance.now() - this.introStartedAt) / INTRO_TITLE_DURATION_MS);
}
public update(): void {
const now = performance.now();
if (!this.introComplete && now - this.introStartedAt > INTRO_TITLE_DURATION_MS) {
this.complete(now);
}
if (
!this.introComplete ||
this.hasStartedDrawing ||
this.introCompletedAt === null ||
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
) {
return;
}
this.showDrawHint();
}
public complete(completedAt = performance.now()): void {
if (this.introComplete) {
return;
}
this.introComplete = true;
this.introCompletedAt = completedAt;
this.hideDrawHint();
}
public markStartedDrawing(): void {
this.hasStartedDrawing = true;
this.hideDrawHint();
}
public destroy(): void {
this.hideDrawHint();
}
private showDrawHint(): void {
if (this.isDrawHintVisible) {
return;
}
this.isDrawHintVisible = true;
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
this.prompt.innerHTML = `
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
<path class="draw-hint-stroke" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
</svg>
<span class="draw-hint-text">Draw on the screen</span>
`;
}
private hideDrawHint(): void {
this.isDrawHintVisible = false;
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
this.prompt.replaceChildren();
}
}

View file

@ -0,0 +1,354 @@
import { appConfig } from '../config';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
interface IntroTitlePoint {
x: number;
y: number;
tangent: number | null;
colorIndex: number;
}
interface IntroTitleAgentOptions {
count: number;
width: number;
height: number;
}
const INTRO_TITLE = appConfig.simulation.intro.title;
export const createIntroTitleAgents = ({
count,
width,
height,
}: IntroTitleAgentOptions): Float32Array => {
if (count <= 0) {
return new Float32Array();
}
const safeWidth = Math.max(1, width);
const safeHeight = Math.max(1, height);
const points = createIntroTitlePoints(safeWidth, safeHeight);
if (points.length === 0) {
return new Float32Array();
}
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
const minSide = Math.min(safeWidth, safeHeight);
const targetJitter = Math.max(
appConfig.simulation.intro.minTargetJitterPx,
minSide * appConfig.simulation.intro.targetJitterSideRatio
);
const entryJitter = Math.max(
appConfig.simulation.intro.minEntryJitterPx,
minSide * appConfig.simulation.intro.entryJitterSideRatio
);
const titleRadius = points.reduce(
(radius, point) =>
Math.max(
radius,
Math.hypot(
point.x - safeWidth / 2,
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
)
),
0
);
const introCircleRadius = Math.min(
Math.max(
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
minSide * appConfig.simulation.intro.circleMinSideRatio
),
minSide * appConfig.simulation.intro.circleMaxSideRatio
);
for (let i = 0; i < count; i++) {
const point = points[Math.floor(Math.random() * points.length)];
const targetX = Math.max(
0,
Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter)
);
const targetY = Math.max(
0,
Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter)
);
const [startX, startY] = getIntroRadialStart(
targetX,
targetY,
safeWidth,
safeHeight,
introCircleRadius,
entryJitter
);
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
let targetAngle = point.tangent ?? approachAngle;
if (Math.cos(targetAngle - approachAngle) < 0) {
targetAngle += Math.PI;
}
const distanceFraction =
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
const base = i * AGENT_FLOAT_COUNT;
data[base] = startX;
data[base + 1] = startY;
data[base + 2] =
approachAngle +
(Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
data[base + 3] = point.colorIndex;
data[base + 4] = targetX;
data[base + 5] = targetY;
data[base + 6] = targetAngle;
data[base + 7] = Math.min(
appConfig.simulation.intro.targetDelayMax,
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier
);
}
return data;
};
const getIntroRadialStart = (
targetX: number,
targetY: number,
width: number,
height: number,
radius: number,
jitter: number
): [number, number] => {
const centerX = width / 2;
const centerY = height * appConfig.simulation.intro.verticalAnchor;
const offsetX = targetX - centerX;
const offsetY = targetY - centerY;
const length = Math.hypot(offsetX, offsetY);
const angle =
length > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2;
const directionX = Math.cos(angle);
const directionY = Math.sin(angle);
const tangentX = -directionY;
const tangentY = directionX;
const tangentJitter = (Math.random() - 0.5) * jitter;
const radialJitter =
(Math.random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
const startX =
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
const startY =
centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter;
return [
Math.max(0, Math.min(width - 1, startX)),
Math.max(0, Math.min(height - 1, startY)),
];
};
const createIntroTitlePoints = (
width: number,
height: number
): Array<IntroTitlePoint> => {
const maskCanvas = document.createElement('canvas');
maskCanvas.width = width;
maskCanvas.height = height;
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!context) {
return [];
}
const fontSize = getIntroTitleFontSize(context, width, height);
context.clearRect(0, 0, width, height);
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = '#fff';
context.strokeStyle = '#fff';
context.lineJoin = 'round';
context.lineWidth = Math.max(
appConfig.simulation.intro.titleStrokeWidthMinPx,
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
);
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
drawIntroTitleText(
context,
width / 2,
height * appConfig.simulation.intro.verticalAnchor,
letterSpacing,
'stroke'
);
drawIntroTitleText(
context,
width / 2,
height * appConfig.simulation.intro.verticalAnchor,
letterSpacing,
'fill'
);
const { data } = context.getImageData(0, 0, width, height);
const step = Math.max(
1,
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
);
const points: Array<IntroTitlePoint> = [];
const characterColorBoundaries = getIntroTitleColorBoundaries(
context,
width,
letterSpacing
);
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
const alpha = getMaskAlpha(data, width, height, x, y);
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
continue;
}
points.push({
x,
y,
tangent: estimateMaskTangent(data, width, height, x, y),
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
});
}
}
return points;
};
const getIntroTitleColorBoundaries = (
context: CanvasRenderingContext2D,
width: number,
letterSpacing: number
): [number, number] => {
const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = width / 2 - totalWidth / 2;
const [firstCutLetter, secondCutLetter] =
appConfig.simulation.intro.titleColorCutLetters;
const letterBoxes = letters.map((letter, index) => {
const letterWidth = context.measureText(letter).width;
const box = {
left: x,
right: x + letterWidth,
};
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
return box;
});
const getBoundaryBetweenLetters = (leftLetterIndex: number) =>
(letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2;
return [
getBoundaryBetweenLetters(firstCutLetter - 1),
getBoundaryBetweenLetters(secondCutLetter - 1),
];
};
const drawIntroTitleText = (
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
letterSpacing: number,
mode: 'fill' | 'stroke'
): void => {
const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = centerX - totalWidth / 2;
letters.forEach((letter, index) => {
const letterWidth = context.measureText(letter).width;
const drawX = x + letterWidth / 2;
if (mode === 'fill') {
context.fillText(letter, drawX, centerY);
} else {
context.strokeText(letter, drawX, centerY);
}
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
});
};
const measureIntroTitleText = (
context: CanvasRenderingContext2D,
letters: Array<string>,
letterSpacing: number
): number => {
const textWidth = letters.reduce(
(width, letter) => width + context.measureText(letter).width,
0
);
return textWidth + Math.max(0, letters.length - 1) * letterSpacing;
};
const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => {
if (x < boundaries[0]) {
return 0;
}
if (x < boundaries[1]) {
return 1;
}
return 2;
};
const getIntroTitleFontSize = (
context: CanvasRenderingContext2D,
width: number,
height: number
): number => {
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
let fontSize = Math.floor(
Math.min(
height * appConfig.simulation.intro.initialFontHeightRatio,
width * appConfig.simulation.intro.initialFontWidthRatio
)
);
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
const metrics = context.measureText(INTRO_TITLE);
const measuredHeight =
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
if (metrics.width <= maxWidth && measuredHeight <= maxHeight) {
return fontSize;
}
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
}
return fontSize;
};
const estimateMaskTangent = (
data: Uint8ClampedArray,
width: number,
height: number,
x: number,
y: number
): number | null => {
const gradientX =
getMaskAlpha(data, width, height, x + 1, y) -
getMaskAlpha(data, width, height, x - 1, y);
const gradientY =
getMaskAlpha(data, width, height, x, y + 1) -
getMaskAlpha(data, width, height, x, y - 1);
if (
Math.abs(gradientX) + Math.abs(gradientY) <
appConfig.simulation.intro.maskGradientThreshold
) {
return null;
}
return Math.atan2(gradientX, -gradientY);
};
const getMaskAlpha = (
data: Uint8ClampedArray,
width: number,
height: number,
x: number,
y: number
): number => {
const clampedX = Math.max(0, Math.min(width - 1, Math.round(x)));
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
return data[(clampedY * width + clampedX) * 4 + 3];
};

View file

@ -0,0 +1,248 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { activeVibe, settings } from '../settings';
import { EraserPreview } from './eraser-preview';
import { StrokeSegment } from './game-loop-types';
interface GardenPointerInputOptions {
canvas: HTMLCanvasElement;
audio: GardenAudio;
brushPipeline: BrushPipeline;
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
eraserPreview: EraserPreview;
getCanvasSize: () => vec2;
getDevicePixelRatio: () => number;
getMirrorSegmentCount: () => number;
onStartDrawing: () => void;
onEraseGestureEnded: () => void;
spawnStrokeAgents: (from: vec2, to: vec2) => void;
}
export class GardenPointerInput {
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private lastPointerPressure = 0.5;
private isErasing = false;
public constructor(private readonly options: GardenPointerInputOptions) {}
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing;
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
}
public updateEraserPreview(event?: PointerEvent): void {
this.options.eraserPreview.update(event, this.isSwipeActive);
}
public clearSwipesIfIdle(): void {
if (this.isSwipeActive) {
return;
}
this.options.brushPipeline.clearSwipes();
this.options.eraserAgentPipeline.clearSwipes();
this.options.eraserTexturePipeline.clearSwipes();
}
public scaleLastPointerPosition(scale: vec2): void {
if (this.lastPointerPosition !== null) {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
}
}
public get isSwipeActive(): boolean {
return this.activePointerId !== null;
}
public get isEraseMode(): boolean {
return this.isErasing;
}
private get canvas(): HTMLCanvasElement {
return this.options.canvas;
}
private readonly onPointerDown = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.updateEraserPreview(event);
if (this.activePointerId !== null) {
return;
}
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
this.options.audio.beginGesture();
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.options.brushPipeline.clearSwipes();
this.options.eraserAgentPipeline.clearSwipes();
this.options.eraserTexturePipeline.clearSwipes();
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.lastPointerPressure = this.getPointerPressure(event);
this.addSwipeAt(event);
};
private readonly onPointerMove = (event: PointerEvent) => {
this.updateEraserPreview(event);
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
};
private readonly onPointerUp = (event: PointerEvent) => {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event, { emitAudio: false });
this.options.audio.endGesture();
if (this.isErasing) {
this.options.onEraseGestureEnded();
}
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
this.updateEraserPreview(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.updateEraserPreview(event);
};
private readonly onPointerLeave = () => {
this.options.eraserPreview.setPointerHoveringCanvas(false);
this.updateEraserPreview();
};
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
const rect = this.canvas.getBoundingClientRect();
const devicePixelRatio = this.options.getDevicePixelRatio();
const position = vec2.fromValues(
(event.clientX - rect.left) * devicePixelRatio,
(event.clientY - rect.top) * devicePixelRatio
);
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
const elapsedSeconds = Math.max(
appConfig.deltaTime.minDeltaTimeSeconds,
(event.timeStamp - previousTimeMs) / 1000
);
const distancePixels = vec2.distance(previousPosition, position);
const velocityPixelsPerSecond = distancePixels / elapsedSeconds;
const pressure = this.getPointerPressure(event);
this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure;
const segments = this.isErasing
? [{ from: previousPosition, to: position }]
: this.getMirroredStrokeSegments(previousPosition, position);
segments.forEach((segment) => {
if (this.isErasing) {
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
} else {
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
}
});
if (!this.isErasing) {
segments.forEach((segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
});
}
if (options.emitAudio !== false) {
this.options.audio.stroke({
vibe: activeVibe,
from: previousPosition,
to: position,
canvasSize: this.options.getCanvasSize(),
colorIndex: settings.selectedColorIndex,
isErasing: this.isErasing,
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
velocityPixelsPerSecond,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
pointerType: event.pointerType,
});
}
this.lastPointerPosition = position;
this.lastPointerEventTimeMs = event.timeStamp;
}
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
const segmentCount = this.options.getMirrorSegmentCount();
if (segmentCount <= 1) {
return [{ from, to }];
}
const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
const angleStep = (Math.PI * 2) / segmentCount;
const segments: Array<StrokeSegment> = [];
for (let i = 0; i < segmentCount; i++) {
const angle = angleStep * i;
segments.push({
from: rotatePointAround(from, center, angle),
to: rotatePointAround(to, center, angle),
});
}
return segments;
}
private getPointerPressure(event: PointerEvent): number {
if (Number.isFinite(event.pressure) && event.pressure > 0) {
return Math.min(1, Math.max(0, event.pressure));
}
return event.buttons > 0 || event.type === 'pointerdown'
? gardenAudioConfig.input.pressureFallback
: 0;
}
}
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
if (angle === 0) {
return point;
}
const offsetX = point[0] - center[0];
const offsetY = point[1] - center[1];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return vec2.fromValues(
center[0] + offsetX * cos - offsetY * sin,
center[1] + offsetX * sin + offsetY * cos
);
};

View file

@ -0,0 +1,40 @@
import { activeVibe } from '../settings';
import { hexToRgb } from '../vibes';
import { RenderInputs } from './game-loop-types';
export class RenderInputCache {
private cachedVibeId: string | null = null;
private cachedRenderInputs?: RenderInputs;
private previousAccentColor = '';
public invalidate(): void {
this.cachedVibeId = null;
this.cachedRenderInputs = undefined;
}
public get(): RenderInputs {
if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) {
return this.cachedRenderInputs;
}
this.cachedVibeId = activeVibe.id;
this.cachedRenderInputs = {
channelColors: activeVibe.colors.map(hexToRgb),
backgroundColor: hexToRgb(activeVibe.backgroundColor),
};
return this.cachedRenderInputs;
}
public updateAccentColor(color: [number, number, number]): void {
const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round(
color[1] * 255
)},${Math.round(color[2] * 255)})`;
if (this.previousAccentColor === accentColor) {
return;
}
this.previousAccentColor = accentColor;
document.documentElement.style.setProperty('--accent-color', accentColor);
}
}

View file

@ -0,0 +1,99 @@
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { SimulationTextures } from './simulation-textures';
export interface SimulationFramePipelines {
copyPipeline: CopyPipeline;
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
diffusionPipeline: DiffusionPipeline;
brushEffectDiffusionPipeline: DiffusionPipeline;
renderPipeline: RenderPipeline;
}
export class SimulationFrameRenderer {
public constructor(
private readonly device: GPUDevice,
private readonly textures: SimulationTextures,
private readonly pipelines: SimulationFramePipelines
) {}
public execute(renderSpeed: number, isErasing: boolean): void {
for (let i = 0; i < renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
this.pipelines.copyPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView()
);
if (isErasing) {
this.pipelines.eraserTexturePipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView()
);
this.pipelines.eraserTexturePipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView()
);
this.pipelines.eraserTexturePipeline.execute(
commandEncoder,
this.textures.trailMapB.getTextureView()
);
this.pipelines.eraserAgentPipeline.execute(commandEncoder);
} else {
this.pipelines.brushPipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView()
);
this.pipelines.brushPipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView()
);
}
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView(),
this.textures.influenceMapA.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getTextureView()
);
this.pipelines.renderPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.sourceMapA.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView()
);
this.pipelines.brushEffectDiffusionPipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView(),
this.textures.influenceMapB.getTextureView()
);
this.device.queue.submit([commandEncoder.finish()]);
this.textures.swapSourceMaps();
this.textures.swapInfluenceMaps();
}
}
public clearSwipes(): void {
this.pipelines.brushPipeline.clearSwipes();
this.pipelines.eraserAgentPipeline.clearSwipes();
this.pipelines.eraserTexturePipeline.clearSwipes();
}
}

View file

@ -0,0 +1,58 @@
import { vec2 } from 'gl-matrix';
import { ResizableTexture } from '../utils/graphics/resizable-texture';
export class SimulationTextures {
public readonly trailMapA: ResizableTexture;
public readonly trailMapB: ResizableTexture;
public sourceMapA: ResizableTexture;
public sourceMapB: ResizableTexture;
public influenceMapA: ResizableTexture;
public influenceMapB: ResizableTexture;
public constructor(
private readonly device: GPUDevice,
canvasSize: vec2
) {
this.trailMapA = new ResizableTexture(this.device, canvasSize);
this.trailMapB = new ResizableTexture(this.device, canvasSize);
this.sourceMapA = new ResizableTexture(this.device, canvasSize);
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
}
public resizeTo(nextSize: vec2): vec2 | null {
const previousSize = this.trailMapA.getSize();
if (vec2.equals(previousSize, nextSize)) {
return null;
}
const scale = vec2.div(vec2.create(), nextSize, previousSize);
this.trailMapA.resize(nextSize);
this.trailMapB.resize(nextSize);
this.sourceMapA.resize(nextSize);
this.sourceMapB.resize(nextSize);
this.influenceMapA.resize(nextSize);
this.influenceMapB.resize(nextSize);
return scale;
}
public swapSourceMaps(): void {
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
}
public swapInfluenceMaps(): void {
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
}
public destroy(): void {
this.trailMapA.destroy();
this.trailMapB.destroy();
this.sourceMapA.destroy();
this.sourceMapB.destroy();
this.influenceMapA.destroy();
this.influenceMapB.destroy();
}
}

View file

@ -1,26 +1,71 @@
import { isProduction } from './constants';
import GameLoop from './game-loop/game-loop';
import { GameRules } from './game-loop/game-rules';
import './index.scss';
import { appConfig } from './config';
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 { setUpSettingsPage } from './page/set-up-settings-page';
import { SettingsSlider } from './page/settings-slider';
import { resetSettings } from './settings';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { ErrorHandler, Severity } from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { VIBE_PRESETS } from './vibes';
const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default;
return Math.min(
appConfig.toolbar.eraser.max,
Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue))
);
};
const getEraserSizeRatio = (size: number): number =>
(size - appConfig.toolbar.eraser.min) /
(appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min);
const clampMirrorSegmentCount = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default;
return Math.min(
appConfig.toolbar.mirror.max,
Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue))
);
};
const getMirrorSegmentRatio = (count: number): number =>
(count - appConfig.toolbar.mirror.min) /
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
const formatMirrorSegmentCount = (count: number): string =>
count === appConfig.toolbar.mirror.default
? 'Mirror off'
: `${count} ${appConfig.toolbar.mirror.names[count] ?? 'slices'}`;
const renderRuntimeMessage = (
container: HTMLElement,
error: Parameters<Parameters<typeof ErrorHandler.addOnErrorListener>[0]>[0]
) => {
const message = document.createElement('pre');
message.className = error.severity;
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
message.setAttribute(
'aria-live',
error.severity === Severity.ERROR ? 'assertive' : 'polite'
);
container.append(message);
if (error.severity === Severity.ERROR) {
message.tabIndex = -1;
message.focus({ preventScroll: true });
}
};
const elements = {
aside: document.querySelector('aside') as HTMLDivElement,
infoButton: document.querySelector('button.info') as HTMLButtonElement,
infoElement: document.querySelector('.info-page') as HTMLDivElement,
settingsPage: document.querySelector('.settings-page') as HTMLDivElement,
settingsContent: document.querySelector('.settings-content') as HTMLDivElement,
applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement,
minimizeFullScreenButton: document.querySelector(
'button.minimize-full-screen'
) as HTMLButtonElement,
@ -28,9 +73,107 @@ const elements = {
'button.maximize-full-screen'
) as HTMLButtonElement,
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
soundButton: document.querySelector('button.sound') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
eraserPreview: document.querySelector('.eraser-preview') as HTMLDivElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
previousVibe: document.querySelector('.previous-vibe') as HTMLButtonElement,
nextVibe: document.querySelector('.next-vibe') as HTMLButtonElement,
swatches: Array.from(
document.querySelectorAll('.color-swatch')
) as Array<HTMLButtonElement>,
eraserSizeControl: document.querySelector('.eraser-size-control') as HTMLLabelElement,
eraserSizeSlider: document.querySelector('.eraser-size-slider') as HTMLInputElement,
mirrorSegmentControl: document.querySelector(
'.mirror-segment-control'
) as HTMLLabelElement,
mirrorSegmentSlider: document.querySelector(
'.mirror-segment-slider'
) as HTMLInputElement,
export4k: document.querySelector('.export-4k') as HTMLButtonElement,
exportStatus: document.querySelector('.export-status') as HTMLSpanElement,
prompt: document.querySelector('.garden-prompt') as HTMLDivElement,
};
let isAudioMuted = localStorage.getItem(appConfig.storage.audioMutedKey) === '1';
const renderAudioUi = (game: GameLoop | null) => {
elements.soundButton.classList.toggle('muted', isAudioMuted);
elements.soundButton.setAttribute('aria-pressed', String(isAudioMuted));
elements.soundButton.setAttribute(
'aria-label',
isAudioMuted ? 'Unmute audio' : 'Mute audio'
);
elements.soundButton.title = isAudioMuted ? 'Unmute audio' : 'Mute audio';
game?.setAudioMuted(isAudioMuted);
};
const renderPaletteUi = (game: GameLoop | null) => {
const isErasing = elements.eraserSizeControl.dataset.active === '1';
elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = activeVibe.colors[index];
swatch.classList.toggle(
'active',
settings.selectedColorIndex === index && !isErasing
);
});
elements.eraserSizeControl.classList.toggle('active', isErasing);
game?.setEraseMode(isErasing);
document.documentElement.style.setProperty(
'--garden-background',
activeVibe.backgroundColor
);
game?.onVibeChanged();
};
const renderEraserSizeUi = (game: GameLoop | null) => {
const size = clampEraserSize(settings.eraserSize);
if (settings.eraserSize !== size) {
settings.eraserSize = size;
}
elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString();
elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString();
elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString();
elements.eraserSizeSlider.value = size.toString();
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size);
const scale =
appConfig.toolbar.eraser.controlScaleMin +
(appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) *
ratio;
elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`);
elements.eraserSizeControl.style.setProperty(
'--eraser-control-scale',
scale.toFixed(3)
);
game?.updateEraserPreview();
};
const renderMirrorSegmentUi = () => {
const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
if (settings.mirrorSegmentCount !== count) {
settings.mirrorSegmentCount = count;
}
elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString();
elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString();
elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString();
elements.mirrorSegmentSlider.value = count.toString();
const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count);
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
elements.mirrorSegmentControl.title = label;
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`);
elements.mirrorSegmentControl.style.setProperty(
'--mirror-angle',
`${(360 / count).toFixed(3)}deg`
);
};
const main = async () => {
@ -38,36 +181,48 @@ const main = async () => {
let shouldStop = false;
let game: GameLoop | null = null;
elements.errorContainer.setAttribute('aria-live', 'assertive');
ErrorHandler.addOnErrorListener((error, _metadata) => {
elements.errorContainer.innerHTML += `
<pre class="${error.severity}">${error.message}</div>
`;
game?.destroy();
shouldStop = true;
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
game?.destroy();
shouldStop = true;
}
});
const syncRuntimeUi = () => {
renderEraserSizeUi(game);
renderMirrorSegmentUi();
renderPaletteUi(game);
};
const infoPageHandler = new CollapsiblePanelAnimator(
elements.infoButton,
elements.infoElement,
elements.aside
);
const settingsPageHandler = new CollapsiblePanelAnimator(
elements.settingsButton,
elements.settingsPage,
elements.aside
);
settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
if (isProduction) {
infoPageHandler.open();
}
const configPane = new ConfigPane({
settingsButton: elements.settingsButton,
onConfigChange: syncRuntimeUi,
onRuntimeChange: syncRuntimeUi,
onRuntimeReset: () => {
resetSettings();
syncRuntimeUi();
},
onRestart: () => game?.destroy(),
onVibeChange: (vibeId) => {
applyVibeSettings(vibeId);
syncRuntimeUi();
game?.playVibeChangeAudio(false);
},
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
new MenuHider(
elements.aside,
() =>
FullScreenHandler.isInFullScreenMode() &&
!settingsPageHandler.isOpen &&
!configPane.isOpen &&
!infoPageHandler.isOpen
);
new FullScreenHandler(
@ -76,31 +231,113 @@ const main = async () => {
document.body
);
const fontsReady = document.fonts.ready.catch(() => undefined);
const gpu = await initializeGpu();
await fontsReady;
elements.restartButton.addEventListener('click', () => game?.destroy());
const deltaTimeCalculator = new DeltaTimeCalculator();
let sliders: Array<SettingsSlider<any>> = [];
elements.applyDefaults.addEventListener('click', () => {
resetSettings();
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
elements.soundButton.addEventListener('click', (event) => {
isAudioMuted = !isAudioMuted;
localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(event.isTrusted);
}
});
while (!shouldStop) {
const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
const deltaTimeCalculator = new DeltaTimeCalculator();
if (sliders.length === 0) {
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
elements.previousVibe.addEventListener('click', (event) => {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const vibe =
VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length];
applyVibeSettings(vibe.id);
configPane.refresh();
syncRuntimeUi();
game?.playVibeChangeAudio(event.isTrusted);
});
elements.nextVibe.addEventListener('click', (event) => {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
applyVibeSettings(vibe.id);
configPane.refresh();
syncRuntimeUi();
game?.playVibeChangeAudio(event.isTrusted);
});
elements.swatches.forEach((swatch, index) => {
swatch.addEventListener('click', () => {
settings.selectedColorIndex = index;
elements.eraserSizeControl.dataset.active = '0';
game?.setEraseMode(false);
renderPaletteUi(game);
configPane.refresh();
});
});
const activateEraser = () => {
elements.eraserSizeControl.dataset.active = '1';
renderPaletteUi(game);
};
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
elements.eraserSizeControl.addEventListener('click', activateEraser);
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
elements.eraserSizeSlider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
elements.eraserSizeControl.dataset.active = '1';
renderEraserSizeUi(game);
renderPaletteUi(game);
configPane.refresh();
});
elements.mirrorSegmentSlider.addEventListener('input', () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value)
);
elements.eraserSizeControl.dataset.active = '0';
renderMirrorSegmentUi();
renderPaletteUi(game);
configPane.refresh();
});
elements.export4k.addEventListener('click', async () => {
if (!game || elements.export4k.disabled) {
return;
}
elements.export4k.disabled = true;
try {
await game.export4K();
} catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING });
} finally {
elements.export4k.disabled = false;
}
});
renderPaletteUi(game);
renderEraserSizeUi(game);
renderMirrorSegmentUi();
renderAudioUi(game);
while (!shouldStop) {
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
prompt: elements.prompt,
eraserPreview: elements.eraserPreview,
exportStatus: elements.exportStatus,
});
renderPaletteUi(game);
renderEraserSizeUi(game);
renderMirrorSegmentUi();
renderAudioUi(game);
await game.start();
}
} catch (e) {
const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
ErrorHandler.addError(Severity.ERROR, message);
ErrorHandler.addException(e);
console.error(e);
}
};

View file

@ -1,5 +1,8 @@
export class CollapsiblePanelAnimator {
private static nextPanelId = 0;
private _isOpen = false;
private focusBeforeOpen: HTMLElement | null = null;
public onOpen: () => unknown = () => {};
public onClose: () => unknown = () => {};
@ -9,25 +12,64 @@ export class CollapsiblePanelAnimator {
private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement
) {
const panelId =
collapsibleContent.id ||
`collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
collapsibleContent.id = panelId;
toggleButton.setAttribute('aria-controls', panelId);
if (!collapsibleContent.hasAttribute('role')) {
collapsibleContent.setAttribute('role', 'region');
}
if (!collapsibleContent.hasAttribute('aria-label')) {
const label =
toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
}
if (!collapsibleContent.hasAttribute('tabindex')) {
collapsibleContent.tabIndex = -1;
}
toggleButton.addEventListener('click', this.toggle.bind(this));
window.addEventListener(
'click',
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
);
window.addEventListener('keydown', (event) => {
if (this._isOpen && event.key === 'Escape') {
event.preventDefault();
this.close();
}
});
this.syncAccessibility();
}
public open() {
if (this._isOpen) {
return;
}
this.focusBeforeOpen =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true;
this.collapsibleContent.classList.remove('hidden');
this.toggleButton.classList.add('active');
this.syncAccessibility();
this.onOpen();
this.focusPanel();
}
public close() {
if (!this._isOpen) {
return;
}
const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false;
this.collapsibleContent.classList.add('hidden');
this.toggleButton.classList.remove('active');
this.syncAccessibility();
this.onClose();
if (focusWasInside) {
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
}
}
public toggle() {
@ -41,4 +83,20 @@ export class CollapsiblePanelAnimator {
public get isOpen() {
return this._isOpen;
}
private syncAccessibility() {
this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
this.toggleButton.classList.toggle('active', this._isOpen);
this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
this.collapsibleContent.inert = !this._isOpen;
}
private focusPanel() {
requestAnimationFrame(() => {
if (this._isOpen) {
this.collapsibleContent.focus({ preventScroll: true });
}
});
}
}

268
src/page/config-pane.ts Normal file
View file

@ -0,0 +1,268 @@
import { Pane, type BindingParams, type FolderApi } from 'tweakpane';
import {
appConfig,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
import { activeVibe, settings } from '../settings';
import { VIBE_PRESETS } from '../vibes';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
interface ConfigPaneOptions {
onConfigChange: () => void;
onRestart: () => void;
onRuntimeChange: () => void;
onRuntimeReset: () => void;
onVibeChange: (vibeId: string) => void;
settingsButton: HTMLButtonElement;
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
['boolean', 'number', 'string'].includes(typeof value);
const isColorString = (value: unknown): value is string =>
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(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 => {
const finiteValue = Number.isFinite(value) ? value : config.min;
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
};
const getNumberBindingParams = (
key: keyof GardenRuntimeSettings & string,
config: NumberControlConfig
): BindingParams => ({
label: config.label ?? toLabel(key),
min: config.min,
max: config.max,
step: config.step,
});
export class ConfigPane {
private readonly container: HTMLDivElement;
private readonly pane: Pane;
private readonly state = {
activeVibeId: activeVibe.id,
};
public constructor(private readonly options: ConfigPaneOptions) {
this.container = document.createElement('div');
this.container.className = 'config-pane-container';
Object.assign(this.container.style, {
boxSizing: 'border-box',
maxHeight: 'calc(100vh - 24px)',
pointerEvents: 'none',
position: 'fixed',
right: 'max(12px, env(safe-area-inset-right, 0px))',
top: 'max(12px, env(safe-area-inset-top, 0px))',
width:
'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
zIndex: '20',
});
document.body.appendChild(this.container);
this.pane = new Pane({
container: this.container,
title: appConfig.tuningPane.title,
expanded: true,
});
this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.element.classList.add('config-pane');
this.pane.element.style.boxSizing = 'border-box';
this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
this.pane.element.style.overflowY = 'auto';
this.pane.element.style.pointerEvents = 'auto';
this.pane.element.style.width = '100%';
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.syncButton();
}
public get isOpen(): boolean {
return !this.pane.hidden;
}
public refresh(): void {
this.state.activeVibeId = activeVibe.id;
this.pane.refresh();
this.syncButton();
}
private readonly toggle = () => {
this.pane.hidden = !this.pane.hidden;
this.syncButton();
};
private setHidden(isHidden: boolean): void {
this.pane.hidden = isHidden;
this.syncButton();
}
private setUpRuntimeTab(container: PaneContainer): void {
container
.addBinding(this.state, 'activeVibeId', {
label: 'active vibe',
options: Object.fromEntries(
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
) as Record<string, string>,
})
.on('change', ({ value }) => {
this.options.onVibeChange(value);
this.refresh();
});
container
.addButton({
title: 'Reset runtime settings',
})
.on('click', () => {
this.options.onRuntimeReset();
this.refresh();
});
container
.addButton({
title: 'Restart simulation',
})
.on('click', () => this.options.onRestart());
const folders = new Map<string, PaneContainer>();
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
const folder =
folders.get(config.folder) ??
container.addFolder({
title: config.folder,
expanded: config.folder !== 'Runtime',
});
folders.set(config.folder, folder);
const settingKey = key as keyof GardenRuntimeSettings & string;
settings[settingKey] = normalizeNumber(settings[settingKey], config);
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();
});
});
}
private setUpConfigTab(container: PaneContainer): void {
this.addObjectBindings(
container,
appConfig as unknown as Record<string, unknown>,
[]
);
}
private addObjectBindings(
container: PaneContainer,
source: Record<string, unknown>,
path: Array<string>
): void {
Object.entries(source).forEach(([key, value]) => {
if (isBindablePrimitive(value)) {
this.addPrimitiveBinding(container, source, key, value);
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<string, unknown>,
`${index}`,
item
);
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 addPrimitiveBinding(
container: PaneContainer,
source: Record<string, unknown>,
key: string,
value: boolean | number | string
): void {
const params: BindingParams = {
label: toLabel(key),
...(isColorString(value) ? { color: { type: 'int' } } : {}),
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
};
container
.addBinding(source, key, params)
.on('change', () => this.options.onConfigChange());
}
private syncButton(): void {
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';
}
public close(): void {
this.setHidden(true);
}
}

View file

@ -1,17 +1,68 @@
export class MenuHider {
private static readonly DEFAULT_TIME_TO_LIVE = 3500;
private static readonly INTERVAL = 50;
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
import { appConfig } from '../config';
public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
export class MenuHider {
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
private static readonly BOTTOM_REVEAL_DISTANCE =
appConfig.menuHider.bottomRevealDistancePx;
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
private isHidden = false;
public constructor(
private readonly element: HTMLElement,
private readonly shouldBeHidden: () => boolean
) {
setInterval(() => {
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
this.updateVisibility();
}, MenuHider.INTERVAL);
element.addEventListener(
'mouseover',
() => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
);
element.addEventListener('mouseover', this.wakeUp);
element.addEventListener('focusin', this.wakeUp);
element.addEventListener('pointerdown', this.wakeUp);
window.addEventListener('pointermove', this.wakeUpNearViewportBottom, {
passive: true,
});
window.addEventListener('pointerdown', this.wakeUp, {
capture: true,
passive: true,
});
window.addEventListener('touchstart', this.wakeUp, {
capture: true,
passive: true,
});
window.addEventListener('keydown', this.wakeUp, { capture: true });
window.addEventListener('focusin', this.wakeUp, { capture: true });
this.updateVisibility();
}
private readonly wakeUp = () => {
this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
this.updateVisibility();
};
private readonly wakeUpNearViewportBottom = (event: PointerEvent) => {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE;
if (event.clientY >= revealStart) {
this.wakeUp();
}
};
private updateVisibility() {
const focusWithin = this.element.contains(document.activeElement);
const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin;
if (this.isHidden === shouldHide) {
return;
}
this.isHidden = shouldHide;
this.element.classList.toggle('menu-hidden', shouldHide);
this.element.style.opacity = shouldHide ? '0' : '1';
this.element.setAttribute('aria-hidden', String(shouldHide));
this.element.inert = shouldHide;
}
}

View file

@ -1,129 +0,0 @@
import { isProduction } from '../constants';
import { settings } from '../settings';
import { SettingsSlider, ValueScaling } from './settings-slider';
export const setUpSettingsPage = (
settingsPage: HTMLDivElement,
maxAgentCount: number
): Array<SettingsSlider<any>> => {
const params = new URLSearchParams(window.location.search);
const shouldShowAdvancedSettings = !isProduction && params.get('dev') !== '0';
const sliders: Array<SettingsSlider<any>> = [
new SettingsSlider(settings, 'brushEffectDuration', {
min: 0.5,
max: 20,
unit: 's',
scaling: ValueScaling.Quadratic,
}),
...(shouldShowAdvancedSettings
? [
new SettingsSlider(settings, 'agentBudgetMax', {
min: 1_000,
max: maxAgentCount,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'spawnPerPixel', {
min: 0.01,
max: 1,
}),
new SettingsSlider(settings, 'moveSpeed', {
min: 10,
max: 500,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'turnSpeed', {
min: 1,
max: 200,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'sensorOffsetAngle', {
min: 0,
max: 90,
step: 1,
}),
new SettingsSlider(settings, 'sensorOffsetDistance', {
min: 0,
max: 200,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'turnWhenLost', {
min: 0,
max: 1,
}),
new SettingsSlider(settings, 'individualTrailWeight', {
min: 0,
max: 1,
}),
new SettingsSlider(settings, 'diffusionRateTrails', {
min: 0,
max: 2,
}),
new SettingsSlider(settings, 'decayRateTrails', {
min: 0.1,
max: 5000,
scaling: ValueScaling.Quadratic,
}),
new SettingsSlider(settings, 'diffusionRateBrush', {
min: 0.001,
max: 1,
}),
new SettingsSlider(settings, 'decayRateBrush', {
min: 0.1,
max: 100,
}),
new SettingsSlider(settings, 'anisotropy', {
min: 0,
max: 1,
}),
new SettingsSlider(settings, 'brushSize', {
min: 1,
max: 60,
}),
new SettingsSlider(settings, 'clarity', {
min: 0.00001,
max: 1,
}),
]
: []),
...(shouldShowAdvancedSettings
? [
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
]
: []),
];
const sliderContainerElement = document.createElement('div');
sliders.forEach((slider) => {
sliderContainerElement.appendChild(slider.element);
});
settingsPage.appendChild(sliderContainerElement);
return sliders;
};

View file

@ -1,146 +0,0 @@
import { formatNumber } from '../utils/format-number';
export enum ValueScaling {
Linear,
Quadratic,
Logarithmic,
}
export interface SliderConfiguration {
min: number;
max: number;
unit?: string;
step?: number;
onChangeCallback?: (value: number) => unknown;
scaling: ValueScaling;
rounding: (value: number) => number;
}
export class SettingsSlider<T extends Record<string, number>> {
private static readonly DEFAULT_STEP_COUNT = 20000;
private readonly slider: HTMLInputElement;
private readonly valueDisplay: HTMLSpanElement;
private readonly sliderWrapper: HTMLDivElement;
private readonly config: SliderConfiguration = {
min: 0,
max: 1,
scaling: ValueScaling.Linear,
rounding: (value) => value,
};
public constructor(
private readonly settings: T,
private readonly settingName: keyof T & string,
config: Partial<SliderConfiguration> = {}
) {
this.slider = SettingsSlider.createSlider();
this.valueDisplay = SettingsSlider.createValueDisplay();
this.sliderWrapper = SettingsSlider.createSliderWrapper(
this.settingName,
this.slider,
this.valueDisplay
);
this.slider.addEventListener('input', this.onChange.bind(this));
this.updateConfig(config);
}
private static createSlider() {
const input = document.createElement('input');
input.type = 'range';
return input;
}
private static createValueDisplay() {
return document.createElement('span');
}
private static createSliderWrapper(
name: string,
slider: HTMLInputElement,
valueDisplay: HTMLSpanElement
) {
const wrapper = document.createElement('div');
wrapper.classList.add('slider');
const label = document.createElement('label');
const title = document.createElement('p');
title.innerText = SettingsSlider.formatLabel(name);
title.appendChild(valueDisplay);
label.appendChild(title);
label.appendChild(slider);
wrapper.appendChild(label);
return wrapper;
}
private static formatLabel(value: string): string {
const formatted = value.replace(/([A-Z])/g, ' $1');
return (
formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1).toLocaleLowerCase()
);
}
private onChange() {
this.settings[this.settingName] = this.config.rounding(
this.inverseScaling(Number(this.slider.value))
) as any;
this.config.onChangeCallback?.(this.settings[this.settingName]);
this.valueDisplay.innerText = formatNumber(
this.settings[this.settingName],
this.config.unit
);
}
public updateSliderValueBasedOnSource() {
this.slider.value = this.scaling(this.settings[this.settingName]).toString();
this.onChange();
}
public updateConfig(config: Partial<SliderConfiguration>) {
Object.assign(this.config, config);
if (this.config.step === undefined) {
this.config.step =
(this.scaling(this.config.max) - this.scaling(this.config.min)) /
SettingsSlider.DEFAULT_STEP_COUNT;
}
this.slider.min = this.scaling(this.config.min).toString();
this.slider.max = this.scaling(this.config.max).toString();
this.slider.step = this.config.step.toString();
this.slider.value = this.scaling(this.settings[this.settingName]).toString();
this.onChange();
}
public get element(): HTMLElement {
return this.sliderWrapper;
}
private get scaling(): (value: number) => number {
switch (this.config.scaling) {
case ValueScaling.Linear:
return (value) => value;
case ValueScaling.Quadratic:
return (value) => Math.sqrt(value);
case ValueScaling.Logarithmic:
return (value) => Math.log10(value);
}
}
private get inverseScaling(): (value: number) => number {
switch (this.config.scaling) {
case ValueScaling.Linear:
return (value) => value;
case ValueScaling.Quadratic:
return (value) => Math.pow(value, 2);
case ValueScaling.Logarithmic:
return (value) => Math.pow(10, value);
}
}
}

View file

@ -0,0 +1,36 @@
struct Settings {
agentCount: u32,
padding0: u32,
padding1: u32,
padding2: u32,
};
struct Counters {
aliveAgentCount: atomic<u32>,
padding0: atomic<u32>,
padding1: atomic<u32>,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var<storage, read_write> counters: Counters;
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if id >= settings.agentCount {
return;
}
let agent = agents[id];
if agent.colorIndex < 0.0 {
return;
}
let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1);
compactedAgents[compactedIndex] = agent;
}

View file

@ -5,8 +5,8 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings;
struct Counters {
evenGenerationAlive: atomic<u32>,
oddGenerationAlive: atomic<u32>,
redAgentsAlive: atomic<u32>,
greenAgentsAlive: atomic<u32>,
};
@group(1) @binding(2) var<storage, read_write> counters: Counters;
@ -23,9 +23,13 @@ fn main(
return;
}
if agents[id].generation % 2 == 0 {
atomicAdd(&counters.evenGenerationAlive, 1);
if agents[id].colorIndex < 0.0 {
return;
}
if agents[id].colorIndex < 0.5 {
atomicAdd(&counters.redAgentsAlive, 1);
} else {
atomicAdd(&counters.oddGenerationAlive, 1);
atomicAdd(&counters.greenAgentsAlive, 1);
}
}

View file

@ -30,5 +30,8 @@ fn main(
randomPosition.xz * state.size,
random.r * 3.14 * 2,
0,
vec2<f32>(-1.0, -1.0),
0.0,
0.0,
);
}

View file

@ -1,27 +1,42 @@
import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../../utils/graphics/smart-compile';
import { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
import { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 1;
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 3;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly compactionBindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer;
private readonly bindGroup: GPUBindGroup;
private readonly compactionBindGroup: GPUBindGroup;
private readonly firstGenerationPipeline: GPUComputePipeline;
private readonly countingPipeline: GPUComputePipeline;
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
public readonly agentsBuffer: GPUBuffer;
private readonly compactedAgentsBuffer: GPUBuffer;
public readonly countersBuffer: GPUBuffer;
public readonly countersStagingBuffer: GPUBuffer;
private readonly counterClearValues = new Uint32Array(
AgentGenerationPipeline.COUNTER_COUNT
);
private readonly agentCountUniformValues = new Uint32Array(
AgentGenerationPipeline.UNIFORM_COUNT
);
public constructor(
private readonly device: GPUDevice,
@ -54,9 +69,47 @@ export class AgentGenerationPipeline {
],
});
this.compactionBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
],
});
this.agentsBuffer = this.device.createBuffer({
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.compactedAgentsBuffer = this.device.createBuffer({
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
this.countersBuffer = this.device.createBuffer({
@ -98,6 +151,36 @@ export class AgentGenerationPipeline {
],
});
this.compactionBindGroup = this.device.createBindGroup({
layout: this.compactionBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
{
binding: 3,
resource: {
buffer: this.compactedAgentsBuffer,
},
},
],
});
this.firstGenerationPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
@ -122,16 +205,79 @@ export class AgentGenerationPipeline {
entryPoint: 'main',
},
});
this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, resizeShader),
entryPoint: 'main',
},
});
this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
compactionShader
),
entryPoint: 'main',
},
});
}
public get maxAgentCount(): number {
return Math.min(
this.maxAgentCountUpperLimit,
Number.isFinite(this.maxAgentCountUpperLimit)
? this.maxAgentCountUpperLimit
: Number.POSITIVE_INFINITY,
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
this.device.limits.maxComputeWorkgroupsPerDimension ** 3
);
}
public writeAgents(agentOffset: number, data: Float32Array): void {
this.device.queue.writeBuffer(
this.agentsBuffer,
agentOffset * AGENT_SIZE_IN_BYTES,
data
);
}
public resizeAgents(agentCount: number, scale: vec2): void {
if (agentCount <= 0 || vec2.equals(scale, vec2.fromValues(1, 1))) {
return;
}
this.device.queue.writeBuffer(
this.uniforms,
0,
new Float32Array([scale[0], scale[1], agentCount, 0])
);
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
this.commonState.execute(passEncoder);
passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]);
}
public spawnFirstGeneration(): void {
const commandEncoder = this.device.createCommandEncoder();
@ -152,8 +298,11 @@ export class AgentGenerationPipeline {
}
public async countAgents(agentCount: number): Promise<GenerationCounts> {
this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0]));
this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount]));
this.counterClearValues.fill(0);
this.agentCountUniformValues.fill(0);
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();
@ -190,10 +339,62 @@ export class AgentGenerationPipeline {
};
}
public async compactAgents(agentCount: number): Promise<number> {
if (agentCount <= 0) {
return 0;
}
this.counterClearValues.fill(0);
this.agentCountUniformValues.fill(0);
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();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.compactionBindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
commandEncoder.copyBufferToBuffer(
this.compactedAgentsBuffer,
0,
this.agentsBuffer,
0,
agentCount * AGENT_SIZE_IN_BYTES
);
commandEncoder.copyBufferToBuffer(
this.countersBuffer,
0,
this.countersStagingBuffer,
0,
Uint32Array.BYTES_PER_ELEMENT
);
this.device.queue.submit([commandEncoder.finish()]);
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
const compactedCount = new Uint32Array(
this.countersStagingBuffer.getMappedRange().slice(0, Uint32Array.BYTES_PER_ELEMENT)
)[0];
this.countersStagingBuffer.unmap();
return compactedCount;
}
public destroy() {
this.uniforms.destroy();
this.countersBuffer.destroy();
this.countersStagingBuffer.destroy();
this.compactedAgentsBuffer.destroy();
this.agentsBuffer.destroy();
}
}

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
const wgslFloatCountByType: Record<string, number> = {
f32: 1,
'vec2<f32>': 2,
};
const getAgentStructFields = () => {
const match = /struct Agent\s*\{(?<body>[\s\S]*?)\n\}/.exec(agentSchema);
if (!match?.groups?.body) {
throw new Error('Agent struct was not found in agent-schema.wgsl');
}
return match.groups.body
.split('\n')
.map((line) => line.trim().replace(/,$/, ''))
.filter(Boolean)
.map((line) => {
const fieldMatch = /^(?<name>\w+):\s*(?<type>[^,]+)$/.exec(line);
if (!fieldMatch?.groups) {
throw new Error(`Unsupported Agent field syntax: ${line}`);
}
return {
name: fieldMatch.groups.name,
type: fieldMatch.groups.type,
};
});
};
describe('Agent TS/WGSL contract', () => {
it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => {
const fields = getAgentStructFields();
const wgslFloatCount = fields.reduce((sum, field) => {
const count = wgslFloatCountByType[field.type];
if (!count) {
throw new Error(`Unsupported WGSL Agent field type: ${field.type}`);
}
return sum + count;
}, 0);
expect(fields.map((field) => field.name)).toEqual([
'position',
'angle',
'colorIndex',
'targetPosition',
'targetAngle',
'introDelay',
]);
expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT);
expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT);
});
it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
[firstGenerationShader, countingShader, resizeShader, compactionShader].forEach(
(shader) => {
expect(shader).toMatch(/@workgroup_size\(64\)/);
}
);
expect(agentSchema).toContain('workgroup_count.x * 64');
expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64');
expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
expect(compactionShader).toContain('if id >= settings.agentCount');
});
});

View file

@ -3,7 +3,11 @@ import { vec2 } from 'gl-matrix';
export interface Agent {
position: vec2;
angle: number;
generation: number;
colorIndex: number;
targetPosition: vec2;
targetAngle: number;
introDelay: number;
}
export const AGENT_SIZE_IN_BYTES = 4 * Float32Array.BYTES_PER_ELEMENT;
export const AGENT_FLOAT_COUNT = 8;
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;

View file

@ -1,5 +1,7 @@
import { vec2 } from 'gl-matrix';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@ -9,14 +11,19 @@ import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 19;
private static readonly UNIFORM_COUNT = 8;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
private previousTrailMapOut?: GPUTextureView;
private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
AgentPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByTexture = new WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>();
private agentCount = 0;
@ -45,115 +52,108 @@ export class AgentPipeline {
public setParameters({
deltaTime,
center,
radius,
brushTrailWeight,
moveSpeed,
turnSpeed,
sensorOffsetAngle,
sensorOffsetDistance,
nextGenerationSensorOffsetDistance,
currentGenerationAggression,
nextGenerationAggression,
nextGenerationSpeed,
isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
infectionProbability,
agentCount,
introProgress,
}: AgentSettings & {
deltaTime: number;
currentGenerationAggression: number;
nextGenerationAggression: number;
nextGenerationSensorOffsetDistance: number;
nextGenerationSpeed: number;
isNextGenerationOdd: number;
center: vec2;
radius: number;
infectionProbability: number;
agentCount: number;
introProgress?: number;
}) {
this.agentCount = agentCount;
this.device.queue.writeBuffer(
this.uniformValues[0] = moveSpeed * deltaTime;
this.uniformValues[1] = turnSpeed * deltaTime;
this.uniformValues[2] = (sensorOffsetAngle * Math.PI) / 180;
this.uniformValues[3] = sensorOffsetDistance;
this.uniformValues[4] = turnWhenLost;
this.uniformValues[5] = individualTrailWeight;
this.uniformValues[6] = agentCount;
this.uniformValues[7] = introProgress ?? 1;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([
...center,
radius,
brushTrailWeight,
moveSpeed * deltaTime,
turnSpeed * deltaTime,
(sensorOffsetAngle * Math.PI) / 180,
sensorOffsetDistance,
currentGenerationAggression,
nextGenerationAggression,
nextGenerationSensorOffsetDistance,
nextGenerationSpeed * deltaTime,
isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
infectionProbability,
agentCount,
])
this.uniformValues,
this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
trailMapOut: GPUTextureView,
sourceMap: GPUTextureView
) {
this.ensureBindGroupExists(trailMapIn, trailMapOut);
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
}
private ensureBindGroupExists(trailMapIn: GPUTextureView, trailMapOut: GPUTextureView) {
if (
this.previousTrailMapIn !== trailMapIn ||
this.previousTrailMapOut !== trailMapOut
) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: trailMapIn,
},
{
binding: 3,
resource: trailMapOut,
},
],
});
this.previousTrailMapIn = trailMapIn;
this.previousTrailMapOut = trailMapOut;
private getBindGroup(
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
sourceMap: GPUTextureView
): GPUBindGroup {
let outputCache = this.bindGroupsByTexture.get(trailMapIn);
if (!outputCache) {
outputCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
this.bindGroupsByTexture.set(trailMapIn, outputCache);
}
let sourceCache = outputCache.get(trailMapOut);
if (!sourceCache) {
sourceCache = new WeakMap<GPUTextureView, GPUBindGroup>();
outputCache.set(trailMapOut, sourceCache);
}
const cached = sourceCache.get(sourceMap);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: trailMapIn,
},
{
binding: 3,
resource: trailMapOut,
},
{
binding: 4,
resource: sourceMap,
},
],
});
sourceCache.set(sourceMap, bindGroup);
return bindGroup;
}
public destroy() {
@ -191,6 +191,13 @@ export class AgentPipeline {
format: 'rgba16float',
},
},
{
binding: 4,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
],
};
}

View file

@ -1,11 +1,8 @@
export interface AgentSettings {
brushTrailWeight: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
currentGenerationAggression: number;
nextGenerationAggression: number;
}

View file

@ -1,37 +1,18 @@
struct Settings {
center: vec2<f32>,
radius: f32,
brushTrailWeight: f32,
currentGenerationMoveRate: f32,
moveRate: f32,
turnRate: f32,
sensorAngle: f32,
sensorOffset: f32,
currentGenerationAggression: f32,
nextGenerationAggression: f32,
nextGenerationSensorOffsetDistance: f32,
nextGenerationMoveRate: f32,
isNextGenerationOdd: f32,
turnWhenLost: f32,
individualTrailWeight: f32,
infectionProbability: f32,
agentCount: f32 // might be smaller than the length of the agents array
agentCount: f32,
introProgress: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
// even generation's trail -> red channel
// odd generation's trail -> green channel
// unused -> blue channel
// brush -> alpha channel
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
@group(1) @binding(4) var sourceMap: texture_2d<f32>;
@compute @workgroup_size(64)
fn main(
@ -45,90 +26,125 @@ fn main(
}
var agent = agents[id];
if agent.colorIndex < 0.0 {
return;
}
let hasIntroTarget =
settings.introProgress < 0.999 &&
agent.targetPosition.x >= 0.0 &&
agent.targetPosition.y >= 0.0;
if hasIntroTarget && settings.introProgress < agent.introDelay {
return;
}
let random = textureSampleLevel(
noise,
noiseSampler,
vec2(
f32(id) % 23647 / 2000,
state.time % 3243 / 2000
),
fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)),
0
);
let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
let isFromOddGeneration = agent.generation % 2;
let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
let rightSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, -settings.sensorAngle);
let sensorOffset = mix(settings.sensorOffset, settings.nextGenerationSensorOffsetDistance, isFromNextGeneration);
let moveRate = mix(settings.currentGenerationMoveRate, settings.nextGenerationMoveRate, isFromNextGeneration);
let brushWeight = mix(settings.brushTrailWeight, 0, isFromNextGeneration);
let trailForward = sense(agent.position, agent.angle, sensorOffset, 0);
let trailLeft = sense(agent.position, agent.angle, sensorOffset, settings.sensorAngle);
let trailRight = sense(agent.position, agent.angle, sensorOffset, -settings.sensorAngle);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
var weightForward = brushWeight * trailForward.a;
var weightLeft = brushWeight * trailLeft.a;
var weightRight = brushWeight * trailRight.a;
let channelMask = get_channel_mask(agent.colorIndex);
let friendForward = dot(trailForward.rgb, channelMask);
let friendLeft = dot(trailLeft.rgb, channelMask);
let friendRight = dot(trailRight.rgb, channelMask);
let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward;
let sourceForward = dot(sourceForwardSample.rgb, channelMask);
let sourceLeft = dot(sourceLeftSample.rgb, channelMask);
let sourceRight = dot(sourceRightSample.rgb, channelMask);
weightForward += mix(trailForward.r + agression * trailForward.g, trailForward.g + agression * trailForward.r, isFromOddGeneration);
weightLeft += mix(trailLeft.r + agression * trailLeft.g, trailLeft.g + agression * trailLeft.r, isFromOddGeneration);
weightRight += mix(trailRight.r + agression * trailRight.g, trailRight.g + agression * trailRight.r, isFromOddGeneration);
var rotation: f32;
let weightForward = friendForward + sourceForward * 24.0;
let weightLeft = friendLeft + sourceLeft * 24.0;
let weightRight = friendRight + sourceRight * 24.0;
var rotation = (random.r - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = 0;
rotation = rotation * 0.25;
} else {
rotation = sign(weightLeft - weightRight) * settings.turnRate;
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
let nextPosition = clamp(
agent.position + vec2(cos(agent.angle), sin(agent.angle)) * moveRate,
vec2<f32>(0, 0),
state.size
);
if nextPosition.x == 0 || nextPosition.x == state.size.x || nextPosition.y == 0 || nextPosition.y == state.size.y {
rotation = 3.14159265359 + random.a - 0.5;
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(agent.position), 0);
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, channelMask), 0.0, 1.0);
var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
var introTargetOffset = vec2<f32>(0.0, 0.0);
var introTargetDistance = 0.0;
if hasIntroTarget {
introTargetOffset = agent.targetPosition - agent.position;
introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(4.0, max(28.0, settings.sensorOffset * 0.75), introTargetDistance);
let desiredAngle = mix(targetAngle, agent.targetAngle, nearTitle * 0.2);
let introTurn = angle_delta(agent.angle, desiredAngle);
rotation = clamp(introTurn, -settings.turnRate * 3.4, settings.turnRate * 3.4)
+ (random.g - 0.5) * settings.turnWhenLost * 0.18;
moveRate = min(settings.moveRate * mix(2.65, 0.01, nearTitle), introTargetDistance);
}
var trail = vec4<f32>(settings.individualTrailWeight, 0, 0, 0);
if isFromOddGeneration == 1.0 {
trail = vec4<f32>(0, settings.individualTrailWeight, 0, 0);
}
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
agent.angle += rotation;
trailBelow += trail;
if settings.radius > 0 && length(settings.center - agent.position) < settings.radius {
agent.generation = settings.isNextGenerationOdd;
// clear trail map below so the agent won't die immediately
// trailBelow.r = (1 - settings.isNextGenerationOdd) * (trailBelow.r + trailBelow.g);
// trailBelow.g = settings.isNextGenerationOdd * (trailBelow.r + trailBelow.g);
} else {
let relativeWeight = mix(trailBelow.g - trailBelow.r, trailBelow.r - trailBelow.g, isFromOddGeneration);
if (relativeWeight > 0 && (
(isFromCurrentGeneration == 1.0 && trailBelow.a == 0 && random.b < settings.infectionProbability)
|| (isFromCurrentGeneration == 0.0 && trailBelow.a > 0)
)) || (trailBelow.a > 0 && isFromCurrentGeneration == 0.0){
// trailBelow.r = isFromOddGeneration * (trailBelow.r + trailBelow.g);
// trailBelow.g = (1 - isFromOddGeneration) * (trailBelow.r + trailBelow.g);
agent.generation = (agent.generation + 1) % 2;
var step = vec2(cos(agent.angle), sin(agent.angle)) * moveRate;
if hasIntroTarget {
step = vec2<f32>(0.0, 0.0);
if introTargetDistance > 0.5 {
step = introTargetOffset / introTargetDistance * moveRate;
}
}
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
let nextPosition = clamp(agent.position + step, vec2<f32>(0, 0), maxPosition);
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
rotation = 3.14159265359 + random.a - 0.5;
}
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
let sourceBelowStrength = dot(sourceBelow.rgb, channelMask);
let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>(
trailBelow.rgb + channelMask * trailWeight,
max(trailBelow.a, 0.0)
);
agent.angle += rotation;
agent.position = nextPosition;
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
agents[id] = agent;
}
fn sense(agentPosition: vec2<f32>, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4<f32> {
fn sensor_position(agentPosition: vec2<f32>, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec2<i32> {
let sensorAngle = agentAngle + sensorOffsetAngle;
let sensorPosition = vec2<i32>(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
return textureLoad(trailMapIn, sensorPosition, 0);
return vec2<i32>(clamp(
agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset,
vec2<f32>(0, 0),
state.size - vec2<f32>(1, 1)
));
}
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
if colorIndex < 0.5 {
return vec3<f32>(1, 0, 0);
}
if colorIndex < 1.5 {
return vec3<f32>(0, 1, 0);
}
return vec3<f32>(0, 0, 1);
}
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
}

View file

@ -1,14 +1,24 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { BrushSettings } from './brush-settings';
import shader from './brush.wgsl?raw';
interface LineSegment {
from: vec2;
to: vec2;
}
export class BrushPipeline {
private static readonly UNIFORM_COUNT = 2;
private static readonly MAX_LINE_COUNT = 20;
private static readonly UNIFORM_COUNT = 8;
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
@ -16,10 +26,20 @@ export class BrushPipeline {
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
BrushPipeline.UNIFORM_COUNT
);
private readonly vertexBuffer: GPUBuffer;
private readonly vertexUploadData = new Float32Array(
BrushPipeline.MAX_LINE_COUNT *
BrushPipeline.VERTICES_PER_LINE_SEGMENT *
BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT
);
private linePoints: Array<vec2> = [];
private actualPoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
public constructor(
private readonly device: GPUDevice,
@ -72,18 +92,6 @@ export class BrushPipeline {
targets: [
{
format: 'rgba16float',
blend: {
color: {
operation: 'add',
srcFactor: 'zero',
dstFactor: 'one',
},
alpha: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
},
},
],
},
@ -111,112 +119,188 @@ export class BrushPipeline {
}
public addSwipe(position: vec2) {
this.linePoints.push(position);
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2) {
this.lineSegments.push({
from: vec2.clone(from),
to: vec2.clone(to),
});
}
public clearSwipes() {
this.linePoints.length = 0;
this.lineSegments.length = 0;
this.actualSegments.length = 0;
}
public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
this.device.queue.writeBuffer(
public setParameters({
brushSize,
brushSizeVariation,
selectedColorIndex,
isErasing,
}: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
this.uniformValues[0] = brushSize / 2;
this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation);
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0;
this.uniformValues[5] = !isErasing && selectedColorIndex === 1 ? 1 : 0;
this.uniformValues[6] = !isErasing && selectedColorIndex === 2 ? 1 : 0;
this.uniformValues[7] = isErasing ? 0 : 1;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
this.uniformValues,
this.uniformCache
);
this.actualPoints = this.linePoints.slice();
this.linePoints.splice(0, this.linePoints.length - 1);
this.actualSegments = this.lineSegments.slice();
this.lineSegments.length = 0;
if (this.actualPoints.length === 0) {
if (this.actualSegments.length === 0) {
return;
}
if (this.actualPoints.length === 1) {
this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
if (this.actualSegments.length > BrushPipeline.MAX_LINE_COUNT) {
this.actualSegments = BrushPipeline.subsampleSegments(this.actualSegments);
}
if (this.actualPoints.length > BrushPipeline.MAX_LINE_COUNT + 1) {
this.actualPoints = BrushPipeline.subsampleLinePoints(this.actualPoints);
const lineCount = this.lineCount;
let floatOffset = 0;
for (let i = 0; i < lineCount; i++) {
const segment = this.actualSegments[i];
floatOffset = this.writeSegmentVertices(
this.vertexUploadData,
floatOffset,
segment.from,
segment.to,
brushSize / 2
);
}
this.device.queue.writeBuffer(
this.vertexBuffer,
0,
new Float32Array(
new Array(this.lineCount).fill(0).flatMap((_, i) => {
const from = this.actualPoints[i];
const to = this.actualPoints[i + 1];
const [a, b, c, d] = this.getSegmentBoundingBox(from, to, brushSize / 2);
return [a, b, c, b, c, d].flatMap((v) => [...v, ...from, ...to]);
})
)
this.vertexUploadData,
0,
floatOffset
);
}
private static subsampleLinePoints(points: Array<vec2>): Array<vec2> {
const lines = [];
for (let i = 0; i < points.length - 2; i++) {
lines.push({
from: points[i],
to: points[i + 1],
length: vec2.dist(points[i], points[i + 1]),
});
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
if (segments.length <= BrushPipeline.MAX_LINE_COUNT) {
return segments;
}
const sumLength = lines.reduce((sum, line) => sum + line.length, 0);
let currentLineIndex = 0;
let lineLengthSoFar = 0;
const result: Array<vec2> = [points[0]];
for (let i = 1; i < BrushPipeline.MAX_LINE_COUNT; i++) {
const t = (i * sumLength) / (BrushPipeline.MAX_LINE_COUNT + 1);
while (lineLengthSoFar + lines[currentLineIndex].length < t) {
lineLengthSoFar += lines[currentLineIndex].length;
currentLineIndex++;
}
const line = lines[currentLineIndex];
const position = vec2.lerp(
vec2.create(),
line.from,
line.to,
(t - lineLengthSoFar) / line.length
const result: Array<LineSegment> = [];
for (let i = 0; i < BrushPipeline.MAX_LINE_COUNT; i++) {
const index = Math.round(
(i * (segments.length - 1)) / (BrushPipeline.MAX_LINE_COUNT - 1)
);
result.push(position);
result.push(segments[index]);
}
result.push(points[points.length - 1]);
return result;
}
private getSegmentBoundingBox(from: vec2, to: vec2, width: number): Array<vec2> {
let dir = vec2.sub(vec2.create(), to, from);
vec2.normalize(dir, dir);
private writeSegmentVertices(
target: Float32Array,
offset: number,
from: vec2,
to: vec2,
width: number
): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const length = Math.hypot(dx, dy);
const directionX = length > 0 ? dx / length : 1;
const directionY = length > 0 ? dy / length : 0;
const scaledDirectionX = directionX * width;
const scaledDirectionY = directionY * width;
const perpendicularX = directionY * width;
const perpendicularY = -directionX * width;
if (vec2.len(dir) === 0) {
dir = vec2.fromValues(1, 0); // allow single point swipes
}
const startX = from[0] - scaledDirectionX;
const startY = from[1] - scaledDirectionY;
const endX = to[0] + scaledDirectionX;
const endY = to[1] + scaledDirectionY;
const perp = vec2.fromValues(dir[1], -dir[0]);
offset = this.writeVertex(
target,
offset,
startX + perpendicularX,
startY + perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
startX - perpendicularX,
startY - perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
endX + perpendicularX,
endY + perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
startX - perpendicularX,
startY - perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
endX + perpendicularX,
endY + perpendicularY,
from,
to
);
return this.writeVertex(
target,
offset,
endX - perpendicularX,
endY - perpendicularY,
from,
to
);
}
vec2.scale(dir, dir, width);
vec2.scale(perp, perp, width);
const offsetStart = vec2.sub(vec2.create(), from, dir);
const offsetEnd = vec2.add(vec2.create(), to, dir);
return [
vec2.add(vec2.create(), offsetStart, perp),
vec2.sub(vec2.create(), offsetStart, perp),
vec2.add(vec2.create(), offsetEnd, perp),
vec2.sub(vec2.create(), offsetEnd, perp),
];
private writeVertex(
target: Float32Array,
offset: number,
screenX: number,
screenY: number,
from: vec2,
to: vec2
): number {
target[offset++] = screenX;
target[offset++] = screenY;
target[offset++] = from[0];
target[offset++] = from[1];
target[offset++] = to[0];
target[offset++] = to[1];
return offset;
}
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
if (this.lineCount === 0) {
return;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
@ -256,6 +340,6 @@ export class BrushPipeline {
}
private get lineCount() {
return clamp(this.actualPoints.length - 1, 0, BrushPipeline.MAX_LINE_COUNT);
return clamp(this.actualSegments.length, 0, BrushPipeline.MAX_LINE_COUNT);
}
}

View file

@ -1,4 +1,6 @@
export interface BrushSettings {
brushSize: number;
eraserSize: number;
mirrorSegmentCount: number;
brushSizeVariation: number;
}

View file

@ -1,6 +1,9 @@
struct Settings {
brushSize: f32,
brushSizeVariation: f32
brushSizeVariation: f32,
padding0: f32,
padding1: f32,
brushValue: vec4<f32>,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -19,7 +22,7 @@ fn vertex(
@location(2) @interpolate(flat) end: vec2<f32>
) -> VertexOutput {
let uv = screenPosition / state.size;
let position = uv * 2.0 - 1.0;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
}
@ -29,20 +32,34 @@ fn fragment(
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
var distance = distanceFromLine(screenPosition, start, end);
let noise = textureSample(noise, noiseSampler, screenPosition / state.size / 50);
distance += noise.r * settings.brushSizeVariation;
let distance = distanceFromLine(screenPosition, start, end);
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
let grainNoise = textureSample(
noise,
noiseSampler,
fract(screenPosition / 22.0 + vec2(0.31, 0.67))
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
let feather = max(1.0, settings.brushSize * 0.22);
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
let strength = edge * mix(0.45, 1.0, grainNoise);
if(distance > settings.brushSize) {
if(strength < 0.02) {
discard;
}
return vec4(0, 0, 0, 1);
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
fn distanceFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let pa = position - start;
let direction = end - start;
let q = clamp(dot(pa, direction) / dot(direction, direction), 0, 1);
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
return length(pa);
}
let q = clamp(dot(pa, direction) / denominator, 0, 1);
return length(pa - direction * q);
}

View file

@ -8,7 +8,7 @@ import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
private static readonly NOISE_TEXTURE_SIZE = 1024;
private static readonly NOISE_TEXTURE_SIZE = 2048;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);

View file

@ -1,19 +1,28 @@
import { vec2 } from 'gl-matrix';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './copy.wgsl?raw';
export class CopyPipeline {
private static readonly UNIFORM_COUNT = 2;
private static readonly DEFAULT_SCALE = vec2.fromValues(1, 1);
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(CopyPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
CopyPipeline.UNIFORM_COUNT
);
private readonly sampler: GPUSampler;
private readonly vertexBuffer: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
@ -23,6 +32,11 @@ export class CopyPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.sampler = this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
this.vertexBuffer = device.createBuffer({
size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2<f32>
usage: GPUBufferUsage.VERTEX,
@ -79,9 +93,16 @@ export class CopyPipeline {
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
scale: vec2 = vec2.fromValues(1, 1)
scale: vec2 = CopyPipeline.DEFAULT_SCALE
) {
this.device.queue.writeBuffer(this.uniforms, 0, new Float32Array(scale));
this.uniformValues[0] = scale[0];
this.uniformValues[1] = scale[1];
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
@ -93,10 +114,10 @@ export class CopyPipeline {
],
};
this.ensureBindGroupExists(trailMapIn);
const bindGroup = this.getBindGroup(trailMapIn);
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(0, this.bindGroup);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.end();
@ -104,35 +125,37 @@ export class CopyPipeline {
public destroy() {
this.vertexBuffer.destroy();
this.uniforms.destroy();
}
private ensureBindGroupExists(trailMapIn: GPUTextureView) {
if (this.previousTrailMapIn !== trailMapIn) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: trailMapIn,
},
],
});
this.previousTrailMapIn = trailMapIn;
private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
const cached = this.bindGroupsByInput.get(trailMapIn);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.sampler,
},
{
binding: 2,
resource: trailMapIn,
},
],
});
this.bindGroupsByInput.set(trailMapIn, bindGroup);
return bindGroup;
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import {
getSafeInverseDiffusionRate,
setDiffusionUniformValues,
} from './diffusion-pipeline';
describe('diffusion pipeline parameters', () => {
it('keeps zero diffusion rates finite before writing shader uniforms', () => {
const uniformValues = new Float32Array(4);
setDiffusionUniformValues(uniformValues, {
decayRateBrush: 900,
decayRateTrails: 970,
diffusionRateBrush: 0,
diffusionRateTrails: 0,
});
expect(Number.isFinite(uniformValues[0])).toBe(true);
expect(Number.isFinite(uniformValues[2])).toBe(true);
expect(uniformValues[0]).toBeGreaterThan(0);
expect(uniformValues[2]).toBeGreaterThan(0);
});
it('passes valid diffusion rates through as inverse values', () => {
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
});
});

View file

@ -1,3 +1,4 @@
import { appConfig } from '../../config';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@ -8,8 +9,36 @@ import { CommonState } from '../common-state/common-state';
import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
const MIN_DIFFUSION_RATE = appConfig.pipelines.diffusion.minDiffusionRate;
type DiffusionUniformSettings = Pick<
DiffusionSettings,
'diffusionRateTrails' | 'decayRateTrails' | 'diffusionRateBrush' | 'decayRateBrush'
>;
export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 /
(Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
? diffusionRate
: MIN_DIFFUSION_RATE);
export const setDiffusionUniformValues = (
target: Float32Array,
{
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
}: DiffusionUniformSettings
): void => {
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / 1000;
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
target[3] = decayRateBrush / 1000;
};
export class DiffusionPipeline {
private static readonly UNIFORM_COUNT = 5;
private static readonly UNIFORM_COUNT = 4;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
@ -18,10 +47,10 @@ export class DiffusionPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite(
DiffusionPipeline.UNIFORM_COUNT
);
private readonly sampler: GPUSampler;
private readonly vertexBuffer: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
public constructor(
private readonly device: GPUDevice,
@ -57,6 +86,11 @@ export class DiffusionPipeline {
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.sampler = this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
}
public setParameters({
@ -64,13 +98,13 @@ export class DiffusionPipeline {
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
anisotropy,
}: DiffusionSettings) {
this.uniformValues[0] = 1 / diffusionRateTrails;
this.uniformValues[1] = decayRateTrails / 1000;
this.uniformValues[2] = 1 / diffusionRateBrush;
this.uniformValues[3] = decayRateBrush / 1000;
this.uniformValues[4] = anisotropy;
setDiffusionUniformValues(this.uniformValues, {
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
});
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@ -84,7 +118,7 @@ export class DiffusionPipeline {
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
) {
this.ensureBindGroupExists(trailMapIn);
const bindGroup = this.getBindGroup(trailMapIn);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
@ -101,38 +135,39 @@ export class DiffusionPipeline {
passEncoder.setPipeline(this.pipeline);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
private ensureBindGroupExists(trailMapIn: GPUTextureView) {
if (this.previousTrailMapIn !== trailMapIn) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: trailMapIn,
},
],
});
this.previousTrailMapIn = trailMapIn;
private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
const cached = this.bindGroupsByInput.get(trailMapIn);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.sampler,
},
{
binding: 2,
resource: trailMapIn,
},
],
});
this.bindGroupsByInput.set(trailMapIn, bindGroup);
return bindGroup;
}
public destroy() {

View file

@ -3,4 +3,5 @@ export interface DiffusionSettings {
decayRateTrails: number;
diffusionRateBrush: number;
decayRateBrush: number;
brushEffectDuration: number;
}

View file

@ -0,0 +1,244 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile';
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import { CommonState } from '../common-state/common-state';
import shader from './eraser-agent.wgsl?raw';
interface LineSegment {
from: vec2;
to: vec2;
}
const shaderWithConfig = shader.replace(
'const MAX_SEGMENT_COUNT = 384u;',
`const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;`
);
export class EraserAgentPipeline {
private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
private static readonly UNIFORM_COUNT = 4;
private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount;
private static readonly SEGMENT_FLOAT_COUNT =
appConfig.pipelines.eraser.segmentFloatCount;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
private readonly segmentsBuffer: GPUBuffer;
private readonly segmentUploadData = new Float32Array(
EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
);
private linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
private segmentCount = 0;
private agentCount = 0;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly agentsBuffer: GPUBuffer
) {
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'read-only-storage',
},
},
],
});
this.uniforms = this.device.createBuffer({
size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.segmentsBuffer = this.device.createBuffer({
size:
EraserAgentPipeline.MAX_SEGMENT_COUNT *
EraserAgentPipeline.SEGMENT_FLOAT_COUNT *
Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.segmentsBuffer,
},
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
shaderWithConfig
),
entryPoint: 'main',
},
});
}
public addSwipe(position: vec2): void {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2): void {
this.lineSegments.push({
from: vec2.clone(from),
to: vec2.clone(to),
});
}
public clearSwipes(): void {
this.linePoints.length = 0;
this.lineSegments.length = 0;
this.actualSegments.length = 0;
this.segmentCount = 0;
}
public setParameters({
agentCount,
eraserSize,
}: {
agentCount: number;
eraserSize: number;
}): void {
this.agentCount = agentCount;
this.actualSegments = this.lineSegments.slice();
this.lineSegments.length = 0;
if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) {
this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments);
}
this.segmentCount = Math.max(0, this.actualSegments.length);
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius;
this.uniformValues[1] = this.segmentCount;
this.uniformValues[2] = agentCount;
this.uniformValues[3] = eraserRadius * eraserRadius;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
if (this.segmentCount === 0) {
return;
}
for (let i = 0; i < this.segmentCount; i++) {
const { from, to } = this.actualSegments[i];
const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT;
this.segmentUploadData[offset] = from[0];
this.segmentUploadData[offset + 1] = from[1];
this.segmentUploadData[offset + 2] = to[0];
this.segmentUploadData[offset + 3] = to[1];
}
this.device.queue.writeBuffer(
this.segmentsBuffer,
0,
this.segmentUploadData,
0,
this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
);
}
public execute(commandEncoder: GPUCommandEncoder): void {
if (this.segmentCount === 0 || this.agentCount === 0) {
return;
}
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
this.agentCount,
EraserAgentPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
}
public destroy(): void {
this.uniforms.destroy();
this.segmentsBuffer.destroy();
}
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) {
return segments;
}
const result: Array<LineSegment> = [];
for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) {
const index = Math.round(
(i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1)
);
result.push(segments[index]);
}
return result;
}
}

View file

@ -0,0 +1,63 @@
struct Settings {
eraserRadius: f32,
segmentCount: f32,
agentCount: f32,
eraserRadiusSquared: f32,
};
const MAX_SEGMENT_COUNT = 384u;
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var<storage, read> segments: array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if id >= u32(settings.agentCount) {
return;
}
var agent = agents[id];
if agent.colorIndex < 0.0 {
return;
}
for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
if i >= u32(settings.segmentCount) {
break;
}
let segment = segments[i];
let distanceSquared = distanceSquaredFromLine(
agent.position,
segment.xy,
segment.zw
);
if distanceSquared <= settings.eraserRadiusSquared {
agent.position = vec2<f32>(-1.0, -1.0);
agent.targetPosition = vec2<f32>(-1.0, -1.0);
agent.colorIndex = -1.0;
agents[id] = agent;
return;
}
}
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -0,0 +1,333 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import shader from './eraser-texture.wgsl?raw';
interface LineSegment {
from: vec2;
to: vec2;
}
export class EraserTexturePipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserTexturePipeline.UNIFORM_COUNT
);
private readonly vertexBuffer: GPUBuffer;
private readonly vertexUploadData = new Float32Array(
EraserTexturePipeline.MAX_LINE_COUNT *
EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
);
private linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: 'uniform',
},
},
],
});
this.vertexBuffer = device.createBuffer({
size:
EraserTexturePipeline.MAX_LINE_COUNT *
EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT *
Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
attributes: [
{
shaderLocation: 0,
format: 'float32x2',
offset: 0,
},
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
},
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-list',
},
});
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
],
});
}
public addSwipe(position: vec2): void {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2): void {
this.lineSegments.push({
from: vec2.clone(from),
to: vec2.clone(to),
});
}
public clearSwipes(): void {
this.linePoints.length = 0;
this.lineSegments.length = 0;
this.actualSegments.length = 0;
}
public setParameters({ eraserSize }: { eraserSize: number }): void {
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius;
this.uniformValues[1] = eraserRadius * eraserRadius;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
this.actualSegments = this.lineSegments.slice();
this.lineSegments.length = 0;
if (this.actualSegments.length === 0) {
return;
}
if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) {
this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments);
}
const lineCount = this.lineCount;
let floatOffset = 0;
for (let i = 0; i < lineCount; i++) {
const segment = this.actualSegments[i];
floatOffset = this.writeSegmentVertices(
this.vertexUploadData,
floatOffset,
segment.from,
segment.to,
eraserRadius
);
}
this.device.queue.writeBuffer(
this.vertexBuffer,
0,
this.vertexUploadData,
0,
floatOffset
);
}
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
if (this.lineCount === 0) {
return;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: textureOut,
loadOp: 'load',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
passEncoder.end();
}
public destroy(): void {
this.vertexBuffer.destroy();
this.uniforms.destroy();
}
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
return segments;
}
const result: Array<LineSegment> = [];
for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) {
const index = Math.round(
(i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1)
);
result.push(segments[index]);
}
return result;
}
private writeSegmentVertices(
target: Float32Array,
offset: number,
from: vec2,
to: vec2,
width: number
): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const length = Math.hypot(dx, dy);
const directionX = length > 0 ? dx / length : 1;
const directionY = length > 0 ? dy / length : 0;
const scaledDirectionX = directionX * width;
const scaledDirectionY = directionY * width;
const perpendicularX = directionY * width;
const perpendicularY = -directionX * width;
const startX = from[0] - scaledDirectionX;
const startY = from[1] - scaledDirectionY;
const endX = to[0] + scaledDirectionX;
const endY = to[1] + scaledDirectionY;
offset = this.writeVertex(
target,
offset,
startX + perpendicularX,
startY + perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
startX - perpendicularX,
startY - perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
endX + perpendicularX,
endY + perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
startX - perpendicularX,
startY - perpendicularY,
from,
to
);
offset = this.writeVertex(
target,
offset,
endX + perpendicularX,
endY + perpendicularY,
from,
to
);
return this.writeVertex(
target,
offset,
endX - perpendicularX,
endY - perpendicularY,
from,
to
);
}
private writeVertex(
target: Float32Array,
offset: number,
screenX: number,
screenY: number,
from: vec2,
to: vec2
): number {
target[offset++] = screenX;
target[offset++] = screenY;
target[offset++] = from[0];
target[offset++] = from[1];
target[offset++] = to[0];
target[offset++] = to[1];
return offset;
}
private get lineCount(): number {
return clamp(this.actualSegments.length, 0, EraserTexturePipeline.MAX_LINE_COUNT);
}
}

View file

@ -0,0 +1,53 @@
struct Settings {
eraserRadius: f32,
eraserRadiusSquared: f32,
padding1: f32,
padding2: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
}
@vertex
fn vertex(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
) -> VertexOutput {
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
}
@fragment
fn fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
discard;
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -1,5 +1,7 @@
import { vec3 } from 'gl-matrix';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@ -7,15 +9,23 @@ import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw';
export class RenderPipeline {
private static readonly UNIFORM_COUNT = 13;
private static readonly UNIFORM_COUNT = 20;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly canvasPipeline: GPURenderPipeline;
private readonly exportPipeline: GPURenderPipeline;
private readonly sampler: GPUSampler;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
RenderPipeline.UNIFORM_COUNT
);
private readonly vertexBuffer: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousColorTexture?: GPUTextureView;
private readonly bindGroupsByTexture = new WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, GPUBindGroup>
>();
public constructor(
private readonly context: GPUCanvasContext,
@ -27,104 +37,179 @@ export class RenderPipeline {
const { buffer, vertex } = setUpFullScreenQuad(device);
this.vertexBuffer = buffer;
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex,
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: navigator.gpu.getPreferredCanvasFormat(),
},
],
},
primitive: {
topology: 'triangle-strip',
},
this.sampler = device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
const format = navigator.gpu.getPreferredCanvasFormat();
this.canvasPipeline = this.createPipeline(format, vertex);
this.exportPipeline = this.createPipeline(format, vertex);
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
private createPipeline(
format: GPUTextureFormat,
vertex: GPUVertexState
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex,
fragment: {
module: smartCompile(this.device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format,
},
],
},
primitive: {
topology: 'triangle-strip',
},
});
}
public setParameters({
brushColor,
evenGenerationColor,
oddGenerationColor,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
clarity,
}: RenderSettings & {
brushColor: vec3;
evenGenerationColor: vec3;
oddGenerationColor: vec3;
channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number];
cameraCenter: [number, number];
cameraZoom: number;
}) {
this.device.queue.writeBuffer(
const [a, b, c] = channelColors;
this.uniformValues[0] = a[0];
this.uniformValues[1] = a[1];
this.uniformValues[2] = a[2];
this.uniformValues[3] = 0;
this.uniformValues[4] = b[0];
this.uniformValues[5] = b[1];
this.uniformValues[6] = b[2];
this.uniformValues[7] = 0;
this.uniformValues[8] = c[0];
this.uniformValues[9] = c[1];
this.uniformValues[10] = c[2];
this.uniformValues[11] = 0;
this.uniformValues[12] = backgroundColor[0];
this.uniformValues[13] = backgroundColor[1];
this.uniformValues[14] = backgroundColor[2];
this.uniformValues[15] = clarity;
this.uniformValues[16] = cameraCenter[0];
this.uniformValues[17] = cameraCenter[1];
this.uniformValues[18] = cameraZoom;
this.uniformValues[19] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([
...brushColor,
0, //padding
...evenGenerationColor,
0, //padding
...oddGenerationColor,
clarity,
])
this.uniformValues,
this.uniformCache
);
}
public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
this.ensureBindGroupExists(colorTexture);
public execute(
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView
) {
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: this.context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 1, b: 1, a: 1 },
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
passEncoder.setPipeline(this.canvasPipeline);
this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
private ensureBindGroupExists(colorTexture: GPUTextureView) {
if (this.previousColorTexture !== colorTexture) {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: colorTexture,
},
],
});
public executeToView(
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView,
outputTexture: GPUTextureView
) {
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
this.previousColorTexture = colorTexture;
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: outputTexture,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.setPipeline(this.exportPipeline);
this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
private getBindGroup(
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView
): GPUBindGroup {
let sourceTextureCache = this.bindGroupsByTexture.get(colorTexture);
if (!sourceTextureCache) {
sourceTextureCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByTexture.set(colorTexture, sourceTextureCache);
}
const cached = sourceTextureCache.get(sourceTexture);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.sampler,
},
{
binding: 2,
resource: colorTexture,
},
{
binding: 3,
resource: sourceTexture,
},
],
});
sourceTextureCache.set(sourceTexture, bindGroup);
return bindGroup;
}
public destroy() {
@ -156,6 +241,13 @@ export class RenderPipeline {
sampleType: 'float',
},
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: 'float',
},
},
],
};
}

View file

@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest';
import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
import countingShader from './agents/agent-generation/agent-counting.wgsl?raw';
import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
import { AgentPipeline } from './agents/agent-pipeline';
import agentShader from './agents/agent.wgsl?raw';
import { BrushPipeline } from './brush/brush-pipeline';
import brushShader from './brush/brush.wgsl?raw';
import { CommonState } from './common-state/common-state';
import { CopyPipeline } from './copy/copy-pipeline';
import copyShader from './copy/copy.wgsl?raw';
import diffusionShader from './diffusion/diffuse.wgsl?raw';
import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
import eraserAgentShader from './eraser/eraser-agent.wgsl?raw';
import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline';
import eraserTextureShader from './eraser/eraser-texture.wgsl?raw';
import { RenderPipeline } from './render/render-pipeline';
import renderShader from './render/render.wgsl?raw';
const wgslFloatCountsByType: Record<string, number> = {
f32: 1,
u32: 1,
'vec2<f32>': 2,
'vec3<f32>': 3,
'vec4<f32>': 4,
};
const stripComments = (source: string): string =>
source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
const getStructFields = (source: string, structName: string) => {
const match = new RegExp(
`struct ${structName}\\s*\\{(?<body>[\\s\\S]*?)\\n\\s*\\}`
).exec(stripComments(source));
if (!match?.groups?.body) {
throw new Error(`${structName} struct was not found`);
}
return match.groups.body
.split('\n')
.map((line) => line.trim().replace(/,$/, ''))
.filter(Boolean)
.map((line) => {
const fieldMatch = /^(?<name>\w+):\s*(?<type>[^,]+)$/.exec(line);
if (!fieldMatch?.groups) {
throw new Error(`Unsupported WGSL struct field syntax: ${line}`);
}
return {
name: fieldMatch.groups.name,
type: fieldMatch.groups.type,
};
});
};
const countUniformScalars = (source: string, structName: string): number =>
getStructFields(source, structName).reduce((sum, field) => {
const count = wgslFloatCountsByType[field.type];
if (!count) {
throw new Error(`Unsupported WGSL uniform field type: ${field.type}`);
}
return sum + count;
}, 0);
const getUniformCount = (pipeline: unknown): number =>
(pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT;
const expectStructUniformLayout = ({
pipeline,
source,
structName,
fieldNames,
}: {
pipeline: unknown;
source: string;
structName: string;
fieldNames: Array<string>;
}) => {
const fields = getStructFields(source, structName);
expect(fields.map((field) => field.name)).toEqual(fieldNames);
expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline));
};
describe('WGSL uniform layout contracts', () => {
it('keeps shared common-state uniforms aligned with WGSL', () => {
expectStructUniformLayout({
pipeline: CommonState,
source: CommonState.shaderCode,
structName: 'State',
fieldNames: ['size', 'deltaTime', 'time'],
});
});
it('keeps render and simulation uniforms aligned with WGSL', () => {
expectStructUniformLayout({
pipeline: AgentPipeline,
source: agentShader,
structName: 'Settings',
fieldNames: [
'moveRate',
'turnRate',
'sensorAngle',
'sensorOffset',
'turnWhenLost',
'individualTrailWeight',
'agentCount',
'introProgress',
],
});
expectStructUniformLayout({
pipeline: BrushPipeline,
source: brushShader,
structName: 'Settings',
fieldNames: [
'brushSize',
'brushSizeVariation',
'padding0',
'padding1',
'brushValue',
],
});
expectStructUniformLayout({
pipeline: DiffusionPipeline,
source: diffusionShader,
structName: 'Settings',
fieldNames: [
'inverseDiffusionRateTrails',
'decayRateTrails',
'inverseDiffusionRateBrush',
'decayRateBrush',
],
});
expectStructUniformLayout({
pipeline: RenderPipeline,
source: renderShader,
structName: 'Settings',
fieldNames: [
'colorA',
'backgroundColorPadding0',
'colorB',
'backgroundColorPadding1',
'colorC',
'backgroundColorPadding2',
'backgroundColor',
'clarity',
'cameraCenter',
'cameraZoom',
'padding0',
],
});
});
it('keeps eraser uniforms aligned with WGSL', () => {
expectStructUniformLayout({
pipeline: EraserAgentPipeline,
source: eraserAgentShader,
structName: 'Settings',
fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'],
});
expectStructUniformLayout({
pipeline: EraserTexturePipeline,
source: eraserTextureShader,
structName: 'Settings',
fieldNames: ['eraserRadius', 'eraserRadiusSquared', 'padding1', 'padding2'],
});
});
it('keeps copy uniforms aligned with WGSL', () => {
const match = /var<uniform>\s+sourceScaler:\s*(?<type>[^;]+);/.exec(copyShader);
expect(match?.groups?.type).toBe('vec2<f32>');
expect(wgslFloatCountsByType[match?.groups?.type ?? '']).toBe(
getUniformCount(CopyPipeline)
);
});
it('keeps agent-generation uniforms large enough for every generation shader', () => {
const generationUniformCounts = [
countUniformScalars(countingShader, 'Settings'),
countUniformScalars(resizeShader, 'ResizeSettings'),
countUniformScalars(compactionShader, 'Settings'),
];
expect(Math.max(...generationUniformCounts)).toBe(
getUniformCount(AgentGenerationPipeline)
);
});
});

View file

@ -1,54 +1,38 @@
import { GameLoopSettings } from './game-loop/game-loop-settings';
import { AgentSettings } from './pipelines/agents/agent-settings';
import { BrushSettings } from './pipelines/brush/brush-settings';
import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
import { RenderSettings } from './pipelines/render/render-settings';
import { persist } from './utils/persist';
import { appConfig, type GardenRuntimeSettings } from './config';
import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
const initialValues: GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings = {
agentCount: 1_001_500,
const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
...appConfig.runtimeSettings.defaults,
...vibe.settings,
});
currentGenerationAggression: -5,
nextGenerationAggression: 0.2,
export let activeVibe = getInitialVibe();
moveSpeed: 74,
turnSpeed: 45,
sensorOffsetAngle: 31,
sensorOffsetDistance: 43,
turnWhenLost: 0.01,
brushTrailWeight: 500,
individualTrailWeight: 0.05,
diffusionRateTrails: 0,
decayRateTrails: 944,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
clarity: 0.7,
brushSize: 12,
brushSizeVariation: 0.5, // hidden on the UI
startColorHue: 200,
maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
// debug options
renderSpeed: 1,
simulatedDelayMs: 0,
export const settings: { [key: string]: number } & GardenRuntimeSettings = {
...buildInitialValues(activeVibe),
};
export const settings: { [key: string]: number } & GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings = persist({ ...initialValues });
export const resetSettings = () => {
Object.assign(settings, initialValues);
Object.assign(settings, buildInitialValues(activeVibe));
};
export const applyVibeSettings = (vibeId: string) => {
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
if (!vibe) {
return activeVibe;
}
activeVibe = vibe;
Object.assign(settings, {
...buildInitialValues(vibe),
agentCount: settings.agentCount,
brushEffectDuration: settings.brushEffectDuration,
eraserSize: settings.eraserSize,
mirrorSegmentCount: settings.mirrorSegmentCount,
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
});
localStorage.setItem(appConfig.storage.vibeKey, vibe.id);
return activeVibe;
};

View file

@ -1,7 +1,7 @@
html > body > aside.control-dock {
position: absolute;
left: 50%;
bottom: calc(0.75rem + env(safe-area-inset-bottom));
bottom: env(safe-area-inset-bottom);
z-index: 4;
width: min(calc(100vw - 1rem), 980px);
transform: translateX(-50%);

View file

@ -45,13 +45,11 @@ html > body > aside.control-dock > .toolbar-row {
min-width: 0;
min-height: 86px;
padding: 8px 9px;
border: 1px solid rgb(255 255 255 / 10%);
border: 1px solid transparent;
border-radius: 10px;
background: rgb(12 14 16 / 74%);
backdrop-filter: blur(16px);
box-shadow:
0 10px 30px rgb(0 0 0 / 22%),
0 1px 3px rgb(0 0 0 / 18%);
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
> .vibe-button {
@ -582,18 +580,19 @@ html > body > aside.control-dock > .toolbar-row {
flex: 1 1 auto;
min-width: 0;
gap: 8px;
padding: 8px;
padding: 4px 8px;
> nav.buttons {
grid-area: nav;
justify-content: center;
gap: 2px;
padding-top: 7px;
padding-top: 3px;
border-top: 1px solid rgb(255 255 255 / 12%);
> button {
width: 44px;
height: 44px;
height: 38px;
min-height: 38px;
&::after {
width: 17px;
@ -616,34 +615,102 @@ html > body > aside.control-dock > .toolbar-row {
padding: 2px 4px;
> .swatches {
flex-wrap: wrap;
justify-content: center;
gap: 9px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
flex: 1 1 100%;
align-items: center;
justify-items: center;
justify-content: stretch;
column-gap: 7px;
row-gap: 8px;
width: 100%;
min-width: 0;
min-height: 54px;
padding: 7px 8px;
padding: 4px 6px;
> .color-swatch {
grid-column: span 2;
width: 44px;
height: 44px;
}
> .eraser-size-control {
width: clamp(104px, 31vw, 138px);
height: 44px;
flex-basis: clamp(104px, 31vw, 138px);
padding: 0 9px;
grid-column: 1 / span 3;
justify-self: stretch;
width: 100%;
min-width: 0;
height: 42px;
flex-basis: auto;
padding: 0 8px;
&::before {
right: 8px;
left: 8px;
}
}
> .mirror-segment-control {
width: clamp(104px, 31vw, 138px);
height: 44px;
flex-basis: clamp(104px, 31vw, 138px);
padding: 0 9px;
grid-column: 4 / span 3;
justify-self: stretch;
width: 100%;
min-width: 0;
height: 42px;
flex-basis: auto;
padding: 0 8px;
&::before {
right: 9px;
left: 9px;
right: 8px;
left: 8px;
}
input[type='range'] {
&::-webkit-slider-thumb {
@include range-thumb-base(
38px,
38px,
2px solid rgb(240 255 251 / 94%),
50%
);
margin-top: -15.5px;
background:
radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px),
repeating-conic-gradient(
from -90deg,
rgb(218 255 241) 0 8deg,
rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
);
box-shadow:
inset 0 0 0 6px rgb(0 0 0 / 18%),
0 0 0 3px rgb(92 206 177 / 16%),
0 4px 10px rgb(0 0 0 / 28%);
}
&::-webkit-slider-thumb:hover {
box-shadow:
inset 0 0 0 6px rgb(0 0 0 / 18%),
0 0 0 4px rgb(92 206 177 / 24%),
0 5px 12px rgb(0 0 0 / 32%);
}
&::-moz-range-thumb {
@include range-thumb-base(
38px,
38px,
2px solid rgb(240 255 251 / 94%),
50%
);
background:
radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px),
repeating-conic-gradient(
from -90deg,
rgb(218 255 241) 0 8deg,
rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
);
box-shadow:
inset 0 0 0 6px rgb(0 0 0 / 18%),
0 0 0 3px rgb(92 206 177 / 16%),
0 4px 10px rgb(0 0 0 / 28%);
}
}
}
}

View file

@ -1,15 +1,19 @@
import { appConfig } from '../config';
import { clamp } from './clamp';
import { exponentialDecay } from './exponential-decay';
export class DeltaTimeCalculator {
private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
private static FPS_EXPONENTIAL_DECAY_STRENGTH =
appConfig.deltaTime.fpsExponentialDecayStrength;
private previousTime: DOMHighResTimeStamp | null = null;
private deltaTimeAccumulator: number | null = null;
constructor(
private readonly maxDeltaTimeInSeconds: number = 1 / 30,
private readonly minDeltaTimeInSeconds: number = 1 / 240
private readonly maxDeltaTimeInSeconds: number =
appConfig.deltaTime.maxDeltaTimeSeconds,
private readonly minDeltaTimeInSeconds: number =
appConfig.deltaTime.minDeltaTimeSeconds
) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}

View file

@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from './cached-buffer-write';
const createGpuWriteStub = () => {
const writeBuffer = vi.fn();
const device = {
queue: {
writeBuffer,
},
} as unknown as GPUDevice;
return { device, writeBuffer };
};
describe('cached float32 buffer writes', () => {
it('writes the first value set and skips unchanged values', () => {
const { device, writeBuffer } = createGpuWriteStub();
const buffer = {} as GPUBuffer;
const cache = createCachedFloat32BufferWrite(3);
const values = new Float32Array([1, 2, 3]);
expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(true);
expect(writeBuffer).toHaveBeenCalledTimes(1);
expect(writeBuffer).toHaveBeenLastCalledWith(buffer, 0, values);
expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(false);
expect(writeBuffer).toHaveBeenCalledTimes(1);
});
it('writes again when any float changes', () => {
const { device, writeBuffer } = createGpuWriteStub();
const buffer = {} as GPUBuffer;
const cache = createCachedFloat32BufferWrite(3);
expect(
writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 3]), cache)
).toBe(true);
expect(
writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 4]), cache)
).toBe(true);
expect(writeBuffer).toHaveBeenCalledTimes(2);
});
it('rejects cache length mismatches before writing', () => {
const { device, writeBuffer } = createGpuWriteStub();
const buffer = {} as GPUBuffer;
const cache = createCachedFloat32BufferWrite(2);
expect(() =>
writeFloat32BufferIfChanged(device, buffer, new Float32Array([1]), cache)
).toThrow('Cached buffer write length mismatch');
expect(writeBuffer).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,36 @@
export interface CachedFloat32BufferWrite {
hasValue: boolean;
previous: Float32Array;
}
export const createCachedFloat32BufferWrite = (
length: number
): CachedFloat32BufferWrite => ({
hasValue: false,
previous: new Float32Array(length),
});
export const writeFloat32BufferIfChanged = (
device: GPUDevice,
buffer: GPUBuffer,
values: Float32Array,
cache: CachedFloat32BufferWrite
): boolean => {
if (values.length !== cache.previous.length) {
throw new Error('Cached buffer write length mismatch');
}
let hasChanged = !cache.hasValue;
for (let i = 0; i < values.length && !hasChanged; i++) {
hasChanged = !Object.is(values[i], cache.previous[i]);
}
if (!hasChanged) {
return false;
}
cache.previous.set(values);
cache.hasValue = true;
device.queue.writeBuffer(buffer, 0, values);
return true;
};

View file

@ -1,39 +0,0 @@
export const persist = <T extends Record<string, number>>(wrapee: T): T => {
const keys = Object.keys(wrapee);
keys.sort();
const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
const params = new URLSearchParams(window.location.search);
const newParams = new URLSearchParams();
keys.forEach((key) => {
if (params.has(keysToShortKeys[key])) {
(wrapee as any)[key] = Number(params.get(keysToShortKeys[key]));
newParams.set(keysToShortKeys[key], params.get(keysToShortKeys[key])!);
}
});
window.history.replaceState(
{},
'',
`${window.location.pathname}?${newParams.toString()}`
);
return new Proxy(wrapee, {
set: (target, key: string, value: number) => {
const params = new URLSearchParams(window.location.search);
params.set(keysToShortKeys[key], value.toString());
(target as any)[key] = value;
window.history.replaceState(
{},
'',
`${window.location.pathname}?${params.toString()}`
);
return true;
},
});
};

View file

@ -4,22 +4,12 @@ import { gardenAudioConfig } from './audio/garden-audio-config';
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
const originalLocalStorage = globalThis.localStorage;
const originalWindow = globalThis.window;
const setBrowserVibeState = ({
search = '',
storedVibeId = null,
}: {
search?: string;
storedVibeId?: string | null;
}) => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: new URL(`https://garden.test/${search}`),
},
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
@ -30,38 +20,22 @@ const setBrowserVibeState = ({
});
};
describe('vibe URL selection', () => {
describe('vibe selection', () => {
afterEach(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: originalLocalStorage,
});
});
it('uses a valid vibe id from the URL before local storage', () => {
setBrowserVibeState({
search: '?vibe=moon-orchid',
storedVibeId: 'candy-rain',
});
expect(getInitialVibe().id).toBe('moon-orchid');
});
it('uses a valid stored vibe id when the URL does not provide one', () => {
it('uses a valid stored vibe id', () => {
setBrowserVibeState({ storedVibeId: 'sunlit-moss' });
expect(getInitialVibe().id).toBe('sunlit-moss');
});
it('falls back to the default preset for an unknown URL vibe id', () => {
setBrowserVibeState({
search: '?vibe=unknown',
storedVibeId: 'sunlit-moss',
});
it('falls back to the default preset for an unknown stored vibe id', () => {
setBrowserVibeState({ storedVibeId: 'unknown' });
expect(getInitialVibe()).toBe(VIBE_PRESETS[0]);
});
@ -107,7 +81,6 @@ describe('vibe and audio config contract', () => {
expect(gardenAudioConfig.colorVoices).toHaveLength(3);
gardenAudioConfig.colorVoices.forEach((voice) => {
expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true);
expect(Number.isFinite(voice.octaveOffset)).toBe(true);
expect(voice.velocityMultiplier).toBeGreaterThan(0);
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
});

23
src/vibes.ts Normal file
View file

@ -0,0 +1,23 @@
import { appConfig, type VibePreset } from './config';
export type { GardenVibeSettings, VibePreset } from './config';
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
export const hexToRgb = (hex: string): [number, number, number] => {
const value = hex.replace('#', '');
return [
parseInt(value.slice(0, 2), 16) / 255,
parseInt(value.slice(2, 4), 16) / 255,
parseInt(value.slice(4, 6), 16) / 255,
];
};
export const getInitialVibe = (): VibePreset => {
const id = localStorage.getItem(appConfig.storage.vibeKey);
return (
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
VIBE_PRESETS[0]
);
};