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

View file

@ -2,7 +2,7 @@
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette, Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
draw persistent coloured paths, spawn agents from those strokes, erase locally, 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). 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); }, canvasName);
await page.goto('/'); 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/, { await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000, timeout: 30_000,
}); });

View file

@ -95,7 +95,7 @@
<p> <p>
Switch vibes to recolour the whole garden without clearing your drawing. Add 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 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>
<p> <p>
Built with WebGPU and running locally in your browser. Source on Built with WebGPU and running locally in your browser. Source on
@ -185,8 +185,8 @@
</div> </div>
<button <button
class="export-4k" class="export-4k"
aria-label="Download 4K upscale image" aria-label="Download internal buffer snapshot"
title="Download 4K upscale of the live simulation" title="Download internal buffer snapshot"
></button> ></button>
<span class="export-status" aria-live="polite"></span> <span class="export-status" aria-live="polite"></span>
<button <button

View file

@ -60,7 +60,7 @@ export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
track('Export', { track('Export', {
props: { props: {
format: 'png', format: 'png',
resolution: '4k', resolution: 'internal-buffer',
vibeId, 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'; import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor'; type GardenAudioChordQuality = 'major' | 'minor';
@ -86,5 +85,3 @@ export interface GardenAudioConfig {
maniaSmoothingSeconds: number; maniaSmoothingSeconds: number;
}; };
} }
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

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

View file

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

View file

@ -1,9 +1,13 @@
import { clamp } from '../utils/math'; import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types'; 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 outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = { const graphTuning = {
closeGain: 0.0001, closeGain: 0.0001,
closeRampSeconds: 0.015, closeRampSeconds: 0.015,
@ -13,12 +17,6 @@ const graphTuning = {
noiseMin: -1, noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory, latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType, outputFilterType: 'highpass' as BiquadFilterType,
noiseBufferChannels: 1,
noiseBufferDurationSeconds: 1,
unlockTickFrequencyHz: 440,
unlockTickGain: 0.0001,
unlockTickSeconds: 0.025,
unlockTickType: 'sine' as OscillatorType,
compressor: { compressor: {
thresholdDb: -18, thresholdDb: -18,
kneeDb: 18, kneeDb: 18,
@ -44,8 +42,10 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null; private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null; private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null; private delayOutput: GainNode | null = null;
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null; private lastPianoBusActivity = 0;
private mediaStreamElement: HTMLAudioElement | null = null; private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>(); private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {} public constructor(private readonly config: GardenAudioConfig) {}
@ -64,20 +64,20 @@ export class GardenAudioGraph {
return null; return null;
} }
let context: AudioContext; // Tells iOS to treat this as media playback, so the hardware ringer/mute
try { // switch does not silence Web Audio output. No-op on browsers without the
context = new AudioContextConstructor({ // Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = 'playback';
}
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint, latencyHint: graphTuning.latencyHint,
}); });
} catch {
context = new AudioContextConstructor();
}
const masterGain = context.createGain(); const masterGain = context.createGain();
const highPass = context.createBiquadFilter(); const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor(); const compressor = context.createDynamicsCompressor();
const mediaStreamDestination = isIosLike()
? context.createMediaStreamDestination()
: null;
masterGain.gain.value = 0; masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType; highPass.type = graphTuning.outputFilterType;
@ -90,15 +90,10 @@ export class GardenAudioGraph {
masterGain.connect(highPass); masterGain.connect(highPass);
highPass.connect(compressor); highPass.connect(compressor);
if (mediaStreamDestination) {
compressor.connect(mediaStreamDestination);
} else {
compressor.connect(context.destination); compressor.connect(context.destination);
}
this.context = context; this.context = context;
this.masterGain = masterGain; this.masterGain = masterGain;
this.mediaStreamDestination = mediaStreamDestination;
this.noiseBuffer = this.createNoiseBuffer(context); this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain); this.createDelay(context, masterGain);
this.createBuses(context, masterGain); this.createBuses(context, masterGain);
@ -106,35 +101,6 @@ export class GardenAudioGraph {
return context; 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 { public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) { if (!this.context || !this.masterGain) {
return; 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 { public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) { if (!this.context || !this.delayNode) {
return; return;
@ -228,6 +162,20 @@ export class GardenAudioGraph {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus; 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> { public async close(): Promise<void> {
const context = this.context; const context = this.context;
if (!context) { if (!context) {
@ -304,27 +252,33 @@ export class GardenAudioGraph {
this.noiseBus.connect(eventBus); 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) => { this.pianoBuses.forEach((bus, role) => {
const baseGain = this.config.graph.pianoBusGains[role]; const baseGain = this.config.graph.pianoBusGains[role];
const ducking = this.config.graph.pianoBusActivityDucking[role]; const ducking = this.config.graph.pianoBusActivityDucking[role];
bus.gain.setTargetAtTime( bus.gain.setTargetAtTime(
Math.max(0, baseGain * (1 - activity * ducking)), Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now, now,
this.config.updateRampSeconds effectiveTimeConstantSeconds
); );
}); });
} }
private createNoiseBuffer(context: AudioContext): AudioBuffer { private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer( const buffer = context.createBuffer(
appPositiveInteger(graphTuning.noiseBufferChannels),
Math.max(
1, 1,
Math.floor( Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
)
),
context.sampleRate context.sampleRate
); );
const data = buffer.getChannelData(0); const data = buffer.getChannelData(0);
@ -348,16 +302,10 @@ export class GardenAudioGraph {
this.delayNode = null; this.delayNode = null;
this.delayFeedback = null; this.delayFeedback = null;
this.delayOutput = null; this.delayOutput = null;
this.mediaStreamDestination = null; this.lastPianoBusActivity = 0;
if (this.mediaStreamElement) { this.pianoBusGainScale = 1;
this.mediaStreamElement.pause(); this.pianoBusGainScaleAutomationUntil = 0;
this.mediaStreamElement.srcObject = null; this.pianoBusGainScaleTimeConstantSeconds = 0;
this.mediaStreamElement.remove();
this.mediaStreamElement = null;
}
this.pianoBuses.clear(); 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'; import type { GardenAudioStroke } from './garden-audio-types';
const fallbackNormalizationPixels = 1000;
const fallbackFrameSeconds = 1 / 60;
const minElapsedSeconds = 0.001; const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics { export interface GardenAudioStrokeMetrics {
distancePixels: number;
elapsedSeconds: number; elapsedSeconds: number;
normalizedDistance: number; normalizedDistance: number;
normalizedSpeed: number; normalizedSpeed: number;
@ -15,42 +12,13 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
const dx = stroke.to[0] - stroke.from[0]; const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1]; const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy); const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = getElapsedSeconds(stroke); const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke); const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
const normalizedDistance = distancePixels / normalizationPixels;
return { return {
distancePixels,
elapsedSeconds, elapsedSeconds,
normalizedDistance, normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds, 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 { 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; 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 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; vibe: VibePreset;
from: ArrayLike<number>; from: ArrayLike<number>;
to: ArrayLike<number>; to: ArrayLike<number>;
canvasSize?: ArrayLike<number>; canvasSize: ArrayLike<number>;
isErasing: boolean; isErasing: boolean;
elapsedSeconds?: number; elapsedSeconds: number;
} }
export interface GardenAudioStartOptions { export interface GardenAudioStartOptions {
@ -23,12 +23,6 @@ export interface LoadedPianoSample {
buffer: AudioBuffer; buffer: AudioBuffer;
} }
export interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
export interface PianoNote { export interface PianoNote {
midi: number; midi: number;
velocity: number; velocity: number;

View file

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

View file

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

View file

@ -5,17 +5,7 @@ import type {
GardenAudioConfig, GardenAudioConfig,
GardenAudioVibeProfile, GardenAudioVibeProfile,
} from './garden-audio-config'; } from './garden-audio-config';
import { import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
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 type { PianoNote } from './garden-audio-types'; import type { PianoNote } from './garden-audio-types';
import { import {
generativePianoTuning, generativePianoTuning,
@ -23,6 +13,39 @@ import {
type GardenAudioRegister, type GardenAudioRegister,
type GardenAudioStylePool, type GardenAudioStylePool,
} from './generative-piano-tuning'; } 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; type GardenAudioStyleIndex = 0 | 1 | 2;
@ -124,6 +147,23 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false; 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({ private recordTouchDown({
vibe, vibe,
now, now,
@ -234,47 +274,27 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0); const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true); const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [ const stinger = this.generation.vibeChangeStinger;
{ const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
midi: this.chooseMidi( [0],
{ baseMidi: rootMidi, offsets: [0] }, [intervals[1], intervals[2]],
this.generation.padRegisters[0] [intervals[3], intervals[2]],
),
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],
},
]; ];
notes.forEach((note, index) => { offsetsByVoice.forEach((offsets, index) => {
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
);
this.playNote({ this.playNote({
...note, midi,
durationSeconds: this.generation.stingerDurationSeconds, velocity: stinger.velocities[index],
pan: stinger.pans[index],
delaySend: stinger.delaySends[index],
durationSeconds: stinger.noteDurationSeconds,
role: 'stinger', role: 'stinger',
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
profile, startTime: now + index * stinger.spacingSeconds,
note.midi,
this.generation.vibeChangeStinger.lowpassExpression
),
startTime: now + index * this.generation.stingerSpacingSeconds,
}); });
}); });
} }
@ -285,12 +305,8 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false; this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY; this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY; this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
this.lastMidiByStyle[0] = null; this.lastMidiByStyle.fill(null);
this.lastMidiByStyle[1] = null; this.lastPadMidiByVoice.fill(null);
this.lastMidiByStyle[2] = null;
this.lastPadMidiByVoice[0] = null;
this.lastPadMidiByVoice[1] = null;
this.lastPadMidiByVoice[2] = null;
this.brushPhraseLayers = []; this.brushPhraseLayers = [];
this.nextBrushStreamStep = null; this.nextBrushStreamStep = null;
this.brushStreamNoteIndex = 0; 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( private playSupportNote(
profile: GardenAudioVibeProfile, profile: GardenAudioVibeProfile,
barIndex: number, barIndex: number,
@ -546,10 +593,6 @@ export class GenerativePianoEngine {
}): void { }): void {
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex]; const pool = this.generation.stylePools[styleIndex];
const register = this.getBiasedRegister(
pool,
this.generation.touchNote.registerBiasManiaAmount
);
const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
@ -558,7 +601,7 @@ export class GenerativePianoEngine {
baseMidi: rootMidi, baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex), offsets: this.getSupportOffsets(chordIntervals, styleIndex),
}, },
register, pool,
this.lastMidiByStyle[styleIndex], this.lastMidiByStyle[styleIndex],
true true
); );
@ -877,9 +920,7 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number { private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats = const intervalBeats =
intensity >= this.generation.brushStream.manicThreshold intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamManicIntervalBeats
: intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats ? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold : intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats ? this.generation.brushStreamActiveIntervalBeats
@ -888,30 +929,17 @@ export class GenerativePianoEngine {
} }
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number { 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; const ageSeconds = startTime - layer.startedAt;
return clamp01( return clamp01(1 - ageSeconds / lifetimeSeconds);
1 -
ageSeconds /
Math.max(
this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
lifetimeSeconds
)
);
} }
private getMotifOffset(strength: number): number { private getMotifOffset(strength: number): number {
const energyStep = return strength >= this.generation.brushMotif.highThreshold
strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset ? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold : strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset ? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset; : this.generation.brushMotif.lowOffset;
return clamp(
energyStep,
this.generation.brushMotif.minOffset,
this.generation.brushMotif.maxOffset
);
} }
private getBrushMotifDegrees({ 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 { private getNextBarTimeAt(startTime: number): number {
const nextBarStep = const nextBarStep =
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) * 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 { GardenAudioGraph } from './garden-audio-graph';
import type { NoiseBurst } from './garden-audio-types'; import type { NoiseBurst } from './garden-audio-types';
@ -15,9 +15,8 @@ export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {} public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void { public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBus, noiseBuffer } = this.graph; const { context, noiseBus, noiseBuffer } = this.graph;
const outputBus = noiseBus ?? eventBus; if (!context || !noiseBus || !noiseBuffer) {
if (!context || !outputBus || !noiseBuffer) {
return; return;
} }
@ -28,7 +27,7 @@ export class NoiseBurstPlayer {
const source = context.createBufferSource(); const source = context.createBufferSource();
const filter = context.createBiquadFilter(); const filter = context.createBiquadFilter();
const envelope = context.createGain(); const envelope = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart); const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds; const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer; source.buffer = noiseBuffer;
@ -41,10 +40,11 @@ export class NoiseBurstPlayer {
scheduledStart + noiseBurstTuning.attackSeconds scheduledStart + noiseBurstTuning.attackSeconds
); );
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt); envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter); source.connect(filter);
filter.connect(envelope); filter.connect(envelope);
envelope.connect(panNode.input); envelope.connect(panner);
panNode.output.connect(outputBus); panner.connect(noiseBus);
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds); source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
source.stop(stopAt); source.stop(stopAt);
source.addEventListener( source.addEventListener(
@ -53,7 +53,7 @@ export class NoiseBurstPlayer {
source.disconnect(); source.disconnect();
filter.disconnect(); filter.disconnect();
envelope.disconnect(); envelope.disconnect();
panNode.disconnect(); panner.disconnect();
}, },
{ once: true } { once: true }
); );

View file

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

View file

@ -8,7 +8,6 @@ interface PianoSampleDefinition {
export interface PianoSampleLoadProgress { export interface PianoSampleLoadProgress {
loadedCount: number; loadedCount: number;
totalCount: number; totalCount: number;
sample?: PianoSampleDefinition;
} }
const sampleFiles: Array<[fileName: string, midi: number]> = [ 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 sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
const preloadDecode = {
channels: 2,
frames: 128,
sampleRateHz: 48_000,
};
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({ .map(([fileName, midi]) => ({
@ -61,10 +55,6 @@ const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
let loadedPianoSamples: Array<LoadedPianoSample> | null = null; let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null; let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
interface PianoSampleLoadOptions {
forceReload?: boolean;
}
const sampleLoadTuning = { const sampleLoadTuning = {
concurrency: 4, concurrency: 4,
sampleTimeoutMs: 15_000, sampleTimeoutMs: 15_000,
@ -81,18 +71,14 @@ export const preloadPianoSamples = (
); );
} }
const decodeContext = new OfflineAudioContextConstructor( // Decoding ignores these, but the constructor demands real numbers.
preloadDecode.channels, const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
preloadDecode.frames,
preloadDecode.sampleRateHz
);
return loadPianoSamples(decodeContext, onProgress); return loadPianoSamples(decodeContext, onProgress);
}; };
export const loadPianoSamples = ( export const loadPianoSamples = (
decodeContext: BaseAudioContext, decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void, onProgress?: (progress: PianoSampleLoadProgress) => void
options: PianoSampleLoadOptions = {}
): Promise<Array<LoadedPianoSample>> => { ): Promise<Array<LoadedPianoSample>> => {
if (loadedPianoSamples) { if (loadedPianoSamples) {
onProgress?.({ onProgress?.({
@ -102,7 +88,7 @@ export const loadPianoSamples = (
return Promise.resolve([...loadedPianoSamples]); return Promise.resolve([...loadedPianoSamples]);
} }
if (pianoSampleLoadPromise && options.forceReload !== true) { if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise; return pianoSampleLoadPromise;
} }
@ -115,12 +101,12 @@ export const loadPianoSamples = (
async (sample) => { async (sample) => {
try { try {
return await withTimeout( return await withTimeout(
loadPianoSample(decodeContext, sample), (signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs sampleLoadTuning.sampleTimeoutMs
); );
} finally { } finally {
loadedCount += 1; loadedCount += 1;
onProgress?.({ loadedCount, totalCount, sample }); onProgress?.({ loadedCount, totalCount });
} }
} }
).then( ).then(
@ -147,15 +133,16 @@ export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
const loadPianoSample = async ( const loadPianoSample = async (
decodeContext: BaseAudioContext, decodeContext: BaseAudioContext,
sample: PianoSampleDefinition sample: PianoSampleDefinition,
signal: AbortSignal
): Promise<LoadedPianoSample> => { ): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url); const response = await fetch(sample.url, { signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`); throw new Error(`Unable to load piano sample ${sample.url}`);
} }
const audioData = await response.arrayBuffer(); const audioData = await response.arrayBuffer();
const buffer = await decodeAudioData(decodeContext, audioData); const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: sample.midi, buffer }; return { midi: sample.midi, buffer };
}; };
@ -178,13 +165,18 @@ const loadPianoSampleBatch = async (
return results; 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) => { new Promise((resolve, reject) => {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => { const timeout = globalThis.setTimeout(() => {
controller.abort();
reject(new Error('Timed out while loading a piano sample.')); reject(new Error('Timed out while loading a piano sample.'));
}, timeoutMs); }, timeoutMs);
promise.then( operation(controller.signal).then(
(value) => { (value) => {
globalThis.clearTimeout(timeout); globalThis.clearTimeout(timeout);
resolve(value); 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, maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240, minDeltaTimeSeconds: 1 / 240,
}, },
export4k: { exportSnapshot: {
bytesPerPixel: 4, bytesPerPixel: 4,
filenameExtension: 'png', filenameExtension: 'png',
filenamePrefix: 'fleeting-garden', filenamePrefix: 'fleeting-garden',
filenameSuffix: '-upscale', filenameSuffix: '-snapshot',
height: 2160,
jsHeapSafetyMultiplier: 1.5,
jsHeapTextureBytesMultiplier: 4,
lowMemoryDeviceGiB: 2,
lowMemoryExportFraction: 0.08,
mimeType: 'image/png', mimeType: 'image/png',
rowAlignmentBytes: 256, rowAlignmentBytes: 256,
width: 3840,
}, },
menuHider: { menuHider: {
bottomRevealDistancePx: 96, bottomRevealDistancePx: 96,
@ -148,17 +142,6 @@ export const appConfig = {
controls: runtimeControls, controls: runtimeControls,
}, },
simulation: { 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, brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 }, clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000, initialAgentCount: 180_000,

View file

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

View file

@ -35,18 +35,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
brushFeatherRatio: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.001,
},
brushMinimumFeather: {
folder: 'Brush',
min: 0,
max: 8,
step: 0.1,
},
brushDiscardThreshold: { brushDiscardThreshold: {
folder: 'Brush', folder: 'Brush',
min: 0, min: 0,
@ -255,6 +243,22 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1, max: 1,
step: 0.001, 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: { maxAgentCount: {
folder: 'Agent', folder: 'Agent',
integer: true, integer: true,
@ -299,12 +303,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
strokeSpawnSpreadBrushSizeMultiplier: {
folder: 'Agent',
min: 0,
max: 4,
step: 0.01,
},
turnSpeed: { turnSpeed: {
folder: 'Agent', folder: 'Agent',
min: 1, min: 1,
@ -317,24 +315,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1, max: 1,
step: 0.001, 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: { forwardRotationScale: {
folder: 'Agent', folder: 'Agent',
min: 0, 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 { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings'; import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings'; import type { RenderSettings } from '../pipelines/render/render-settings';
import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig { export interface NumberControlConfig {
folder: string; folder: string;
@ -18,6 +19,8 @@ export interface NumberControlConfig {
} }
export type GardenRuntimeSettings = { export type GardenRuntimeSettings = {
adaptiveCapInitial: number;
adaptiveCapMin: number;
brushCurveResolution: number; brushCurveResolution: number;
brushCurveMinBrushRadius: number; brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number; brushCurveMinSegmentSpacing: number;
@ -37,7 +40,6 @@ export type GardenRuntimeSettings = {
maxAgentCount: number; maxAgentCount: number;
selectedColorIndex: number; selectedColorIndex: number;
spawnPerPixel: number; spawnPerPixel: number;
strokeSpawnSpreadBrushSizeMultiplier: number;
} & AgentSettings & } & AgentSettings &
BrushSettings & BrushSettings &
DiffusionSettings & DiffusionSettings &
@ -78,8 +80,8 @@ export enum VibeId {
export interface VibePreset { export interface VibePreset {
id: VibeId; id: VibeId;
name: string; name: string;
colors: [string, string, string]; colors: [RgbColor, RgbColor, RgbColor];
backgroundColor: string; backgroundColor: RgbColor;
settings: GardenVibeSettings; settings: GardenVibeSettings;
audio: GardenAudioVibeProfile; audio: GardenAudioVibeProfile;
} }
@ -90,19 +92,13 @@ export interface GardenAppConfig {
maxDeltaTimeSeconds: number; maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number; minDeltaTimeSeconds: number;
}; };
export4k: { exportSnapshot: {
bytesPerPixel: number; bytesPerPixel: number;
filenameExtension: string; filenameExtension: string;
filenamePrefix: string; filenamePrefix: string;
filenameSuffix: string; filenameSuffix: string;
height: number;
jsHeapSafetyMultiplier: number;
jsHeapTextureBytesMultiplier: number;
lowMemoryDeviceGiB: number;
lowMemoryExportFraction: number;
mimeType: string; mimeType: string;
rowAlignmentBytes: number; rowAlignmentBytes: number;
width: number;
}; };
menuHider: { menuHider: {
bottomRevealDistancePx: number; bottomRevealDistancePx: number;
@ -136,17 +132,6 @@ export interface GardenAppConfig {
controls: RuntimeSettingControlConfig; controls: RuntimeSettingControlConfig;
}; };
simulation: { simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapInitial: number;
adaptiveCapMin: number;
adaptiveRefreshTargetFps: number;
frameGapResetSeconds: number;
fpsHeadroom: number;
fpsSmoothingNew: number;
fpsSmoothingRetain: number;
initialFps: number;
};
brushEffectFramesPerSecond: number; brushEffectFramesPerSecond: number;
clearColor: GPUColor; clearColor: GPUColor;
initialAgentCount: number; initialAgentCount: number;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,23 +2,17 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio'; import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
BrushPipeline, import { activeVibe } from '../settings';
getSafePixelRatio, import { BrushStrokeSmoother } from './brush-stroke-smoother';
} from '../pipelines/brush/brush-pipeline'; import { type StrokeSegment } from './game-loop-types';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { getMirroredStrokeSegments } from './stroke-mirroring';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { type StrokeOutput } from './stroke-output';
import { activeVibe, settings } from '../settings';
import { EraserPreview } from './eraser-preview';
import { StrokeSegment } from './game-loop-types';
interface GardenPointerInputOptions { interface GardenPointerInputOptions {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
audio: GardenAudio; audio: GardenAudio;
brushPipeline: BrushPipeline; strokeOutput: StrokeOutput;
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
eraserPreview: EraserPreview;
getCanvasPixelRatio: () => number; getCanvasPixelRatio: () => number;
getMirrorSegmentCount: () => number; getMirrorSegmentCount: () => number;
onStartDrawing: () => void; onStartDrawing: () => void;
@ -26,19 +20,28 @@ interface GardenPointerInputOptions {
spawnStrokeAgents: (from: vec2, to: vec2) => void; spawnStrokeAgents: (from: vec2, to: vec2) => void;
} }
interface PointerSample {
position: vec2;
previousPosition: vec2;
elapsedSeconds: number;
timeStamp: number;
}
export class GardenPointerInput { export class GardenPointerInput {
private readonly brushSmoother: BrushStrokeSmoother;
private activePointerId: number | null = null; private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null; private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null; private lastPointerEventTimeMs: number | null = null;
private smoothedStrokePoints: Array<vec2> = [];
private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false; 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 { public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown); this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove); this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp); this.canvas.addEventListener('pointerup', this.onPointerUp);
@ -46,8 +49,6 @@ export class GardenPointerInput {
} }
public detach(): void { public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown); this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove); this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp); this.canvas.removeEventListener('pointerup', this.onPointerUp);
@ -56,11 +57,6 @@ export class GardenPointerInput {
public setEraseMode(isErasing: boolean): void { public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing; 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 { public clearSwipesIfIdle(): void {
@ -68,9 +64,7 @@ export class GardenPointerInput {
return; return;
} }
this.options.brushPipeline.clearSwipes(); this.options.strokeOutput.clearSwipes();
this.options.eraserAgentPipeline.clearSwipes();
this.options.eraserTexturePipeline.clearSwipes();
} }
public scaleLastPointerPosition(scale: vec2): void { public scaleLastPointerPosition(scale: vec2): void {
@ -78,13 +72,7 @@ export class GardenPointerInput {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale); vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
} }
this.smoothedStrokePoints.forEach((point) => { this.brushSmoother.scale(scale);
vec2.mul(point, point, scale);
});
if (this.lastSmoothedBrushPosition !== null) {
vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
}
} }
public get isSwipeActive(): boolean { public get isSwipeActive(): boolean {
@ -100,8 +88,6 @@ export class GardenPointerInput {
} }
private readonly onPointerDown = (event: PointerEvent) => { private readonly onPointerDown = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.updateEraserPreview(event);
if (this.activePointerId !== null) { if (this.activePointerId !== null) {
return; return;
} }
@ -111,17 +97,14 @@ export class GardenPointerInput {
this.options.onStartDrawing(); this.options.onStartDrawing();
this.activePointerId = event.pointerId; this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId); this.canvas.setPointerCapture(event.pointerId);
this.options.brushPipeline.clearSwipes(); this.options.strokeOutput.clearSwipes();
this.options.eraserAgentPipeline.clearSwipes();
this.options.eraserTexturePipeline.clearSwipes();
this.lastPointerPosition = null; this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null; this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke(); this.brushSmoother.clear();
this.addSwipeAt(event, { emitAudio: false }); this.addSwipeAt(event, { emitAudio: false });
}; };
private readonly onPointerMove = (event: PointerEvent) => { private readonly onPointerMove = (event: PointerEvent) => {
this.updateEraserPreview(event);
if (event.pointerId !== this.activePointerId) { if (event.pointerId !== this.activePointerId) {
return; return;
} }
@ -136,7 +119,7 @@ export class GardenPointerInput {
} }
this.options.audio.start(activeVibe, { userGesture: true }); this.options.audio.start(activeVibe, { userGesture: true });
this.addSwipeAt(event, { emitAudio: false }); this.addSwipeAt(event, { emitAudio: false });
this.finishSmoothedStroke(); this.finishBrushStroke();
this.options.audio.endGesture(); this.options.audio.endGesture();
if (this.isErasing) { if (this.isErasing) {
this.options.onEraseGestureEnded(); this.options.onEraseGestureEnded();
@ -145,24 +128,27 @@ export class GardenPointerInput {
this.activePointerId = null; this.activePointerId = null;
this.lastPointerPosition = null; this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null; this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke(); this.brushSmoother.clear();
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();
}; };
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { 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 position = this.getCanvasPointerPosition(event);
const previousPosition = this.lastPointerPosition ?? position; const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
@ -171,34 +157,37 @@ export class GardenPointerInput {
(event.timeStamp - previousTimeMs) / 1000 (event.timeStamp - previousTimeMs) / 1000
); );
const segments = this.isErasing return {
? [{ from: previousPosition, to: position }] position,
: this.getMirroredStrokeSegments(previousPosition, position); previousPosition,
elapsedSeconds,
if (this.isErasing) { timeStamp: event.timeStamp,
segments.forEach((segment) => { };
this.options.eraserAgentPipeline.addSwipeSegment();
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
});
} else {
this.addSmoothedBrushSample(position);
segments.forEach((segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
});
} }
if (options.emitAudio !== false) {
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);
}
);
}
private addEraseSample(sample: PointerSample): void {
this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position);
}
private emitStrokeAudio(sample: PointerSample): void {
this.options.audio.stroke({ this.options.audio.stroke({
vibe: activeVibe, vibe: activeVibe,
from: previousPosition, from: sample.previousPosition,
to: position, to: sample.position,
canvasSize: [this.canvas.width, this.canvas.height], canvasSize: [this.canvas.width, this.canvas.height],
isErasing: this.isErasing, isErasing: this.isErasing,
elapsedSeconds, elapsedSeconds: sample.elapsedSeconds,
}); });
} }
this.lastPointerPosition = position;
this.lastPointerEventTimeMs = event.timeStamp;
}
private getCanvasPointerPosition(event: PointerEvent): vec2 { private getCanvasPointerPosition(event: PointerEvent): vec2 {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
@ -210,103 +199,23 @@ export class GardenPointerInput {
); );
} }
private addSmoothedBrushSample(position: vec2): void { private addBrushSegments(segments: Array<StrokeSegment>): void {
const previousSample = segments.forEach((segment) => {
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
if ( this.options.strokeOutput.addBrushSegment(
previousSample !== undefined && mirroredSegment.from,
vec2.squaredDistance(previousSample, position) <= mirroredSegment.to
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 finishSmoothedStroke(): void { private finishBrushStroke(): void {
if (this.isErasing || this.smoothedStrokePoints.length === 0) { if (this.isErasing) {
return; return;
} }
const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; this.addBrushSegments(this.brushSmoother.finish());
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;
} }
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> { private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
@ -326,66 +235,15 @@ export class GardenPointerInput {
: [...coalescedEvents, event]; : [...coalescedEvents, event];
} }
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> { private getMirroredSegments(from: vec2, to: vec2): Array<StrokeSegment> {
const segmentCount = this.options.getMirrorSegmentCount(); return getMirroredStrokeSegments(
if (segmentCount <= 1) { from,
return [{ from, to }]; to,
} vec2.fromValues(this.canvas.width, this.canvas.height),
this.options.getMirrorSegmentCount()
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;
}
}
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 => const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
left.clientX === right.clientX && left.clientX === right.clientX &&

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

View file

@ -22,7 +22,7 @@ export class SimulationTextures {
this.sourceMapB = this.createTexture(canvasSize); this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapA = this.createTexture(canvasSize); this.influenceMapA = this.createTexture(canvasSize);
this.influenceMapB = this.createTexture(canvasSize); this.influenceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createTexture(canvasSize); this.eraserMask = this.createEraserMask(canvasSize);
} }
public resizeTo(nextSize: vec2): vec2 | null { public resizeTo(nextSize: vec2): vec2 | null {
@ -97,4 +97,16 @@ export class SimulationTextures {
private createTexture(size: vec2): ResizableTexture { private createTexture(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, size); 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'; } from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu'; import { initializeGpu } from './utils/graphics/initialize-gpu';
import { clamp01 } from './utils/math'; import { clamp01 } from './utils/math';
import { rgbColorToCss } from './utils/rgb-color';
import { VIBE_PRESETS } from './vibes'; import { VIBE_PRESETS } from './vibes';
const AUDIO_VOLUME_STEP = 0.01; const AUDIO_VOLUME_STEP = 0.01;
@ -358,7 +359,7 @@ const renderAudioUi = (game: GameLoop | null) => {
const renderPaletteUi = (game: GameLoop | null) => { const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => { elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = activeVibe.colors[index]; swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle( swatch.classList.toggle(
CSS_CLASSES.active, CSS_CLASSES.active,
settings.selectedColorIndex === index && !isEraserActive settings.selectedColorIndex === index && !isEraserActive
@ -368,7 +369,7 @@ const renderPaletteUi = (game: GameLoop | null) => {
game?.setEraseMode(isEraserActive); game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
CSS_VARIABLES.gardenBackground, CSS_VARIABLES.gardenBackground,
activeVibe.backgroundColor rgbColorToCss(activeVibe.backgroundColor)
); );
}; };
@ -471,7 +472,7 @@ const main = async () => {
new FullScreenHandler( new FullScreenHandler(
elements.minimizeFullScreenButton, elements.minimizeFullScreenButton,
elements.maximizeFullScreenButton, elements.maximizeFullScreenButton,
document.body document.documentElement
); );
const startAudioFromUserGesture = (event: Event) => { const startAudioFromUserGesture = (event: Event) => {
@ -599,7 +600,7 @@ const main = async () => {
elements.export4k.disabled = true; elements.export4k.disabled = true;
try { try {
await game.export4K(); await game.exportSnapshot();
trackExport({ vibeId: activeVibe.id }); trackExport({ vibeId: activeVibe.id });
} catch (error) { } catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING }); 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); document.body.classList.add(CSS_CLASSES.preDrawing);
elements.canvas.addEventListener( elements.canvas.addEventListener(
DOM_EVENTS.pointerDown, DOM_EVENTS.pointerDown,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ struct Settings {
sensorOffset: f32, sensorOffset: f32,
turnWhenLost: f32, turnWhenLost: f32,
individualTrailWeight: f32, individualTrailWeight: f32,
agentCount: f32, agentCount: u32,
introProgress: f32, introProgress: f32,
color1ToColor1: f32, color1ToColor1: f32,
color1ToColor2: f32, color1ToColor2: f32,
@ -17,9 +17,6 @@ struct Settings {
color3ToColor1: f32, color3ToColor1: f32,
color3ToColor2: f32, color3ToColor2: f32,
color3ToColor3: f32, color3ToColor3: f32,
sourceAttractionWeight: f32,
sourceSlowMoveRate: f32,
sourceTrailWeightMultiplier: f32,
forwardRotationScale: f32, forwardRotationScale: f32,
introNearDistanceInner: f32, introNearDistanceInner: f32,
introNearDistanceMin: f32, introNearDistanceMin: f32,
@ -37,7 +34,6 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMapIn: texture_2d<f32>; @group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>; @group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
@group(1) @binding(4) var sourceMap: texture_2d<f32>;
@compute @workgroup_size(64) @compute @workgroup_size(64)
fn main( fn main(
@ -46,7 +42,7 @@ fn main(
) { ) {
let id = get_id(global_id, num_workgroups); let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) { if id >= settings.agentCount {
return; 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 channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex); let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id, state.time);
let trailForwardWeight = dot(trailForward.rgb, reactionMask); var rotation = 0.0;
let trailLeftWeight = dot(trailLeft.rgb, reactionMask); var step = vec2<f32>(0.0, 0.0);
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;
if hasIntroTarget { if hasIntroTarget {
introTargetOffset = targetPosition - position; let introTargetOffset = targetPosition - position;
introTargetDistance = length(introTargetOffset); let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x); let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep( let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner, settings.introNearDistanceInner,
@ -148,19 +96,46 @@ fn main(
+ (random_float(randomSeed + 1013904223u) - 0.5) * + (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost * settings.turnWhenLost *
settings.introRandomTurnMultiplier; settings.introRandomTurnMultiplier;
moveRate = min( let moveRate = min(
settings.moveRate * settings.moveRate *
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle), mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
introTargetDistance introTargetDistance
); );
}
var step = direction * moveRate;
if hasIntroTarget {
step = vec2<f32>(0.0, 0.0);
if introTargetDistance > settings.introStepStopDistance { if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate; 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); 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; 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); var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>( trailBelow = vec4<f32>(
trailBelow.rgb + channelMask * trailWeight, trailBelow.rgb + channelMask * settings.individualTrailWeight,
max(trailBelow.a, 0.0) max(trailBelow.a, 0.0)
); );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => {
Object.assign(settings, { Object.assign(settings, {
...buildSettings(vibe), ...buildSettings(vibe),
eraserSize: settings.eraserSize, eraserSize: settings.eraserSize,
adaptiveCapInitial: settings.adaptiveCapInitial,
adaptiveCapMin: settings.adaptiveCapMin,
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels, internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
maxAgentCount: settings.maxAgentCount, maxAgentCount: settings.maxAgentCount,
mirrorSegmentCount: settings.mirrorSegmentCount, 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; display: none;
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -10,20 +10,25 @@ export const smartCompile = (
code: concatenated, code: concatenated,
}); });
module.getCompilationInfo().then((info) => module.getCompilationInfo().then((info) => {
info.messages.forEach((message) => 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( ErrorHandler.addError(
{ {
info: Severity.INFO, info: Severity.INFO,
warning: Severity.WARNING, warning: Severity.WARNING,
error: Severity.ERROR, error: Severity.ERROR,
}[message.type], }[message.type],
`${message.message}\n${ `${message.message}\n${sourceLine}${fullSource}`
concatenated.split('\n')[message.lineNum - 1]
}\n\nCode:\n${concatenated}\n`
)
)
); );
});
});
return module; 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];
};