More clean up

This commit is contained in:
Andras Schmelczer 2026-05-20 21:03:41 +01:00
parent c94ffcc506
commit f03da42b5e
43 changed files with 827 additions and 1085 deletions

View file

@ -4,7 +4,7 @@ export const DISABLED_FLAG_VALUE = '0';
export const UNIT_INTERVAL_INPUT_MIN = '0';
export const UNIT_INTERVAL_INPUT_MAX = '1';
export const DEFAULT_AUDIO_VOLUME = 0.42;
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const APP_STORAGE_KEYS = {
audioMuted: 'fleeting-garden:audio-muted',

View file

@ -1,3 +1,4 @@
import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
@ -7,81 +8,106 @@ export interface GardenAudioChord {
quality: GardenAudioChordQuality;
}
export interface GardenAudioVibeProfile {
export interface GardenAudioVibeSettings {
idleIntensity: number;
bpm: number;
rampUpIntensity: number;
rampUpTime: number;
noteLength: number;
notePitchOffset: number;
brightness: number;
}
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
rootMidi: number;
scale: Array<number>;
brightness: number;
delayTimeMultiplier: number;
progression: Array<GardenAudioChord>;
}
export interface GardenAudioConfig {
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
export const createGardenAudioConfig = () => ({
masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeSeconds: number;
feedback: number;
wetGain: number;
erasingActivity: number;
activityFeedbackWeight: number;
feedbackMax: number;
feedbackMin: number;
outputActivityWeight: number;
outputBase: number;
outputActivityDuck: number;
timeRampSeconds: number;
};
timeSeconds: 0.46,
feedback: 0.12,
wetGain: 0.044,
erasingActivity: 0.12,
activityFeedbackWeight: 0.08,
feedbackMax: 0.32,
feedbackMin: 0.04,
outputActivityWeight: 0.5,
outputBase: 0.65,
outputActivityDuck: 0.28,
timeRampSeconds: 0.12,
},
piano: {
maxVoices: number;
gain: number;
sustainSeconds: number;
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
sustainBase: number;
sustainVelocityRange: number;
};
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
},
rhythm: {
bpm: number;
stepsPerBeat: number;
stepsPerBar: number;
sparseActivity: number;
};
idleIntensity: 0.08,
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
filterMaxHz: number;
durationSeconds: number;
pan: number;
pianoActivity: number;
};
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
durationSeconds: 0.08,
pan: 0,
pianoActivity: 0,
},
energy: {
attackSeconds: number;
decaySeconds: number;
immediateActivityScale: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
attackSeconds: 0.08,
decaySeconds: 0.9,
immediateActivityScale: 0.85,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
graph: {
pianoBusGains: Record<PianoNoteRole, number>;
pianoBusActivityDucking: Record<PianoNoteRole, number>;
noiseBusGain: number;
};
pianoBusGains: {
pad: 0.86,
support: 0.94,
texture: 0.88,
gesture: 1,
brush: 0.9,
stinger: 0.92,
} satisfies Record<PianoNoteRole, number>,
pianoBusActivityDucking: {
pad: 0.42,
support: 0.18,
texture: -0.06,
gesture: 0,
brush: -0.08,
stinger: 0,
} satisfies Record<PianoNoteRole, number>,
noiseBusGain: 0.72,
},
input: {
fullActivitySpeed: number;
activityNoiseFloorSpeed: number;
activityCurve: number;
activitySoftCeiling: number;
activityAttackSeconds: number;
activityReleaseSeconds: number;
minAudibleDistance: number;
manicActivityThreshold: number;
manicReleaseThreshold: number;
maniaSmoothingSeconds: number;
};
}
fullActivitySpeed: 0.86,
activityNoiseFloorSpeed: 0.025,
activityCurve: 0.74,
activitySoftCeiling: 0.96,
activityAttackSeconds: 0.055,
activityReleaseSeconds: 0.2,
minAudibleDistance: 0.0025,
manicActivityThreshold: 0.9,
manicReleaseThreshold: 0.76,
maniaSmoothingSeconds: 0.12,
},
});
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;

View file

@ -1,5 +1,5 @@
import { approach, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
export class GardenAudioEnergy {
private isGestureActive = false;
@ -19,13 +19,10 @@ export class GardenAudioEnergy {
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number): void {
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(
this.energy,
strokeEnergy * this.config.energy.immediateActivityScale
);
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
}
}
@ -34,7 +31,7 @@ export class GardenAudioEnergy {
this.energy = 0;
}
public update(now: number): void {
public update(now: number, profile: GardenAudioVibeProfile): void {
if (this.lastEnergyUpdateAt <= 0) {
this.lastEnergyUpdateAt = now;
return;
@ -51,7 +48,7 @@ export class GardenAudioEnergy {
if (!this.isGestureActive) {
timeConstant = this.config.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = this.config.energy.attackSeconds;
timeConstant = profile.rampUpTime;
}
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}

View file

@ -1,9 +1,17 @@
import { clamp } from '../utils/math';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { GardenAudioConfig } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type NavigatorWithAudioSession = Navigator & {
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
audioSession?: {
type:
| 'auto'
| 'playback'
| 'ambient'
| 'transient'
| 'transient-solo'
| 'play-and-record';
};
};
const outputHighPassFrequencyHz = 45;
@ -113,19 +121,19 @@ export class GardenAudioGraph {
);
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
public applyDelayProfile(): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.config.delay.timeSeconds,
this.context.currentTime,
this.config.delay.timeRampSeconds
);
}
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
public updateDelay(activity: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return;
}
@ -133,7 +141,7 @@ export class GardenAudioGraph {
const now = this.context.currentTime;
const normalizedActivity = clamp(activity, 0, 1);
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.config.delay.timeSeconds,
now,
this.config.delay.timeRampSeconds
);

View file

@ -1,6 +1,34 @@
import type { VibePreset } from '../vibes';
import type { GardenAudioVibeProfile } from './garden-audio-config';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const DEFAULT_ROOT_MIDI = 57;
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
let profile = profileCache.get(vibe);
if (!profile) {
profile = {
...vibe.audio,
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
scale: DEFAULT_SCALE as Array<number>,
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
};
profileCache.set(vibe, profile);
return profile;
}
Object.assign(profile, vibe.audio);
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
return profile;
};

View file

@ -32,6 +32,7 @@ export interface PianoNote {
role?: PianoNoteRole;
delaySend?: number;
lowpassHz?: number;
sustainSeconds?: number;
}
export type PianoNoteRole =

View file

@ -114,7 +114,7 @@ export class GardenAudio {
this.lifecycle = 'started';
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe));
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
const pianoLoad = this.piano.loadIfIdle(context);
@ -122,7 +122,7 @@ export class GardenAudio {
void pianoLoad
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime);
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
})
.catch((error) => {
@ -207,7 +207,8 @@ export class GardenAudio {
}
this.applyVibe(snapshot.vibe);
this.energy.update(context.currentTime);
const profile = getVibeProfile(snapshot.vibe);
this.energy.update(context.currentTime, profile);
if (snapshot.isErasing) {
this.energy.silence();
@ -254,7 +255,8 @@ export class GardenAudio {
return;
}
this.energy.recordStroke(strokeEnergy);
const profile = getVibeProfile(stroke.vibe);
this.energy.recordStroke(strokeEnergy, profile);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
@ -347,11 +349,10 @@ export class GardenAudio {
return;
}
const profile = getVibeProfile(snapshot.vibe);
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
this.graph.updateDelay(activity);
}
private applyVibe(vibe: VibePreset): void {
@ -360,7 +361,8 @@ export class GardenAudio {
}
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile();
this.pianoEngine.cue(this.graph.context.currentTime, profile);
}
}

View file

@ -37,10 +37,7 @@ const getChordIntervals = (
: chordVoicings.minorClosed;
};
const degreeToSemitone = (
profile: GardenAudioVibeProfile,
degree: number
): number => {
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
const scaleIndex =
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
const octave = Math.floor(degree / profile.scale.length);
@ -96,6 +93,7 @@ interface BrushPhraseLayer {
export class GenerativePianoEngine {
private nextBeatStep: number | null = null;
private timelineStartedAt: number | null = null;
private activeProfile: GardenAudioVibeProfile | null = null;
private isWaitingForGestureAccent = false;
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
private lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
@ -124,13 +122,17 @@ export class GenerativePianoEngine {
return generativePianoTuning;
}
public prime(now: number): void {
public prime(now: number, profile: GardenAudioVibeProfile): void {
this.activeProfile = profile;
this.timelineStartedAt ??= now;
this.nextBeatStep ??= 0;
this.nextBrushStreamStep ??= 0;
}
public cue(now: number): void {
public cue(now: number, profile?: GardenAudioVibeProfile): void {
if (profile) {
this.activeProfile = profile;
}
this.nextBeatStep = 0;
this.timelineStartedAt = now;
this.nextBrushStreamStep = 0;
@ -148,10 +150,9 @@ export class GenerativePianoEngine {
}
public release(vibe: VibePreset, now: number): number {
this.prime(now);
this.isWaitingForGestureAccent = false;
const profile = getVibeProfile(vibe);
this.prime(now, profile);
this.isWaitingForGestureAccent = false;
const releaseStep = this.getReleaseResolutionStep(now);
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
@ -198,7 +199,8 @@ export class GenerativePianoEngine {
activity,
maniaAmount = 0,
}: StrokeAccentRequest): void {
this.prime(now);
const profile = getVibeProfile(vibe);
this.prime(now, profile);
const strength = clamp01(activity);
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
@ -242,14 +244,14 @@ export class GenerativePianoEngine {
activity,
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
}: RenderLookaheadRequest): void {
this.prime(now);
const profile = getVibeProfile(vibe);
this.prime(now, profile);
this.skipLateBeats(now);
if (this.nextBeatStep === null) {
return;
}
const profile = getVibeProfile(vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
@ -286,7 +288,7 @@ export class GenerativePianoEngine {
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
);
this.playNote({
this.playProfileNote(profile, {
midi,
velocity: stinger.velocities[index],
pan: stinger.pans[index],
@ -302,6 +304,7 @@ export class GenerativePianoEngine {
public reset(): void {
this.nextBeatStep = null;
this.timelineStartedAt = null;
this.activeProfile = null;
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
@ -314,6 +317,13 @@ export class GenerativePianoEngine {
this.brushStreamNoteCountsByBar.clear();
}
private playProfileNote(profile: GardenAudioVibeProfile, note: PianoNote): void {
this.playNote({
...note,
sustainSeconds: profile.noteLength,
});
}
private renderBeat({
profile,
beatIndex,
@ -398,7 +408,7 @@ export class GenerativePianoEngine {
false
);
this.lastPadMidiByVoice[index] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
velocity + expression * this.generation.padChord.expressionVelocityWeight,
@ -433,8 +443,13 @@ export class GenerativePianoEngine {
offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index];
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
this.playNote({
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
register,
null,
false
);
this.playProfileNote(profile, {
midi,
velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds,
@ -470,7 +485,7 @@ export class GenerativePianoEngine {
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.supportNote.velocityBase +
@ -516,7 +531,7 @@ export class GenerativePianoEngine {
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.textureNote.velocityBase +
@ -563,7 +578,7 @@ export class GenerativePianoEngine {
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.gestureAccent.velocityBase +
@ -608,7 +623,7 @@ export class GenerativePianoEngine {
this.lastMidiByStyle[styleIndex] = midi;
this.lastBrushStreamMidi = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.touchNote.velocityBase +
@ -818,7 +833,7 @@ export class GenerativePianoEngine {
this.lastBrushStreamMidi = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.brushStream.velocityBase +
@ -851,7 +866,7 @@ export class GenerativePianoEngine {
this.generation.brushStreamEcho.maxMidi
? midi + this.generation.brushStreamEcho.octaveSemitones
: midi - this.generation.brushStreamEcho.octaveSemitones;
this.playNote({
this.playProfileNote(profile, {
midi: echoMidi,
velocity:
(this.generation.brushStreamEcho.velocityBase +
@ -1159,14 +1174,18 @@ export class GenerativePianoEngine {
}
private getExpression(activity: number): number {
const liftedActivity = Math.max(
activity,
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
);
return clamp01(
(activity - this.config.rhythm.sparseActivity) /
(liftedActivity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
}
private getBeatDurationSeconds(): number {
return 60 / this.config.rhythm.bpm;
return 60 / (this.activeProfile?.bpm ?? this.config.rhythm.bpm);
}
private getStepDurationSeconds(): number {

View file

@ -72,6 +72,7 @@ export class PianoSampler {
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
}: PianoNote): void {
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
@ -92,12 +93,11 @@ export class PianoSampler {
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
profileSustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createBufferSource();
@ -150,7 +150,7 @@ export class PianoSampler {
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
durationSeconds + profileSustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);

View file

@ -1,4 +1,5 @@
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants';
import { createGardenAudioConfig } from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
@ -14,90 +15,7 @@ export type {
} from './config/types';
export const appConfig = {
audio: {
masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeSeconds: 0.46,
feedback: 0.12,
wetGain: 0.044,
erasingActivity: 0.12,
activityFeedbackWeight: 0.08,
feedbackMax: 0.32,
feedbackMin: 0.04,
outputActivityWeight: 0.5,
outputBase: 0.65,
outputActivityDuck: 0.28,
timeRampSeconds: 0.12,
},
piano: {
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
},
rhythm: {
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
durationSeconds: 0.08,
pan: 0,
pianoActivity: 0,
},
energy: {
attackSeconds: 0.08,
decaySeconds: 0.9,
immediateActivityScale: 0.85,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
graph: {
pianoBusGains: {
pad: 0.86,
support: 0.94,
texture: 0.88,
gesture: 1,
brush: 0.9,
stinger: 0.92,
},
pianoBusActivityDucking: {
pad: 0.42,
support: 0.18,
texture: -0.06,
gesture: 0,
brush: -0.08,
stinger: 0,
},
noiseBusGain: 0.72,
},
input: {
fullActivitySpeed: 0.86,
activityNoiseFloorSpeed: 0.025,
activityCurve: 0.74,
activitySoftCeiling: 0.96,
activityAttackSeconds: 0.055,
activityReleaseSeconds: 0.2,
minAudibleDistance: 0.0025,
manicActivityThreshold: 0.9,
manicReleaseThreshold: 0.76,
maniaSmoothingSeconds: 0.12,
},
},
audio: createGardenAudioConfig(),
deltaTime: {
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
@ -124,7 +42,7 @@ export const appConfig = {
noiseHashMultiplier: 43758.5453123,
noiseHashX: 12.9898,
noiseHashY: 78.233,
noiseTextureFormat: 'rgba8unorm',
noiseTextureFormat: 'r8unorm',
noiseTextureSize: 2048,
},
brush: {
@ -261,7 +179,7 @@ export const appConfig = {
},
},
tuningPane: {
expandedDepth: 1,
showFpsOverlay: import.meta.env.DEV,
startHidden: true,
title: 'Garden Config',
},

View file

@ -1,12 +1,11 @@
import { colorInteractionSettings } from './color-interactions';
import type { GardenAppConfig } from './types';
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
selectedColorIndex: 0,
...colorInteractionSettings,
turnWhenLost: 0.8,
forwardRotationScale: 0.25,
sensorOffsetAngle: 32,
introNearDistanceMin: 28,
introNearDistanceInner: 4,
introNearSensorOffsetMultiplier: 0.75,
@ -19,7 +18,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
introStepStopDistance: 0.5,
randomTimeScale: 0.34816,
diffusionRateBrush: 0.35,
diffusionRateTrails: 0.22,
decayRateBrush: 18,
diffusionDecayRateDivisor: 1000,
diffusionNeighborDivisor: 8,
@ -33,7 +32,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushCurveSegmentBrushRadiusRatio: 0.65,
brushSmoothingMinSampleDistance: 0.5,
brushSizeVariation: 0.5,
brushAlpha: 1,
brushDiscardThreshold: 0.02,
brushCoarseNoiseScale: 160,
@ -58,5 +56,4 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
renderTraceNormalizationFloor: 1,
renderBrushColorBase: 1.2,
renderBrushColorStrengthMultiplier: 1.6,
backgroundGrainStrength: 0.018,
};

View file

@ -11,380 +11,100 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color3ToColor1: colorInteractionControl('3 -> 1'),
color3ToColor2: colorInteractionControl('3 -> 2'),
color3ToColor3: colorInteractionControl('3 -> 3'),
brushEffectDuration: {
folder: 'Diffusion',
min: 0.5,
max: 20,
step: 0.05,
},
brushSize: {
folder: 'Brush',
label: 'brush size',
min: 1,
max: 60,
step: 0.25,
},
spawnPerPixel: {
folder: 'Brush',
label: 'agents per brush pixel',
min: 0.01,
max: 1,
step: 0.001,
},
brushSizeVariation: {
folder: 'Brush',
label: 'brush variance',
min: 0,
max: 1,
step: 0.01,
},
brushAlpha: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.001,
},
brushDiscardThreshold: {
folder: 'Brush',
min: 0,
max: 0.25,
step: 0.001,
},
brushCoarseNoiseScale: {
folder: 'Brush',
min: 1,
max: 480,
step: 1,
},
brushGrainNoiseScale: {
folder: 'Brush',
min: 1,
max: 180,
step: 1,
},
brushGrainNoiseOffsetX: {
folder: 'Brush',
min: -2,
max: 2,
step: 0.01,
},
brushGrainNoiseOffsetY: {
folder: 'Brush',
min: -2,
max: 2,
step: 0.01,
},
brushGrainMinStrength: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.001,
},
brushGrainMaxStrength: {
folder: 'Brush',
min: 0,
max: 2,
step: 0.001,
},
brushCurveResolution: {
folder: 'Brush',
integer: true,
label: 'curve resolution',
min: 1,
max: 32,
step: 1,
},
brushSmoothingMinSampleDistance: {
folder: 'Brush',
min: 0,
max: 4,
step: 0.01,
},
brushCurveMinSegmentSpacing: {
folder: 'Brush',
min: 0.1,
max: 32,
step: 0.1,
},
brushCurveSegmentBrushRadiusRatio: {
folder: 'Brush',
min: 0,
max: 4,
step: 0.01,
},
brushCurveMinBrushRadius: {
folder: 'Brush',
min: 0.1,
max: 16,
step: 0.1,
},
brushCurveMirrorResolutionExponent: {
folder: 'Brush',
min: 0,
max: 2,
step: 0.01,
},
clarity: {
folder: 'Render',
min: 0.00001,
max: 1,
step: 0.001,
},
renderTraceNormalizationFloor: {
folder: 'Render',
min: 0.01,
max: 4,
step: 0.01,
},
renderBrushColorBase: {
folder: 'Render',
min: 0,
max: 4,
step: 0.01,
},
renderBrushColorStrengthMultiplier: {
folder: 'Render',
min: 0,
max: 4,
step: 0.01,
},
backgroundGrainStrength: {
folder: 'Render',
min: 0,
max: 0.12,
step: 0.001,
},
internalRenderAreaMegapixels: {
folder: 'Render',
label: 'internal area (MP)',
min: 0.5,
max: 16.6,
step: 0.1,
},
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
max: 100,
step: 0.1,
},
decayRateTrails: {
folder: 'Diffusion',
min: 0.1,
max: 5000,
step: 1,
},
diffusionRateBrush: {
folder: 'Diffusion',
folder: 'Brush',
label: 'brush diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
diffusionDecayRateDivisor: {
folder: 'Diffusion',
min: 1,
max: 5000,
step: 1,
},
diffusionNeighborDivisor: {
folder: 'Diffusion',
min: 1,
max: 16,
step: 0.1,
},
brushDecayAlphaOffset: {
folder: 'Diffusion',
min: 1,
max: 1.1,
step: 0.0001,
},
diffusionRateTrails: {
folder: 'Diffusion',
sensorOffsetDistance: {
folder: 'Agents',
label: 'sensor distance',
min: 0,
max: 2,
step: 0.001,
},
eraserSize: {
folder: 'Brush',
integer: true,
min: 24,
max: 240,
step: 1,
},
eraserMaskAlphaThreshold: {
folder: 'Eraser',
min: 0,
max: 1,
step: 0.001,
},
eraserLineDistanceEpsilon: {
folder: 'Eraser',
min: 0,
max: 0.01,
step: 0.00001,
},
eraserClearRed: {
folder: 'Eraser',
min: 0,
max: 1,
step: 0.001,
},
eraserClearGreen: {
folder: 'Eraser',
min: 0,
max: 1,
step: 0.001,
},
eraserClearBlue: {
folder: 'Eraser',
min: 0,
max: 1,
step: 0.001,
},
eraserClearAlpha: {
folder: 'Eraser',
min: 0,
max: 1,
step: 0.001,
},
individualTrailWeight: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
adaptiveCapInitial: {
folder: 'Agent',
integer: true,
label: 'adaptive cap initial',
min: 50_000,
max: 2_000_000,
step: 10_000,
},
adaptiveCapMin: {
folder: 'Agent',
integer: true,
label: 'adaptive cap min',
min: 0,
max: 500_000,
step: 10_000,
},
maxAgentCount: {
folder: 'Agent',
integer: true,
label: 'max agent count',
step: 10_000,
},
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
min: 1,
max: 12,
max: 200,
step: 1,
},
moveSpeed: {
folder: 'Agent',
folder: 'Agents',
label: 'move speed',
min: 10,
max: 500,
step: 1,
},
selectedColorIndex: {
folder: 'Brush',
integer: true,
min: 0,
max: 2,
step: 1,
},
sensorOffsetAngle: {
folder: 'Agent',
min: 0,
max: 90,
step: 1,
},
sensorOffsetDistance: {
folder: 'Agent',
min: 0,
max: 200,
step: 1,
},
spawnPerPixel: {
folder: 'Agent',
min: 0.01,
max: 1,
step: 0.001,
},
turnSpeed: {
folder: 'Agent',
folder: 'Agents',
label: 'turn speed',
min: 1,
max: 200,
step: 1,
},
turnWhenLost: {
folder: 'Agent',
individualTrailWeight: {
folder: 'Agents',
label: 'individual trail weight',
min: 0,
max: 1,
step: 0.001,
},
forwardRotationScale: {
folder: 'Agent',
min: 0,
decayRateTrails: {
folder: 'Agents',
label: 'trail decay',
min: 800,
max: 1000,
step: 1,
},
clarity: {
folder: 'Look',
label: 'clarity',
min: 0.00001,
max: 1,
step: 0.001,
},
introNearDistanceMin: {
folder: 'Agent',
backgroundGrainStrength: {
folder: 'Look',
label: 'grain strength',
min: 0,
max: 100,
max: 0.12,
step: 0.001,
},
maxAgentCount: {
folder: 'Performance',
integer: true,
label: 'max agent count',
min: 0,
max: 2_000_000,
step: 10_000,
},
internalRenderAreaMegapixels: {
folder: 'Performance',
label: 'internal resolution (MP)',
min: 0.5,
max: 16.6,
step: 0.1,
},
introNearDistanceInner: {
folder: 'Agent',
min: 0,
max: 100,
step: 0.1,
},
introNearSensorOffsetMultiplier: {
folder: 'Agent',
min: 0,
max: 4,
step: 0.01,
},
introTargetAngleBlend: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
introProgressCutoff: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
introTurnRateMultiplier: {
folder: 'Agent',
min: 0,
max: 8,
step: 0.01,
},
introRandomTurnMultiplier: {
folder: 'Agent',
min: 0,
max: 2,
step: 0.001,
},
introFarMoveMultiplier: {
folder: 'Agent',
min: 0,
max: 6,
step: 0.01,
},
introNearMoveMultiplier: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
introStepStopDistance: {
folder: 'Agent',
min: 0,
max: 8,
step: 0.01,
},
randomTimeScale: {
folder: 'Agent',
min: 0,
max: 4,
step: 0.00001,
},
};

View file

@ -1,6 +1,6 @@
import type {
GardenAudioConfig,
GardenAudioVibeProfile,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
@ -45,19 +45,29 @@ export type GardenRuntimeSettings = {
DiffusionSettings &
RenderSettings;
type RuntimeSettingControlConfig = {
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
};
type RuntimeSettingControlConfig = Partial<
Record<keyof GardenRuntimeSettings, NumberControlConfig>
>;
type GardenVibeSettings = Pick<
GardenRuntimeSettings,
| 'backgroundGrainStrength'
| 'brushSize'
| 'brushSizeVariation'
| 'clarity'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'diffusionRateBrush'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
@ -83,7 +93,7 @@ export interface VibePreset {
colors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
audio: GardenAudioVibeSettings;
}
export interface GardenAppConfig {
@ -239,7 +249,7 @@ export interface GardenAppConfig {
};
};
tuningPane: {
expandedDepth: number;
showFpsOverlay: boolean;
startHidden: boolean;
title: string;
};

View file

@ -1,25 +1,15 @@
import type { GardenAudioChord } from '../audio/garden-audio-config';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import { colorInteractionSettings } from './color-interactions';
import { VibeId, type VibePreset } from './types';
const majorProgression: Array<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 suspendedPentatonic = [0, 2, 5, 7, 9];
const mixolydianPentatonic = [0, 2, 4, 7, 10];
const dorianHexatonic = [0, 2, 3, 5, 7, 10];
const darkMinorPentatonic = [0, 2, 3, 7, 10];
const defaultAudioSettings = {
idleIntensity: 0.08,
bpm: 74,
rampUpIntensity: 0.85,
rampUpTime: 0.08,
noteLength: 0.42,
notePitchOffset: 0,
} satisfies Omit<GardenAudioVibeSettings, 'brightness'>;
export const defaultVibeId = VibeId.CandyRain;
@ -34,23 +24,22 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [16, 21, 31],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 14,
brushSizeVariation: 0.5,
clarity: 0.62,
decayRateTrails: 965,
diffusionRateTrails: 0.22,
diffusionRateBrush: 0.35,
individualTrailWeight: 0.07,
moveSpeed: 82,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
},
audio: {
rootMidi: 57,
scale: majorPentatonic,
...defaultAudioSettings,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
},
},
{
@ -63,28 +52,22 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [23, 32, 22],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.014,
brushSize: 16,
brushSizeVariation: 0.35,
clarity: 0.68,
decayRateTrails: 975,
diffusionRateTrails: 0.18,
diffusionRateBrush: 0.28,
individualTrailWeight: 0.06,
moveSpeed: 70,
sensorOffsetAngle: 28,
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
},
audio: {
rootMidi: 53,
scale: mixolydianPentatonic,
...defaultAudioSettings,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
},
},
{
@ -97,23 +80,22 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [15, 24, 34],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.022,
brushSize: 13,
brushSizeVariation: 0.58,
clarity: 0.58,
decayRateTrails: 955,
diffusionRateTrails: 0.28,
diffusionRateBrush: 0.42,
individualTrailWeight: 0.055,
moveSpeed: 90,
sensorOffsetAngle: 36,
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
},
audio: {
rootMidi: 50,
scale: dorianHexatonic,
...defaultAudioSettings,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
},
},
{
@ -126,23 +108,22 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [20, 18, 29],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 12,
brushSizeVariation: 0.45,
clarity: 0.64,
decayRateTrails: 968,
diffusionRateTrails: 0.2,
diffusionRateBrush: 0.32,
individualTrailWeight: 0.065,
moveSpeed: 76,
sensorOffsetAngle: 32,
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
},
audio: {
rootMidi: 49,
scale: darkMinorPentatonic,
...defaultAudioSettings,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
},
{
@ -155,23 +136,22 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [25, 23, 22],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.024,
brushSize: 15,
brushSizeVariation: 0.62,
clarity: 0.55,
decayRateTrails: 948,
diffusionRateTrails: 0.32,
diffusionRateBrush: 0.48,
individualTrailWeight: 0.05,
moveSpeed: 96,
sensorOffsetAngle: 40,
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
},
audio: {
rootMidi: 56,
scale: majorPentatonic,
...defaultAudioSettings,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
},
{
@ -184,28 +164,22 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [16, 24, 32],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.012,
brushSize: 18,
brushSizeVariation: 0.28,
clarity: 0.7,
decayRateTrails: 982,
diffusionRateTrails: 0.14,
diffusionRateBrush: 0.24,
individualTrailWeight: 0.075,
moveSpeed: 62,
sensorOffsetAngle: 26,
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
},
audio: {
rootMidi: 62,
scale: suspendedPentatonic,
...defaultAudioSettings,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
},
},
];

View file

@ -36,7 +36,6 @@ export class GameLoopResources {
public readonly eraserAgentPipeline: EraserAgentPipeline;
public readonly eraserTexturePipeline: EraserTexturePipeline;
public readonly diffusionPipeline: DiffusionPipeline;
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
public readonly renderPipeline: RenderPipeline;
private readonly frameRenderer: SimulationFrameRenderer;
@ -74,7 +73,6 @@ export class GameLoopResources {
);
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
@ -83,7 +81,6 @@ export class GameLoopResources {
eraserAgentPipeline: this.eraserAgentPipeline,
eraserTexturePipeline: this.eraserTexturePipeline,
diffusionPipeline: this.diffusionPipeline,
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
renderPipeline: this.renderPipeline,
});
}
@ -94,6 +91,7 @@ export class GameLoopResources {
public clearSimulation(): void {
this.textures.clear();
this.frameRenderer.resetSourceMapActivity();
}
public setFrameParameters({
@ -115,6 +113,7 @@ export class GameLoopResources {
this.agentPipeline.setParameters({
...settings,
deltaTime,
time,
agentCount: activeAgentCount,
moveSpeed:
settings.moveSpeed *
@ -138,6 +137,7 @@ export class GameLoopResources {
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
maskSize: canvasSize,
});
this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize,
@ -147,7 +147,6 @@ export class GameLoopResources {
eraserClearBlue: settings.eraserClearBlue,
eraserClearAlpha: settings.eraserClearAlpha,
});
this.setBrushEffectDiffusionParameters();
}
public executeFrame(
@ -164,20 +163,8 @@ export class GameLoopResources {
this.eraserAgentPipeline.destroy();
this.eraserTexturePipeline.destroy();
this.diffusionPipeline.destroy();
this.brushEffectDiffusionPipeline.destroy();
this.renderPipeline.destroy();
this.commonState.destroy();
this.textures.destroy();
}
private setBrushEffectDiffusionParameters(): void {
const framesToOneE = Math.max(
1,
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
);
this.brushEffectDiffusionPipeline.setParameters({
...settings,
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
});
}
}

View file

@ -29,7 +29,7 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsOverlay: DevStatsOverlay | null;
private devStatsOverlay: DevStatsOverlay | null = null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
@ -47,9 +47,6 @@ export default class GameLoop {
ui: GardenUi
) {
this.resize();
this.devStatsOverlay = import.meta.env.DEV
? new DevStatsOverlay(canvas.parentElement ?? document.body)
: null;
this.resources = new GameLoopResources(
canvas,
device,
@ -103,8 +100,12 @@ export default class GameLoop {
});
window.addEventListener('resize', this.resizeListener);
this.pointerInput.attach();
this.eraserPreviewController.attach();
this.syncDevStatsOverlay();
}
public attachPointerInput(): void {
this.pointerInput.attach();
}
public setEraseMode(isErasing: boolean): void {
@ -118,6 +119,7 @@ export default class GameLoop {
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.syncDevStatsOverlay();
}
public setAudioMuted(isMuted: boolean): void {
@ -153,6 +155,7 @@ export default class GameLoop {
this.pointerInput.detach();
this.eraserPreviewController.detach();
this.devStatsOverlay?.destroy();
this.devStatsOverlay = null;
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
await this.agentPopulation.waitForCompaction();
@ -220,6 +223,18 @@ export default class GameLoop {
requestAnimationFrame(this.render);
};
private syncDevStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) {
this.devStatsOverlay ??= new DevStatsOverlay(
this.canvas.parentElement ?? document.body
);
return;
}
this.devStatsOverlay?.destroy();
this.devStatsOverlay = null;
}
private updateAccentColor(color: RgbColor): void {
const accentColor = rgbColorToCss(color);
if (this.previousAccentColor === accentColor) {

View file

@ -13,17 +13,26 @@ interface SimulationFramePipelines {
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
diffusionPipeline: DiffusionPipeline;
brushEffectDiffusionPipeline: DiffusionPipeline;
renderPipeline: RenderPipeline;
}
export class SimulationFrameRenderer {
private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
private sourceActiveFramesRemaining = 0;
private sourceMapsCleared = true;
public constructor(
private readonly device: GPUDevice,
private readonly textures: SimulationTextures,
private readonly pipelines: SimulationFramePipelines
) {}
public resetSourceMapActivity(): void {
this.sourceActiveFramesRemaining = 0;
this.sourceMapsCleared = true;
}
public execute(
isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null
@ -31,6 +40,7 @@ export class SimulationFrameRenderer {
const commandEncoder = this.device.createCommandEncoder();
this.textures.copyTrailMapAToB(commandEncoder);
let wroteSourceMap = false;
if (isErasing) {
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
const eraserMask = this.textures.eraserMask.getTextureView();
@ -38,18 +48,29 @@ export class SimulationFrameRenderer {
commandEncoder,
eraserMask,
this.textures.sourceMapA.getTextureView(),
this.textures.influenceMapA.getTextureView(),
this.textures.trailMapB.getTextureView()
);
this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask);
}
} else {
this.pipelines.brushPipeline.executeMultiTarget(
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget(
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.influenceMapA.getTextureView()
this.textures.sourceMapA.getTextureView()
);
}
if (wroteSourceMap) {
this.sourceActiveFramesRemaining =
SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE;
this.sourceMapsCleared = false;
}
const useSourceMap = this.sourceActiveFramesRemaining > 0;
if (!useSourceMap && !this.sourceMapsCleared) {
this.textures.clearSourceMaps(commandEncoder);
this.sourceMapsCleared = true;
}
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
@ -64,28 +85,24 @@ export class SimulationFrameRenderer {
const canvasTexture = this.pipelines.renderPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.sourceMapA.getTextureView()
this.textures.sourceMapA.getTextureView(),
useSourceMap
);
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
if (useSourceMap) {
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(),
this.textures.sourceMapB.getSize()
);
}
this.device.queue.submit([commandEncoder.finish()]);
canvasReadbackRequest?.afterSubmit();
const postRenderCommandEncoder = this.device.createCommandEncoder();
this.pipelines.diffusionPipeline.execute(
postRenderCommandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(),
this.textures.sourceMapB.getSize()
);
this.pipelines.brushEffectDiffusionPipeline.execute(
postRenderCommandEncoder,
this.textures.influenceMapA.getTextureView(),
this.textures.influenceMapB.getTextureView(),
this.textures.influenceMapB.getSize()
);
this.device.queue.submit([postRenderCommandEncoder.finish()]);
this.textures.swapBrushEffectMaps();
if (useSourceMap) {
this.textures.swapSourceMaps();
this.sourceActiveFramesRemaining -= 1;
}
}
}

View file

@ -8,8 +8,6 @@ export class SimulationTextures {
public readonly trailMapB: ResizableTexture;
public sourceMapA: ResizableTexture;
public sourceMapB: ResizableTexture;
public influenceMapA: ResizableTexture;
public influenceMapB: ResizableTexture;
public eraserMask: ResizableTexture;
public constructor(
@ -20,8 +18,6 @@ export class SimulationTextures {
this.trailMapB = this.createTexture(canvasSize);
this.sourceMapA = this.createTexture(canvasSize);
this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapA = this.createTexture(canvasSize);
this.influenceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createEraserMask(canvasSize);
}
@ -36,8 +32,6 @@ export class SimulationTextures {
this.trailMapB.resize(nextSize);
this.sourceMapA.resize(nextSize);
this.sourceMapB.resize(nextSize);
this.influenceMapA.resize(nextSize);
this.influenceMapB.resize(nextSize);
this.eraserMask.resize(nextSize);
return scale;
@ -50,8 +44,6 @@ export class SimulationTextures {
this.trailMapB,
this.sourceMapA,
this.sourceMapB,
this.influenceMapA,
this.influenceMapB,
this.eraserMask,
].forEach((texture) => {
const passEncoder = commandEncoder.beginRenderPass({
@ -79,9 +71,20 @@ export class SimulationTextures {
);
}
public swapBrushEffectMaps(): void {
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [this.sourceMapA, this.sourceMapB].map((texture) => ({
view: texture.getTextureView(),
clearValue: appConfig.simulation.clearColor,
loadOp: 'clear',
storeOp: 'store',
})),
});
passEncoder.end();
}
public swapSourceMaps(): void {
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
}
public destroy(): void {
@ -89,8 +92,6 @@ export class SimulationTextures {
this.trailMapB.destroy();
this.sourceMapA.destroy();
this.sourceMapB.destroy();
this.influenceMapA.destroy();
this.influenceMapB.destroy();
this.eraserMask.destroy();
}

View file

@ -31,7 +31,7 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number
appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
export const getToolbarContrastMetrics = (
const getToolbarContrastMetrics = (
pixels: Uint8Array,
sampleCount: number,
isBgra: boolean

View file

@ -16,7 +16,7 @@ import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { activeVibe, applyVibeSettings, settings } from './settings';
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
@ -148,7 +148,6 @@ const LOADING_MESSAGES = {
const VIBE_CHANGE_SOURCES = {
nextButton: 'next-button',
previousButton: 'previous-button',
settings: 'settings',
} as const;
const clampEraserSize = (value: number): number => {
@ -432,6 +431,7 @@ const main = async () => {
initAnalytics();
let shouldStop = false;
let hasStarted = false;
let game: GameLoop | null = null;
let configPane: ConfigPane | null = null;
@ -477,6 +477,7 @@ const main = async () => {
const startAudioFromUserGesture = (event: Event) => {
if (
!hasStarted ||
isAudioMuted ||
(event.target instanceof Node && elements.startButton.contains(event.target)) ||
(event.target instanceof Node && elements.soundButton.contains(event.target))
@ -652,28 +653,6 @@ const main = async () => {
},
onOpenChange: () => undefined,
onRuntimeChange: syncRuntimeUi,
onRuntimeReset: () => {
resetSettings();
game?.onVibeChanged();
syncRuntimeUi();
},
onRestart: () => game?.destroy(),
onVibeChange: (vibeId) => {
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
if (!vibe) {
return;
}
const activePreset = applyVibeSettings(vibe);
trackVibeChange({
vibeId: activePreset.id,
vibeName: activePreset.name,
source: VIBE_CHANGE_SOURCES.settings,
});
game?.onVibeChanged();
syncRuntimeUi();
game?.playVibeChangeAudio(false);
},
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
await fontsReady;
@ -702,6 +681,7 @@ const main = async () => {
await new Promise<void>((resolve) => {
const onClick = () => {
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
hasStarted = true;
game?.startAudio(true);
trackStart();
elements.splash.hidden = true;
@ -731,6 +711,7 @@ const main = async () => {
)
);
}
game.attachPointerInput();
await game.start();
}
} catch (e) {

View file

@ -1,17 +1,32 @@
import type { BindingParams, FolderApi } from '@tweakpane/core';
import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import {
appConfig,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
import {
hexColorToRgbColor,
rgbColorToCss,
rgbColorToHex,
type RgbColor,
} from '../utils/rgb-color';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
type VibeNumberKey = keyof GardenAudioVibeSettings;
interface PaneState extends GardenAudioVibeSettings {
backgroundColor: string;
color1: string;
color2: string;
color3: string;
}
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
@ -33,37 +48,54 @@ const colorReactionRows = [
},
] as const;
const colorReactionKeySet = new Set<string>(
colorReactionRows.flatMap((row) => [...row.keys])
);
const brushControlKeys = [
'brushSize',
'spawnPerPixel',
'brushSizeVariation',
'diffusionRateBrush',
] satisfies Array<RuntimeControlKey>;
const isColorReactionKey = (key: string): key is ColorReactionKey =>
colorReactionKeySet.has(key);
const agentControlKeys = [
'sensorOffsetDistance',
'moveSpeed',
'turnSpeed',
'individualTrailWeight',
'decayRateTrails',
] satisfies Array<RuntimeControlKey>;
const lookControlKeys = [
'clarity',
'backgroundGrainStrength',
] satisfies Array<RuntimeControlKey>;
const performanceControlKeys = [
'maxAgentCount',
'internalRenderAreaMegapixels',
] satisfies Array<RuntimeControlKey>;
const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey;
label: string;
min: number;
max: number;
step: number;
}> = [
{ key: 'idleIntensity', label: 'idle intensity', min: 0, max: 0.8, step: 0.01 },
{ key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 },
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
{ key: 'noteLength', label: 'note length', min: 0.1, max: 1.8, step: 0.01 },
{ key: 'notePitchOffset', label: 'higher / lower notes', min: -12, max: 12, step: 1 },
{ key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 },
];
interface ConfigPaneOptions {
onConfigChange: () => void;
onOpenChange?: (isOpen: boolean) => void;
onRestart: () => void;
onRuntimeChange: () => void;
onRuntimeReset: () => void;
onVibeChange: (vibeId: VibeId) => void;
settingsButton: HTMLButtonElement;
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
['boolean', 'number', 'string'].includes(typeof value);
const toLabel = (value: string): string =>
value
.replace(/\[(\d+)\]/g, ' $1')
.replace(/([A-Z])/g, ' $1')
.replace(/[-_]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
if (config.options) {
const optionValues = Object.values(config.options);
@ -81,12 +113,9 @@ const normalizeNumber = (value: number, config: NumberControlConfig): number =>
return config.integer ? Math.round(clampedValue) : clampedValue;
};
const getNumberBindingParams = (
key: keyof GardenRuntimeSettings & string,
config: NumberControlConfig
): BindingParams => {
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
const params: BindingParams = {
label: config.label ?? toLabel(key),
label: config.label,
options: config.options,
step: config.step,
};
@ -107,8 +136,12 @@ export class ConfigPane {
colorIndex: number;
element: HTMLElement;
}> = [];
private readonly state: { activeVibeId: VibeId } = {
activeVibeId: activeVibe.id,
private readonly state: PaneState = {
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
color1: rgbColorToHex(activeVibe.colors[0]),
color2: rgbColorToHex(activeVibe.colors[1]),
color3: rgbColorToHex(activeVibe.colors[2]),
...activeVibe.audio,
};
public constructor(private readonly options: ConfigPaneOptions) {
@ -142,12 +175,7 @@ export class ConfigPane {
this.options.settingsButton.addEventListener('click', this.toggle);
const tabs = this.pane.addTab({
pages: [{ title: 'Runtime' }, { title: 'Config' }],
});
this.setUpRuntimeTab(tabs.pages[0]);
this.setUpConfigTab(tabs.pages[1]);
this.setUpTuningPane(this.pane);
this.syncOpenState();
}
@ -156,7 +184,7 @@ export class ConfigPane {
}
public refresh(): void {
this.state.activeVibeId = activeVibe.id;
this.syncVibeState();
this.pane.refresh();
this.syncColorReactionMatrix();
this.syncOpenState();
@ -176,73 +204,116 @@ export class ConfigPane {
this.syncOpenState();
}
private setUpRuntimeTab(container: PaneContainer): void {
private setUpTuningPane(container: PaneContainer): void {
this.setUpVibeSection(container);
this.addRuntimeSection(container, 'Brush', brushControlKeys, true);
this.addRuntimeSection(container, 'Agents', agentControlKeys, true);
this.addColorReactionMatrix(container);
this.addRuntimeSection(container, 'Look', lookControlKeys, true);
const performanceFolder = this.addRuntimeSection(
container,
'Performance',
performanceControlKeys,
true
);
this.addFpsOverlayBinding(performanceFolder);
this.setUpMusicSection(container);
this.syncColorReactionMatrix();
}
private setUpVibeSection(container: PaneContainer): void {
const folder = container.addFolder({
title: 'Vibe',
expanded: true,
});
this.addColorBinding(folder, 'color1', 'colour 1', (color) => {
activeVibe.colors[0] = color;
});
this.addColorBinding(folder, 'color2', 'colour 2', (color) => {
activeVibe.colors[1] = color;
});
this.addColorBinding(folder, 'color3', 'colour 3', (color) => {
activeVibe.colors[2] = color;
});
this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => {
activeVibe.backgroundColor = color;
});
if (import.meta.env.DEV) {
folder
.addButton({ title: 'Copy vibe preset' })
.on('click', () => void this.copyVibePresetToClipboard());
}
}
private addColorBinding(
container: PaneContainer,
key: VibeColorKey,
label: string,
updateColor: (color: RgbColor) => void
): void {
container
.addBinding(this.state, 'activeVibeId', {
label: 'active vibe',
options: Object.fromEntries(
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
) as Record<string, VibeId>,
})
.addBinding(this.state, key, {
label,
view: 'color',
} as BindingParams)
.on('change', ({ value }) => {
if (!isVibeId(value)) {
this.refresh();
const color = hexColorToRgbColor(String(value));
if (!color) {
this.syncVibeState();
this.pane.refresh();
return;
}
this.options.onVibeChange(value);
this.refresh();
});
container.addButton({ title: 'Reset runtime settings' }).on('click', () => {
this.options.onRuntimeReset();
this.refresh();
});
updateColor(color);
this.syncColorReactionMatrix();
this.options.onConfigChange();
});
}
private addRuntimeSection(
container: PaneContainer,
title: string,
keys: ReadonlyArray<RuntimeControlKey>,
expanded: boolean
): PaneContainer {
const folder = container.addFolder({ title, expanded });
keys.forEach((key) => this.addRuntimeBinding(folder, key));
return folder;
}
private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumber(settings[key], config);
container
.addButton({
title: 'Restart simulation',
})
.on('click', () => this.options.onRestart());
const folders = new Map<string, PaneContainer>();
let hasAddedColorReactionMatrix = false;
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
const settingKey = key as keyof GardenRuntimeSettings & string;
settings[settingKey] = normalizeNumber(settings[settingKey], config);
if (isColorReactionKey(key)) {
if (!hasAddedColorReactionMatrix) {
this.addColorReactionMatrix(container);
hasAddedColorReactionMatrix = true;
.addBinding(settings, key, getNumberBindingParams(config))
.on('change', () => {
const nextValue = normalizeNumber(settings[key], config);
if (nextValue !== settings[key]) {
settings[key] = nextValue;
this.pane.refresh();
}
return;
}
this.options.onRuntimeChange();
});
}
const folder =
folders.get(config.folder) ??
container.addFolder({
title: config.folder,
expanded: config.folder !== 'Runtime',
});
folders.set(config.folder, folder);
folder
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
.on('change', () => {
const nextValue = normalizeNumber(settings[settingKey], config);
if (nextValue !== settings[settingKey]) {
settings[settingKey] = nextValue;
this.pane.refresh();
}
this.options.onRuntimeChange();
});
});
this.syncColorReactionMatrix();
private addFpsOverlayBinding(container: PaneContainer): void {
container
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
label: 'FPS overlay',
})
.on('change', () => this.options.onConfigChange());
}
private addColorReactionMatrix(container: PaneContainer): void {
const folder = container.addFolder({
title: 'Color Reactions',
title: 'Follow / Ignore / Avoid',
expanded: true,
});
folder.element.classList.add('color-reaction-folder');
@ -319,6 +390,10 @@ export class ConfigPane {
);
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return cell;
}
Object.entries(config.options ?? {}).forEach(([label, value]) => {
const option = doc.createElement('option');
option.value = String(value);
@ -341,6 +416,10 @@ export class ConfigPane {
private syncColorReactionMatrix(): void {
this.colorReactionSelects.forEach((select, key) => {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumber(settings[key], config);
select.value = String(settings[key]);
});
@ -350,96 +429,66 @@ export class ConfigPane {
});
}
private setUpConfigTab(container: PaneContainer): void {
this.addObjectBindings(
container,
appConfig as unknown as Record<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);
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}`
);
return;
}
if (isPlainObject(item)) {
this.addObjectBindings(
folder.addFolder({
title: `[${index}]`,
expanded: false,
}),
item,
[...path, key, String(index)]
);
}
});
return;
}
if (isPlainObject(value)) {
this.addObjectBindings(
container.addFolder({
title: toLabel(key),
expanded: path.length < appConfig.tuningPane.expandedDepth,
}),
value,
[...path, key]
);
}
private setUpMusicSection(container: PaneContainer): void {
const folder = container.addFolder({ title: 'Music', expanded: true });
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step });
});
}
private addPrimitiveBinding(
private addVibeNumberBinding(
container: PaneContainer,
source: Record<string, unknown>,
key: string
key: VibeNumberKey,
config: NumberControlConfig
): void {
const params: BindingParams = {
label: toLabel(key),
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
};
this.state[key] = normalizeNumber(this.state[key], config);
container
.addBinding(source, key, params)
.on('change', () => this.options.onConfigChange());
.addBinding(this.state, key, getNumberBindingParams(config))
.on('change', () => {
const nextValue = normalizeNumber(this.state[key], config);
if (nextValue !== this.state[key]) {
this.state[key] = nextValue;
this.pane.refresh();
}
activeVibe.audio[key] = nextValue;
this.options.onConfigChange();
});
}
private syncButton(): void {
this.options.settingsButton.classList.toggle('active', this.isOpen);
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
this.options.settingsButton.setAttribute(
'aria-label',
this.isOpen ? 'Hide config overlay' : 'Show config overlay'
);
this.options.settingsButton.title = this.isOpen
? 'Hide config overlay'
: 'Show config overlay';
private async copyVibePresetToClipboard(): Promise<void> {
const settingKeys = Object.keys(activeVibe.settings) as Array<
keyof typeof activeVibe.settings
>;
const preset = {
name: `${activeVibe.name} Copy`,
colors: activeVibe.colors,
backgroundColor: activeVibe.backgroundColor,
settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])),
audio: activeVibe.audio,
};
try {
await navigator.clipboard.writeText(JSON.stringify(preset, null, 2));
} catch (error) {
console.warn('Could not copy vibe preset to clipboard.', error);
}
}
private syncVibeState(): void {
this.state.color1 = rgbColorToHex(activeVibe.colors[0]);
this.state.color2 = rgbColorToHex(activeVibe.colors[1]);
this.state.color3 = rgbColorToHex(activeVibe.colors[2]);
this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor);
Object.assign(this.state, activeVibe.audio);
}
private syncOpenState(): void {
this.syncButton();
const { settingsButton } = this.options;
const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay';
settingsButton.classList.toggle('active', this.isOpen);
settingsButton.setAttribute('aria-expanded', String(this.isOpen));
settingsButton.setAttribute('aria-label', label);
settingsButton.title = label;
this.options.onOpenChange?.(this.isOpen);
}
}

View file

@ -1,28 +1,19 @@
export const AGENT_WORKGROUP_SIZE = 64;
export const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535;
export const AGENT_MAX_DISPATCHABLE_COUNT = 0xffffffff;
const AGENT_WORKGROUP_SIZE = 64;
const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535;
export const AGENT_MAX_DISPATCHABLE_COUNT =
AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION * AGENT_WORKGROUP_SIZE;
export const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => {
const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => {
if (!Number.isFinite(agentCount) || agentCount <= 0) {
throw new Error('Agent count must be a positive finite number');
}
const workgroupCount = Math.ceil(agentCount / AGENT_WORKGROUP_SIZE);
if (workgroupCount <= AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
return [workgroupCount, 1];
}
const workgroupX = Math.min(
Math.ceil(Math.sqrt(workgroupCount)),
AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION
);
const workgroupY = Math.ceil(workgroupCount / workgroupX);
if (workgroupY > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
if (workgroupCount > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
throw new Error('Agent count exceeds dispatchable workgroup range');
}
return [workgroupX, workgroupY];
return [workgroupCount, 1];
};
export const dispatchAgentWorkgroups = (

View file

@ -9,6 +9,8 @@ struct Counters {
aliveAgentCount: atomic<u32>,
};
const clearCompactedTailStride = 4u;
@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>;
@ -20,10 +22,9 @@ var<workgroup> clearAliveAgentCount: u32;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) num_workgroups: vec3<u32>
@builtin(local_invocation_id) local_id: vec3<u32>
) {
let id = get_id(global_id, num_workgroups);
let id = get_id(global_id);
if local_id.x == 0u {
atomicStore(&workgroupAliveCount, 0u);
@ -63,10 +64,9 @@ fn main(
@compute @workgroup_size(64)
fn clearCompactedTail(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) num_workgroups: vec3<u32>
@builtin(local_invocation_id) local_id: vec3<u32>
) {
let id = get_id(global_id, num_workgroups);
let id = get_id(global_id);
if local_id.x == 0u {
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
@ -74,11 +74,11 @@ fn clearCompactedTail(
workgroupBarrier();
if id >= settings.agentCount {
return;
}
if id >= clearAliveAgentCount {
compactedAgents[id].colorIndex = -1.0;
let firstClearId = clearAliveAgentCount + id * clearCompactedTailStride;
for (var offset = 0u; offset < clearCompactedTailStride; offset += 1u) {
let clearId = firstClearId + offset;
if clearId < settings.agentCount {
compactedAgents[clearId].colorIndex = -1.0;
}
}
}

View file

@ -1,21 +1,19 @@
import { vec2 } from 'gl-matrix';
import { smartCompile } from '../../../utils/graphics/smart-compile';
import {
AGENT_MAX_DISPATCHABLE_COUNT,
dispatchAgentWorkgroups,
} from '../agent-dispatch';
import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
import compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
export const AGENT_FLOAT_COUNT = 8;
export const AGENT_SIZE_IN_BYTES =
AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
export class AgentGenerationPipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 1;
private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4;
private static readonly ALLOCATION_GROWTH_FACTOR = 1.25;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer;
@ -33,21 +31,14 @@ export class AgentGenerationPipeline {
private allocatedMaxAgentCount: number;
private readonly countersBuffer: GPUBuffer;
private readonly countersStagingBuffer: GPUBuffer;
private readonly counterClearValues = new Uint32Array(
AgentGenerationPipeline.COUNTER_COUNT
);
private readonly agentCountUniformValues = new Uint32Array(
AgentGenerationPipeline.UNIFORM_COUNT
);
private readonly resizeUniformBuffer = new ArrayBuffer(
AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
);
private readonly resizeUniformFloatValues = new Float32Array(
this.resizeUniformBuffer
);
private readonly resizeUniformUintValues = new Uint32Array(
this.resizeUniformBuffer
);
private readonly resizeUniformFloatValues = new Float32Array(this.resizeUniformBuffer);
private readonly resizeUniformUintValues = new Uint32Array(this.resizeUniformBuffer);
public constructor(
private readonly device: GPUDevice,
@ -173,11 +164,19 @@ export class AgentGenerationPipeline {
requestedMaxAgentCount: number,
activeAgentCount: number
): number {
const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
if (nextMaxAgentCount <= this.allocatedMaxAgentCount) {
const requestedClampedMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
if (requestedClampedMaxAgentCount <= this.allocatedMaxAgentCount) {
return this.allocatedMaxAgentCount;
}
const nextMaxAgentCount = this.clampMaxAgentCount(
Math.max(
requestedClampedMaxAgentCount,
Math.ceil(
this.allocatedMaxAgentCount * AgentGenerationPipeline.ALLOCATION_GROWTH_FACTOR
)
)
);
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
const previousMaxAgentCount = this.allocatedMaxAgentCount;
this.allocatedMaxAgentCount = nextMaxAgentCount;
@ -270,16 +269,19 @@ export class AgentGenerationPipeline {
this.inactiveAgentsBuffer = this.createAgentsBuffer();
this.agentCountUniformValues[0] = agentCount;
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, agentCount);
passEncoder.setPipeline(this.clearCompactedTailPipeline);
dispatchAgentWorkgroups(passEncoder, agentCount);
dispatchAgentWorkgroups(
passEncoder,
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
);
passEncoder.end();
commandEncoder.copyBufferToBuffer(

View file

@ -8,10 +8,9 @@ struct ResizeSettings {
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) num_workgroups: vec3<u32>
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id, num_workgroups);
let id = get_id(global_id);
if id >= resizeSettings.agentCount {
return;

View file

@ -11,6 +11,6 @@ struct Agent {
const agentWorkgroupSize = 64u;
fn get_id(global_id: vec3<u32>, num_workgroups: vec3<u32>) -> u32 {
return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize;
fn get_id(global_id: vec3<u32>) -> u32 {
return global_id.x;
}

View file

@ -79,10 +79,12 @@ export class AgentPipeline {
introNearMoveMultiplier,
introStepStopDistance,
randomTimeScale,
time,
agentCount,
introProgress,
}: AgentSettings & {
deltaTime: number;
time: number;
agentCount: number;
introProgress?: number;
}) {
@ -117,7 +119,7 @@ export class AgentPipeline {
this.uniformValues[26] = introFarMoveMultiplier;
this.uniformValues[27] = introNearMoveMultiplier;
this.uniformValues[28] = introStepStopDistance;
this.uniformValues[29] = randomTimeScale;
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,

View file

@ -28,7 +28,7 @@ struct Settings {
introFarMoveMultiplier: f32,
introNearMoveMultiplier: f32,
introStepStopDistance: f32,
randomTimeScale: f32,
randomTimeSeed: u32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -37,10 +37,9 @@ struct Settings {
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) num_workgroups: vec3<u32>
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id, num_workgroups);
let id = get_id(global_id);
if id >= settings.agentCount {
return;
@ -65,7 +64,8 @@ fn main(
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id, state.time);
let randomSeed = random_seed(id);
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
var rotation = 0.0;
var step = vec2<f32>(0.0, 0.0);
@ -108,16 +108,18 @@ fn main(
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
settings.sensorOffset,
maxPosition
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
settings.sensorOffset,
maxPosition
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
@ -138,7 +140,6 @@ fn main(
step = direction * settings.moveRate;
}
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
let nextPosition = clamp(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_float(randomSeed + 22695477u) - 0.5;
@ -155,11 +156,16 @@ fn main(
agents[id].position = nextPosition;
}
fn sensor_position(agentPosition: vec2<f32>, direction: vec2<f32>, sensorOffset: f32) -> vec2<i32> {
fn sensor_position(
agentPosition: vec2<f32>,
direction: vec2<f32>,
sensorOffset: f32,
maxPosition: vec2<f32>
) -> vec2<i32> {
return vec2<i32>(clamp(
agentPosition + direction * sensorOffset,
vec2<f32>(0, 0),
state.size - vec2<f32>(1, 1)
maxPosition
));
}
@ -212,9 +218,8 @@ fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
}
fn random_seed(id: u32, time: f32) -> u32 {
let timeSeed = u32(time * settings.randomTimeScale);
return id * 747796405u + timeSeed * 2891336453u;
fn random_seed(id: u32) -> u32 {
return id * 747796405u + settings.randomTimeSeed * 2891336453u;
}
fn random_float(seed: u32) -> f32 {

View file

@ -25,7 +25,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
? pixelRatio
: 1;
export const setBrushUniformValues = (
const setBrushUniformValues = (
target: Float32Array,
{
brushSize,
@ -105,7 +105,7 @@ export class BrushPipeline {
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2);
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 1);
this.uniforms = this.device.createBuffer({
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -209,12 +209,10 @@ export class BrushPipeline {
public executeMultiTarget(
commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView,
influenceMapOut: GPUTextureView
): void {
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
sourceMapOut: GPUTextureView
): boolean {
return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
sourceMapOut,
influenceMapOut,
]);
}
@ -222,9 +220,9 @@ export class BrushPipeline {
commandEncoder: GPUCommandEncoder,
pipeline: GPURenderPipeline,
textureViews: Array<GPUTextureView>
): void {
): boolean {
if (this.lineCount === 0) {
return;
return false;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
@ -242,6 +240,7 @@ export class BrushPipeline {
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
passEncoder.end();
return true;
}
public destroy() {

View file

@ -25,7 +25,6 @@ struct VertexOutput {
struct BrushTargets {
@location(0) source: vec4<f32>,
@location(1) influence: vec4<f32>,
}
@vertex
@ -53,6 +52,22 @@ fn fragmentMrt(
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> BrushTargets {
let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared);
if(strength < settings.brushDiscardThreshold) {
discard;
}
let color = brushOutput(strength);
return BrushTargets(color);
}
fn brushStrength(
screenPosition: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let distanceSquared = distanceSquaredFromLine(
screenPosition,
start,
@ -60,24 +75,9 @@ fn fragmentMrt(
inverseLengthSquared
);
if distanceSquared > settings.brushGeometryRadiusSquared {
discard;
return 0.0;
}
let strength = brushStrength(screenPosition, distanceSquared);
if(strength < settings.brushDiscardThreshold) {
discard;
}
let color = brushOutput(strength);
return BrushTargets(color, color);
}
fn brushStrength(
screenPosition: vec2<f32>,
distanceSquared: f32
) -> f32 {
let distance = sqrt(distanceSquared);
let coarseNoise = textureSampleLevel(
noise,
noiseSampler,
@ -85,7 +85,7 @@ fn brushStrength(
0.0
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
let edge = 1.0 - step(radius, distance);
let edge = 1.0 - step(radius * radius, distanceSquared);
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
return 0.0;
}
@ -112,10 +112,6 @@ fn distanceSquaredFromLine(
) -> f32 {
let pa = position - start;
if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
@ -140,14 +136,13 @@ fn segment_vertex_position(
}
fn segment_vertex_corner(index: u32) -> vec2<f32> {
if index == 0u {
return vec2<f32>(-1.0, 1.0);
}
if index == 1u || index == 3u {
return vec2<f32>(-1.0, -1.0);
}
if index == 2u || index == 4u {
return vec2<f32>(1.0, 1.0);
}
return vec2<f32>(1.0, -1.0);
let corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, -1.0),
);
return corners[index];
}

View file

@ -20,6 +20,7 @@ const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba16float, write>;
var<workgroup> tile: array<vec4<f32>, 324>;
var<workgroup> tileTrailStrength: array<f32, 324>;
@compute @workgroup_size(16, 16)
fn main(
@ -28,31 +29,32 @@ fn main(
@builtin(workgroup_id) workgroup_id: vec3<u32>
) {
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
let textureSizeU32 = vec2<u32>(textureSize);
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
var tileIndex = localLinearIndex;
loop {
if tileIndex >= TILE_TEXEL_COUNT {
break;
}
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
let isInteriorTile =
workgroupOrigin.x > 0u &&
workgroupOrigin.y > 0u &&
workgroupOrigin.x + WORKGROUP_SIZE_X < textureSizeU32.x &&
workgroupOrigin.y + WORKGROUP_SIZE_Y < textureSizeU32.y;
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
let sourcePixelU32 =
workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y) +
tilePosition;
let sourcePixel = clamp(
vec2<i32>(i32(sourcePixelU32.x), i32(sourcePixelU32.y)) - vec2<i32>(1, 1),
vec2<i32>(0, 0),
textureSize - vec2<i32>(1, 1)
);
tile[tileIndex] = textureLoad(trailMap, sourcePixel, 0);
tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y;
let unclampedSourcePixel = vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1);
var sourcePixel = unclampedSourcePixel;
if !isInteriorTile {
sourcePixel = clamp(unclampedSourcePixel, vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1));
}
let texel = textureLoad(trailMap, sourcePixel, 0);
tile[tileIndex] = texel;
tileTrailStrength[tileIndex] = length(texel.rgb);
}
workgroupBarrier();
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
let inBounds = pixel.x < textureSize.x && pixel.y < textureSize.y;
if !inBounds {
return;
}
@ -110,11 +112,12 @@ fn propagate(
brushWeight: f32
) -> vec4<f32> {
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
let neighbour = tile[u32(neighbourIndex)];
let neighbourTileIndex = u32(neighbourIndex);
let neighbour = tile[neighbourTileIndex];
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
return vec4(
vec3(length(neighbour.rgb) * trailWeight),
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
neighbour.a * brushWeight
) * difference;
}

View file

@ -21,14 +21,14 @@ type DiffusionUniformSettings = Pick<
| 'brushDecayAlphaOffset'
>;
export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 /
(Number.isFinite(diffusionRate) &&
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
? diffusionRate
: appConfig.pipelines.diffusion.minDiffusionRate);
export const setDiffusionUniformValues = (
const setDiffusionUniformValues = (
target: Float32Array,
{
diffusionRateTrails,

View file

@ -1,3 +1,5 @@
import { vec2 } from 'gl-matrix';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@ -86,9 +88,11 @@ export class EraserAgentPipeline {
public setParameters({
agentCount,
eraserMaskAlphaThreshold,
maskSize,
}: {
agentCount: number;
eraserMaskAlphaThreshold: number;
maskSize: vec2;
}): void {
this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount;
@ -96,8 +100,8 @@ export class EraserAgentPipeline {
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1]));
writeFloat32BufferIfChanged(
this.device,
this.uniforms,

View file

@ -1,8 +1,8 @@
struct Settings {
agentCount: u32,
eraserMaskAlphaThreshold: f32,
padding1: f32,
padding2: f32,
maskWidth: u32,
maskHeight: u32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -10,10 +10,9 @@ struct Settings {
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) num_workgroups: vec3<u32>
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id, num_workgroups);
let id = get_id(global_id);
if id >= settings.agentCount {
return;
@ -24,7 +23,7 @@ fn main(
return;
}
let maskSize = vec2<i32>(textureDimensions(eraserMask));
let maskSize = vec2<i32>(i32(settings.maskWidth), i32(settings.maskHeight));
let maskPosition = clamp(
vec2<i32>(agents[id].position),
vec2<i32>(0, 0),

View file

@ -68,7 +68,6 @@ export class EraserTexturePipeline {
'r8unorm',
'rgba16float',
'rgba16float',
'rgba16float',
]);
this.uniforms = this.device.createBuffer({
@ -169,7 +168,6 @@ export class EraserTexturePipeline {
commandEncoder: GPUCommandEncoder,
eraserMaskOut: GPUTextureView,
sourceMapOut: GPUTextureView,
influenceMapOut: GPUTextureView,
trailMapOut: GPUTextureView
): void {
if (this.lineCount === 0) {
@ -200,11 +198,6 @@ export class EraserTexturePipeline {
loadOp: 'load',
storeOp: 'store',
},
{
view: influenceMapOut,
loadOp: 'load',
storeOp: 'store',
},
{
view: trailMapOut,
loadOp: 'load',

View file

@ -19,17 +19,10 @@ struct VertexOutput {
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct EraserTextureTargets {
@location(0) source: vec4<f32>,
@location(1) influence: vec4<f32>,
@location(2) trail: vec4<f32>,
}
struct EraserCombinedTargets {
@location(0) mask: vec4<f32>,
@location(1) source: vec4<f32>,
@location(2) influence: vec4<f32>,
@location(3) trail: vec4<f32>,
@location(2) trail: vec4<f32>,
}
@vertex
@ -50,35 +43,6 @@ fn vertex(
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> @location(0) vec4<f32> {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
return getEraserClearValue();
}
@fragment
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserTextureTargets {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
let cleared = getEraserClearValue();
return EraserTextureTargets(cleared, cleared, cleared);
}
@fragment
fn fragmentCombined(
@location(0) screenPosition: vec2<f32>,
@ -91,7 +55,7 @@ fn fragmentCombined(
}
let cleared = getEraserClearValue();
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared);
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared);
}
fn getEraserMaskValue() -> vec4<f32> {
@ -124,10 +88,6 @@ fn distanceSquaredFromLine(
) -> f32 {
let pa = position - start;
if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
@ -152,14 +112,13 @@ fn segment_vertex_position(
}
fn segment_vertex_corner(index: u32) -> vec2<f32> {
if index == 0u {
return vec2<f32>(-1.0, 1.0);
}
if index == 1u || index == 3u {
return vec2<f32>(-1.0, -1.0);
}
if index == 2u || index == 4u {
return vec2<f32>(1.0, 1.0);
}
return vec2<f32>(1.0, -1.0);
let corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, -1.0),
);
return corners[index];
}

View file

@ -14,6 +14,7 @@ export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly noSourcePipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@ -36,6 +37,7 @@ export class RenderPipeline {
const format = navigator.gpu.getPreferredCanvasFormat();
this.pipeline = this.createPipeline(format, vertex, 'fragment');
this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource');
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -112,7 +114,8 @@ export class RenderPipeline {
public execute(
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView
sourceTexture: GPUTextureView,
useSourceTexture = true
): GPUTexture {
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
const canvasTexture = this.context.getCurrentTexture();
@ -128,7 +131,7 @@ export class RenderPipeline {
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
passEncoder.setPipeline(useSourceTexture ? this.pipeline : this.noSourcePipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(3, 1);

View file

@ -27,9 +27,18 @@ fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
return renderColor(traces, sources, pixel);
}
@fragment
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), pixel);
}
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
let background = getTexturedBackground(pixel);
if max(max(max(traces.r, traces.g), traces.b), max(max(sources.r, sources.g), sources.b)) <= 0.0 {
let tracesMax = maxComponent(traces.rgb);
let sourcesMax = maxComponent(sources.rgb);
if max(tracesMax, sourcesMax) <= 0.0 {
return vec4(background, 1);
}
@ -38,13 +47,13 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
clarity(traces.g),
clarity(traces.b)
);
if max(max(sources.r, sources.g), sources.b) <= 0.0 {
if sourcesMax <= 0.0 {
let traceColor =
traceStrengths.r * settings.colorA
+ traceStrengths.g * settings.colorB
+ traceStrengths.b * settings.colorC;
let normalizedTraceColor = normalizeColorIntensity(traceColor);
let traceStrength = max(max(traceStrengths.r, traceStrengths.g), traceStrengths.b);
let traceStrength = maxComponent(traceStrengths);
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
}
@ -64,7 +73,7 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
+ sourceStrengths.g * settings.colorB
+ sourceStrengths.b * settings.colorC;
let normalizedBrushColor = normalizeColorIntensity(brushColor);
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
let brushStrength = maxComponent(sourceStrengths);
let brushVisibility = clamp(
brushStrength * (
settings.brushColorBase +
@ -75,20 +84,27 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
);
let color = max(normalizedTraceColor, normalizedBrushColor);
let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility);
let strength = max(maxComponent(strengths), brushVisibility);
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
}
fn maxComponent(v: vec3<f32>) -> f32 {
return max(max(v.r, v.g), v.b);
}
fn clarity(strength: f32) -> f32 {
return pow(clamp(strength, 0, 1), settings.clarity);
}
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
let brightestChannel = max(max(color.r, color.g), color.b);
let brightestChannel = maxComponent(color);
return color / max(settings.traceNormalizationFloor, brightestChannel);
}
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
if settings.backgroundGrainStrength == 0.0 {
return clamp(settings.backgroundColor, vec3(0), vec3(1));
}
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;

View file

@ -15,11 +15,6 @@ export const settings: GardenRuntimeSettings = {
...buildSettings(activeVibe),
};
export const resetSettings = (): GardenRuntimeSettings => {
Object.assign(settings, buildSettings(activeVibe));
return settings;
};
export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe = vibe;
Object.assign(settings, {

View file

@ -35,7 +35,7 @@ export const initializeContext = ({
device: device,
format: gpu.getPreferredCanvasFormat(),
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: 'premultiplied',
alphaMode: 'opaque',
});
} catch (error) {
throw new RuntimeError(

View file

@ -48,9 +48,9 @@ export const generateNoise = ({
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4(
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[1]}),
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[2]}),
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[3]}),
0.0,
0.0,
1.0,
);
}`
),

View file

@ -11,5 +11,32 @@ const clampRgbChannel = (value: number): number =>
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
export const rgbColorToHex = ([red, green, blue]: RgbColor): string =>
`#${[red, green, blue]
.map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0'))
.join('')}`;
export const hexColorToRgbColor = (value: string): RgbColor | null => {
const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!match) {
return null;
}
const shorthandOrHex = match[1];
const hex =
shorthandOrHex.length === 3
? shorthandOrHex
.split('')
.map((channel) => `${channel}${channel}`)
.join('')
: shorthandOrHex;
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
];
};
export const rgbChannelToUnit = (value: number): number =>
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));

View file

@ -7,7 +7,7 @@ export type { VibePreset } from './config';
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
export const isVibeId = (value: unknown): value is VibeId =>
const isVibeId = (value: unknown): value is VibeId =>
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
export const getInitialVibe = (): VibePreset => {