More clean up
This commit is contained in:
parent
c94ffcc506
commit
f03da42b5e
43 changed files with 827 additions and 1085 deletions
|
|
@ -4,7 +4,7 @@ export const DISABLED_FLAG_VALUE = '0';
|
|||
export const UNIT_INTERVAL_INPUT_MIN = '0';
|
||||
export const UNIT_INTERVAL_INPUT_MAX = '1';
|
||||
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.42;
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
|
||||
export const APP_STORAGE_KEYS = {
|
||||
audioMuted: 'fleeting-garden:audio-muted',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor';
|
||||
|
|
@ -7,81 +8,106 @@ export interface GardenAudioChord {
|
|||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile {
|
||||
export interface GardenAudioVibeSettings {
|
||||
idleIntensity: number;
|
||||
bpm: number;
|
||||
rampUpIntensity: number;
|
||||
rampUpTime: number;
|
||||
noteLength: number;
|
||||
notePitchOffset: number;
|
||||
brightness: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||
rootMidi: number;
|
||||
scale: Array<number>;
|
||||
brightness: number;
|
||||
delayTimeMultiplier: number;
|
||||
progression: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioConfig {
|
||||
masterVolume: number;
|
||||
fadeInSeconds: number;
|
||||
updateRampSeconds: number;
|
||||
export const createGardenAudioConfig = () => ({
|
||||
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeSeconds: number;
|
||||
feedback: number;
|
||||
wetGain: number;
|
||||
erasingActivity: number;
|
||||
activityFeedbackWeight: number;
|
||||
feedbackMax: number;
|
||||
feedbackMin: number;
|
||||
outputActivityWeight: number;
|
||||
outputBase: number;
|
||||
outputActivityDuck: number;
|
||||
timeRampSeconds: number;
|
||||
};
|
||||
timeSeconds: 0.46,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
activityFeedbackWeight: 0.08,
|
||||
feedbackMax: 0.32,
|
||||
feedbackMin: 0.04,
|
||||
outputActivityWeight: 0.5,
|
||||
outputBase: 0.65,
|
||||
outputActivityDuck: 0.28,
|
||||
timeRampSeconds: 0.12,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: number;
|
||||
gain: number;
|
||||
sustainSeconds: number;
|
||||
sustainLevel: number;
|
||||
releaseSeconds: number;
|
||||
lowpassHz: number;
|
||||
gainAttackSeconds: number;
|
||||
lowpassMaxHz: number;
|
||||
lowpassMinHz: number;
|
||||
sustainBase: number;
|
||||
sustainVelocityRange: number;
|
||||
};
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.32,
|
||||
releaseSeconds: 0.24,
|
||||
lowpassHz: 7600,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
},
|
||||
rhythm: {
|
||||
bpm: number;
|
||||
stepsPerBeat: number;
|
||||
stepsPerBar: number;
|
||||
sparseActivity: number;
|
||||
};
|
||||
idleIntensity: 0.08,
|
||||
bpm: 74,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: number;
|
||||
noiseGain: number;
|
||||
filterMinHz: number;
|
||||
filterMaxHz: number;
|
||||
durationSeconds: number;
|
||||
pan: number;
|
||||
pianoActivity: number;
|
||||
};
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
durationSeconds: 0.08,
|
||||
pan: 0,
|
||||
pianoActivity: 0,
|
||||
},
|
||||
energy: {
|
||||
attackSeconds: number;
|
||||
decaySeconds: number;
|
||||
immediateActivityScale: number;
|
||||
releaseSeconds: number;
|
||||
strokeDecaySeconds: number;
|
||||
};
|
||||
attackSeconds: 0.08,
|
||||
decaySeconds: 0.9,
|
||||
immediateActivityScale: 0.85,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
graph: {
|
||||
pianoBusGains: Record<PianoNoteRole, number>;
|
||||
pianoBusActivityDucking: Record<PianoNoteRole, number>;
|
||||
noiseBusGain: number;
|
||||
};
|
||||
pianoBusGains: {
|
||||
pad: 0.86,
|
||||
support: 0.94,
|
||||
texture: 0.88,
|
||||
gesture: 1,
|
||||
brush: 0.9,
|
||||
stinger: 0.92,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
pianoBusActivityDucking: {
|
||||
pad: 0.42,
|
||||
support: 0.18,
|
||||
texture: -0.06,
|
||||
gesture: 0,
|
||||
brush: -0.08,
|
||||
stinger: 0,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
noiseBusGain: 0.72,
|
||||
},
|
||||
input: {
|
||||
fullActivitySpeed: number;
|
||||
activityNoiseFloorSpeed: number;
|
||||
activityCurve: number;
|
||||
activitySoftCeiling: number;
|
||||
activityAttackSeconds: number;
|
||||
activityReleaseSeconds: number;
|
||||
minAudibleDistance: number;
|
||||
manicActivityThreshold: number;
|
||||
manicReleaseThreshold: number;
|
||||
maniaSmoothingSeconds: number;
|
||||
};
|
||||
}
|
||||
fullActivitySpeed: 0.86,
|
||||
activityNoiseFloorSpeed: 0.025,
|
||||
activityCurve: 0.74,
|
||||
activitySoftCeiling: 0.96,
|
||||
activityAttackSeconds: 0.055,
|
||||
activityReleaseSeconds: 0.2,
|
||||
minAudibleDistance: 0.0025,
|
||||
manicActivityThreshold: 0.9,
|
||||
manicReleaseThreshold: 0.76,
|
||||
maniaSmoothingSeconds: 0.12,
|
||||
},
|
||||
});
|
||||
|
||||
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { approach, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export class GardenAudioEnergy {
|
||||
private isGestureActive = false;
|
||||
|
|
@ -19,13 +19,10 @@ export class GardenAudioEnergy {
|
|||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number): void {
|
||||
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(
|
||||
this.energy,
|
||||
strokeEnergy * this.config.energy.immediateActivityScale
|
||||
);
|
||||
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +31,7 @@ export class GardenAudioEnergy {
|
|||
this.energy = 0;
|
||||
}
|
||||
|
||||
public update(now: number): void {
|
||||
public update(now: number, profile: GardenAudioVibeProfile): void {
|
||||
if (this.lastEnergyUpdateAt <= 0) {
|
||||
this.lastEnergyUpdateAt = now;
|
||||
return;
|
||||
|
|
@ -51,7 +48,7 @@ export class GardenAudioEnergy {
|
|||
if (!this.isGestureActive) {
|
||||
timeConstant = this.config.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = this.config.energy.attackSeconds;
|
||||
timeConstant = profile.rampUpTime;
|
||||
}
|
||||
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
|
||||
audioSession?: {
|
||||
type:
|
||||
| 'auto'
|
||||
| 'playback'
|
||||
| 'ambient'
|
||||
| 'transient'
|
||||
| 'transient-solo'
|
||||
| 'play-and-record';
|
||||
};
|
||||
};
|
||||
|
||||
const outputHighPassFrequencyHz = 45;
|
||||
|
|
@ -113,19 +121,19 @@ export class GardenAudioGraph {
|
|||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||
public applyDelayProfile(): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.config.delay.timeSeconds,
|
||||
this.context.currentTime,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
|
||||
public updateDelay(activity: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -133,7 +141,7 @@ export class GardenAudioGraph {
|
|||
const now = this.context.currentTime;
|
||||
const normalizedActivity = clamp(activity, 0, 1);
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.config.delay.timeSeconds,
|
||||
now,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,34 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
import type { GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
||||
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const DEFAULT_ROOT_MIDI = 57;
|
||||
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
||||
|
||||
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
let profile = profileCache.get(vibe);
|
||||
if (!profile) {
|
||||
profile = {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: DEFAULT_SCALE as Array<number>,
|
||||
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
|
||||
};
|
||||
profileCache.set(vibe, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
Object.assign(profile, vibe.audio);
|
||||
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
|
||||
return profile;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface PianoNote {
|
|||
role?: PianoNoteRole;
|
||||
delaySend?: number;
|
||||
lowpassHz?: number;
|
||||
sustainSeconds?: number;
|
||||
}
|
||||
|
||||
export type PianoNoteRole =
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export class GardenAudio {
|
|||
|
||||
this.lifecycle = 'started';
|
||||
this.applyVibe(vibe);
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe));
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
const pianoLoad = this.piano.loadIfIdle(context);
|
||||
|
|
@ -122,7 +122,7 @@ export class GardenAudio {
|
|||
void pianoLoad
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -207,7 +207,8 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
this.energy.update(context.currentTime);
|
||||
const profile = getVibeProfile(snapshot.vibe);
|
||||
this.energy.update(context.currentTime, profile);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
|
|
@ -254,7 +255,8 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
this.energy.recordStroke(strokeEnergy);
|
||||
const profile = getVibeProfile(stroke.vibe);
|
||||
this.energy.recordStroke(strokeEnergy, profile);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
|
|
@ -347,11 +349,10 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(profile, activity);
|
||||
this.graph.updateDelay(activity);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
|
|
@ -360,7 +361,8 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(vibe));
|
||||
this.pianoEngine.cue(this.graph.context.currentTime);
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile();
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,7 @@ const getChordIntervals = (
|
|||
: chordVoicings.minorClosed;
|
||||
};
|
||||
|
||||
const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
|
|
@ -96,6 +93,7 @@ interface BrushPhraseLayer {
|
|||
export class GenerativePianoEngine {
|
||||
private nextBeatStep: number | null = null;
|
||||
private timelineStartedAt: number | null = null;
|
||||
private activeProfile: GardenAudioVibeProfile | null = null;
|
||||
private isWaitingForGestureAccent = false;
|
||||
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
private lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
|
||||
|
|
@ -124,13 +122,17 @@ export class GenerativePianoEngine {
|
|||
return generativePianoTuning;
|
||||
}
|
||||
|
||||
public prime(now: number): void {
|
||||
public prime(now: number, profile: GardenAudioVibeProfile): void {
|
||||
this.activeProfile = profile;
|
||||
this.timelineStartedAt ??= now;
|
||||
this.nextBeatStep ??= 0;
|
||||
this.nextBrushStreamStep ??= 0;
|
||||
}
|
||||
|
||||
public cue(now: number): void {
|
||||
public cue(now: number, profile?: GardenAudioVibeProfile): void {
|
||||
if (profile) {
|
||||
this.activeProfile = profile;
|
||||
}
|
||||
this.nextBeatStep = 0;
|
||||
this.timelineStartedAt = now;
|
||||
this.nextBrushStreamStep = 0;
|
||||
|
|
@ -148,10 +150,9 @@ export class GenerativePianoEngine {
|
|||
}
|
||||
|
||||
public release(vibe: VibePreset, now: number): number {
|
||||
this.prime(now);
|
||||
this.isWaitingForGestureAccent = false;
|
||||
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.prime(now, profile);
|
||||
this.isWaitingForGestureAccent = false;
|
||||
const releaseStep = this.getReleaseResolutionStep(now);
|
||||
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
|
||||
|
||||
|
|
@ -198,7 +199,8 @@ export class GenerativePianoEngine {
|
|||
activity,
|
||||
maniaAmount = 0,
|
||||
}: StrokeAccentRequest): void {
|
||||
this.prime(now);
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.prime(now, profile);
|
||||
const strength = clamp01(activity);
|
||||
const normalizedManiaAmount = clamp01(maniaAmount);
|
||||
const styleIndex = this.getStyleIndex(now);
|
||||
|
|
@ -242,14 +244,14 @@ export class GenerativePianoEngine {
|
|||
activity,
|
||||
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
|
||||
}: RenderLookaheadRequest): void {
|
||||
this.prime(now);
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.prime(now, profile);
|
||||
this.skipLateBeats(now);
|
||||
|
||||
if (this.nextBeatStep === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(vibe);
|
||||
const lookaheadEnd = now + lookaheadSeconds;
|
||||
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
|
||||
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
|
||||
|
|
@ -286,7 +288,7 @@ export class GenerativePianoEngine {
|
|||
{ baseMidi: rootMidi, offsets },
|
||||
this.generation.padRegisters[index]
|
||||
);
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity: stinger.velocities[index],
|
||||
pan: stinger.pans[index],
|
||||
|
|
@ -302,6 +304,7 @@ export class GenerativePianoEngine {
|
|||
public reset(): void {
|
||||
this.nextBeatStep = null;
|
||||
this.timelineStartedAt = null;
|
||||
this.activeProfile = null;
|
||||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
|
||||
|
|
@ -314,6 +317,13 @@ export class GenerativePianoEngine {
|
|||
this.brushStreamNoteCountsByBar.clear();
|
||||
}
|
||||
|
||||
private playProfileNote(profile: GardenAudioVibeProfile, note: PianoNote): void {
|
||||
this.playNote({
|
||||
...note,
|
||||
sustainSeconds: profile.noteLength,
|
||||
});
|
||||
}
|
||||
|
||||
private renderBeat({
|
||||
profile,
|
||||
beatIndex,
|
||||
|
|
@ -398,7 +408,7 @@ export class GenerativePianoEngine {
|
|||
false
|
||||
);
|
||||
this.lastPadMidiByVoice[index] = midi;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity:
|
||||
velocity + expression * this.generation.padChord.expressionVelocityWeight,
|
||||
|
|
@ -433,8 +443,13 @@ export class GenerativePianoEngine {
|
|||
|
||||
offsetsByVoice.forEach((offsets, index) => {
|
||||
const register = this.generation.padRegisters[index];
|
||||
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
|
||||
this.playNote({
|
||||
const midi = this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets },
|
||||
register,
|
||||
null,
|
||||
false
|
||||
);
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity: release.velocities[index],
|
||||
startTime: startTime + index * release.strumSeconds,
|
||||
|
|
@ -470,7 +485,7 @@ export class GenerativePianoEngine {
|
|||
);
|
||||
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity:
|
||||
(this.generation.supportNote.velocityBase +
|
||||
|
|
@ -516,7 +531,7 @@ export class GenerativePianoEngine {
|
|||
);
|
||||
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity:
|
||||
(this.generation.textureNote.velocityBase +
|
||||
|
|
@ -563,7 +578,7 @@ export class GenerativePianoEngine {
|
|||
);
|
||||
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity:
|
||||
(this.generation.gestureAccent.velocityBase +
|
||||
|
|
@ -608,7 +623,7 @@ export class GenerativePianoEngine {
|
|||
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.lastBrushStreamMidi = midi;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity:
|
||||
(this.generation.touchNote.velocityBase +
|
||||
|
|
@ -818,7 +833,7 @@ export class GenerativePianoEngine {
|
|||
|
||||
this.lastBrushStreamMidi = midi;
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi,
|
||||
velocity:
|
||||
(this.generation.brushStream.velocityBase +
|
||||
|
|
@ -851,7 +866,7 @@ export class GenerativePianoEngine {
|
|||
this.generation.brushStreamEcho.maxMidi
|
||||
? midi + this.generation.brushStreamEcho.octaveSemitones
|
||||
: midi - this.generation.brushStreamEcho.octaveSemitones;
|
||||
this.playNote({
|
||||
this.playProfileNote(profile, {
|
||||
midi: echoMidi,
|
||||
velocity:
|
||||
(this.generation.brushStreamEcho.velocityBase +
|
||||
|
|
@ -1159,14 +1174,18 @@ export class GenerativePianoEngine {
|
|||
}
|
||||
|
||||
private getExpression(activity: number): number {
|
||||
const liftedActivity = Math.max(
|
||||
activity,
|
||||
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
|
||||
);
|
||||
return clamp01(
|
||||
(activity - this.config.rhythm.sparseActivity) /
|
||||
(liftedActivity - this.config.rhythm.sparseActivity) /
|
||||
(1 - this.config.rhythm.sparseActivity)
|
||||
);
|
||||
}
|
||||
|
||||
private getBeatDurationSeconds(): number {
|
||||
return 60 / this.config.rhythm.bpm;
|
||||
return 60 / (this.activeProfile?.bpm ?? this.config.rhythm.bpm);
|
||||
}
|
||||
|
||||
private getStepDurationSeconds(): number {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export class PianoSampler {
|
|||
role,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
|
||||
}: PianoNote): void {
|
||||
const { context } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
|
|
@ -92,12 +93,11 @@ export class PianoSampler {
|
|||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
profileSustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart +
|
||||
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
|
|
@ -150,7 +150,7 @@ export class PianoSampler {
|
|||
const releaseAt =
|
||||
scheduledStart +
|
||||
clamp(
|
||||
durationSeconds + this.config.piano.sustainSeconds * 0.5,
|
||||
durationSeconds + profileSustainSeconds * 0.5,
|
||||
pianoSamplerTuning.minDurationSeconds,
|
||||
pianoSamplerTuning.synthMaxDurationSeconds
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants';
|
||||
import { createGardenAudioConfig } from './audio/garden-audio-config';
|
||||
import { defaultSettings } from './config/default-settings';
|
||||
import { runtimeControls } from './config/runtime-controls';
|
||||
import type { GardenAppConfig } from './config/types';
|
||||
|
|
@ -14,90 +15,7 @@ export type {
|
|||
} from './config/types';
|
||||
|
||||
export const appConfig = {
|
||||
audio: {
|
||||
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeSeconds: 0.46,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
activityFeedbackWeight: 0.08,
|
||||
feedbackMax: 0.32,
|
||||
feedbackMin: 0.04,
|
||||
outputActivityWeight: 0.5,
|
||||
outputBase: 0.65,
|
||||
outputActivityDuck: 0.28,
|
||||
timeRampSeconds: 0.12,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.32,
|
||||
releaseSeconds: 0.24,
|
||||
lowpassHz: 7600,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
},
|
||||
rhythm: {
|
||||
bpm: 74,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
durationSeconds: 0.08,
|
||||
pan: 0,
|
||||
pianoActivity: 0,
|
||||
},
|
||||
energy: {
|
||||
attackSeconds: 0.08,
|
||||
decaySeconds: 0.9,
|
||||
immediateActivityScale: 0.85,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
graph: {
|
||||
pianoBusGains: {
|
||||
pad: 0.86,
|
||||
support: 0.94,
|
||||
texture: 0.88,
|
||||
gesture: 1,
|
||||
brush: 0.9,
|
||||
stinger: 0.92,
|
||||
},
|
||||
pianoBusActivityDucking: {
|
||||
pad: 0.42,
|
||||
support: 0.18,
|
||||
texture: -0.06,
|
||||
gesture: 0,
|
||||
brush: -0.08,
|
||||
stinger: 0,
|
||||
},
|
||||
noiseBusGain: 0.72,
|
||||
},
|
||||
input: {
|
||||
fullActivitySpeed: 0.86,
|
||||
activityNoiseFloorSpeed: 0.025,
|
||||
activityCurve: 0.74,
|
||||
activitySoftCeiling: 0.96,
|
||||
activityAttackSeconds: 0.055,
|
||||
activityReleaseSeconds: 0.2,
|
||||
minAudibleDistance: 0.0025,
|
||||
manicActivityThreshold: 0.9,
|
||||
manicReleaseThreshold: 0.76,
|
||||
maniaSmoothingSeconds: 0.12,
|
||||
},
|
||||
},
|
||||
audio: createGardenAudioConfig(),
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
|
|
@ -124,7 +42,7 @@ export const appConfig = {
|
|||
noiseHashMultiplier: 43758.5453123,
|
||||
noiseHashX: 12.9898,
|
||||
noiseHashY: 78.233,
|
||||
noiseTextureFormat: 'rgba8unorm',
|
||||
noiseTextureFormat: 'r8unorm',
|
||||
noiseTextureSize: 2048,
|
||||
},
|
||||
brush: {
|
||||
|
|
@ -261,7 +179,7 @@ export const appConfig = {
|
|||
},
|
||||
},
|
||||
tuningPane: {
|
||||
expandedDepth: 1,
|
||||
showFpsOverlay: import.meta.env.DEV,
|
||||
startHidden: true,
|
||||
title: 'Garden Config',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { colorInteractionSettings } from './color-interactions';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
selectedColorIndex: 0,
|
||||
...colorInteractionSettings,
|
||||
|
||||
turnWhenLost: 0.8,
|
||||
forwardRotationScale: 0.25,
|
||||
sensorOffsetAngle: 32,
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
introNearSensorOffsetMultiplier: 0.75,
|
||||
|
|
@ -19,7 +18,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
introStepStopDistance: 0.5,
|
||||
randomTimeScale: 0.34816,
|
||||
|
||||
diffusionRateBrush: 0.35,
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateBrush: 18,
|
||||
diffusionDecayRateDivisor: 1000,
|
||||
diffusionNeighborDivisor: 8,
|
||||
|
|
@ -33,7 +32,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||
brushSmoothingMinSampleDistance: 0.5,
|
||||
|
||||
brushSizeVariation: 0.5,
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushCoarseNoiseScale: 160,
|
||||
|
|
@ -58,5 +56,4 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
renderTraceNormalizationFloor: 1,
|
||||
renderBrushColorBase: 1.2,
|
||||
renderBrushColorStrengthMultiplier: 1.6,
|
||||
backgroundGrainStrength: 0.018,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,380 +11,100 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
color3ToColor1: colorInteractionControl('3 -> 1'),
|
||||
color3ToColor2: colorInteractionControl('3 -> 2'),
|
||||
color3ToColor3: colorInteractionControl('3 -> 3'),
|
||||
brushEffectDuration: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.5,
|
||||
max: 20,
|
||||
step: 0.05,
|
||||
},
|
||||
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
label: 'brush size',
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 0.25,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Brush',
|
||||
label: 'agents per brush pixel',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
brushSizeVariation: {
|
||||
folder: 'Brush',
|
||||
label: 'brush variance',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
brushAlpha: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
brushDiscardThreshold: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 0.25,
|
||||
step: 0.001,
|
||||
},
|
||||
brushCoarseNoiseScale: {
|
||||
folder: 'Brush',
|
||||
min: 1,
|
||||
max: 480,
|
||||
step: 1,
|
||||
},
|
||||
brushGrainNoiseScale: {
|
||||
folder: 'Brush',
|
||||
min: 1,
|
||||
max: 180,
|
||||
step: 1,
|
||||
},
|
||||
brushGrainNoiseOffsetX: {
|
||||
folder: 'Brush',
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
brushGrainNoiseOffsetY: {
|
||||
folder: 'Brush',
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
brushGrainMinStrength: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
brushGrainMaxStrength: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.001,
|
||||
},
|
||||
brushCurveResolution: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
label: 'curve resolution',
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
},
|
||||
brushSmoothingMinSampleDistance: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
brushCurveMinSegmentSpacing: {
|
||||
folder: 'Brush',
|
||||
min: 0.1,
|
||||
max: 32,
|
||||
step: 0.1,
|
||||
},
|
||||
brushCurveSegmentBrushRadiusRatio: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
brushCurveMinBrushRadius: {
|
||||
folder: 'Brush',
|
||||
min: 0.1,
|
||||
max: 16,
|
||||
step: 0.1,
|
||||
},
|
||||
brushCurveMirrorResolutionExponent: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
clarity: {
|
||||
folder: 'Render',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
renderTraceNormalizationFloor: {
|
||||
folder: 'Render',
|
||||
min: 0.01,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
renderBrushColorBase: {
|
||||
folder: 'Render',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
renderBrushColorStrengthMultiplier: {
|
||||
folder: 'Render',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
backgroundGrainStrength: {
|
||||
folder: 'Render',
|
||||
min: 0,
|
||||
max: 0.12,
|
||||
step: 0.001,
|
||||
},
|
||||
internalRenderAreaMegapixels: {
|
||||
folder: 'Render',
|
||||
label: 'internal area (MP)',
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
step: 0.1,
|
||||
},
|
||||
decayRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
step: 0.1,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
},
|
||||
diffusionRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
folder: 'Brush',
|
||||
label: 'brush diffusion',
|
||||
min: 0.001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionDecayRateDivisor: {
|
||||
folder: 'Diffusion',
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
},
|
||||
diffusionNeighborDivisor: {
|
||||
folder: 'Diffusion',
|
||||
min: 1,
|
||||
max: 16,
|
||||
step: 0.1,
|
||||
},
|
||||
brushDecayAlphaOffset: {
|
||||
folder: 'Diffusion',
|
||||
min: 1,
|
||||
max: 1.1,
|
||||
step: 0.0001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Agents',
|
||||
label: 'sensor distance',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserSize: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 24,
|
||||
max: 240,
|
||||
step: 1,
|
||||
},
|
||||
eraserMaskAlphaThreshold: {
|
||||
folder: 'Eraser',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserLineDistanceEpsilon: {
|
||||
folder: 'Eraser',
|
||||
min: 0,
|
||||
max: 0.01,
|
||||
step: 0.00001,
|
||||
},
|
||||
eraserClearRed: {
|
||||
folder: 'Eraser',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserClearGreen: {
|
||||
folder: 'Eraser',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserClearBlue: {
|
||||
folder: 'Eraser',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserClearAlpha: {
|
||||
folder: 'Eraser',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
adaptiveCapInitial: {
|
||||
folder: 'Agent',
|
||||
integer: true,
|
||||
label: 'adaptive cap initial',
|
||||
min: 50_000,
|
||||
max: 2_000_000,
|
||||
step: 10_000,
|
||||
},
|
||||
adaptiveCapMin: {
|
||||
folder: 'Agent',
|
||||
integer: true,
|
||||
label: 'adaptive cap min',
|
||||
min: 0,
|
||||
max: 500_000,
|
||||
step: 10_000,
|
||||
},
|
||||
maxAgentCount: {
|
||||
folder: 'Agent',
|
||||
integer: true,
|
||||
label: 'max agent count',
|
||||
step: 10_000,
|
||||
},
|
||||
mirrorSegmentCount: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 12,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Agent',
|
||||
folder: 'Agents',
|
||||
label: 'move speed',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
selectedColorIndex: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Agent',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Agent',
|
||||
folder: 'Agents',
|
||||
label: 'turn speed',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Agent',
|
||||
individualTrailWeight: {
|
||||
folder: 'Agents',
|
||||
label: 'individual trail weight',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
forwardRotationScale: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
decayRateTrails: {
|
||||
folder: 'Agents',
|
||||
label: 'trail decay',
|
||||
min: 800,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
},
|
||||
|
||||
clarity: {
|
||||
folder: 'Look',
|
||||
label: 'clarity',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
introNearDistanceMin: {
|
||||
folder: 'Agent',
|
||||
backgroundGrainStrength: {
|
||||
folder: 'Look',
|
||||
label: 'grain strength',
|
||||
min: 0,
|
||||
max: 100,
|
||||
max: 0.12,
|
||||
step: 0.001,
|
||||
},
|
||||
|
||||
maxAgentCount: {
|
||||
folder: 'Performance',
|
||||
integer: true,
|
||||
label: 'max agent count',
|
||||
min: 0,
|
||||
max: 2_000_000,
|
||||
step: 10_000,
|
||||
},
|
||||
internalRenderAreaMegapixels: {
|
||||
folder: 'Performance',
|
||||
label: 'internal resolution (MP)',
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
step: 0.1,
|
||||
},
|
||||
introNearDistanceInner: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 0.1,
|
||||
},
|
||||
introNearSensorOffsetMultiplier: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
introTargetAngleBlend: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
introProgressCutoff: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
introTurnRateMultiplier: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 0.01,
|
||||
},
|
||||
introRandomTurnMultiplier: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.001,
|
||||
},
|
||||
introFarMoveMultiplier: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 6,
|
||||
step: 0.01,
|
||||
},
|
||||
introNearMoveMultiplier: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
introStepStopDistance: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 0.01,
|
||||
},
|
||||
randomTimeScale: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.00001,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
GardenAudioVibeSettings,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
||||
|
|
@ -45,19 +45,29 @@ export type GardenRuntimeSettings = {
|
|||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
type RuntimeSettingControlConfig = {
|
||||
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||
};
|
||||
type RuntimeSettingControlConfig = Partial<
|
||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
type GardenVibeSettings = Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'backgroundGrainStrength'
|
||||
| 'brushSize'
|
||||
| 'brushSizeVariation'
|
||||
| 'clarity'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'decayRateTrails'
|
||||
| 'diffusionRateTrails'
|
||||
| 'diffusionRateBrush'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'turnSpeed'
|
||||
|
|
@ -83,7 +93,7 @@ export interface VibePreset {
|
|||
colors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeProfile;
|
||||
audio: GardenAudioVibeSettings;
|
||||
}
|
||||
|
||||
export interface GardenAppConfig {
|
||||
|
|
@ -239,7 +249,7 @@ export interface GardenAppConfig {
|
|||
};
|
||||
};
|
||||
tuningPane: {
|
||||
expandedDepth: number;
|
||||
showFpsOverlay: boolean;
|
||||
startHidden: boolean;
|
||||
title: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import type { GardenAudioChord } from '../audio/garden-audio-config';
|
||||
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||
import { colorInteractionSettings } from './color-interactions';
|
||||
import { VibeId, type VibePreset } from './types';
|
||||
|
||||
const majorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const minorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
];
|
||||
|
||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||
const suspendedPentatonic = [0, 2, 5, 7, 9];
|
||||
const mixolydianPentatonic = [0, 2, 4, 7, 10];
|
||||
const dorianHexatonic = [0, 2, 3, 5, 7, 10];
|
||||
const darkMinorPentatonic = [0, 2, 3, 7, 10];
|
||||
const defaultAudioSettings = {
|
||||
idleIntensity: 0.08,
|
||||
bpm: 74,
|
||||
rampUpIntensity: 0.85,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.42,
|
||||
notePitchOffset: 0,
|
||||
} satisfies Omit<GardenAudioVibeSettings, 'brightness'>;
|
||||
|
||||
export const defaultVibeId = VibeId.CandyRain;
|
||||
|
||||
|
|
@ -34,23 +24,22 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [16, 21, 31],
|
||||
settings: {
|
||||
...colorInteractionSettings,
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 14,
|
||||
brushSizeVariation: 0.5,
|
||||
clarity: 0.62,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateTrails: 0.22,
|
||||
diffusionRateBrush: 0.35,
|
||||
individualTrailWeight: 0.07,
|
||||
moveSpeed: 82,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
spawnPerPixel: 0.22,
|
||||
turnSpeed: 58,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 57,
|
||||
scale: majorPentatonic,
|
||||
...defaultAudioSettings,
|
||||
brightness: 1.04,
|
||||
delayTimeMultiplier: 0.92,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -63,28 +52,22 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [23, 32, 22],
|
||||
settings: {
|
||||
...colorInteractionSettings,
|
||||
backgroundGrainStrength: 0.014,
|
||||
brushSize: 16,
|
||||
brushSizeVariation: 0.35,
|
||||
clarity: 0.68,
|
||||
decayRateTrails: 975,
|
||||
diffusionRateTrails: 0.18,
|
||||
diffusionRateBrush: 0.28,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 70,
|
||||
sensorOffsetAngle: 28,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.18,
|
||||
turnSpeed: 44,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 53,
|
||||
scale: mixolydianPentatonic,
|
||||
...defaultAudioSettings,
|
||||
brightness: 0.92,
|
||||
delayTimeMultiplier: 1.08,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -97,23 +80,22 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [15, 24, 34],
|
||||
settings: {
|
||||
...colorInteractionSettings,
|
||||
backgroundGrainStrength: 0.022,
|
||||
brushSize: 13,
|
||||
brushSizeVariation: 0.58,
|
||||
clarity: 0.58,
|
||||
decayRateTrails: 955,
|
||||
diffusionRateTrails: 0.28,
|
||||
diffusionRateBrush: 0.42,
|
||||
individualTrailWeight: 0.055,
|
||||
moveSpeed: 90,
|
||||
sensorOffsetAngle: 36,
|
||||
sensorOffsetDistance: 35,
|
||||
spawnPerPixel: 0.25,
|
||||
turnSpeed: 62,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 50,
|
||||
scale: dorianHexatonic,
|
||||
...defaultAudioSettings,
|
||||
brightness: 1,
|
||||
delayTimeMultiplier: 1.12,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -126,23 +108,22 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [20, 18, 29],
|
||||
settings: {
|
||||
...colorInteractionSettings,
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 12,
|
||||
brushSizeVariation: 0.45,
|
||||
clarity: 0.64,
|
||||
decayRateTrails: 968,
|
||||
diffusionRateTrails: 0.2,
|
||||
diffusionRateBrush: 0.32,
|
||||
individualTrailWeight: 0.065,
|
||||
moveSpeed: 76,
|
||||
sensorOffsetAngle: 32,
|
||||
sensorOffsetDistance: 42,
|
||||
spawnPerPixel: 0.2,
|
||||
turnSpeed: 52,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 49,
|
||||
scale: darkMinorPentatonic,
|
||||
...defaultAudioSettings,
|
||||
brightness: 0.9,
|
||||
delayTimeMultiplier: 1.24,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -155,23 +136,22 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [25, 23, 22],
|
||||
settings: {
|
||||
...colorInteractionSettings,
|
||||
backgroundGrainStrength: 0.024,
|
||||
brushSize: 15,
|
||||
brushSizeVariation: 0.62,
|
||||
clarity: 0.55,
|
||||
decayRateTrails: 948,
|
||||
diffusionRateTrails: 0.32,
|
||||
diffusionRateBrush: 0.48,
|
||||
individualTrailWeight: 0.05,
|
||||
moveSpeed: 96,
|
||||
sensorOffsetAngle: 40,
|
||||
sensorOffsetDistance: 32,
|
||||
spawnPerPixel: 0.24,
|
||||
turnSpeed: 70,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 56,
|
||||
scale: majorPentatonic,
|
||||
...defaultAudioSettings,
|
||||
brightness: 1.08,
|
||||
delayTimeMultiplier: 0.86,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -184,28 +164,22 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [16, 24, 32],
|
||||
settings: {
|
||||
...colorInteractionSettings,
|
||||
backgroundGrainStrength: 0.012,
|
||||
brushSize: 18,
|
||||
brushSizeVariation: 0.28,
|
||||
clarity: 0.7,
|
||||
decayRateTrails: 982,
|
||||
diffusionRateTrails: 0.14,
|
||||
diffusionRateBrush: 0.24,
|
||||
individualTrailWeight: 0.075,
|
||||
moveSpeed: 62,
|
||||
sensorOffsetAngle: 26,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.16,
|
||||
turnSpeed: 40,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 62,
|
||||
scale: suspendedPentatonic,
|
||||
...defaultAudioSettings,
|
||||
brightness: 0.88,
|
||||
delayTimeMultiplier: 1.32,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export class GameLoopResources {
|
|||
public readonly eraserAgentPipeline: EraserAgentPipeline;
|
||||
public readonly eraserTexturePipeline: EraserTexturePipeline;
|
||||
public readonly diffusionPipeline: DiffusionPipeline;
|
||||
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
public readonly renderPipeline: RenderPipeline;
|
||||
|
||||
private readonly frameRenderer: SimulationFrameRenderer;
|
||||
|
|
@ -74,7 +73,6 @@ export class GameLoopResources {
|
|||
);
|
||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||
this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
||||
|
|
@ -83,7 +81,6 @@ export class GameLoopResources {
|
|||
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||
diffusionPipeline: this.diffusionPipeline,
|
||||
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
|
||||
renderPipeline: this.renderPipeline,
|
||||
});
|
||||
}
|
||||
|
|
@ -94,6 +91,7 @@ export class GameLoopResources {
|
|||
|
||||
public clearSimulation(): void {
|
||||
this.textures.clear();
|
||||
this.frameRenderer.resetSourceMapActivity();
|
||||
}
|
||||
|
||||
public setFrameParameters({
|
||||
|
|
@ -115,6 +113,7 @@ export class GameLoopResources {
|
|||
this.agentPipeline.setParameters({
|
||||
...settings,
|
||||
deltaTime,
|
||||
time,
|
||||
agentCount: activeAgentCount,
|
||||
moveSpeed:
|
||||
settings.moveSpeed *
|
||||
|
|
@ -138,6 +137,7 @@ export class GameLoopResources {
|
|||
this.eraserAgentPipeline.setParameters({
|
||||
agentCount: activeAgentCount,
|
||||
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
|
||||
maskSize: canvasSize,
|
||||
});
|
||||
this.eraserTexturePipeline.setParameters({
|
||||
eraserSize: eraserPixelSize,
|
||||
|
|
@ -147,7 +147,6 @@ export class GameLoopResources {
|
|||
eraserClearBlue: settings.eraserClearBlue,
|
||||
eraserClearAlpha: settings.eraserClearAlpha,
|
||||
});
|
||||
this.setBrushEffectDiffusionParameters();
|
||||
}
|
||||
|
||||
public executeFrame(
|
||||
|
|
@ -164,20 +163,8 @@ export class GameLoopResources {
|
|||
this.eraserAgentPipeline.destroy();
|
||||
this.eraserTexturePipeline.destroy();
|
||||
this.diffusionPipeline.destroy();
|
||||
this.brushEffectDiffusionPipeline.destroy();
|
||||
this.renderPipeline.destroy();
|
||||
this.commonState.destroy();
|
||||
this.textures.destroy();
|
||||
}
|
||||
|
||||
private setBrushEffectDiffusionParameters(): void {
|
||||
const framesToOneE = Math.max(
|
||||
1,
|
||||
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
|
||||
);
|
||||
this.brushEffectDiffusionPipeline.setParameters({
|
||||
...settings,
|
||||
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default class GameLoop {
|
|||
private readonly agentPopulation: AgentPopulation;
|
||||
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
||||
private readonly framePerformance = new FramePerformance();
|
||||
private readonly devStatsOverlay: DevStatsOverlay | null;
|
||||
private devStatsOverlay: DevStatsOverlay | null = null;
|
||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||
private readonly seed = this.seedValue.toString(16);
|
||||
|
|
@ -47,9 +47,6 @@ export default class GameLoop {
|
|||
ui: GardenUi
|
||||
) {
|
||||
this.resize();
|
||||
this.devStatsOverlay = import.meta.env.DEV
|
||||
? new DevStatsOverlay(canvas.parentElement ?? document.body)
|
||||
: null;
|
||||
this.resources = new GameLoopResources(
|
||||
canvas,
|
||||
device,
|
||||
|
|
@ -103,8 +100,12 @@ export default class GameLoop {
|
|||
});
|
||||
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
this.pointerInput.attach();
|
||||
this.eraserPreviewController.attach();
|
||||
this.syncDevStatsOverlay();
|
||||
}
|
||||
|
||||
public attachPointerInput(): void {
|
||||
this.pointerInput.attach();
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
|
|
@ -118,6 +119,7 @@ export default class GameLoop {
|
|||
|
||||
public onVibeChanged(): void {
|
||||
this.agentPopulation.onVibeChanged();
|
||||
this.syncDevStatsOverlay();
|
||||
}
|
||||
|
||||
public setAudioMuted(isMuted: boolean): void {
|
||||
|
|
@ -153,6 +155,7 @@ export default class GameLoop {
|
|||
this.pointerInput.detach();
|
||||
this.eraserPreviewController.detach();
|
||||
this.devStatsOverlay?.destroy();
|
||||
this.devStatsOverlay = null;
|
||||
this.toolbarContrastMonitor.destroy();
|
||||
this.introPrompt.destroy();
|
||||
await this.agentPopulation.waitForCompaction();
|
||||
|
|
@ -220,6 +223,18 @@ export default class GameLoop {
|
|||
requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
private syncDevStatsOverlay(): void {
|
||||
if (appConfig.tuningPane.showFpsOverlay) {
|
||||
this.devStatsOverlay ??= new DevStatsOverlay(
|
||||
this.canvas.parentElement ?? document.body
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.devStatsOverlay?.destroy();
|
||||
this.devStatsOverlay = null;
|
||||
}
|
||||
|
||||
private updateAccentColor(color: RgbColor): void {
|
||||
const accentColor = rgbColorToCss(color);
|
||||
if (this.previousAccentColor === accentColor) {
|
||||
|
|
|
|||
|
|
@ -13,17 +13,26 @@ interface SimulationFramePipelines {
|
|||
eraserAgentPipeline: EraserAgentPipeline;
|
||||
eraserTexturePipeline: EraserTexturePipeline;
|
||||
diffusionPipeline: DiffusionPipeline;
|
||||
brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
renderPipeline: RenderPipeline;
|
||||
}
|
||||
|
||||
export class SimulationFrameRenderer {
|
||||
private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
|
||||
|
||||
private sourceActiveFramesRemaining = 0;
|
||||
private sourceMapsCleared = true;
|
||||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
private readonly textures: SimulationTextures,
|
||||
private readonly pipelines: SimulationFramePipelines
|
||||
) {}
|
||||
|
||||
public resetSourceMapActivity(): void {
|
||||
this.sourceActiveFramesRemaining = 0;
|
||||
this.sourceMapsCleared = true;
|
||||
}
|
||||
|
||||
public execute(
|
||||
isErasing: boolean,
|
||||
canvasReadbackRequest?: CanvasReadbackRequest | null
|
||||
|
|
@ -31,6 +40,7 @@ export class SimulationFrameRenderer {
|
|||
const commandEncoder = this.device.createCommandEncoder();
|
||||
|
||||
this.textures.copyTrailMapAToB(commandEncoder);
|
||||
let wroteSourceMap = false;
|
||||
if (isErasing) {
|
||||
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
||||
const eraserMask = this.textures.eraserMask.getTextureView();
|
||||
|
|
@ -38,18 +48,29 @@ export class SimulationFrameRenderer {
|
|||
commandEncoder,
|
||||
eraserMask,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.influenceMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask);
|
||||
}
|
||||
} else {
|
||||
this.pipelines.brushPipeline.executeMultiTarget(
|
||||
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
}
|
||||
|
||||
if (wroteSourceMap) {
|
||||
this.sourceActiveFramesRemaining =
|
||||
SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE;
|
||||
this.sourceMapsCleared = false;
|
||||
}
|
||||
|
||||
const useSourceMap = this.sourceActiveFramesRemaining > 0;
|
||||
if (!useSourceMap && !this.sourceMapsCleared) {
|
||||
this.textures.clearSourceMaps(commandEncoder);
|
||||
this.sourceMapsCleared = true;
|
||||
}
|
||||
|
||||
this.pipelines.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
|
|
@ -64,28 +85,24 @@ export class SimulationFrameRenderer {
|
|||
const canvasTexture = this.pipelines.renderPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
useSourceMap
|
||||
);
|
||||
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
|
||||
|
||||
if (useSourceMap) {
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.sourceMapB.getTextureView(),
|
||||
this.textures.sourceMapB.getSize()
|
||||
);
|
||||
}
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
canvasReadbackRequest?.afterSubmit();
|
||||
|
||||
const postRenderCommandEncoder = this.device.createCommandEncoder();
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
postRenderCommandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.sourceMapB.getTextureView(),
|
||||
this.textures.sourceMapB.getSize()
|
||||
);
|
||||
this.pipelines.brushEffectDiffusionPipeline.execute(
|
||||
postRenderCommandEncoder,
|
||||
this.textures.influenceMapA.getTextureView(),
|
||||
this.textures.influenceMapB.getTextureView(),
|
||||
this.textures.influenceMapB.getSize()
|
||||
);
|
||||
|
||||
this.device.queue.submit([postRenderCommandEncoder.finish()]);
|
||||
this.textures.swapBrushEffectMaps();
|
||||
if (useSourceMap) {
|
||||
this.textures.swapSourceMaps();
|
||||
this.sourceActiveFramesRemaining -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ export class SimulationTextures {
|
|||
public readonly trailMapB: ResizableTexture;
|
||||
public sourceMapA: ResizableTexture;
|
||||
public sourceMapB: ResizableTexture;
|
||||
public influenceMapA: ResizableTexture;
|
||||
public influenceMapB: ResizableTexture;
|
||||
public eraserMask: ResizableTexture;
|
||||
|
||||
public constructor(
|
||||
|
|
@ -20,8 +18,6 @@ export class SimulationTextures {
|
|||
this.trailMapB = this.createTexture(canvasSize);
|
||||
this.sourceMapA = this.createTexture(canvasSize);
|
||||
this.sourceMapB = this.createTexture(canvasSize);
|
||||
this.influenceMapA = this.createTexture(canvasSize);
|
||||
this.influenceMapB = this.createTexture(canvasSize);
|
||||
this.eraserMask = this.createEraserMask(canvasSize);
|
||||
}
|
||||
|
||||
|
|
@ -36,8 +32,6 @@ export class SimulationTextures {
|
|||
this.trailMapB.resize(nextSize);
|
||||
this.sourceMapA.resize(nextSize);
|
||||
this.sourceMapB.resize(nextSize);
|
||||
this.influenceMapA.resize(nextSize);
|
||||
this.influenceMapB.resize(nextSize);
|
||||
this.eraserMask.resize(nextSize);
|
||||
|
||||
return scale;
|
||||
|
|
@ -50,8 +44,6 @@ export class SimulationTextures {
|
|||
this.trailMapB,
|
||||
this.sourceMapA,
|
||||
this.sourceMapB,
|
||||
this.influenceMapA,
|
||||
this.influenceMapB,
|
||||
this.eraserMask,
|
||||
].forEach((texture) => {
|
||||
const passEncoder = commandEncoder.beginRenderPass({
|
||||
|
|
@ -79,9 +71,20 @@ export class SimulationTextures {
|
|||
);
|
||||
}
|
||||
|
||||
public swapBrushEffectMaps(): void {
|
||||
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
||||
const passEncoder = commandEncoder.beginRenderPass({
|
||||
colorAttachments: [this.sourceMapA, this.sourceMapB].map((texture) => ({
|
||||
view: texture.getTextureView(),
|
||||
clearValue: appConfig.simulation.clearColor,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
})),
|
||||
});
|
||||
passEncoder.end();
|
||||
}
|
||||
|
||||
public swapSourceMaps(): void {
|
||||
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
|
|
@ -89,8 +92,6 @@ export class SimulationTextures {
|
|||
this.trailMapB.destroy();
|
||||
this.sourceMapA.destroy();
|
||||
this.sourceMapB.destroy();
|
||||
this.influenceMapA.destroy();
|
||||
this.influenceMapB.destroy();
|
||||
this.eraserMask.destroy();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number
|
|||
appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
|
||||
appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
|
||||
|
||||
export const getToolbarContrastMetrics = (
|
||||
const getToolbarContrastMetrics = (
|
||||
pixels: Uint8Array,
|
||||
sampleCount: number,
|
||||
isBgra: boolean
|
||||
|
|
|
|||
29
src/index.ts
29
src/index.ts
|
|
@ -16,7 +16,7 @@ import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
|||
import { ConfigPane } from './page/config-pane';
|
||||
import { FullScreenHandler } from './page/full-screen-handler';
|
||||
import { MenuHider } from './page/menu-hider';
|
||||
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
||||
import { activeVibe, applyVibeSettings, settings } from './settings';
|
||||
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
||||
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
|
||||
|
|
@ -148,7 +148,6 @@ const LOADING_MESSAGES = {
|
|||
const VIBE_CHANGE_SOURCES = {
|
||||
nextButton: 'next-button',
|
||||
previousButton: 'previous-button',
|
||||
settings: 'settings',
|
||||
} as const;
|
||||
|
||||
const clampEraserSize = (value: number): number => {
|
||||
|
|
@ -432,6 +431,7 @@ const main = async () => {
|
|||
initAnalytics();
|
||||
|
||||
let shouldStop = false;
|
||||
let hasStarted = false;
|
||||
let game: GameLoop | null = null;
|
||||
let configPane: ConfigPane | null = null;
|
||||
|
||||
|
|
@ -477,6 +477,7 @@ const main = async () => {
|
|||
|
||||
const startAudioFromUserGesture = (event: Event) => {
|
||||
if (
|
||||
!hasStarted ||
|
||||
isAudioMuted ||
|
||||
(event.target instanceof Node && elements.startButton.contains(event.target)) ||
|
||||
(event.target instanceof Node && elements.soundButton.contains(event.target))
|
||||
|
|
@ -652,28 +653,6 @@ const main = async () => {
|
|||
},
|
||||
onOpenChange: () => undefined,
|
||||
onRuntimeChange: syncRuntimeUi,
|
||||
onRuntimeReset: () => {
|
||||
resetSettings();
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
},
|
||||
onRestart: () => game?.destroy(),
|
||||
onVibeChange: (vibeId) => {
|
||||
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
|
||||
if (!vibe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
trackVibeChange({
|
||||
vibeId: activePreset.id,
|
||||
vibeName: activePreset.name,
|
||||
source: VIBE_CHANGE_SOURCES.settings,
|
||||
});
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
game?.playVibeChangeAudio(false);
|
||||
},
|
||||
});
|
||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||
await fontsReady;
|
||||
|
|
@ -702,6 +681,7 @@ const main = async () => {
|
|||
await new Promise<void>((resolve) => {
|
||||
const onClick = () => {
|
||||
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
|
||||
hasStarted = true;
|
||||
game?.startAudio(true);
|
||||
trackStart();
|
||||
elements.splash.hidden = true;
|
||||
|
|
@ -731,6 +711,7 @@ const main = async () => {
|
|||
)
|
||||
);
|
||||
}
|
||||
game.attachPointerInput();
|
||||
await game.start();
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,32 @@
|
|||
import type { BindingParams, FolderApi } from '@tweakpane/core';
|
||||
import { Pane } from 'tweakpane';
|
||||
|
||||
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||
import {
|
||||
appConfig,
|
||||
type GardenRuntimeSettings,
|
||||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||
import {
|
||||
hexColorToRgbColor,
|
||||
rgbColorToCss,
|
||||
rgbColorToHex,
|
||||
type RgbColor,
|
||||
} from '../utils/rgb-color';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
|
||||
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
|
||||
type VibeNumberKey = keyof GardenAudioVibeSettings;
|
||||
|
||||
interface PaneState extends GardenAudioVibeSettings {
|
||||
backgroundColor: string;
|
||||
color1: string;
|
||||
color2: string;
|
||||
color3: string;
|
||||
}
|
||||
|
||||
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
|
||||
|
||||
|
|
@ -33,37 +48,54 @@ const colorReactionRows = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
const colorReactionKeySet = new Set<string>(
|
||||
colorReactionRows.flatMap((row) => [...row.keys])
|
||||
);
|
||||
const brushControlKeys = [
|
||||
'brushSize',
|
||||
'spawnPerPixel',
|
||||
'brushSizeVariation',
|
||||
'diffusionRateBrush',
|
||||
] satisfies Array<RuntimeControlKey>;
|
||||
|
||||
const isColorReactionKey = (key: string): key is ColorReactionKey =>
|
||||
colorReactionKeySet.has(key);
|
||||
const agentControlKeys = [
|
||||
'sensorOffsetDistance',
|
||||
'moveSpeed',
|
||||
'turnSpeed',
|
||||
'individualTrailWeight',
|
||||
'decayRateTrails',
|
||||
] satisfies Array<RuntimeControlKey>;
|
||||
|
||||
const lookControlKeys = [
|
||||
'clarity',
|
||||
'backgroundGrainStrength',
|
||||
] satisfies Array<RuntimeControlKey>;
|
||||
|
||||
const performanceControlKeys = [
|
||||
'maxAgentCount',
|
||||
'internalRenderAreaMegapixels',
|
||||
] satisfies Array<RuntimeControlKey>;
|
||||
|
||||
const MUSIC_CONTROLS: ReadonlyArray<{
|
||||
key: VibeNumberKey;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}> = [
|
||||
{ key: 'idleIntensity', label: 'idle intensity', min: 0, max: 0.8, step: 0.01 },
|
||||
{ key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 },
|
||||
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
|
||||
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
|
||||
{ key: 'noteLength', label: 'note length', min: 0.1, max: 1.8, step: 0.01 },
|
||||
{ key: 'notePitchOffset', label: 'higher / lower notes', min: -12, max: 12, step: 1 },
|
||||
{ key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 },
|
||||
];
|
||||
|
||||
interface ConfigPaneOptions {
|
||||
onConfigChange: () => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
onRestart: () => void;
|
||||
onRuntimeChange: () => void;
|
||||
onRuntimeReset: () => void;
|
||||
onVibeChange: (vibeId: VibeId) => void;
|
||||
settingsButton: HTMLButtonElement;
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
|
||||
['boolean', 'number', 'string'].includes(typeof value);
|
||||
|
||||
const toLabel = (value: string): string =>
|
||||
value
|
||||
.replace(/\[(\d+)\]/g, ' $1')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
|
||||
if (config.options) {
|
||||
const optionValues = Object.values(config.options);
|
||||
|
|
@ -81,12 +113,9 @@ const normalizeNumber = (value: number, config: NumberControlConfig): number =>
|
|||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||
};
|
||||
|
||||
const getNumberBindingParams = (
|
||||
key: keyof GardenRuntimeSettings & string,
|
||||
config: NumberControlConfig
|
||||
): BindingParams => {
|
||||
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||
const params: BindingParams = {
|
||||
label: config.label ?? toLabel(key),
|
||||
label: config.label,
|
||||
options: config.options,
|
||||
step: config.step,
|
||||
};
|
||||
|
|
@ -107,8 +136,12 @@ export class ConfigPane {
|
|||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
}> = [];
|
||||
private readonly state: { activeVibeId: VibeId } = {
|
||||
activeVibeId: activeVibe.id,
|
||||
private readonly state: PaneState = {
|
||||
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
|
||||
color1: rgbColorToHex(activeVibe.colors[0]),
|
||||
color2: rgbColorToHex(activeVibe.colors[1]),
|
||||
color3: rgbColorToHex(activeVibe.colors[2]),
|
||||
...activeVibe.audio,
|
||||
};
|
||||
|
||||
public constructor(private readonly options: ConfigPaneOptions) {
|
||||
|
|
@ -142,12 +175,7 @@ export class ConfigPane {
|
|||
|
||||
this.options.settingsButton.addEventListener('click', this.toggle);
|
||||
|
||||
const tabs = this.pane.addTab({
|
||||
pages: [{ title: 'Runtime' }, { title: 'Config' }],
|
||||
});
|
||||
|
||||
this.setUpRuntimeTab(tabs.pages[0]);
|
||||
this.setUpConfigTab(tabs.pages[1]);
|
||||
this.setUpTuningPane(this.pane);
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +184,7 @@ export class ConfigPane {
|
|||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.state.activeVibeId = activeVibe.id;
|
||||
this.syncVibeState();
|
||||
this.pane.refresh();
|
||||
this.syncColorReactionMatrix();
|
||||
this.syncOpenState();
|
||||
|
|
@ -176,73 +204,116 @@ export class ConfigPane {
|
|||
this.syncOpenState();
|
||||
}
|
||||
|
||||
private setUpRuntimeTab(container: PaneContainer): void {
|
||||
private setUpTuningPane(container: PaneContainer): void {
|
||||
this.setUpVibeSection(container);
|
||||
this.addRuntimeSection(container, 'Brush', brushControlKeys, true);
|
||||
this.addRuntimeSection(container, 'Agents', agentControlKeys, true);
|
||||
this.addColorReactionMatrix(container);
|
||||
this.addRuntimeSection(container, 'Look', lookControlKeys, true);
|
||||
const performanceFolder = this.addRuntimeSection(
|
||||
container,
|
||||
'Performance',
|
||||
performanceControlKeys,
|
||||
true
|
||||
);
|
||||
this.addFpsOverlayBinding(performanceFolder);
|
||||
this.setUpMusicSection(container);
|
||||
this.syncColorReactionMatrix();
|
||||
}
|
||||
|
||||
private setUpVibeSection(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Vibe',
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
this.addColorBinding(folder, 'color1', 'colour 1', (color) => {
|
||||
activeVibe.colors[0] = color;
|
||||
});
|
||||
this.addColorBinding(folder, 'color2', 'colour 2', (color) => {
|
||||
activeVibe.colors[1] = color;
|
||||
});
|
||||
this.addColorBinding(folder, 'color3', 'colour 3', (color) => {
|
||||
activeVibe.colors[2] = color;
|
||||
});
|
||||
this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => {
|
||||
activeVibe.backgroundColor = color;
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
folder
|
||||
.addButton({ title: 'Copy vibe preset' })
|
||||
.on('click', () => void this.copyVibePresetToClipboard());
|
||||
}
|
||||
}
|
||||
|
||||
private addColorBinding(
|
||||
container: PaneContainer,
|
||||
key: VibeColorKey,
|
||||
label: string,
|
||||
updateColor: (color: RgbColor) => void
|
||||
): void {
|
||||
container
|
||||
.addBinding(this.state, 'activeVibeId', {
|
||||
label: 'active vibe',
|
||||
options: Object.fromEntries(
|
||||
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
|
||||
) as Record<string, VibeId>,
|
||||
})
|
||||
.addBinding(this.state, key, {
|
||||
label,
|
||||
view: 'color',
|
||||
} as BindingParams)
|
||||
.on('change', ({ value }) => {
|
||||
if (!isVibeId(value)) {
|
||||
this.refresh();
|
||||
const color = hexColorToRgbColor(String(value));
|
||||
if (!color) {
|
||||
this.syncVibeState();
|
||||
this.pane.refresh();
|
||||
return;
|
||||
}
|
||||
this.options.onVibeChange(value);
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
container.addButton({ title: 'Reset runtime settings' }).on('click', () => {
|
||||
this.options.onRuntimeReset();
|
||||
this.refresh();
|
||||
});
|
||||
updateColor(color);
|
||||
this.syncColorReactionMatrix();
|
||||
this.options.onConfigChange();
|
||||
});
|
||||
}
|
||||
|
||||
private addRuntimeSection(
|
||||
container: PaneContainer,
|
||||
title: string,
|
||||
keys: ReadonlyArray<RuntimeControlKey>,
|
||||
expanded: boolean
|
||||
): PaneContainer {
|
||||
const folder = container.addFolder({ title, expanded });
|
||||
keys.forEach((key) => this.addRuntimeBinding(folder, key));
|
||||
return folder;
|
||||
}
|
||||
|
||||
private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = normalizeNumber(settings[key], config);
|
||||
|
||||
container
|
||||
.addButton({
|
||||
title: 'Restart simulation',
|
||||
})
|
||||
.on('click', () => this.options.onRestart());
|
||||
|
||||
const folders = new Map<string, PaneContainer>();
|
||||
let hasAddedColorReactionMatrix = false;
|
||||
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
|
||||
const settingKey = key as keyof GardenRuntimeSettings & string;
|
||||
settings[settingKey] = normalizeNumber(settings[settingKey], config);
|
||||
|
||||
if (isColorReactionKey(key)) {
|
||||
if (!hasAddedColorReactionMatrix) {
|
||||
this.addColorReactionMatrix(container);
|
||||
hasAddedColorReactionMatrix = true;
|
||||
.addBinding(settings, key, getNumberBindingParams(config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumber(settings[key], config);
|
||||
if (nextValue !== settings[key]) {
|
||||
settings[key] = nextValue;
|
||||
this.pane.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.options.onRuntimeChange();
|
||||
});
|
||||
}
|
||||
|
||||
const folder =
|
||||
folders.get(config.folder) ??
|
||||
container.addFolder({
|
||||
title: config.folder,
|
||||
expanded: config.folder !== 'Runtime',
|
||||
});
|
||||
folders.set(config.folder, folder);
|
||||
|
||||
folder
|
||||
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumber(settings[settingKey], config);
|
||||
if (nextValue !== settings[settingKey]) {
|
||||
settings[settingKey] = nextValue;
|
||||
this.pane.refresh();
|
||||
}
|
||||
this.options.onRuntimeChange();
|
||||
});
|
||||
});
|
||||
this.syncColorReactionMatrix();
|
||||
private addFpsOverlayBinding(container: PaneContainer): void {
|
||||
container
|
||||
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
|
||||
label: 'FPS overlay',
|
||||
})
|
||||
.on('change', () => this.options.onConfigChange());
|
||||
}
|
||||
|
||||
private addColorReactionMatrix(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Color Reactions',
|
||||
title: 'Follow / Ignore / Avoid',
|
||||
expanded: true,
|
||||
});
|
||||
folder.element.classList.add('color-reaction-folder');
|
||||
|
|
@ -319,6 +390,10 @@ export class ConfigPane {
|
|||
);
|
||||
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return cell;
|
||||
}
|
||||
|
||||
Object.entries(config.options ?? {}).forEach(([label, value]) => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = String(value);
|
||||
|
|
@ -341,6 +416,10 @@ export class ConfigPane {
|
|||
private syncColorReactionMatrix(): void {
|
||||
this.colorReactionSelects.forEach((select, key) => {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = normalizeNumber(settings[key], config);
|
||||
select.value = String(settings[key]);
|
||||
});
|
||||
|
|
@ -350,96 +429,66 @@ export class ConfigPane {
|
|||
});
|
||||
}
|
||||
|
||||
private setUpConfigTab(container: PaneContainer): void {
|
||||
this.addObjectBindings(
|
||||
container,
|
||||
appConfig as unknown as Record<string, unknown>,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
private addObjectBindings(
|
||||
container: PaneContainer,
|
||||
source: Record<string, unknown>,
|
||||
path: Array<string>
|
||||
): void {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isBindablePrimitive(value)) {
|
||||
this.addPrimitiveBinding(container, source, key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const folder = container.addFolder({
|
||||
title: toLabel(`${key}[]`),
|
||||
expanded: path.length < appConfig.tuningPane.expandedDepth,
|
||||
});
|
||||
value.forEach((item, index) => {
|
||||
if (isBindablePrimitive(item)) {
|
||||
this.addPrimitiveBinding(
|
||||
folder,
|
||||
value as unknown as Record<string, unknown>,
|
||||
`${index}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(item)) {
|
||||
this.addObjectBindings(
|
||||
folder.addFolder({
|
||||
title: `[${index}]`,
|
||||
expanded: false,
|
||||
}),
|
||||
item,
|
||||
[...path, key, String(index)]
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
this.addObjectBindings(
|
||||
container.addFolder({
|
||||
title: toLabel(key),
|
||||
expanded: path.length < appConfig.tuningPane.expandedDepth,
|
||||
}),
|
||||
value,
|
||||
[...path, key]
|
||||
);
|
||||
}
|
||||
private setUpMusicSection(container: PaneContainer): void {
|
||||
const folder = container.addFolder({ title: 'Music', expanded: true });
|
||||
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
|
||||
this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step });
|
||||
});
|
||||
}
|
||||
|
||||
private addPrimitiveBinding(
|
||||
private addVibeNumberBinding(
|
||||
container: PaneContainer,
|
||||
source: Record<string, unknown>,
|
||||
key: string
|
||||
key: VibeNumberKey,
|
||||
config: NumberControlConfig
|
||||
): void {
|
||||
const params: BindingParams = {
|
||||
label: toLabel(key),
|
||||
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
|
||||
};
|
||||
this.state[key] = normalizeNumber(this.state[key], config);
|
||||
|
||||
container
|
||||
.addBinding(source, key, params)
|
||||
.on('change', () => this.options.onConfigChange());
|
||||
.addBinding(this.state, key, getNumberBindingParams(config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumber(this.state[key], config);
|
||||
if (nextValue !== this.state[key]) {
|
||||
this.state[key] = nextValue;
|
||||
this.pane.refresh();
|
||||
}
|
||||
activeVibe.audio[key] = nextValue;
|
||||
this.options.onConfigChange();
|
||||
});
|
||||
}
|
||||
|
||||
private syncButton(): void {
|
||||
this.options.settingsButton.classList.toggle('active', this.isOpen);
|
||||
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
|
||||
this.options.settingsButton.setAttribute(
|
||||
'aria-label',
|
||||
this.isOpen ? 'Hide config overlay' : 'Show config overlay'
|
||||
);
|
||||
this.options.settingsButton.title = this.isOpen
|
||||
? 'Hide config overlay'
|
||||
: 'Show config overlay';
|
||||
private async copyVibePresetToClipboard(): Promise<void> {
|
||||
const settingKeys = Object.keys(activeVibe.settings) as Array<
|
||||
keyof typeof activeVibe.settings
|
||||
>;
|
||||
const preset = {
|
||||
name: `${activeVibe.name} Copy`,
|
||||
colors: activeVibe.colors,
|
||||
backgroundColor: activeVibe.backgroundColor,
|
||||
settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])),
|
||||
audio: activeVibe.audio,
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(preset, null, 2));
|
||||
} catch (error) {
|
||||
console.warn('Could not copy vibe preset to clipboard.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private syncVibeState(): void {
|
||||
this.state.color1 = rgbColorToHex(activeVibe.colors[0]);
|
||||
this.state.color2 = rgbColorToHex(activeVibe.colors[1]);
|
||||
this.state.color3 = rgbColorToHex(activeVibe.colors[2]);
|
||||
this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor);
|
||||
Object.assign(this.state, activeVibe.audio);
|
||||
}
|
||||
|
||||
private syncOpenState(): void {
|
||||
this.syncButton();
|
||||
const { settingsButton } = this.options;
|
||||
const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay';
|
||||
settingsButton.classList.toggle('active', this.isOpen);
|
||||
settingsButton.setAttribute('aria-expanded', String(this.isOpen));
|
||||
settingsButton.setAttribute('aria-label', label);
|
||||
settingsButton.title = label;
|
||||
this.options.onOpenChange?.(this.isOpen);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,19 @@
|
|||
export const AGENT_WORKGROUP_SIZE = 64;
|
||||
export const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535;
|
||||
export const AGENT_MAX_DISPATCHABLE_COUNT = 0xffffffff;
|
||||
const AGENT_WORKGROUP_SIZE = 64;
|
||||
const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535;
|
||||
export const AGENT_MAX_DISPATCHABLE_COUNT =
|
||||
AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION * AGENT_WORKGROUP_SIZE;
|
||||
|
||||
export const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => {
|
||||
const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => {
|
||||
if (!Number.isFinite(agentCount) || agentCount <= 0) {
|
||||
throw new Error('Agent count must be a positive finite number');
|
||||
}
|
||||
|
||||
const workgroupCount = Math.ceil(agentCount / AGENT_WORKGROUP_SIZE);
|
||||
if (workgroupCount <= AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
|
||||
return [workgroupCount, 1];
|
||||
}
|
||||
|
||||
const workgroupX = Math.min(
|
||||
Math.ceil(Math.sqrt(workgroupCount)),
|
||||
AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION
|
||||
);
|
||||
const workgroupY = Math.ceil(workgroupCount / workgroupX);
|
||||
|
||||
if (workgroupY > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
|
||||
if (workgroupCount > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
|
||||
throw new Error('Agent count exceeds dispatchable workgroup range');
|
||||
}
|
||||
|
||||
return [workgroupX, workgroupY];
|
||||
return [workgroupCount, 1];
|
||||
};
|
||||
|
||||
export const dispatchAgentWorkgroups = (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ struct Counters {
|
|||
aliveAgentCount: atomic<u32>,
|
||||
};
|
||||
|
||||
const clearCompactedTailStride = 4u;
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
||||
|
|
@ -20,10 +22,9 @@ var<workgroup> clearAliveAgentCount: u32;
|
|||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
let id = get_id(global_id);
|
||||
|
||||
if local_id.x == 0u {
|
||||
atomicStore(&workgroupAliveCount, 0u);
|
||||
|
|
@ -63,10 +64,9 @@ fn main(
|
|||
@compute @workgroup_size(64)
|
||||
fn clearCompactedTail(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
let id = get_id(global_id);
|
||||
|
||||
if local_id.x == 0u {
|
||||
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
|
||||
|
|
@ -74,11 +74,11 @@ fn clearCompactedTail(
|
|||
|
||||
workgroupBarrier();
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
if id >= clearAliveAgentCount {
|
||||
compactedAgents[id].colorIndex = -1.0;
|
||||
let firstClearId = clearAliveAgentCount + id * clearCompactedTailStride;
|
||||
for (var offset = 0u; offset < clearCompactedTailStride; offset += 1u) {
|
||||
let clearId = firstClearId + offset;
|
||||
if clearId < settings.agentCount {
|
||||
compactedAgents[clearId].colorIndex = -1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
||||
import {
|
||||
AGENT_MAX_DISPATCHABLE_COUNT,
|
||||
dispatchAgentWorkgroups,
|
||||
} from '../agent-dispatch';
|
||||
import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
|
||||
import compactionShader from './agent-compaction.wgsl?raw';
|
||||
import resizeShader from './agent-resize.wgsl?raw';
|
||||
import agentSchema from './agent-schema.wgsl?raw';
|
||||
|
||||
export const AGENT_FLOAT_COUNT = 8;
|
||||
export const AGENT_SIZE_IN_BYTES =
|
||||
AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||
const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||
|
||||
export class AgentGenerationPipeline {
|
||||
private static readonly UNIFORM_COUNT = 4;
|
||||
private static readonly COUNTER_COUNT = 1;
|
||||
private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4;
|
||||
private static readonly ALLOCATION_GROWTH_FACTOR = 1.25;
|
||||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
|
|
@ -33,21 +31,14 @@ export class AgentGenerationPipeline {
|
|||
private allocatedMaxAgentCount: number;
|
||||
private readonly countersBuffer: GPUBuffer;
|
||||
private readonly countersStagingBuffer: GPUBuffer;
|
||||
private readonly counterClearValues = new Uint32Array(
|
||||
AgentGenerationPipeline.COUNTER_COUNT
|
||||
);
|
||||
private readonly agentCountUniformValues = new Uint32Array(
|
||||
AgentGenerationPipeline.UNIFORM_COUNT
|
||||
);
|
||||
private readonly resizeUniformBuffer = new ArrayBuffer(
|
||||
AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
private readonly resizeUniformFloatValues = new Float32Array(
|
||||
this.resizeUniformBuffer
|
||||
);
|
||||
private readonly resizeUniformUintValues = new Uint32Array(
|
||||
this.resizeUniformBuffer
|
||||
);
|
||||
private readonly resizeUniformFloatValues = new Float32Array(this.resizeUniformBuffer);
|
||||
private readonly resizeUniformUintValues = new Uint32Array(this.resizeUniformBuffer);
|
||||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
|
|
@ -173,11 +164,19 @@ export class AgentGenerationPipeline {
|
|||
requestedMaxAgentCount: number,
|
||||
activeAgentCount: number
|
||||
): number {
|
||||
const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
|
||||
if (nextMaxAgentCount <= this.allocatedMaxAgentCount) {
|
||||
const requestedClampedMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
|
||||
if (requestedClampedMaxAgentCount <= this.allocatedMaxAgentCount) {
|
||||
return this.allocatedMaxAgentCount;
|
||||
}
|
||||
|
||||
const nextMaxAgentCount = this.clampMaxAgentCount(
|
||||
Math.max(
|
||||
requestedClampedMaxAgentCount,
|
||||
Math.ceil(
|
||||
this.allocatedMaxAgentCount * AgentGenerationPipeline.ALLOCATION_GROWTH_FACTOR
|
||||
)
|
||||
)
|
||||
);
|
||||
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
|
||||
const previousMaxAgentCount = this.allocatedMaxAgentCount;
|
||||
this.allocatedMaxAgentCount = nextMaxAgentCount;
|
||||
|
|
@ -270,16 +269,19 @@ export class AgentGenerationPipeline {
|
|||
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||
|
||||
this.agentCountUniformValues[0] = agentCount;
|
||||
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
|
||||
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
||||
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT);
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.compactionPipeline);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||
dispatchAgentWorkgroups(
|
||||
passEncoder,
|
||||
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
||||
);
|
||||
passEncoder.end();
|
||||
|
||||
commandEncoder.copyBufferToBuffer(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ struct ResizeSettings {
|
|||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
let id = get_id(global_id);
|
||||
|
||||
if id >= resizeSettings.agentCount {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ struct Agent {
|
|||
|
||||
const agentWorkgroupSize = 64u;
|
||||
|
||||
fn get_id(global_id: vec3<u32>, num_workgroups: vec3<u32>) -> u32 {
|
||||
return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize;
|
||||
fn get_id(global_id: vec3<u32>) -> u32 {
|
||||
return global_id.x;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,10 +79,12 @@ export class AgentPipeline {
|
|||
introNearMoveMultiplier,
|
||||
introStepStopDistance,
|
||||
randomTimeScale,
|
||||
time,
|
||||
agentCount,
|
||||
introProgress,
|
||||
}: AgentSettings & {
|
||||
deltaTime: number;
|
||||
time: number;
|
||||
agentCount: number;
|
||||
introProgress?: number;
|
||||
}) {
|
||||
|
|
@ -117,7 +119,7 @@ export class AgentPipeline {
|
|||
this.uniformValues[26] = introFarMoveMultiplier;
|
||||
this.uniformValues[27] = introNearMoveMultiplier;
|
||||
this.uniformValues[28] = introStepStopDistance;
|
||||
this.uniformValues[29] = randomTimeScale;
|
||||
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ struct Settings {
|
|||
introFarMoveMultiplier: f32,
|
||||
introNearMoveMultiplier: f32,
|
||||
introStepStopDistance: f32,
|
||||
randomTimeScale: f32,
|
||||
randomTimeSeed: u32,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
|
|
@ -37,10 +37,9 @@ struct Settings {
|
|||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
let id = get_id(global_id);
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
|
|
@ -65,7 +64,8 @@ fn main(
|
|||
|
||||
let channelMask = get_channel_mask(colorIndex);
|
||||
let reactionMask = get_reaction_mask(colorIndex);
|
||||
let randomSeed = random_seed(id, state.time);
|
||||
let randomSeed = random_seed(id);
|
||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||
var rotation = 0.0;
|
||||
var step = vec2<f32>(0.0, 0.0);
|
||||
|
||||
|
|
@ -108,16 +108,18 @@ fn main(
|
|||
let randomTurn = random_float(randomSeed);
|
||||
let direction = vec2(cos(angle), sin(angle));
|
||||
|
||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
|
||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
|
||||
let leftSensor = sensor_position(
|
||||
position,
|
||||
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
||||
settings.sensorOffset
|
||||
settings.sensorOffset,
|
||||
maxPosition
|
||||
);
|
||||
let rightSensor = sensor_position(
|
||||
position,
|
||||
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
||||
settings.sensorOffset
|
||||
settings.sensorOffset,
|
||||
maxPosition
|
||||
);
|
||||
|
||||
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
|
||||
|
|
@ -138,7 +140,6 @@ fn main(
|
|||
step = direction * settings.moveRate;
|
||||
}
|
||||
|
||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||
let nextPosition = clamp(position + step, vec2<f32>(0, 0), maxPosition);
|
||||
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
||||
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
|
||||
|
|
@ -155,11 +156,16 @@ fn main(
|
|||
agents[id].position = nextPosition;
|
||||
}
|
||||
|
||||
fn sensor_position(agentPosition: vec2<f32>, direction: vec2<f32>, sensorOffset: f32) -> vec2<i32> {
|
||||
fn sensor_position(
|
||||
agentPosition: vec2<f32>,
|
||||
direction: vec2<f32>,
|
||||
sensorOffset: f32,
|
||||
maxPosition: vec2<f32>
|
||||
) -> vec2<i32> {
|
||||
return vec2<i32>(clamp(
|
||||
agentPosition + direction * sensorOffset,
|
||||
vec2<f32>(0, 0),
|
||||
state.size - vec2<f32>(1, 1)
|
||||
maxPosition
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -212,9 +218,8 @@ fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
|||
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
|
||||
}
|
||||
|
||||
fn random_seed(id: u32, time: f32) -> u32 {
|
||||
let timeSeed = u32(time * settings.randomTimeScale);
|
||||
return id * 747796405u + timeSeed * 2891336453u;
|
||||
fn random_seed(id: u32) -> u32 {
|
||||
return id * 747796405u + settings.randomTimeSeed * 2891336453u;
|
||||
}
|
||||
|
||||
fn random_float(seed: u32) -> f32 {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
|
|||
? pixelRatio
|
||||
: 1;
|
||||
|
||||
export const setBrushUniformValues = (
|
||||
const setBrushUniformValues = (
|
||||
target: Float32Array,
|
||||
{
|
||||
brushSize,
|
||||
|
|
@ -105,7 +105,7 @@ export class BrushPipeline {
|
|||
});
|
||||
|
||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2);
|
||||
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 1);
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -209,12 +209,10 @@ export class BrushPipeline {
|
|||
|
||||
public executeMultiTarget(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
sourceMapOut: GPUTextureView,
|
||||
influenceMapOut: GPUTextureView
|
||||
): void {
|
||||
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
|
||||
sourceMapOut: GPUTextureView
|
||||
): boolean {
|
||||
return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
|
||||
sourceMapOut,
|
||||
influenceMapOut,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -222,9 +220,9 @@ export class BrushPipeline {
|
|||
commandEncoder: GPUCommandEncoder,
|
||||
pipeline: GPURenderPipeline,
|
||||
textureViews: Array<GPUTextureView>
|
||||
): void {
|
||||
): boolean {
|
||||
if (this.lineCount === 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||
|
|
@ -242,6 +240,7 @@ export class BrushPipeline {
|
|||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
||||
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
|
||||
passEncoder.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ struct VertexOutput {
|
|||
|
||||
struct BrushTargets {
|
||||
@location(0) source: vec4<f32>,
|
||||
@location(1) influence: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
|
|
@ -53,6 +52,22 @@ fn fragmentMrt(
|
|||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> BrushTargets {
|
||||
let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared);
|
||||
|
||||
if(strength < settings.brushDiscardThreshold) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let color = brushOutput(strength);
|
||||
return BrushTargets(color);
|
||||
}
|
||||
|
||||
fn brushStrength(
|
||||
screenPosition: vec2<f32>,
|
||||
start: vec2<f32>,
|
||||
direction: vec2<f32>,
|
||||
inverseLengthSquared: f32
|
||||
) -> f32 {
|
||||
let distanceSquared = distanceSquaredFromLine(
|
||||
screenPosition,
|
||||
start,
|
||||
|
|
@ -60,24 +75,9 @@ fn fragmentMrt(
|
|||
inverseLengthSquared
|
||||
);
|
||||
if distanceSquared > settings.brushGeometryRadiusSquared {
|
||||
discard;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let strength = brushStrength(screenPosition, distanceSquared);
|
||||
|
||||
if(strength < settings.brushDiscardThreshold) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let color = brushOutput(strength);
|
||||
return BrushTargets(color, color);
|
||||
}
|
||||
|
||||
fn brushStrength(
|
||||
screenPosition: vec2<f32>,
|
||||
distanceSquared: f32
|
||||
) -> f32 {
|
||||
let distance = sqrt(distanceSquared);
|
||||
let coarseNoise = textureSampleLevel(
|
||||
noise,
|
||||
noiseSampler,
|
||||
|
|
@ -85,7 +85,7 @@ fn brushStrength(
|
|||
0.0
|
||||
).r;
|
||||
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
|
||||
let edge = 1.0 - step(radius, distance);
|
||||
let edge = 1.0 - step(radius * radius, distanceSquared);
|
||||
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
|
||||
return 0.0;
|
||||
}
|
||||
|
|
@ -112,10 +112,6 @@ fn distanceSquaredFromLine(
|
|||
) -> f32 {
|
||||
let pa = position - start;
|
||||
|
||||
if inverseLengthSquared <= 0.0 {
|
||||
return dot(pa, pa);
|
||||
}
|
||||
|
||||
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
|
||||
let nearestOffset = pa - direction * q;
|
||||
return dot(nearestOffset, nearestOffset);
|
||||
|
|
@ -140,14 +136,13 @@ fn segment_vertex_position(
|
|||
}
|
||||
|
||||
fn segment_vertex_corner(index: u32) -> vec2<f32> {
|
||||
if index == 0u {
|
||||
return vec2<f32>(-1.0, 1.0);
|
||||
}
|
||||
if index == 1u || index == 3u {
|
||||
return vec2<f32>(-1.0, -1.0);
|
||||
}
|
||||
if index == 2u || index == 4u {
|
||||
return vec2<f32>(1.0, 1.0);
|
||||
}
|
||||
return vec2<f32>(1.0, -1.0);
|
||||
let corners = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(1.0, 1.0),
|
||||
vec2<f32>(1.0, -1.0),
|
||||
);
|
||||
return corners[index];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
|
|||
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba16float, write>;
|
||||
|
||||
var<workgroup> tile: array<vec4<f32>, 324>;
|
||||
var<workgroup> tileTrailStrength: array<f32, 324>;
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn main(
|
||||
|
|
@ -28,31 +29,32 @@ fn main(
|
|||
@builtin(workgroup_id) workgroup_id: vec3<u32>
|
||||
) {
|
||||
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
||||
let textureSizeU32 = vec2<u32>(textureSize);
|
||||
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
|
||||
var tileIndex = localLinearIndex;
|
||||
|
||||
loop {
|
||||
if tileIndex >= TILE_TEXEL_COUNT {
|
||||
break;
|
||||
}
|
||||
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
|
||||
let isInteriorTile =
|
||||
workgroupOrigin.x > 0u &&
|
||||
workgroupOrigin.y > 0u &&
|
||||
workgroupOrigin.x + WORKGROUP_SIZE_X < textureSizeU32.x &&
|
||||
workgroupOrigin.y + WORKGROUP_SIZE_Y < textureSizeU32.y;
|
||||
|
||||
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
|
||||
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
|
||||
let sourcePixelU32 =
|
||||
workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y) +
|
||||
tilePosition;
|
||||
let sourcePixel = clamp(
|
||||
vec2<i32>(i32(sourcePixelU32.x), i32(sourcePixelU32.y)) - vec2<i32>(1, 1),
|
||||
vec2<i32>(0, 0),
|
||||
textureSize - vec2<i32>(1, 1)
|
||||
);
|
||||
tile[tileIndex] = textureLoad(trailMap, sourcePixel, 0);
|
||||
tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y;
|
||||
let unclampedSourcePixel = vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1);
|
||||
var sourcePixel = unclampedSourcePixel;
|
||||
if !isInteriorTile {
|
||||
sourcePixel = clamp(unclampedSourcePixel, vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1));
|
||||
}
|
||||
let texel = textureLoad(trailMap, sourcePixel, 0);
|
||||
tile[tileIndex] = texel;
|
||||
tileTrailStrength[tileIndex] = length(texel.rgb);
|
||||
}
|
||||
|
||||
workgroupBarrier();
|
||||
|
||||
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
|
||||
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
|
||||
let inBounds = pixel.x < textureSize.x && pixel.y < textureSize.y;
|
||||
if !inBounds {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -110,11 +112,12 @@ fn propagate(
|
|||
brushWeight: f32
|
||||
) -> vec4<f32> {
|
||||
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
|
||||
let neighbour = tile[u32(neighbourIndex)];
|
||||
let neighbourTileIndex = u32(neighbourIndex);
|
||||
let neighbour = tile[neighbourTileIndex];
|
||||
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
|
||||
|
||||
return vec4(
|
||||
vec3(length(neighbour.rgb) * trailWeight),
|
||||
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
|
||||
neighbour.a * brushWeight
|
||||
) * difference;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ type DiffusionUniformSettings = Pick<
|
|||
| 'brushDecayAlphaOffset'
|
||||
>;
|
||||
|
||||
export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
|
||||
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
|
||||
1 /
|
||||
(Number.isFinite(diffusionRate) &&
|
||||
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
|
||||
? diffusionRate
|
||||
: appConfig.pipelines.diffusion.minDiffusionRate);
|
||||
|
||||
export const setDiffusionUniformValues = (
|
||||
const setDiffusionUniformValues = (
|
||||
target: Float32Array,
|
||||
{
|
||||
diffusionRateTrails,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import {
|
||||
createCachedFloat32BufferWrite,
|
||||
writeFloat32BufferIfChanged,
|
||||
|
|
@ -86,9 +88,11 @@ export class EraserAgentPipeline {
|
|||
public setParameters({
|
||||
agentCount,
|
||||
eraserMaskAlphaThreshold,
|
||||
maskSize,
|
||||
}: {
|
||||
agentCount: number;
|
||||
eraserMaskAlphaThreshold: number;
|
||||
maskSize: vec2;
|
||||
}): void {
|
||||
this.agentCount = agentCount;
|
||||
this.activeSegmentCount = this.pendingSegmentCount;
|
||||
|
|
@ -96,8 +100,8 @@ export class EraserAgentPipeline {
|
|||
|
||||
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
|
||||
this.uniformValues[1] = eraserMaskAlphaThreshold;
|
||||
this.uniformValues[2] = 0;
|
||||
this.uniformValues[3] = 0;
|
||||
this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
|
||||
this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1]));
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
struct Settings {
|
||||
agentCount: u32,
|
||||
eraserMaskAlphaThreshold: f32,
|
||||
padding1: f32,
|
||||
padding2: f32,
|
||||
maskWidth: u32,
|
||||
maskHeight: u32,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
|
|
@ -10,10 +10,9 @@ struct Settings {
|
|||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
let id = get_id(global_id);
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
|
|
@ -24,7 +23,7 @@ fn main(
|
|||
return;
|
||||
}
|
||||
|
||||
let maskSize = vec2<i32>(textureDimensions(eraserMask));
|
||||
let maskSize = vec2<i32>(i32(settings.maskWidth), i32(settings.maskHeight));
|
||||
let maskPosition = clamp(
|
||||
vec2<i32>(agents[id].position),
|
||||
vec2<i32>(0, 0),
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ export class EraserTexturePipeline {
|
|||
'r8unorm',
|
||||
'rgba16float',
|
||||
'rgba16float',
|
||||
'rgba16float',
|
||||
]);
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
|
|
@ -169,7 +168,6 @@ export class EraserTexturePipeline {
|
|||
commandEncoder: GPUCommandEncoder,
|
||||
eraserMaskOut: GPUTextureView,
|
||||
sourceMapOut: GPUTextureView,
|
||||
influenceMapOut: GPUTextureView,
|
||||
trailMapOut: GPUTextureView
|
||||
): void {
|
||||
if (this.lineCount === 0) {
|
||||
|
|
@ -200,11 +198,6 @@ export class EraserTexturePipeline {
|
|||
loadOp: 'load',
|
||||
storeOp: 'store',
|
||||
},
|
||||
{
|
||||
view: influenceMapOut,
|
||||
loadOp: 'load',
|
||||
storeOp: 'store',
|
||||
},
|
||||
{
|
||||
view: trailMapOut,
|
||||
loadOp: 'load',
|
||||
|
|
|
|||
|
|
@ -19,17 +19,10 @@ struct VertexOutput {
|
|||
@location(3) @interpolate(flat) inverseLengthSquared: f32,
|
||||
}
|
||||
|
||||
struct EraserTextureTargets {
|
||||
@location(0) source: vec4<f32>,
|
||||
@location(1) influence: vec4<f32>,
|
||||
@location(2) trail: vec4<f32>,
|
||||
}
|
||||
|
||||
struct EraserCombinedTargets {
|
||||
@location(0) mask: vec4<f32>,
|
||||
@location(1) source: vec4<f32>,
|
||||
@location(2) influence: vec4<f32>,
|
||||
@location(3) trail: vec4<f32>,
|
||||
@location(2) trail: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
|
|
@ -50,35 +43,6 @@ fn vertex(
|
|||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> @location(0) vec4<f32> {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
|
||||
discard;
|
||||
}
|
||||
|
||||
return getEraserClearValue();
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentMrt(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> EraserTextureTargets {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let cleared = getEraserClearValue();
|
||||
return EraserTextureTargets(cleared, cleared, cleared);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentCombined(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
|
|
@ -91,7 +55,7 @@ fn fragmentCombined(
|
|||
}
|
||||
|
||||
let cleared = getEraserClearValue();
|
||||
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared);
|
||||
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared);
|
||||
}
|
||||
|
||||
fn getEraserMaskValue() -> vec4<f32> {
|
||||
|
|
@ -124,10 +88,6 @@ fn distanceSquaredFromLine(
|
|||
) -> f32 {
|
||||
let pa = position - start;
|
||||
|
||||
if inverseLengthSquared <= 0.0 {
|
||||
return dot(pa, pa);
|
||||
}
|
||||
|
||||
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
|
||||
let nearestOffset = pa - direction * q;
|
||||
return dot(nearestOffset, nearestOffset);
|
||||
|
|
@ -152,14 +112,13 @@ fn segment_vertex_position(
|
|||
}
|
||||
|
||||
fn segment_vertex_corner(index: u32) -> vec2<f32> {
|
||||
if index == 0u {
|
||||
return vec2<f32>(-1.0, 1.0);
|
||||
}
|
||||
if index == 1u || index == 3u {
|
||||
return vec2<f32>(-1.0, -1.0);
|
||||
}
|
||||
if index == 2u || index == 4u {
|
||||
return vec2<f32>(1.0, 1.0);
|
||||
}
|
||||
return vec2<f32>(1.0, -1.0);
|
||||
let corners = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(1.0, 1.0),
|
||||
vec2<f32>(1.0, -1.0),
|
||||
);
|
||||
return corners[index];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export class RenderPipeline {
|
|||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly pipeline: GPURenderPipeline;
|
||||
private readonly noSourcePipeline: GPURenderPipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
|
|
@ -36,6 +37,7 @@ export class RenderPipeline {
|
|||
|
||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||
this.pipeline = this.createPipeline(format, vertex, 'fragment');
|
||||
this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource');
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -112,7 +114,8 @@ export class RenderPipeline {
|
|||
public execute(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
colorTexture: GPUTextureView,
|
||||
sourceTexture: GPUTextureView
|
||||
sourceTexture: GPUTextureView,
|
||||
useSourceTexture = true
|
||||
): GPUTexture {
|
||||
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
|
||||
const canvasTexture = this.context.getCurrentTexture();
|
||||
|
|
@ -128,7 +131,7 @@ export class RenderPipeline {
|
|||
],
|
||||
};
|
||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setPipeline(useSourceTexture ? this.pipeline : this.noSourcePipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, bindGroup);
|
||||
passEncoder.draw(3, 1);
|
||||
|
|
|
|||
|
|
@ -27,9 +27,18 @@ fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
|||
return renderColor(traces, sources, pixel);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let pixel = vec2<i32>(position.xy);
|
||||
let traces = textureLoad(trailMap, pixel, 0);
|
||||
return renderColor(traces, vec4<f32>(0.0), pixel);
|
||||
}
|
||||
|
||||
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
|
||||
let background = getTexturedBackground(pixel);
|
||||
if max(max(max(traces.r, traces.g), traces.b), max(max(sources.r, sources.g), sources.b)) <= 0.0 {
|
||||
let tracesMax = maxComponent(traces.rgb);
|
||||
let sourcesMax = maxComponent(sources.rgb);
|
||||
if max(tracesMax, sourcesMax) <= 0.0 {
|
||||
return vec4(background, 1);
|
||||
}
|
||||
|
||||
|
|
@ -38,13 +47,13 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
|
|||
clarity(traces.g),
|
||||
clarity(traces.b)
|
||||
);
|
||||
if max(max(sources.r, sources.g), sources.b) <= 0.0 {
|
||||
if sourcesMax <= 0.0 {
|
||||
let traceColor =
|
||||
traceStrengths.r * settings.colorA
|
||||
+ traceStrengths.g * settings.colorB
|
||||
+ traceStrengths.b * settings.colorC;
|
||||
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
||||
let traceStrength = max(max(traceStrengths.r, traceStrengths.g), traceStrengths.b);
|
||||
let traceStrength = maxComponent(traceStrengths);
|
||||
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +73,7 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
|
|||
+ sourceStrengths.g * settings.colorB
|
||||
+ sourceStrengths.b * settings.colorC;
|
||||
let normalizedBrushColor = normalizeColorIntensity(brushColor);
|
||||
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
|
||||
let brushStrength = maxComponent(sourceStrengths);
|
||||
let brushVisibility = clamp(
|
||||
brushStrength * (
|
||||
settings.brushColorBase +
|
||||
|
|
@ -75,20 +84,27 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
|
|||
);
|
||||
let color = max(normalizedTraceColor, normalizedBrushColor);
|
||||
|
||||
let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility);
|
||||
let strength = max(maxComponent(strengths), brushVisibility);
|
||||
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||
}
|
||||
|
||||
fn maxComponent(v: vec3<f32>) -> f32 {
|
||||
return max(max(v.r, v.g), v.b);
|
||||
}
|
||||
|
||||
fn clarity(strength: f32) -> f32 {
|
||||
return pow(clamp(strength, 0, 1), settings.clarity);
|
||||
}
|
||||
|
||||
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||
let brightestChannel = max(max(color.r, color.g), color.b);
|
||||
let brightestChannel = maxComponent(color);
|
||||
return color / max(settings.traceNormalizationFloor, brightestChannel);
|
||||
}
|
||||
|
||||
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
|
||||
if settings.backgroundGrainStrength == 0.0 {
|
||||
return clamp(settings.backgroundColor, vec3(0), vec3(1));
|
||||
}
|
||||
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
|
||||
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,6 @@ export const settings: GardenRuntimeSettings = {
|
|||
...buildSettings(activeVibe),
|
||||
};
|
||||
|
||||
export const resetSettings = (): GardenRuntimeSettings => {
|
||||
Object.assign(settings, buildSettings(activeVibe));
|
||||
return settings;
|
||||
};
|
||||
|
||||
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||
activeVibe = vibe;
|
||||
Object.assign(settings, {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const initializeContext = ({
|
|||
device: device,
|
||||
format: gpu.getPreferredCanvasFormat(),
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||
alphaMode: 'premultiplied',
|
||||
alphaMode: 'opaque',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RuntimeError(
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ export const generateNoise = ({
|
|||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4(
|
||||
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
|
||||
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[1]}),
|
||||
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[2]}),
|
||||
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[3]}),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
}`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,5 +11,32 @@ const clampRgbChannel = (value: number): number =>
|
|||
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
|
||||
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
|
||||
|
||||
export const rgbColorToHex = ([red, green, blue]: RgbColor): string =>
|
||||
`#${[red, green, blue]
|
||||
.map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`;
|
||||
|
||||
export const hexColorToRgbColor = (value: string): RgbColor | null => {
|
||||
const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorthandOrHex = match[1];
|
||||
const hex =
|
||||
shorthandOrHex.length === 3
|
||||
? shorthandOrHex
|
||||
.split('')
|
||||
.map((channel) => `${channel}${channel}`)
|
||||
.join('')
|
||||
: shorthandOrHex;
|
||||
|
||||
return [
|
||||
Number.parseInt(hex.slice(0, 2), 16),
|
||||
Number.parseInt(hex.slice(2, 4), 16),
|
||||
Number.parseInt(hex.slice(4, 6), 16),
|
||||
];
|
||||
};
|
||||
|
||||
export const rgbChannelToUnit = (value: number): number =>
|
||||
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export type { VibePreset } from './config';
|
|||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||
|
||||
export const isVibeId = (value: unknown): value is VibeId =>
|
||||
const isVibeId = (value: unknown): value is VibeId =>
|
||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue