This commit is contained in:
Andras Schmelczer 2026-05-19 21:03:53 +01:00
parent ea0304356f
commit 7c70f15e49
65 changed files with 1127 additions and 1911 deletions

View file

@ -2,7 +2,7 @@
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
draw persistent coloured paths, spawn agents from those strokes, erase locally,
and export the scene as a 4K wallpaper.
and export the scene as an internal render buffer snapshot.
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).

View file

@ -68,6 +68,10 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
}, canvasName);
await page.goto('/');
const startButton = page.getByRole('button', { name: 'Start' });
await expect(startButton).toBeVisible();
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});

View file

@ -95,7 +95,7 @@
<p>
Switch vibes to recolour the whole garden without clearing your drawing. Add
or mute the generated piano, restart for a blank canvas, or export the current
frame as a 4K image.
frame as an internal buffer snapshot.
</p>
<p>
Built with WebGPU and running locally in your browser. Source on
@ -185,8 +185,8 @@
</div>
<button
class="export-4k"
aria-label="Download 4K upscale image"
title="Download 4K upscale of the live simulation"
aria-label="Download internal buffer snapshot"
title="Download internal buffer snapshot"
></button>
<span class="export-status" aria-live="polite"></span>
<button

View file

@ -60,7 +60,7 @@ export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
track('Export', {
props: {
format: 'png',
resolution: '4k',
resolution: 'internal-buffer',
vibeId,
},
});

View file

@ -1,38 +0,0 @@
import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
interface AudioPanNode {
input: AudioNode;
output: AudioNode;
disconnect: () => void;
}
type AudioContextWithOptionalPanner = AudioContext & {
createStereoPanner?: () => StereoPannerNode;
};
export const createAudioPanNode = (
context: AudioContext,
pan: number,
startTime: number
): AudioPanNode => {
const createStereoPanner = (context as AudioContextWithOptionalPanner)
.createStereoPanner;
if (!isIosLike() && typeof createStereoPanner === 'function') {
const panner = createStereoPanner.call(context);
panner.pan.setValueAtTime(clamp(pan, -1, 1), startTime);
return {
input: panner,
output: panner,
disconnect: () => panner.disconnect(),
};
}
const passthrough = context.createGain();
return {
input: passthrough,
output: passthrough,
disconnect: () => passthrough.disconnect(),
};
};

View file

@ -1,3 +0,0 @@
export const isIosLike = (): boolean =>
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

View file

@ -1,4 +1,3 @@
import { appConfig } from '../config';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
@ -86,5 +85,3 @@ export interface GardenAudioConfig {
maniaSmoothingSeconds: number;
};
}
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

@ -19,20 +19,14 @@ export class GardenAudioEnergy {
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
public recordStroke(strokeEnergy: number): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(
this.energy,
energy * this.config.energy.immediateActivityScale
strokeEnergy * this.config.energy.immediateActivityScale
);
}
this.lastEnergyUpdateAt ||= now;
}
public recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
@ -46,7 +40,7 @@ export class GardenAudioEnergy {
return;
}
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
const elapsedSeconds = now - this.lastEnergyUpdateAt;
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.config.energy.strokeDecaySeconds
@ -62,14 +56,6 @@ export class GardenAudioEnergy {
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return this.getLevel();
}
public getLevel(): number {
return clamp01(this.energy);
}

View file

@ -8,28 +8,17 @@ interface GardenAudioGestureFrame {
}
export class GardenAudioGestureState {
private gestureClockSeconds = 0;
private activity = 0;
private maniaAmount = 0;
private isManic = false;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public beginGesture(): void {
this.reset();
}
public endGesture(): void {
this.reset();
}
public recordStroke({
metrics,
}: {
metrics: GardenAudioStrokeMetrics;
}): GardenAudioGestureFrame {
this.gestureClockSeconds += metrics.elapsedSeconds;
const targetActivity = this.getTargetActivity(metrics);
const activityTimeConstant =
targetActivity > this.activity
@ -65,29 +54,21 @@ export class GardenAudioGestureState {
}
public reset(): void {
this.gestureClockSeconds = 0;
this.activity = 0;
this.maniaAmount = 0;
this.isManic = false;
}
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
const normalizedSpeed = Math.max(0, metrics.normalizedSpeed);
const activeSpeed = Math.max(
this.inputConfig.activityNoiseFloorSpeed,
this.inputConfig.fullActivitySpeed
);
const speedRange =
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
const speedAmount = clamp01(
(normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) /
(activeSpeed - this.inputConfig.activityNoiseFloorSpeed)
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
);
const distanceAmount = clamp01(
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
);
const activity = Math.pow(
speedAmount,
Math.max(0.001, this.inputConfig.activityCurve)
);
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}

View file

@ -1,9 +1,13 @@
import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
import type { GardenAudioConfig, GardenAudioVibeProfile } 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' };
};
const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: 0.0001,
closeRampSeconds: 0.015,
@ -13,12 +17,6 @@ const graphTuning = {
noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType,
noiseBufferChannels: 1,
noiseBufferDurationSeconds: 1,
unlockTickFrequencyHz: 440,
unlockTickGain: 0.0001,
unlockTickSeconds: 0.025,
unlockTickType: 'sine' as OscillatorType,
compressor: {
thresholdDb: -18,
kneeDb: 18,
@ -44,8 +42,10 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
private mediaStreamElement: HTMLAudioElement | null = null;
private lastPianoBusActivity = 0;
private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {}
@ -64,20 +64,20 @@ export class GardenAudioGraph {
return null;
}
let context: AudioContext;
try {
context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
} catch {
context = new AudioContextConstructor();
// Tells iOS to treat this as media playback, so the hardware ringer/mute
// switch does not silence Web Audio output. No-op on browsers without the
// Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = 'playback';
}
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
const mediaStreamDestination = isIosLike()
? context.createMediaStreamDestination()
: null;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
@ -90,15 +90,10 @@ export class GardenAudioGraph {
masterGain.connect(highPass);
highPass.connect(compressor);
if (mediaStreamDestination) {
compressor.connect(mediaStreamDestination);
} else {
compressor.connect(context.destination);
}
compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.mediaStreamDestination = mediaStreamDestination;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
@ -106,35 +101,6 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit can report "running" while the hardware output is still silent.
// A very short, nearly inaudible oscillator in the gesture stack is more
// reliable than a fully silent buffer on recent Safari versions.
public unlock(): void {
if (!this.context) {
return;
}
const now = this.context.currentTime;
const source = this.context.createOscillator();
const gain = this.context.createGain();
source.type = graphTuning.unlockTickType;
source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
source.stop(now + graphTuning.unlockTickSeconds);
source.addEventListener(
'ended',
() => {
source.disconnect();
gain.disconnect();
},
{ once: true }
);
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@ -147,38 +113,6 @@ export class GardenAudioGraph {
);
}
public startMediaElementOutput(): void {
if (!this.mediaStreamDestination) {
return;
}
const mediaElement = this.ensureMediaStreamElement();
const playPromise = mediaElement.play();
void playPromise?.catch(() => undefined);
}
private ensureMediaStreamElement(): HTMLAudioElement {
if (this.mediaStreamElement) {
return this.mediaStreamElement;
}
const mediaElement = document.createElement('audio');
mediaElement.autoplay = true;
mediaElement.volume = 1;
mediaElement.setAttribute('playsinline', '');
mediaElement.setAttribute('aria-hidden', 'true');
mediaElement.style.position = 'fixed';
mediaElement.style.width = '1px';
mediaElement.style.height = '1px';
mediaElement.style.opacity = '0';
mediaElement.style.pointerEvents = 'none';
mediaElement.style.left = '-9999px';
mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
document.body.append(mediaElement);
this.mediaStreamElement = mediaElement;
return mediaElement;
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) {
return;
@ -228,6 +162,20 @@ export class GardenAudioGraph {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
}
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
if (!this.context) {
this.pianoBusGainScale = clamp(targetScale, 0, 1);
return;
}
const now = this.context.currentTime;
this.pianoBusGainScale = clamp(targetScale, 0, 1);
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
}
public async close(): Promise<void> {
const context = this.context;
if (!context) {
@ -304,27 +252,33 @@ export class GardenAudioGraph {
this.noiseBus.connect(eventBus);
}
private updatePianoBusGains(activity: number, now: number): void {
private updatePianoBusGains(
activity: number,
now: number,
timeConstantSeconds?: number
): void {
const effectiveTimeConstantSeconds =
timeConstantSeconds ??
(now < this.pianoBusGainScaleAutomationUntil
? this.pianoBusGainScaleTimeConstantSeconds
: this.config.updateRampSeconds);
this.lastPianoBusActivity = activity;
this.pianoBuses.forEach((bus, role) => {
const baseGain = this.config.graph.pianoBusGains[role];
const ducking = this.config.graph.pianoBusActivityDucking[role];
bus.gain.setTargetAtTime(
Math.max(0, baseGain * (1 - activity * ducking)),
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now,
this.config.updateRampSeconds
effectiveTimeConstantSeconds
);
});
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
appPositiveInteger(graphTuning.noiseBufferChannels),
Math.max(
1,
Math.floor(
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
)
),
1,
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate
);
const data = buffer.getChannelData(0);
@ -348,16 +302,10 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.mediaStreamDestination = null;
if (this.mediaStreamElement) {
this.mediaStreamElement.pause();
this.mediaStreamElement.srcObject = null;
this.mediaStreamElement.remove();
this.mediaStreamElement = null;
}
this.lastPianoBusActivity = 0;
this.pianoBusGainScale = 1;
this.pianoBusGainScaleAutomationUntil = 0;
this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}
const appPositiveInteger = (value: number): number =>
Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));

View file

@ -1,11 +1,8 @@
import type { GardenAudioStroke } from './garden-audio-types';
const fallbackNormalizationPixels = 1000;
const fallbackFrameSeconds = 1 / 60;
const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
distancePixels: number;
elapsedSeconds: number;
normalizedDistance: number;
normalizedSpeed: number;
@ -15,42 +12,13 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = getElapsedSeconds(stroke);
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
const normalizedDistance = distancePixels / normalizationPixels;
return {
distancePixels,
elapsedSeconds,
normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds,
};
};
const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
if (
stroke.elapsedSeconds !== undefined &&
Number.isFinite(stroke.elapsedSeconds) &&
stroke.elapsedSeconds > 0
) {
return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
}
return fallbackFrameSeconds;
};
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
const width = stroke.canvasSize?.[0];
const height = stroke.canvasSize?.[1];
if (
width !== undefined &&
height !== undefined &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
return Math.max(1, Math.min(width, height));
}
return fallbackNormalizationPixels;
};

View file

@ -1,36 +1,6 @@
import type { VibePreset } from '../vibes';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
import type { GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
const chordVoicings = {
majorOpen: [0, 7, 12, 16],
minorOpen: [0, 7, 12, 15],
majorClosed: [0, 4, 7, 12, 16],
minorClosed: [0, 3, 7, 12, 15],
};
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
export const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
}
return chord.quality === 'major'
? chordVoicings.majorClosed
: chordVoicings.minorClosed;
};
export 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);
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
};

View file

@ -1,3 +0,0 @@
export const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
export const GENERATIVE_START_DELAY_SECONDS = 0.02;
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;

View file

@ -9,9 +9,9 @@ export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize?: ArrayLike<number>;
canvasSize: ArrayLike<number>;
isErasing: boolean;
elapsedSeconds?: number;
elapsedSeconds: number;
}
export interface GardenAudioStartOptions {
@ -23,12 +23,6 @@ export interface LoadedPianoSample {
buffer: AudioBuffer;
}
export interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
export interface PianoNote {
midi: number;
velocity: number;

View file

@ -16,18 +16,12 @@ import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
const muteGain = 0.0001;
const muteRampSeconds = 0.02;
const brushUpPianoFinishSeconds = 1.2;
const brushUpPianoFadeSeconds = 1.1;
const brushUpPianoBusFadeSeconds = 2.4;
const brushUpPianoBusFadeSettleSeconds = 3.2;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
@ -40,12 +34,12 @@ export class GardenAudio {
private currentVibeId: VibeId | null = null;
private lifecycle: AudioLifecycle = 'idle';
private isReleasingPiano = false;
private isMuted = false;
private isGestureActive = false;
private isPianoStoppedAfterGesture = false;
private fadePianoAfter: number | null = null;
private fadePianoAt: number | null = null;
private masterVolume: number;
private stopPianoAfter: number | null = null;
private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -75,33 +69,16 @@ export class GardenAudio {
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise<void> | null = null;
if (isUserGesture) {
this.graph.startMediaElementOutput();
this.graph.unlock();
}
if (needsResume) {
if (!isUserGesture) {
return;
}
resumePromise = context.resume();
}
if (isUserGesture) {
this.graph.unlock();
}
if (resumePromise) {
void resumePromise
void context
.resume()
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.graph.unlock();
this.completeStart(vibe, {
context,
startupRampSeconds,
});
this.completeStart(vibe, { context, startupRampSeconds });
}
})
.catch((error) => {
@ -113,10 +90,7 @@ export class GardenAudio {
return;
}
this.completeStart(vibe, {
context,
startupRampSeconds,
});
this.completeStart(vibe, { context, startupRampSeconds });
}
private completeStart(
@ -207,23 +181,21 @@ export class GardenAudio {
}
this.isGestureActive = true;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.gestureState.beginGesture();
this.isReleasingPiano = false;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
this.gestureState.reset();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.endGesture();
this.gestureState.reset();
this.isGestureActive = false;
const context = this.graph.context;
this.isPianoStoppedAfterGesture = true;
this.fadePianoAfter = context
? context.currentTime + brushUpPianoFinishSeconds
: null;
this.stopPianoAfter = null;
this.isReleasingPiano = true;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.energy.endGesture();
this.pianoEngine.endGesture();
}
@ -241,17 +213,8 @@ export class GardenAudio {
this.energy.silence();
}
if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
this.piano.fadeAll(brushUpPianoFadeSeconds);
this.fadePianoAfter = null;
this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
}
if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
this.piano.stopAll();
this.pianoEngine.reset();
this.stopPianoAfter = null;
}
if (!this.isGestureActive && this.isReleasingPiano) {
this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot);
return;
}
@ -287,12 +250,11 @@ export class GardenAudio {
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.energy.recordEraserStroke();
this.playEraser(strokeEnergy, now);
return;
}
this.energy.recordStroke(strokeEnergy, now);
this.energy.recordStroke(strokeEnergy);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
@ -311,9 +273,9 @@ export class GardenAudio {
this.pianoEngine.reset();
this.currentVibeId = null;
this.isGestureActive = false;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.isReleasingPiano = false;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@ -333,6 +295,25 @@ export class GardenAudio {
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private updatePianoRelease(vibe: VibePreset, now: number): void {
if (this.fadePianoAt === null && this.stopPianoAt === null) {
this.fadePianoAt = this.pianoEngine.release(vibe, now);
}
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.fadePianoAt = null;
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
}
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
this.piano.stopAll();
this.pianoEngine.reset();
this.stopPianoAt = null;
this.isReleasingPiano = false;
}
}
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;

View file

@ -23,6 +23,16 @@ interface GenerativePianoTuning {
pans: [number, number, number];
delaySends: [number, number, number];
lowpassExpression: number;
noteDurationSeconds: number;
spacingSeconds: number;
};
releaseResolution: {
durationSeconds: number;
fadeAfterSeconds: number;
velocities: [number, number, number];
delaySend: number;
lowpassExpression: number;
strumSeconds: number;
};
highActivityExtra: {
barOffset: number;
@ -67,7 +77,6 @@ interface GenerativePianoTuning {
delaySend: number;
};
touchNote: {
registerBiasManiaAmount: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
@ -80,7 +89,6 @@ interface GenerativePianoTuning {
initialMotifOffset: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
fadeMinimumLifetimeSeconds: number;
layerIntensityBase: number;
layerIntensityManiaWeight: number;
frameActivityWeight: number;
@ -106,7 +114,6 @@ interface GenerativePianoTuning {
lowpassBaseExpression: number;
lowpassIntensityWeight: number;
lowpassManiaWeight: number;
manicThreshold: number;
intenseThreshold: number;
activeThreshold: number;
};
@ -133,8 +140,6 @@ interface GenerativePianoTuning {
highOffset: number;
mediumOffset: number;
lowOffset: number;
minOffset: number;
maxOffset: number;
};
registerBias: {
maniaShiftSemitones: number;
@ -171,8 +176,6 @@ interface GenerativePianoTuning {
gestureAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
stingerDurationSeconds: number;
stingerSpacingSeconds: number;
maxBrushPhraseLayers: number;
maxBrushStreamNotesPerBar: number;
brushLayerBaseSeconds: number;
@ -181,7 +184,6 @@ interface GenerativePianoTuning {
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
brushStreamManicIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
@ -236,6 +238,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
pans: [-0.16, 0, 0.16],
delaySends: [0.012, 0.014, 0.016],
lowpassExpression: 0.35,
noteDurationSeconds: 1.1,
spacingSeconds: 0.08,
},
releaseResolution: {
durationSeconds: 3.4,
fadeAfterSeconds: 2.4,
velocities: [0.064, 0.05, 0.038],
delaySend: 0.018,
lowpassExpression: 0.34,
strumSeconds: 0.055,
},
highActivityExtra: {
barOffset: 1,
@ -284,7 +296,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
delaySend: 0.012,
},
touchNote: {
registerBiasManiaAmount: 0,
velocityBase: 0.14,
velocityStrengthWeight: 0.11,
durationBaseSeconds: 0.55,
@ -297,7 +308,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
initialMotifOffset: -1,
energyDecaySeconds: 0.72,
maniaDecaySeconds: 0.54,
fadeMinimumLifetimeSeconds: 0.001,
layerIntensityBase: 0.8,
layerIntensityManiaWeight: 0.42,
frameActivityWeight: 0.42,
@ -323,7 +333,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18,
manicThreshold: 0.85,
intenseThreshold: 0.62,
activeThreshold: 0.34,
},
@ -350,8 +359,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
highOffset: 1,
mediumOffset: 0,
lowOffset: -1,
minOffset: -3,
maxOffset: 4,
},
registerBias: {
maniaShiftSemitones: 4,
@ -388,8 +395,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58,
stingerSpacingSeconds: 0.08,
stingerDurationSeconds: 1.1,
maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 9,
brushLayerBaseSeconds: 5.5,
@ -398,7 +403,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.5,
brushStreamManicIntervalBeats: 0.5,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46,

View file

@ -5,17 +5,7 @@ import type {
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
degreeToSemitone,
getChordIntervals,
getVibeProfile,
PITCH_SEMITONES_PER_OCTAVE,
} from './garden-audio-music';
import {
GENERATIVE_LOOKAHEAD_SECONDS,
GENERATIVE_START_DELAY_SECONDS,
PIANO_SCHEDULE_AHEAD_SECONDS,
} from './garden-audio-scheduling';
import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { PianoNote } from './garden-audio-types';
import {
generativePianoTuning,
@ -23,6 +13,39 @@ import {
type GardenAudioRegister,
type GardenAudioStylePool,
} from './generative-piano-tuning';
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
const GENERATIVE_START_DELAY_SECONDS = 0.02;
const chordVoicings = {
majorOpen: [0, 7, 12, 16],
minorOpen: [0, 7, 12, 15],
majorClosed: [0, 4, 7, 12, 16],
minorClosed: [0, 3, 7, 12, 15],
};
const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
}
return chord.quality === 'major'
? chordVoicings.majorClosed
: chordVoicings.minorClosed;
};
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);
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
};
type GardenAudioStyleIndex = 0 | 1 | 2;
@ -124,6 +147,23 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
}
public release(vibe: VibePreset, now: number): number {
this.prime(now);
this.isWaitingForGestureAccent = false;
const profile = getVibeProfile(vibe);
const releaseStep = this.getReleaseResolutionStep(now);
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
this.playReleaseResolution(profile, releaseStep, releaseStart);
this.nextBeatStep = null;
this.nextBrushStreamStep = null;
this.brushPhraseLayers = [];
this.brushStreamNoteCountsByBar.clear();
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
}
private recordTouchDown({
vibe,
now,
@ -234,47 +274,27 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [0] },
this.generation.padRegisters[0]
),
velocity: this.generation.vibeChangeStinger.velocities[0],
pan: this.generation.vibeChangeStinger.pans[0],
delaySend: this.generation.vibeChangeStinger.delaySends[0],
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
this.generation.padRegisters[1]
),
velocity: this.generation.vibeChangeStinger.velocities[1],
pan: this.generation.vibeChangeStinger.pans[1],
delaySend: this.generation.vibeChangeStinger.delaySends[1],
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
this.generation.padRegisters[2]
),
velocity: this.generation.vibeChangeStinger.velocities[2],
pan: this.generation.vibeChangeStinger.pans[2],
delaySend: this.generation.vibeChangeStinger.delaySends[2],
},
const stinger = this.generation.vibeChangeStinger;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
[intervals[3], intervals[2]],
];
notes.forEach((note, index) => {
offsetsByVoice.forEach((offsets, index) => {
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
);
this.playNote({
...note,
durationSeconds: this.generation.stingerDurationSeconds,
midi,
velocity: stinger.velocities[index],
pan: stinger.pans[index],
delaySend: stinger.delaySends[index],
durationSeconds: stinger.noteDurationSeconds,
role: 'stinger',
lowpassHz: this.getLowpassHz(
profile,
note.midi,
this.generation.vibeChangeStinger.lowpassExpression
),
startTime: now + index * this.generation.stingerSpacingSeconds,
lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
startTime: now + index * stinger.spacingSeconds,
});
});
}
@ -285,12 +305,8 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
this.lastMidiByStyle[0] = null;
this.lastMidiByStyle[1] = null;
this.lastMidiByStyle[2] = null;
this.lastPadMidiByVoice[0] = null;
this.lastPadMidiByVoice[1] = null;
this.lastPadMidiByVoice[2] = null;
this.lastMidiByStyle.fill(null);
this.lastPadMidiByVoice.fill(null);
this.brushPhraseLayers = [];
this.nextBrushStreamStep = null;
this.brushStreamNoteIndex = 0;
@ -400,6 +416,37 @@ export class GenerativePianoEngine {
});
}
private playReleaseResolution(
profile: GardenAudioVibeProfile,
stepIndex: number,
startTime: number
): void {
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const release = this.generation.releaseResolution;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
[intervals[3], intervals[2]],
];
offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index];
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
this.playNote({
midi,
velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds,
durationSeconds: release.durationSeconds,
pan: register.pan,
role: 'pad',
delaySend: release.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
});
});
}
private playSupportNote(
profile: GardenAudioVibeProfile,
barIndex: number,
@ -546,10 +593,6 @@ export class GenerativePianoEngine {
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const register = this.getBiasedRegister(
pool,
this.generation.touchNote.registerBiasManiaAmount
);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -558,7 +601,7 @@ export class GenerativePianoEngine {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
register,
pool,
this.lastMidiByStyle[styleIndex],
true
);
@ -877,41 +920,26 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats =
intensity >= this.generation.brushStream.manicThreshold
? this.generation.brushStreamManicIntervalBeats
: intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
}
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt);
const ageSeconds = startTime - layer.startedAt;
return clamp01(
1 -
ageSeconds /
Math.max(
this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
lifetimeSeconds
)
);
return clamp01(1 - ageSeconds / lifetimeSeconds);
}
private getMotifOffset(strength: number): number {
const energyStep =
strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
return clamp(
energyStep,
this.generation.brushMotif.minOffset,
this.generation.brushMotif.maxOffset
);
return strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
}
private getBrushMotifDegrees({
@ -1185,6 +1213,19 @@ export class GenerativePianoEngine {
);
}
private getReleaseResolutionStep(startTime: number): number {
const currentStep = this.getStepIndexAtTime(startTime);
const nextBeatStep =
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) *
this.config.rhythm.stepsPerBeat;
const nextBarStep =
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) *
this.config.rhythm.stepsPerBar;
const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2;
return barIsClose ? nextBarStep : nextBeatStep;
}
private getNextBarTimeAt(startTime: number): number {
const nextBarStep =
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) *

View file

@ -1,4 +1,4 @@
import { createAudioPanNode } from './audio-pan-node';
import { clamp } from '../utils/math';
import type { GardenAudioGraph } from './garden-audio-graph';
import type { NoiseBurst } from './garden-audio-types';
@ -15,9 +15,8 @@ export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
const outputBus = noiseBus ?? eventBus;
if (!context || !outputBus || !noiseBuffer) {
const { context, noiseBus, noiseBuffer } = this.graph;
if (!context || !noiseBus || !noiseBuffer) {
return;
}
@ -28,7 +27,7 @@ export class NoiseBurstPlayer {
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
@ -41,10 +40,11 @@ export class NoiseBurstPlayer {
scheduledStart + noiseBurstTuning.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panNode.input);
panNode.output.connect(outputBus);
envelope.connect(panner);
panner.connect(noiseBus);
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
source.stop(stopAt);
source.addEventListener(
@ -53,7 +53,7 @@ export class NoiseBurstPlayer {
source.disconnect();
filter.disconnect();
envelope.disconnect();
panNode.disconnect();
panner.disconnect();
},
{ once: true }
);

View file

@ -1,18 +1,20 @@
import { clamp, clamp01 } from '../utils/math';
import { createAudioPanNode } from './audio-pan-node';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
import type {
ActivePianoVoice,
LoadedPianoSample,
PianoNote,
} from './garden-audio-types';
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
type PianoLoadState = 'idle' | 'loading' | 'loaded';
interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType,
filterQ: 0.7,
@ -29,7 +31,6 @@ const pianoSamplerTuning = {
export class PianoSampler {
private loadState: PianoLoadState = 'idle';
private sampleLoadPromise: Promise<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
@ -38,9 +39,9 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.loadState === 'loaded') {
return Promise.resolve();
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
if (this.loadState !== 'idle') {
return null;
}
const loadedSamples = getLoadedPianoSamples();
@ -50,33 +51,16 @@ export class PianoSampler {
return Promise.resolve();
}
if (this.sampleLoadPromise) {
return this.sampleLoadPromise;
}
this.loadState = 'loading';
this.sampleLoadPromise = loadPianoSamples(context, undefined, {
forceReload: true,
})
return loadPianoSamples(context)
.then((samples) => {
this.setSamples(samples);
this.loadState = 'loaded';
})
.catch((error) => {
this.loadState = 'idle';
this.sampleLoadPromise = null;
throw error;
});
return this.sampleLoadPromise;
}
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
if (this.loadState !== 'idle') {
return null;
}
return this.load(context);
}
public play({
@ -89,115 +73,114 @@ export class PianoSampler {
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, delayInput } = this.graph;
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
this.playSynthFallback({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend,
lowpassHz,
});
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const sample = this.findNearestSample(midi);
if (sample) {
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createBufferSource();
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope: (gain) => {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
});
return;
}
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
let sendGain: GainNode | null = null;
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator();
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (oldest) {
this.stopVoice(oldest, scheduledStart);
}
}
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
source.connect(filter);
filter.connect(gain);
gain.connect(panNode.input);
panNode.output.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panNode.output.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panNode.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope: (gain) => {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
{ once: true }
);
});
}
public stopAll(): void {
@ -215,114 +198,64 @@ export class PianoSampler {
this.activeVoices = [];
}
public fadeAll(fadeSeconds: number): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
this.trimActiveVoices(now);
this.activeVoices.forEach((voice) => {
this.fadeVoice(voice, now, fadeDurationSeconds);
});
}
public reset(): void {
this.loadState = 'idle';
this.sampleLoadPromise = null;
this.samples = [];
this.activeVoices = [];
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private playSynthFallback({
midi,
velocity,
startTime,
durationSeconds,
private scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope,
}: {
source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
pan: number;
lowpassHz: number;
delaySend: number;
eventBus: GainNode;
configureGainEnvelope: (gain: GainNode) => void;
}): void {
const { context, delayInput } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
if (!context) {
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (oldest) {
this.stopVoice(oldest, scheduledStart);
}
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
}
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
gain.connect(panNode.input);
panNode.output.connect(eventBus);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panNode.output.connect(sendGain);
panner.connect(sendGain);
sendGain.connect(delayInput);
}
@ -336,7 +269,7 @@ export class PianoSampler {
source.disconnect();
filter.disconnect();
gain.disconnect();
panNode.disconnect();
panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
@ -344,6 +277,16 @@ export class PianoSampler {
);
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
@ -358,32 +301,7 @@ export class PianoSampler {
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt);
} catch {
// The voice may already have ended; either way it is no longer part of the mix.
}
}
private fadeVoice(
voice: ActivePianoVoice,
now: number,
fadeDurationSeconds: number
): void {
const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
now,
Math.max(0.001, fadeDurationSeconds / 4)
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
} catch {
// The voice may already have ended; either way it is fading out of the mix.
}
voice.source.stop(stopAt);
}
private setSamples(samples: Array<LoadedPianoSample>): void {

View file

@ -8,7 +8,6 @@ interface PianoSampleDefinition {
export interface PianoSampleLoadProgress {
loadedCount: number;
totalCount: number;
sample?: PianoSampleDefinition;
}
const sampleFiles: Array<[fileName: string, midi: number]> = [
@ -45,11 +44,6 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
];
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
const preloadDecode = {
channels: 2,
frames: 128,
sampleRateHz: 48_000,
};
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
@ -61,10 +55,6 @@ const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
interface PianoSampleLoadOptions {
forceReload?: boolean;
}
const sampleLoadTuning = {
concurrency: 4,
sampleTimeoutMs: 15_000,
@ -81,18 +71,14 @@ export const preloadPianoSamples = (
);
}
const decodeContext = new OfflineAudioContextConstructor(
preloadDecode.channels,
preloadDecode.frames,
preloadDecode.sampleRateHz
);
// Decoding ignores these, but the constructor demands real numbers.
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void,
options: PianoSampleLoadOptions = {}
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
if (loadedPianoSamples) {
onProgress?.({
@ -102,7 +88,7 @@ export const loadPianoSamples = (
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise && options.forceReload !== true) {
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise;
}
@ -115,12 +101,12 @@ export const loadPianoSamples = (
async (sample) => {
try {
return await withTimeout(
loadPianoSample(decodeContext, sample),
(signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs
);
} finally {
loadedCount += 1;
onProgress?.({ loadedCount, totalCount, sample });
onProgress?.({ loadedCount, totalCount });
}
}
).then(
@ -147,15 +133,16 @@ export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition
sample: PianoSampleDefinition,
signal: AbortSignal
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url);
const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeAudioData(decodeContext, audioData);
const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
};
@ -178,13 +165,18 @@ const loadPianoSampleBatch = async (
return results;
};
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
const withTimeout = <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number
): Promise<T> =>
new Promise((resolve, reject) => {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
controller.abort();
reject(new Error('Timed out while loading a piano sample.'));
}, timeoutMs);
promise.then(
operation(controller.signal).then(
(value) => {
globalThis.clearTimeout(timeout);
resolve(value);
@ -195,12 +187,3 @@ const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
}
);
});
const decodeAudioData = (
decodeContext: BaseAudioContext,
audioData: ArrayBuffer
): Promise<AudioBuffer> =>
new Promise((resolve, reject) => {
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
decodePromise?.then(resolve, reject);
});

View file

@ -102,19 +102,13 @@ export const appConfig = {
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
export4k: {
exportSnapshot: {
bytesPerPixel: 4,
filenameExtension: 'png',
filenamePrefix: 'fleeting-garden',
filenameSuffix: '-upscale',
height: 2160,
jsHeapSafetyMultiplier: 1.5,
jsHeapTextureBytesMultiplier: 4,
lowMemoryDeviceGiB: 2,
lowMemoryExportFraction: 0.08,
filenameSuffix: '-snapshot',
mimeType: 'image/png',
rowAlignmentBytes: 256,
width: 3840,
},
menuHider: {
bottomRevealDistancePx: 96,
@ -148,17 +142,6 @@ export const appConfig = {
controls: runtimeControls,
},
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: 200_000,
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000,
adaptiveRefreshTargetFps: 60,
frameGapResetSeconds: 1,
fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
initialFps: 60,
},
brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,

View file

@ -6,9 +6,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
...colorInteractionSettings,
turnWhenLost: 0.8,
sourceAttractionWeight: 24,
sourceSlowMoveRate: 0.08,
sourceTrailWeightMultiplier: 16,
forwardRotationScale: 0.25,
introNearDistanceMin: 28,
introNearDistanceInner: 4,
@ -38,8 +35,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushSizeVariation: 0.5,
brushAlpha: 1,
brushFeatherRatio: 0.22,
brushMinimumFeather: 1,
brushDiscardThreshold: 0.02,
brushCoarseNoiseScale: 160,
brushGrainNoiseScale: 22,
@ -55,8 +50,9 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
eraserLineDistanceEpsilon: 0.0001,
eraserMaskAlphaThreshold: 0.5,
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000,
internalRenderAreaMegapixels: 8.3,
strokeSpawnSpreadBrushSizeMultiplier: 1,
maxAgentCount: 700_000,
renderTraceNormalizationFloor: 1,

View file

@ -35,18 +35,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
brushFeatherRatio: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.001,
},
brushMinimumFeather: {
folder: 'Brush',
min: 0,
max: 8,
step: 0.1,
},
brushDiscardThreshold: {
folder: 'Brush',
min: 0,
@ -255,6 +243,22 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
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,
@ -299,12 +303,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
strokeSpawnSpreadBrushSizeMultiplier: {
folder: 'Agent',
min: 0,
max: 4,
step: 0.01,
},
turnSpeed: {
folder: 'Agent',
min: 1,
@ -317,24 +315,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
sourceAttractionWeight: {
folder: 'Agent',
min: 0,
max: 64,
step: 0.1,
},
sourceSlowMoveRate: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
sourceTrailWeightMultiplier: {
folder: 'Agent',
min: 0,
max: 64,
step: 0.1,
},
forwardRotationScale: {
folder: 'Agent',
min: 0,

View file

@ -6,6 +6,7 @@ import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings';
import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig {
folder: string;
@ -18,6 +19,8 @@ export interface NumberControlConfig {
}
export type GardenRuntimeSettings = {
adaptiveCapInitial: number;
adaptiveCapMin: number;
brushCurveResolution: number;
brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number;
@ -37,7 +40,6 @@ export type GardenRuntimeSettings = {
maxAgentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
strokeSpawnSpreadBrushSizeMultiplier: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
@ -78,8 +80,8 @@ export enum VibeId {
export interface VibePreset {
id: VibeId;
name: string;
colors: [string, string, string];
backgroundColor: string;
colors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
@ -90,19 +92,13 @@ export interface GardenAppConfig {
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
export4k: {
exportSnapshot: {
bytesPerPixel: number;
filenameExtension: string;
filenamePrefix: string;
filenameSuffix: string;
height: number;
jsHeapSafetyMultiplier: number;
jsHeapTextureBytesMultiplier: number;
lowMemoryDeviceGiB: number;
lowMemoryExportFraction: number;
mimeType: string;
rowAlignmentBytes: number;
width: number;
};
menuHider: {
bottomRevealDistancePx: number;
@ -136,17 +132,6 @@ export interface GardenAppConfig {
controls: RuntimeSettingControlConfig;
};
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapInitial: number;
adaptiveCapMin: number;
adaptiveRefreshTargetFps: number;
frameGapResetSeconds: number;
fpsHeadroom: number;
fpsSmoothingNew: number;
fpsSmoothingRetain: number;
initialFps: number;
};
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;

View file

@ -27,8 +27,12 @@ export const vibePresets: Array<VibePreset> = [
{
id: VibeId.CandyRain,
name: 'Candy Rain',
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f',
colors: [
[255, 93, 162],
[54, 215, 208],
[255, 216, 77],
],
backgroundColor: [16, 21, 31],
settings: {
brushSize: 14,
clarity: 0.62,
@ -52,8 +56,12 @@ export const vibePresets: Array<VibePreset> = [
{
id: VibeId.SunlitMoss,
name: 'Sunlit Moss',
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016',
colors: [
[131, 212, 131],
[246, 215, 107],
[94, 193, 161],
],
backgroundColor: [23, 32, 22],
settings: {
brushSize: 16,
clarity: 0.68,
@ -82,8 +90,12 @@ export const vibePresets: Array<VibePreset> = [
{
id: VibeId.CoralTide,
name: 'Coral Tide',
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822',
colors: [
[255, 127, 110],
[64, 184, 255],
[244, 240, 166],
],
backgroundColor: [15, 24, 34],
settings: {
brushSize: 13,
clarity: 0.58,
@ -107,8 +119,12 @@ export const vibePresets: Array<VibePreset> = [
{
id: VibeId.MoonOrchid,
name: 'Moon Orchid',
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d',
colors: [
[201, 147, 255],
[125, 216, 255],
[240, 244, 255],
],
backgroundColor: [20, 18, 29],
settings: {
brushSize: 12,
clarity: 0.64,
@ -132,8 +148,12 @@ export const vibePresets: Array<VibePreset> = [
{
id: VibeId.PeachNeon,
name: 'Peach Neon',
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716',
colors: [
[255, 155, 115],
[91, 240, 169],
[110, 168, 255],
],
backgroundColor: [25, 23, 22],
settings: {
brushSize: 15,
clarity: 0.55,
@ -157,8 +177,12 @@ export const vibePresets: Array<VibePreset> = [
{
id: VibeId.FrostBloom,
name: 'Frost Bloom',
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820',
colors: [
[180, 247, 255],
[158, 200, 255],
[255, 184, 210],
],
backgroundColor: [16, 24, 32],
settings: {
brushSize: 18,
clarity: 0.7,

View file

@ -7,16 +7,20 @@ import {
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import type { FramePerformance } from './frame-performance';
import { createIntroTitleAgents } from './intro-title-agents';
export class AgentPopulation {
private activeCount = 0;
// Current performance-aware limit; new agents above it replace old agents.
private adaptiveCap: number;
// Next active agent slot to overwrite when new agents exceed the current cap.
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private pendingCompaction: Promise<void> | null = null;
// Highest active slot written while async compaction is running.
private postCompactionWriteEnd = 0;
private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
@ -24,11 +28,12 @@ export class AgentPopulation {
public constructor(
private readonly pipeline: AgentGenerationPipeline,
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
private readonly getCanvasPixelRatio = () => 1
private readonly introSeed: number,
private readonly getCanvasPixelRatio: () => number,
private readonly framePerformance: FramePerformance
) {
this.adaptiveCap = this.clampAdaptiveCap(
appConfig.simulation.budget.adaptiveCapInitial
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
this.framePerformance.adaptiveCapInitial
);
}
@ -41,7 +46,7 @@ export class AgentPopulation {
}
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min(
this.adaptiveCap,
appConfig.simulation.initialAgentCount
@ -65,14 +70,10 @@ export class AgentPopulation {
}
public onVibeChanged(): void {
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
public growBudget(deltaTime: number, smoothedFps: number): void {
this.updateAdaptiveCap(deltaTime, smoothedFps);
}
public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale);
}
@ -103,8 +104,7 @@ export class AgentPopulation {
this.activeCount,
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
);
this.replacementCursor =
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
this.clampReplacementCursor();
this.trimActiveCountToBudget();
})
.catch((error: unknown) => {
@ -121,10 +121,32 @@ export class AgentPopulation {
await this.pendingCompaction;
}
public updateAdaptiveCap(): void {
const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
if (this.canExpandAdaptiveCap) {
this.adaptiveCap = previousCap;
this.trimActiveCountToBudget();
return;
}
const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
const responsiveCap = Math.min(
previousCap,
this.clampAndEnsureAdaptiveCap(this.activeCount)
);
const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease);
}
public spawnStrokeAgents(from: vec2, to: vec2): void {
const deltaX = to[0] - from[0];
const deltaY = to[1] - from[1];
const length = Math.max(
appConfig.simulation.stroke.minSegmentLengthPx,
vec2.dist(from, to)
Math.hypot(deltaX, deltaY)
);
const count = Math.max(
appConfig.simulation.stroke.minAgentCount,
@ -136,8 +158,8 @@ export class AgentPopulation {
)
)
);
const direction = vec2.sub(vec2.create(), to, from);
const baseAngle = Math.atan2(direction[1], direction[0]);
const baseAngle = Math.atan2(deltaY, deltaX);
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
for (let i = 0; i < count; i++) {
const t = count === 1 ? 1 : i / (count - 1);
@ -147,10 +169,6 @@ export class AgentPopulation {
baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
const spread =
settings.brushSize *
getSafePixelRatio(this.getCanvasPixelRatio()) *
settings.strokeSpawnSpreadBrushSizeMultiplier;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 2] = angle;
@ -170,7 +188,7 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
@ -218,40 +236,15 @@ export class AgentPopulation {
);
}
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
this.canExpandAdaptiveCap =
smoothedFps >=
appConfig.simulation.budget.adaptiveRefreshTargetFps *
appConfig.simulation.budget.fpsHeadroom;
if (this.canExpandAdaptiveCap) {
this.adaptiveCap = previousCap;
this.trimActiveCountToBudget();
return;
}
const decrease = Math.max(
1,
Math.ceil(
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
)
);
const responsiveCap = Math.min(previousCap, this.clampAdaptiveCap(this.activeCount));
const nextCap = this.clampAdaptiveCap(responsiveCap - decrease);
this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease);
}
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available;
this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
@ -263,11 +256,15 @@ export class AgentPopulation {
this.adaptiveCap,
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
);
this.clampReplacementCursor();
}
private clampReplacementCursor(): void {
this.replacementCursor =
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
}
private clampAdaptiveCap(value: number): number {
private clampAndEnsureAdaptiveCap(value: number): number {
const runtimeMaxCap =
settings.maxAgentCount === Number.POSITIVE_INFINITY
? Number.POSITIVE_INFINITY
@ -275,7 +272,7 @@ export class AgentPopulation {
? Math.max(0, Math.floor(settings.maxAgentCount))
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
return Math.min(

View file

@ -1,193 +0,0 @@
import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes';
import {
estimateExport4KMemory,
getAspectFitExport4KDimensions,
getBrowserExportMemoryInfo,
getExport4KPreflightError,
} from './export-4k';
interface Export4KRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
statusElement: HTMLElement;
seed: string;
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
getVibeId: () => VibeId;
}
export class Export4KRenderer {
private isExporting = false;
public constructor(private readonly options: Export4KRendererOptions) {}
public async export(): Promise<void> {
if (this.isExporting) {
this.statusElement.textContent = '4K upscale already rendering...';
return;
}
this.isExporting = true;
this.statusElement.textContent = 'Rendering 4K upscale...';
try {
const sourceSize = this.options.getSourceSize();
const exportDimensions = getAspectFitExport4KDimensions(
sourceSize.width,
sourceSize.height
);
const estimate = estimateExport4KMemory(
exportDimensions.width,
exportDimensions.height
);
const preflightError = getExport4KPreflightError({
limits: this.device.limits,
memoryInfo: getBrowserExportMemoryInfo(),
estimate,
});
if (preflightError) {
this.statusElement.textContent = '4K upscale unavailable';
throw preflightError;
}
await this.renderExport(estimate);
this.statusElement.textContent = '';
} finally {
this.isExporting = false;
}
}
private async renderExport(
estimate: ReturnType<typeof estimateExport4KMemory>
): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
const format = navigator.gpu.getPreferredCanvasFormat();
let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null;
let isOutputMapped = false;
try {
texture = this.device.createTexture({
size: { width, height },
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
output = this.device.createBuffer({
size: estimate.readbackBufferBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const commandEncoder = this.device.createCommandEncoder();
this.options.renderPipeline.executeToView(
commandEncoder,
this.options.getColorTextureView(),
this.options.getSourceTextureView(),
texture.createView()
);
commandEncoder.copyTextureToBuffer(
{ texture },
{ buffer: output, bytesPerRow, rowsPerImage: height },
{ width, height }
);
this.device.queue.submit([commandEncoder.finish()]);
await output.mapAsync(GPUMapMode.READ);
isOutputMapped = true;
const pixels = readExportPixels({
mapped: new Uint8Array(output.getMappedRange()),
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra: format === 'bgra8unorm',
});
output.unmap();
isOutputMapped = false;
output.destroy();
output = null;
texture.destroy();
texture = null;
await this.downloadPixels(pixels, width, height);
} catch (error) {
this.statusElement.textContent = '4K upscale failed';
throw error;
} finally {
if (output && isOutputMapped) {
output.unmap();
}
output?.destroy();
texture?.destroy();
}
}
private async downloadPixels(
pixels: Uint8ClampedArray<ArrayBuffer>,
width: number,
height: number
): Promise<void> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create export canvas');
}
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({ type: appConfig.export4k.mimeType });
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
link.download = `${appConfig.export4k.filenamePrefix}_${this.options.getVibeId()}_${
this.options.seed
}_${width}x${height}${appConfig.export4k.filenameSuffix}.${appConfig.export4k.filenameExtension}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
}
}
private get device(): GPUDevice {
return this.options.device;
}
private get statusElement(): HTMLElement {
return this.options.statusElement;
}
}
const readExportPixels = ({
mapped,
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra,
}: {
mapped: Uint8Array;
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
isBgra: boolean;
}): Uint8ClampedArray<ArrayBuffer> => {
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
unpaddedBytesPerRow * height
);
for (let y = 0; y < height; y++) {
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
const source = sourceOffset + x * appConfig.export4k.bytesPerPixel;
const target = targetOffset + x * appConfig.export4k.bytesPerPixel;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
pixels[target + 3] = mapped[source + 3];
}
}
return pixels;
};

View file

@ -1,203 +0,0 @@
import { appConfig } from '../config';
import { RuntimeError } from '../utils/error-handler';
const GIBIBYTE = 1024 ** 3;
interface Export4KMemoryEstimate {
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
textureBytes: number;
readbackBufferBytes: number;
estimatedJsHeapBytes: number;
estimatedPeakBytes: number;
}
interface Export4KDimensions {
width: number;
height: number;
}
interface BrowserMemoryInfo {
deviceMemoryBytes?: number;
jsHeapSizeLimitBytes?: number;
usedJsHeapSizeBytes?: number;
}
interface Export4KPreflightOptions {
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
memoryInfo?: BrowserMemoryInfo;
estimate?: Export4KMemoryEstimate;
}
const alignTo = (value: number, alignment: number): number =>
Math.ceil(value / alignment) * alignment;
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
const formatByteSize = (bytes: number): string => `${Math.ceil(bytes / 1024 / 1024)} MiB`;
export const getAspectFitExport4KDimensions = (
sourceWidth: number,
sourceHeight: number,
maxWidth = appConfig.export4k.width,
maxHeight = appConfig.export4k.height
): Export4KDimensions => {
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth <= 0 ||
sourceHeight <= 0
) {
return { width: maxWidth, height: maxHeight };
}
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
return {
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
};
};
export const estimateExport4KMemory = (
width = appConfig.export4k.width,
height = appConfig.export4k.height
): Export4KMemoryEstimate => {
const unpaddedBytesPerRow = width * appConfig.export4k.bytesPerPixel;
const bytesPerRow = alignTo(unpaddedBytesPerRow, appConfig.export4k.rowAlignmentBytes);
const textureBytes = unpaddedBytesPerRow * height;
const readbackBufferBytes = bytesPerRow * height;
const estimatedJsHeapBytes =
textureBytes * appConfig.export4k.jsHeapTextureBytesMultiplier;
return {
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
textureBytes,
readbackBufferBytes,
estimatedJsHeapBytes,
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
};
};
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
const navigatorWithMemory =
typeof navigator === 'undefined'
? undefined
: (navigator as Navigator & { deviceMemory?: number });
const performanceWithMemory =
typeof performance === 'undefined'
? undefined
: (performance as Performance & {
memory?: {
jsHeapSizeLimit?: number;
usedJSHeapSize?: number;
};
});
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
performanceWithMemory?.memory?.jsHeapSizeLimit
);
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
performanceWithMemory?.memory?.usedJSHeapSize
);
return {
...(deviceMemoryGib === undefined
? {}
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
};
};
export const getExport4KPreflightError = ({
limits,
memoryInfo = {},
estimate = estimateExport4KMemory(),
}: Export4KPreflightOptions): RuntimeError | null => {
if (
estimate.width > limits.maxTextureDimension2D ||
estimate.height > limits.maxTextureDimension2D
) {
return new RuntimeError(
'export-4k-texture-too-large',
`This GPU cannot create a ${estimate.width}x${estimate.height} export texture.`,
{
details: {
exportWidth: estimate.width,
exportHeight: estimate.height,
maxTextureDimension2D: limits.maxTextureDimension2D,
},
}
);
}
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
return new RuntimeError(
'export-4k-readback-too-large',
'This GPU cannot allocate the 4K export readback buffer.',
{
details: {
readbackBufferBytes: estimate.readbackBufferBytes,
maxBufferSize: limits.maxBufferSize,
},
}
);
}
if (
memoryInfo.deviceMemoryBytes !== undefined &&
memoryInfo.deviceMemoryBytes <= appConfig.export4k.lowMemoryDeviceGiB * GIBIBYTE &&
estimate.estimatedPeakBytes >
memoryInfo.deviceMemoryBytes * appConfig.export4k.lowMemoryExportFraction
) {
return new RuntimeError(
'export-4k-low-device-memory',
`4K upscale export needs about ${formatByteSize(
estimate.estimatedPeakBytes
)} of temporary memory, which is not safe on this low-memory device.`,
{
details: {
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
estimatedPeakBytes: estimate.estimatedPeakBytes,
},
}
);
}
if (
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
memoryInfo.usedJsHeapSizeBytes !== undefined
) {
const availableJsHeapBytes =
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
if (
availableJsHeapBytes <
estimate.estimatedJsHeapBytes * appConfig.export4k.jsHeapSafetyMultiplier
) {
return new RuntimeError(
'export-4k-low-js-heap',
`4K upscale export needs about ${formatByteSize(
estimate.estimatedJsHeapBytes
)} of JavaScript heap, and this browser does not report enough free heap.`,
{
details: {
availableJsHeapBytes,
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
},
}
);
}
}
return null;
};

View file

@ -1,13 +1,40 @@
import { appConfig } from '../config';
import { settings } from '../settings';
export class FramePerformance {
public smoothedFps = appConfig.simulation.budget.initialFps;
private readonly adaptiveRefreshTargetFps = 60;
private readonly initialFps = this.adaptiveRefreshTargetFps;
public smoothedFps = this.initialFps;
public measuredFps = 0;
public frameDeltaSeconds = 0;
public measuredFrameTimeMs = 0;
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
private readonly frameGapResetSeconds = 1;
private readonly fpsHeadroom = 0.9;
private readonly fpsSmoothingNew = 0.06;
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
private previousFrameTime: DOMHighResTimeStamp | null = null;
public get adaptiveCapInitial(): number {
return settings.adaptiveCapInitial;
}
public get adaptiveCapMin(): number {
return settings.adaptiveCapMin;
}
public get hasAdaptiveCapHeadroom(): boolean {
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
}
public get adaptiveCapDecreaseAgents(): number {
return Math.max(
1,
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
);
}
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
this.previousFrameTime = time;
@ -24,12 +51,11 @@ export class FramePerformance {
this.frameDeltaSeconds = deltaSeconds;
this.measuredFrameTimeMs = deltaSeconds * 1000;
this.measuredFps = fps;
if (deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds) {
if (deltaSeconds > this.frameGapResetSeconds) {
return;
}
this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
fps * appConfig.simulation.budget.fpsSmoothingNew;
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
}
}

View file

@ -44,7 +44,8 @@ export class GameLoopResources {
public constructor(
canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
canvasSize: vec2
canvasSize: vec2,
initialAgentCapacity: number
) {
const context = initializeContext({ device, canvas });
@ -58,7 +59,7 @@ export class GameLoopResources {
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
Math.min(settings.maxAgentCount, appConfig.simulation.budget.adaptiveCapInitial)
Math.min(settings.maxAgentCount, initialAgentCapacity)
);
this.agentPipeline = new AgentPipeline(

View file

@ -1,5 +1,7 @@
import { vec2 } from 'gl-matrix';
import type { RgbColor } from '../utils/rgb-color';
export interface GardenUi {
prompt: HTMLElement;
eraserPreview: HTMLElement;
@ -8,8 +10,8 @@ export interface GardenUi {
}
export interface RenderInputs {
channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number];
channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
}
export interface StrokeSegment {

View file

@ -1,32 +1,33 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population';
import { DevStatsOverlay } from './dev-stats-overlay';
import { EraserPointerPreviewController } from './eraser-pointer-preview-controller';
import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-renderer';
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance';
import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
import { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop {
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
private readonly renderInputs = new RenderInputCache();
private readonly audio = new GardenAudio(appConfig.audio);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly eraserPreviewController: EraserPointerPreviewController;
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsOverlay: DevStatsOverlay | null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
@ -35,6 +36,7 @@ export default class GameLoop {
private readonly resizeListener = this.resize.bind(this);
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = '';
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
@ -48,38 +50,53 @@ export default class GameLoop {
this.devStatsOverlay = import.meta.env.DEV
? new DevStatsOverlay(canvas.parentElement ?? document.body)
: null;
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.resources = new GameLoopResources(
canvas,
device,
this.canvasSize,
this.framePerformance.adaptiveCapInitial
);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue,
() => this.canvasPixelRatio
() => this.canvasPixelRatio,
this.framePerformance
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
audio: this.audio,
brushPipeline: this.resources.brushPipeline,
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
strokeOutput: new PipelineStrokeOutput(
this.resources.brushPipeline,
this.resources.eraserAgentPipeline,
this.resources.eraserTexturePipeline
),
getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
this.export4KRenderer = new Export4KRenderer({
this.eraserPreviewController = new EraserPointerPreviewController({
canvas,
eraserPreview: this.eraserPreview,
getIsSwipeActive: () => this.pointerInput.isSwipeActive,
});
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device,
renderPipeline: this.resources.renderPipeline,
statusElement: ui.exportStatus,
seed: this.seed,
getSourceSize: () => ({
width: this.canvas.width,
height: this.canvas.height,
}),
getSourceSize: () => {
const size = this.resources.textures.trailMapA.getSize();
return {
width: size[0],
height: size[1],
};
},
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
@ -87,18 +104,19 @@ export default class GameLoop {
window.addEventListener('resize', this.resizeListener);
this.pointerInput.attach();
this.eraserPreviewController.attach();
}
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
this.eraserPreviewController.setEraseMode(isErasing);
}
public updateEraserPreview(event?: PointerEvent): void {
this.pointerInput.updateEraserPreview(event);
this.eraserPreviewController.update(event);
}
public onVibeChanged(): void {
this.renderInputs.invalidate();
this.agentPopulation.onVibeChanged();
}
@ -123,8 +141,8 @@ export default class GameLoop {
return this.finished.promise;
}
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
public async exportSnapshot(): Promise<void> {
return this.exportSnapshotRenderer.export();
}
public async destroy(): Promise<void> {
@ -133,6 +151,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach();
this.eraserPreviewController.detach();
this.devStatsOverlay?.destroy();
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
@ -149,22 +168,20 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.growBudget(
this.framePerformance.frameDeltaSeconds,
this.framePerformance.smoothedFps
);
this.agentPopulation.updateAdaptiveCap();
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
this.resize();
this.resizeSimulationToCanvas(time);
this.regenerateIntroAfterSettledResize(time);
const { channelColors, backgroundColor } = this.renderInputs.get();
const channelColors = activeVibe.colors;
const backgroundColor = activeVibe.backgroundColor;
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
this.updateAccentColor(accentColor);
this.audio.update({
vibe: activeVibe,
isErasing,
@ -203,6 +220,16 @@ export default class GameLoop {
requestAnimationFrame(this.render);
};
private updateAccentColor(color: RgbColor): void {
const accentColor = rgbColorToCss(color);
if (this.previousAccentColor === accentColor) {
return;
}
this.previousAccentColor = accentColor;
document.documentElement.style.setProperty('--accent-color', accentColor);
}
private resize(): void {
const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({

View file

@ -2,23 +2,17 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
import {
BrushPipeline,
getSafePixelRatio,
} from '../pipelines/brush/brush-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { activeVibe, settings } from '../settings';
import { EraserPreview } from './eraser-preview';
import { StrokeSegment } from './game-loop-types';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { activeVibe } from '../settings';
import { BrushStrokeSmoother } from './brush-stroke-smoother';
import { type StrokeSegment } from './game-loop-types';
import { getMirroredStrokeSegments } from './stroke-mirroring';
import { type StrokeOutput } from './stroke-output';
interface GardenPointerInputOptions {
canvas: HTMLCanvasElement;
audio: GardenAudio;
brushPipeline: BrushPipeline;
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
eraserPreview: EraserPreview;
strokeOutput: StrokeOutput;
getCanvasPixelRatio: () => number;
getMirrorSegmentCount: () => number;
onStartDrawing: () => void;
@ -26,19 +20,28 @@ interface GardenPointerInputOptions {
spawnStrokeAgents: (from: vec2, to: vec2) => void;
}
interface PointerSample {
position: vec2;
previousPosition: vec2;
elapsedSeconds: number;
timeStamp: number;
}
export class GardenPointerInput {
private readonly brushSmoother: BrushStrokeSmoother;
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private smoothedStrokePoints: Array<vec2> = [];
private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
public constructor(private readonly options: GardenPointerInputOptions) {}
public constructor(private readonly options: GardenPointerInputOptions) {
this.brushSmoother = new BrushStrokeSmoother({
getCanvasPixelRatio: options.getCanvasPixelRatio,
getMirrorSegmentCount: options.getMirrorSegmentCount,
});
}
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
@ -46,8 +49,6 @@ export class GardenPointerInput {
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
@ -56,11 +57,6 @@ export class GardenPointerInput {
public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing;
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
}
public updateEraserPreview(event?: PointerEvent): void {
this.options.eraserPreview.update(event, this.isSwipeActive);
}
public clearSwipesIfIdle(): void {
@ -68,9 +64,7 @@ export class GardenPointerInput {
return;
}
this.options.brushPipeline.clearSwipes();
this.options.eraserAgentPipeline.clearSwipes();
this.options.eraserTexturePipeline.clearSwipes();
this.options.strokeOutput.clearSwipes();
}
public scaleLastPointerPosition(scale: vec2): void {
@ -78,13 +72,7 @@ export class GardenPointerInput {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
}
this.smoothedStrokePoints.forEach((point) => {
vec2.mul(point, point, scale);
});
if (this.lastSmoothedBrushPosition !== null) {
vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
}
this.brushSmoother.scale(scale);
}
public get isSwipeActive(): boolean {
@ -100,8 +88,6 @@ export class GardenPointerInput {
}
private readonly onPointerDown = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.updateEraserPreview(event);
if (this.activePointerId !== null) {
return;
}
@ -111,17 +97,14 @@ export class GardenPointerInput {
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.options.brushPipeline.clearSwipes();
this.options.eraserAgentPipeline.clearSwipes();
this.options.eraserTexturePipeline.clearSwipes();
this.options.strokeOutput.clearSwipes();
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.brushSmoother.clear();
this.addSwipeAt(event, { emitAudio: false });
};
private readonly onPointerMove = (event: PointerEvent) => {
this.updateEraserPreview(event);
if (event.pointerId !== this.activePointerId) {
return;
}
@ -136,7 +119,7 @@ export class GardenPointerInput {
}
this.options.audio.start(activeVibe, { userGesture: true });
this.addSwipeAt(event, { emitAudio: false });
this.finishSmoothedStroke();
this.finishBrushStroke();
this.options.audio.endGesture();
if (this.isErasing) {
this.options.onEraseGestureEnded();
@ -145,24 +128,27 @@ export class GardenPointerInput {
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
this.updateEraserPreview(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.updateEraserPreview(event);
};
private readonly onPointerLeave = () => {
this.options.eraserPreview.setPointerHoveringCanvas(false);
this.updateEraserPreview();
this.brushSmoother.clear();
};
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
const sample = this.getPointerSample(event);
if (this.isErasing) {
this.addEraseSample(sample);
} else {
this.addBrushSample(sample);
}
if (options.emitAudio !== false) {
this.emitStrokeAudio(sample);
}
this.lastPointerPosition = sample.position;
this.lastPointerEventTimeMs = sample.timeStamp;
}
private getPointerSample(event: PointerEvent): PointerSample {
const position = this.getCanvasPointerPosition(event);
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
@ -171,33 +157,36 @@ export class GardenPointerInput {
(event.timeStamp - previousTimeMs) / 1000
);
const segments = this.isErasing
? [{ from: previousPosition, to: position }]
: this.getMirroredStrokeSegments(previousPosition, position);
return {
position,
previousPosition,
elapsedSeconds,
timeStamp: event.timeStamp,
};
}
if (this.isErasing) {
segments.forEach((segment) => {
this.options.eraserAgentPipeline.addSwipeSegment();
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
});
} else {
this.addSmoothedBrushSample(position);
segments.forEach((segment) => {
private addBrushSample(sample: PointerSample): void {
this.addBrushSegments(this.brushSmoother.addSample(sample.position));
this.getMirroredSegments(sample.previousPosition, sample.position).forEach(
(segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
});
}
if (options.emitAudio !== false) {
this.options.audio.stroke({
vibe: activeVibe,
from: previousPosition,
to: position,
canvasSize: [this.canvas.width, this.canvas.height],
isErasing: this.isErasing,
elapsedSeconds,
});
}
this.lastPointerPosition = position;
this.lastPointerEventTimeMs = event.timeStamp;
}
);
}
private addEraseSample(sample: PointerSample): void {
this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position);
}
private emitStrokeAudio(sample: PointerSample): void {
this.options.audio.stroke({
vibe: activeVibe,
from: sample.previousPosition,
to: sample.position,
canvasSize: [this.canvas.width, this.canvas.height],
isErasing: this.isErasing,
elapsedSeconds: sample.elapsedSeconds,
});
}
private getCanvasPointerPosition(event: PointerEvent): vec2 {
@ -210,103 +199,23 @@ export class GardenPointerInput {
);
}
private addSmoothedBrushSample(position: vec2): void {
const previousSample =
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <=
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
return;
}
this.smoothedStrokePoints.push(vec2.clone(position));
if (this.smoothedStrokePoints.length > 3) {
this.smoothedStrokePoints.shift();
}
if (this.smoothedStrokePoints.length === 1) {
this.addMirroredBrushSegment(position, position);
this.lastSmoothedBrushPosition = vec2.clone(position);
return;
}
if (this.smoothedStrokePoints.length === 2) {
const [start, end] = this.smoothedStrokePoints;
const midpoint = getMidpoint(start, end);
this.addMirroredBrushSegment(start, midpoint);
this.lastSmoothedBrushPosition = midpoint;
return;
}
const [start, control, end] = this.smoothedStrokePoints;
const curveStart = getMidpoint(start, control);
const curveEnd = getMidpoint(control, end);
this.addQuadraticBrushSegments(curveStart, control, curveEnd);
this.lastSmoothedBrushPosition = curveEnd;
}
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const canvasPixelRatio = getSafePixelRatio(
this.options.getCanvasPixelRatio()
);
const brushRadius = Math.max(
settings.brushCurveMinBrushRadius * canvasPixelRatio,
(settings.brushSize * canvasPixelRatio) / 2
);
const segmentSpacing = Math.max(
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
const curveResolution = getBrushCurveResolution();
const maxCurveSegments = Math.max(
1,
Math.floor(
curveResolution /
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
)
);
const segmentCount = Math.min(
maxCurveSegments,
Math.max(1, Math.ceil(curveLength / segmentSpacing))
);
let previousPoint = start;
for (let i = 1; i <= segmentCount; i++) {
const point = getQuadraticPoint(start, control, end, i / segmentCount);
this.addMirroredBrushSegment(previousPoint, point);
previousPoint = point;
}
}
private addMirroredBrushSegment(from: vec2, to: vec2): void {
this.getMirroredStrokeSegments(from, to).forEach((segment) => {
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
private addBrushSegments(segments: Array<StrokeSegment>): void {
segments.forEach((segment) => {
this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
this.options.strokeOutput.addBrushSegment(
mirroredSegment.from,
mirroredSegment.to
);
});
});
}
private finishSmoothedStroke(): void {
if (this.isErasing || this.smoothedStrokePoints.length === 0) {
private finishBrushStroke(): void {
if (this.isErasing) {
return;
}
const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
this.lastSmoothedBrushPosition !== null &&
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
}
}
private clearSmoothedStroke(): void {
this.smoothedStrokePoints.length = 0;
this.lastSmoothedBrushPosition = null;
this.addBrushSegments(this.brushSmoother.finish());
}
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
@ -326,67 +235,16 @@ export class GardenPointerInput {
: [...coalescedEvents, event];
}
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
const segmentCount = this.options.getMirrorSegmentCount();
if (segmentCount <= 1) {
return [{ from, to }];
}
const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
const angleStep = (Math.PI * 2) / segmentCount;
const segments: Array<StrokeSegment> = [];
for (let i = 0; i < segmentCount; i++) {
const angle = angleStep * i;
segments.push({
from: rotatePointAround(from, center, angle),
to: rotatePointAround(to, center, angle),
});
}
return segments;
private getMirroredSegments(from: vec2, to: vec2): Array<StrokeSegment> {
return getMirroredStrokeSegments(
from,
to,
vec2.fromValues(this.canvas.width, this.canvas.height),
this.options.getMirrorSegmentCount()
);
}
}
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
if (angle === 0) {
return point;
}
const offsetX = point[0] - center[0];
const offsetY = point[1] - center[1];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return vec2.fromValues(
center[0] + offsetX * cos - offsetY * sin,
center[1] + offsetX * sin + offsetY * cos
);
};
const getMidpoint = (from: vec2, to: vec2): vec2 =>
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
const inverseT = 1 - t;
return vec2.fromValues(
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
);
};
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
};
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
left.clientX === right.clientX &&
left.clientY === right.clientY &&

View file

@ -1,41 +0,0 @@
import { activeVibe } from '../settings';
import { hexToRgb } from '../utils/hex-to-rgb';
import { type VibeId } from '../vibes';
import { RenderInputs } from './game-loop-types';
export class RenderInputCache {
private cachedVibeId: VibeId | null = null;
private cachedRenderInputs?: RenderInputs;
private previousAccentColor = '';
public invalidate(): void {
this.cachedVibeId = null;
this.cachedRenderInputs = undefined;
}
public get(): RenderInputs {
if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) {
return this.cachedRenderInputs;
}
this.cachedVibeId = activeVibe.id;
this.cachedRenderInputs = {
channelColors: activeVibe.colors.map(hexToRgb),
backgroundColor: hexToRgb(activeVibe.backgroundColor),
};
return this.cachedRenderInputs;
}
public updateAccentColor(color: [number, number, number]): void {
const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round(
color[1] * 255
)},${Math.round(color[2] * 255)})`;
if (this.previousAccentColor === accentColor) {
return;
}
this.previousAccentColor = accentColor;
document.documentElement.style.setProperty('--accent-color', accentColor);
}
}

View file

@ -53,8 +53,7 @@ export class SimulationFrameRenderer {
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView(),
this.textures.influenceMapA.getTextureView()
this.textures.trailMapB.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
@ -68,21 +67,25 @@ export class SimulationFrameRenderer {
this.textures.sourceMapA.getTextureView()
);
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
this.device.queue.submit([commandEncoder.finish()]);
canvasReadbackRequest?.afterSubmit();
const postRenderCommandEncoder = this.device.createCommandEncoder();
this.pipelines.diffusionPipeline.execute(
commandEncoder,
postRenderCommandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(),
this.textures.sourceMapB.getSize()
);
this.pipelines.brushEffectDiffusionPipeline.execute(
commandEncoder,
postRenderCommandEncoder,
this.textures.influenceMapA.getTextureView(),
this.textures.influenceMapB.getTextureView(),
this.textures.influenceMapB.getSize()
);
this.device.queue.submit([commandEncoder.finish()]);
canvasReadbackRequest?.afterSubmit();
this.device.queue.submit([postRenderCommandEncoder.finish()]);
this.textures.swapBrushEffectMaps();
}
}

View file

@ -22,7 +22,7 @@ export class SimulationTextures {
this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapA = this.createTexture(canvasSize);
this.influenceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createTexture(canvasSize);
this.eraserMask = this.createEraserMask(canvasSize);
}
public resizeTo(nextSize: vec2): vec2 | null {
@ -97,4 +97,16 @@ export class SimulationTextures {
private createTexture(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, size);
}
private createEraserMask(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, size, {
clearValue: { r: 1, g: 1, b: 1, a: 1 },
format: 'r8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST,
});
}
}

View file

@ -28,6 +28,7 @@ import {
} from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { clamp01 } from './utils/math';
import { rgbColorToCss } from './utils/rgb-color';
import { VIBE_PRESETS } from './vibes';
const AUDIO_VOLUME_STEP = 0.01;
@ -358,7 +359,7 @@ const renderAudioUi = (game: GameLoop | null) => {
const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = activeVibe.colors[index];
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle(
CSS_CLASSES.active,
settings.selectedColorIndex === index && !isEraserActive
@ -368,7 +369,7 @@ const renderPaletteUi = (game: GameLoop | null) => {
game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty(
CSS_VARIABLES.gardenBackground,
activeVibe.backgroundColor
rgbColorToCss(activeVibe.backgroundColor)
);
};
@ -471,7 +472,7 @@ const main = async () => {
new FullScreenHandler(
elements.minimizeFullScreenButton,
elements.maximizeFullScreenButton,
document.body
document.documentElement
);
const startAudioFromUserGesture = (event: Event) => {
@ -599,7 +600,7 @@ const main = async () => {
elements.export4k.disabled = true;
try {
await game.export4K();
await game.exportSnapshot();
trackExport({ vibeId: activeVibe.id });
} catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING });
@ -716,7 +717,7 @@ const main = async () => {
});
}
// Keep the toolbar/dock hidden until the user actually starts drawing.
// Keep the dev stats overlay hidden until the user actually starts drawing.
document.body.classList.add(CSS_CLASSES.preDrawing);
elements.canvas.addEventListener(
DOM_EVENTS.pointerDown,

View file

@ -7,6 +7,7 @@ import {
type NumberControlConfig,
} from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
@ -55,9 +56,6 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
['boolean', 'number', 'string'].includes(typeof value);
const isColorString = (value: unknown): value is string =>
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
const toLabel = (value: string): string =>
value
.replace(/\[(\d+)\]/g, ' $1')
@ -348,7 +346,7 @@ export class ConfigPane {
});
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
});
}
@ -367,7 +365,7 @@ export class ConfigPane {
): void {
Object.entries(source).forEach(([key, value]) => {
if (isBindablePrimitive(value)) {
this.addPrimitiveBinding(container, source, key, value);
this.addPrimitiveBinding(container, source, key);
return;
}
@ -381,8 +379,7 @@ export class ConfigPane {
this.addPrimitiveBinding(
folder,
value as unknown as Record<string, unknown>,
`${index}`,
item
`${index}`
);
return;
}
@ -417,12 +414,10 @@ export class ConfigPane {
private addPrimitiveBinding(
container: PaneContainer,
source: Record<string, unknown>,
key: string,
value: boolean | number | string
key: string
): void {
const params: BindingParams = {
label: toLabel(key),
...(isColorString(value) ? { color: { type: 'int' } } : {}),
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
};

View file

@ -4,7 +4,7 @@ export class FullScreenHandler {
private readonly maximizeButton: HTMLElement,
target: HTMLElement
) {
if (!document.fullscreenEnabled) {
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
minimizeButton.hidden = true;
maximizeButton.hidden = true;
return;
@ -13,7 +13,9 @@ export class FullScreenHandler {
this.updateButtons();
addEventListener('fullscreenchange', this.updateButtons.bind(this));
maximizeButton.addEventListener('click', () => target.requestFullscreen());
maximizeButton.addEventListener('click', () => {
void target.requestFullscreen().catch(() => undefined);
});
minimizeButton.addEventListener('click', () => document.exitFullscreen());
}

View file

@ -15,10 +15,7 @@ struct Counters {
var<workgroup> workgroupAliveCount: atomic<u32>;
var<workgroup> workgroupCompactedOffset: u32;
fn dead_agent() -> Agent {
return Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
}
var<workgroup> clearAliveAgentCount: u32;
@compute @workgroup_size(64)
fn main(
@ -35,12 +32,12 @@ fn main(
workgroupBarrier();
var localCompactedIndex = 0u;
var agent = dead_agent();
var agent: Agent;
var isAlive = false;
if id < settings.agentCount {
agent = agents[id];
isAlive = agent.colorIndex >= 0.0;
isAlive = agents[id].colorIndex >= 0.0;
if isAlive {
agent = agents[id];
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
}
}
@ -66,16 +63,22 @@ 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>
) {
let id = get_id(global_id, num_workgroups);
if local_id.x == 0u {
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
}
workgroupBarrier();
if id >= settings.agentCount {
return;
}
let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
if id >= aliveAgentCount {
compactedAgents[id] = dead_agent();
if id >= clearAliveAgentCount {
compactedAgents[id].colorIndex = -1.0;
}
}

View file

@ -39,6 +39,15 @@ export class AgentGenerationPipeline {
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
);
public constructor(
private readonly device: GPUDevice,
@ -233,11 +242,11 @@ export class AgentGenerationPipeline {
return;
}
this.device.queue.writeBuffer(
this.uniforms,
0,
new Float32Array([scale[0], scale[1], agentCount, 0])
);
this.resizeUniformFloatValues[0] = scale[0];
this.resizeUniformFloatValues[1] = scale[1];
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
this.resizeUniformUintValues[3] = 0;
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();

View file

@ -1,7 +1,7 @@
struct ResizeSettings {
scale: vec2<f32>,
agentCount: f32,
padding: f32,
agentCount: u32,
padding: u32,
};
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
@ -13,10 +13,11 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
if id >= u32(resizeSettings.agentCount) {
if id >= resizeSettings.agentCount {
return;
}
agents[id].position = agents[id].position * resizeSettings.scale;
agents[id].targetPosition = agents[id].targetPosition * resizeSettings.scale;
let scale = resizeSettings.scale;
agents[id].position = agents[id].position * scale;
agents[id].targetPosition = agents[id].targetPosition * scale;
}

View file

@ -10,21 +10,19 @@ import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly UNIFORM_COUNT = 33;
private static readonly UNIFORM_COUNT = 30;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
AgentPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByAgentsBuffer = new WeakMap<
GPUBuffer,
WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>();
private agentCount = 0;
@ -69,9 +67,6 @@ export class AgentPipeline {
color3ToColor1,
color3ToColor2,
color3ToColor3,
sourceAttractionWeight,
sourceSlowMoveRate,
sourceTrailWeightMultiplier,
forwardRotationScale,
introNearDistanceInner,
introNearDistanceMin,
@ -100,7 +95,7 @@ export class AgentPipeline {
this.uniformValues[4] = sensorOffsetDistance;
this.uniformValues[5] = turnWhenLost;
this.uniformValues[6] = individualTrailWeight;
this.uniformValues[7] = agentCount;
this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount));
this.uniformValues[8] = introProgress ?? 1;
this.uniformValues[9] = color1ToColor1;
this.uniformValues[10] = color1ToColor2;
@ -111,21 +106,18 @@ export class AgentPipeline {
this.uniformValues[15] = color3ToColor1;
this.uniformValues[16] = color3ToColor2;
this.uniformValues[17] = color3ToColor3;
this.uniformValues[18] = sourceAttractionWeight;
this.uniformValues[19] = sourceSlowMoveRate;
this.uniformValues[20] = sourceTrailWeightMultiplier;
this.uniformValues[21] = forwardRotationScale;
this.uniformValues[22] = introNearDistanceInner;
this.uniformValues[23] = introNearDistanceMin;
this.uniformValues[24] = introNearSensorOffsetMultiplier;
this.uniformValues[25] = introTargetAngleBlend;
this.uniformValues[26] = introProgressCutoff;
this.uniformValues[27] = introTurnRateMultiplier;
this.uniformValues[28] = introRandomTurnMultiplier;
this.uniformValues[29] = introFarMoveMultiplier;
this.uniformValues[30] = introNearMoveMultiplier;
this.uniformValues[31] = introStepStopDistance;
this.uniformValues[32] = randomTimeScale;
this.uniformValues[18] = forwardRotationScale;
this.uniformValues[19] = introNearDistanceInner;
this.uniformValues[20] = introNearDistanceMin;
this.uniformValues[21] = introNearSensorOffsetMultiplier;
this.uniformValues[22] = introTargetAngleBlend;
this.uniformValues[23] = introProgressCutoff;
this.uniformValues[24] = introTurnRateMultiplier;
this.uniformValues[25] = introRandomTurnMultiplier;
this.uniformValues[26] = introFarMoveMultiplier;
this.uniformValues[27] = introNearMoveMultiplier;
this.uniformValues[28] = introStepStopDistance;
this.uniformValues[29] = randomTimeScale;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@ -137,14 +129,13 @@ export class AgentPipeline {
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
sourceMap: GPUTextureView
trailMapOut: GPUTextureView
) {
if (this.agentCount <= 0) {
return;
}
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
@ -156,32 +147,22 @@ export class AgentPipeline {
private getBindGroup(
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
sourceMap: GPUTextureView
trailMapOut: GPUTextureView
): GPUBindGroup {
const agentsBuffer = this.getAgentsBuffer();
let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
if (!textureCache) {
textureCache = new WeakMap<
GPUTextureView,
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
>();
textureCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
}
let outputCache = textureCache.get(trailMapIn);
if (!outputCache) {
outputCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
textureCache.set(trailMapIn, outputCache);
}
let sourceCache = outputCache.get(trailMapOut);
if (!sourceCache) {
sourceCache = new WeakMap<GPUTextureView, GPUBindGroup>();
outputCache.set(trailMapOut, sourceCache);
}
const cached = sourceCache.get(sourceMap);
const cached = outputCache.get(trailMapOut);
if (cached) {
return cached;
}
@ -209,14 +190,10 @@ export class AgentPipeline {
binding: 3,
resource: trailMapOut,
},
{
binding: 4,
resource: sourceMap,
},
],
});
sourceCache.set(sourceMap, bindGroup);
outputCache.set(trailMapOut, bindGroup);
return bindGroup;
}
@ -255,13 +232,6 @@ export class AgentPipeline {
format: 'rgba16float',
},
},
{
binding: 4,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
],
};
}

View file

@ -14,9 +14,6 @@ export interface AgentSettings {
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
sourceAttractionWeight: number;
sourceSlowMoveRate: number;
sourceTrailWeightMultiplier: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;

View file

@ -6,7 +6,7 @@ struct Settings {
sensorOffset: f32,
turnWhenLost: f32,
individualTrailWeight: f32,
agentCount: f32,
agentCount: u32,
introProgress: f32,
color1ToColor1: f32,
color1ToColor2: f32,
@ -17,9 +17,6 @@ struct Settings {
color3ToColor1: f32,
color3ToColor2: f32,
color3ToColor3: f32,
sourceAttractionWeight: f32,
sourceSlowMoveRate: f32,
sourceTrailWeightMultiplier: f32,
forwardRotationScale: f32,
introNearDistanceInner: f32,
introNearDistanceMin: f32,
@ -37,7 +34,6 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
@group(1) @binding(4) var sourceMap: texture_2d<f32>;
@compute @workgroup_size(64)
fn main(
@ -46,7 +42,7 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) {
if id >= settings.agentCount {
return;
}
@ -67,63 +63,15 @@ fn main(
}
}
let randomSeed = random_seed(id, state.time);
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
let trailRightWeight = dot(trailRight.rgb, reactionMask);
let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
let weightForward =
trailForwardWeight + sourceForwardWeight * settings.sourceAttractionWeight;
let weightLeft = trailLeftWeight + sourceLeftWeight * settings.sourceAttractionWeight;
let weightRight =
trailRightWeight + sourceRightWeight * settings.sourceAttractionWeight;
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(position), 0);
let positiveReactionMask = max(reactionMask, vec3<f32>(0.0));
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
var moveRate = settings.moveRate * mix(1.0, settings.sourceSlowMoveRate, sourceAtAgentStrength);
var introTargetOffset = vec2<f32>(0.0, 0.0);
var introTargetDistance = 0.0;
let randomSeed = random_seed(id, state.time);
var rotation = 0.0;
var step = vec2<f32>(0.0, 0.0);
if hasIntroTarget {
introTargetOffset = targetPosition - position;
introTargetDistance = length(introTargetOffset);
let introTargetOffset = targetPosition - position;
let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner,
@ -148,19 +96,46 @@ fn main(
+ (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost *
settings.introRandomTurnMultiplier;
moveRate = min(
let moveRate = min(
settings.moveRate *
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
introTargetDistance
);
}
var step = direction * moveRate;
if hasIntroTarget {
step = vec2<f32>(0.0, 0.0);
if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate;
}
} else {
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
step = direction * settings.moveRate;
}
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
@ -169,14 +144,9 @@ fn main(
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
}
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
let trailWeight =
settings.individualTrailWeight *
(1.0 + sourceBelowStrength * settings.sourceTrailWeightMultiplier);
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>(
trailBelow.rgb + channelMask * trailWeight,
trailBelow.rgb + channelMask * settings.individualTrailWeight,
max(trailBelow.a, 0.0)
);

View file

@ -31,8 +31,6 @@ export const setBrushUniformValues = (
brushSize,
brushSizeVariation,
brushAlpha,
brushFeatherRatio,
brushMinimumFeather,
brushDiscardThreshold,
brushCoarseNoiseScale,
brushGrainNoiseScale,
@ -47,30 +45,24 @@ export const setBrushUniformValues = (
const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
const brushFeather = Math.max(
brushMinimumFeatherPixels,
brushRadius * brushFeatherRatio
);
const brushGeometryRadius =
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
target[0] = brushRadius;
target[1] = brushRadiusVariation;
target[2] = brushFeatherRatio;
target[3] = brushMinimumFeatherPixels;
target[1] = brushRadiusVariation * 2;
target[2] = brushGeometryRadius * brushGeometryRadius;
target[3] = brushGeometryRadius;
target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha;
target[8] = brushCoarseNoiseScale * safePixelRatio;
target[9] = brushGrainNoiseScale * safePixelRatio;
target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio);
target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[10] = brushGrainNoiseOffsetX;
target[11] = brushGrainNoiseOffsetY;
target[12] = brushDiscardThreshold;
target[13] = brushGrainMinStrength;
target[14] = brushGrainMaxStrength;
target[15] = brushGeometryRadius;
target[15] = 0;
};
export class BrushPipeline {

View file

@ -2,8 +2,6 @@ export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
brushAlpha: number;
brushFeatherRatio: number;
brushMinimumFeather: number;
brushDiscardThreshold: number;
brushCoarseNoiseScale: number;
brushGrainNoiseScale: number;

View file

@ -1,8 +1,8 @@
struct Settings {
brushSize: f32,
brushSizeVariation: f32,
brushFeatherRatio: f32,
brushMinimumFeather: f32,
brushGeometryRadiusSquared: f32,
brushGeometryRadius: f32,
brushValue: vec4<f32>,
brushCoarseNoiseScale: f32,
brushGrainNoiseScale: f32,
@ -11,7 +11,6 @@ struct Settings {
brushDiscardThreshold: f32,
brushGrainMinStrength: f32,
brushGrainMaxStrength: f32,
brushGeometryRadius: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -20,7 +19,8 @@ struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct BrushTargets {
@ -34,24 +34,36 @@ fn vertex(
@location(0) start: vec2<f32>,
@location(1) end: vec2<f32>
) -> VertexOutput {
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
if denominator > 0.0001 {
inverseLengthSquared = 1.0 / denominator;
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius);
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> BrushTargets {
let distanceSquared = distanceSquaredFromLine(screenPosition, start, end);
if distanceSquared > settings.brushGeometryRadius * settings.brushGeometryRadius {
let distanceSquared = distanceSquaredFromLine(
screenPosition,
start,
direction,
inverseLengthSquared
);
if distanceSquared > settings.brushGeometryRadiusSquared {
discard;
}
let strength = brushStrength(screenPosition, start, end, distanceSquared);
let strength = brushStrength(screenPosition, distanceSquared);
if(strength < settings.brushDiscardThreshold) {
discard;
@ -63,27 +75,28 @@ fn fragmentMrt(
fn brushStrength(
screenPosition: vec2<f32>,
start: vec2<f32>,
end: vec2<f32>,
distanceSquared: f32
) -> f32 {
let distance = sqrt(distanceSquared);
let coarseNoise = textureSample(
let coarseNoise = textureSampleLevel(
noise,
noiseSampler,
fract(screenPosition / settings.brushCoarseNoiseScale)
screenPosition * settings.brushCoarseNoiseScale,
0.0
).r;
let grainNoise = textureSample(
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
let edge = 1.0 - step(radius, distance);
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
return 0.0;
}
let grainNoise = textureSampleLevel(
noise,
noiseSampler,
fract(
screenPosition / settings.brushGrainNoiseScale +
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY)
)
screenPosition * settings.brushGrainNoiseScale +
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
let feather = max(settings.brushMinimumFeather, settings.brushSize * settings.brushFeatherRatio);
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
}
@ -91,16 +104,19 @@ fn brushOutput(strength: f32) -> vec4<f32> {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0, 1);
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -83,6 +83,8 @@ export class CommonState {
{
binding: 1,
resource: this.device.createSampler({
addressModeU: 'repeat',
addressModeV: 'repeat',
magFilter: 'linear',
minFilter: 'linear',
}),

View file

@ -3,9 +3,9 @@ struct Settings {
decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
diffusionNeighborDivisor: f32,
brushDecayAlphaOffset: f32,
padding0: f32,
diffusionNeighborScale: f32,
brushDecayAlphaMultiplier: f32,
brushDecayAlphaSubtract: f32,
padding1: f32,
};
@ -60,8 +60,26 @@ fn main(
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex];
let random = random_from_pixel(pixel);
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
let r2 = random * random;
let r4 = r2 * r2;
let r8 = r4 * r4;
let r16 = r8 * r8;
let trailWeight = diffusion_weight(
random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateTrails
);
let brushWeight = diffusion_weight(
random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateBrush
);
current += (
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
@ -73,11 +91,11 @@ fn main(
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
) / max(1.0, settings.diffusionNeighborDivisor);
) * settings.diffusionNeighborScale;
let decayed = clamp(vec4(
current.rgb * settings.decayRateTrails,
max(0, current.a + (current.a - settings.brushDecayAlphaOffset) * settings.decayRateBrush)
max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract)
), vec4(0), vec4(1));
textureStore(trailMapOut, pixel, decayed);
@ -110,9 +128,14 @@ fn random_from_pixel(pixel: vec2<i32>) -> f32 {
return f32(hash) * 2.3283064365386963e-10;
}
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
let r = clamp(random, 0.0, 1.0);
fn diffusion_weight(
r: f32,
r2: f32,
r4: f32,
r8: f32,
r16: f32,
inverseRate: f32
) -> f32 {
if inverseRate < 1.0 {
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
return mix(
@ -122,22 +145,18 @@ fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
);
}
let r2 = r * r;
if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0);
}
let r4 = r2 * r2;
if inverseRate < 4.0 {
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
}
let r8 = r4 * r4;
if inverseRate < 8.0 {
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
}
let r16 = r8 * r8;
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
* min(1.0, 16.0 / inverseRate);
}

View file

@ -41,12 +41,18 @@ export const setDiffusionUniformValues = (
}: DiffusionUniformSettings
): void => {
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
const brushDecayRate = decayRateBrush / decayDivisor;
const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
? Math.max(1, diffusionNeighborDivisor)
: 1;
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / decayDivisor;
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
target[3] = decayRateBrush / decayDivisor;
target[4] = diffusionNeighborDivisor;
target[5] = brushDecayAlphaOffset;
target[3] = brushDecayRate;
target[4] = 1 / neighborDivisor;
target[5] = 1 + brushDecayRate;
target[6] = brushDecayAlphaOffset * brushDecayRate;
target[7] = 0;
};
export class DiffusionPipeline {

View file

@ -14,6 +14,7 @@ export class EraserAgentPipeline {
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
@ -93,7 +94,7 @@ export class EraserAgentPipeline {
this.activeSegmentCount = this.pendingSegmentCount;
this.pendingSegmentCount = 0;
this.uniformValues[0] = agentCount;
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;

View file

@ -1,5 +1,5 @@
struct Settings {
agentCount: f32,
agentCount: u32,
eraserMaskAlphaThreshold: f32,
padding1: f32,
padding2: f32,
@ -15,7 +15,7 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) {
if id >= settings.agentCount {
return;
}
@ -32,7 +32,7 @@ fn main(
);
let maskSample = textureLoad(eraserMask, maskPosition, 0);
if maskSample.a < settings.eraserMaskAlphaThreshold {
if maskSample.r < settings.eraserMaskAlphaThreshold {
agents[id].colorIndex = -1.0;
}
}

View file

@ -64,7 +64,12 @@ export class EraserTexturePipeline {
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', 4);
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [
'r8unorm',
'rgba16float',
'rgba16float',
'rgba16float',
]);
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -119,7 +124,7 @@ export class EraserTexturePipeline {
this.uniformValues[3] = eraserClearGreen;
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = 0;
this.uniformValues[6] = eraserRadius;
this.uniformValues[7] = 0;
writeFloat32BufferIfChanged(
this.device,
@ -225,7 +230,7 @@ export class EraserTexturePipeline {
private createPipeline(
shaderModule: GPUShaderModule,
fragmentEntryPoint: string,
colorTargetCount: number
targetFormats: Array<GPUTextureFormat>
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
@ -258,9 +263,7 @@ export class EraserTexturePipeline {
fragment: {
module: shaderModule,
entryPoint: fragmentEntryPoint,
targets: Array.from({ length: colorTargetCount }, () => ({
format: 'rgba16float' as const,
})),
targets: targetFormats.map((format) => ({ format })),
},
primitive: {
topology: 'triangle-list',

View file

@ -5,7 +5,7 @@ struct Settings {
clearGreen: f32,
clearBlue: f32,
clearAlpha: f32,
padding0: f32,
eraserRadius: f32,
padding1: f32,
};
@ -15,7 +15,8 @@ struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct EraserTextureTargets {
@ -37,19 +38,26 @@ fn vertex(
@location(0) start: vec2<f32>,
@location(1) end: vec2<f32>
) -> VertexOutput {
let screenPosition = segment_vertex_position(vertexIndex, start, end, sqrt(settings.eraserRadiusSquared));
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
if denominator > settings.lineDistanceEpsilon {
inverseLengthSquared = 1.0 / denominator;
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.eraserRadius);
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
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) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> @location(0) vec4<f32> {
if shouldDiscardEraserFragment(screenPosition, start, end) {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
@ -60,9 +68,10 @@ fn fragment(
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserTextureTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
@ -74,14 +83,19 @@ fn fragmentMrt(
fn fragmentCombined(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserCombinedTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
let cleared = getEraserClearValue();
return EraserCombinedTargets(cleared, cleared, cleared, cleared);
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared);
}
fn getEraserMaskValue() -> vec4<f32> {
return vec4<f32>(settings.clearAlpha, 0.0, 0.0, 1.0);
}
fn getEraserClearValue() -> vec4<f32> {
@ -96,21 +110,25 @@ fn getEraserClearValue() -> vec4<f32> {
fn shouldDiscardEraserFragment(
screenPosition: vec2<f32>,
start: vec2<f32>,
end: vec2<f32>
direction: vec2<f32>,
inverseLengthSquared: f32
) -> bool {
return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared;
return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared;
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= settings.lineDistanceEpsilon {
if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -4,6 +4,7 @@ import {
} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw';
@ -13,8 +14,6 @@ export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly sampledPipeline: GPURenderPipeline;
private readonly sampler: GPUSampler;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@ -35,14 +34,8 @@ export class RenderPipeline {
const vertex = setUpFullScreenQuad(device);
this.sampler = device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
const format = navigator.gpu.getPreferredCanvasFormat();
this.pipeline = this.createPipeline(format, vertex, 'fragment');
this.sampledPipeline = this.createPipeline(format, vertex, 'fragmentSampled');
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -84,25 +77,25 @@ export class RenderPipeline {
renderBrushColorStrengthMultiplier,
backgroundGrainStrength,
}: RenderSettings & {
channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number];
channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
}) {
const [a, b, c] = channelColors;
this.uniformValues[0] = a[0];
this.uniformValues[1] = a[1];
this.uniformValues[2] = a[2];
this.uniformValues[0] = rgbChannelToUnit(a[0]);
this.uniformValues[1] = rgbChannelToUnit(a[1]);
this.uniformValues[2] = rgbChannelToUnit(a[2]);
this.uniformValues[3] = 0;
this.uniformValues[4] = b[0];
this.uniformValues[5] = b[1];
this.uniformValues[6] = b[2];
this.uniformValues[4] = rgbChannelToUnit(b[0]);
this.uniformValues[5] = rgbChannelToUnit(b[1]);
this.uniformValues[6] = rgbChannelToUnit(b[2]);
this.uniformValues[7] = 0;
this.uniformValues[8] = c[0];
this.uniformValues[9] = c[1];
this.uniformValues[10] = c[2];
this.uniformValues[8] = rgbChannelToUnit(c[0]);
this.uniformValues[9] = rgbChannelToUnit(c[1]);
this.uniformValues[10] = rgbChannelToUnit(c[2]);
this.uniformValues[11] = 0;
this.uniformValues[12] = backgroundColor[0];
this.uniformValues[13] = backgroundColor[1];
this.uniformValues[14] = backgroundColor[2];
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
this.uniformValues[15] = clarity;
this.uniformValues[16] = renderTraceNormalizationFloor;
this.uniformValues[17] = renderBrushColorBase;
@ -162,7 +155,7 @@ export class RenderPipeline {
},
],
});
passEncoder.setPipeline(this.sampledPipeline);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(3, 1);
@ -193,10 +186,6 @@ export class RenderPipeline {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.sampler,
},
{
binding: 2,
resource: colorTexture,
@ -226,13 +215,6 @@ export class RenderPipeline {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
},
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,

View file

@ -14,27 +14,17 @@ struct Settings {
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
@fragment
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
let pixel = clamp(vec2<i32>(position.xy), vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1));
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, vec2<i32>(position.xy));
}
const NOISE_TEXTURE_MASK = 2047u;
@fragment
fn fragmentSampled(
@location(0) uv: vec2<f32>,
@builtin(position) position: vec4<f32>
) -> @location(0) vec4<f32> {
let traces = textureSample(trailMap, Sampler, uv);
let sources = textureSample(sourceMap, Sampler, uv);
return renderColor(traces, sources, vec2<i32>(position.xy));
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, pixel);
}
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
@ -48,6 +38,16 @@ 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 {
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);
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
}
let sourceStrengths = vec3(
clarity(sources.r),
clarity(sources.g),
@ -89,8 +89,7 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
}
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
let noiseCoord = pixel % noiseSize;
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
return clamp(

View file

@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => {
Object.assign(settings, {
...buildSettings(vibe),
eraserSize: settings.eraserSize,
adaptiveCapInitial: settings.adaptiveCapInitial,
adaptiveCapMin: settings.adaptiveCapMin,
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
maxAgentCount: settings.maxAgentCount,
mirrorSegmentCount: settings.mirrorSegmentCount,

View file

@ -1,4 +1,5 @@
html > body.pre-drawing .dev-stats-overlay {
html > body.pre-drawing .dev-stats-overlay,
html > body.is-loading .dev-stats-overlay {
display: none;
}

View file

@ -155,4 +155,3 @@ html > body.is-loading {
visibility: hidden;
}
}

View file

@ -38,10 +38,13 @@ $toolbar-icons: (
html > body > aside.control-dock > .toolbar-row {
--toolbar-background-opacity: 0%;
--toolbar-background-strength: 0;
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
--toolbar-top-max-width: 594px;
display: grid;
grid-template-areas:
'previous controls next'
'previous divider next'
'previous buttons next';
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
@ -51,7 +54,7 @@ html > body > aside.control-dock > .toolbar-row {
margin: 0 auto;
padding-inline: clamp(8px, 1.4vw, 14px);
column-gap: 0;
row-gap: clamp(6px, 1.8vw, 14px);
row-gap: 0;
border-radius: 12px;
color: rgb(245 250 244 / 92%);
background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
@ -70,6 +73,17 @@ html > body > aside.control-dock > .toolbar-row {
background-color var(--transition-time-long),
box-shadow var(--transition-time-long);
&::after {
content: '';
grid-area: divider;
align-self: center;
justify-self: center;
width: min(100%, var(--toolbar-top-max-width));
height: 1px;
margin-block: var(--toolbar-divider-space);
background: rgb(255 255 255 / 12%);
}
button {
min-width: 44px;
min-height: 44px;
@ -103,7 +117,7 @@ html > body > aside.control-dock > .toolbar-row {
align-items: center;
justify-content: center;
justify-self: center;
width: min(100%, max-content);
width: min(100%, var(--toolbar-top-max-width));
min-width: 0;
padding: 8px 9px;
}
@ -167,12 +181,12 @@ html > body > aside.control-dock > .toolbar-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
justify-content: center;
justify-self: center;
gap: 4px;
width: 100%;
width: fit-content;
max-width: 100%;
min-width: 0;
padding-top: 7px;
border-top: 1px solid rgb(255 255 255 / 12%);
> button,
> .audio-control > button {
@ -571,10 +585,17 @@ html > body > aside.control-dock > .toolbar-row {
}
@include on-small-screen {
--toolbar-divider-space: 4px;
--toolbar-top-max-width: 329px;
grid-template-areas:
'previous controls next'
'. divider .'
'buttons buttons buttons';
width: 100%;
padding-inline: 4px;
column-gap: 0;
row-gap: 4px;
row-gap: 0;
> .vibe-button {
width: 36px;
@ -591,8 +612,12 @@ html > body > aside.control-dock > .toolbar-row {
}
> nav.buttons {
justify-self: stretch;
justify-content: space-between;
gap: clamp(1px, 0.55vw, 2px);
padding-top: 3px;
width: auto;
max-width: none;
margin-inline: -4px;
> button {
width: auto;
@ -643,13 +668,18 @@ html > body > aside.control-dock > .toolbar-row {
justify-content: stretch;
width: 100%;
min-width: 0;
min-height: 54px;
min-height: 48px;
flex: 1 1 100%;
padding: 4px 6px;
column-gap: 7px;
row-gap: 8px;
padding: 3px 5px;
column-gap: 6px;
row-gap: 6px;
> .color-swatch {
width: 38px;
height: 38px;
min-width: 38px;
min-height: 38px;
grid-column: span 2;
}
@ -658,8 +688,8 @@ html > body > aside.control-dock > .toolbar-row {
justify-self: stretch;
width: 100%;
min-width: 0;
height: 42px;
padding: 0 8px;
height: 38px;
padding: 0 7px;
}
> .eraser-size-control {
@ -667,8 +697,8 @@ html > body > aside.control-dock > .toolbar-row {
}
> .mirror-segment-control {
--thumb-height: 38px;
--thumb-width: 38px;
--thumb-height: 34px;
--thumb-width: 34px;
grid-column: 4 / span 3;
}

View file

@ -1,7 +1,7 @@
:root {
--transition-time: 200ms;
--transition-time-long: 350ms;
--accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734);
--accent-color: rgb(255, 93, 162);
--main-color: #aaa;
--normal-margin: 2rem;
--small-margin: 1rem;

View file

@ -1,15 +1,32 @@
import { vec2 } from 'gl-matrix';
interface ResizableTextureOptions {
clearValue?: GPUColor;
format?: GPUTextureFormat;
usage?: GPUTextureUsageFlags;
}
export class ResizableTexture {
private texture: GPUTexture;
private textureView: GPUTextureView;
private size: vec2;
private readonly clearValue: GPUColor;
private readonly format: GPUTextureFormat;
private readonly usage: GPUTextureUsageFlags;
public constructor(
private readonly device: GPUDevice,
size: vec2
size: vec2,
{
clearValue = { r: 0, g: 0, b: 0, a: 0 },
format = 'rgba16float',
usage = defaultTextureUsage,
}: ResizableTextureOptions = {}
) {
this.size = vec2.clone(size);
this.clearValue = clearValue;
this.format = format;
this.usage = usage;
this.texture = this.createTexture(size);
this.textureView = this.texture.createView();
}
@ -31,7 +48,7 @@ export class ResizableTexture {
colorAttachments: [
{
view: newTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
clearValue: this.clearValue,
loadOp: 'clear',
storeOp: 'store',
},
@ -69,14 +86,16 @@ export class ResizableTexture {
private createTexture(size: vec2): GPUTexture {
return this.device.createTexture({
format: 'rgba16float',
format: this.format,
size: { width: size[0], height: size[1] },
usage:
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST,
usage: this.usage,
});
}
}
const defaultTextureUsage =
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST;

View file

@ -10,20 +10,25 @@ export const smartCompile = (
code: concatenated,
});
module.getCompilationInfo().then((info) =>
info.messages.forEach((message) =>
module.getCompilationInfo().then((info) => {
if (info.messages.length === 0) {
return;
}
const lines = concatenated.split('\n');
info.messages.forEach((message) => {
const sourceLine = lines[message.lineNum - 1] ?? '';
const fullSource = import.meta.env.DEV ? `\n\nCode:\n${concatenated}\n` : '';
ErrorHandler.addError(
{
info: Severity.INFO,
warning: Severity.WARNING,
error: Severity.ERROR,
}[message.type],
`${message.message}\n${
concatenated.split('\n')[message.lineNum - 1]
}\n\nCode:\n${concatenated}\n`
)
)
);
`${message.message}\n${sourceLine}${fullSource}`
);
});
});
return module;
};

View file

@ -1,12 +0,0 @@
const HEX_COLOR_PATTERN =
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
export const hexToRgb = (hex: string): [number, number, number] => {
const match = HEX_COLOR_PATTERN.exec(hex);
if (!match?.groups) {
return [0, 0, 0];
}
const { red, green, blue } = match.groups;
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
};