diff --git a/README.md b/README.md
index 1a7466c..77909bc 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
draw persistent coloured paths, spawn agents from those strokes, erase locally,
-and export the scene as a 4K wallpaper.
+and export the scene as an internal render buffer snapshot.
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
index ef4cfc3..f74b88d 100644
--- a/e2e/app.spec.ts
+++ b/e2e/app.spec.ts
@@ -68,6 +68,10 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
}, canvasName);
await page.goto('/');
+ const startButton = page.getByRole('button', { name: 'Start' });
+ await expect(startButton).toBeVisible();
+ await expect(startButton).toBeEnabled({ timeout: 30_000 });
+ await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
diff --git a/index.html b/index.html
index 16f5c13..9c3e01a 100644
--- a/index.html
+++ b/index.html
@@ -95,7 +95,7 @@
Switch vibes to recolour the whole garden without clearing your drawing. Add
or mute the generated piano, restart for a blank canvas, or export the current
- frame as a 4K image.
+ frame as an internal buffer snapshot.
Built with WebGPU and running locally in your browser. Source on
@@ -185,8 +185,8 @@
{
track('Export', {
props: {
format: 'png',
- resolution: '4k',
+ resolution: 'internal-buffer',
vibeId,
},
});
diff --git a/src/audio/audio-pan-node.ts b/src/audio/audio-pan-node.ts
deleted file mode 100644
index 0d1942e..0000000
--- a/src/audio/audio-pan-node.ts
+++ /dev/null
@@ -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(),
- };
-};
diff --git a/src/audio/audio-platform.ts b/src/audio/audio-platform.ts
deleted file mode 100644
index 642893b..0000000
--- a/src/audio/audio-platform.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const isIosLike = (): boolean =>
- /iPad|iPhone|iPod/.test(navigator.userAgent) ||
- (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index b6722a5..baa8eb4 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -1,4 +1,3 @@
-import { appConfig } from '../config';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
@@ -86,5 +85,3 @@ export interface GardenAudioConfig {
maniaSmoothingSeconds: number;
};
}
-
-export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts
index f6e689d..7ab46f2 100644
--- a/src/audio/garden-audio-energy.ts
+++ b/src/audio/garden-audio-energy.ts
@@ -19,20 +19,14 @@ export class GardenAudioEnergy {
this.targetEnergy = 0;
}
- public recordStroke(strokeEnergy: number, now: number): void {
- const energy = clamp01(strokeEnergy);
- this.targetEnergy = Math.max(this.targetEnergy, energy);
+ public recordStroke(strokeEnergy: number): void {
+ this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(
this.energy,
- energy * this.config.energy.immediateActivityScale
+ strokeEnergy * this.config.energy.immediateActivityScale
);
}
- this.lastEnergyUpdateAt ||= now;
- }
-
- public recordEraserStroke(): void {
- this.targetEnergy = 0;
}
public silence(): void {
@@ -46,7 +40,7 @@ export class GardenAudioEnergy {
return;
}
- const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
+ const elapsedSeconds = now - this.lastEnergyUpdateAt;
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.config.energy.strokeDecaySeconds
@@ -62,14 +56,6 @@ export class GardenAudioEnergy {
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
- public getActivity(): number {
- if (!this.isGestureActive) {
- return 0;
- }
-
- return this.getLevel();
- }
-
public getLevel(): number {
return clamp01(this.energy);
}
diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts
index 7d85364..abd27f7 100644
--- a/src/audio/garden-audio-gesture-state.ts
+++ b/src/audio/garden-audio-gesture-state.ts
@@ -8,28 +8,17 @@ interface GardenAudioGestureFrame {
}
export class GardenAudioGestureState {
- private gestureClockSeconds = 0;
private activity = 0;
private maniaAmount = 0;
private isManic = false;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
- public beginGesture(): void {
- this.reset();
- }
-
- public endGesture(): void {
- this.reset();
- }
-
public recordStroke({
metrics,
}: {
metrics: GardenAudioStrokeMetrics;
}): GardenAudioGestureFrame {
- this.gestureClockSeconds += metrics.elapsedSeconds;
-
const targetActivity = this.getTargetActivity(metrics);
const activityTimeConstant =
targetActivity > this.activity
@@ -65,29 +54,21 @@ export class GardenAudioGestureState {
}
public reset(): void {
- this.gestureClockSeconds = 0;
this.activity = 0;
this.maniaAmount = 0;
this.isManic = false;
}
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
- const normalizedSpeed = Math.max(0, metrics.normalizedSpeed);
- const activeSpeed = Math.max(
- this.inputConfig.activityNoiseFloorSpeed,
- this.inputConfig.fullActivitySpeed
- );
+ const speedRange =
+ this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
const speedAmount = clamp01(
- (normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) /
- (activeSpeed - this.inputConfig.activityNoiseFloorSpeed)
+ (metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
);
const distanceAmount = clamp01(
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
);
- const activity = Math.pow(
- speedAmount,
- Math.max(0.001, this.inputConfig.activityCurve)
- );
+ const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}
diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts
index 2862e5a..628bfe0 100644
--- a/src/audio/garden-audio-graph.ts
+++ b/src/audio/garden-audio-graph.ts
@@ -1,9 +1,13 @@
import { clamp } from '../utils/math';
-import { isIosLike } from './audio-platform';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
+type NavigatorWithAudioSession = Navigator & {
+ audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
+};
+
const outputHighPassFrequencyHz = 45;
+const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: 0.0001,
closeRampSeconds: 0.015,
@@ -13,12 +17,6 @@ const graphTuning = {
noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType,
- noiseBufferChannels: 1,
- noiseBufferDurationSeconds: 1,
- unlockTickFrequencyHz: 440,
- unlockTickGain: 0.0001,
- unlockTickSeconds: 0.025,
- unlockTickType: 'sine' as OscillatorType,
compressor: {
thresholdDb: -18,
kneeDb: 18,
@@ -44,8 +42,10 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
- private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
- private mediaStreamElement: HTMLAudioElement | null = null;
+ private lastPianoBusActivity = 0;
+ private pianoBusGainScale = 1;
+ private pianoBusGainScaleAutomationUntil = 0;
+ private pianoBusGainScaleTimeConstantSeconds = 0;
private readonly pianoBuses = new Map();
public constructor(private readonly config: GardenAudioConfig) {}
@@ -64,20 +64,20 @@ export class GardenAudioGraph {
return null;
}
- let context: AudioContext;
- try {
- context = new AudioContextConstructor({
- latencyHint: graphTuning.latencyHint,
- });
- } catch {
- context = new AudioContextConstructor();
+ // Tells iOS to treat this as media playback, so the hardware ringer/mute
+ // switch does not silence Web Audio output. No-op on browsers without the
+ // Audio Session API.
+ const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
+ if (audioSession) {
+ audioSession.type = 'playback';
}
+
+ const context = new AudioContextConstructor({
+ latencyHint: graphTuning.latencyHint,
+ });
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
- const mediaStreamDestination = isIosLike()
- ? context.createMediaStreamDestination()
- : null;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
@@ -90,15 +90,10 @@ export class GardenAudioGraph {
masterGain.connect(highPass);
highPass.connect(compressor);
- if (mediaStreamDestination) {
- compressor.connect(mediaStreamDestination);
- } else {
- compressor.connect(context.destination);
- }
+ compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
- this.mediaStreamDestination = mediaStreamDestination;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
@@ -106,35 +101,6 @@ export class GardenAudioGraph {
return context;
}
- // iOS WebKit can report "running" while the hardware output is still silent.
- // A very short, nearly inaudible oscillator in the gesture stack is more
- // reliable than a fully silent buffer on recent Safari versions.
- public unlock(): void {
- if (!this.context) {
- return;
- }
-
- const now = this.context.currentTime;
- const source = this.context.createOscillator();
- const gain = this.context.createGain();
-
- source.type = graphTuning.unlockTickType;
- source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
- gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
- source.connect(gain);
- gain.connect(this.context.destination);
- source.start(now);
- source.stop(now + graphTuning.unlockTickSeconds);
- source.addEventListener(
- 'ended',
- () => {
- source.disconnect();
- gain.disconnect();
- },
- { once: true }
- );
- }
-
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@@ -147,38 +113,6 @@ export class GardenAudioGraph {
);
}
- public startMediaElementOutput(): void {
- if (!this.mediaStreamDestination) {
- return;
- }
-
- const mediaElement = this.ensureMediaStreamElement();
- const playPromise = mediaElement.play();
- void playPromise?.catch(() => undefined);
- }
-
- private ensureMediaStreamElement(): HTMLAudioElement {
- if (this.mediaStreamElement) {
- return this.mediaStreamElement;
- }
-
- const mediaElement = document.createElement('audio');
- mediaElement.autoplay = true;
- mediaElement.volume = 1;
- mediaElement.setAttribute('playsinline', '');
- mediaElement.setAttribute('aria-hidden', 'true');
- mediaElement.style.position = 'fixed';
- mediaElement.style.width = '1px';
- mediaElement.style.height = '1px';
- mediaElement.style.opacity = '0';
- mediaElement.style.pointerEvents = 'none';
- mediaElement.style.left = '-9999px';
- mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
- document.body.append(mediaElement);
- this.mediaStreamElement = mediaElement;
- return mediaElement;
- }
-
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) {
return;
@@ -228,6 +162,20 @@ export class GardenAudioGraph {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
}
+ public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
+ if (!this.context) {
+ this.pianoBusGainScale = clamp(targetScale, 0, 1);
+ return;
+ }
+
+ const now = this.context.currentTime;
+
+ this.pianoBusGainScale = clamp(targetScale, 0, 1);
+ this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
+ this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
+ this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
+ }
+
public async close(): Promise {
const context = this.context;
if (!context) {
@@ -304,27 +252,33 @@ export class GardenAudioGraph {
this.noiseBus.connect(eventBus);
}
- private updatePianoBusGains(activity: number, now: number): void {
+ private updatePianoBusGains(
+ activity: number,
+ now: number,
+ timeConstantSeconds?: number
+ ): void {
+ const effectiveTimeConstantSeconds =
+ timeConstantSeconds ??
+ (now < this.pianoBusGainScaleAutomationUntil
+ ? this.pianoBusGainScaleTimeConstantSeconds
+ : this.config.updateRampSeconds);
+
+ this.lastPianoBusActivity = activity;
this.pianoBuses.forEach((bus, role) => {
const baseGain = this.config.graph.pianoBusGains[role];
const ducking = this.config.graph.pianoBusActivityDucking[role];
bus.gain.setTargetAtTime(
- Math.max(0, baseGain * (1 - activity * ducking)),
+ Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now,
- this.config.updateRampSeconds
+ effectiveTimeConstantSeconds
);
});
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
- appPositiveInteger(graphTuning.noiseBufferChannels),
- Math.max(
- 1,
- Math.floor(
- context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
- )
- ),
+ 1,
+ Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate
);
const data = buffer.getChannelData(0);
@@ -348,16 +302,10 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
- this.mediaStreamDestination = null;
- if (this.mediaStreamElement) {
- this.mediaStreamElement.pause();
- this.mediaStreamElement.srcObject = null;
- this.mediaStreamElement.remove();
- this.mediaStreamElement = null;
- }
+ this.lastPianoBusActivity = 0;
+ this.pianoBusGainScale = 1;
+ this.pianoBusGainScaleAutomationUntil = 0;
+ this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}
-
-const appPositiveInteger = (value: number): number =>
- Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));
diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts
index ffe4bef..aa0e34a 100644
--- a/src/audio/garden-audio-input.ts
+++ b/src/audio/garden-audio-input.ts
@@ -1,11 +1,8 @@
import type { GardenAudioStroke } from './garden-audio-types';
-const fallbackNormalizationPixels = 1000;
-const fallbackFrameSeconds = 1 / 60;
const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
- distancePixels: number;
elapsedSeconds: number;
normalizedDistance: number;
normalizedSpeed: number;
@@ -15,42 +12,13 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
- const elapsedSeconds = getElapsedSeconds(stroke);
- const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
+ const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
+ const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
+ const normalizedDistance = distancePixels / normalizationPixels;
return {
- distancePixels,
elapsedSeconds,
normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds,
};
};
-
-const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
- if (
- stroke.elapsedSeconds !== undefined &&
- Number.isFinite(stroke.elapsedSeconds) &&
- stroke.elapsedSeconds > 0
- ) {
- return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
- }
-
- return fallbackFrameSeconds;
-};
-
-const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
- const width = stroke.canvasSize?.[0];
- const height = stroke.canvasSize?.[1];
- if (
- width !== undefined &&
- height !== undefined &&
- Number.isFinite(width) &&
- Number.isFinite(height) &&
- width > 0 &&
- height > 0
- ) {
- return Math.max(1, Math.min(width, height));
- }
-
- return fallbackNormalizationPixels;
-};
diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts
index 8b4ee2d..695964f 100644
--- a/src/audio/garden-audio-music.ts
+++ b/src/audio/garden-audio-music.ts
@@ -1,36 +1,6 @@
import type { VibePreset } from '../vibes';
-import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
+import type { GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
-const chordVoicings = {
- majorOpen: [0, 7, 12, 16],
- minorOpen: [0, 7, 12, 15],
- majorClosed: [0, 4, 7, 12, 16],
- minorClosed: [0, 3, 7, 12, 15],
-};
-
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
-
-export const getChordIntervals = (
- chord: GardenAudioChord,
- openVoicing: boolean
-): Array => {
- 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;
-};
diff --git a/src/audio/garden-audio-scheduling.ts b/src/audio/garden-audio-scheduling.ts
deleted file mode 100644
index 5174442..0000000
--- a/src/audio/garden-audio-scheduling.ts
+++ /dev/null
@@ -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;
diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts
index b0d58a9..72e19ef 100644
--- a/src/audio/garden-audio-types.ts
+++ b/src/audio/garden-audio-types.ts
@@ -9,9 +9,9 @@ export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike;
to: ArrayLike;
- canvasSize?: ArrayLike;
+ canvasSize: ArrayLike;
isErasing: boolean;
- elapsedSeconds?: number;
+ elapsedSeconds: number;
}
export interface GardenAudioStartOptions {
@@ -23,12 +23,6 @@ export interface LoadedPianoSample {
buffer: AudioBuffer;
}
-export interface ActivePianoVoice {
- gain: GainNode;
- source: AudioScheduledSourceNode;
- stopAt: number;
-}
-
export interface PianoNote {
midi: number;
velocity: number;
diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts
index 024a6cb..668564e 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -16,18 +16,12 @@ import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
-export type {
- GardenAudioSnapshot,
- GardenAudioStartOptions,
- GardenAudioStroke,
-} from './garden-audio-types';
-
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
const muteGain = 0.0001;
const muteRampSeconds = 0.02;
-const brushUpPianoFinishSeconds = 1.2;
-const brushUpPianoFadeSeconds = 1.1;
+const brushUpPianoBusFadeSeconds = 2.4;
+const brushUpPianoBusFadeSettleSeconds = 3.2;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
@@ -40,12 +34,12 @@ export class GardenAudio {
private currentVibeId: VibeId | null = null;
private lifecycle: AudioLifecycle = 'idle';
+ private isReleasingPiano = false;
private isMuted = false;
private isGestureActive = false;
- private isPianoStoppedAfterGesture = false;
- private fadePianoAfter: number | null = null;
+ private fadePianoAt: number | null = null;
private masterVolume: number;
- private stopPianoAfter: number | null = null;
+ private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@@ -75,33 +69,16 @@ export class GardenAudio {
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
- let resumePromise: Promise | null = null;
-
- if (isUserGesture) {
- this.graph.startMediaElementOutput();
- this.graph.unlock();
- }
if (needsResume) {
if (!isUserGesture) {
return;
}
- resumePromise = context.resume();
- }
-
- if (isUserGesture) {
- this.graph.unlock();
- }
-
- if (resumePromise) {
- void resumePromise
+ void context
+ .resume()
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
- this.graph.unlock();
- this.completeStart(vibe, {
- context,
- startupRampSeconds,
- });
+ this.completeStart(vibe, { context, startupRampSeconds });
}
})
.catch((error) => {
@@ -113,10 +90,7 @@ export class GardenAudio {
return;
}
- this.completeStart(vibe, {
- context,
- startupRampSeconds,
- });
+ this.completeStart(vibe, { context, startupRampSeconds });
}
private completeStart(
@@ -207,23 +181,21 @@ export class GardenAudio {
}
this.isGestureActive = true;
- this.isPianoStoppedAfterGesture = false;
- this.fadePianoAfter = null;
- this.stopPianoAfter = null;
- this.gestureState.beginGesture();
+ this.isReleasingPiano = false;
+ this.fadePianoAt = null;
+ this.stopPianoAt = null;
+ this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
+ this.gestureState.reset();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
- this.gestureState.endGesture();
+ this.gestureState.reset();
this.isGestureActive = false;
- const context = this.graph.context;
- this.isPianoStoppedAfterGesture = true;
- this.fadePianoAfter = context
- ? context.currentTime + brushUpPianoFinishSeconds
- : null;
- this.stopPianoAfter = null;
+ this.isReleasingPiano = true;
+ this.fadePianoAt = null;
+ this.stopPianoAt = null;
this.energy.endGesture();
this.pianoEngine.endGesture();
}
@@ -241,17 +213,8 @@ export class GardenAudio {
this.energy.silence();
}
- if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
- if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
- this.piano.fadeAll(brushUpPianoFadeSeconds);
- this.fadePianoAfter = null;
- this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
- }
- if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
- this.piano.stopAll();
- this.pianoEngine.reset();
- this.stopPianoAfter = null;
- }
+ if (!this.isGestureActive && this.isReleasingPiano) {
+ this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot);
return;
}
@@ -287,12 +250,11 @@ export class GardenAudio {
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
- this.energy.recordEraserStroke();
this.playEraser(strokeEnergy, now);
return;
}
- this.energy.recordStroke(strokeEnergy, now);
+ this.energy.recordStroke(strokeEnergy);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
@@ -311,9 +273,9 @@ export class GardenAudio {
this.pianoEngine.reset();
this.currentVibeId = null;
this.isGestureActive = false;
- this.isPianoStoppedAfterGesture = false;
- this.fadePianoAfter = null;
- this.stopPianoAfter = null;
+ this.isReleasingPiano = false;
+ this.fadePianoAt = null;
+ this.stopPianoAt = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@@ -333,6 +295,25 @@ export class GardenAudio {
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
+ private updatePianoRelease(vibe: VibePreset, now: number): void {
+ if (this.fadePianoAt === null && this.stopPianoAt === null) {
+ this.fadePianoAt = this.pianoEngine.release(vibe, now);
+ }
+
+ if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
+ this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
+ this.fadePianoAt = null;
+ this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
+ }
+
+ if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
+ this.piano.stopAll();
+ this.pianoEngine.reset();
+ this.stopPianoAt = null;
+ this.isReleasingPiano = false;
+ }
+ }
+
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
diff --git a/src/audio/generative-piano-tuning.ts b/src/audio/generative-piano-tuning.ts
index 5d3625c..701d846 100644
--- a/src/audio/generative-piano-tuning.ts
+++ b/src/audio/generative-piano-tuning.ts
@@ -23,6 +23,16 @@ interface GenerativePianoTuning {
pans: [number, number, number];
delaySends: [number, number, number];
lowpassExpression: number;
+ noteDurationSeconds: number;
+ spacingSeconds: number;
+ };
+ releaseResolution: {
+ durationSeconds: number;
+ fadeAfterSeconds: number;
+ velocities: [number, number, number];
+ delaySend: number;
+ lowpassExpression: number;
+ strumSeconds: number;
};
highActivityExtra: {
barOffset: number;
@@ -67,7 +77,6 @@ interface GenerativePianoTuning {
delaySend: number;
};
touchNote: {
- registerBiasManiaAmount: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
@@ -80,7 +89,6 @@ interface GenerativePianoTuning {
initialMotifOffset: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
- fadeMinimumLifetimeSeconds: number;
layerIntensityBase: number;
layerIntensityManiaWeight: number;
frameActivityWeight: number;
@@ -106,7 +114,6 @@ interface GenerativePianoTuning {
lowpassBaseExpression: number;
lowpassIntensityWeight: number;
lowpassManiaWeight: number;
- manicThreshold: number;
intenseThreshold: number;
activeThreshold: number;
};
@@ -133,8 +140,6 @@ interface GenerativePianoTuning {
highOffset: number;
mediumOffset: number;
lowOffset: number;
- minOffset: number;
- maxOffset: number;
};
registerBias: {
maniaShiftSemitones: number;
@@ -171,8 +176,6 @@ interface GenerativePianoTuning {
gestureAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
- stingerDurationSeconds: number;
- stingerSpacingSeconds: number;
maxBrushPhraseLayers: number;
maxBrushStreamNotesPerBar: number;
brushLayerBaseSeconds: number;
@@ -181,7 +184,6 @@ interface GenerativePianoTuning {
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
- brushStreamManicIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
@@ -236,6 +238,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
pans: [-0.16, 0, 0.16],
delaySends: [0.012, 0.014, 0.016],
lowpassExpression: 0.35,
+ noteDurationSeconds: 1.1,
+ spacingSeconds: 0.08,
+ },
+ releaseResolution: {
+ durationSeconds: 3.4,
+ fadeAfterSeconds: 2.4,
+ velocities: [0.064, 0.05, 0.038],
+ delaySend: 0.018,
+ lowpassExpression: 0.34,
+ strumSeconds: 0.055,
},
highActivityExtra: {
barOffset: 1,
@@ -284,7 +296,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
delaySend: 0.012,
},
touchNote: {
- registerBiasManiaAmount: 0,
velocityBase: 0.14,
velocityStrengthWeight: 0.11,
durationBaseSeconds: 0.55,
@@ -297,7 +308,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
initialMotifOffset: -1,
energyDecaySeconds: 0.72,
maniaDecaySeconds: 0.54,
- fadeMinimumLifetimeSeconds: 0.001,
layerIntensityBase: 0.8,
layerIntensityManiaWeight: 0.42,
frameActivityWeight: 0.42,
@@ -323,7 +333,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18,
- manicThreshold: 0.85,
intenseThreshold: 0.62,
activeThreshold: 0.34,
},
@@ -350,8 +359,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
highOffset: 1,
mediumOffset: 0,
lowOffset: -1,
- minOffset: -3,
- maxOffset: 4,
},
registerBias: {
maniaShiftSemitones: 4,
@@ -388,8 +395,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58,
- stingerSpacingSeconds: 0.08,
- stingerDurationSeconds: 1.1,
maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 9,
brushLayerBaseSeconds: 5.5,
@@ -398,7 +403,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.5,
- brushStreamManicIntervalBeats: 0.5,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46,
diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts
index 482a4fd..059288c 100644
--- a/src/audio/generative-piano.ts
+++ b/src/audio/generative-piano.ts
@@ -5,17 +5,7 @@ import type {
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
-import {
- degreeToSemitone,
- getChordIntervals,
- getVibeProfile,
- PITCH_SEMITONES_PER_OCTAVE,
-} from './garden-audio-music';
-import {
- GENERATIVE_LOOKAHEAD_SECONDS,
- GENERATIVE_START_DELAY_SECONDS,
- PIANO_SCHEDULE_AHEAD_SECONDS,
-} from './garden-audio-scheduling';
+import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { PianoNote } from './garden-audio-types';
import {
generativePianoTuning,
@@ -23,6 +13,39 @@ import {
type GardenAudioRegister,
type GardenAudioStylePool,
} from './generative-piano-tuning';
+import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
+
+const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
+const GENERATIVE_START_DELAY_SECONDS = 0.02;
+
+const chordVoicings = {
+ majorOpen: [0, 7, 12, 16],
+ minorOpen: [0, 7, 12, 15],
+ majorClosed: [0, 4, 7, 12, 16],
+ minorClosed: [0, 3, 7, 12, 15],
+};
+
+const getChordIntervals = (
+ chord: GardenAudioChord,
+ openVoicing: boolean
+): Array => {
+ if (openVoicing) {
+ return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
+ }
+ return chord.quality === 'major'
+ ? chordVoicings.majorClosed
+ : chordVoicings.minorClosed;
+};
+
+const degreeToSemitone = (
+ profile: GardenAudioVibeProfile,
+ degree: number
+): number => {
+ const scaleIndex =
+ ((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
+ const octave = Math.floor(degree / profile.scale.length);
+ return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
+};
type GardenAudioStyleIndex = 0 | 1 | 2;
@@ -124,6 +147,23 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
}
+ public release(vibe: VibePreset, now: number): number {
+ this.prime(now);
+ this.isWaitingForGestureAccent = false;
+
+ const profile = getVibeProfile(vibe);
+ const releaseStep = this.getReleaseResolutionStep(now);
+ const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
+
+ this.playReleaseResolution(profile, releaseStep, releaseStart);
+ this.nextBeatStep = null;
+ this.nextBrushStreamStep = null;
+ this.brushPhraseLayers = [];
+ this.brushStreamNoteCountsByBar.clear();
+
+ return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
+ }
+
private recordTouchDown({
vibe,
now,
@@ -234,47 +274,27 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
- const notes = [
- {
- midi: this.chooseMidi(
- { baseMidi: rootMidi, offsets: [0] },
- this.generation.padRegisters[0]
- ),
- velocity: this.generation.vibeChangeStinger.velocities[0],
- pan: this.generation.vibeChangeStinger.pans[0],
- delaySend: this.generation.vibeChangeStinger.delaySends[0],
- },
- {
- midi: this.chooseMidi(
- { baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
- this.generation.padRegisters[1]
- ),
- velocity: this.generation.vibeChangeStinger.velocities[1],
- pan: this.generation.vibeChangeStinger.pans[1],
- delaySend: this.generation.vibeChangeStinger.delaySends[1],
- },
- {
- midi: this.chooseMidi(
- { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
- this.generation.padRegisters[2]
- ),
- velocity: this.generation.vibeChangeStinger.velocities[2],
- pan: this.generation.vibeChangeStinger.pans[2],
- delaySend: this.generation.vibeChangeStinger.delaySends[2],
- },
+ const stinger = this.generation.vibeChangeStinger;
+ const offsetsByVoice: ReadonlyArray> = [
+ [0],
+ [intervals[1], intervals[2]],
+ [intervals[3], intervals[2]],
];
- notes.forEach((note, index) => {
+ offsetsByVoice.forEach((offsets, index) => {
+ const midi = this.chooseMidi(
+ { baseMidi: rootMidi, offsets },
+ this.generation.padRegisters[index]
+ );
this.playNote({
- ...note,
- durationSeconds: this.generation.stingerDurationSeconds,
+ midi,
+ velocity: stinger.velocities[index],
+ pan: stinger.pans[index],
+ delaySend: stinger.delaySends[index],
+ durationSeconds: stinger.noteDurationSeconds,
role: 'stinger',
- lowpassHz: this.getLowpassHz(
- profile,
- note.midi,
- this.generation.vibeChangeStinger.lowpassExpression
- ),
- startTime: now + index * this.generation.stingerSpacingSeconds,
+ lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
+ startTime: now + index * stinger.spacingSeconds,
});
});
}
@@ -285,12 +305,8 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
- this.lastMidiByStyle[0] = null;
- this.lastMidiByStyle[1] = null;
- this.lastMidiByStyle[2] = null;
- this.lastPadMidiByVoice[0] = null;
- this.lastPadMidiByVoice[1] = null;
- this.lastPadMidiByVoice[2] = null;
+ this.lastMidiByStyle.fill(null);
+ this.lastPadMidiByVoice.fill(null);
this.brushPhraseLayers = [];
this.nextBrushStreamStep = null;
this.brushStreamNoteIndex = 0;
@@ -400,6 +416,37 @@ export class GenerativePianoEngine {
});
}
+ private playReleaseResolution(
+ profile: GardenAudioVibeProfile,
+ stepIndex: number,
+ startTime: number
+ ): void {
+ const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
+ const intervals = getChordIntervals(chord, true);
+ const rootMidi = profile.rootMidi + chord.rootOffset;
+ const release = this.generation.releaseResolution;
+ const offsetsByVoice: ReadonlyArray> = [
+ [0],
+ [intervals[1], intervals[2]],
+ [intervals[3], intervals[2]],
+ ];
+
+ offsetsByVoice.forEach((offsets, index) => {
+ const register = this.generation.padRegisters[index];
+ const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
+ this.playNote({
+ midi,
+ velocity: release.velocities[index],
+ startTime: startTime + index * release.strumSeconds,
+ durationSeconds: release.durationSeconds,
+ pan: register.pan,
+ role: 'pad',
+ delaySend: release.delaySend,
+ lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
+ });
+ });
+ }
+
private playSupportNote(
profile: GardenAudioVibeProfile,
barIndex: number,
@@ -546,10 +593,6 @@ export class GenerativePianoEngine {
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
- const register = this.getBiasedRegister(
- pool,
- this.generation.touchNote.registerBiasManiaAmount
- );
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@@ -558,7 +601,7 @@ export class GenerativePianoEngine {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
- register,
+ pool,
this.lastMidiByStyle[styleIndex],
true
);
@@ -877,41 +920,26 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats =
- intensity >= this.generation.brushStream.manicThreshold
- ? this.generation.brushStreamManicIntervalBeats
- : intensity >= this.generation.brushStream.intenseThreshold
- ? this.generation.brushStreamIntenseIntervalBeats
- : intensity >= this.generation.brushStream.activeThreshold
- ? this.generation.brushStreamActiveIntervalBeats
- : this.generation.brushStreamIdleIntervalBeats;
+ intensity >= this.generation.brushStream.intenseThreshold
+ ? this.generation.brushStreamIntenseIntervalBeats
+ : intensity >= this.generation.brushStream.activeThreshold
+ ? this.generation.brushStreamActiveIntervalBeats
+ : this.generation.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
}
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
- const lifetimeSeconds = layer.expiresAt - layer.startedAt;
+ const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt);
const ageSeconds = startTime - layer.startedAt;
- return clamp01(
- 1 -
- ageSeconds /
- Math.max(
- this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
- lifetimeSeconds
- )
- );
+ return clamp01(1 - ageSeconds / lifetimeSeconds);
}
private getMotifOffset(strength: number): number {
- const energyStep =
- strength >= this.generation.brushMotif.highThreshold
- ? this.generation.brushMotif.highOffset
- : strength >= this.generation.brushMotif.mediumThreshold
- ? this.generation.brushMotif.mediumOffset
- : this.generation.brushMotif.lowOffset;
- return clamp(
- energyStep,
- this.generation.brushMotif.minOffset,
- this.generation.brushMotif.maxOffset
- );
+ return strength >= this.generation.brushMotif.highThreshold
+ ? this.generation.brushMotif.highOffset
+ : strength >= this.generation.brushMotif.mediumThreshold
+ ? this.generation.brushMotif.mediumOffset
+ : this.generation.brushMotif.lowOffset;
}
private getBrushMotifDegrees({
@@ -1185,6 +1213,19 @@ export class GenerativePianoEngine {
);
}
+ private getReleaseResolutionStep(startTime: number): number {
+ const currentStep = this.getStepIndexAtTime(startTime);
+ const nextBeatStep =
+ Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) *
+ this.config.rhythm.stepsPerBeat;
+ const nextBarStep =
+ Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) *
+ this.config.rhythm.stepsPerBar;
+ const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2;
+
+ return barIsClose ? nextBarStep : nextBeatStep;
+ }
+
private getNextBarTimeAt(startTime: number): number {
const nextBarStep =
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) *
diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts
index 3ba5b62..19cc45b 100644
--- a/src/audio/noise-burst-player.ts
+++ b/src/audio/noise-burst-player.ts
@@ -1,4 +1,4 @@
-import { createAudioPanNode } from './audio-pan-node';
+import { clamp } from '../utils/math';
import type { GardenAudioGraph } from './garden-audio-graph';
import type { NoiseBurst } from './garden-audio-types';
@@ -15,9 +15,8 @@ export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
- const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
- const outputBus = noiseBus ?? eventBus;
- if (!context || !outputBus || !noiseBuffer) {
+ const { context, noiseBus, noiseBuffer } = this.graph;
+ if (!context || !noiseBus || !noiseBuffer) {
return;
}
@@ -28,7 +27,7 @@ export class NoiseBurstPlayer {
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
- const panNode = createAudioPanNode(context, pan, scheduledStart);
+ const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
@@ -41,10 +40,11 @@ export class NoiseBurstPlayer {
scheduledStart + noiseBurstTuning.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
+ panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(envelope);
- envelope.connect(panNode.input);
- panNode.output.connect(outputBus);
+ envelope.connect(panner);
+ panner.connect(noiseBus);
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
source.stop(stopAt);
source.addEventListener(
@@ -53,7 +53,7 @@ export class NoiseBurstPlayer {
source.disconnect();
filter.disconnect();
envelope.disconnect();
- panNode.disconnect();
+ panner.disconnect();
},
{ once: true }
);
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index c3a5007..744aad0 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -1,18 +1,20 @@
import { clamp, clamp01 } from '../utils/math';
-import { createAudioPanNode } from './audio-pan-node';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
-import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
-import type {
- ActivePianoVoice,
- LoadedPianoSample,
- PianoNote,
-} from './garden-audio-types';
+import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
+export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
+
type PianoLoadState = 'idle' | 'loading' | 'loaded';
+interface ActivePianoVoice {
+ gain: GainNode;
+ source: AudioScheduledSourceNode;
+ stopAt: number;
+}
+
const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType,
filterQ: 0.7,
@@ -29,7 +31,6 @@ const pianoSamplerTuning = {
export class PianoSampler {
private loadState: PianoLoadState = 'idle';
- private sampleLoadPromise: Promise | null = null;
private samples: Array = [];
private activeVoices: Array = [];
@@ -38,9 +39,9 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph
) {}
- public load(context: BaseAudioContext): Promise {
- if (this.loadState === 'loaded') {
- return Promise.resolve();
+ public loadIfIdle(context: BaseAudioContext): Promise | null {
+ if (this.loadState !== 'idle') {
+ return null;
}
const loadedSamples = getLoadedPianoSamples();
@@ -50,33 +51,16 @@ export class PianoSampler {
return Promise.resolve();
}
- if (this.sampleLoadPromise) {
- return this.sampleLoadPromise;
- }
-
this.loadState = 'loading';
- this.sampleLoadPromise = loadPianoSamples(context, undefined, {
- forceReload: true,
- })
+ return loadPianoSamples(context)
.then((samples) => {
this.setSamples(samples);
this.loadState = 'loaded';
})
.catch((error) => {
this.loadState = 'idle';
- this.sampleLoadPromise = null;
throw error;
});
-
- return this.sampleLoadPromise;
- }
-
- public loadIfIdle(context: BaseAudioContext): Promise | null {
- if (this.loadState !== 'idle') {
- return null;
- }
-
- return this.load(context);
}
public play({
@@ -89,115 +73,114 @@ export class PianoSampler {
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
- const { context, delayInput } = this.graph;
+ const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
- const sample = this.findNearestSample(midi);
- if (!sample) {
- this.playSynthFallback({
- midi,
- velocity,
- startTime,
- durationSeconds,
- pan,
- role,
- delaySend,
- lowpassHz,
- });
- return;
- }
-
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
+ const sample = this.findNearestSample(midi);
+
+ if (sample) {
+ const noteGainValue = Math.max(
+ pianoSamplerTuning.minGain,
+ this.config.piano.gain * noteVelocity
+ );
+ const sustainSeconds =
+ this.config.piano.sustainSeconds *
+ (this.config.piano.sustainBase +
+ noteVelocity * this.config.piano.sustainVelocityRange);
+ const sustainAt =
+ scheduledStart +
+ Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
+ const releaseAt = sustainAt + sustainSeconds;
+ const stopAt = releaseAt + this.config.piano.releaseSeconds;
+ const source = context.createBufferSource();
+
+ source.buffer = sample.buffer;
+ source.playbackRate.setValueAtTime(
+ Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
+ scheduledStart
+ );
+
+ this.scheduleVoice({
+ source,
+ scheduledStart,
+ stopAt,
+ pan,
+ lowpassHz,
+ delaySend,
+ eventBus,
+ configureGainEnvelope: (gain) => {
+ gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
+ gain.gain.exponentialRampToValueAtTime(
+ noteGainValue,
+ scheduledStart + this.config.piano.gainAttackSeconds
+ );
+ gain.gain.setTargetAtTime(
+ Math.max(
+ pianoSamplerTuning.minGain,
+ noteGainValue * this.config.piano.sustainLevel
+ ),
+ sustainAt,
+ Math.max(
+ pianoSamplerTuning.minFadeSeconds,
+ sustainSeconds * this.config.piano.sustainBase
+ )
+ );
+ gain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ releaseAt,
+ this.config.piano.releaseSeconds
+ );
+ },
+ });
+ return;
+ }
+
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
- this.config.piano.gain * noteVelocity
+ this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
);
- const sustainSeconds =
- this.config.piano.sustainSeconds *
- (this.config.piano.sustainBase +
- noteVelocity * this.config.piano.sustainVelocityRange);
- const sustainAt =
- scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
- const releaseAt = sustainAt + sustainSeconds;
- const releaseSeconds = this.config.piano.releaseSeconds;
- const stopAt = releaseAt + releaseSeconds;
- const source = context.createBufferSource();
- const filter = context.createBiquadFilter();
- const gain = context.createGain();
- const panNode = createAudioPanNode(context, pan, scheduledStart);
- let sendGain: GainNode | null = null;
+ const releaseAt =
+ scheduledStart +
+ clamp(
+ durationSeconds + this.config.piano.sustainSeconds * 0.5,
+ pianoSamplerTuning.minDurationSeconds,
+ pianoSamplerTuning.synthMaxDurationSeconds
+ );
+ const stopAt = releaseAt + this.config.piano.releaseSeconds;
+ const source = context.createOscillator();
- this.trimActiveVoices(scheduledStart);
- while (this.activeVoices.length >= this.config.piano.maxVoices) {
- const oldest = this.activeVoices.shift();
- if (oldest) {
- this.stopVoice(oldest, scheduledStart);
- }
- }
+ source.type = pianoSamplerTuning.synthOscillatorType;
+ source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
- source.buffer = sample.buffer;
- source.playbackRate.setValueAtTime(
- Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
- scheduledStart
- );
- filter.type = pianoSamplerTuning.filterType;
- filter.frequency.setValueAtTime(
- clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
- scheduledStart
- );
- filter.Q.value = pianoSamplerTuning.filterQ;
- gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
- gain.gain.exponentialRampToValueAtTime(
- noteGainValue,
- scheduledStart + this.config.piano.gainAttackSeconds
- );
- gain.gain.setTargetAtTime(
- Math.max(
- pianoSamplerTuning.minGain,
- noteGainValue * this.config.piano.sustainLevel
- ),
- sustainAt,
- Math.max(
- pianoSamplerTuning.minFadeSeconds,
- sustainSeconds * this.config.piano.sustainBase
- )
- );
- gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
- source.connect(filter);
- filter.connect(gain);
- gain.connect(panNode.input);
- panNode.output.connect(eventBus);
-
- if (delayInput && delaySend > 0) {
- sendGain = context.createGain();
- sendGain.gain.value = delaySend;
- panNode.output.connect(sendGain);
- sendGain.connect(delayInput);
- }
-
- source.start(scheduledStart);
- source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
- this.activeVoices.push({ gain, source, stopAt });
-
- source.addEventListener(
- 'ended',
- () => {
- source.disconnect();
- filter.disconnect();
- gain.disconnect();
- panNode.disconnect();
- sendGain?.disconnect();
- this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
+ this.scheduleVoice({
+ source,
+ scheduledStart,
+ stopAt,
+ pan,
+ lowpassHz,
+ delaySend,
+ eventBus,
+ configureGainEnvelope: (gain) => {
+ gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
+ gain.gain.exponentialRampToValueAtTime(
+ noteGainValue,
+ scheduledStart + this.config.piano.gainAttackSeconds
+ );
+ gain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ releaseAt,
+ this.config.piano.releaseSeconds
+ );
},
- { once: true }
- );
+ });
}
public stopAll(): void {
@@ -215,114 +198,64 @@ export class PianoSampler {
this.activeVoices = [];
}
- public fadeAll(fadeSeconds: number): void {
- const context = this.graph.context;
- if (!context) {
- this.activeVoices = [];
- return;
- }
-
- const now = context.currentTime;
- const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
-
- this.trimActiveVoices(now);
- this.activeVoices.forEach((voice) => {
- this.fadeVoice(voice, now, fadeDurationSeconds);
- });
- }
-
public reset(): void {
this.loadState = 'idle';
- this.sampleLoadPromise = null;
this.samples = [];
this.activeVoices = [];
}
- private findNearestSample(midi: number): LoadedPianoSample | null {
- if (this.samples.length === 0) {
- return null;
- }
-
- return this.samples.reduce((nearest, sample) =>
- Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
- );
- }
-
- private playSynthFallback({
- midi,
- velocity,
- startTime,
- durationSeconds,
+ private scheduleVoice({
+ source,
+ scheduledStart,
+ stopAt,
pan,
- role,
- delaySend = 0,
- lowpassHz = this.config.piano.lowpassHz,
- }: PianoNote): void {
+ lowpassHz,
+ delaySend,
+ eventBus,
+ configureGainEnvelope,
+ }: {
+ source: AudioScheduledSourceNode;
+ scheduledStart: number;
+ stopAt: number;
+ pan: number;
+ lowpassHz: number;
+ delaySend: number;
+ eventBus: GainNode;
+ configureGainEnvelope: (gain: GainNode) => void;
+ }): void {
const { context, delayInput } = this.graph;
- const eventBus = this.graph.getPianoBus(role);
- if (!context || !eventBus) {
+ if (!context) {
return;
}
- const scheduledStart = Math.max(
- context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
- startTime
- );
- const noteVelocity = clamp01(velocity);
- const noteGainValue = Math.max(
- pianoSamplerTuning.minGain,
- this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
- );
- const releaseAt =
- scheduledStart +
- clamp(
- durationSeconds + this.config.piano.sustainSeconds * 0.5,
- pianoSamplerTuning.minDurationSeconds,
- pianoSamplerTuning.synthMaxDurationSeconds
- );
- const stopAt = releaseAt + this.config.piano.releaseSeconds;
- const source = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
- const panNode = createAudioPanNode(context, pan, scheduledStart);
+ const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
- const oldest = this.activeVoices.shift();
- if (oldest) {
- this.stopVoice(oldest, scheduledStart);
- }
+ this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
}
- source.type = pianoSamplerTuning.synthOscillatorType;
- source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
- gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
- gain.gain.exponentialRampToValueAtTime(
- noteGainValue,
- scheduledStart + this.config.piano.gainAttackSeconds
- );
- gain.gain.setTargetAtTime(
- pianoSamplerTuning.minGain,
- releaseAt,
- this.config.piano.releaseSeconds
- );
+ panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
+ configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
- gain.connect(panNode.input);
- panNode.output.connect(eventBus);
+ gain.connect(panner);
+ panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
- panNode.output.connect(sendGain);
+ panner.connect(sendGain);
sendGain.connect(delayInput);
}
@@ -336,7 +269,7 @@ export class PianoSampler {
source.disconnect();
filter.disconnect();
gain.disconnect();
- panNode.disconnect();
+ panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
@@ -344,6 +277,16 @@ export class PianoSampler {
);
}
+ private findNearestSample(midi: number): LoadedPianoSample | null {
+ if (this.samples.length === 0) {
+ return null;
+ }
+
+ return this.samples.reduce((nearest, sample) =>
+ Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
+ );
+ }
+
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
@@ -358,32 +301,7 @@ export class PianoSampler {
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
- try {
- voice.source.stop(stopAt);
- } catch {
- // The voice may already have ended; either way it is no longer part of the mix.
- }
- }
-
- private fadeVoice(
- voice: ActivePianoVoice,
- now: number,
- fadeDurationSeconds: number
- ): void {
- const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
-
- voice.gain.gain.cancelScheduledValues(now);
- voice.gain.gain.setTargetAtTime(
- pianoSamplerTuning.minGain,
- now,
- Math.max(0.001, fadeDurationSeconds / 4)
- );
- voice.stopAt = stopAt;
- try {
- voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
- } catch {
- // The voice may already have ended; either way it is fading out of the mix.
- }
+ voice.source.stop(stopAt);
}
private setSamples(samples: Array): void {
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index d456968..e132697 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -8,7 +8,6 @@ interface PianoSampleDefinition {
export interface PianoSampleLoadProgress {
loadedCount: number;
totalCount: number;
- sample?: PianoSampleDefinition;
}
const sampleFiles: Array<[fileName: string, midi: number]> = [
@@ -45,11 +44,6 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
];
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
-const preloadDecode = {
- channels: 2,
- frames: 128,
- sampleRateHz: 48_000,
-};
const pianoSampleDefinitions: Array = sampleFiles
.map(([fileName, midi]) => ({
@@ -61,10 +55,6 @@ const pianoSampleDefinitions: Array = sampleFiles
let loadedPianoSamples: Array | null = null;
let pianoSampleLoadPromise: Promise> | null = null;
-interface PianoSampleLoadOptions {
- forceReload?: boolean;
-}
-
const sampleLoadTuning = {
concurrency: 4,
sampleTimeoutMs: 15_000,
@@ -81,18 +71,14 @@ export const preloadPianoSamples = (
);
}
- const decodeContext = new OfflineAudioContextConstructor(
- preloadDecode.channels,
- preloadDecode.frames,
- preloadDecode.sampleRateHz
- );
+ // Decoding ignores these, but the constructor demands real numbers.
+ const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
- onProgress?: (progress: PianoSampleLoadProgress) => void,
- options: PianoSampleLoadOptions = {}
+ onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise> => {
if (loadedPianoSamples) {
onProgress?.({
@@ -102,7 +88,7 @@ export const loadPianoSamples = (
return Promise.resolve([...loadedPianoSamples]);
}
- if (pianoSampleLoadPromise && options.forceReload !== true) {
+ if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise;
}
@@ -115,12 +101,12 @@ export const loadPianoSamples = (
async (sample) => {
try {
return await withTimeout(
- loadPianoSample(decodeContext, sample),
+ (signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs
);
} finally {
loadedCount += 1;
- onProgress?.({ loadedCount, totalCount, sample });
+ onProgress?.({ loadedCount, totalCount });
}
}
).then(
@@ -147,15 +133,16 @@ export const getLoadedPianoSamples = (): Array | null =>
const loadPianoSample = async (
decodeContext: BaseAudioContext,
- sample: PianoSampleDefinition
+ sample: PianoSampleDefinition,
+ signal: AbortSignal
): Promise => {
- const response = await fetch(sample.url);
+ const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
- const buffer = await decodeAudioData(decodeContext, audioData);
+ const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
};
@@ -178,13 +165,18 @@ const loadPianoSampleBatch = async (
return results;
};
-const withTimeout = (promise: Promise, timeoutMs: number): Promise =>
+const withTimeout = (
+ operation: (signal: AbortSignal) => Promise,
+ timeoutMs: number
+): Promise =>
new Promise((resolve, reject) => {
+ const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
+ controller.abort();
reject(new Error('Timed out while loading a piano sample.'));
}, timeoutMs);
- promise.then(
+ operation(controller.signal).then(
(value) => {
globalThis.clearTimeout(timeout);
resolve(value);
@@ -195,12 +187,3 @@ const withTimeout = (promise: Promise, timeoutMs: number): Promise =>
}
);
});
-
-const decodeAudioData = (
- decodeContext: BaseAudioContext,
- audioData: ArrayBuffer
-): Promise =>
- new Promise((resolve, reject) => {
- const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
- decodePromise?.then(resolve, reject);
- });
diff --git a/src/config.ts b/src/config.ts
index 3df06aa..56c5780 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -102,19 +102,13 @@ export const appConfig = {
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
- export4k: {
+ exportSnapshot: {
bytesPerPixel: 4,
filenameExtension: 'png',
filenamePrefix: 'fleeting-garden',
- filenameSuffix: '-upscale',
- height: 2160,
- jsHeapSafetyMultiplier: 1.5,
- jsHeapTextureBytesMultiplier: 4,
- lowMemoryDeviceGiB: 2,
- lowMemoryExportFraction: 0.08,
+ filenameSuffix: '-snapshot',
mimeType: 'image/png',
rowAlignmentBytes: 256,
- width: 3840,
},
menuHider: {
bottomRevealDistancePx: 96,
@@ -148,17 +142,6 @@ export const appConfig = {
controls: runtimeControls,
},
simulation: {
- budget: {
- adaptiveCapDecreaseAgentsPerSecond: 200_000,
- adaptiveCapInitial: 1_000_000,
- adaptiveCapMin: 50_000,
- adaptiveRefreshTargetFps: 60,
- frameGapResetSeconds: 1,
- fpsHeadroom: 0.95,
- fpsSmoothingNew: 0.06,
- fpsSmoothingRetain: 0.94,
- initialFps: 60,
- },
brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,
diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts
index 47584d3..c0b5cb4 100644
--- a/src/config/default-settings.ts
+++ b/src/config/default-settings.ts
@@ -6,9 +6,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
...colorInteractionSettings,
turnWhenLost: 0.8,
- sourceAttractionWeight: 24,
- sourceSlowMoveRate: 0.08,
- sourceTrailWeightMultiplier: 16,
forwardRotationScale: 0.25,
introNearDistanceMin: 28,
introNearDistanceInner: 4,
@@ -38,8 +35,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushSizeVariation: 0.5,
brushAlpha: 1,
- brushFeatherRatio: 0.22,
- brushMinimumFeather: 1,
brushDiscardThreshold: 0.02,
brushCoarseNoiseScale: 160,
brushGrainNoiseScale: 22,
@@ -55,8 +50,9 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
eraserLineDistanceEpsilon: 0.0001,
eraserMaskAlphaThreshold: 0.5,
+ adaptiveCapInitial: 1_000_000,
+ adaptiveCapMin: 50_000,
internalRenderAreaMegapixels: 8.3,
- strokeSpawnSpreadBrushSizeMultiplier: 1,
maxAgentCount: 700_000,
renderTraceNormalizationFloor: 1,
diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts
index 7d9870e..6474c8e 100644
--- a/src/config/runtime-controls.ts
+++ b/src/config/runtime-controls.ts
@@ -35,18 +35,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
- brushFeatherRatio: {
- folder: 'Brush',
- min: 0,
- max: 1,
- step: 0.001,
- },
- brushMinimumFeather: {
- folder: 'Brush',
- min: 0,
- max: 8,
- step: 0.1,
- },
brushDiscardThreshold: {
folder: 'Brush',
min: 0,
@@ -255,6 +243,22 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
+ adaptiveCapInitial: {
+ folder: 'Agent',
+ integer: true,
+ label: 'adaptive cap initial',
+ min: 50_000,
+ max: 2_000_000,
+ step: 10_000,
+ },
+ adaptiveCapMin: {
+ folder: 'Agent',
+ integer: true,
+ label: 'adaptive cap min',
+ min: 0,
+ max: 500_000,
+ step: 10_000,
+ },
maxAgentCount: {
folder: 'Agent',
integer: true,
@@ -299,12 +303,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
- strokeSpawnSpreadBrushSizeMultiplier: {
- folder: 'Agent',
- min: 0,
- max: 4,
- step: 0.01,
- },
turnSpeed: {
folder: 'Agent',
min: 1,
@@ -317,24 +315,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
- sourceAttractionWeight: {
- folder: 'Agent',
- min: 0,
- max: 64,
- step: 0.1,
- },
- sourceSlowMoveRate: {
- folder: 'Agent',
- min: 0,
- max: 1,
- step: 0.001,
- },
- sourceTrailWeightMultiplier: {
- folder: 'Agent',
- min: 0,
- max: 64,
- step: 0.1,
- },
forwardRotationScale: {
folder: 'Agent',
min: 0,
diff --git a/src/config/types.ts b/src/config/types.ts
index f347cea..a237c17 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -6,6 +6,7 @@ import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings';
+import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig {
folder: string;
@@ -18,6 +19,8 @@ export interface NumberControlConfig {
}
export type GardenRuntimeSettings = {
+ adaptiveCapInitial: number;
+ adaptiveCapMin: number;
brushCurveResolution: number;
brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number;
@@ -37,7 +40,6 @@ export type GardenRuntimeSettings = {
maxAgentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
- strokeSpawnSpreadBrushSizeMultiplier: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
@@ -78,8 +80,8 @@ export enum VibeId {
export interface VibePreset {
id: VibeId;
name: string;
- colors: [string, string, string];
- backgroundColor: string;
+ colors: [RgbColor, RgbColor, RgbColor];
+ backgroundColor: RgbColor;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
@@ -90,19 +92,13 @@ export interface GardenAppConfig {
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
- export4k: {
+ exportSnapshot: {
bytesPerPixel: number;
filenameExtension: string;
filenamePrefix: string;
filenameSuffix: string;
- height: number;
- jsHeapSafetyMultiplier: number;
- jsHeapTextureBytesMultiplier: number;
- lowMemoryDeviceGiB: number;
- lowMemoryExportFraction: number;
mimeType: string;
rowAlignmentBytes: number;
- width: number;
};
menuHider: {
bottomRevealDistancePx: number;
@@ -136,17 +132,6 @@ export interface GardenAppConfig {
controls: RuntimeSettingControlConfig;
};
simulation: {
- budget: {
- adaptiveCapDecreaseAgentsPerSecond: number;
- adaptiveCapInitial: number;
- adaptiveCapMin: number;
- adaptiveRefreshTargetFps: number;
- frameGapResetSeconds: number;
- fpsHeadroom: number;
- fpsSmoothingNew: number;
- fpsSmoothingRetain: number;
- initialFps: number;
- };
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;
diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts
index 9885ab6..000e65a 100644
--- a/src/config/vibe-presets.ts
+++ b/src/config/vibe-presets.ts
@@ -27,8 +27,12 @@ export const vibePresets: Array = [
{
id: VibeId.CandyRain,
name: 'Candy Rain',
- colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
- backgroundColor: '#10151f',
+ colors: [
+ [255, 93, 162],
+ [54, 215, 208],
+ [255, 216, 77],
+ ],
+ backgroundColor: [16, 21, 31],
settings: {
brushSize: 14,
clarity: 0.62,
@@ -52,8 +56,12 @@ export const vibePresets: Array = [
{
id: VibeId.SunlitMoss,
name: 'Sunlit Moss',
- colors: ['#83d483', '#f6d76b', '#5ec1a1'],
- backgroundColor: '#172016',
+ colors: [
+ [131, 212, 131],
+ [246, 215, 107],
+ [94, 193, 161],
+ ],
+ backgroundColor: [23, 32, 22],
settings: {
brushSize: 16,
clarity: 0.68,
@@ -82,8 +90,12 @@ export const vibePresets: Array = [
{
id: VibeId.CoralTide,
name: 'Coral Tide',
- colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
- backgroundColor: '#0f1822',
+ colors: [
+ [255, 127, 110],
+ [64, 184, 255],
+ [244, 240, 166],
+ ],
+ backgroundColor: [15, 24, 34],
settings: {
brushSize: 13,
clarity: 0.58,
@@ -107,8 +119,12 @@ export const vibePresets: Array = [
{
id: VibeId.MoonOrchid,
name: 'Moon Orchid',
- colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
- backgroundColor: '#14121d',
+ colors: [
+ [201, 147, 255],
+ [125, 216, 255],
+ [240, 244, 255],
+ ],
+ backgroundColor: [20, 18, 29],
settings: {
brushSize: 12,
clarity: 0.64,
@@ -132,8 +148,12 @@ export const vibePresets: Array = [
{
id: VibeId.PeachNeon,
name: 'Peach Neon',
- colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
- backgroundColor: '#191716',
+ colors: [
+ [255, 155, 115],
+ [91, 240, 169],
+ [110, 168, 255],
+ ],
+ backgroundColor: [25, 23, 22],
settings: {
brushSize: 15,
clarity: 0.55,
@@ -157,8 +177,12 @@ export const vibePresets: Array = [
{
id: VibeId.FrostBloom,
name: 'Frost Bloom',
- colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
- backgroundColor: '#101820',
+ colors: [
+ [180, 247, 255],
+ [158, 200, 255],
+ [255, 184, 210],
+ ],
+ backgroundColor: [16, 24, 32],
settings: {
brushSize: 18,
clarity: 0.7,
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 94d5846..172cdf2 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -7,16 +7,20 @@ import {
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
+import type { FramePerformance } from './frame-performance';
import { createIntroTitleAgents } from './intro-title-agents';
export class AgentPopulation {
private activeCount = 0;
+ // Current performance-aware limit; new agents above it replace old agents.
private adaptiveCap: number;
+ // Next active agent slot to overwrite when new agents exceed the current cap.
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private pendingCompaction: Promise | null = null;
+ // Highest active slot written while async compaction is running.
private postCompactionWriteEnd = 0;
private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
@@ -24,11 +28,12 @@ export class AgentPopulation {
public constructor(
private readonly pipeline: AgentGenerationPipeline,
- private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
- private readonly getCanvasPixelRatio = () => 1
+ private readonly introSeed: number,
+ private readonly getCanvasPixelRatio: () => number,
+ private readonly framePerformance: FramePerformance
) {
- this.adaptiveCap = this.clampAdaptiveCap(
- appConfig.simulation.budget.adaptiveCapInitial
+ this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
+ this.framePerformance.adaptiveCapInitial
);
}
@@ -41,7 +46,7 @@ export class AgentPopulation {
}
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
- this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
+ this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min(
this.adaptiveCap,
appConfig.simulation.initialAgentCount
@@ -65,14 +70,10 @@ export class AgentPopulation {
}
public onVibeChanged(): void {
- this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
+ this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
- public growBudget(deltaTime: number, smoothedFps: number): void {
- this.updateAdaptiveCap(deltaTime, smoothedFps);
- }
-
public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale);
}
@@ -103,8 +104,7 @@ export class AgentPopulation {
this.activeCount,
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
);
- this.replacementCursor =
- this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
+ this.clampReplacementCursor();
this.trimActiveCountToBudget();
})
.catch((error: unknown) => {
@@ -121,10 +121,32 @@ export class AgentPopulation {
await this.pendingCompaction;
}
+ public updateAdaptiveCap(): void {
+ const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
+ this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
+
+ if (this.canExpandAdaptiveCap) {
+ this.adaptiveCap = previousCap;
+ this.trimActiveCountToBudget();
+ return;
+ }
+
+ const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
+ const responsiveCap = Math.min(
+ previousCap,
+ this.clampAndEnsureAdaptiveCap(this.activeCount)
+ );
+ const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
+ this.adaptiveCap = nextCap;
+ this.trimActiveCountToBudget(decrease);
+ }
+
public spawnStrokeAgents(from: vec2, to: vec2): void {
+ const deltaX = to[0] - from[0];
+ const deltaY = to[1] - from[1];
const length = Math.max(
appConfig.simulation.stroke.minSegmentLengthPx,
- vec2.dist(from, to)
+ Math.hypot(deltaX, deltaY)
);
const count = Math.max(
appConfig.simulation.stroke.minAgentCount,
@@ -136,8 +158,8 @@ export class AgentPopulation {
)
)
);
- const direction = vec2.sub(vec2.create(), to, from);
- const baseAngle = Math.atan2(direction[1], direction[0]);
+ const baseAngle = Math.atan2(deltaY, deltaX);
+ const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
for (let i = 0; i < count; i++) {
const t = count === 1 ? 1 : i / (count - 1);
@@ -147,10 +169,6 @@ export class AgentPopulation {
baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
- const spread =
- settings.brushSize *
- getSafePixelRatio(this.getCanvasPixelRatio()) *
- settings.strokeSpawnSpreadBrushSizeMultiplier;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 2] = angle;
@@ -170,7 +188,7 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
- this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
+ this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
@@ -218,40 +236,15 @@ export class AgentPopulation {
);
}
- private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
- const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
- this.canExpandAdaptiveCap =
- smoothedFps >=
- appConfig.simulation.budget.adaptiveRefreshTargetFps *
- appConfig.simulation.budget.fpsHeadroom;
-
- if (this.canExpandAdaptiveCap) {
- this.adaptiveCap = previousCap;
- this.trimActiveCountToBudget();
- return;
- }
-
- const decrease = Math.max(
- 1,
- Math.ceil(
- appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
- )
- );
- const responsiveCap = Math.min(previousCap, this.clampAdaptiveCap(this.activeCount));
- const nextCap = this.clampAdaptiveCap(responsiveCap - decrease);
- this.adaptiveCap = nextCap;
- this.trimActiveCountToBudget(decrease);
- }
-
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
- const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
+ const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available;
- this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
+ this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
@@ -263,11 +256,15 @@ export class AgentPopulation {
this.adaptiveCap,
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
);
+ this.clampReplacementCursor();
+ }
+
+ private clampReplacementCursor(): void {
this.replacementCursor =
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
}
- private clampAdaptiveCap(value: number): number {
+ private clampAndEnsureAdaptiveCap(value: number): number {
const runtimeMaxCap =
settings.maxAgentCount === Number.POSITIVE_INFINITY
? Number.POSITIVE_INFINITY
@@ -275,7 +272,7 @@ export class AgentPopulation {
? Math.max(0, Math.floor(settings.maxAgentCount))
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
- const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
+ const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
return Math.min(
diff --git a/src/game-loop/export-4k-renderer.ts b/src/game-loop/export-4k-renderer.ts
deleted file mode 100644
index 4aafa01..0000000
--- a/src/game-loop/export-4k-renderer.ts
+++ /dev/null
@@ -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 {
- 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
- ): Promise {
- 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,
- width: number,
- height: number
- ): Promise {
- 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 => {
- const pixels: Uint8ClampedArray = 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;
-};
diff --git a/src/game-loop/export-4k.ts b/src/game-loop/export-4k.ts
deleted file mode 100644
index 8adf5ec..0000000
--- a/src/game-loop/export-4k.ts
+++ /dev/null
@@ -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;
- 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;
-};
diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts
index 45e19fc..abdb795 100644
--- a/src/game-loop/frame-performance.ts
+++ b/src/game-loop/frame-performance.ts
@@ -1,13 +1,40 @@
-import { appConfig } from '../config';
+import { settings } from '../settings';
export class FramePerformance {
- public smoothedFps = appConfig.simulation.budget.initialFps;
+ private readonly adaptiveRefreshTargetFps = 60;
+ private readonly initialFps = this.adaptiveRefreshTargetFps;
+ public smoothedFps = this.initialFps;
+
public measuredFps = 0;
public frameDeltaSeconds = 0;
public measuredFrameTimeMs = 0;
+ private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
+ private readonly frameGapResetSeconds = 1;
+ private readonly fpsHeadroom = 0.9;
+ private readonly fpsSmoothingNew = 0.06;
+ private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
private previousFrameTime: DOMHighResTimeStamp | null = null;
+ public get adaptiveCapInitial(): number {
+ return settings.adaptiveCapInitial;
+ }
+
+ public get adaptiveCapMin(): number {
+ return settings.adaptiveCapMin;
+ }
+
+ public get hasAdaptiveCapHeadroom(): boolean {
+ return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
+ }
+
+ public get adaptiveCapDecreaseAgents(): number {
+ return Math.max(
+ 1,
+ Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
+ );
+ }
+
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
this.previousFrameTime = time;
@@ -24,12 +51,11 @@ export class FramePerformance {
this.frameDeltaSeconds = deltaSeconds;
this.measuredFrameTimeMs = deltaSeconds * 1000;
this.measuredFps = fps;
- if (deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds) {
+ if (deltaSeconds > this.frameGapResetSeconds) {
return;
}
this.smoothedFps =
- this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
- fps * appConfig.simulation.budget.fpsSmoothingNew;
+ this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
}
}
diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts
index 06778f6..799c413 100644
--- a/src/game-loop/game-loop-resources.ts
+++ b/src/game-loop/game-loop-resources.ts
@@ -44,7 +44,8 @@ export class GameLoopResources {
public constructor(
canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
- canvasSize: vec2
+ canvasSize: vec2,
+ initialAgentCapacity: number
) {
const context = initializeContext({ device, canvas });
@@ -58,7 +59,7 @@ export class GameLoopResources {
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
- Math.min(settings.maxAgentCount, appConfig.simulation.budget.adaptiveCapInitial)
+ Math.min(settings.maxAgentCount, initialAgentCapacity)
);
this.agentPipeline = new AgentPipeline(
diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts
index 1b5182b..6b3ecfe 100644
--- a/src/game-loop/game-loop-types.ts
+++ b/src/game-loop/game-loop-types.ts
@@ -1,5 +1,7 @@
import { vec2 } from 'gl-matrix';
+import type { RgbColor } from '../utils/rgb-color';
+
export interface GardenUi {
prompt: HTMLElement;
eraserPreview: HTMLElement;
@@ -8,8 +10,8 @@ export interface GardenUi {
}
export interface RenderInputs {
- channelColors: Array<[number, number, number]>;
- backgroundColor: [number, number, number];
+ channelColors: [RgbColor, RgbColor, RgbColor];
+ backgroundColor: RgbColor;
}
export interface StrokeSegment {
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index 09af1e6..78fb15a 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -1,32 +1,33 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
-import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
+import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population';
import { DevStatsOverlay } from './dev-stats-overlay';
+import { EraserPointerPreviewController } from './eraser-pointer-preview-controller';
import { EraserPreview } from './eraser-preview';
-import { Export4KRenderer } from './export-4k-renderer';
+import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance';
import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
import { GardenPointerInput } from './pointer-input';
-import { RenderInputCache } from './render-input-cache';
+import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop {
private readonly resources: GameLoopResources;
- private readonly audio = new GardenAudio(gardenAudioConfig);
- private readonly renderInputs = new RenderInputCache();
+ private readonly audio = new GardenAudio(appConfig.audio);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
+ private readonly eraserPreviewController: EraserPointerPreviewController;
private readonly agentPopulation: AgentPopulation;
- private readonly export4KRenderer: Export4KRenderer;
+ private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsOverlay: DevStatsOverlay | null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
@@ -35,6 +36,7 @@ export default class GameLoop {
private readonly resizeListener = this.resize.bind(this);
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
+ private previousAccentColor = '';
private hasFinished = false;
private readonly finished = Promise.withResolvers();
@@ -48,38 +50,53 @@ export default class GameLoop {
this.devStatsOverlay = import.meta.env.DEV
? new DevStatsOverlay(canvas.parentElement ?? document.body)
: null;
- this.resources = new GameLoopResources(canvas, device, this.canvasSize);
+ this.resources = new GameLoopResources(
+ canvas,
+ device,
+ this.canvasSize,
+ this.framePerformance.adaptiveCapInitial
+ );
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue,
- () => this.canvasPixelRatio
+ () => this.canvasPixelRatio,
+ this.framePerformance
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
audio: this.audio,
- brushPipeline: this.resources.brushPipeline,
- eraserAgentPipeline: this.resources.eraserAgentPipeline,
- eraserTexturePipeline: this.resources.eraserTexturePipeline,
- eraserPreview: this.eraserPreview,
+ strokeOutput: new PipelineStrokeOutput(
+ this.resources.brushPipeline,
+ this.resources.eraserAgentPipeline,
+ this.resources.eraserTexturePipeline
+ ),
getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
- this.export4KRenderer = new Export4KRenderer({
+ this.eraserPreviewController = new EraserPointerPreviewController({
+ canvas,
+ eraserPreview: this.eraserPreview,
+ getIsSwipeActive: () => this.pointerInput.isSwipeActive,
+ });
+ this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device,
renderPipeline: this.resources.renderPipeline,
statusElement: ui.exportStatus,
seed: this.seed,
- getSourceSize: () => ({
- width: this.canvas.width,
- height: this.canvas.height,
- }),
+ getSourceSize: () => {
+ const size = this.resources.textures.trailMapA.getSize();
+ return {
+ width: size[0],
+ height: size[1],
+ };
+ },
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
@@ -87,18 +104,19 @@ export default class GameLoop {
window.addEventListener('resize', this.resizeListener);
this.pointerInput.attach();
+ this.eraserPreviewController.attach();
}
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
+ this.eraserPreviewController.setEraseMode(isErasing);
}
public updateEraserPreview(event?: PointerEvent): void {
- this.pointerInput.updateEraserPreview(event);
+ this.eraserPreviewController.update(event);
}
public onVibeChanged(): void {
- this.renderInputs.invalidate();
this.agentPopulation.onVibeChanged();
}
@@ -123,8 +141,8 @@ export default class GameLoop {
return this.finished.promise;
}
- public async export4K(): Promise {
- return this.export4KRenderer.export();
+ public async exportSnapshot(): Promise {
+ return this.exportSnapshotRenderer.export();
}
public async destroy(): Promise {
@@ -133,6 +151,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach();
+ this.eraserPreviewController.detach();
this.devStatsOverlay?.destroy();
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
@@ -149,22 +168,20 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
- this.agentPopulation.growBudget(
- this.framePerformance.frameDeltaSeconds,
- this.framePerformance.smoothedFps
- );
+ this.agentPopulation.updateAdaptiveCap();
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
this.resize();
this.resizeSimulationToCanvas(time);
this.regenerateIntroAfterSettledResize(time);
- const { channelColors, backgroundColor } = this.renderInputs.get();
+ const channelColors = activeVibe.colors;
+ const backgroundColor = activeVibe.backgroundColor;
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
- this.renderInputs.updateAccentColor(accentColor);
+ this.updateAccentColor(accentColor);
this.audio.update({
vibe: activeVibe,
isErasing,
@@ -203,6 +220,16 @@ export default class GameLoop {
requestAnimationFrame(this.render);
};
+ private updateAccentColor(color: RgbColor): void {
+ const accentColor = rgbColorToCss(color);
+ if (this.previousAccentColor === accentColor) {
+ return;
+ }
+
+ this.previousAccentColor = accentColor;
+ document.documentElement.style.setProperty('--accent-color', accentColor);
+ }
+
private resize(): void {
const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({
diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts
index c3441e9..962cfe8 100644
--- a/src/game-loop/pointer-input.ts
+++ b/src/game-loop/pointer-input.ts
@@ -2,23 +2,17 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
-import {
- BrushPipeline,
- getSafePixelRatio,
-} from '../pipelines/brush/brush-pipeline';
-import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
-import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
-import { activeVibe, settings } from '../settings';
-import { EraserPreview } from './eraser-preview';
-import { StrokeSegment } from './game-loop-types';
+import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
+import { activeVibe } from '../settings';
+import { BrushStrokeSmoother } from './brush-stroke-smoother';
+import { type StrokeSegment } from './game-loop-types';
+import { getMirroredStrokeSegments } from './stroke-mirroring';
+import { type StrokeOutput } from './stroke-output';
interface GardenPointerInputOptions {
canvas: HTMLCanvasElement;
audio: GardenAudio;
- brushPipeline: BrushPipeline;
- eraserAgentPipeline: EraserAgentPipeline;
- eraserTexturePipeline: EraserTexturePipeline;
- eraserPreview: EraserPreview;
+ strokeOutput: StrokeOutput;
getCanvasPixelRatio: () => number;
getMirrorSegmentCount: () => number;
onStartDrawing: () => void;
@@ -26,19 +20,28 @@ interface GardenPointerInputOptions {
spawnStrokeAgents: (from: vec2, to: vec2) => void;
}
+interface PointerSample {
+ position: vec2;
+ previousPosition: vec2;
+ elapsedSeconds: number;
+ timeStamp: number;
+}
+
export class GardenPointerInput {
+ private readonly brushSmoother: BrushStrokeSmoother;
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
- private smoothedStrokePoints: Array = [];
- private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
- public constructor(private readonly options: GardenPointerInputOptions) {}
+ public constructor(private readonly options: GardenPointerInputOptions) {
+ this.brushSmoother = new BrushStrokeSmoother({
+ getCanvasPixelRatio: options.getCanvasPixelRatio,
+ getMirrorSegmentCount: options.getMirrorSegmentCount,
+ });
+ }
public attach(): void {
- this.canvas.addEventListener('pointerenter', this.onPointerEnter);
- this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
@@ -46,8 +49,6 @@ export class GardenPointerInput {
}
public detach(): void {
- this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
- this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
@@ -56,11 +57,6 @@ export class GardenPointerInput {
public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing;
- this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
- }
-
- public updateEraserPreview(event?: PointerEvent): void {
- this.options.eraserPreview.update(event, this.isSwipeActive);
}
public clearSwipesIfIdle(): void {
@@ -68,9 +64,7 @@ export class GardenPointerInput {
return;
}
- this.options.brushPipeline.clearSwipes();
- this.options.eraserAgentPipeline.clearSwipes();
- this.options.eraserTexturePipeline.clearSwipes();
+ this.options.strokeOutput.clearSwipes();
}
public scaleLastPointerPosition(scale: vec2): void {
@@ -78,13 +72,7 @@ export class GardenPointerInput {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
}
- this.smoothedStrokePoints.forEach((point) => {
- vec2.mul(point, point, scale);
- });
-
- if (this.lastSmoothedBrushPosition !== null) {
- vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
- }
+ this.brushSmoother.scale(scale);
}
public get isSwipeActive(): boolean {
@@ -100,8 +88,6 @@ export class GardenPointerInput {
}
private readonly onPointerDown = (event: PointerEvent) => {
- this.options.eraserPreview.setPointerHoveringCanvas(true);
- this.updateEraserPreview(event);
if (this.activePointerId !== null) {
return;
}
@@ -111,17 +97,14 @@ export class GardenPointerInput {
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
- this.options.brushPipeline.clearSwipes();
- this.options.eraserAgentPipeline.clearSwipes();
- this.options.eraserTexturePipeline.clearSwipes();
+ this.options.strokeOutput.clearSwipes();
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
- this.clearSmoothedStroke();
+ this.brushSmoother.clear();
this.addSwipeAt(event, { emitAudio: false });
};
private readonly onPointerMove = (event: PointerEvent) => {
- this.updateEraserPreview(event);
if (event.pointerId !== this.activePointerId) {
return;
}
@@ -136,7 +119,7 @@ export class GardenPointerInput {
}
this.options.audio.start(activeVibe, { userGesture: true });
this.addSwipeAt(event, { emitAudio: false });
- this.finishSmoothedStroke();
+ this.finishBrushStroke();
this.options.audio.endGesture();
if (this.isErasing) {
this.options.onEraseGestureEnded();
@@ -145,24 +128,27 @@ export class GardenPointerInput {
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
- this.clearSmoothedStroke();
- this.options.eraserPreview.setPointerHoveringCanvas(
- this.options.eraserPreview.isPointerInsideCanvas(event)
- );
- this.updateEraserPreview(event);
- };
-
- private readonly onPointerEnter = (event: PointerEvent) => {
- this.options.eraserPreview.setPointerHoveringCanvas(true);
- this.updateEraserPreview(event);
- };
-
- private readonly onPointerLeave = () => {
- this.options.eraserPreview.setPointerHoveringCanvas(false);
- this.updateEraserPreview();
+ this.brushSmoother.clear();
};
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
+ const sample = this.getPointerSample(event);
+
+ if (this.isErasing) {
+ this.addEraseSample(sample);
+ } else {
+ this.addBrushSample(sample);
+ }
+
+ if (options.emitAudio !== false) {
+ this.emitStrokeAudio(sample);
+ }
+
+ this.lastPointerPosition = sample.position;
+ this.lastPointerEventTimeMs = sample.timeStamp;
+ }
+
+ private getPointerSample(event: PointerEvent): PointerSample {
const position = this.getCanvasPointerPosition(event);
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
@@ -171,33 +157,36 @@ export class GardenPointerInput {
(event.timeStamp - previousTimeMs) / 1000
);
- const segments = this.isErasing
- ? [{ from: previousPosition, to: position }]
- : this.getMirroredStrokeSegments(previousPosition, position);
+ return {
+ position,
+ previousPosition,
+ elapsedSeconds,
+ timeStamp: event.timeStamp,
+ };
+ }
- if (this.isErasing) {
- segments.forEach((segment) => {
- this.options.eraserAgentPipeline.addSwipeSegment();
- this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
- });
- } else {
- this.addSmoothedBrushSample(position);
- segments.forEach((segment) => {
+ private addBrushSample(sample: PointerSample): void {
+ this.addBrushSegments(this.brushSmoother.addSample(sample.position));
+ this.getMirroredSegments(sample.previousPosition, sample.position).forEach(
+ (segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
- });
- }
- if (options.emitAudio !== false) {
- this.options.audio.stroke({
- vibe: activeVibe,
- from: previousPosition,
- to: position,
- canvasSize: [this.canvas.width, this.canvas.height],
- isErasing: this.isErasing,
- elapsedSeconds,
- });
- }
- this.lastPointerPosition = position;
- this.lastPointerEventTimeMs = event.timeStamp;
+ }
+ );
+ }
+
+ private addEraseSample(sample: PointerSample): void {
+ this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position);
+ }
+
+ private emitStrokeAudio(sample: PointerSample): void {
+ this.options.audio.stroke({
+ vibe: activeVibe,
+ from: sample.previousPosition,
+ to: sample.position,
+ canvasSize: [this.canvas.width, this.canvas.height],
+ isErasing: this.isErasing,
+ elapsedSeconds: sample.elapsedSeconds,
+ });
}
private getCanvasPointerPosition(event: PointerEvent): vec2 {
@@ -210,103 +199,23 @@ export class GardenPointerInput {
);
}
- private addSmoothedBrushSample(position: vec2): void {
- const previousSample =
- this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
- if (
- previousSample !== undefined &&
- vec2.squaredDistance(previousSample, position) <=
- getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
- ) {
- return;
- }
-
- this.smoothedStrokePoints.push(vec2.clone(position));
-
- if (this.smoothedStrokePoints.length > 3) {
- this.smoothedStrokePoints.shift();
- }
-
- if (this.smoothedStrokePoints.length === 1) {
- this.addMirroredBrushSegment(position, position);
- this.lastSmoothedBrushPosition = vec2.clone(position);
- return;
- }
-
- if (this.smoothedStrokePoints.length === 2) {
- const [start, end] = this.smoothedStrokePoints;
- const midpoint = getMidpoint(start, end);
- this.addMirroredBrushSegment(start, midpoint);
- this.lastSmoothedBrushPosition = midpoint;
- return;
- }
-
- const [start, control, end] = this.smoothedStrokePoints;
- const curveStart = getMidpoint(start, control);
- const curveEnd = getMidpoint(control, end);
- this.addQuadraticBrushSegments(curveStart, control, curveEnd);
- this.lastSmoothedBrushPosition = curveEnd;
- }
-
- private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
- const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
- const canvasPixelRatio = getSafePixelRatio(
- this.options.getCanvasPixelRatio()
- );
- const brushRadius = Math.max(
- settings.brushCurveMinBrushRadius * canvasPixelRatio,
- (settings.brushSize * canvasPixelRatio) / 2
- );
- const segmentSpacing = Math.max(
- settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
- brushRadius * settings.brushCurveSegmentBrushRadiusRatio
- );
- const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
- const curveResolution = getBrushCurveResolution();
- const maxCurveSegments = Math.max(
- 1,
- Math.floor(
- curveResolution /
- Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
- )
- );
- const segmentCount = Math.min(
- maxCurveSegments,
- Math.max(1, Math.ceil(curveLength / segmentSpacing))
- );
-
- let previousPoint = start;
- for (let i = 1; i <= segmentCount; i++) {
- const point = getQuadraticPoint(start, control, end, i / segmentCount);
- this.addMirroredBrushSegment(previousPoint, point);
- previousPoint = point;
- }
- }
-
- private addMirroredBrushSegment(from: vec2, to: vec2): void {
- this.getMirroredStrokeSegments(from, to).forEach((segment) => {
- this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
+ private addBrushSegments(segments: Array): void {
+ segments.forEach((segment) => {
+ this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
+ this.options.strokeOutput.addBrushSegment(
+ mirroredSegment.from,
+ mirroredSegment.to
+ );
+ });
});
}
- private finishSmoothedStroke(): void {
- if (this.isErasing || this.smoothedStrokePoints.length === 0) {
+ private finishBrushStroke(): void {
+ if (this.isErasing) {
return;
}
- const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
- if (
- this.lastSmoothedBrushPosition !== null &&
- vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
- getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
- ) {
- this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
- }
- }
-
- private clearSmoothedStroke(): void {
- this.smoothedStrokePoints.length = 0;
- this.lastSmoothedBrushPosition = null;
+ this.addBrushSegments(this.brushSmoother.finish());
}
private getCoalescedPointerEvents(event: PointerEvent): Array {
@@ -326,67 +235,16 @@ export class GardenPointerInput {
: [...coalescedEvents, event];
}
- private getMirroredStrokeSegments(from: vec2, to: vec2): Array {
- const segmentCount = this.options.getMirrorSegmentCount();
- if (segmentCount <= 1) {
- return [{ from, to }];
- }
-
- const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
- const angleStep = (Math.PI * 2) / segmentCount;
- const segments: Array = [];
- for (let i = 0; i < segmentCount; i++) {
- const angle = angleStep * i;
- segments.push({
- from: rotatePointAround(from, center, angle),
- to: rotatePointAround(to, center, angle),
- });
- }
-
- return segments;
+ private getMirroredSegments(from: vec2, to: vec2): Array {
+ return getMirroredStrokeSegments(
+ from,
+ to,
+ vec2.fromValues(this.canvas.width, this.canvas.height),
+ this.options.getMirrorSegmentCount()
+ );
}
}
-const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
- if (angle === 0) {
- return point;
- }
-
- const offsetX = point[0] - center[0];
- const offsetY = point[1] - center[1];
- const cos = Math.cos(angle);
- const sin = Math.sin(angle);
- return vec2.fromValues(
- center[0] + offsetX * cos - offsetY * sin,
- center[1] + offsetX * sin + offsetY * cos
- );
-};
-
-const getMidpoint = (from: vec2, to: vec2): vec2 =>
- vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
-
-const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
- const inverseT = 1 - t;
- return vec2.fromValues(
- inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
- inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
- );
-};
-
-const getBrushCurveResolution = (): number => {
- const resolution = Number.isFinite(settings.brushCurveResolution)
- ? settings.brushCurveResolution
- : appConfig.defaultSettings.brushCurveResolution;
- return Math.max(1, Math.floor(resolution));
-};
-
-const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
- const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
- ? settings.brushSmoothingMinSampleDistance
- : appConfig.defaultSettings.brushSmoothingMinSampleDistance;
- return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
-};
-
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
left.clientX === right.clientX &&
left.clientY === right.clientY &&
diff --git a/src/game-loop/render-input-cache.ts b/src/game-loop/render-input-cache.ts
deleted file mode 100644
index 53234b3..0000000
--- a/src/game-loop/render-input-cache.ts
+++ /dev/null
@@ -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);
- }
-}
diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts
index 96ef477..719f56d 100644
--- a/src/game-loop/simulation-frame.ts
+++ b/src/game-loop/simulation-frame.ts
@@ -53,8 +53,7 @@ export class SimulationFrameRenderer {
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
- this.textures.trailMapB.getTextureView(),
- this.textures.influenceMapA.getTextureView()
+ this.textures.trailMapB.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
@@ -68,21 +67,25 @@ export class SimulationFrameRenderer {
this.textures.sourceMapA.getTextureView()
);
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
+
+ this.device.queue.submit([commandEncoder.finish()]);
+ canvasReadbackRequest?.afterSubmit();
+
+ const postRenderCommandEncoder = this.device.createCommandEncoder();
this.pipelines.diffusionPipeline.execute(
- commandEncoder,
+ postRenderCommandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(),
this.textures.sourceMapB.getSize()
);
this.pipelines.brushEffectDiffusionPipeline.execute(
- commandEncoder,
+ postRenderCommandEncoder,
this.textures.influenceMapA.getTextureView(),
this.textures.influenceMapB.getTextureView(),
this.textures.influenceMapB.getSize()
);
- this.device.queue.submit([commandEncoder.finish()]);
- canvasReadbackRequest?.afterSubmit();
+ this.device.queue.submit([postRenderCommandEncoder.finish()]);
this.textures.swapBrushEffectMaps();
}
}
diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts
index 825f014..fd3bc87 100644
--- a/src/game-loop/simulation-textures.ts
+++ b/src/game-loop/simulation-textures.ts
@@ -22,7 +22,7 @@ export class SimulationTextures {
this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapA = this.createTexture(canvasSize);
this.influenceMapB = this.createTexture(canvasSize);
- this.eraserMask = this.createTexture(canvasSize);
+ this.eraserMask = this.createEraserMask(canvasSize);
}
public resizeTo(nextSize: vec2): vec2 | null {
@@ -97,4 +97,16 @@ export class SimulationTextures {
private createTexture(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, size);
}
+
+ private createEraserMask(size: vec2): ResizableTexture {
+ return new ResizableTexture(this.device, size, {
+ clearValue: { r: 1, g: 1, b: 1, a: 1 },
+ format: 'r8unorm',
+ usage:
+ GPUTextureUsage.TEXTURE_BINDING |
+ GPUTextureUsage.RENDER_ATTACHMENT |
+ GPUTextureUsage.COPY_SRC |
+ GPUTextureUsage.COPY_DST,
+ });
+ }
}
diff --git a/src/index.ts b/src/index.ts
index f88ae26..8552579 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -28,6 +28,7 @@ import {
} from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { clamp01 } from './utils/math';
+import { rgbColorToCss } from './utils/rgb-color';
import { VIBE_PRESETS } from './vibes';
const AUDIO_VOLUME_STEP = 0.01;
@@ -358,7 +359,7 @@ const renderAudioUi = (game: GameLoop | null) => {
const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => {
- swatch.style.backgroundColor = activeVibe.colors[index];
+ swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle(
CSS_CLASSES.active,
settings.selectedColorIndex === index && !isEraserActive
@@ -368,7 +369,7 @@ const renderPaletteUi = (game: GameLoop | null) => {
game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty(
CSS_VARIABLES.gardenBackground,
- activeVibe.backgroundColor
+ rgbColorToCss(activeVibe.backgroundColor)
);
};
@@ -471,7 +472,7 @@ const main = async () => {
new FullScreenHandler(
elements.minimizeFullScreenButton,
elements.maximizeFullScreenButton,
- document.body
+ document.documentElement
);
const startAudioFromUserGesture = (event: Event) => {
@@ -599,7 +600,7 @@ const main = async () => {
elements.export4k.disabled = true;
try {
- await game.export4K();
+ await game.exportSnapshot();
trackExport({ vibeId: activeVibe.id });
} catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING });
@@ -716,7 +717,7 @@ const main = async () => {
});
}
- // Keep the toolbar/dock hidden until the user actually starts drawing.
+ // Keep the dev stats overlay hidden until the user actually starts drawing.
document.body.classList.add(CSS_CLASSES.preDrawing);
elements.canvas.addEventListener(
DOM_EVENTS.pointerDown,
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index defadee..9e692b3 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -7,6 +7,7 @@ import {
type NumberControlConfig,
} from '../config';
import { activeVibe, settings } from '../settings';
+import { rgbColorToCss } from '../utils/rgb-color';
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
type PaneContainer = Pick;
@@ -55,9 +56,6 @@ const isPlainObject = (value: unknown): value is Record =>
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
['boolean', 'number', 'string'].includes(typeof value);
-const isColorString = (value: unknown): value is string =>
- typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
-
const toLabel = (value: string): string =>
value
.replace(/\[(\d+)\]/g, ' $1')
@@ -348,7 +346,7 @@ export class ConfigPane {
});
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
- element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
+ element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
});
}
@@ -367,7 +365,7 @@ export class ConfigPane {
): void {
Object.entries(source).forEach(([key, value]) => {
if (isBindablePrimitive(value)) {
- this.addPrimitiveBinding(container, source, key, value);
+ this.addPrimitiveBinding(container, source, key);
return;
}
@@ -381,8 +379,7 @@ export class ConfigPane {
this.addPrimitiveBinding(
folder,
value as unknown as Record,
- `${index}`,
- item
+ `${index}`
);
return;
}
@@ -417,12 +414,10 @@ export class ConfigPane {
private addPrimitiveBinding(
container: PaneContainer,
source: Record,
- key: string,
- value: boolean | number | string
+ key: string
): void {
const params: BindingParams = {
label: toLabel(key),
- ...(isColorString(value) ? { color: { type: 'int' } } : {}),
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
};
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index e8071c5..7e8845c 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -4,7 +4,7 @@ export class FullScreenHandler {
private readonly maximizeButton: HTMLElement,
target: HTMLElement
) {
- if (!document.fullscreenEnabled) {
+ if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
minimizeButton.hidden = true;
maximizeButton.hidden = true;
return;
@@ -13,7 +13,9 @@ export class FullScreenHandler {
this.updateButtons();
addEventListener('fullscreenchange', this.updateButtons.bind(this));
- maximizeButton.addEventListener('click', () => target.requestFullscreen());
+ maximizeButton.addEventListener('click', () => {
+ void target.requestFullscreen().catch(() => undefined);
+ });
minimizeButton.addEventListener('click', () => document.exitFullscreen());
}
diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
index d71ddb2..845580b 100644
--- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
@@ -15,10 +15,7 @@ struct Counters {
var workgroupAliveCount: atomic;
var workgroupCompactedOffset: u32;
-
-fn dead_agent() -> Agent {
- return Agent(vec2(0.0, 0.0), 0.0, -1.0, vec2(-1.0, -1.0), 0.0, 0.0);
-}
+var clearAliveAgentCount: u32;
@compute @workgroup_size(64)
fn main(
@@ -35,12 +32,12 @@ fn main(
workgroupBarrier();
var localCompactedIndex = 0u;
- var agent = dead_agent();
+ var agent: Agent;
var isAlive = false;
if id < settings.agentCount {
- agent = agents[id];
- isAlive = agent.colorIndex >= 0.0;
+ isAlive = agents[id].colorIndex >= 0.0;
if isAlive {
+ agent = agents[id];
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
}
}
@@ -66,16 +63,22 @@ fn main(
@compute @workgroup_size(64)
fn clearCompactedTail(
@builtin(global_invocation_id) global_id: vec3,
+ @builtin(local_invocation_id) local_id: vec3,
@builtin(num_workgroups) num_workgroups: vec3
) {
let id = get_id(global_id, num_workgroups);
+ if local_id.x == 0u {
+ clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
+ }
+
+ workgroupBarrier();
+
if id >= settings.agentCount {
return;
}
- let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
- if id >= aliveAgentCount {
- compactedAgents[id] = dead_agent();
+ if id >= clearAliveAgentCount {
+ compactedAgents[id].colorIndex = -1.0;
}
}
diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
index f243441..890b3db 100644
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
+++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
@@ -39,6 +39,15 @@ export class AgentGenerationPipeline {
private readonly agentCountUniformValues = new Uint32Array(
AgentGenerationPipeline.UNIFORM_COUNT
);
+ private readonly resizeUniformBuffer = new ArrayBuffer(
+ AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
+ );
+ private readonly resizeUniformFloatValues = new Float32Array(
+ this.resizeUniformBuffer
+ );
+ private readonly resizeUniformUintValues = new Uint32Array(
+ this.resizeUniformBuffer
+ );
public constructor(
private readonly device: GPUDevice,
@@ -233,11 +242,11 @@ export class AgentGenerationPipeline {
return;
}
- this.device.queue.writeBuffer(
- this.uniforms,
- 0,
- new Float32Array([scale[0], scale[1], agentCount, 0])
- );
+ this.resizeUniformFloatValues[0] = scale[0];
+ this.resizeUniformFloatValues[1] = scale[1];
+ this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
+ this.resizeUniformUintValues[3] = 0;
+ this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl
index 3e160de..bbbe120 100644
--- a/src/pipelines/agents/agent-generation/agent-resize.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl
@@ -1,7 +1,7 @@
struct ResizeSettings {
scale: vec2,
- agentCount: f32,
- padding: f32,
+ agentCount: u32,
+ padding: u32,
};
@group(1) @binding(0) var resizeSettings: ResizeSettings;
@@ -13,10 +13,11 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
- if id >= u32(resizeSettings.agentCount) {
+ if id >= resizeSettings.agentCount {
return;
}
- agents[id].position = agents[id].position * resizeSettings.scale;
- agents[id].targetPosition = agents[id].targetPosition * resizeSettings.scale;
+ let scale = resizeSettings.scale;
+ agents[id].position = agents[id].position * scale;
+ agents[id].targetPosition = agents[id].targetPosition * scale;
}
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index 3e1ac46..a599d32 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -10,21 +10,19 @@ import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
- private static readonly UNIFORM_COUNT = 33;
+ private static readonly UNIFORM_COUNT = 30;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
+ private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
AgentPipeline.UNIFORM_COUNT
);
private readonly bindGroupsByAgentsBuffer = new WeakMap<
GPUBuffer,
- WeakMap<
- GPUTextureView,
- WeakMap>
- >
+ WeakMap>
>();
private agentCount = 0;
@@ -69,9 +67,6 @@ export class AgentPipeline {
color3ToColor1,
color3ToColor2,
color3ToColor3,
- sourceAttractionWeight,
- sourceSlowMoveRate,
- sourceTrailWeightMultiplier,
forwardRotationScale,
introNearDistanceInner,
introNearDistanceMin,
@@ -100,7 +95,7 @@ export class AgentPipeline {
this.uniformValues[4] = sensorOffsetDistance;
this.uniformValues[5] = turnWhenLost;
this.uniformValues[6] = individualTrailWeight;
- this.uniformValues[7] = agentCount;
+ this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount));
this.uniformValues[8] = introProgress ?? 1;
this.uniformValues[9] = color1ToColor1;
this.uniformValues[10] = color1ToColor2;
@@ -111,21 +106,18 @@ export class AgentPipeline {
this.uniformValues[15] = color3ToColor1;
this.uniformValues[16] = color3ToColor2;
this.uniformValues[17] = color3ToColor3;
- this.uniformValues[18] = sourceAttractionWeight;
- this.uniformValues[19] = sourceSlowMoveRate;
- this.uniformValues[20] = sourceTrailWeightMultiplier;
- this.uniformValues[21] = forwardRotationScale;
- this.uniformValues[22] = introNearDistanceInner;
- this.uniformValues[23] = introNearDistanceMin;
- this.uniformValues[24] = introNearSensorOffsetMultiplier;
- this.uniformValues[25] = introTargetAngleBlend;
- this.uniformValues[26] = introProgressCutoff;
- this.uniformValues[27] = introTurnRateMultiplier;
- this.uniformValues[28] = introRandomTurnMultiplier;
- this.uniformValues[29] = introFarMoveMultiplier;
- this.uniformValues[30] = introNearMoveMultiplier;
- this.uniformValues[31] = introStepStopDistance;
- this.uniformValues[32] = randomTimeScale;
+ this.uniformValues[18] = forwardRotationScale;
+ this.uniformValues[19] = introNearDistanceInner;
+ this.uniformValues[20] = introNearDistanceMin;
+ this.uniformValues[21] = introNearSensorOffsetMultiplier;
+ this.uniformValues[22] = introTargetAngleBlend;
+ this.uniformValues[23] = introProgressCutoff;
+ this.uniformValues[24] = introTurnRateMultiplier;
+ this.uniformValues[25] = introRandomTurnMultiplier;
+ this.uniformValues[26] = introFarMoveMultiplier;
+ this.uniformValues[27] = introNearMoveMultiplier;
+ this.uniformValues[28] = introStepStopDistance;
+ this.uniformValues[29] = randomTimeScale;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@@ -137,14 +129,13 @@ export class AgentPipeline {
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
- trailMapOut: GPUTextureView,
- sourceMap: GPUTextureView
+ trailMapOut: GPUTextureView
) {
if (this.agentCount <= 0) {
return;
}
- const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
+ const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
@@ -156,32 +147,22 @@ export class AgentPipeline {
private getBindGroup(
trailMapIn: GPUTextureView,
- trailMapOut: GPUTextureView,
- sourceMap: GPUTextureView
+ trailMapOut: GPUTextureView
): GPUBindGroup {
const agentsBuffer = this.getAgentsBuffer();
let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
if (!textureCache) {
- textureCache = new WeakMap<
- GPUTextureView,
- WeakMap>
- >();
+ textureCache = new WeakMap>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
}
let outputCache = textureCache.get(trailMapIn);
if (!outputCache) {
- outputCache = new WeakMap>();
+ outputCache = new WeakMap();
textureCache.set(trailMapIn, outputCache);
}
- let sourceCache = outputCache.get(trailMapOut);
- if (!sourceCache) {
- sourceCache = new WeakMap();
- outputCache.set(trailMapOut, sourceCache);
- }
-
- const cached = sourceCache.get(sourceMap);
+ const cached = outputCache.get(trailMapOut);
if (cached) {
return cached;
}
@@ -209,14 +190,10 @@ export class AgentPipeline {
binding: 3,
resource: trailMapOut,
},
- {
- binding: 4,
- resource: sourceMap,
- },
],
});
- sourceCache.set(sourceMap, bindGroup);
+ outputCache.set(trailMapOut, bindGroup);
return bindGroup;
}
@@ -255,13 +232,6 @@ export class AgentPipeline {
format: 'rgba16float',
},
},
- {
- binding: 4,
- visibility: GPUShaderStage.COMPUTE,
- texture: {
- sampleType: 'float',
- },
- },
],
};
}
diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts
index 2517791..7628306 100644
--- a/src/pipelines/agents/agent-settings.ts
+++ b/src/pipelines/agents/agent-settings.ts
@@ -14,9 +14,6 @@ export interface AgentSettings {
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
- sourceAttractionWeight: number;
- sourceSlowMoveRate: number;
- sourceTrailWeightMultiplier: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index f1b7412..1a9b50d 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -6,7 +6,7 @@ struct Settings {
sensorOffset: f32,
turnWhenLost: f32,
individualTrailWeight: f32,
- agentCount: f32,
+ agentCount: u32,
introProgress: f32,
color1ToColor1: f32,
color1ToColor2: f32,
@@ -17,9 +17,6 @@ struct Settings {
color3ToColor1: f32,
color3ToColor2: f32,
color3ToColor3: f32,
- sourceAttractionWeight: f32,
- sourceSlowMoveRate: f32,
- sourceTrailWeightMultiplier: f32,
forwardRotationScale: f32,
introNearDistanceInner: f32,
introNearDistanceMin: f32,
@@ -37,7 +34,6 @@ struct Settings {
@group(1) @binding(0) var settings: Settings;
@group(1) @binding(2) var trailMapIn: texture_2d;
@group(1) @binding(3) var trailMapOut: texture_storage_2d;
-@group(1) @binding(4) var sourceMap: texture_2d;
@compute @workgroup_size(64)
fn main(
@@ -46,7 +42,7 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
- if id >= u32(settings.agentCount) {
+ if id >= settings.agentCount {
return;
}
@@ -67,63 +63,15 @@ fn main(
}
}
- let randomSeed = random_seed(id, state.time);
- let randomTurn = random_float(randomSeed);
- let direction = vec2(cos(angle), sin(angle));
-
- let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
- let leftSensor = sensor_position(
- position,
- rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
- settings.sensorOffset
- );
- let rightSensor = sensor_position(
- position,
- rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
- settings.sensorOffset
- );
-
- let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
- let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
- let trailRight = textureLoad(trailMapIn, rightSensor, 0);
- let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
- let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
- let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
-
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
-
- let trailForwardWeight = dot(trailForward.rgb, reactionMask);
- let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
- let trailRightWeight = dot(trailRight.rgb, reactionMask);
-
- let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
- let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
- let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
-
- let weightForward =
- trailForwardWeight + sourceForwardWeight * settings.sourceAttractionWeight;
- let weightLeft = trailLeftWeight + sourceLeftWeight * settings.sourceAttractionWeight;
- let weightRight =
- trailRightWeight + sourceRightWeight * settings.sourceAttractionWeight;
-
- var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
- if weightForward >= weightLeft && weightForward >= weightRight {
- rotation = rotation * settings.forwardRotationScale;
- } else {
- rotation += sign(weightLeft - weightRight) * settings.turnRate;
- }
-
- let sourceAtAgent = textureLoad(sourceMap, vec2(position), 0);
- let positiveReactionMask = max(reactionMask, vec3(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(0.0, 0.0);
- var introTargetDistance = 0.0;
+ let randomSeed = random_seed(id, state.time);
+ var rotation = 0.0;
+ var step = vec2(0.0, 0.0);
if hasIntroTarget {
- introTargetOffset = targetPosition - position;
- introTargetDistance = length(introTargetOffset);
+ let introTargetOffset = targetPosition - position;
+ let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner,
@@ -148,19 +96,46 @@ fn main(
+ (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost *
settings.introRandomTurnMultiplier;
- moveRate = min(
+ let moveRate = min(
settings.moveRate *
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
introTargetDistance
);
- }
-
- var step = direction * moveRate;
- if hasIntroTarget {
- step = vec2(0.0, 0.0);
if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate;
}
+ } else {
+ let randomTurn = random_float(randomSeed);
+ let direction = vec2(cos(angle), sin(angle));
+
+ let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
+ let leftSensor = sensor_position(
+ position,
+ rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
+ settings.sensorOffset
+ );
+ let rightSensor = sensor_position(
+ position,
+ rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
+ settings.sensorOffset
+ );
+
+ let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
+ let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
+ let trailRight = textureLoad(trailMapIn, rightSensor, 0);
+
+ let weightForward = dot(trailForward.rgb, reactionMask);
+ let weightLeft = dot(trailLeft.rgb, reactionMask);
+ let weightRight = dot(trailRight.rgb, reactionMask);
+
+ rotation = (randomTurn - 0.5) * settings.turnWhenLost;
+ if weightForward >= weightLeft && weightForward >= weightRight {
+ rotation = rotation * settings.forwardRotationScale;
+ } else {
+ rotation += sign(weightLeft - weightRight) * settings.turnRate;
+ }
+
+ step = direction * settings.moveRate;
}
let maxPosition = state.size - vec2(1.0, 1.0);
@@ -169,14 +144,9 @@ fn main(
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
}
- let sourceBelow = textureLoad(sourceMap, vec2(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(nextPosition), 0);
trailBelow = vec4(
- trailBelow.rgb + channelMask * trailWeight,
+ trailBelow.rgb + channelMask * settings.individualTrailWeight,
max(trailBelow.a, 0.0)
);
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index ecce302..b3c098e 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -31,8 +31,6 @@ export const setBrushUniformValues = (
brushSize,
brushSizeVariation,
brushAlpha,
- brushFeatherRatio,
- brushMinimumFeather,
brushDiscardThreshold,
brushCoarseNoiseScale,
brushGrainNoiseScale,
@@ -47,30 +45,24 @@ export const setBrushUniformValues = (
const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
- const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
- const brushFeather = Math.max(
- brushMinimumFeatherPixels,
- brushRadius * brushFeatherRatio
- );
- const brushGeometryRadius =
- brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
+ const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
target[0] = brushRadius;
- target[1] = brushRadiusVariation;
- target[2] = brushFeatherRatio;
- target[3] = brushMinimumFeatherPixels;
+ target[1] = brushRadiusVariation * 2;
+ target[2] = brushGeometryRadius * brushGeometryRadius;
+ target[3] = brushGeometryRadius;
target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha;
- target[8] = brushCoarseNoiseScale * safePixelRatio;
- target[9] = brushGrainNoiseScale * safePixelRatio;
+ target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio);
+ target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[10] = brushGrainNoiseOffsetX;
target[11] = brushGrainNoiseOffsetY;
target[12] = brushDiscardThreshold;
target[13] = brushGrainMinStrength;
target[14] = brushGrainMaxStrength;
- target[15] = brushGeometryRadius;
+ target[15] = 0;
};
export class BrushPipeline {
diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts
index 75c68b8..6ea2b59 100644
--- a/src/pipelines/brush/brush-settings.ts
+++ b/src/pipelines/brush/brush-settings.ts
@@ -2,8 +2,6 @@ export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
brushAlpha: number;
- brushFeatherRatio: number;
- brushMinimumFeather: number;
brushDiscardThreshold: number;
brushCoarseNoiseScale: number;
brushGrainNoiseScale: number;
diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl
index e88c38e..0b6e451 100644
--- a/src/pipelines/brush/brush.wgsl
+++ b/src/pipelines/brush/brush.wgsl
@@ -1,8 +1,8 @@
struct Settings {
brushSize: f32,
brushSizeVariation: f32,
- brushFeatherRatio: f32,
- brushMinimumFeather: f32,
+ brushGeometryRadiusSquared: f32,
+ brushGeometryRadius: f32,
brushValue: vec4,
brushCoarseNoiseScale: f32,
brushGrainNoiseScale: f32,
@@ -11,7 +11,6 @@ struct Settings {
brushDiscardThreshold: f32,
brushGrainMinStrength: f32,
brushGrainMaxStrength: f32,
- brushGeometryRadius: f32,
};
@group(1) @binding(0) var settings: Settings;
@@ -20,7 +19,8 @@ struct VertexOutput {
@builtin(position) position: vec4,
@location(0) screenPosition: vec2,
@location(1) @interpolate(flat) start: vec2,
- @location(2) @interpolate(flat) end: vec2
+ @location(2) @interpolate(flat) direction: vec2,
+ @location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct BrushTargets {
@@ -34,24 +34,36 @@ fn vertex(
@location(0) start: vec2,
@location(1) end: vec2
) -> VertexOutput {
+ let direction = end - start;
+ let denominator = dot(direction, direction);
+ var inverseLengthSquared = 0.0;
+ if denominator > 0.0001 {
+ inverseLengthSquared = 1.0 / denominator;
+ }
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius);
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
- return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
+ return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragmentMrt(
@location(0) screenPosition: vec2,
@location(1) @interpolate(flat) start: vec2,
- @location(2) @interpolate(flat) end: vec2
+ @location(2) @interpolate(flat) direction: vec2,
+ @location(3) @interpolate(flat) inverseLengthSquared: f32
) -> BrushTargets {
- let distanceSquared = distanceSquaredFromLine(screenPosition, start, end);
- if distanceSquared > settings.brushGeometryRadius * settings.brushGeometryRadius {
+ let distanceSquared = distanceSquaredFromLine(
+ screenPosition,
+ start,
+ direction,
+ inverseLengthSquared
+ );
+ if distanceSquared > settings.brushGeometryRadiusSquared {
discard;
}
- let strength = brushStrength(screenPosition, start, end, distanceSquared);
+ let strength = brushStrength(screenPosition, distanceSquared);
if(strength < settings.brushDiscardThreshold) {
discard;
@@ -63,27 +75,28 @@ fn fragmentMrt(
fn brushStrength(
screenPosition: vec2,
- start: vec2,
- end: vec2,
distanceSquared: f32
) -> f32 {
let distance = sqrt(distanceSquared);
- let coarseNoise = textureSample(
+ let coarseNoise = textureSampleLevel(
noise,
noiseSampler,
- fract(screenPosition / settings.brushCoarseNoiseScale)
+ screenPosition * settings.brushCoarseNoiseScale,
+ 0.0
).r;
- let grainNoise = textureSample(
+ let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
+ let edge = 1.0 - step(radius, distance);
+ if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
+ return 0.0;
+ }
+
+ let grainNoise = textureSampleLevel(
noise,
noiseSampler,
- fract(
- screenPosition / settings.brushGrainNoiseScale +
- vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY)
- )
+ screenPosition * settings.brushGrainNoiseScale +
+ vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
+ 0.0
).r;
- let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
- let feather = max(settings.brushMinimumFeather, settings.brushSize * settings.brushFeatherRatio);
- let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
}
@@ -91,16 +104,19 @@ fn brushOutput(strength: f32) -> vec4 {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
-fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
+fn distanceSquaredFromLine(
+ position: vec2,
+ start: vec2,
+ direction: vec2,
+ inverseLengthSquared: f32
+) -> f32 {
let pa = position - start;
- let direction = end - start;
- let denominator = dot(direction, direction);
- if denominator <= 0.0001 {
+ if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
- let q = clamp(dot(pa, direction) / denominator, 0, 1);
+ let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 8be1f8f..af12cd2 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -83,6 +83,8 @@ export class CommonState {
{
binding: 1,
resource: this.device.createSampler({
+ addressModeU: 'repeat',
+ addressModeV: 'repeat',
magFilter: 'linear',
minFilter: 'linear',
}),
diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl
index c0ee060..8f341d4 100644
--- a/src/pipelines/diffusion/diffuse.wgsl
+++ b/src/pipelines/diffusion/diffuse.wgsl
@@ -3,9 +3,9 @@ struct Settings {
decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
- diffusionNeighborDivisor: f32,
- brushDecayAlphaOffset: f32,
- padding0: f32,
+ diffusionNeighborScale: f32,
+ brushDecayAlphaMultiplier: f32,
+ brushDecayAlphaSubtract: f32,
padding1: f32,
};
@@ -60,8 +60,26 @@ fn main(
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex];
let random = random_from_pixel(pixel);
- let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
- let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
+ let r2 = random * random;
+ let r4 = r2 * r2;
+ let r8 = r4 * r4;
+ let r16 = r8 * r8;
+ let trailWeight = diffusion_weight(
+ random,
+ r2,
+ r4,
+ r8,
+ r16,
+ settings.inverseDiffusionRateTrails
+ );
+ let brushWeight = diffusion_weight(
+ random,
+ r2,
+ r4,
+ r8,
+ r16,
+ settings.inverseDiffusionRateBrush
+ );
current += (
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
@@ -73,11 +91,11 @@ fn main(
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
- ) / max(1.0, settings.diffusionNeighborDivisor);
+ ) * settings.diffusionNeighborScale;
let decayed = clamp(vec4(
current.rgb * settings.decayRateTrails,
- max(0, current.a + (current.a - settings.brushDecayAlphaOffset) * settings.decayRateBrush)
+ max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract)
), vec4(0), vec4(1));
textureStore(trailMapOut, pixel, decayed);
@@ -110,9 +128,14 @@ fn random_from_pixel(pixel: vec2) -> f32 {
return f32(hash) * 2.3283064365386963e-10;
}
-fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
- let r = clamp(random, 0.0, 1.0);
-
+fn diffusion_weight(
+ r: f32,
+ r2: f32,
+ r4: f32,
+ r8: f32,
+ r16: f32,
+ inverseRate: f32
+) -> f32 {
if inverseRate < 1.0 {
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
return mix(
@@ -122,22 +145,18 @@ fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
);
}
- let r2 = r * r;
if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0);
}
- let r4 = r2 * r2;
if inverseRate < 4.0 {
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
}
- let r8 = r4 * r4;
if inverseRate < 8.0 {
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
}
- let r16 = r8 * r8;
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
* min(1.0, 16.0 / inverseRate);
}
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index 898503c..b3499ee 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -41,12 +41,18 @@ export const setDiffusionUniformValues = (
}: DiffusionUniformSettings
): void => {
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
+ const brushDecayRate = decayRateBrush / decayDivisor;
+ const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
+ ? Math.max(1, diffusionNeighborDivisor)
+ : 1;
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / decayDivisor;
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
- target[3] = decayRateBrush / decayDivisor;
- target[4] = diffusionNeighborDivisor;
- target[5] = brushDecayAlphaOffset;
+ target[3] = brushDecayRate;
+ target[4] = 1 / neighborDivisor;
+ target[5] = 1 + brushDecayRate;
+ target[6] = brushDecayAlphaOffset * brushDecayRate;
+ target[7] = 0;
};
export class DiffusionPipeline {
diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts
index 392fbd4..702c047 100644
--- a/src/pipelines/eraser/eraser-agent-pipeline.ts
+++ b/src/pipelines/eraser/eraser-agent-pipeline.ts
@@ -14,6 +14,7 @@ export class EraserAgentPipeline {
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
+ private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
@@ -93,7 +94,7 @@ export class EraserAgentPipeline {
this.activeSegmentCount = this.pendingSegmentCount;
this.pendingSegmentCount = 0;
- this.uniformValues[0] = agentCount;
+ this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl
index 1abec62..133b767 100644
--- a/src/pipelines/eraser/eraser-agent.wgsl
+++ b/src/pipelines/eraser/eraser-agent.wgsl
@@ -1,5 +1,5 @@
struct Settings {
- agentCount: f32,
+ agentCount: u32,
eraserMaskAlphaThreshold: f32,
padding1: f32,
padding2: f32,
@@ -15,7 +15,7 @@ fn main(
) {
let id = get_id(global_id, num_workgroups);
- if id >= u32(settings.agentCount) {
+ if id >= settings.agentCount {
return;
}
@@ -32,7 +32,7 @@ fn main(
);
let maskSample = textureLoad(eraserMask, maskPosition, 0);
- if maskSample.a < settings.eraserMaskAlphaThreshold {
+ if maskSample.r < settings.eraserMaskAlphaThreshold {
agents[id].colorIndex = -1.0;
}
}
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
index c3da496..c23a1bb 100644
--- a/src/pipelines/eraser/eraser-texture-pipeline.ts
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -64,7 +64,12 @@ export class EraserTexturePipeline {
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
- this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', 4);
+ this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [
+ 'r8unorm',
+ 'rgba16float',
+ 'rgba16float',
+ 'rgba16float',
+ ]);
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@@ -119,7 +124,7 @@ export class EraserTexturePipeline {
this.uniformValues[3] = eraserClearGreen;
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
- this.uniformValues[6] = 0;
+ this.uniformValues[6] = eraserRadius;
this.uniformValues[7] = 0;
writeFloat32BufferIfChanged(
this.device,
@@ -225,7 +230,7 @@ export class EraserTexturePipeline {
private createPipeline(
shaderModule: GPUShaderModule,
fragmentEntryPoint: string,
- colorTargetCount: number
+ targetFormats: Array
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
@@ -258,9 +263,7 @@ export class EraserTexturePipeline {
fragment: {
module: shaderModule,
entryPoint: fragmentEntryPoint,
- targets: Array.from({ length: colorTargetCount }, () => ({
- format: 'rgba16float' as const,
- })),
+ targets: targetFormats.map((format) => ({ format })),
},
primitive: {
topology: 'triangle-list',
diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl
index 38b083f..e5675f2 100644
--- a/src/pipelines/eraser/eraser-texture.wgsl
+++ b/src/pipelines/eraser/eraser-texture.wgsl
@@ -5,7 +5,7 @@ struct Settings {
clearGreen: f32,
clearBlue: f32,
clearAlpha: f32,
- padding0: f32,
+ eraserRadius: f32,
padding1: f32,
};
@@ -15,7 +15,8 @@ struct VertexOutput {
@builtin(position) position: vec4,
@location(0) screenPosition: vec2,
@location(1) @interpolate(flat) start: vec2,
- @location(2) @interpolate(flat) end: vec2
+ @location(2) @interpolate(flat) direction: vec2,
+ @location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct EraserTextureTargets {
@@ -37,19 +38,26 @@ fn vertex(
@location(0) start: vec2,
@location(1) end: vec2
) -> VertexOutput {
- let screenPosition = segment_vertex_position(vertexIndex, start, end, sqrt(settings.eraserRadiusSquared));
+ let direction = end - start;
+ let denominator = dot(direction, direction);
+ var inverseLengthSquared = 0.0;
+ if denominator > settings.lineDistanceEpsilon {
+ inverseLengthSquared = 1.0 / denominator;
+ }
+ let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.eraserRadius);
let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
- return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
+ return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
}
@fragment
fn fragment(
@location(0) screenPosition: vec2,
@location(1) @interpolate(flat) start: vec2,
- @location(2) @interpolate(flat) end: vec2
+ @location(2) @interpolate(flat) direction: vec2,
+ @location(3) @interpolate(flat) inverseLengthSquared: f32
) -> @location(0) vec4 {
- if shouldDiscardEraserFragment(screenPosition, start, end) {
+ if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
@@ -60,9 +68,10 @@ fn fragment(
fn fragmentMrt(
@location(0) screenPosition: vec2,
@location(1) @interpolate(flat) start: vec2,
- @location(2) @interpolate(flat) end: vec2
+ @location(2) @interpolate(flat) direction: vec2,
+ @location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserTextureTargets {
- if shouldDiscardEraserFragment(screenPosition, start, end) {
+ if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
@@ -74,14 +83,19 @@ fn fragmentMrt(
fn fragmentCombined(
@location(0) screenPosition: vec2,
@location(1) @interpolate(flat) start: vec2,
- @location(2) @interpolate(flat) end: vec2
+ @location(2) @interpolate(flat) direction: vec2,
+ @location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserCombinedTargets {
- if shouldDiscardEraserFragment(screenPosition, start, end) {
+ if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
discard;
}
let cleared = getEraserClearValue();
- return EraserCombinedTargets(cleared, cleared, cleared, cleared);
+ return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared);
+}
+
+fn getEraserMaskValue() -> vec4 {
+ return vec4(settings.clearAlpha, 0.0, 0.0, 1.0);
}
fn getEraserClearValue() -> vec4 {
@@ -96,21 +110,25 @@ fn getEraserClearValue() -> vec4 {
fn shouldDiscardEraserFragment(
screenPosition: vec2,
start: vec2,
- end: vec2
+ direction: vec2,
+ inverseLengthSquared: f32
) -> bool {
- return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared;
+ return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared;
}
-fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
+fn distanceSquaredFromLine(
+ position: vec2,
+ start: vec2,
+ direction: vec2,
+ inverseLengthSquared: f32
+) -> f32 {
let pa = position - start;
- let direction = end - start;
- let denominator = dot(direction, direction);
- if denominator <= settings.lineDistanceEpsilon {
+ if inverseLengthSquared <= 0.0 {
return dot(pa, pa);
}
- let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
+ let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}
diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts
index 74cb5e5..da58f5a 100644
--- a/src/pipelines/render/render-pipeline.ts
+++ b/src/pipelines/render/render-pipeline.ts
@@ -4,6 +4,7 @@ import {
} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
+import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw';
@@ -13,8 +14,6 @@ export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
- private readonly sampledPipeline: GPURenderPipeline;
- private readonly sampler: GPUSampler;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@@ -35,14 +34,8 @@ export class RenderPipeline {
const vertex = setUpFullScreenQuad(device);
- this.sampler = device.createSampler({
- magFilter: 'linear',
- minFilter: 'linear',
- });
-
const format = navigator.gpu.getPreferredCanvasFormat();
this.pipeline = this.createPipeline(format, vertex, 'fragment');
- this.sampledPipeline = this.createPipeline(format, vertex, 'fragmentSampled');
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@@ -84,25 +77,25 @@ export class RenderPipeline {
renderBrushColorStrengthMultiplier,
backgroundGrainStrength,
}: RenderSettings & {
- channelColors: Array<[number, number, number]>;
- backgroundColor: [number, number, number];
+ channelColors: [RgbColor, RgbColor, RgbColor];
+ backgroundColor: RgbColor;
}) {
const [a, b, c] = channelColors;
- this.uniformValues[0] = a[0];
- this.uniformValues[1] = a[1];
- this.uniformValues[2] = a[2];
+ this.uniformValues[0] = rgbChannelToUnit(a[0]);
+ this.uniformValues[1] = rgbChannelToUnit(a[1]);
+ this.uniformValues[2] = rgbChannelToUnit(a[2]);
this.uniformValues[3] = 0;
- this.uniformValues[4] = b[0];
- this.uniformValues[5] = b[1];
- this.uniformValues[6] = b[2];
+ this.uniformValues[4] = rgbChannelToUnit(b[0]);
+ this.uniformValues[5] = rgbChannelToUnit(b[1]);
+ this.uniformValues[6] = rgbChannelToUnit(b[2]);
this.uniformValues[7] = 0;
- this.uniformValues[8] = c[0];
- this.uniformValues[9] = c[1];
- this.uniformValues[10] = c[2];
+ this.uniformValues[8] = rgbChannelToUnit(c[0]);
+ this.uniformValues[9] = rgbChannelToUnit(c[1]);
+ this.uniformValues[10] = rgbChannelToUnit(c[2]);
this.uniformValues[11] = 0;
- this.uniformValues[12] = backgroundColor[0];
- this.uniformValues[13] = backgroundColor[1];
- this.uniformValues[14] = backgroundColor[2];
+ this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
+ this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
+ this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
this.uniformValues[15] = clarity;
this.uniformValues[16] = renderTraceNormalizationFloor;
this.uniformValues[17] = renderBrushColorBase;
@@ -162,7 +155,7 @@ export class RenderPipeline {
},
],
});
- passEncoder.setPipeline(this.sampledPipeline);
+ passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(3, 1);
@@ -193,10 +186,6 @@ export class RenderPipeline {
buffer: this.uniforms,
},
},
- {
- binding: 1,
- resource: this.sampler,
- },
{
binding: 2,
resource: colorTexture,
@@ -226,13 +215,6 @@ export class RenderPipeline {
type: 'uniform',
},
},
- {
- binding: 1,
- visibility: GPUShaderStage.FRAGMENT,
- sampler: {
- type: 'filtering',
- },
- },
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl
index 959e5f6..1961e8b 100644
--- a/src/pipelines/render/render.wgsl
+++ b/src/pipelines/render/render.wgsl
@@ -14,27 +14,17 @@ struct Settings {
};
@group(1) @binding(0) var settings: Settings;
-@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d;
@group(1) @binding(3) var sourceMap: texture_2d;
-@fragment
-fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 {
- let textureSize = vec2(textureDimensions(trailMap, 0));
- let pixel = clamp(vec2(position.xy), vec2(0, 0), textureSize - vec2(1, 1));
- let traces = textureLoad(trailMap, pixel, 0);
- let sources = textureLoad(sourceMap, pixel, 0);
- return renderColor(traces, sources, vec2(position.xy));
-}
+const NOISE_TEXTURE_MASK = 2047u;
@fragment
-fn fragmentSampled(
- @location(0) uv: vec2,
- @builtin(position) position: vec4
-) -> @location(0) vec4 {
- let traces = textureSample(trailMap, Sampler, uv);
- let sources = textureSample(sourceMap, Sampler, uv);
- return renderColor(traces, sources, vec2(position.xy));
+fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 {
+ let pixel = vec2(position.xy);
+ let traces = textureLoad(trailMap, pixel, 0);
+ let sources = textureLoad(sourceMap, pixel, 0);
+ return renderColor(traces, sources, pixel);
}
fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4 {
@@ -48,6 +38,16 @@ fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4<
clarity(traces.g),
clarity(traces.b)
);
+ if max(max(sources.r, sources.g), sources.b) <= 0.0 {
+ let traceColor =
+ traceStrengths.r * settings.colorA
+ + traceStrengths.g * settings.colorB
+ + traceStrengths.b * settings.colorC;
+ let normalizedTraceColor = normalizeColorIntensity(traceColor);
+ let traceStrength = max(max(traceStrengths.r, traceStrengths.g), traceStrengths.b);
+ return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
+ }
+
let sourceStrengths = vec3(
clarity(sources.r),
clarity(sources.g),
@@ -89,8 +89,7 @@ fn normalizeColorIntensity(color: vec3) -> vec3 {
}
fn getTexturedBackground(pixel: vec2) -> vec3 {
- let noiseSize = vec2(textureDimensions(noise, 0));
- let noiseCoord = pixel % noiseSize;
+ let noiseCoord = vec2(vec2(pixel) & vec2(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
return clamp(
diff --git a/src/settings.ts b/src/settings.ts
index dfd469b..c28a005 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => {
Object.assign(settings, {
...buildSettings(vibe),
eraserSize: settings.eraserSize,
+ adaptiveCapInitial: settings.adaptiveCapInitial,
+ adaptiveCapMin: settings.adaptiveCapMin,
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
maxAgentCount: settings.maxAgentCount,
mirrorSegmentCount: settings.mirrorSegmentCount,
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
index bf17e11..acb3ae9 100644
--- a/src/style/_app-shell.scss
+++ b/src/style/_app-shell.scss
@@ -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;
}
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
index ac83f11..e540be7 100644
--- a/src/style/_loading.scss
+++ b/src/style/_loading.scss
@@ -155,4 +155,3 @@ html > body.is-loading {
visibility: hidden;
}
}
-
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
index 1e3615d..2c89027 100644
--- a/src/style/_toolbar.scss
+++ b/src/style/_toolbar.scss
@@ -38,10 +38,13 @@ $toolbar-icons: (
html > body > aside.control-dock > .toolbar-row {
--toolbar-background-opacity: 0%;
--toolbar-background-strength: 0;
+ --toolbar-divider-space: clamp(6px, 1.8vw, 14px);
+ --toolbar-top-max-width: 594px;
display: grid;
grid-template-areas:
'previous controls next'
+ 'previous divider next'
'previous buttons next';
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
@@ -51,7 +54,7 @@ html > body > aside.control-dock > .toolbar-row {
margin: 0 auto;
padding-inline: clamp(8px, 1.4vw, 14px);
column-gap: 0;
- row-gap: clamp(6px, 1.8vw, 14px);
+ row-gap: 0;
border-radius: 12px;
color: rgb(245 250 244 / 92%);
background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
@@ -70,6 +73,17 @@ html > body > aside.control-dock > .toolbar-row {
background-color var(--transition-time-long),
box-shadow var(--transition-time-long);
+ &::after {
+ content: '';
+ grid-area: divider;
+ align-self: center;
+ justify-self: center;
+ width: min(100%, var(--toolbar-top-max-width));
+ height: 1px;
+ margin-block: var(--toolbar-divider-space);
+ background: rgb(255 255 255 / 12%);
+ }
+
button {
min-width: 44px;
min-height: 44px;
@@ -103,7 +117,7 @@ html > body > aside.control-dock > .toolbar-row {
align-items: center;
justify-content: center;
justify-self: center;
- width: min(100%, max-content);
+ width: min(100%, var(--toolbar-top-max-width));
min-width: 0;
padding: 8px 9px;
}
@@ -167,12 +181,12 @@ html > body > aside.control-dock > .toolbar-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
- justify-content: space-between;
+ justify-content: center;
+ justify-self: center;
gap: 4px;
- width: 100%;
+ width: fit-content;
+ max-width: 100%;
min-width: 0;
- padding-top: 7px;
- border-top: 1px solid rgb(255 255 255 / 12%);
> button,
> .audio-control > button {
@@ -571,10 +585,17 @@ html > body > aside.control-dock > .toolbar-row {
}
@include on-small-screen {
+ --toolbar-divider-space: 4px;
+ --toolbar-top-max-width: 329px;
+
+ grid-template-areas:
+ 'previous controls next'
+ '. divider .'
+ 'buttons buttons buttons';
width: 100%;
padding-inline: 4px;
column-gap: 0;
- row-gap: 4px;
+ row-gap: 0;
> .vibe-button {
width: 36px;
@@ -591,8 +612,12 @@ html > body > aside.control-dock > .toolbar-row {
}
> nav.buttons {
+ justify-self: stretch;
+ justify-content: space-between;
gap: clamp(1px, 0.55vw, 2px);
- padding-top: 3px;
+ width: auto;
+ max-width: none;
+ margin-inline: -4px;
> button {
width: auto;
@@ -643,13 +668,18 @@ html > body > aside.control-dock > .toolbar-row {
justify-content: stretch;
width: 100%;
min-width: 0;
- min-height: 54px;
+ min-height: 48px;
flex: 1 1 100%;
- padding: 4px 6px;
- column-gap: 7px;
- row-gap: 8px;
+ padding: 3px 5px;
+ column-gap: 6px;
+ row-gap: 6px;
> .color-swatch {
+ width: 38px;
+ height: 38px;
+ min-width: 38px;
+ min-height: 38px;
+
grid-column: span 2;
}
@@ -658,8 +688,8 @@ html > body > aside.control-dock > .toolbar-row {
justify-self: stretch;
width: 100%;
min-width: 0;
- height: 42px;
- padding: 0 8px;
+ height: 38px;
+ padding: 0 7px;
}
> .eraser-size-control {
@@ -667,8 +697,8 @@ html > body > aside.control-dock > .toolbar-row {
}
> .mirror-segment-control {
- --thumb-height: 38px;
- --thumb-width: 38px;
+ --thumb-height: 34px;
+ --thumb-width: 34px;
grid-column: 4 / span 3;
}
diff --git a/src/style/vars.scss b/src/style/vars.scss
index 9feb7fd..24df21b 100644
--- a/src/style/vars.scss
+++ b/src/style/vars.scss
@@ -1,7 +1,7 @@
:root {
--transition-time: 200ms;
--transition-time-long: 350ms;
- --accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734);
+ --accent-color: rgb(255, 93, 162);
--main-color: #aaa;
--normal-margin: 2rem;
--small-margin: 1rem;
diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts
index dd53998..d975edc 100644
--- a/src/utils/graphics/resizable-texture.ts
+++ b/src/utils/graphics/resizable-texture.ts
@@ -1,15 +1,32 @@
import { vec2 } from 'gl-matrix';
+interface ResizableTextureOptions {
+ clearValue?: GPUColor;
+ format?: GPUTextureFormat;
+ usage?: GPUTextureUsageFlags;
+}
+
export class ResizableTexture {
private texture: GPUTexture;
private textureView: GPUTextureView;
private size: vec2;
+ private readonly clearValue: GPUColor;
+ private readonly format: GPUTextureFormat;
+ private readonly usage: GPUTextureUsageFlags;
public constructor(
private readonly device: GPUDevice,
- size: vec2
+ size: vec2,
+ {
+ clearValue = { r: 0, g: 0, b: 0, a: 0 },
+ format = 'rgba16float',
+ usage = defaultTextureUsage,
+ }: ResizableTextureOptions = {}
) {
this.size = vec2.clone(size);
+ this.clearValue = clearValue;
+ this.format = format;
+ this.usage = usage;
this.texture = this.createTexture(size);
this.textureView = this.texture.createView();
}
@@ -31,7 +48,7 @@ export class ResizableTexture {
colorAttachments: [
{
view: newTextureView,
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
+ clearValue: this.clearValue,
loadOp: 'clear',
storeOp: 'store',
},
@@ -69,14 +86,16 @@ export class ResizableTexture {
private createTexture(size: vec2): GPUTexture {
return this.device.createTexture({
- format: 'rgba16float',
+ format: this.format,
size: { width: size[0], height: size[1] },
- usage:
- GPUTextureUsage.STORAGE_BINDING |
- GPUTextureUsage.TEXTURE_BINDING |
- GPUTextureUsage.RENDER_ATTACHMENT |
- GPUTextureUsage.COPY_SRC |
- GPUTextureUsage.COPY_DST,
+ usage: this.usage,
});
}
}
+
+const defaultTextureUsage =
+ GPUTextureUsage.STORAGE_BINDING |
+ GPUTextureUsage.TEXTURE_BINDING |
+ GPUTextureUsage.RENDER_ATTACHMENT |
+ GPUTextureUsage.COPY_SRC |
+ GPUTextureUsage.COPY_DST;
diff --git a/src/utils/graphics/smart-compile.ts b/src/utils/graphics/smart-compile.ts
index 044ec24..191380e 100644
--- a/src/utils/graphics/smart-compile.ts
+++ b/src/utils/graphics/smart-compile.ts
@@ -10,20 +10,25 @@ export const smartCompile = (
code: concatenated,
});
- module.getCompilationInfo().then((info) =>
- info.messages.forEach((message) =>
+ module.getCompilationInfo().then((info) => {
+ if (info.messages.length === 0) {
+ return;
+ }
+
+ const lines = concatenated.split('\n');
+ info.messages.forEach((message) => {
+ const sourceLine = lines[message.lineNum - 1] ?? '';
+ const fullSource = import.meta.env.DEV ? `\n\nCode:\n${concatenated}\n` : '';
ErrorHandler.addError(
{
info: Severity.INFO,
warning: Severity.WARNING,
error: Severity.ERROR,
}[message.type],
- `${message.message}\n${
- concatenated.split('\n')[message.lineNum - 1]
- }\n\nCode:\n${concatenated}\n`
- )
- )
- );
+ `${message.message}\n${sourceLine}${fullSource}`
+ );
+ });
+ });
return module;
};
diff --git a/src/utils/hex-to-rgb.ts b/src/utils/hex-to-rgb.ts
deleted file mode 100644
index 2eff6b9..0000000
--- a/src/utils/hex-to-rgb.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-const HEX_COLOR_PATTERN =
- /^#?(?[0-9a-f]{2})(?[0-9a-f]{2})(?[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];
-};