simplify more
This commit is contained in:
parent
f03da42b5e
commit
2fe3c69963
40 changed files with 690 additions and 873 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 { DEFAULT_AUDIO_VOLUME } from '../app-constants';
|
||||||
import type { PianoNoteRole } from './garden-audio-types';
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
type GardenAudioChordQuality = 'major' | 'minor';
|
|
||||||
|
|
||||||
export interface GardenAudioChord {
|
export interface GardenAudioChord {
|
||||||
rootOffset: number;
|
rootOffset: number;
|
||||||
quality: GardenAudioChordQuality;
|
quality: 'major' | 'minor';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioVibeSettings {
|
export interface GardenAudioVibeSettings {
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,6 @@ export interface GardenAudioStroke {
|
||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioStartOptions {
|
|
||||||
userGesture?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoadedPianoSample {
|
export interface LoadedPianoSample {
|
||||||
midi: number;
|
midi: number;
|
||||||
buffer: AudioBuffer;
|
buffer: AudioBuffer;
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@ import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { getStrokeMetrics } from './garden-audio-input';
|
import { getStrokeMetrics } from './garden-audio-input';
|
||||||
import { getVibeProfile } from './garden-audio-music';
|
import { getVibeProfile } from './garden-audio-music';
|
||||||
import type {
|
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
|
||||||
GardenAudioSnapshot,
|
|
||||||
GardenAudioStartOptions,
|
|
||||||
GardenAudioStroke,
|
|
||||||
} from './garden-audio-types';
|
|
||||||
import { GenerativePianoEngine } from './generative-piano';
|
import { GenerativePianoEngine } from './generative-piano';
|
||||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||||
import { PianoSampler } from './piano-sampler';
|
import { PianoSampler } from './piano-sampler';
|
||||||
|
|
@ -53,7 +49,7 @@ export class GardenAudio {
|
||||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
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;
|
const isUserGesture = options.userGesture === true;
|
||||||
|
|
||||||
if (this.lifecycle === 'destroyed') {
|
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;
|
const previousVibeId = this.currentVibeId;
|
||||||
this.start(vibe, options);
|
this.start(vibe, options);
|
||||||
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
||||||
|
|
|
||||||
|
|
@ -46,20 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb
|
||||||
|
|
||||||
type GardenAudioStyleIndex = 0 | 1 | 2;
|
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 {
|
interface TouchDownRequest {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
now: number;
|
now: number;
|
||||||
|
|
@ -118,10 +104,6 @@ export class GenerativePianoEngine {
|
||||||
private readonly playNote: (note: PianoNote) => void
|
private readonly playNote: (note: PianoNote) => void
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get generation(): typeof generativePianoTuning {
|
|
||||||
return generativePianoTuning;
|
|
||||||
}
|
|
||||||
|
|
||||||
public prime(now: number, profile: GardenAudioVibeProfile): void {
|
public prime(now: number, profile: GardenAudioVibeProfile): void {
|
||||||
this.activeProfile = profile;
|
this.activeProfile = profile;
|
||||||
this.timelineStartedAt ??= now;
|
this.timelineStartedAt ??= now;
|
||||||
|
|
@ -162,7 +144,7 @@ export class GenerativePianoEngine {
|
||||||
this.brushPhraseLayers = [];
|
this.brushPhraseLayers = [];
|
||||||
this.brushStreamNoteCountsByBar.clear();
|
this.brushStreamNoteCountsByBar.clear();
|
||||||
|
|
||||||
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
|
return releaseStart + generativePianoTuning.releaseResolution.fadeAfterSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordTouchDown({
|
private recordTouchDown({
|
||||||
|
|
@ -198,7 +180,12 @@ export class GenerativePianoEngine {
|
||||||
now,
|
now,
|
||||||
activity,
|
activity,
|
||||||
maniaAmount = 0,
|
maniaAmount = 0,
|
||||||
}: StrokeAccentRequest): void {
|
}: {
|
||||||
|
vibe: VibePreset;
|
||||||
|
now: number;
|
||||||
|
activity: number;
|
||||||
|
maniaAmount?: number;
|
||||||
|
}): void {
|
||||||
const profile = getVibeProfile(vibe);
|
const profile = getVibeProfile(vibe);
|
||||||
this.prime(now, profile);
|
this.prime(now, profile);
|
||||||
const strength = clamp01(activity);
|
const strength = clamp01(activity);
|
||||||
|
|
@ -206,12 +193,12 @@ export class GenerativePianoEngine {
|
||||||
const styleIndex = this.getStyleIndex(now);
|
const styleIndex = this.getStyleIndex(now);
|
||||||
const accentStep = this.getNextStepIndexAt(
|
const accentStep = this.getNextStepIndexAt(
|
||||||
now,
|
now,
|
||||||
this.generation.gestureAccent.quantizeStepLookahead
|
generativePianoTuning.gestureAccent.quantizeStepLookahead
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isWaitingForGestureAccent &&
|
this.isWaitingForGestureAccent &&
|
||||||
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds
|
now - this.lastGestureAccentAt >= generativePianoTuning.gestureAccentMinIntervalSeconds
|
||||||
) {
|
) {
|
||||||
this.recordTouchDown({
|
this.recordTouchDown({
|
||||||
vibe,
|
vibe,
|
||||||
|
|
@ -230,8 +217,8 @@ export class GenerativePianoEngine {
|
||||||
maniaAmount: normalizedManiaAmount,
|
maniaAmount: normalizedManiaAmount,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
strength >= this.generation.strokeAccentThreshold &&
|
strength >= generativePianoTuning.strokeAccentThreshold &&
|
||||||
accentStep - this.lastStrokeAccentStep >= this.generation.strokeAccentMinSteps
|
accentStep - this.lastStrokeAccentStep >= generativePianoTuning.strokeAccentMinSteps
|
||||||
) {
|
) {
|
||||||
this.lastStrokeAccentStep = accentStep;
|
this.lastStrokeAccentStep = accentStep;
|
||||||
this.playGestureAccent(vibe, accentStep, styleIndex, strength);
|
this.playGestureAccent(vibe, accentStep, styleIndex, strength);
|
||||||
|
|
@ -243,7 +230,12 @@ export class GenerativePianoEngine {
|
||||||
now,
|
now,
|
||||||
activity,
|
activity,
|
||||||
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
|
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
|
||||||
}: RenderLookaheadRequest): void {
|
}: {
|
||||||
|
vibe: VibePreset;
|
||||||
|
now: number;
|
||||||
|
activity: number;
|
||||||
|
lookaheadSeconds?: number;
|
||||||
|
}): void {
|
||||||
const profile = getVibeProfile(vibe);
|
const profile = getVibeProfile(vibe);
|
||||||
this.prime(now, profile);
|
this.prime(now, profile);
|
||||||
this.skipLateBeats(now);
|
this.skipLateBeats(now);
|
||||||
|
|
@ -253,13 +245,14 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookaheadEnd = now + lookaheadSeconds;
|
const lookaheadEnd = now + lookaheadSeconds;
|
||||||
|
const expression = this.getExpression(activity);
|
||||||
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
|
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
|
||||||
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
|
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
|
||||||
this.renderBeat({
|
this.renderBeat({
|
||||||
profile,
|
profile,
|
||||||
beatIndex,
|
beatIndex,
|
||||||
startTime: this.getTimeForStep(this.nextBeatStep),
|
startTime: this.getTimeForStep(this.nextBeatStep),
|
||||||
expression: this.getExpression(activity),
|
expression,
|
||||||
});
|
});
|
||||||
this.nextBeatStep += this.config.rhythm.stepsPerBeat;
|
this.nextBeatStep += this.config.rhythm.stepsPerBeat;
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +260,7 @@ export class GenerativePianoEngine {
|
||||||
vibe,
|
vibe,
|
||||||
now,
|
now,
|
||||||
lookaheadEnd,
|
lookaheadEnd,
|
||||||
activity,
|
activity: expression,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +269,7 @@ export class GenerativePianoEngine {
|
||||||
const chord = this.getChord(profile, 0);
|
const chord = this.getChord(profile, 0);
|
||||||
const intervals = getChordIntervals(chord, true);
|
const intervals = getChordIntervals(chord, true);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const stinger = this.generation.vibeChangeStinger;
|
const stinger = generativePianoTuning.vibeChangeStinger;
|
||||||
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
||||||
[0],
|
[0],
|
||||||
[intervals[1], intervals[2]],
|
[intervals[1], intervals[2]],
|
||||||
|
|
@ -286,7 +279,7 @@ export class GenerativePianoEngine {
|
||||||
offsetsByVoice.forEach((offsets, index) => {
|
offsetsByVoice.forEach((offsets, index) => {
|
||||||
const midi = this.chooseMidi(
|
const midi = this.chooseMidi(
|
||||||
{ baseMidi: rootMidi, offsets },
|
{ baseMidi: rootMidi, offsets },
|
||||||
this.generation.padRegisters[index]
|
generativePianoTuning.padRegisters[index]
|
||||||
);
|
);
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
|
|
@ -340,7 +333,7 @@ export class GenerativePianoEngine {
|
||||||
const barIndex = Math.floor(beatIndex / beatsPerBar);
|
const barIndex = Math.floor(beatIndex / beatsPerBar);
|
||||||
const styleIndex = this.getStyleIndex(startTime);
|
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);
|
this.playPadChord(profile, barIndex, startTime, expression);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,21 +342,21 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
beatInBar === this.generation.textureBeat &&
|
beatInBar === generativePianoTuning.textureBeat &&
|
||||||
this.shouldPlayTexture(expression, barIndex)
|
this.shouldPlayTexture(expression, barIndex)
|
||||||
) {
|
) {
|
||||||
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
|
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
beatInBar === this.generation.highActivityExtraBeat &&
|
beatInBar === generativePianoTuning.highActivityExtraBeat &&
|
||||||
expression >= this.generation.highActivityExtraThreshold
|
expression >= generativePianoTuning.highActivityExtraThreshold
|
||||||
) {
|
) {
|
||||||
this.playTextureNote(
|
this.playTextureNote(
|
||||||
profile,
|
profile,
|
||||||
barIndex + this.generation.highActivityExtra.barOffset,
|
barIndex + generativePianoTuning.highActivityExtra.barOffset,
|
||||||
startTime,
|
startTime,
|
||||||
expression * this.generation.highActivityExtra.expressionMultiplier,
|
expression * generativePianoTuning.highActivityExtra.expressionMultiplier,
|
||||||
styleIndex
|
styleIndex
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -380,23 +373,23 @@ export class GenerativePianoEngine {
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
this.getBarDurationSeconds() *
|
this.getBarDurationSeconds() *
|
||||||
this.generation.chordBars *
|
generativePianoTuning.chordBars *
|
||||||
this.generation.padDurationBarScale;
|
generativePianoTuning.padDurationBarScale;
|
||||||
const notes = [
|
const notes = [
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [0] },
|
source: { baseMidi: rootMidi, offsets: [0] },
|
||||||
register: this.generation.padRegisters[0],
|
register: generativePianoTuning.padRegisters[0],
|
||||||
velocity: this.generation.padChord.velocities[0],
|
velocity: generativePianoTuning.padChord.velocities[0],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
||||||
register: this.generation.padRegisters[1],
|
register: generativePianoTuning.padRegisters[1],
|
||||||
velocity: this.generation.padChord.velocities[1],
|
velocity: generativePianoTuning.padChord.velocities[1],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||||
register: this.generation.padRegisters[2],
|
register: generativePianoTuning.padRegisters[2],
|
||||||
velocity: this.generation.padChord.velocities[2],
|
velocity: generativePianoTuning.padChord.velocities[2],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -411,16 +404,16 @@ export class GenerativePianoEngine {
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
velocity:
|
velocity:
|
||||||
velocity + expression * this.generation.padChord.expressionVelocityWeight,
|
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
pan: register.pan,
|
pan: register.pan,
|
||||||
role: 'pad',
|
role: 'pad',
|
||||||
delaySend: this.generation.padChord.delaySend,
|
delaySend: generativePianoTuning.padChord.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(
|
lowpassHz: this.getLowpassHz(
|
||||||
profile,
|
profile,
|
||||||
midi,
|
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 chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
||||||
const intervals = getChordIntervals(chord, true);
|
const intervals = getChordIntervals(chord, true);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const release = this.generation.releaseResolution;
|
const release = generativePianoTuning.releaseResolution;
|
||||||
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
||||||
[0],
|
[0],
|
||||||
[intervals[1], intervals[2]],
|
[intervals[1], intervals[2]],
|
||||||
|
|
@ -442,7 +435,7 @@ export class GenerativePianoEngine {
|
||||||
];
|
];
|
||||||
|
|
||||||
offsetsByVoice.forEach((offsets, index) => {
|
offsetsByVoice.forEach((offsets, index) => {
|
||||||
const register = this.generation.padRegisters[index];
|
const register = generativePianoTuning.padRegisters[index];
|
||||||
const midi = this.chooseMidi(
|
const midi = this.chooseMidi(
|
||||||
{ baseMidi: rootMidi, offsets },
|
{ baseMidi: rootMidi, offsets },
|
||||||
register,
|
register,
|
||||||
|
|
@ -469,7 +462,7 @@ export class GenerativePianoEngine {
|
||||||
expression: number,
|
expression: number,
|
||||||
styleIndex: GardenAudioStyleIndex
|
styleIndex: GardenAudioStyleIndex
|
||||||
): void {
|
): void {
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const chord = this.getChord(profile, barIndex);
|
const chord = this.getChord(profile, barIndex);
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
|
@ -488,22 +481,22 @@ export class GenerativePianoEngine {
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.supportNote.velocityBase +
|
(generativePianoTuning.supportNote.velocityBase +
|
||||||
expression * this.generation.supportNote.velocityExpressionWeight) *
|
expression * generativePianoTuning.supportNote.velocityExpressionWeight) *
|
||||||
styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.supportNote.durationBaseSeconds +
|
generativePianoTuning.supportNote.durationBaseSeconds +
|
||||||
expression * this.generation.supportNote.durationExpressionSeconds,
|
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex),
|
||||||
role: 'support',
|
role: 'support',
|
||||||
delaySend:
|
delaySend:
|
||||||
this.generation.supportNote.delaySendBase +
|
generativePianoTuning.supportNote.delaySendBase +
|
||||||
expression * this.generation.supportNote.delaySendExpressionWeight,
|
expression * generativePianoTuning.supportNote.delaySendExpressionWeight,
|
||||||
lowpassHz: this.getLowpassHz(
|
lowpassHz: this.getLowpassHz(
|
||||||
profile,
|
profile,
|
||||||
midi,
|
midi,
|
||||||
expression * this.generation.supportNote.lowpassExpressionWeight
|
expression * generativePianoTuning.supportNote.lowpassExpressionWeight
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -515,7 +508,7 @@ export class GenerativePianoEngine {
|
||||||
expression: number,
|
expression: number,
|
||||||
styleIndex: GardenAudioStyleIndex
|
styleIndex: GardenAudioStyleIndex
|
||||||
): void {
|
): void {
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const chord = this.getChord(profile, barIndex);
|
const chord = this.getChord(profile, barIndex);
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
|
const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
|
||||||
|
|
@ -534,18 +527,18 @@ export class GenerativePianoEngine {
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.textureNote.velocityBase +
|
(generativePianoTuning.textureNote.velocityBase +
|
||||||
expression * this.generation.textureNote.velocityExpressionWeight) *
|
expression * generativePianoTuning.textureNote.velocityExpressionWeight) *
|
||||||
styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.textureNote.durationBaseSeconds +
|
generativePianoTuning.textureNote.durationBaseSeconds +
|
||||||
expression * this.generation.textureNote.durationExpressionSeconds,
|
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex),
|
||||||
role: 'texture',
|
role: 'texture',
|
||||||
delaySend:
|
delaySend:
|
||||||
this.generation.textureNote.delaySendBase +
|
generativePianoTuning.textureNote.delaySendBase +
|
||||||
expression * this.generation.textureNote.delaySendExpressionWeight,
|
expression * generativePianoTuning.textureNote.delaySendExpressionWeight,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, expression),
|
lowpassHz: this.getLowpassHz(profile, midi, expression),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -557,13 +550,13 @@ export class GenerativePianoEngine {
|
||||||
strength: number
|
strength: number
|
||||||
): void {
|
): void {
|
||||||
const profile = getVibeProfile(vibe);
|
const profile = getVibeProfile(vibe);
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const startTime = this.getTimeForStep(stepIndex);
|
const startTime = this.getTimeForStep(stepIndex);
|
||||||
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const degrees = this.rotate(
|
const degrees = this.rotate(
|
||||||
pool.scaleDegrees,
|
pool.scaleDegrees,
|
||||||
Math.round(strength * this.generation.gestureAccent.rotationStrengthMultiplier)
|
Math.round(strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier)
|
||||||
);
|
);
|
||||||
|
|
||||||
const midi = this.chooseMidi(
|
const midi = this.chooseMidi(
|
||||||
|
|
@ -581,16 +574,16 @@ export class GenerativePianoEngine {
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.gestureAccent.velocityBase +
|
(generativePianoTuning.gestureAccent.velocityBase +
|
||||||
strength * this.generation.gestureAccent.velocityStrengthWeight) *
|
strength * generativePianoTuning.gestureAccent.velocityStrengthWeight) *
|
||||||
styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.gestureAccent.durationBaseSeconds +
|
generativePianoTuning.gestureAccent.durationBaseSeconds +
|
||||||
strength * this.generation.gestureAccent.durationStrengthSeconds,
|
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex),
|
||||||
role: 'gesture',
|
role: 'gesture',
|
||||||
delaySend: this.generation.gestureAccent.delaySend,
|
delaySend: generativePianoTuning.gestureAccent.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -607,7 +600,7 @@ export class GenerativePianoEngine {
|
||||||
strength: number;
|
strength: number;
|
||||||
}): void {
|
}): void {
|
||||||
const profile = getVibeProfile(vibe);
|
const profile = getVibeProfile(vibe);
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
|
@ -626,22 +619,22 @@ export class GenerativePianoEngine {
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.touchNote.velocityBase +
|
(generativePianoTuning.touchNote.velocityBase +
|
||||||
strength * this.generation.touchNote.velocityStrengthWeight) *
|
strength * generativePianoTuning.touchNote.velocityStrengthWeight) *
|
||||||
styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.touchNote.durationBaseSeconds +
|
generativePianoTuning.touchNote.durationBaseSeconds +
|
||||||
strength * this.generation.touchNote.durationStrengthSeconds,
|
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex),
|
||||||
role: 'gesture',
|
role: 'gesture',
|
||||||
delaySend: this.generation.touchNote.delaySend,
|
delaySend: generativePianoTuning.touchNote.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(
|
lowpassHz: this.getLowpassHz(
|
||||||
profile,
|
profile,
|
||||||
midi,
|
midi,
|
||||||
clamp01(
|
clamp01(
|
||||||
this.generation.touchNote.lowpassBaseExpression +
|
generativePianoTuning.touchNote.lowpassBaseExpression +
|
||||||
strength * this.generation.touchNote.lowpassStrengthWeight
|
strength * generativePianoTuning.touchNote.lowpassStrengthWeight
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -661,8 +654,8 @@ export class GenerativePianoEngine {
|
||||||
maniaAmount: number;
|
maniaAmount: number;
|
||||||
}): void {
|
}): void {
|
||||||
const lifetimeSeconds =
|
const lifetimeSeconds =
|
||||||
this.generation.brushLayerBaseSeconds +
|
generativePianoTuning.brushLayerBaseSeconds +
|
||||||
strength * this.generation.brushLayerEnergySeconds;
|
strength * generativePianoTuning.brushLayerEnergySeconds;
|
||||||
const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds);
|
const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds);
|
||||||
|
|
||||||
this.brushPhraseLayers.push({
|
this.brushPhraseLayers.push({
|
||||||
|
|
@ -672,13 +665,13 @@ export class GenerativePianoEngine {
|
||||||
expiresAt,
|
expiresAt,
|
||||||
styleIndex,
|
styleIndex,
|
||||||
energy: strength,
|
energy: strength,
|
||||||
motifOffsets: [styleIndex + this.generation.brushPhrase.initialMotifOffset],
|
motifOffsets: [styleIndex + generativePianoTuning.brushPhrase.initialMotifOffset],
|
||||||
maniaAmount,
|
maniaAmount,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) {
|
if (this.brushPhraseLayers.length > generativePianoTuning.maxBrushPhraseLayers) {
|
||||||
this.brushPhraseLayers = this.brushPhraseLayers.slice(
|
this.brushPhraseLayers = this.brushPhraseLayers.slice(
|
||||||
-this.generation.maxBrushPhraseLayers
|
-generativePianoTuning.maxBrushPhraseLayers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -704,17 +697,17 @@ export class GenerativePianoEngine {
|
||||||
layer.styleIndex = styleIndex;
|
layer.styleIndex = styleIndex;
|
||||||
layer.energy = Math.max(
|
layer.energy = Math.max(
|
||||||
layer.energy *
|
layer.energy *
|
||||||
Math.exp(-elapsedSeconds / this.generation.brushPhrase.energyDecaySeconds),
|
Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.energyDecaySeconds),
|
||||||
strength
|
strength
|
||||||
);
|
);
|
||||||
layer.maniaAmount = Math.max(
|
layer.maniaAmount = Math.max(
|
||||||
layer.maniaAmount *
|
layer.maniaAmount *
|
||||||
Math.exp(-elapsedSeconds / this.generation.brushPhrase.maniaDecaySeconds),
|
Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.maniaDecaySeconds),
|
||||||
maniaAmount
|
maniaAmount
|
||||||
);
|
);
|
||||||
layer.motifOffsets.push(this.getMotifOffset(strength));
|
layer.motifOffsets.push(this.getMotifOffset(strength));
|
||||||
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
|
if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) {
|
||||||
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
|
layer.motifOffsets = layer.motifOffsets.slice(-generativePianoTuning.brushMotifMaxSteps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -750,7 +743,7 @@ export class GenerativePianoEngine {
|
||||||
const startTime = this.getTimeForStep(this.nextBrushStreamStep);
|
const startTime = this.getTimeForStep(this.nextBrushStreamStep);
|
||||||
const frame = this.getBrushStreamFrame(startTime, activity);
|
const frame = this.getBrushStreamFrame(startTime, activity);
|
||||||
if (
|
if (
|
||||||
frame.intensity >= this.generation.brushLayerMinIntensity &&
|
frame.intensity >= generativePianoTuning.brushLayerMinIntensity &&
|
||||||
this.reserveBrushStreamNote(this.nextBrushStreamStep)
|
this.reserveBrushStreamNote(this.nextBrushStreamStep)
|
||||||
) {
|
) {
|
||||||
this.playBrushStreamNote({
|
this.playBrushStreamNote({
|
||||||
|
|
@ -783,22 +776,22 @@ export class GenerativePianoEngine {
|
||||||
layer: BrushPhraseLayer | null;
|
layer: BrushPhraseLayer | null;
|
||||||
}): void {
|
}): void {
|
||||||
const profile = getVibeProfile(vibe);
|
const profile = getVibeProfile(vibe);
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const maniaAmount =
|
const maniaAmount =
|
||||||
layer?.maniaAmount ??
|
layer?.maniaAmount ??
|
||||||
clamp01(
|
clamp01(
|
||||||
(intensity - this.generation.brushStream.inferredManiaThreshold) /
|
(intensity - generativePianoTuning.brushStream.inferredManiaThreshold) /
|
||||||
this.generation.brushStream.inferredManiaRange
|
generativePianoTuning.brushStream.inferredManiaRange
|
||||||
);
|
);
|
||||||
const register = this.getBiasedRegister(
|
const register = this.getBiasedRegister(
|
||||||
pool,
|
pool,
|
||||||
maniaAmount * this.generation.brushStream.registerManiaShift
|
maniaAmount * generativePianoTuning.brushStream.registerManiaShift
|
||||||
);
|
);
|
||||||
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const useChordTone =
|
const useChordTone =
|
||||||
this.brushStreamNoteIndex % this.generation.brushStream.chordToneEverySteps === 0;
|
this.brushStreamNoteIndex % generativePianoTuning.brushStream.chordToneEverySteps === 0;
|
||||||
const source = useChordTone
|
const source = useChordTone
|
||||||
? {
|
? {
|
||||||
baseMidi: rootMidi,
|
baseMidi: rootMidi,
|
||||||
|
|
@ -817,18 +810,18 @@ export class GenerativePianoEngine {
|
||||||
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
||||||
const pan = this.getStylePan(styleIndex);
|
const pan = this.getStylePan(styleIndex);
|
||||||
const durationSeconds = clamp(
|
const durationSeconds = clamp(
|
||||||
this.generation.brushStream.durationBaseSeconds +
|
generativePianoTuning.brushStream.durationBaseSeconds +
|
||||||
intensity * this.generation.brushStream.durationIntensitySeconds -
|
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
|
||||||
maniaAmount * this.generation.brushStream.durationManiaSeconds,
|
maniaAmount * generativePianoTuning.brushStream.durationManiaSeconds,
|
||||||
this.generation.brushStream.durationMinSeconds,
|
generativePianoTuning.brushStream.durationMinSeconds,
|
||||||
this.generation.brushStream.durationMaxSeconds
|
generativePianoTuning.brushStream.durationMaxSeconds
|
||||||
);
|
);
|
||||||
const delaySend = clamp(
|
const delaySend = clamp(
|
||||||
this.generation.brushStream.delaySendBase +
|
generativePianoTuning.brushStream.delaySendBase +
|
||||||
intensity * this.generation.brushStream.delaySendIntensityWeight -
|
intensity * generativePianoTuning.brushStream.delaySendIntensityWeight -
|
||||||
maniaAmount * this.generation.brushStream.delaySendManiaWeight,
|
maniaAmount * generativePianoTuning.brushStream.delaySendManiaWeight,
|
||||||
this.generation.brushStream.delaySendMin,
|
generativePianoTuning.brushStream.delaySendMin,
|
||||||
this.generation.brushStream.delaySendMax
|
generativePianoTuning.brushStream.delaySendMax
|
||||||
);
|
);
|
||||||
|
|
||||||
this.lastBrushStreamMidi = midi;
|
this.lastBrushStreamMidi = midi;
|
||||||
|
|
@ -836,8 +829,8 @@ export class GenerativePianoEngine {
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi,
|
midi,
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.brushStream.velocityBase +
|
(generativePianoTuning.brushStream.velocityBase +
|
||||||
intensity * this.generation.brushStream.velocityIntensityWeight) *
|
intensity * generativePianoTuning.brushStream.velocityIntensityWeight) *
|
||||||
styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
|
|
@ -848,46 +841,46 @@ export class GenerativePianoEngine {
|
||||||
profile,
|
profile,
|
||||||
midi,
|
midi,
|
||||||
clamp01(
|
clamp01(
|
||||||
this.generation.brushStream.lowpassBaseExpression +
|
generativePianoTuning.brushStream.lowpassBaseExpression +
|
||||||
intensity * this.generation.brushStream.lowpassIntensityWeight +
|
intensity * generativePianoTuning.brushStream.lowpassIntensityWeight +
|
||||||
maniaAmount * this.generation.brushStream.lowpassManiaWeight
|
maniaAmount * generativePianoTuning.brushStream.lowpassManiaWeight
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
maniaAmount >= this.generation.brushStreamEcho.maniaThreshold &&
|
maniaAmount >= generativePianoTuning.brushStreamEcho.maniaThreshold &&
|
||||||
(this.brushStreamNoteIndex % this.generation.brushStreamEcho.stepModulo ===
|
(this.brushStreamNoteIndex % generativePianoTuning.brushStreamEcho.stepModulo ===
|
||||||
this.generation.brushStreamEcho.stepRemainder ||
|
generativePianoTuning.brushStreamEcho.stepRemainder ||
|
||||||
intensity >= this.generation.brushStreamEcho.intensityThreshold)
|
intensity >= generativePianoTuning.brushStreamEcho.intensityThreshold)
|
||||||
) {
|
) {
|
||||||
const echoMidi =
|
const echoMidi =
|
||||||
midi + this.generation.brushStreamEcho.octaveSemitones <=
|
midi + generativePianoTuning.brushStreamEcho.octaveSemitones <=
|
||||||
this.generation.brushStreamEcho.maxMidi
|
generativePianoTuning.brushStreamEcho.maxMidi
|
||||||
? midi + this.generation.brushStreamEcho.octaveSemitones
|
? midi + generativePianoTuning.brushStreamEcho.octaveSemitones
|
||||||
: midi - this.generation.brushStreamEcho.octaveSemitones;
|
: midi - generativePianoTuning.brushStreamEcho.octaveSemitones;
|
||||||
this.playProfileNote(profile, {
|
this.playProfileNote(profile, {
|
||||||
midi: echoMidi,
|
midi: echoMidi,
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.brushStreamEcho.velocityBase +
|
(generativePianoTuning.brushStreamEcho.velocityBase +
|
||||||
intensity * this.generation.brushStreamEcho.velocityIntensityWeight) *
|
intensity * generativePianoTuning.brushStreamEcho.velocityIntensityWeight) *
|
||||||
styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
|
startTime: startTime + generativePianoTuning.brushMotifCanonDelaySeconds,
|
||||||
durationSeconds: Math.max(
|
durationSeconds: Math.max(
|
||||||
this.generation.brushStreamEcho.durationMinSeconds,
|
generativePianoTuning.brushStreamEcho.durationMinSeconds,
|
||||||
durationSeconds * this.generation.brushStreamEcho.durationScale
|
durationSeconds * generativePianoTuning.brushStreamEcho.durationScale
|
||||||
),
|
),
|
||||||
pan: clamp(pan * this.generation.brushStreamEcho.panScale, -1, 1),
|
pan: clamp(pan * generativePianoTuning.brushStreamEcho.panScale, -1, 1),
|
||||||
role: 'brush',
|
role: 'brush',
|
||||||
delaySend: Math.max(
|
delaySend: Math.max(
|
||||||
this.generation.brushStreamEcho.delaySendMin,
|
generativePianoTuning.brushStreamEcho.delaySendMin,
|
||||||
delaySend * this.generation.brushStreamEcho.delaySendScale
|
delaySend * generativePianoTuning.brushStreamEcho.delaySendScale
|
||||||
),
|
),
|
||||||
lowpassHz: this.getLowpassHz(
|
lowpassHz: this.getLowpassHz(
|
||||||
profile,
|
profile,
|
||||||
echoMidi,
|
echoMidi,
|
||||||
this.generation.brushStreamEcho.lowpassBaseExpression +
|
generativePianoTuning.brushStreamEcho.lowpassBaseExpression +
|
||||||
maniaAmount * this.generation.brushStreamEcho.lowpassManiaWeight
|
maniaAmount * generativePianoTuning.brushStreamEcho.lowpassManiaWeight
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -905,8 +898,8 @@ export class GenerativePianoEngine {
|
||||||
intensity:
|
intensity:
|
||||||
layer.energy *
|
layer.energy *
|
||||||
this.getBrushPhraseFade(layer, startTime) *
|
this.getBrushPhraseFade(layer, startTime) *
|
||||||
(this.generation.brushPhrase.layerIntensityBase +
|
(generativePianoTuning.brushPhrase.layerIntensityBase +
|
||||||
layer.maniaAmount * this.generation.brushPhrase.layerIntensityManiaWeight),
|
layer.maniaAmount * generativePianoTuning.brushPhrase.layerIntensityManiaWeight),
|
||||||
}));
|
}));
|
||||||
const dominant = layerStates.reduce<{
|
const dominant = layerStates.reduce<{
|
||||||
layer: BrushPhraseLayer;
|
layer: BrushPhraseLayer;
|
||||||
|
|
@ -924,10 +917,10 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
intensity: clamp01(
|
intensity: clamp01(
|
||||||
activity * this.generation.brushPhrase.frameActivityWeight +
|
activity * generativePianoTuning.brushPhrase.frameActivityWeight +
|
||||||
layeredIntensity +
|
layeredIntensity +
|
||||||
(dominant?.layer.maniaAmount ?? 0) *
|
(dominant?.layer.maniaAmount ?? 0) *
|
||||||
this.generation.brushPhrase.frameManiaWeight
|
generativePianoTuning.brushPhrase.frameManiaWeight
|
||||||
),
|
),
|
||||||
layer: dominant?.layer ?? null,
|
layer: dominant?.layer ?? null,
|
||||||
};
|
};
|
||||||
|
|
@ -935,11 +928,11 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
private getBrushStreamIntervalSteps(intensity: number): number {
|
private getBrushStreamIntervalSteps(intensity: number): number {
|
||||||
const intervalBeats =
|
const intervalBeats =
|
||||||
intensity >= this.generation.brushStream.intenseThreshold
|
intensity >= generativePianoTuning.brushStream.intenseThreshold
|
||||||
? this.generation.brushStreamIntenseIntervalBeats
|
? generativePianoTuning.brushStreamIntenseIntervalBeats
|
||||||
: intensity >= this.generation.brushStream.activeThreshold
|
: intensity >= generativePianoTuning.brushStream.activeThreshold
|
||||||
? this.generation.brushStreamActiveIntervalBeats
|
? generativePianoTuning.brushStreamActiveIntervalBeats
|
||||||
: this.generation.brushStreamIdleIntervalBeats;
|
: generativePianoTuning.brushStreamIdleIntervalBeats;
|
||||||
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
|
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -950,11 +943,11 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMotifOffset(strength: number): number {
|
private getMotifOffset(strength: number): number {
|
||||||
return strength >= this.generation.brushMotif.highThreshold
|
return strength >= generativePianoTuning.brushMotif.highThreshold
|
||||||
? this.generation.brushMotif.highOffset
|
? generativePianoTuning.brushMotif.highOffset
|
||||||
: strength >= this.generation.brushMotif.mediumThreshold
|
: strength >= generativePianoTuning.brushMotif.mediumThreshold
|
||||||
? this.generation.brushMotif.mediumOffset
|
? generativePianoTuning.brushMotif.mediumOffset
|
||||||
: this.generation.brushMotif.lowOffset;
|
: generativePianoTuning.brushMotif.lowOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBrushMotifDegrees({
|
private getBrushMotifDegrees({
|
||||||
|
|
@ -986,17 +979,17 @@ export class GenerativePianoEngine {
|
||||||
maniaAmount: number
|
maniaAmount: number
|
||||||
): GardenAudioRegister {
|
): GardenAudioRegister {
|
||||||
const shift = Math.round(
|
const shift = Math.round(
|
||||||
maniaAmount * this.generation.registerBias.maniaShiftSemitones
|
maniaAmount * generativePianoTuning.registerBias.maniaShiftSemitones
|
||||||
);
|
);
|
||||||
const midiMin = clamp(
|
const midiMin = clamp(
|
||||||
register.midiMin + shift,
|
register.midiMin + shift,
|
||||||
this.generation.registerBias.midiMin,
|
generativePianoTuning.registerBias.midiMin,
|
||||||
this.generation.registerBias.midiMaxForMin
|
generativePianoTuning.registerBias.midiMaxForMin
|
||||||
);
|
);
|
||||||
const midiMax = clamp(
|
const midiMax = clamp(
|
||||||
register.midiMax + shift,
|
register.midiMax + shift,
|
||||||
midiMin + this.generation.registerBias.minimumSpan,
|
midiMin + generativePianoTuning.registerBias.minimumSpan,
|
||||||
this.generation.registerBias.midiMax
|
generativePianoTuning.registerBias.midiMax
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1039,8 +1032,8 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
pitchSource.offsets.forEach((offset, preference) => {
|
pitchSource.offsets.forEach((offset, preference) => {
|
||||||
for (
|
for (
|
||||||
let octave = this.generation.candidateOctaveSearch.min;
|
let octave = generativePianoTuning.candidateOctaveSearch.min;
|
||||||
octave <= this.generation.candidateOctaveSearch.max;
|
octave <= generativePianoTuning.candidateOctaveSearch.max;
|
||||||
octave += 1
|
octave += 1
|
||||||
) {
|
) {
|
||||||
const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE;
|
const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||||
|
|
@ -1067,38 +1060,38 @@ export class GenerativePianoEngine {
|
||||||
return (
|
return (
|
||||||
Math.abs(candidate.midi - previousMidi) +
|
Math.abs(candidate.midi - previousMidi) +
|
||||||
Math.abs(candidate.midi - register.preferredMidi) *
|
Math.abs(candidate.midi - register.preferredMidi) *
|
||||||
this.generation.noteScoreRegisterWeight +
|
generativePianoTuning.noteScoreRegisterWeight +
|
||||||
candidate.preference * this.generation.noteScorePreferenceWeight +
|
candidate.preference * generativePianoTuning.noteScorePreferenceWeight +
|
||||||
candidate.chordToneDistance * this.generation.noteScoreChordToneWeight +
|
candidate.chordToneDistance * generativePianoTuning.noteScoreChordToneWeight +
|
||||||
(avoidRepeat && candidate.midi === previousMidi
|
(avoidRepeat && candidate.midi === previousMidi
|
||||||
? this.generation.noteScoreRepeatPenalty
|
? generativePianoTuning.noteScoreRepeatPenalty
|
||||||
: 0)
|
: 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldPlaySupport(expression: number, barIndex: number): boolean {
|
private shouldPlaySupport(expression: number, barIndex: number): boolean {
|
||||||
if (expression >= this.generation.supportNote.expressionThreshold) {
|
if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset
|
barIndex % generativePianoTuning.supportBarSpacing === generativePianoTuning.supportBarOffset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
||||||
const spacing =
|
const spacing =
|
||||||
expression < this.generation.textureNote.idleExpressionThreshold
|
expression < generativePianoTuning.textureNote.idleExpressionThreshold
|
||||||
? this.generation.idleTextureBarSpacing
|
? generativePianoTuning.idleTextureBarSpacing
|
||||||
: expression < this.generation.textureNote.mediumExpressionThreshold
|
: expression < generativePianoTuning.textureNote.mediumExpressionThreshold
|
||||||
? this.generation.mediumTextureBarSpacing
|
? generativePianoTuning.mediumTextureBarSpacing
|
||||||
: this.generation.textureNote.intenseSpacing;
|
: generativePianoTuning.textureNote.intenseSpacing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
barIndex % spacing ===
|
barIndex % spacing ===
|
||||||
(spacing === this.generation.textureNote.intenseSpacing
|
(spacing === generativePianoTuning.textureNote.intenseSpacing
|
||||||
? 0
|
? 0
|
||||||
: this.generation.textureNote.idlePhase)
|
: generativePianoTuning.textureNote.idlePhase)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1106,14 +1099,14 @@ export class GenerativePianoEngine {
|
||||||
chordIntervals: ReadonlyArray<number>,
|
chordIntervals: ReadonlyArray<number>,
|
||||||
styleIndex: GardenAudioStyleIndex
|
styleIndex: GardenAudioStyleIndex
|
||||||
): Array<number> {
|
): Array<number> {
|
||||||
return this.generation.supportNote.offsetsByStyle[styleIndex].map((offset) =>
|
return generativePianoTuning.supportNote.offsetsByStyle[styleIndex].map((offset) =>
|
||||||
getConfiguredChordOffset(chordIntervals, offset)
|
getConfiguredChordOffset(chordIntervals, offset)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
|
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
|
||||||
const progressionIndex =
|
const progressionIndex =
|
||||||
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length;
|
Math.floor(barIndex / generativePianoTuning.chordBars) % profile.progression.length;
|
||||||
return profile.progression[progressionIndex];
|
return profile.progression[progressionIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1122,17 +1115,17 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStyleIndex(startTime: number): GardenAudioStyleIndex {
|
private getStyleIndex(startTime: number): GardenAudioStyleIndex {
|
||||||
const styleCount = this.generation.stylePools.length;
|
const styleCount = generativePianoTuning.stylePools.length;
|
||||||
const rotationBars = Math.max(1, Math.round(this.generation.styleRotationBars));
|
const rotationBars = Math.max(1, Math.round(generativePianoTuning.styleRotationBars));
|
||||||
return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) %
|
return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) %
|
||||||
styleCount) as GardenAudioStyleIndex;
|
styleCount) as GardenAudioStyleIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const styleVoice = styleVoices[styleIndex];
|
const styleVoice = styleVoices[styleIndex];
|
||||||
return clamp(
|
return clamp(
|
||||||
pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale,
|
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
|
||||||
-1,
|
-1,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -1145,13 +1138,13 @@ export class GenerativePianoEngine {
|
||||||
): number {
|
): number {
|
||||||
const midiLift =
|
const midiLift =
|
||||||
clamp01(
|
clamp01(
|
||||||
(midi - this.generation.lowpass.midiBase) / this.generation.lowpass.midiRange
|
(midi - generativePianoTuning.lowpass.midiBase) / generativePianoTuning.lowpass.midiRange
|
||||||
) * this.generation.lowpass.midiLiftHz;
|
) * generativePianoTuning.lowpass.midiLiftHz;
|
||||||
return clamp(
|
return clamp(
|
||||||
this.config.piano.lowpassHz *
|
this.config.piano.lowpassHz *
|
||||||
profile.brightness *
|
profile.brightness *
|
||||||
(this.generation.lowpass.expressionBase +
|
(generativePianoTuning.lowpass.expressionBase +
|
||||||
expression * this.generation.lowpass.expressionWeight) +
|
expression * generativePianoTuning.lowpass.expressionWeight) +
|
||||||
midiLift,
|
midiLift,
|
||||||
this.config.piano.lowpassMinHz,
|
this.config.piano.lowpassMinHz,
|
||||||
this.config.piano.lowpassMaxHz
|
this.config.piano.lowpassMaxHz
|
||||||
|
|
@ -1174,14 +1167,14 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getExpression(activity: number): number {
|
private getExpression(activity: number): number {
|
||||||
const liftedActivity = Math.max(
|
const activityExpression = clamp01(
|
||||||
activity,
|
(activity - this.config.rhythm.sparseActivity) /
|
||||||
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
|
|
||||||
);
|
|
||||||
return clamp01(
|
|
||||||
(liftedActivity - this.config.rhythm.sparseActivity) /
|
|
||||||
(1 - 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 {
|
private getBeatDurationSeconds(): number {
|
||||||
|
|
@ -1263,7 +1256,7 @@ export class GenerativePianoEngine {
|
||||||
private reserveBrushStreamNote(stepIndex: number): boolean {
|
private reserveBrushStreamNote(stepIndex: number): boolean {
|
||||||
const barIndex = this.getBarIndexForStep(stepIndex);
|
const barIndex = this.getBarIndexForStep(stepIndex);
|
||||||
const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0;
|
const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0;
|
||||||
if (noteCount >= this.generation.maxBrushStreamNotesPerBar) {
|
if (noteCount >= generativePianoTuning.maxBrushStreamNotesPerBar) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,7 @@ export class PianoSampler {
|
||||||
const sample = this.findNearestSample(midi);
|
const sample = this.findNearestSample(midi);
|
||||||
|
|
||||||
if (sample) {
|
if (sample) {
|
||||||
const noteGainValue = Math.max(
|
const noteGainValue = this.computeNoteGain(noteVelocity);
|
||||||
pianoSamplerTuning.minGain,
|
|
||||||
this.config.piano.gain * noteVelocity
|
|
||||||
);
|
|
||||||
const sustainSeconds =
|
const sustainSeconds =
|
||||||
profileSustainSeconds *
|
profileSustainSeconds *
|
||||||
(this.config.piano.sustainBase +
|
(this.config.piano.sustainBase +
|
||||||
|
|
@ -143,9 +140,9 @@ export class PianoSampler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteGainValue = Math.max(
|
const noteGainValue = this.computeNoteGain(
|
||||||
pianoSamplerTuning.minGain,
|
noteVelocity,
|
||||||
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
|
pianoSamplerTuning.synthGainScale
|
||||||
);
|
);
|
||||||
const releaseAt =
|
const releaseAt =
|
||||||
scheduledStart +
|
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 {
|
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||||
if (this.samples.length === 0) {
|
if (this.samples.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,10 @@ import { runtimeControls } from './config/runtime-controls';
|
||||||
import type { GardenAppConfig } from './config/types';
|
import type { GardenAppConfig } from './config/types';
|
||||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
export { VibeId } from './config/types';
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GardenAppConfig,
|
GardenAppConfig,
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
NumberControlConfig,
|
NumberControlConfig,
|
||||||
VibePreset,
|
|
||||||
} from './config/types';
|
} from './config/types';
|
||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,15 @@ export const colorInteractionSettings = {
|
||||||
color3ToColor3: 1,
|
color3ToColor3: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentInteractionOptions: Record<string, number> = {
|
|
||||||
Follow: 1,
|
|
||||||
Avoid: -1,
|
|
||||||
Ignore: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||||
folder: 'Color Reactions',
|
folder: 'Color Reactions',
|
||||||
label,
|
label,
|
||||||
min: -1,
|
min: -1,
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 1,
|
step: 1,
|
||||||
options: agentInteractionOptions,
|
options: {
|
||||||
|
Follow: 1,
|
||||||
|
Avoid: -1,
|
||||||
|
Ignore: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
|
|
||||||
brushAlpha: 1,
|
brushAlpha: 1,
|
||||||
brushDiscardThreshold: 0.02,
|
brushDiscardThreshold: 0.02,
|
||||||
brushCoarseNoiseScale: 160,
|
|
||||||
brushGrainNoiseScale: 22,
|
brushGrainNoiseScale: 22,
|
||||||
brushGrainNoiseOffsetX: 0.31,
|
brushGrainNoiseOffsetX: 0.31,
|
||||||
brushGrainNoiseOffsetY: 0.67,
|
brushGrainNoiseOffsetY: 0.67,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { colorInteractionControl } from './color-interactions';
|
import { colorInteractionControl } from './color-interactions';
|
||||||
import type { GardenAppConfig } from './types';
|
import type { GardenAppConfig } from './types';
|
||||||
|
|
||||||
|
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||||
|
|
||||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
color1ToColor1: colorInteractionControl('1 -> 1'),
|
color1ToColor1: colorInteractionControl('1 -> 1'),
|
||||||
color1ToColor2: colorInteractionControl('1 -> 2'),
|
color1ToColor2: colorInteractionControl('1 -> 2'),
|
||||||
|
|
@ -26,21 +28,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
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: {
|
sensorOffsetDistance: {
|
||||||
folder: 'Agents',
|
folder: 'Agents',
|
||||||
label: 'sensor distance',
|
label: 'sensor distance',
|
||||||
|
|
@ -62,6 +49,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 200,
|
max: 200,
|
||||||
step: 1,
|
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: {
|
individualTrailWeight: {
|
||||||
folder: 'Agents',
|
folder: 'Agents',
|
||||||
label: 'individual trail weight',
|
label: 'individual trail weight',
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import type {
|
||||||
GardenAudioConfig,
|
GardenAudioConfig,
|
||||||
GardenAudioVibeSettings,
|
GardenAudioVibeSettings,
|
||||||
} from '../audio/garden-audio-config';
|
} from '../audio/garden-audio-config';
|
||||||
import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
|
||||||
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
|
||||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
|
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
import type { RenderSettings } from '../pipelines/render/render-settings';
|
import type { RenderSettings } from '../pipelines/render/render-pipeline';
|
||||||
import type { RgbColor } from '../utils/rgb-color';
|
import type { RgbColor } from '../utils/rgb-color';
|
||||||
|
|
||||||
export interface NumberControlConfig {
|
export interface NumberControlConfig {
|
||||||
|
format?: (value: number) => string;
|
||||||
folder: string;
|
folder: string;
|
||||||
integer?: boolean;
|
integer?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
@ -53,7 +54,6 @@ type GardenVibeSettings = Pick<
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
| 'backgroundGrainStrength'
|
| 'backgroundGrainStrength'
|
||||||
| 'brushSize'
|
| 'brushSize'
|
||||||
| 'brushSizeVariation'
|
|
||||||
| 'clarity'
|
| 'clarity'
|
||||||
| 'color1ToColor1'
|
| 'color1ToColor1'
|
||||||
| 'color1ToColor2'
|
| 'color1ToColor2'
|
||||||
|
|
@ -65,7 +65,6 @@ type GardenVibeSettings = Pick<
|
||||||
| 'color3ToColor2'
|
| 'color3ToColor2'
|
||||||
| 'color3ToColor3'
|
| 'color3ToColor3'
|
||||||
| 'decayRateTrails'
|
| 'decayRateTrails'
|
||||||
| 'diffusionRateBrush'
|
|
||||||
| 'individualTrailWeight'
|
| 'individualTrailWeight'
|
||||||
| 'moveSpeed'
|
| 'moveSpeed'
|
||||||
| 'sensorOffsetDistance'
|
| 'sensorOffsetDistance'
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,8 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
backgroundGrainStrength: 0.018,
|
backgroundGrainStrength: 0.018,
|
||||||
brushSize: 14,
|
brushSize: 14,
|
||||||
brushSizeVariation: 0.5,
|
|
||||||
clarity: 0.62,
|
clarity: 0.62,
|
||||||
decayRateTrails: 965,
|
decayRateTrails: 965,
|
||||||
diffusionRateBrush: 0.35,
|
|
||||||
individualTrailWeight: 0.07,
|
individualTrailWeight: 0.07,
|
||||||
moveSpeed: 82,
|
moveSpeed: 82,
|
||||||
sensorOffsetDistance: 38,
|
sensorOffsetDistance: 38,
|
||||||
|
|
@ -55,10 +53,8 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
backgroundGrainStrength: 0.014,
|
backgroundGrainStrength: 0.014,
|
||||||
brushSize: 16,
|
brushSize: 16,
|
||||||
brushSizeVariation: 0.35,
|
|
||||||
clarity: 0.68,
|
clarity: 0.68,
|
||||||
decayRateTrails: 975,
|
decayRateTrails: 975,
|
||||||
diffusionRateBrush: 0.28,
|
|
||||||
individualTrailWeight: 0.06,
|
individualTrailWeight: 0.06,
|
||||||
moveSpeed: 70,
|
moveSpeed: 70,
|
||||||
sensorOffsetDistance: 46,
|
sensorOffsetDistance: 46,
|
||||||
|
|
@ -83,10 +79,8 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
backgroundGrainStrength: 0.022,
|
backgroundGrainStrength: 0.022,
|
||||||
brushSize: 13,
|
brushSize: 13,
|
||||||
brushSizeVariation: 0.58,
|
|
||||||
clarity: 0.58,
|
clarity: 0.58,
|
||||||
decayRateTrails: 955,
|
decayRateTrails: 955,
|
||||||
diffusionRateBrush: 0.42,
|
|
||||||
individualTrailWeight: 0.055,
|
individualTrailWeight: 0.055,
|
||||||
moveSpeed: 90,
|
moveSpeed: 90,
|
||||||
sensorOffsetDistance: 35,
|
sensorOffsetDistance: 35,
|
||||||
|
|
@ -111,10 +105,8 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
backgroundGrainStrength: 0.018,
|
backgroundGrainStrength: 0.018,
|
||||||
brushSize: 12,
|
brushSize: 12,
|
||||||
brushSizeVariation: 0.45,
|
|
||||||
clarity: 0.64,
|
clarity: 0.64,
|
||||||
decayRateTrails: 968,
|
decayRateTrails: 968,
|
||||||
diffusionRateBrush: 0.32,
|
|
||||||
individualTrailWeight: 0.065,
|
individualTrailWeight: 0.065,
|
||||||
moveSpeed: 76,
|
moveSpeed: 76,
|
||||||
sensorOffsetDistance: 42,
|
sensorOffsetDistance: 42,
|
||||||
|
|
@ -139,10 +131,8 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
backgroundGrainStrength: 0.024,
|
backgroundGrainStrength: 0.024,
|
||||||
brushSize: 15,
|
brushSize: 15,
|
||||||
brushSizeVariation: 0.62,
|
|
||||||
clarity: 0.55,
|
clarity: 0.55,
|
||||||
decayRateTrails: 948,
|
decayRateTrails: 948,
|
||||||
diffusionRateBrush: 0.48,
|
|
||||||
individualTrailWeight: 0.05,
|
individualTrailWeight: 0.05,
|
||||||
moveSpeed: 96,
|
moveSpeed: 96,
|
||||||
sensorOffsetDistance: 32,
|
sensorOffsetDistance: 32,
|
||||||
|
|
@ -167,10 +157,8 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
backgroundGrainStrength: 0.012,
|
backgroundGrainStrength: 0.012,
|
||||||
brushSize: 18,
|
brushSize: 18,
|
||||||
brushSizeVariation: 0.28,
|
|
||||||
clarity: 0.7,
|
clarity: 0.7,
|
||||||
decayRateTrails: 982,
|
decayRateTrails: 982,
|
||||||
diffusionRateBrush: 0.24,
|
|
||||||
individualTrailWeight: 0.075,
|
individualTrailWeight: 0.075,
|
||||||
moveSpeed: 62,
|
moveSpeed: 62,
|
||||||
sensorOffsetDistance: 52,
|
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 previewClientPosition: { x: number; y: number } | null = null;
|
||||||
private isErasing = false;
|
private isErasing = false;
|
||||||
private isPointerHoveringCanvas = false;
|
private isPointerHoveringCanvas = false;
|
||||||
|
private isSwipeActive = false;
|
||||||
private previousSize: number | null = null;
|
private previousSize: number | null = null;
|
||||||
private previousLeft = '';
|
private previousLeft = '';
|
||||||
private previousTop = '';
|
private previousTop = '';
|
||||||
|
|
@ -11,19 +12,36 @@ export class EraserPreview {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
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.isErasing = isErasing;
|
||||||
this.update(undefined, isSwipeActive);
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPointerHoveringCanvas(isHovering: boolean): void {
|
public update(event?: PointerEvent): void {
|
||||||
this.isPointerHoveringCanvas = isHovering;
|
this.isSwipeActive = this.getIsSwipeActive();
|
||||||
}
|
|
||||||
|
|
||||||
public update(event?: PointerEvent, isSwipeActive = false): void {
|
|
||||||
if (event) {
|
if (event) {
|
||||||
this.previewClientPosition = {
|
this.previewClientPosition = {
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
|
|
@ -39,7 +57,7 @@ export class EraserPreview {
|
||||||
if (
|
if (
|
||||||
!this.isErasing ||
|
!this.isErasing ||
|
||||||
this.previewClientPosition === null ||
|
this.previewClientPosition === null ||
|
||||||
(!this.isPointerHoveringCanvas && !isSwipeActive)
|
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
|
||||||
) {
|
) {
|
||||||
this.setVisible(false);
|
this.setVisible(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -59,7 +77,16 @@ export class EraserPreview {
|
||||||
this.setVisible(true);
|
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();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
return (
|
return (
|
||||||
event.clientX >= rect.left &&
|
event.clientX >= rect.left &&
|
||||||
|
|
@ -69,12 +96,27 @@ export class EraserPreview {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setVisible(isVisible: boolean): void {
|
private readonly onPointerDown = (event: PointerEvent) => {
|
||||||
if (this.isVisible === isVisible) {
|
this.isPointerHoveringCanvas = true;
|
||||||
return;
|
this.update(event);
|
||||||
}
|
};
|
||||||
|
|
||||||
this.isVisible = isVisible;
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
this.element.classList.toggle('visible', isVisible);
|
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;
|
canvasPixelRatio: number;
|
||||||
introProgress: number;
|
introProgress: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
isErasing: boolean;
|
|
||||||
eraserPixelSize: number;
|
eraserPixelSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||||
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||||
import { AgentPopulation } from './agent-population';
|
import { AgentPopulation } from './agent-population';
|
||||||
import { DevStatsOverlay } from './dev-stats-overlay';
|
import { DevStatsOverlay } from './dev-stats-overlay';
|
||||||
import { EraserPointerPreviewController } from './eraser-pointer-preview-controller';
|
|
||||||
import { EraserPreview } from './eraser-preview';
|
import { EraserPreview } from './eraser-preview';
|
||||||
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
|
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
|
||||||
import { FramePerformance } from './frame-performance';
|
import { FramePerformance } from './frame-performance';
|
||||||
|
|
@ -25,7 +24,6 @@ export default class GameLoop {
|
||||||
private readonly introPrompt: IntroPrompt;
|
private readonly introPrompt: IntroPrompt;
|
||||||
private readonly eraserPreview: EraserPreview;
|
private readonly eraserPreview: EraserPreview;
|
||||||
private readonly pointerInput: GardenPointerInput;
|
private readonly pointerInput: GardenPointerInput;
|
||||||
private readonly eraserPreviewController: EraserPointerPreviewController;
|
|
||||||
private readonly agentPopulation: AgentPopulation;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
||||||
private readonly framePerformance = new FramePerformance();
|
private readonly framePerformance = new FramePerformance();
|
||||||
|
|
@ -34,6 +32,7 @@ export default class GameLoop {
|
||||||
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||||
private readonly seed = this.seedValue.toString(16);
|
private readonly seed = this.seedValue.toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
|
private readonly _canvasSize: vec2 = vec2.create();
|
||||||
|
|
||||||
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||||
private previousAccentColor = '';
|
private previousAccentColor = '';
|
||||||
|
|
@ -54,7 +53,6 @@ export default class GameLoop {
|
||||||
this.framePerformance.adaptiveCapInitial
|
this.framePerformance.adaptiveCapInitial
|
||||||
);
|
);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
|
||||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
||||||
this.agentPopulation = new AgentPopulation(
|
this.agentPopulation = new AgentPopulation(
|
||||||
this.resources.agentGenerationPipeline,
|
this.resources.agentGenerationPipeline,
|
||||||
|
|
@ -77,11 +75,11 @@ export default class GameLoop {
|
||||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||||
});
|
});
|
||||||
this.eraserPreviewController = new EraserPointerPreviewController({
|
this.eraserPreview = new EraserPreview(
|
||||||
canvas,
|
canvas,
|
||||||
eraserPreview: this.eraserPreview,
|
ui.eraserPreview,
|
||||||
getIsSwipeActive: () => this.pointerInput.isSwipeActive,
|
() => this.pointerInput.isSwipeActive
|
||||||
});
|
);
|
||||||
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
||||||
device,
|
device,
|
||||||
renderPipeline: this.resources.renderPipeline,
|
renderPipeline: this.resources.renderPipeline,
|
||||||
|
|
@ -100,7 +98,7 @@ export default class GameLoop {
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', this.resizeListener);
|
window.addEventListener('resize', this.resizeListener);
|
||||||
this.eraserPreviewController.attach();
|
this.eraserPreview.attach();
|
||||||
this.syncDevStatsOverlay();
|
this.syncDevStatsOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,11 +108,11 @@ export default class GameLoop {
|
||||||
|
|
||||||
public setEraseMode(isErasing: boolean): void {
|
public setEraseMode(isErasing: boolean): void {
|
||||||
this.pointerInput.setEraseMode(isErasing);
|
this.pointerInput.setEraseMode(isErasing);
|
||||||
this.eraserPreviewController.setEraseMode(isErasing);
|
this.eraserPreview.setEraseMode(isErasing);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateEraserPreview(event?: PointerEvent): void {
|
public updateEraserPreview(event?: PointerEvent): void {
|
||||||
this.eraserPreviewController.update(event);
|
this.eraserPreview.update(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onVibeChanged(): void {
|
public onVibeChanged(): void {
|
||||||
|
|
@ -153,7 +151,7 @@ export default class GameLoop {
|
||||||
|
|
||||||
window.removeEventListener('resize', this.resizeListener);
|
window.removeEventListener('resize', this.resizeListener);
|
||||||
this.pointerInput.detach();
|
this.pointerInput.detach();
|
||||||
this.eraserPreviewController.detach();
|
this.eraserPreview.detach();
|
||||||
this.devStatsOverlay?.destroy();
|
this.devStatsOverlay?.destroy();
|
||||||
this.devStatsOverlay = null;
|
this.devStatsOverlay = null;
|
||||||
this.toolbarContrastMonitor.destroy();
|
this.toolbarContrastMonitor.destroy();
|
||||||
|
|
@ -198,7 +196,6 @@ export default class GameLoop {
|
||||||
canvasPixelRatio,
|
canvasPixelRatio,
|
||||||
introProgress,
|
introProgress,
|
||||||
selectedColorIndex: settings.selectedColorIndex,
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
isErasing,
|
|
||||||
channelColors,
|
channelColors,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
eraserPixelSize,
|
eraserPixelSize,
|
||||||
|
|
@ -300,7 +297,8 @@ export default class GameLoop {
|
||||||
}
|
}
|
||||||
|
|
||||||
private get canvasSize(): vec2 {
|
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 {
|
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_STEP = 1;
|
||||||
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
|
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 = {
|
const APP_SELECTORS = {
|
||||||
aside: 'aside',
|
aside: 'aside',
|
||||||
canvas: 'canvas',
|
canvas: 'canvas',
|
||||||
|
|
@ -132,24 +79,6 @@ const APP_SELECTORS = {
|
||||||
volumeSlider: '.volume-slider',
|
volumeSlider: '.volume-slider',
|
||||||
} as const;
|
} 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 clampEraserSize = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
|
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
|
||||||
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
||||||
|
|
@ -197,18 +126,18 @@ type RuntimeUiError = Parameters<
|
||||||
>[0];
|
>[0];
|
||||||
|
|
||||||
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
|
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
|
||||||
const message = document.createElement(ELEMENT_TAGS.pre);
|
const message = document.createElement('pre');
|
||||||
message.className = error.severity;
|
message.className = error.severity;
|
||||||
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
||||||
message.setAttribute(
|
message.setAttribute(
|
||||||
ARIA_ATTRIBUTES.role,
|
'role',
|
||||||
error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status
|
error.severity === Severity.ERROR ? 'alert' : 'status'
|
||||||
);
|
);
|
||||||
message.setAttribute(
|
message.setAttribute(
|
||||||
ARIA_ATTRIBUTES.live,
|
'aria-live',
|
||||||
error.severity === Severity.ERROR
|
error.severity === Severity.ERROR
|
||||||
? ARIA_LIVE_VALUES.assertive
|
? 'assertive'
|
||||||
: ARIA_LIVE_VALUES.polite
|
: 'polite'
|
||||||
);
|
);
|
||||||
container.append(message);
|
container.append(message);
|
||||||
|
|
||||||
|
|
@ -229,14 +158,14 @@ const renderStartupException = (exception: unknown) => {
|
||||||
const container =
|
const container =
|
||||||
existingContainer instanceof HTMLElement
|
existingContainer instanceof HTMLElement
|
||||||
? existingContainer
|
? existingContainer
|
||||||
: document.createElement(ELEMENT_TAGS.div);
|
: document.createElement('div');
|
||||||
|
|
||||||
if (!(existingContainer instanceof HTMLElement)) {
|
if (!(existingContainer instanceof HTMLElement)) {
|
||||||
container.className = CSS_CLASSES.errorsContainer;
|
container.className = 'errors-container';
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive);
|
container.setAttribute('aria-live', 'assertive');
|
||||||
renderRuntimeMessage(container, getRuntimeUiError(exception));
|
renderRuntimeMessage(container, getRuntimeUiError(exception));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -298,10 +227,10 @@ const setLoadingStage = (label: string, ratio: number) => {
|
||||||
const percent = Math.round(clamp01(ratio) * 100);
|
const percent = Math.round(clamp01(ratio) * 100);
|
||||||
elements.loadingStatus.textContent = label;
|
elements.loadingStatus.textContent = label;
|
||||||
elements.loadingProgress.style.setProperty(
|
elements.loadingProgress.style.setProperty(
|
||||||
CSS_VARIABLES.loadingProgress,
|
'--loading-progress',
|
||||||
`${percent}%`
|
`${percent}%`
|
||||||
);
|
);
|
||||||
elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent));
|
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
|
||||||
};
|
};
|
||||||
|
|
||||||
let audioVolume = readInitialAudioVolume();
|
let audioVolume = readInitialAudioVolume();
|
||||||
|
|
@ -323,32 +252,32 @@ const renderAudioUi = (game: GameLoop | null) => {
|
||||||
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
|
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
|
||||||
const volumePercent = getAudioVolumePercent(audioVolume);
|
const volumePercent = getAudioVolumePercent(audioVolume);
|
||||||
|
|
||||||
elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
|
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
||||||
elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted));
|
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
||||||
elements.soundButton.setAttribute(
|
elements.soundButton.setAttribute(
|
||||||
ARIA_ATTRIBUTES.label,
|
'aria-label',
|
||||||
isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute
|
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
|
||||||
);
|
);
|
||||||
elements.soundButton.title = isEffectivelyMuted
|
elements.soundButton.title = isEffectivelyMuted
|
||||||
? AUDIO_LABELS.unmute
|
? 'Unmute audio'
|
||||||
: AUDIO_LABELS.mute;
|
: 'Mute audio';
|
||||||
|
|
||||||
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
|
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
|
||||||
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
|
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
|
||||||
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
|
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
|
||||||
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
|
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
|
||||||
elements.volumeSlider.setAttribute(
|
elements.volumeSlider.setAttribute(
|
||||||
ARIA_ATTRIBUTES.valueText,
|
'aria-valuetext',
|
||||||
isEffectivelyMuted
|
isEffectivelyMuted
|
||||||
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%`
|
? `${'Muted'}, ${volumePercent}%`
|
||||||
: `${volumePercent}%`
|
: `${volumePercent}%`
|
||||||
);
|
);
|
||||||
elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
|
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
|
||||||
elements.volumeControl.title = isEffectivelyMuted
|
elements.volumeControl.title = isEffectivelyMuted
|
||||||
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`
|
? `${'Muted'}, ${volumePercent}% ${'volume'}`
|
||||||
: `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`;
|
: `${volumePercent}% ${'volume'}`;
|
||||||
elements.volumeControl.style.setProperty(
|
elements.volumeControl.style.setProperty(
|
||||||
CSS_VARIABLES.volumeProgress,
|
'--volume-progress',
|
||||||
`${volumePercent}%`
|
`${volumePercent}%`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -360,14 +289,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
|
||||||
elements.swatches.forEach((swatch, index) => {
|
elements.swatches.forEach((swatch, index) => {
|
||||||
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
||||||
swatch.classList.toggle(
|
swatch.classList.toggle(
|
||||||
CSS_CLASSES.active,
|
'active',
|
||||||
settings.selectedColorIndex === index && !isEraserActive
|
settings.selectedColorIndex === index && !isEraserActive
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive);
|
elements.eraserSizeControl.classList.toggle('active', isEraserActive);
|
||||||
game?.setEraseMode(isEraserActive);
|
game?.setEraseMode(isEraserActive);
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
CSS_VARIABLES.gardenBackground,
|
'--garden-background',
|
||||||
rgbColorToCss(activeVibe.backgroundColor)
|
rgbColorToCss(activeVibe.backgroundColor)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -382,18 +311,18 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
|
||||||
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
|
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
|
||||||
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
|
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
|
||||||
elements.eraserSizeSlider.value = size.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 ratio = getEraserSizeRatio(size);
|
||||||
const scale =
|
const scale =
|
||||||
ERASER_CONTROL_SCALE_MIN +
|
ERASER_CONTROL_SCALE_MIN +
|
||||||
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
|
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
|
||||||
elements.eraserSizeControl.style.setProperty(
|
elements.eraserSizeControl.style.setProperty(
|
||||||
CSS_VARIABLES.eraserProgress,
|
'--eraser-progress',
|
||||||
`${ratio * 100}%`
|
`${ratio * 100}%`
|
||||||
);
|
);
|
||||||
elements.eraserSizeControl.style.setProperty(
|
elements.eraserSizeControl.style.setProperty(
|
||||||
CSS_VARIABLES.eraserControlScale,
|
'--eraser-control-scale',
|
||||||
scale.toFixed(3)
|
scale.toFixed(3)
|
||||||
);
|
);
|
||||||
game?.updateEraserPreview();
|
game?.updateEraserPreview();
|
||||||
|
|
@ -412,15 +341,15 @@ const renderMirrorSegmentUi = () => {
|
||||||
|
|
||||||
const label = formatMirrorSegmentCount(count);
|
const label = formatMirrorSegmentCount(count);
|
||||||
const ratio = getMirrorSegmentRatio(count);
|
const ratio = getMirrorSegmentRatio(count);
|
||||||
elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label);
|
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
|
||||||
elements.mirrorSegmentControl.title = 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(
|
elements.mirrorSegmentControl.style.setProperty(
|
||||||
CSS_VARIABLES.mirrorProgress,
|
'--mirror-progress',
|
||||||
`${ratio * 100}%`
|
`${ratio * 100}%`
|
||||||
);
|
);
|
||||||
elements.mirrorSegmentControl.style.setProperty(
|
elements.mirrorSegmentControl.style.setProperty(
|
||||||
CSS_VARIABLES.mirrorAngle,
|
'--mirror-angle',
|
||||||
`${(360 / count).toFixed(3)}deg`
|
`${(360 / count).toFixed(3)}deg`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -437,13 +366,13 @@ const main = async () => {
|
||||||
|
|
||||||
elements = queryAppElements();
|
elements = queryAppElements();
|
||||||
elements.errorContainer.setAttribute(
|
elements.errorContainer.setAttribute(
|
||||||
ARIA_ATTRIBUTES.live,
|
'aria-live',
|
||||||
ARIA_LIVE_VALUES.assertive
|
'assertive'
|
||||||
);
|
);
|
||||||
ErrorHandler.addOnErrorListener((error) => {
|
ErrorHandler.addOnErrorListener((error) => {
|
||||||
renderRuntimeMessage(elements.errorContainer, error);
|
renderRuntimeMessage(elements.errorContainer, error);
|
||||||
if (error.severity === Severity.ERROR) {
|
if (error.severity === Severity.ERROR) {
|
||||||
document.body.classList.remove(CSS_CLASSES.isLoading);
|
document.body.classList.remove('is-loading');
|
||||||
game?.destroy();
|
game?.destroy();
|
||||||
shouldStop = true;
|
shouldStop = true;
|
||||||
}
|
}
|
||||||
|
|
@ -488,31 +417,31 @@ const main = async () => {
|
||||||
game?.startAudio(true);
|
game?.startAudio(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, {
|
window.addEventListener('touchstart', startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, {
|
window.addEventListener('pointerdown', startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, {
|
window.addEventListener('touchend', startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, {
|
window.addEventListener('pointerup', startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, {
|
window.addEventListener('click', startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, {
|
window.addEventListener('keydown', startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy());
|
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||||
elements.soundButton.addEventListener(DOM_EVENTS.click, () => {
|
elements.soundButton.addEventListener('click', () => {
|
||||||
const shouldUnmute = isAudioMuted || audioVolume <= 0;
|
const shouldUnmute = isAudioMuted || audioVolume <= 0;
|
||||||
if (shouldUnmute && audioVolume <= 0) {
|
if (shouldUnmute && audioVolume <= 0) {
|
||||||
audioVolume = DEFAULT_AUDIO_VOLUME;
|
audioVolume = DEFAULT_AUDIO_VOLUME;
|
||||||
|
|
@ -524,7 +453,7 @@ const main = async () => {
|
||||||
game?.startAudio(true);
|
game?.startAudio(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => {
|
elements.volumeSlider.addEventListener('input', () => {
|
||||||
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
|
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
|
||||||
isAudioMuted = audioVolume <= 0;
|
isAudioMuted = audioVolume <= 0;
|
||||||
persistAudioUiState();
|
persistAudioUiState();
|
||||||
|
|
@ -550,16 +479,16 @@ const main = async () => {
|
||||||
game?.playVibeChangeAudio(true);
|
game?.playVibeChangeAudio(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
elements.previousVibe.addEventListener(DOM_EVENTS.click, () =>
|
elements.previousVibe.addEventListener('click', () =>
|
||||||
selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton)
|
selectRelativeVibe(-1, 'previous-button')
|
||||||
);
|
);
|
||||||
|
|
||||||
elements.nextVibe.addEventListener(DOM_EVENTS.click, () =>
|
elements.nextVibe.addEventListener('click', () =>
|
||||||
selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton)
|
selectRelativeVibe(1, 'next-button')
|
||||||
);
|
);
|
||||||
|
|
||||||
elements.swatches.forEach((swatch, index) => {
|
elements.swatches.forEach((swatch, index) => {
|
||||||
swatch.addEventListener(DOM_EVENTS.click, () => {
|
swatch.addEventListener('click', () => {
|
||||||
settings.selectedColorIndex = index;
|
settings.selectedColorIndex = index;
|
||||||
isEraserActive = false;
|
isEraserActive = false;
|
||||||
renderPaletteUi(game);
|
renderPaletteUi(game);
|
||||||
|
|
@ -572,11 +501,11 @@ const main = async () => {
|
||||||
renderPaletteUi(game);
|
renderPaletteUi(game);
|
||||||
};
|
};
|
||||||
|
|
||||||
elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser);
|
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
|
||||||
elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser);
|
elements.eraserSizeControl.addEventListener('click', activateEraser);
|
||||||
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser);
|
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
|
||||||
|
|
||||||
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => {
|
elements.eraserSizeSlider.addEventListener('input', () => {
|
||||||
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
||||||
isEraserActive = true;
|
isEraserActive = true;
|
||||||
renderEraserSizeUi(game);
|
renderEraserSizeUi(game);
|
||||||
|
|
@ -584,7 +513,7 @@ const main = async () => {
|
||||||
configPane?.refresh();
|
configPane?.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => {
|
elements.mirrorSegmentSlider.addEventListener('input', () => {
|
||||||
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
||||||
Number(elements.mirrorSegmentSlider.value)
|
Number(elements.mirrorSegmentSlider.value)
|
||||||
);
|
);
|
||||||
|
|
@ -594,7 +523,7 @@ const main = async () => {
|
||||||
configPane?.refresh();
|
configPane?.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.export4k.addEventListener(DOM_EVENTS.click, async () => {
|
elements.export4k.addEventListener('click', async () => {
|
||||||
if (!game || elements.export4k.disabled) {
|
if (!game || elements.export4k.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -620,7 +549,7 @@ const main = async () => {
|
||||||
// the AudioContext on iOS, and gates the intro.
|
// the AudioContext on iOS, and gates the intro.
|
||||||
const fontsReady = document.fonts.ready.catch((error) => {
|
const fontsReady = document.fonts.ready.catch((error) => {
|
||||||
ErrorHandler.addException(error, {
|
ErrorHandler.addException(error, {
|
||||||
fallbackMessage: LOADING_MESSAGES.fontsError,
|
fallbackMessage: 'Could not load fonts.',
|
||||||
severity: Severity.WARNING,
|
severity: Severity.WARNING,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -633,12 +562,12 @@ const main = async () => {
|
||||||
}).then(
|
}).then(
|
||||||
() => {
|
() => {
|
||||||
isPreloadComplete = true;
|
isPreloadComplete = true;
|
||||||
setLoadingStage(LOADING_MESSAGES.ready, 1);
|
setLoadingStage('Ready', 1);
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
isPreloadComplete = true;
|
isPreloadComplete = true;
|
||||||
ErrorHandler.addException(error, {
|
ErrorHandler.addException(error, {
|
||||||
fallbackMessage: LOADING_MESSAGES.pianoSamplesError,
|
fallbackMessage: 'Could not preload piano samples.',
|
||||||
severity: Severity.WARNING,
|
severity: Severity.WARNING,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +580,6 @@ const main = async () => {
|
||||||
game?.onVibeChanged();
|
game?.onVibeChanged();
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
},
|
},
|
||||||
onOpenChange: () => undefined,
|
|
||||||
onRuntimeChange: syncRuntimeUi,
|
onRuntimeChange: syncRuntimeUi,
|
||||||
});
|
});
|
||||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||||
|
|
@ -680,14 +608,14 @@ const main = async () => {
|
||||||
elements.startButton.disabled = false;
|
elements.startButton.disabled = false;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
|
elements.startButton.removeEventListener('click', onClick);
|
||||||
hasStarted = true;
|
hasStarted = true;
|
||||||
game?.startAudio(true);
|
game?.startAudio(true);
|
||||||
trackStart();
|
trackStart();
|
||||||
elements.splash.hidden = true;
|
elements.splash.hidden = true;
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
elements.startButton.addEventListener(DOM_EVENTS.click, onClick);
|
elements.startButton.addEventListener('click', onClick);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isPreloadComplete) {
|
if (!isPreloadComplete) {
|
||||||
|
|
@ -698,16 +626,16 @@ const main = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the dev stats overlay hidden until the user actually starts drawing.
|
// 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(
|
elements.canvas.addEventListener(
|
||||||
DOM_EVENTS.pointerDown,
|
'pointerdown',
|
||||||
() => document.body.classList.remove(CSS_CLASSES.preDrawing),
|
() => document.body.classList.remove('pre-drawing'),
|
||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
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();
|
await game.start();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.classList.remove(CSS_CLASSES.isLoading);
|
document.body.classList.remove('is-loading');
|
||||||
if (hasRuntimeErrorListener) {
|
if (hasRuntimeErrorListener) {
|
||||||
ErrorHandler.addException(e);
|
ErrorHandler.addException(e);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ interface PaneState extends GardenAudioVibeSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
|
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 = [
|
const colorReactionRows = [
|
||||||
{
|
{
|
||||||
|
|
@ -51,14 +56,14 @@ const colorReactionRows = [
|
||||||
const brushControlKeys = [
|
const brushControlKeys = [
|
||||||
'brushSize',
|
'brushSize',
|
||||||
'spawnPerPixel',
|
'spawnPerPixel',
|
||||||
'brushSizeVariation',
|
|
||||||
'diffusionRateBrush',
|
|
||||||
] satisfies Array<RuntimeControlKey>;
|
] satisfies Array<RuntimeControlKey>;
|
||||||
|
|
||||||
const agentControlKeys = [
|
const agentControlKeys = [
|
||||||
'sensorOffsetDistance',
|
'sensorOffsetDistance',
|
||||||
'moveSpeed',
|
'moveSpeed',
|
||||||
'turnSpeed',
|
'turnSpeed',
|
||||||
|
'forwardRotationScale',
|
||||||
|
'turnWhenLost',
|
||||||
'individualTrailWeight',
|
'individualTrailWeight',
|
||||||
'decayRateTrails',
|
'decayRateTrails',
|
||||||
] satisfies Array<RuntimeControlKey>;
|
] satisfies Array<RuntimeControlKey>;
|
||||||
|
|
@ -80,7 +85,7 @@ const MUSIC_CONTROLS: ReadonlyArray<{
|
||||||
max: number;
|
max: number;
|
||||||
step: 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: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 },
|
||||||
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
|
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
|
||||||
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
|
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
|
||||||
|
|
@ -91,7 +96,6 @@ const MUSIC_CONTROLS: ReadonlyArray<{
|
||||||
|
|
||||||
interface ConfigPaneOptions {
|
interface ConfigPaneOptions {
|
||||||
onConfigChange: () => void;
|
onConfigChange: () => void;
|
||||||
onOpenChange?: (isOpen: boolean) => void;
|
|
||||||
onRuntimeChange: () => void;
|
onRuntimeChange: () => void;
|
||||||
settingsButton: HTMLButtonElement;
|
settingsButton: HTMLButtonElement;
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +123,9 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||||
options: config.options,
|
options: config.options,
|
||||||
step: config.step,
|
step: config.step,
|
||||||
};
|
};
|
||||||
|
if (config.format !== undefined) {
|
||||||
|
params.format = config.format;
|
||||||
|
}
|
||||||
if (config.min !== undefined) {
|
if (config.min !== undefined) {
|
||||||
params.min = config.min;
|
params.min = config.min;
|
||||||
}
|
}
|
||||||
|
|
@ -128,10 +135,32 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||||
return params;
|
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 {
|
export class ConfigPane {
|
||||||
private readonly container: HTMLDivElement;
|
private readonly container: HTMLDivElement;
|
||||||
private readonly pane: Pane;
|
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<{
|
private readonly colorReactionSwatches: Array<{
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
|
|
@ -379,56 +408,79 @@ export class ConfigPane {
|
||||||
key: ColorReactionKey,
|
key: ColorReactionKey,
|
||||||
sourceColorIndex: number,
|
sourceColorIndex: number,
|
||||||
targetColorIndex: number
|
targetColorIndex: number
|
||||||
): HTMLLabelElement {
|
): HTMLDivElement {
|
||||||
const cell = doc.createElement('label');
|
const cell = doc.createElement('div');
|
||||||
cell.className = 'color-reaction-matrix__cell';
|
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];
|
const config = appConfig.runtimeSettings.controls[key];
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(config.options ?? {}).forEach(([label, value]) => {
|
const button = doc.createElement('button');
|
||||||
const option = doc.createElement('option');
|
button.className = 'color-reaction-matrix__button';
|
||||||
option.value = String(value);
|
button.type = 'button';
|
||||||
option.textContent = label;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
select.addEventListener('change', () => {
|
const icon = doc.createElement('span');
|
||||||
settings[key] = normalizeNumber(Number(select.value), config);
|
icon.className = 'color-reaction-matrix__icon';
|
||||||
select.value = String(settings[key]);
|
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.options.onRuntimeChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.colorReactionSelects.set(key, select);
|
this.colorReactionButtons.set(key, {
|
||||||
cell.appendChild(select);
|
element: button,
|
||||||
|
sourceColorIndex,
|
||||||
|
targetColorIndex,
|
||||||
|
});
|
||||||
|
cell.appendChild(button);
|
||||||
|
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncColorReactionMatrix(): void {
|
private syncColorReactionMatrix(): void {
|
||||||
this.colorReactionSelects.forEach((select, key) => {
|
this.colorReactionButtons.forEach(
|
||||||
const config = appConfig.runtimeSettings.controls[key];
|
({ element, sourceColorIndex, targetColorIndex }, key) => {
|
||||||
if (!config) {
|
this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
settings[key] = normalizeNumber(settings[key], config);
|
|
||||||
select.value = String(settings[key]);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
|
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
|
||||||
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
|
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 {
|
private setUpMusicSection(container: PaneContainer): void {
|
||||||
const folder = container.addFolder({ title: 'Music', expanded: true });
|
const folder = container.addFolder({ title: 'Music', expanded: true });
|
||||||
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
|
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-expanded', String(this.isOpen));
|
||||||
settingsButton.setAttribute('aria-label', label);
|
settingsButton.setAttribute('aria-label', label);
|
||||||
settingsButton.title = label;
|
settingsButton.title = label;
|
||||||
this.options.onOpenChange?.(this.isOpen);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,9 @@
|
||||||
const AGENT_WORKGROUP_SIZE = 64;
|
const AGENT_WORKGROUP_SIZE = 64;
|
||||||
const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535;
|
export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE;
|
||||||
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 dispatchAgentWorkgroups = (
|
export const dispatchAgentWorkgroups = (
|
||||||
passEncoder: GPUComputePassEncoder,
|
passEncoder: GPUComputePassEncoder,
|
||||||
agentCount: number
|
agentCount: number
|
||||||
): void => {
|
): void => {
|
||||||
const [workgroupX, workgroupY] = getAgentDispatchWorkgroups(agentCount);
|
passEncoder.dispatchWorkgroups(Math.ceil(agentCount / AGENT_WORKGROUP_SIZE), 1);
|
||||||
passEncoder.dispatchWorkgroups(workgroupX, workgroupY);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
|
||||||
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
||||||
import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
|
import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
|
||||||
import compactionShader from './agent-compaction.wgsl?raw';
|
import compactionShader from './agent-compaction.wgsl?raw';
|
||||||
|
|
@ -17,10 +18,18 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly bindGroupsByActiveBuffer = new WeakMap<
|
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUBuffer>(
|
||||||
GPUBuffer,
|
(active, inactive) =>
|
||||||
WeakMap<GPUBuffer, GPUBindGroup>
|
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 resizePipeline: GPUComputePipeline;
|
||||||
private readonly compactionPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
|
|
@ -244,7 +253,6 @@ export class AgentGenerationPipeline {
|
||||||
this.resizeUniformFloatValues[0] = scale[0];
|
this.resizeUniformFloatValues[0] = scale[0];
|
||||||
this.resizeUniformFloatValues[1] = scale[1];
|
this.resizeUniformFloatValues[1] = scale[1];
|
||||||
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
|
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
|
||||||
this.resizeUniformUintValues[3] = 0;
|
|
||||||
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
|
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
|
||||||
|
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
|
@ -314,49 +322,7 @@ export class AgentGenerationPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBindGroup(): GPUBindGroup {
|
private getBindGroup(): GPUBindGroup {
|
||||||
let inactiveCache = this.bindGroupsByActiveBuffer.get(this.activeAgentsBuffer);
|
return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private swapAgentBuffers(): void {
|
private swapAgentBuffers(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
struct ResizeSettings {
|
struct ResizeSettings {
|
||||||
scale: vec2<f32>,
|
scale: vec2<f32>,
|
||||||
agentCount: u32,
|
agentCount: u32,
|
||||||
padding: u32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
|
@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 { CommonState } from '../common-state/common-state';
|
||||||
import { dispatchAgentWorkgroups } from './agent-dispatch';
|
import { dispatchAgentWorkgroups } from './agent-dispatch';
|
||||||
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
||||||
import { AgentSettings } from './agent-settings';
|
|
||||||
import shader from './agent.wgsl?raw';
|
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 {
|
export class AgentPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 30;
|
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';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
import { BrushSettings } from './brush-settings';
|
|
||||||
import shader from './brush.wgsl?raw';
|
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 {
|
interface LineSegment {
|
||||||
from: vec2;
|
from: vec2;
|
||||||
to: vec2;
|
to: vec2;
|
||||||
|
|
@ -29,10 +39,8 @@ const setBrushUniformValues = (
|
||||||
target: Float32Array,
|
target: Float32Array,
|
||||||
{
|
{
|
||||||
brushSize,
|
brushSize,
|
||||||
brushSizeVariation,
|
|
||||||
brushAlpha,
|
brushAlpha,
|
||||||
brushDiscardThreshold,
|
brushDiscardThreshold,
|
||||||
brushCoarseNoiseScale,
|
|
||||||
brushGrainNoiseScale,
|
brushGrainNoiseScale,
|
||||||
brushGrainNoiseOffsetX,
|
brushGrainNoiseOffsetX,
|
||||||
brushGrainNoiseOffsetY,
|
brushGrainNoiseOffsetY,
|
||||||
|
|
@ -44,25 +52,21 @@ const setBrushUniformValues = (
|
||||||
): void => {
|
): void => {
|
||||||
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||||
const brushRadius = (brushSize * safePixelRatio) / 2;
|
const brushRadius = (brushSize * safePixelRatio) / 2;
|
||||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
|
||||||
const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
|
|
||||||
|
|
||||||
target[0] = brushRadius;
|
target[0] = brushRadius;
|
||||||
target[1] = brushRadiusVariation * 2;
|
target[1] = brushRadius * brushRadius;
|
||||||
target[2] = brushGeometryRadius * brushGeometryRadius;
|
target[2] = 0;
|
||||||
target[3] = brushGeometryRadius;
|
target[3] = 0;
|
||||||
target[4] = selectedColorIndex === 0 ? 1 : 0;
|
target[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||||
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||||
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||||
target[7] = brushAlpha;
|
target[7] = brushAlpha;
|
||||||
target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio);
|
target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
|
||||||
target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
|
target[9] = brushGrainNoiseOffsetX;
|
||||||
target[10] = brushGrainNoiseOffsetX;
|
target[10] = brushGrainNoiseOffsetY;
|
||||||
target[11] = brushGrainNoiseOffsetY;
|
target[11] = brushDiscardThreshold;
|
||||||
target[12] = brushDiscardThreshold;
|
target[12] = brushGrainMinStrength;
|
||||||
target[13] = brushGrainMinStrength;
|
target[13] = brushGrainMaxStrength;
|
||||||
target[14] = brushGrainMaxStrength;
|
|
||||||
target[15] = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class BrushPipeline {
|
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 {
|
struct Settings {
|
||||||
brushSize: f32,
|
brushRadius: f32,
|
||||||
brushSizeVariation: f32,
|
brushRadiusSquared: f32,
|
||||||
brushGeometryRadiusSquared: f32,
|
// padding to 16-byte alignment for the following vec4
|
||||||
brushGeometryRadius: f32,
|
_pad0: f32,
|
||||||
|
_pad1: f32,
|
||||||
brushValue: vec4<f32>,
|
brushValue: vec4<f32>,
|
||||||
brushCoarseNoiseScale: f32,
|
|
||||||
brushGrainNoiseScale: f32,
|
brushGrainNoiseScale: f32,
|
||||||
brushGrainNoiseOffsetX: f32,
|
brushGrainNoiseOffsetX: f32,
|
||||||
brushGrainNoiseOffsetY: f32,
|
brushGrainNoiseOffsetY: f32,
|
||||||
|
|
@ -39,7 +39,7 @@ fn vertex(
|
||||||
if denominator > 0.0001 {
|
if denominator > 0.0001 {
|
||||||
inverseLengthSquared = 1.0 / denominator;
|
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 uv = screenPosition / state.size;
|
||||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||||
|
|
@ -74,18 +74,11 @@ fn brushStrength(
|
||||||
direction,
|
direction,
|
||||||
inverseLengthSquared
|
inverseLengthSquared
|
||||||
);
|
);
|
||||||
if distanceSquared > settings.brushGeometryRadiusSquared {
|
if distanceSquared > settings.brushRadiusSquared {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let coarseNoise = textureSampleLevel(
|
let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared);
|
||||||
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);
|
|
||||||
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
|
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,8 @@ export class CommonState {
|
||||||
struct State {
|
struct State {
|
||||||
size: vec2<f32>,
|
size: vec2<f32>,
|
||||||
time: f32,
|
time: f32,
|
||||||
padding0: f32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> state: State;
|
@group(0) @binding(0) var<uniform> state: State;
|
||||||
@group(0) @binding(1) var noiseSampler: sampler;
|
@group(0) @binding(1) var noiseSampler: sampler;
|
||||||
@group(0) @binding(2) var noise: texture_2d<f32>;
|
@group(0) @binding(2) var noise: texture_2d<f32>;
|
||||||
|
|
@ -101,7 +100,6 @@ export class CommonState {
|
||||||
this.uniformValues[0] = canvasSize[0];
|
this.uniformValues[0] = canvasSize[0];
|
||||||
this.uniformValues[1] = canvasSize[1];
|
this.uniformValues[1] = canvasSize[1];
|
||||||
this.uniformValues[2] = time;
|
this.uniformValues[2] = time;
|
||||||
this.uniformValues[3] = 0;
|
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
inverseDiffusionRateTrails: f32,
|
inverseDiffusionRateTrails: f32,
|
||||||
decayRateTrails: f32,
|
decayRateTrails: f32,
|
||||||
inverseDiffusionRateBrush: f32,
|
|
||||||
decayRateBrush: f32,
|
|
||||||
diffusionNeighborScale: f32,
|
diffusionNeighborScale: f32,
|
||||||
brushDecayAlphaMultiplier: f32,
|
brushDecayAlphaMultiplier: f32,
|
||||||
brushDecayAlphaSubtract: f32,
|
brushDecayAlphaSubtract: f32,
|
||||||
|
padding0: f32,
|
||||||
padding1: f32,
|
padding1: f32,
|
||||||
|
padding2: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORKGROUP_SIZE_X = 16u;
|
const WORKGROUP_SIZE_X = 16u;
|
||||||
|
|
@ -74,25 +74,16 @@ fn main(
|
||||||
r16,
|
r16,
|
||||||
settings.inverseDiffusionRateTrails
|
settings.inverseDiffusionRateTrails
|
||||||
);
|
);
|
||||||
let brushWeight = diffusion_weight(
|
|
||||||
random,
|
|
||||||
r2,
|
|
||||||
r4,
|
|
||||||
r8,
|
|
||||||
r16,
|
|
||||||
settings.inverseDiffusionRateBrush
|
|
||||||
);
|
|
||||||
|
|
||||||
current += (
|
current += (
|
||||||
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
|
propagate(centerTileIndex, -1, -1, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, -1, 1, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, -1, 1, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 1, -1, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 1, -1, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 1, 1, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 1, 1, current, trailWeight)
|
||||||
|
|
||||||
+ propagate(centerTileIndex, -1, 0, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, -1, 0, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 0, -1, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 1, 0, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 0, 1, current, trailWeight)
|
||||||
) * settings.diffusionNeighborScale;
|
) * settings.diffusionNeighborScale;
|
||||||
|
|
||||||
let decayed = clamp(vec4(
|
let decayed = clamp(vec4(
|
||||||
|
|
@ -108,8 +99,7 @@ fn propagate(
|
||||||
offsetX: i32,
|
offsetX: i32,
|
||||||
offsetY: i32,
|
offsetY: i32,
|
||||||
currentColor: vec4<f32>,
|
currentColor: vec4<f32>,
|
||||||
trailWeight: f32,
|
trailWeight: f32
|
||||||
brushWeight: f32
|
|
||||||
) -> vec4<f32> {
|
) -> vec4<f32> {
|
||||||
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
|
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
|
||||||
let neighbourTileIndex = u32(neighbourIndex);
|
let neighbourTileIndex = u32(neighbourIndex);
|
||||||
|
|
@ -118,7 +108,7 @@ fn propagate(
|
||||||
|
|
||||||
return vec4(
|
return vec4(
|
||||||
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
|
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
|
||||||
neighbour.a * brushWeight
|
neighbour.a * trailWeight
|
||||||
) * difference;
|
) * difference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
|
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import shader from './diffuse.wgsl?raw';
|
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<
|
type DiffusionUniformSettings = Pick<
|
||||||
DiffusionSettings,
|
DiffusionSettings,
|
||||||
| 'diffusionRateTrails'
|
| 'diffusionRateTrails'
|
||||||
| 'decayRateTrails'
|
| 'decayRateTrails'
|
||||||
| 'diffusionRateBrush'
|
|
||||||
| 'decayRateBrush'
|
| 'decayRateBrush'
|
||||||
| 'diffusionDecayRateDivisor'
|
| 'diffusionDecayRateDivisor'
|
||||||
| 'diffusionNeighborDivisor'
|
| 'diffusionNeighborDivisor'
|
||||||
|
|
@ -33,7 +40,6 @@ const setDiffusionUniformValues = (
|
||||||
{
|
{
|
||||||
diffusionRateTrails,
|
diffusionRateTrails,
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
diffusionDecayRateDivisor,
|
diffusionDecayRateDivisor,
|
||||||
diffusionNeighborDivisor,
|
diffusionNeighborDivisor,
|
||||||
|
|
@ -47,11 +53,11 @@ const setDiffusionUniformValues = (
|
||||||
: 1;
|
: 1;
|
||||||
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
|
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
|
||||||
target[1] = decayRateTrails / decayDivisor;
|
target[1] = decayRateTrails / decayDivisor;
|
||||||
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
|
target[2] = 1 / neighborDivisor;
|
||||||
target[3] = brushDecayRate;
|
target[3] = 1 + brushDecayRate;
|
||||||
target[4] = 1 / neighborDivisor;
|
target[4] = brushDecayAlphaOffset * brushDecayRate;
|
||||||
target[5] = 1 + brushDecayRate;
|
target[5] = 0;
|
||||||
target[6] = brushDecayAlphaOffset * brushDecayRate;
|
target[6] = 0;
|
||||||
target[7] = 0;
|
target[7] = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -66,10 +72,17 @@ export class DiffusionPipeline {
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
DiffusionPipeline.UNIFORM_COUNT
|
DiffusionPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly bindGroupsByInput = new WeakMap<
|
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
||||||
GPUTextureView,
|
(trailMapIn, trailMapOut) =>
|
||||||
WeakMap<GPUTextureView, GPUBindGroup>
|
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) {
|
public constructor(private readonly device: GPUDevice) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(
|
this.bindGroupLayout = device.createBindGroupLayout(
|
||||||
|
|
@ -95,7 +108,6 @@ export class DiffusionPipeline {
|
||||||
public setParameters({
|
public setParameters({
|
||||||
diffusionRateTrails,
|
diffusionRateTrails,
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
diffusionDecayRateDivisor,
|
diffusionDecayRateDivisor,
|
||||||
diffusionNeighborDivisor,
|
diffusionNeighborDivisor,
|
||||||
|
|
@ -104,7 +116,6 @@ export class DiffusionPipeline {
|
||||||
setDiffusionUniformValues(this.uniformValues, {
|
setDiffusionUniformValues(this.uniformValues, {
|
||||||
diffusionRateTrails,
|
diffusionRateTrails,
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
diffusionDecayRateDivisor,
|
diffusionDecayRateDivisor,
|
||||||
diffusionNeighborDivisor,
|
diffusionNeighborDivisor,
|
||||||
|
|
@ -130,51 +141,12 @@ export class DiffusionPipeline {
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
passEncoder.setBindGroup(0, bindGroup);
|
passEncoder.setBindGroup(0, bindGroup);
|
||||||
passEncoder.dispatchWorkgroups(
|
passEncoder.dispatchWorkgroups(
|
||||||
getWorkgroupCount(size[0], DiffusionPipeline.WORKGROUP_SIZE),
|
Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
|
||||||
getWorkgroupCount(size[1], DiffusionPipeline.WORKGROUP_SIZE)
|
Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
|
||||||
);
|
);
|
||||||
passEncoder.end();
|
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() {
|
public destroy() {
|
||||||
this.uniforms.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 { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
|
|
@ -20,10 +21,17 @@ export class EraserAgentPipeline {
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
EraserAgentPipeline.UNIFORM_COUNT
|
EraserAgentPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly bindGroupsByAgentsBuffer = new WeakMap<
|
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
|
||||||
GPUBuffer,
|
(agentsBuffer, eraserMask) =>
|
||||||
WeakMap<GPUTextureView, GPUBindGroup>
|
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 pendingSegmentCount = 0;
|
||||||
private activeSegmentCount = 0;
|
private activeSegmentCount = 0;
|
||||||
|
|
@ -121,7 +129,7 @@ export class EraserAgentPipeline {
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
|
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
|
||||||
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
@ -129,43 +137,4 @@ export class EraserAgentPipeline {
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.uniforms.destroy();
|
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[4] = eraserClearBlue;
|
||||||
this.uniformValues[5] = eraserClearAlpha;
|
this.uniformValues[5] = eraserClearAlpha;
|
||||||
this.uniformValues[6] = eraserRadius;
|
this.uniformValues[6] = eraserRadius;
|
||||||
this.uniformValues[7] = 0;
|
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ struct Settings {
|
||||||
clearBlue: f32,
|
clearBlue: f32,
|
||||||
clearAlpha: f32,
|
clearAlpha: f32,
|
||||||
eraserRadius: f32,
|
eraserRadius: f32,
|
||||||
padding1: f32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
|
|
@ -6,9 +7,16 @@ import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
import { RenderSettings } from './render-settings';
|
|
||||||
import shader from './render.wgsl?raw';
|
import shader from './render.wgsl?raw';
|
||||||
|
|
||||||
|
export interface RenderSettings {
|
||||||
|
clarity: number;
|
||||||
|
renderTraceNormalizationFloor: number;
|
||||||
|
renderBrushColorBase: number;
|
||||||
|
renderBrushColorStrengthMultiplier: number;
|
||||||
|
backgroundGrainStrength: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class RenderPipeline {
|
export class RenderPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 20;
|
private static readonly UNIFORM_COUNT = 20;
|
||||||
|
|
||||||
|
|
@ -21,10 +29,17 @@ export class RenderPipeline {
|
||||||
RenderPipeline.UNIFORM_COUNT
|
RenderPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly bindGroupsByTexture = new WeakMap<
|
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
||||||
GPUTextureView,
|
(colorTexture, sourceTexture) =>
|
||||||
WeakMap<GPUTextureView, GPUBindGroup>
|
this.device.createBindGroup({
|
||||||
>();
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
|
{ binding: 2, resource: colorTexture },
|
||||||
|
{ binding: 3, resource: sourceTexture },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly context: GPUCanvasContext,
|
private readonly context: GPUCanvasContext,
|
||||||
|
|
@ -165,45 +180,6 @@ export class RenderPipeline {
|
||||||
passEncoder.end();
|
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() {
|
public destroy() {
|
||||||
this.uniforms.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 {
|
.color-reaction-matrix__cell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-reaction-matrix__cell > select {
|
.color-reaction-matrix__button {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
border: 1px solid rgb(255 255 255 / 16%);
|
border: 1px solid rgb(255 255 255 / 16%);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
background: rgb(255 255 255 / 8%);
|
background: rgb(255 255 255 / 8%);
|
||||||
color: white;
|
color: white;
|
||||||
font: inherit;
|
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: 2px solid rgb(255 255 255 / 72%);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-reaction-matrix__cell > select > option {
|
.color-reaction-matrix__button[data-reaction='follow'] {
|
||||||
background: rgb(28 31 38);
|
border-color: rgb(115 235 160 / 44%);
|
||||||
color: white;
|
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 => {
|
export const readBrowserStorage = (key: string): string | null => {
|
||||||
try {
|
try {
|
||||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -8,13 +8,11 @@ export const readBrowserStorage = (key: string): string | null => {
|
||||||
|
|
||||||
export const writeBrowserStorage = (key: string, value: string): void => {
|
export const writeBrowserStorage = (key: string, value: string): void => {
|
||||||
try {
|
try {
|
||||||
if (typeof localStorage !== 'undefined') {
|
localStorage.setItem(key, value);
|
||||||
localStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Storage can be unavailable in private browsing or embedded contexts.',
|
'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';
|
import { readBrowserStorage } from './utils/browser-storage';
|
||||||
|
|
||||||
export { VibeId } from './config';
|
export { VibeId };
|
||||||
export type { VibePreset } from './config';
|
export type { VibePreset };
|
||||||
|
|
||||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue