simplify more
This commit is contained in:
parent
f03da42b5e
commit
2fe3c69963
40 changed files with 689 additions and 872 deletions
BIN
public/og-image.jpg
Normal file
BIN
public/og-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ export interface GardenAudioStroke {
|
|||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
userGesture?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSample {
|
||||
midi: number;
|
||||
buffer: AudioBuffer;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushCoarseNoiseScale: 160,
|
||||
brushGrainNoiseScale: 22,
|
||||
brushGrainNoiseOffsetX: 0.31,
|
||||
brushGrainNoiseOffsetY: 0.67,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ interface FrameParameters extends RenderInputs {
|
|||
canvasPixelRatio: number;
|
||||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
eraserPixelSize: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
204
src/index.ts
204
src/index.ts
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
struct ResizeSettings {
|
||||
scale: vec2<f32>,
|
||||
agentCount: u32,
|
||||
padding: u32,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
export interface DiffusionSettings {
|
||||
diffusionRateTrails: number;
|
||||
decayRateTrails: number;
|
||||
diffusionRateBrush: number;
|
||||
decayRateBrush: number;
|
||||
diffusionDecayRateDivisor: number;
|
||||
diffusionNeighborDivisor: number;
|
||||
brushDecayAlphaOffset: number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ struct Settings {
|
|||
clearBlue: f32,
|
||||
clearAlpha: f32,
|
||||
eraserRadius: f32,
|
||||
padding1: f32,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export interface RenderSettings {
|
||||
clarity: number;
|
||||
renderTraceNormalizationFloor: number;
|
||||
renderBrushColorBase: number;
|
||||
renderBrushColorStrengthMultiplier: number;
|
||||
backgroundGrainStrength: number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
19
src/utils/graphics/bind-group-cache.ts
Normal file
19
src/utils/graphics/bind-group-cache.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue