simplify more

This commit is contained in:
Andras Schmelczer 2026-05-20 21:37:30 +01:00
parent f03da42b5e
commit 2fe3c69963
40 changed files with 689 additions and 872 deletions

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -1,11 +1,9 @@
import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
quality: 'major' | 'minor';
}
export interface GardenAudioVibeSettings {

View file

@ -14,10 +14,6 @@ export interface GardenAudioStroke {
elapsedSeconds: number;
}
export interface GardenAudioStartOptions {
userGesture?: boolean;
}
export interface LoadedPianoSample {
midi: number;
buffer: AudioBuffer;

View file

@ -7,11 +7,7 @@ import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile } from './garden-audio-music';
import type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
@ -53,7 +49,7 @@ export class GardenAudio {
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') {
@ -134,7 +130,7 @@ export class GardenAudio {
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;

View file

@ -46,20 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb
type GardenAudioStyleIndex = 0 | 1 | 2;
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
lookaheadSeconds?: number;
}
interface StrokeAccentRequest {
vibe: VibePreset;
now: number;
activity: number;
maniaAmount?: number;
}
interface TouchDownRequest {
vibe: VibePreset;
now: number;
@ -118,10 +104,6 @@ export class GenerativePianoEngine {
private readonly playNote: (note: PianoNote) => void
) {}
private get generation(): typeof generativePianoTuning {
return generativePianoTuning;
}
public prime(now: number, profile: GardenAudioVibeProfile): void {
this.activeProfile = profile;
this.timelineStartedAt ??= now;
@ -162,7 +144,7 @@ export class GenerativePianoEngine {
this.brushPhraseLayers = [];
this.brushStreamNoteCountsByBar.clear();
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
return releaseStart + generativePianoTuning.releaseResolution.fadeAfterSeconds;
}
private recordTouchDown({
@ -198,7 +180,12 @@ export class GenerativePianoEngine {
now,
activity,
maniaAmount = 0,
}: StrokeAccentRequest): void {
}: {
vibe: VibePreset;
now: number;
activity: number;
maniaAmount?: number;
}): void {
const profile = getVibeProfile(vibe);
this.prime(now, profile);
const strength = clamp01(activity);
@ -206,12 +193,12 @@ export class GenerativePianoEngine {
const styleIndex = this.getStyleIndex(now);
const accentStep = this.getNextStepIndexAt(
now,
this.generation.gestureAccent.quantizeStepLookahead
generativePianoTuning.gestureAccent.quantizeStepLookahead
);
if (
this.isWaitingForGestureAccent &&
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds
now - this.lastGestureAccentAt >= generativePianoTuning.gestureAccentMinIntervalSeconds
) {
this.recordTouchDown({
vibe,
@ -230,8 +217,8 @@ export class GenerativePianoEngine {
maniaAmount: normalizedManiaAmount,
});
if (
strength >= this.generation.strokeAccentThreshold &&
accentStep - this.lastStrokeAccentStep >= this.generation.strokeAccentMinSteps
strength >= generativePianoTuning.strokeAccentThreshold &&
accentStep - this.lastStrokeAccentStep >= generativePianoTuning.strokeAccentMinSteps
) {
this.lastStrokeAccentStep = accentStep;
this.playGestureAccent(vibe, accentStep, styleIndex, strength);
@ -243,7 +230,12 @@ export class GenerativePianoEngine {
now,
activity,
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
}: RenderLookaheadRequest): void {
}: {
vibe: VibePreset;
now: number;
activity: number;
lookaheadSeconds?: number;
}): void {
const profile = getVibeProfile(vibe);
this.prime(now, profile);
this.skipLateBeats(now);
@ -253,13 +245,14 @@ export class GenerativePianoEngine {
}
const lookaheadEnd = now + lookaheadSeconds;
const expression = this.getExpression(activity);
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
this.renderBeat({
profile,
beatIndex,
startTime: this.getTimeForStep(this.nextBeatStep),
expression: this.getExpression(activity),
expression,
});
this.nextBeatStep += this.config.rhythm.stepsPerBeat;
}
@ -267,7 +260,7 @@ export class GenerativePianoEngine {
vibe,
now,
lookaheadEnd,
activity,
activity: expression,
});
}
@ -276,7 +269,7 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const stinger = this.generation.vibeChangeStinger;
const stinger = generativePianoTuning.vibeChangeStinger;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
@ -286,7 +279,7 @@ export class GenerativePianoEngine {
offsetsByVoice.forEach((offsets, index) => {
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
generativePianoTuning.padRegisters[index]
);
this.playProfileNote(profile, {
midi,
@ -340,7 +333,7 @@ export class GenerativePianoEngine {
const barIndex = Math.floor(beatIndex / beatsPerBar);
const styleIndex = this.getStyleIndex(startTime);
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
if (beatInBar === 0 && barIndex % generativePianoTuning.chordBars === 0) {
this.playPadChord(profile, barIndex, startTime, expression);
}
@ -349,21 +342,21 @@ export class GenerativePianoEngine {
}
if (
beatInBar === this.generation.textureBeat &&
beatInBar === generativePianoTuning.textureBeat &&
this.shouldPlayTexture(expression, barIndex)
) {
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
}
if (
beatInBar === this.generation.highActivityExtraBeat &&
expression >= this.generation.highActivityExtraThreshold
beatInBar === generativePianoTuning.highActivityExtraBeat &&
expression >= generativePianoTuning.highActivityExtraThreshold
) {
this.playTextureNote(
profile,
barIndex + this.generation.highActivityExtra.barOffset,
barIndex + generativePianoTuning.highActivityExtra.barOffset,
startTime,
expression * this.generation.highActivityExtra.expressionMultiplier,
expression * generativePianoTuning.highActivityExtra.expressionMultiplier,
styleIndex
);
}
@ -380,23 +373,23 @@ export class GenerativePianoEngine {
const rootMidi = profile.rootMidi + chord.rootOffset;
const durationSeconds =
this.getBarDurationSeconds() *
this.generation.chordBars *
this.generation.padDurationBarScale;
generativePianoTuning.chordBars *
generativePianoTuning.padDurationBarScale;
const notes = [
{
source: { baseMidi: rootMidi, offsets: [0] },
register: this.generation.padRegisters[0],
velocity: this.generation.padChord.velocities[0],
register: generativePianoTuning.padRegisters[0],
velocity: generativePianoTuning.padChord.velocities[0],
},
{
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
register: this.generation.padRegisters[1],
velocity: this.generation.padChord.velocities[1],
register: generativePianoTuning.padRegisters[1],
velocity: generativePianoTuning.padChord.velocities[1],
},
{
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
register: this.generation.padRegisters[2],
velocity: this.generation.padChord.velocities[2],
register: generativePianoTuning.padRegisters[2],
velocity: generativePianoTuning.padChord.velocities[2],
},
];
@ -411,16 +404,16 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, {
midi,
velocity:
velocity + expression * this.generation.padChord.expressionVelocityWeight,
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
startTime,
durationSeconds,
pan: register.pan,
role: 'pad',
delaySend: this.generation.padChord.delaySend,
delaySend: generativePianoTuning.padChord.delaySend,
lowpassHz: this.getLowpassHz(
profile,
midi,
expression * this.generation.padChord.lowpassExpressionWeight
expression * generativePianoTuning.padChord.lowpassExpressionWeight
),
});
});
@ -434,7 +427,7 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const release = this.generation.releaseResolution;
const release = generativePianoTuning.releaseResolution;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
@ -442,7 +435,7 @@ export class GenerativePianoEngine {
];
offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index];
const register = generativePianoTuning.padRegisters[index];
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
register,
@ -469,7 +462,7 @@ export class GenerativePianoEngine {
expression: number,
styleIndex: GardenAudioStyleIndex
): void {
const pool = this.generation.stylePools[styleIndex];
const pool = generativePianoTuning.stylePools[styleIndex];
const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -488,22 +481,22 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.supportNote.velocityBase +
expression * this.generation.supportNote.velocityExpressionWeight) *
(generativePianoTuning.supportNote.velocityBase +
expression * generativePianoTuning.supportNote.velocityExpressionWeight) *
styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds:
this.generation.supportNote.durationBaseSeconds +
expression * this.generation.supportNote.durationExpressionSeconds,
generativePianoTuning.supportNote.durationBaseSeconds +
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex),
role: 'support',
delaySend:
this.generation.supportNote.delaySendBase +
expression * this.generation.supportNote.delaySendExpressionWeight,
generativePianoTuning.supportNote.delaySendBase +
expression * generativePianoTuning.supportNote.delaySendExpressionWeight,
lowpassHz: this.getLowpassHz(
profile,
midi,
expression * this.generation.supportNote.lowpassExpressionWeight
expression * generativePianoTuning.supportNote.lowpassExpressionWeight
),
});
}
@ -515,7 +508,7 @@ export class GenerativePianoEngine {
expression: number,
styleIndex: GardenAudioStyleIndex
): void {
const pool = this.generation.stylePools[styleIndex];
const pool = generativePianoTuning.stylePools[styleIndex];
const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false);
const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
@ -534,18 +527,18 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.textureNote.velocityBase +
expression * this.generation.textureNote.velocityExpressionWeight) *
(generativePianoTuning.textureNote.velocityBase +
expression * generativePianoTuning.textureNote.velocityExpressionWeight) *
styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds:
this.generation.textureNote.durationBaseSeconds +
expression * this.generation.textureNote.durationExpressionSeconds,
generativePianoTuning.textureNote.durationBaseSeconds +
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex),
role: 'texture',
delaySend:
this.generation.textureNote.delaySendBase +
expression * this.generation.textureNote.delaySendExpressionWeight,
generativePianoTuning.textureNote.delaySendBase +
expression * generativePianoTuning.textureNote.delaySendExpressionWeight,
lowpassHz: this.getLowpassHz(profile, midi, expression),
});
}
@ -557,13 +550,13 @@ export class GenerativePianoEngine {
strength: number
): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const pool = generativePianoTuning.stylePools[styleIndex];
const startTime = this.getTimeForStep(stepIndex);
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const chordIntervals = getChordIntervals(chord, false);
const degrees = this.rotate(
pool.scaleDegrees,
Math.round(strength * this.generation.gestureAccent.rotationStrengthMultiplier)
Math.round(strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier)
);
const midi = this.chooseMidi(
@ -581,16 +574,16 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.gestureAccent.velocityBase +
strength * this.generation.gestureAccent.velocityStrengthWeight) *
(generativePianoTuning.gestureAccent.velocityBase +
strength * generativePianoTuning.gestureAccent.velocityStrengthWeight) *
styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds:
this.generation.gestureAccent.durationBaseSeconds +
strength * this.generation.gestureAccent.durationStrengthSeconds,
generativePianoTuning.gestureAccent.durationBaseSeconds +
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
pan: this.getStylePan(styleIndex),
role: 'gesture',
delaySend: this.generation.gestureAccent.delaySend,
delaySend: generativePianoTuning.gestureAccent.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, strength),
});
}
@ -607,7 +600,7 @@ export class GenerativePianoEngine {
strength: number;
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const pool = generativePianoTuning.stylePools[styleIndex];
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -626,22 +619,22 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.touchNote.velocityBase +
strength * this.generation.touchNote.velocityStrengthWeight) *
(generativePianoTuning.touchNote.velocityBase +
strength * generativePianoTuning.touchNote.velocityStrengthWeight) *
styleVoices[styleIndex].velocityMultiplier,
startTime: now,
durationSeconds:
this.generation.touchNote.durationBaseSeconds +
strength * this.generation.touchNote.durationStrengthSeconds,
generativePianoTuning.touchNote.durationBaseSeconds +
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
pan: this.getStylePan(styleIndex),
role: 'gesture',
delaySend: this.generation.touchNote.delaySend,
delaySend: generativePianoTuning.touchNote.delaySend,
lowpassHz: this.getLowpassHz(
profile,
midi,
clamp01(
this.generation.touchNote.lowpassBaseExpression +
strength * this.generation.touchNote.lowpassStrengthWeight
generativePianoTuning.touchNote.lowpassBaseExpression +
strength * generativePianoTuning.touchNote.lowpassStrengthWeight
)
),
});
@ -661,8 +654,8 @@ export class GenerativePianoEngine {
maniaAmount: number;
}): void {
const lifetimeSeconds =
this.generation.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds;
generativePianoTuning.brushLayerBaseSeconds +
strength * generativePianoTuning.brushLayerEnergySeconds;
const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds);
this.brushPhraseLayers.push({
@ -672,13 +665,13 @@ export class GenerativePianoEngine {
expiresAt,
styleIndex,
energy: strength,
motifOffsets: [styleIndex + this.generation.brushPhrase.initialMotifOffset],
motifOffsets: [styleIndex + generativePianoTuning.brushPhrase.initialMotifOffset],
maniaAmount,
});
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) {
if (this.brushPhraseLayers.length > generativePianoTuning.maxBrushPhraseLayers) {
this.brushPhraseLayers = this.brushPhraseLayers.slice(
-this.generation.maxBrushPhraseLayers
-generativePianoTuning.maxBrushPhraseLayers
);
}
}
@ -704,17 +697,17 @@ export class GenerativePianoEngine {
layer.styleIndex = styleIndex;
layer.energy = Math.max(
layer.energy *
Math.exp(-elapsedSeconds / this.generation.brushPhrase.energyDecaySeconds),
Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.energyDecaySeconds),
strength
);
layer.maniaAmount = Math.max(
layer.maniaAmount *
Math.exp(-elapsedSeconds / this.generation.brushPhrase.maniaDecaySeconds),
Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.maniaDecaySeconds),
maniaAmount
);
layer.motifOffsets.push(this.getMotifOffset(strength));
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-generativePianoTuning.brushMotifMaxSteps);
}
}
@ -750,7 +743,7 @@ export class GenerativePianoEngine {
const startTime = this.getTimeForStep(this.nextBrushStreamStep);
const frame = this.getBrushStreamFrame(startTime, activity);
if (
frame.intensity >= this.generation.brushLayerMinIntensity &&
frame.intensity >= generativePianoTuning.brushLayerMinIntensity &&
this.reserveBrushStreamNote(this.nextBrushStreamStep)
) {
this.playBrushStreamNote({
@ -783,22 +776,22 @@ export class GenerativePianoEngine {
layer: BrushPhraseLayer | null;
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const pool = generativePianoTuning.stylePools[styleIndex];
const maniaAmount =
layer?.maniaAmount ??
clamp01(
(intensity - this.generation.brushStream.inferredManiaThreshold) /
this.generation.brushStream.inferredManiaRange
(intensity - generativePianoTuning.brushStream.inferredManiaThreshold) /
generativePianoTuning.brushStream.inferredManiaRange
);
const register = this.getBiasedRegister(
pool,
maniaAmount * this.generation.brushStream.registerManiaShift
maniaAmount * generativePianoTuning.brushStream.registerManiaShift
);
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
const useChordTone =
this.brushStreamNoteIndex % this.generation.brushStream.chordToneEverySteps === 0;
this.brushStreamNoteIndex % generativePianoTuning.brushStream.chordToneEverySteps === 0;
const source = useChordTone
? {
baseMidi: rootMidi,
@ -817,18 +810,18 @@ export class GenerativePianoEngine {
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getStylePan(styleIndex);
const durationSeconds = clamp(
this.generation.brushStream.durationBaseSeconds +
intensity * this.generation.brushStream.durationIntensitySeconds -
maniaAmount * this.generation.brushStream.durationManiaSeconds,
this.generation.brushStream.durationMinSeconds,
this.generation.brushStream.durationMaxSeconds
generativePianoTuning.brushStream.durationBaseSeconds +
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
maniaAmount * generativePianoTuning.brushStream.durationManiaSeconds,
generativePianoTuning.brushStream.durationMinSeconds,
generativePianoTuning.brushStream.durationMaxSeconds
);
const delaySend = clamp(
this.generation.brushStream.delaySendBase +
intensity * this.generation.brushStream.delaySendIntensityWeight -
maniaAmount * this.generation.brushStream.delaySendManiaWeight,
this.generation.brushStream.delaySendMin,
this.generation.brushStream.delaySendMax
generativePianoTuning.brushStream.delaySendBase +
intensity * generativePianoTuning.brushStream.delaySendIntensityWeight -
maniaAmount * generativePianoTuning.brushStream.delaySendManiaWeight,
generativePianoTuning.brushStream.delaySendMin,
generativePianoTuning.brushStream.delaySendMax
);
this.lastBrushStreamMidi = midi;
@ -836,8 +829,8 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.brushStream.velocityBase +
intensity * this.generation.brushStream.velocityIntensityWeight) *
(generativePianoTuning.brushStream.velocityBase +
intensity * generativePianoTuning.brushStream.velocityIntensityWeight) *
styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds,
@ -848,46 +841,46 @@ export class GenerativePianoEngine {
profile,
midi,
clamp01(
this.generation.brushStream.lowpassBaseExpression +
intensity * this.generation.brushStream.lowpassIntensityWeight +
maniaAmount * this.generation.brushStream.lowpassManiaWeight
generativePianoTuning.brushStream.lowpassBaseExpression +
intensity * generativePianoTuning.brushStream.lowpassIntensityWeight +
maniaAmount * generativePianoTuning.brushStream.lowpassManiaWeight
)
),
});
if (
maniaAmount >= this.generation.brushStreamEcho.maniaThreshold &&
(this.brushStreamNoteIndex % this.generation.brushStreamEcho.stepModulo ===
this.generation.brushStreamEcho.stepRemainder ||
intensity >= this.generation.brushStreamEcho.intensityThreshold)
maniaAmount >= generativePianoTuning.brushStreamEcho.maniaThreshold &&
(this.brushStreamNoteIndex % generativePianoTuning.brushStreamEcho.stepModulo ===
generativePianoTuning.brushStreamEcho.stepRemainder ||
intensity >= generativePianoTuning.brushStreamEcho.intensityThreshold)
) {
const echoMidi =
midi + this.generation.brushStreamEcho.octaveSemitones <=
this.generation.brushStreamEcho.maxMidi
? midi + this.generation.brushStreamEcho.octaveSemitones
: midi - this.generation.brushStreamEcho.octaveSemitones;
midi + generativePianoTuning.brushStreamEcho.octaveSemitones <=
generativePianoTuning.brushStreamEcho.maxMidi
? midi + generativePianoTuning.brushStreamEcho.octaveSemitones
: midi - generativePianoTuning.brushStreamEcho.octaveSemitones;
this.playProfileNote(profile, {
midi: echoMidi,
velocity:
(this.generation.brushStreamEcho.velocityBase +
intensity * this.generation.brushStreamEcho.velocityIntensityWeight) *
(generativePianoTuning.brushStreamEcho.velocityBase +
intensity * generativePianoTuning.brushStreamEcho.velocityIntensityWeight) *
styleVoices[styleIndex].velocityMultiplier,
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
startTime: startTime + generativePianoTuning.brushMotifCanonDelaySeconds,
durationSeconds: Math.max(
this.generation.brushStreamEcho.durationMinSeconds,
durationSeconds * this.generation.brushStreamEcho.durationScale
generativePianoTuning.brushStreamEcho.durationMinSeconds,
durationSeconds * generativePianoTuning.brushStreamEcho.durationScale
),
pan: clamp(pan * this.generation.brushStreamEcho.panScale, -1, 1),
pan: clamp(pan * generativePianoTuning.brushStreamEcho.panScale, -1, 1),
role: 'brush',
delaySend: Math.max(
this.generation.brushStreamEcho.delaySendMin,
delaySend * this.generation.brushStreamEcho.delaySendScale
generativePianoTuning.brushStreamEcho.delaySendMin,
delaySend * generativePianoTuning.brushStreamEcho.delaySendScale
),
lowpassHz: this.getLowpassHz(
profile,
echoMidi,
this.generation.brushStreamEcho.lowpassBaseExpression +
maniaAmount * this.generation.brushStreamEcho.lowpassManiaWeight
generativePianoTuning.brushStreamEcho.lowpassBaseExpression +
maniaAmount * generativePianoTuning.brushStreamEcho.lowpassManiaWeight
),
});
}
@ -905,8 +898,8 @@ export class GenerativePianoEngine {
intensity:
layer.energy *
this.getBrushPhraseFade(layer, startTime) *
(this.generation.brushPhrase.layerIntensityBase +
layer.maniaAmount * this.generation.brushPhrase.layerIntensityManiaWeight),
(generativePianoTuning.brushPhrase.layerIntensityBase +
layer.maniaAmount * generativePianoTuning.brushPhrase.layerIntensityManiaWeight),
}));
const dominant = layerStates.reduce<{
layer: BrushPhraseLayer;
@ -924,10 +917,10 @@ export class GenerativePianoEngine {
return {
intensity: clamp01(
activity * this.generation.brushPhrase.frameActivityWeight +
activity * generativePianoTuning.brushPhrase.frameActivityWeight +
layeredIntensity +
(dominant?.layer.maniaAmount ?? 0) *
this.generation.brushPhrase.frameManiaWeight
generativePianoTuning.brushPhrase.frameManiaWeight
),
layer: dominant?.layer ?? null,
};
@ -935,11 +928,11 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats =
intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
intensity >= generativePianoTuning.brushStream.intenseThreshold
? generativePianoTuning.brushStreamIntenseIntervalBeats
: intensity >= generativePianoTuning.brushStream.activeThreshold
? generativePianoTuning.brushStreamActiveIntervalBeats
: generativePianoTuning.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
}
@ -950,11 +943,11 @@ export class GenerativePianoEngine {
}
private getMotifOffset(strength: number): number {
return strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
return strength >= generativePianoTuning.brushMotif.highThreshold
? generativePianoTuning.brushMotif.highOffset
: strength >= generativePianoTuning.brushMotif.mediumThreshold
? generativePianoTuning.brushMotif.mediumOffset
: generativePianoTuning.brushMotif.lowOffset;
}
private getBrushMotifDegrees({
@ -986,17 +979,17 @@ export class GenerativePianoEngine {
maniaAmount: number
): GardenAudioRegister {
const shift = Math.round(
maniaAmount * this.generation.registerBias.maniaShiftSemitones
maniaAmount * generativePianoTuning.registerBias.maniaShiftSemitones
);
const midiMin = clamp(
register.midiMin + shift,
this.generation.registerBias.midiMin,
this.generation.registerBias.midiMaxForMin
generativePianoTuning.registerBias.midiMin,
generativePianoTuning.registerBias.midiMaxForMin
);
const midiMax = clamp(
register.midiMax + shift,
midiMin + this.generation.registerBias.minimumSpan,
this.generation.registerBias.midiMax
midiMin + generativePianoTuning.registerBias.minimumSpan,
generativePianoTuning.registerBias.midiMax
);
return {
@ -1039,8 +1032,8 @@ export class GenerativePianoEngine {
pitchSource.offsets.forEach((offset, preference) => {
for (
let octave = this.generation.candidateOctaveSearch.min;
octave <= this.generation.candidateOctaveSearch.max;
let octave = generativePianoTuning.candidateOctaveSearch.min;
octave <= generativePianoTuning.candidateOctaveSearch.max;
octave += 1
) {
const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE;
@ -1067,38 +1060,38 @@ export class GenerativePianoEngine {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - register.preferredMidi) *
this.generation.noteScoreRegisterWeight +
candidate.preference * this.generation.noteScorePreferenceWeight +
candidate.chordToneDistance * this.generation.noteScoreChordToneWeight +
generativePianoTuning.noteScoreRegisterWeight +
candidate.preference * generativePianoTuning.noteScorePreferenceWeight +
candidate.chordToneDistance * generativePianoTuning.noteScoreChordToneWeight +
(avoidRepeat && candidate.midi === previousMidi
? this.generation.noteScoreRepeatPenalty
? generativePianoTuning.noteScoreRepeatPenalty
: 0)
);
}
private shouldPlaySupport(expression: number, barIndex: number): boolean {
if (expression >= this.generation.supportNote.expressionThreshold) {
if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
return true;
}
return (
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset
barIndex % generativePianoTuning.supportBarSpacing === generativePianoTuning.supportBarOffset
);
}
private shouldPlayTexture(expression: number, barIndex: number): boolean {
const spacing =
expression < this.generation.textureNote.idleExpressionThreshold
? this.generation.idleTextureBarSpacing
: expression < this.generation.textureNote.mediumExpressionThreshold
? this.generation.mediumTextureBarSpacing
: this.generation.textureNote.intenseSpacing;
expression < generativePianoTuning.textureNote.idleExpressionThreshold
? generativePianoTuning.idleTextureBarSpacing
: expression < generativePianoTuning.textureNote.mediumExpressionThreshold
? generativePianoTuning.mediumTextureBarSpacing
: generativePianoTuning.textureNote.intenseSpacing;
return (
barIndex % spacing ===
(spacing === this.generation.textureNote.intenseSpacing
(spacing === generativePianoTuning.textureNote.intenseSpacing
? 0
: this.generation.textureNote.idlePhase)
: generativePianoTuning.textureNote.idlePhase)
);
}
@ -1106,14 +1099,14 @@ export class GenerativePianoEngine {
chordIntervals: ReadonlyArray<number>,
styleIndex: GardenAudioStyleIndex
): Array<number> {
return this.generation.supportNote.offsetsByStyle[styleIndex].map((offset) =>
return generativePianoTuning.supportNote.offsetsByStyle[styleIndex].map((offset) =>
getConfiguredChordOffset(chordIntervals, offset)
);
}
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
const progressionIndex =
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length;
Math.floor(barIndex / generativePianoTuning.chordBars) % profile.progression.length;
return profile.progression[progressionIndex];
}
@ -1122,17 +1115,17 @@ export class GenerativePianoEngine {
}
private getStyleIndex(startTime: number): GardenAudioStyleIndex {
const styleCount = this.generation.stylePools.length;
const rotationBars = Math.max(1, Math.round(this.generation.styleRotationBars));
const styleCount = generativePianoTuning.stylePools.length;
const rotationBars = Math.max(1, Math.round(generativePianoTuning.styleRotationBars));
return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) %
styleCount) as GardenAudioStyleIndex;
}
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
const pool = this.generation.stylePools[styleIndex];
const pool = generativePianoTuning.stylePools[styleIndex];
const styleVoice = styleVoices[styleIndex];
return clamp(
pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale,
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
-1,
1
);
@ -1145,13 +1138,13 @@ export class GenerativePianoEngine {
): number {
const midiLift =
clamp01(
(midi - this.generation.lowpass.midiBase) / this.generation.lowpass.midiRange
) * this.generation.lowpass.midiLiftHz;
(midi - generativePianoTuning.lowpass.midiBase) / generativePianoTuning.lowpass.midiRange
) * generativePianoTuning.lowpass.midiLiftHz;
return clamp(
this.config.piano.lowpassHz *
profile.brightness *
(this.generation.lowpass.expressionBase +
expression * this.generation.lowpass.expressionWeight) +
(generativePianoTuning.lowpass.expressionBase +
expression * generativePianoTuning.lowpass.expressionWeight) +
midiLift,
this.config.piano.lowpassMinHz,
this.config.piano.lowpassMaxHz
@ -1174,14 +1167,14 @@ export class GenerativePianoEngine {
}
private getExpression(activity: number): number {
const liftedActivity = Math.max(
activity,
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
);
return clamp01(
(liftedActivity - this.config.rhythm.sparseActivity) /
const activityExpression = clamp01(
(activity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
const idleExpression = clamp01(
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
);
return Math.max(activityExpression, idleExpression);
}
private getBeatDurationSeconds(): number {
@ -1263,7 +1256,7 @@ export class GenerativePianoEngine {
private reserveBrushStreamNote(stepIndex: number): boolean {
const barIndex = this.getBarIndexForStep(stepIndex);
const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0;
if (noteCount >= this.generation.maxBrushStreamNotesPerBar) {
if (noteCount >= generativePianoTuning.maxBrushStreamNotesPerBar) {
return false;
}

View file

@ -88,10 +88,7 @@ export class PianoSampler {
const sample = this.findNearestSample(midi);
if (sample) {
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
(this.config.piano.sustainBase +
@ -143,9 +140,9 @@ export class PianoSampler {
return;
}
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
const noteGainValue = this.computeNoteGain(
noteVelocity,
pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
@ -277,6 +274,13 @@ export class PianoSampler {
);
}
private computeNoteGain(velocity: number, scale = 1): number {
return Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * velocity * scale
);
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;

View file

@ -5,13 +5,10 @@ import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
export { VibeId } from './config/types';
export type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
VibePreset,
} from './config/types';
export const appConfig = {

View file

@ -12,17 +12,15 @@ export const colorInteractionSettings = {
color3ToColor3: 1,
};
const agentInteractionOptions: Record<string, number> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
label,
min: -1,
max: 1,
step: 1,
options: agentInteractionOptions,
options: {
Follow: 1,
Avoid: -1,
Ignore: 0,
},
});

View file

@ -34,7 +34,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushAlpha: 1,
brushDiscardThreshold: 0.02,
brushCoarseNoiseScale: 160,
brushGrainNoiseScale: 22,
brushGrainNoiseOffsetX: 0.31,
brushGrainNoiseOffsetY: 0.67,

View file

@ -1,6 +1,8 @@
import { colorInteractionControl } from './color-interactions';
import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('1 -> 1'),
color1ToColor2: colorInteractionControl('1 -> 2'),
@ -26,21 +28,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
brushSizeVariation: {
folder: 'Brush',
label: 'brush variance',
min: 0,
max: 1,
step: 0.01,
},
diffusionRateBrush: {
folder: 'Brush',
label: 'brush diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
sensorOffsetDistance: {
folder: 'Agents',
label: 'sensor distance',
@ -62,6 +49,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 200,
step: 1,
},
forwardRotationScale: {
folder: 'Agents',
format: formatPercent,
label: 'following sensor %',
min: 0,
max: 1,
step: 0.01,
},
turnWhenLost: {
folder: 'Agents',
label: 'turn when lost',
min: 0,
max: 6.28,
step: 0.01,
},
individualTrailWeight: {
folder: 'Agents',
label: 'individual trail weight',

View file

@ -2,13 +2,14 @@ import type {
GardenAudioConfig,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings';
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
import type { RenderSettings } from '../pipelines/render/render-pipeline';
import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig {
format?: (value: number) => string;
folder: string;
integer?: boolean;
label?: string;
@ -53,7 +54,6 @@ type GardenVibeSettings = Pick<
GardenRuntimeSettings,
| 'backgroundGrainStrength'
| 'brushSize'
| 'brushSizeVariation'
| 'clarity'
| 'color1ToColor1'
| 'color1ToColor2'
@ -65,7 +65,6 @@ type GardenVibeSettings = Pick<
| 'color3ToColor2'
| 'color3ToColor3'
| 'decayRateTrails'
| 'diffusionRateBrush'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetDistance'

View file

@ -27,10 +27,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 14,
brushSizeVariation: 0.5,
clarity: 0.62,
decayRateTrails: 965,
diffusionRateBrush: 0.35,
individualTrailWeight: 0.07,
moveSpeed: 82,
sensorOffsetDistance: 38,
@ -55,10 +53,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings,
backgroundGrainStrength: 0.014,
brushSize: 16,
brushSizeVariation: 0.35,
clarity: 0.68,
decayRateTrails: 975,
diffusionRateBrush: 0.28,
individualTrailWeight: 0.06,
moveSpeed: 70,
sensorOffsetDistance: 46,
@ -83,10 +79,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings,
backgroundGrainStrength: 0.022,
brushSize: 13,
brushSizeVariation: 0.58,
clarity: 0.58,
decayRateTrails: 955,
diffusionRateBrush: 0.42,
individualTrailWeight: 0.055,
moveSpeed: 90,
sensorOffsetDistance: 35,
@ -111,10 +105,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 12,
brushSizeVariation: 0.45,
clarity: 0.64,
decayRateTrails: 968,
diffusionRateBrush: 0.32,
individualTrailWeight: 0.065,
moveSpeed: 76,
sensorOffsetDistance: 42,
@ -139,10 +131,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings,
backgroundGrainStrength: 0.024,
brushSize: 15,
brushSizeVariation: 0.62,
clarity: 0.55,
decayRateTrails: 948,
diffusionRateBrush: 0.48,
individualTrailWeight: 0.05,
moveSpeed: 96,
sensorOffsetDistance: 32,
@ -167,10 +157,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings,
backgroundGrainStrength: 0.012,
brushSize: 18,
brushSizeVariation: 0.28,
clarity: 0.7,
decayRateTrails: 982,
diffusionRateBrush: 0.24,
individualTrailWeight: 0.075,
moveSpeed: 62,
sensorOffsetDistance: 52,

View file

@ -1,71 +0,0 @@
import { EraserPreview } from './eraser-preview';
interface EraserPointerPreviewControllerOptions {
canvas: HTMLCanvasElement;
eraserPreview: EraserPreview;
getIsSwipeActive: () => boolean;
}
export class EraserPointerPreviewController {
public constructor(private readonly options: EraserPointerPreviewControllerOptions) {}
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
}
public update(event?: PointerEvent): void {
this.options.eraserPreview.update(event, this.isSwipeActive);
}
private get canvas(): HTMLCanvasElement {
return this.options.canvas;
}
private get isSwipeActive(): boolean {
return this.options.getIsSwipeActive();
}
private readonly onPointerDown = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.update(event);
};
private readonly onPointerMove = (event: PointerEvent) => {
this.update(event);
};
private readonly onPointerUp = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
this.update(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.update(event);
};
private readonly onPointerLeave = () => {
this.options.eraserPreview.setPointerHoveringCanvas(false);
this.update();
};
}

View file

@ -4,6 +4,7 @@ export class EraserPreview {
private previewClientPosition: { x: number; y: number } | null = null;
private isErasing = false;
private isPointerHoveringCanvas = false;
private isSwipeActive = false;
private previousSize: number | null = null;
private previousLeft = '';
private previousTop = '';
@ -11,19 +12,36 @@ export class EraserPreview {
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly element: HTMLElement
private readonly element: HTMLElement,
private readonly getIsSwipeActive: () => boolean
) {}
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing;
this.update(undefined, isSwipeActive);
this.update();
}
public setPointerHoveringCanvas(isHovering: boolean): void {
this.isPointerHoveringCanvas = isHovering;
}
public update(event?: PointerEvent): void {
this.isSwipeActive = this.getIsSwipeActive();
public update(event?: PointerEvent, isSwipeActive = false): void {
if (event) {
this.previewClientPosition = {
x: event.clientX,
@ -39,7 +57,7 @@ export class EraserPreview {
if (
!this.isErasing ||
this.previewClientPosition === null ||
(!this.isPointerHoveringCanvas && !isSwipeActive)
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
) {
this.setVisible(false);
return;
@ -59,7 +77,16 @@ export class EraserPreview {
this.setVisible(true);
}
public isPointerInsideCanvas(event: PointerEvent): boolean {
private setVisible(isVisible: boolean): void {
if (this.isVisible === isVisible) {
return;
}
this.isVisible = isVisible;
this.element.classList.toggle('visible', isVisible);
}
private isPointerInsideCanvas(event: PointerEvent): boolean {
const rect = this.canvas.getBoundingClientRect();
return (
event.clientX >= rect.left &&
@ -69,12 +96,27 @@ export class EraserPreview {
);
}
private setVisible(isVisible: boolean): void {
if (this.isVisible === isVisible) {
return;
}
private readonly onPointerDown = (event: PointerEvent) => {
this.isPointerHoveringCanvas = true;
this.update(event);
};
this.isVisible = isVisible;
this.element.classList.toggle('visible', isVisible);
}
private readonly onPointerMove = (event: PointerEvent) => {
this.update(event);
};
private readonly onPointerUp = (event: PointerEvent) => {
this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event);
this.update(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.isPointerHoveringCanvas = true;
this.update(event);
};
private readonly onPointerLeave = () => {
this.isPointerHoveringCanvas = false;
this.update();
};
}

View file

@ -23,7 +23,6 @@ interface FrameParameters extends RenderInputs {
canvasPixelRatio: number;
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
eraserPixelSize: number;
}

View file

@ -7,7 +7,6 @@ import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population';
import { DevStatsOverlay } from './dev-stats-overlay';
import { EraserPointerPreviewController } from './eraser-pointer-preview-controller';
import { EraserPreview } from './eraser-preview';
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance';
@ -25,7 +24,6 @@ export default class GameLoop {
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly eraserPreviewController: EraserPointerPreviewController;
private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
@ -34,6 +32,7 @@ export default class GameLoop {
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly _canvasSize: vec2 = vec2.create();
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = '';
@ -54,7 +53,6 @@ export default class GameLoop {
this.framePerformance.adaptiveCapInitial
);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
@ -77,11 +75,11 @@ export default class GameLoop {
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
this.eraserPreviewController = new EraserPointerPreviewController({
this.eraserPreview = new EraserPreview(
canvas,
eraserPreview: this.eraserPreview,
getIsSwipeActive: () => this.pointerInput.isSwipeActive,
});
ui.eraserPreview,
() => this.pointerInput.isSwipeActive
);
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device,
renderPipeline: this.resources.renderPipeline,
@ -100,7 +98,7 @@ export default class GameLoop {
});
window.addEventListener('resize', this.resizeListener);
this.eraserPreviewController.attach();
this.eraserPreview.attach();
this.syncDevStatsOverlay();
}
@ -110,11 +108,11 @@ export default class GameLoop {
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
this.eraserPreviewController.setEraseMode(isErasing);
this.eraserPreview.setEraseMode(isErasing);
}
public updateEraserPreview(event?: PointerEvent): void {
this.eraserPreviewController.update(event);
this.eraserPreview.update(event);
}
public onVibeChanged(): void {
@ -153,7 +151,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach();
this.eraserPreviewController.detach();
this.eraserPreview.detach();
this.devStatsOverlay?.destroy();
this.devStatsOverlay = null;
this.toolbarContrastMonitor.destroy();
@ -198,7 +196,6 @@ export default class GameLoop {
canvasPixelRatio,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
eraserPixelSize,
@ -300,7 +297,8 @@ export default class GameLoop {
}
private get canvasSize(): vec2 {
return vec2.fromValues(this.canvas.width, this.canvas.height);
vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
return this._canvasSize;
}
private get canvasPixelRatio(): number {

View file

@ -47,59 +47,6 @@ const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
const ELEMENT_TAGS = {
div: 'div',
pre: 'pre',
} as const;
const ARIA_ATTRIBUTES = {
label: 'aria-label',
live: 'aria-live',
pressed: 'aria-pressed',
role: 'role',
valueNow: 'aria-valuenow',
valueText: 'aria-valuetext',
} as const;
const ARIA_LIVE_VALUES = {
assertive: 'assertive',
polite: 'polite',
} as const;
const ARIA_ROLES = {
alert: 'alert',
status: 'status',
} as const;
const CSS_CLASSES = {
active: 'active',
errorsContainer: 'errors-container',
isLoading: 'is-loading',
muted: 'muted',
preDrawing: 'pre-drawing',
} as const;
const CSS_VARIABLES = {
eraserControlScale: '--eraser-control-scale',
eraserProgress: '--eraser-progress',
gardenBackground: '--garden-background',
loadingProgress: '--loading-progress',
mirrorAngle: '--mirror-angle',
mirrorProgress: '--mirror-progress',
volumeProgress: '--volume-progress',
} as const;
const DOM_EVENTS = {
click: 'click',
focus: 'focus',
input: 'input',
keydown: 'keydown',
pointerDown: 'pointerdown',
pointerUp: 'pointerup',
touchEnd: 'touchend',
touchStart: 'touchstart',
} as const;
const APP_SELECTORS = {
aside: 'aside',
canvas: 'canvas',
@ -132,24 +79,6 @@ const APP_SELECTORS = {
volumeSlider: '.volume-slider',
} as const;
const AUDIO_LABELS = {
mutedPrefix: 'Muted',
mute: 'Mute audio',
unmute: 'Unmute audio',
volumeSuffix: 'volume',
} as const;
const LOADING_MESSAGES = {
fontsError: 'Could not load fonts.',
pianoSamplesError: 'Could not preload piano samples.',
ready: 'Ready',
} as const;
const VIBE_CHANGE_SOURCES = {
nextButton: 'next-button',
previousButton: 'previous-button',
} as const;
const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
@ -197,18 +126,18 @@ type RuntimeUiError = Parameters<
>[0];
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
const message = document.createElement(ELEMENT_TAGS.pre);
const message = document.createElement('pre');
message.className = error.severity;
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
message.setAttribute(
ARIA_ATTRIBUTES.role,
error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status
'role',
error.severity === Severity.ERROR ? 'alert' : 'status'
);
message.setAttribute(
ARIA_ATTRIBUTES.live,
'aria-live',
error.severity === Severity.ERROR
? ARIA_LIVE_VALUES.assertive
: ARIA_LIVE_VALUES.polite
? 'assertive'
: 'polite'
);
container.append(message);
@ -229,14 +158,14 @@ const renderStartupException = (exception: unknown) => {
const container =
existingContainer instanceof HTMLElement
? existingContainer
: document.createElement(ELEMENT_TAGS.div);
: document.createElement('div');
if (!(existingContainer instanceof HTMLElement)) {
container.className = CSS_CLASSES.errorsContainer;
container.className = 'errors-container';
document.body.append(container);
}
container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive);
container.setAttribute('aria-live', 'assertive');
renderRuntimeMessage(container, getRuntimeUiError(exception));
};
@ -298,10 +227,10 @@ const setLoadingStage = (label: string, ratio: number) => {
const percent = Math.round(clamp01(ratio) * 100);
elements.loadingStatus.textContent = label;
elements.loadingProgress.style.setProperty(
CSS_VARIABLES.loadingProgress,
'--loading-progress',
`${percent}%`
);
elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent));
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
};
let audioVolume = readInitialAudioVolume();
@ -323,32 +252,32 @@ const renderAudioUi = (game: GameLoop | null) => {
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
const volumePercent = getAudioVolumePercent(audioVolume);
elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted));
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
elements.soundButton.setAttribute(
ARIA_ATTRIBUTES.label,
isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute
'aria-label',
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
);
elements.soundButton.title = isEffectivelyMuted
? AUDIO_LABELS.unmute
: AUDIO_LABELS.mute;
? 'Unmute audio'
: 'Mute audio';
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
elements.volumeSlider.setAttribute(
ARIA_ATTRIBUTES.valueText,
'aria-valuetext',
isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%`
? `${'Muted'}, ${volumePercent}%`
: `${volumePercent}%`
);
elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
elements.volumeControl.title = isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`
: `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`;
? `${'Muted'}, ${volumePercent}% ${'volume'}`
: `${volumePercent}% ${'volume'}`;
elements.volumeControl.style.setProperty(
CSS_VARIABLES.volumeProgress,
'--volume-progress',
`${volumePercent}%`
);
@ -360,14 +289,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle(
CSS_CLASSES.active,
'active',
settings.selectedColorIndex === index && !isEraserActive
);
});
elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive);
elements.eraserSizeControl.classList.toggle('active', isEraserActive);
game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty(
CSS_VARIABLES.gardenBackground,
'--garden-background',
rgbColorToCss(activeVibe.backgroundColor)
);
};
@ -382,18 +311,18 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
elements.eraserSizeSlider.value = size.toString();
elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`);
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size);
const scale =
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserProgress,
'--eraser-progress',
`${ratio * 100}%`
);
elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserControlScale,
'--eraser-control-scale',
scale.toFixed(3)
);
game?.updateEraserPreview();
@ -412,15 +341,15 @@ const renderMirrorSegmentUi = () => {
const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count);
elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label);
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
elements.mirrorSegmentControl.title = label;
elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1);
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorProgress,
'--mirror-progress',
`${ratio * 100}%`
);
elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorAngle,
'--mirror-angle',
`${(360 / count).toFixed(3)}deg`
);
};
@ -437,13 +366,13 @@ const main = async () => {
elements = queryAppElements();
elements.errorContainer.setAttribute(
ARIA_ATTRIBUTES.live,
ARIA_LIVE_VALUES.assertive
'aria-live',
'assertive'
);
ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
document.body.classList.remove(CSS_CLASSES.isLoading);
document.body.classList.remove('is-loading');
game?.destroy();
shouldStop = true;
}
@ -488,31 +417,31 @@ const main = async () => {
game?.startAudio(true);
};
window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, {
window.addEventListener('touchstart', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, {
window.addEventListener('pointerdown', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, {
window.addEventListener('touchend', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, {
window.addEventListener('pointerup', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, {
window.addEventListener('click', startAudioFromUserGesture, {
capture: true,
});
window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, {
window.addEventListener('keydown', startAudioFromUserGesture, {
capture: true,
});
elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy());
elements.soundButton.addEventListener(DOM_EVENTS.click, () => {
elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener('click', () => {
const shouldUnmute = isAudioMuted || audioVolume <= 0;
if (shouldUnmute && audioVolume <= 0) {
audioVolume = DEFAULT_AUDIO_VOLUME;
@ -524,7 +453,7 @@ const main = async () => {
game?.startAudio(true);
}
});
elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => {
elements.volumeSlider.addEventListener('input', () => {
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
isAudioMuted = audioVolume <= 0;
persistAudioUiState();
@ -550,16 +479,16 @@ const main = async () => {
game?.playVibeChangeAudio(true);
};
elements.previousVibe.addEventListener(DOM_EVENTS.click, () =>
selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton)
elements.previousVibe.addEventListener('click', () =>
selectRelativeVibe(-1, 'previous-button')
);
elements.nextVibe.addEventListener(DOM_EVENTS.click, () =>
selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton)
elements.nextVibe.addEventListener('click', () =>
selectRelativeVibe(1, 'next-button')
);
elements.swatches.forEach((swatch, index) => {
swatch.addEventListener(DOM_EVENTS.click, () => {
swatch.addEventListener('click', () => {
settings.selectedColorIndex = index;
isEraserActive = false;
renderPaletteUi(game);
@ -572,11 +501,11 @@ const main = async () => {
renderPaletteUi(game);
};
elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser);
elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser);
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
elements.eraserSizeControl.addEventListener('click', activateEraser);
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => {
elements.eraserSizeSlider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
isEraserActive = true;
renderEraserSizeUi(game);
@ -584,7 +513,7 @@ const main = async () => {
configPane?.refresh();
});
elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => {
elements.mirrorSegmentSlider.addEventListener('input', () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value)
);
@ -594,7 +523,7 @@ const main = async () => {
configPane?.refresh();
});
elements.export4k.addEventListener(DOM_EVENTS.click, async () => {
elements.export4k.addEventListener('click', async () => {
if (!game || elements.export4k.disabled) {
return;
}
@ -620,7 +549,7 @@ const main = async () => {
// the AudioContext on iOS, and gates the intro.
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.fontsError,
fallbackMessage: 'Could not load fonts.',
severity: Severity.WARNING,
});
});
@ -633,12 +562,12 @@ const main = async () => {
}).then(
() => {
isPreloadComplete = true;
setLoadingStage(LOADING_MESSAGES.ready, 1);
setLoadingStage('Ready', 1);
},
(error: unknown) => {
isPreloadComplete = true;
ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.pianoSamplesError,
fallbackMessage: 'Could not preload piano samples.',
severity: Severity.WARNING,
});
}
@ -651,7 +580,6 @@ const main = async () => {
game?.onVibeChanged();
syncRuntimeUi();
},
onOpenChange: () => undefined,
onRuntimeChange: syncRuntimeUi,
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
@ -680,14 +608,14 @@ const main = async () => {
elements.startButton.disabled = false;
await new Promise<void>((resolve) => {
const onClick = () => {
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
elements.startButton.removeEventListener('click', onClick);
hasStarted = true;
game?.startAudio(true);
trackStart();
elements.splash.hidden = true;
resolve();
};
elements.startButton.addEventListener(DOM_EVENTS.click, onClick);
elements.startButton.addEventListener('click', onClick);
});
if (!isPreloadComplete) {
@ -698,16 +626,16 @@ const main = async () => {
}
// Keep the dev stats overlay hidden until the user actually starts drawing.
document.body.classList.add(CSS_CLASSES.preDrawing);
document.body.classList.add('pre-drawing');
elements.canvas.addEventListener(
DOM_EVENTS.pointerDown,
() => document.body.classList.remove(CSS_CLASSES.preDrawing),
'pointerdown',
() => document.body.classList.remove('pre-drawing'),
{ once: true }
);
requestAnimationFrame(() =>
requestAnimationFrame(() =>
document.body.classList.remove(CSS_CLASSES.isLoading)
document.body.classList.remove('is-loading')
)
);
}
@ -715,7 +643,7 @@ const main = async () => {
await game.start();
}
} catch (e) {
document.body.classList.remove(CSS_CLASSES.isLoading);
document.body.classList.remove('is-loading');
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {

View file

@ -29,6 +29,11 @@ interface PaneState extends GardenAudioVibeSettings {
}
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Follow', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 },
{ id: 'avoid', label: 'Avoid', value: -1 },
] as const;
const colorReactionRows = [
{
@ -51,14 +56,14 @@ const colorReactionRows = [
const brushControlKeys = [
'brushSize',
'spawnPerPixel',
'brushSizeVariation',
'diffusionRateBrush',
] satisfies Array<RuntimeControlKey>;
const agentControlKeys = [
'sensorOffsetDistance',
'moveSpeed',
'turnSpeed',
'forwardRotationScale',
'turnWhenLost',
'individualTrailWeight',
'decayRateTrails',
] satisfies Array<RuntimeControlKey>;
@ -80,7 +85,7 @@ const MUSIC_CONTROLS: ReadonlyArray<{
max: number;
step: number;
}> = [
{ key: 'idleIntensity', label: 'idle intensity', min: 0, max: 0.8, step: 0.01 },
{ key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 },
{ key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 },
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
@ -91,7 +96,6 @@ const MUSIC_CONTROLS: ReadonlyArray<{
interface ConfigPaneOptions {
onConfigChange: () => void;
onOpenChange?: (isOpen: boolean) => void;
onRuntimeChange: () => void;
settingsButton: HTMLButtonElement;
}
@ -119,6 +123,9 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
options: config.options,
step: config.step,
};
if (config.format !== undefined) {
params.format = config.format;
}
if (config.min !== undefined) {
params.min = config.min;
}
@ -128,10 +135,32 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
return params;
};
const getColorReactionStateIndex = (value: number): number =>
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
const getNextColorReactionState = (
value: number
): (typeof COLOR_REACTION_STATES)[number] => {
const index = getColorReactionStateIndex(value);
return COLOR_REACTION_STATES[
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
];
};
export class ConfigPane {
private readonly container: HTMLDivElement;
private readonly pane: Pane;
private readonly colorReactionSelects = new Map<ColorReactionKey, HTMLSelectElement>();
private readonly colorReactionButtons = new Map<
ColorReactionKey,
{
element: HTMLButtonElement;
sourceColorIndex: number;
targetColorIndex: number;
}
>();
private readonly colorReactionSwatches: Array<{
colorIndex: number;
element: HTMLElement;
@ -379,56 +408,79 @@ export class ConfigPane {
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): HTMLLabelElement {
const cell = doc.createElement('label');
): HTMLDivElement {
const cell = doc.createElement('div');
cell.className = 'color-reaction-matrix__cell';
const select = doc.createElement('select');
select.setAttribute(
'aria-label',
`Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}`
);
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return cell;
}
Object.entries(config.options ?? {}).forEach(([label, value]) => {
const option = doc.createElement('option');
option.value = String(value);
option.textContent = label;
select.appendChild(option);
});
const button = doc.createElement('button');
button.className = 'color-reaction-matrix__button';
button.type = 'button';
select.addEventListener('change', () => {
settings[key] = normalizeNumber(Number(select.value), config);
select.value = String(settings[key]);
const icon = doc.createElement('span');
icon.className = 'color-reaction-matrix__icon';
button.appendChild(icon);
button.addEventListener('click', () => {
const currentValue = normalizeNumber(settings[key], config);
const nextState = getNextColorReactionState(currentValue);
settings[key] = nextState.value;
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
this.options.onRuntimeChange();
});
this.colorReactionSelects.set(key, select);
cell.appendChild(select);
this.colorReactionButtons.set(key, {
element: button,
sourceColorIndex,
targetColorIndex,
});
cell.appendChild(button);
return cell;
}
private syncColorReactionMatrix(): void {
this.colorReactionSelects.forEach((select, key) => {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
this.colorReactionButtons.forEach(
({ element, sourceColorIndex, targetColorIndex }, key) => {
this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex);
}
settings[key] = normalizeNumber(settings[key], config);
select.value = String(settings[key]);
});
);
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
});
}
private syncColorReactionButton(
button: HTMLButtonElement,
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): void {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumber(settings[key], config);
const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]);
const sourceLabel = sourceColorIndex + 1;
const targetLabel = targetColorIndex + 1;
button.dataset.reaction = state.id;
button.setAttribute(
'aria-label',
`Color ${sourceLabel} agents ${state.label.toLowerCase()} color ${targetLabel}; click to switch to ${nextState.label.toLowerCase()}`
);
button.title = state.label;
}
private setUpMusicSection(container: PaneContainer): void {
const folder = container.addFolder({ title: 'Music', expanded: true });
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
@ -489,6 +541,5 @@ export class ConfigPane {
settingsButton.setAttribute('aria-expanded', String(this.isOpen));
settingsButton.setAttribute('aria-label', label);
settingsButton.title = label;
this.options.onOpenChange?.(this.isOpen);
}
}

View file

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

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
import { smartCompile } from '../../../utils/graphics/smart-compile';
import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
import compactionShader from './agent-compaction.wgsl?raw';
@ -17,10 +18,18 @@ export class AgentGenerationPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer;
private readonly bindGroupsByActiveBuffer = new WeakMap<
GPUBuffer,
WeakMap<GPUBuffer, GPUBindGroup>
>();
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUBuffer>(
(active, inactive) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: active } },
{ binding: 2, resource: { buffer: this.countersBuffer } },
{ binding: 3, resource: { buffer: inactive } },
],
})
);
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
@ -244,7 +253,6 @@ export class AgentGenerationPipeline {
this.resizeUniformFloatValues[0] = scale[0];
this.resizeUniformFloatValues[1] = scale[1];
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
this.resizeUniformUintValues[3] = 0;
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
const commandEncoder = this.device.createCommandEncoder();
@ -314,49 +322,7 @@ export class AgentGenerationPipeline {
}
private getBindGroup(): GPUBindGroup {
let inactiveCache = this.bindGroupsByActiveBuffer.get(this.activeAgentsBuffer);
if (!inactiveCache) {
inactiveCache = new WeakMap<GPUBuffer, GPUBindGroup>();
this.bindGroupsByActiveBuffer.set(this.activeAgentsBuffer, inactiveCache);
}
const cached = inactiveCache.get(this.inactiveAgentsBuffer);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.activeAgentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
{
binding: 3,
resource: {
buffer: this.inactiveAgentsBuffer,
},
},
],
});
inactiveCache.set(this.inactiveAgentsBuffer, bindGroup);
return bindGroup;
return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
}
private swapAgentBuffers(): void {

View file

@ -1,7 +1,6 @@
struct ResizeSettings {
scale: vec2<f32>,
agentCount: u32,
padding: u32,
};
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;

View file

@ -6,9 +6,38 @@ import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { dispatchAgentWorkgroups } from './agent-dispatch';
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export interface AgentSettings {
color1ToColor1: number;
color1ToColor2: number;
color1ToColor3: number;
color2ToColor1: number;
color2ToColor2: number;
color2ToColor3: number;
color3ToColor1: number;
color3ToColor2: number;
color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;
introTargetAngleBlend: number;
introProgressCutoff: number;
introNearDistanceInner: number;
introTurnRateMultiplier: number;
introRandomTurnMultiplier: number;
introFarMoveMultiplier: number;
introNearMoveMultiplier: number;
introStepStopDistance: number;
randomTimeScale: number;
}
export class AgentPipeline {
private static readonly UNIFORM_COUNT = 30;

View file

@ -1,29 +0,0 @@
export interface AgentSettings {
color1ToColor1: number;
color1ToColor2: number;
color1ToColor3: number;
color2ToColor1: number;
color2ToColor2: number;
color2ToColor3: number;
color3ToColor1: number;
color3ToColor2: number;
color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;
introTargetAngleBlend: number;
introProgressCutoff: number;
introNearDistanceInner: number;
introTurnRateMultiplier: number;
introRandomTurnMultiplier: number;
introFarMoveMultiplier: number;
introNearMoveMultiplier: number;
introStepStopDistance: number;
randomTimeScale: number;
}

View file

@ -7,9 +7,19 @@ import {
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { BrushSettings } from './brush-settings';
import shader from './brush.wgsl?raw';
export interface BrushSettings {
brushSize: number;
brushAlpha: number;
brushDiscardThreshold: number;
brushGrainNoiseScale: number;
brushGrainNoiseOffsetX: number;
brushGrainNoiseOffsetY: number;
brushGrainMinStrength: number;
brushGrainMaxStrength: number;
}
interface LineSegment {
from: vec2;
to: vec2;
@ -29,10 +39,8 @@ const setBrushUniformValues = (
target: Float32Array,
{
brushSize,
brushSizeVariation,
brushAlpha,
brushDiscardThreshold,
brushCoarseNoiseScale,
brushGrainNoiseScale,
brushGrainNoiseOffsetX,
brushGrainNoiseOffsetY,
@ -44,25 +52,21 @@ const setBrushUniformValues = (
): void => {
const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
target[0] = brushRadius;
target[1] = brushRadiusVariation * 2;
target[2] = brushGeometryRadius * brushGeometryRadius;
target[3] = brushGeometryRadius;
target[1] = brushRadius * brushRadius;
target[2] = 0;
target[3] = 0;
target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha;
target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio);
target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[10] = brushGrainNoiseOffsetX;
target[11] = brushGrainNoiseOffsetY;
target[12] = brushDiscardThreshold;
target[13] = brushGrainMinStrength;
target[14] = brushGrainMaxStrength;
target[15] = 0;
target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[9] = brushGrainNoiseOffsetX;
target[10] = brushGrainNoiseOffsetY;
target[11] = brushDiscardThreshold;
target[12] = brushGrainMinStrength;
target[13] = brushGrainMaxStrength;
};
export class BrushPipeline {

View file

@ -1,12 +0,0 @@
export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
brushAlpha: number;
brushDiscardThreshold: number;
brushCoarseNoiseScale: number;
brushGrainNoiseScale: number;
brushGrainNoiseOffsetX: number;
brushGrainNoiseOffsetY: number;
brushGrainMinStrength: number;
brushGrainMaxStrength: number;
}

View file

@ -1,10 +1,10 @@
struct Settings {
brushSize: f32,
brushSizeVariation: f32,
brushGeometryRadiusSquared: f32,
brushGeometryRadius: f32,
brushRadius: f32,
brushRadiusSquared: f32,
// padding to 16-byte alignment for the following vec4
_pad0: f32,
_pad1: f32,
brushValue: vec4<f32>,
brushCoarseNoiseScale: f32,
brushGrainNoiseScale: f32,
brushGrainNoiseOffsetX: f32,
brushGrainNoiseOffsetY: f32,
@ -39,7 +39,7 @@ fn vertex(
if denominator > 0.0001 {
inverseLengthSquared = 1.0 / denominator;
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius);
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius);
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
@ -74,18 +74,11 @@ fn brushStrength(
direction,
inverseLengthSquared
);
if distanceSquared > settings.brushGeometryRadiusSquared {
if distanceSquared > settings.brushRadiusSquared {
return 0.0;
}
let coarseNoise = textureSampleLevel(
noise,
noiseSampler,
screenPosition * settings.brushCoarseNoiseScale,
0.0
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
let edge = 1.0 - step(radius * radius, distanceSquared);
let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared);
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
return 0.0;
}

View file

@ -24,9 +24,8 @@ export class CommonState {
struct State {
size: vec2<f32>,
time: f32,
padding0: f32,
};
@group(0) @binding(0) var<uniform> state: State;
@group(0) @binding(1) var noiseSampler: sampler;
@group(0) @binding(2) var noise: texture_2d<f32>;
@ -101,7 +100,6 @@ export class CommonState {
this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1];
this.uniformValues[2] = time;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,

View file

@ -1,12 +1,12 @@
struct Settings {
inverseDiffusionRateTrails: f32,
decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
diffusionNeighborScale: f32,
brushDecayAlphaMultiplier: f32,
brushDecayAlphaSubtract: f32,
padding0: f32,
padding1: f32,
padding2: f32,
};
const WORKGROUP_SIZE_X = 16u;
@ -74,25 +74,16 @@ fn main(
r16,
settings.inverseDiffusionRateTrails
);
let brushWeight = diffusion_weight(
random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateBrush
);
current += (
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, -1, 1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 1, -1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 1, 1, current, trailWeight, brushWeight)
propagate(centerTileIndex, -1, -1, current, trailWeight)
+ propagate(centerTileIndex, -1, 1, current, trailWeight)
+ propagate(centerTileIndex, 1, -1, current, trailWeight)
+ propagate(centerTileIndex, 1, 1, current, trailWeight)
+ propagate(centerTileIndex, -1, 0, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, -1, 0, current, trailWeight)
+ propagate(centerTileIndex, 0, -1, current, trailWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight)
) * settings.diffusionNeighborScale;
let decayed = clamp(vec4(
@ -108,8 +99,7 @@ fn propagate(
offsetX: i32,
offsetY: i32,
currentColor: vec4<f32>,
trailWeight: f32,
brushWeight: f32
trailWeight: f32
) -> vec4<f32> {
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
let neighbourTileIndex = u32(neighbourIndex);
@ -118,7 +108,7 @@ fn propagate(
return vec4(
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
neighbour.a * brushWeight
neighbour.a * trailWeight
) * difference;
}

View file

@ -1,20 +1,27 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
decayRateBrush: number;
diffusionDecayRateDivisor: number;
diffusionNeighborDivisor: number;
brushDecayAlphaOffset: number;
}
type DiffusionUniformSettings = Pick<
DiffusionSettings,
| 'diffusionRateTrails'
| 'decayRateTrails'
| 'diffusionRateBrush'
| 'decayRateBrush'
| 'diffusionDecayRateDivisor'
| 'diffusionNeighborDivisor'
@ -33,7 +40,6 @@ const setDiffusionUniformValues = (
{
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
@ -47,11 +53,11 @@ const setDiffusionUniformValues = (
: 1;
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / decayDivisor;
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
target[3] = brushDecayRate;
target[4] = 1 / neighborDivisor;
target[5] = 1 + brushDecayRate;
target[6] = brushDecayAlphaOffset * brushDecayRate;
target[2] = 1 / neighborDivisor;
target[3] = 1 + brushDecayRate;
target[4] = brushDecayAlphaOffset * brushDecayRate;
target[5] = 0;
target[6] = 0;
target[7] = 0;
};
@ -66,10 +72,17 @@ export class DiffusionPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite(
DiffusionPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByInput = new WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, GPUBindGroup>
>();
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
(trailMapIn, trailMapOut) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: trailMapIn },
{ binding: 2, resource: trailMapOut },
],
})
);
public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout(
@ -95,7 +108,6 @@ export class DiffusionPipeline {
public setParameters({
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
@ -104,7 +116,6 @@ export class DiffusionPipeline {
setDiffusionUniformValues(this.uniformValues, {
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
@ -130,51 +141,12 @@ export class DiffusionPipeline {
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups(
getWorkgroupCount(size[0], DiffusionPipeline.WORKGROUP_SIZE),
getWorkgroupCount(size[1], DiffusionPipeline.WORKGROUP_SIZE)
Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
}
private getBindGroup(
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
): GPUBindGroup {
let outputCache = this.bindGroupsByInput.get(trailMapIn);
if (!outputCache) {
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByInput.set(trailMapIn, outputCache);
}
const cached = outputCache.get(trailMapOut);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: trailMapIn,
},
{
binding: 2,
resource: trailMapOut,
},
],
});
outputCache.set(trailMapOut, bindGroup);
return bindGroup;
}
public destroy() {
this.uniforms.destroy();
}

View file

@ -1,9 +0,0 @@
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
diffusionRateBrush: number;
decayRateBrush: number;
diffusionDecayRateDivisor: number;
diffusionNeighborDivisor: number;
brushDecayAlphaOffset: number;
}

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@ -20,10 +21,17 @@ export class EraserAgentPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByAgentsBuffer = new WeakMap<
GPUBuffer,
WeakMap<GPUTextureView, GPUBindGroup>
>();
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
(agentsBuffer, eraserMask) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: agentsBuffer } },
{ binding: 2, resource: eraserMask },
],
})
);
private pendingSegmentCount = 0;
private activeSegmentCount = 0;
@ -121,7 +129,7 @@ export class EraserAgentPipeline {
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.agentCount);
passEncoder.end();
}
@ -129,43 +137,4 @@ export class EraserAgentPipeline {
public destroy(): void {
this.uniforms.destroy();
}
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
const agentsBuffer = this.getAgentsBuffer();
let maskCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
if (!maskCache) {
maskCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, maskCache);
}
const cached = maskCache.get(eraserMask);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: agentsBuffer,
},
},
{
binding: 2,
resource: eraserMask,
},
],
});
maskCache.set(eraserMask, bindGroup);
return bindGroup;
}
}

View file

@ -124,7 +124,6 @@ export class EraserTexturePipeline {
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = eraserRadius;
this.uniformValues[7] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,

View file

@ -6,7 +6,6 @@ struct Settings {
clearBlue: f32,
clearAlpha: f32,
eraserRadius: f32,
padding1: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;

View file

@ -1,3 +1,4 @@
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@ -6,9 +7,16 @@ import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw';
export interface RenderSettings {
clarity: number;
renderTraceNormalizationFloor: number;
renderBrushColorBase: number;
renderBrushColorStrengthMultiplier: number;
backgroundGrainStrength: number;
}
export class RenderPipeline {
private static readonly UNIFORM_COUNT = 20;
@ -21,10 +29,17 @@ export class RenderPipeline {
RenderPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByTexture = new WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, GPUBindGroup>
>();
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
(colorTexture, sourceTexture) =>
this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 2, resource: colorTexture },
{ binding: 3, resource: sourceTexture },
],
})
);
public constructor(
private readonly context: GPUCanvasContext,
@ -165,45 +180,6 @@ export class RenderPipeline {
passEncoder.end();
}
private getBindGroup(
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView
): GPUBindGroup {
let sourceTextureCache = this.bindGroupsByTexture.get(colorTexture);
if (!sourceTextureCache) {
sourceTextureCache = new WeakMap<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: 2,
resource: colorTexture,
},
{
binding: 3,
resource: sourceTexture,
},
],
});
sourceTextureCache.set(sourceTexture, bindGroup);
return bindGroup;
}
public destroy() {
this.uniforms.destroy();
}

View file

@ -1,7 +0,0 @@
export interface RenderSettings {
clarity: number;
renderTraceNormalizationFloor: number;
renderBrushColorBase: number;
renderBrushColorStrengthMultiplier: number;
backgroundGrainStrength: number;
}

View file

@ -44,27 +44,105 @@
.color-reaction-matrix__cell {
min-width: 0;
display: grid;
}
.color-reaction-matrix__cell > select {
.color-reaction-matrix__button {
position: relative;
display: grid;
width: 100%;
min-width: 0;
height: 28px;
place-items: center;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 4px;
padding: 0 4px;
background: rgb(255 255 255 / 8%);
color: white;
font: inherit;
font-size: 11px;
cursor: pointer;
transition:
background-color var(--transition-time),
border-color var(--transition-time),
color var(--transition-time),
transform var(--transition-time);
}
.color-reaction-matrix__cell > select:focus-visible {
.color-reaction-matrix__button:hover {
transform: translateY(-1px);
}
.color-reaction-matrix__button:focus-visible {
outline: 2px solid rgb(255 255 255 / 72%);
outline-offset: 1px;
}
.color-reaction-matrix__cell > select > option {
background: rgb(28 31 38);
color: white;
.color-reaction-matrix__button[data-reaction='follow'] {
border-color: rgb(115 235 160 / 44%);
background: rgb(53 165 96 / 20%);
color: rgb(157 255 195 / 94%);
}
.color-reaction-matrix__button[data-reaction='ignore'] {
border-color: rgb(255 255 255 / 18%);
background: rgb(255 255 255 / 7%);
color: rgb(235 238 245 / 72%);
}
.color-reaction-matrix__button[data-reaction='avoid'] {
border-color: rgb(255 145 120 / 46%);
background: rgb(215 74 54 / 19%);
color: rgb(255 171 148 / 94%);
}
.color-reaction-matrix__icon {
position: relative;
display: block;
width: 16px;
height: 16px;
}
.color-reaction-matrix__icon::before,
.color-reaction-matrix__icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
border-radius: 999px;
background: currentColor;
transform: translate(-50%, -50%);
}
.color-reaction-matrix__button[data-reaction='follow']
> .color-reaction-matrix__icon::before,
.color-reaction-matrix__button[data-reaction='avoid']
> .color-reaction-matrix__icon::before {
width: 14px;
height: 2px;
}
.color-reaction-matrix__button[data-reaction='follow']
> .color-reaction-matrix__icon::after {
width: 2px;
height: 14px;
}
.color-reaction-matrix__button[data-reaction='avoid']
> .color-reaction-matrix__icon::after {
display: none;
}
.color-reaction-matrix__button[data-reaction='ignore'] > .color-reaction-matrix__icon {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-radius: 999px;
opacity: 0.82;
}
.color-reaction-matrix__button[data-reaction='ignore']
> .color-reaction-matrix__icon::before,
.color-reaction-matrix__button[data-reaction='ignore']
> .color-reaction-matrix__icon::after {
display: none;
}

View file

@ -1,6 +1,6 @@
export const readBrowserStorage = (key: string): string | null => {
try {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
return localStorage.getItem(key);
} catch {
return null;
}
@ -8,13 +8,11 @@ export const readBrowserStorage = (key: string): string | null => {
export const writeBrowserStorage = (key: string, value: string): void => {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
localStorage.setItem(key, value);
} catch (error) {
console.warn(
'Storage can be unavailable in private browsing or embedded contexts.',
error,
error
);
}
};

View file

@ -0,0 +1,19 @@
export const createBindGroupCache = <K1 extends object, K2 extends object>(
factory: (key1: K1, key2: K2) => GPUBindGroup
): ((key1: K1, key2: K2) => GPUBindGroup) => {
const outer = new WeakMap<K1, WeakMap<K2, GPUBindGroup>>();
return (key1, key2) => {
let inner = outer.get(key1);
if (!inner) {
inner = new WeakMap();
outer.set(key1, inner);
}
const cached = inner.get(key2);
if (cached) {
return cached;
}
const bindGroup = factory(key1, key2);
inner.set(key2, bindGroup);
return bindGroup;
};
};

View file

@ -1,17 +0,0 @@
export const getWorkgroupCount = (
invocationCount: number,
workgroupSize: number
): number => {
if (
!Number.isFinite(invocationCount) ||
!Number.isFinite(workgroupSize) ||
invocationCount <= 0 ||
workgroupSize <= 0
) {
throw new Error(
'Invocation count and workgroup size must be positive finite numbers'
);
}
return Math.ceil(invocationCount / workgroupSize);
};

View file

@ -1,8 +1,9 @@
import { appConfig, type VibeId, type VibePreset } from './config';
import { appConfig } from './config';
import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage';
export { VibeId } from './config';
export type { VibePreset } from './config';
export { VibeId };
export type { VibePreset };
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));