diff --git a/.gitignore b/.gitignore
index f06235c..0f59a68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules
dist
+test-results
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
index f74b88d..19f0efd 100644
--- a/e2e/app.spec.ts
+++ b/e2e/app.spec.ts
@@ -171,3 +171,57 @@ test('keeps audio focus outlines scoped to the active control', async ({ page })
await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
});
+
+test('keeps the config overlay scrollable and dismissible on mobile', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 640 });
+ await page.goto('/');
+
+ const startButton = page.getByRole('button', { name: 'Start' });
+ await expect(startButton).toBeEnabled({ timeout: 30_000 });
+ await startButton.click();
+ await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
+ timeout: 30_000,
+ });
+
+ const settingsButton = page.locator('button.settings');
+ await settingsButton.click();
+
+ const pane = page.locator('.config-pane');
+ const closeButton = page.locator('.config-pane-close');
+ await expect(pane).toBeVisible();
+ await expect(closeButton).toBeVisible();
+
+ const paneMetrics = await pane.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ return {
+ bottom: rect.bottom,
+ clientHeight: element.clientHeight,
+ overflowY: style.overflowY,
+ scrollHeight: element.scrollHeight,
+ top: rect.top,
+ viewportHeight: window.innerHeight,
+ viewportWidth: window.innerWidth,
+ width: rect.width,
+ };
+ });
+
+ expect(paneMetrics.top).toBeGreaterThanOrEqual(0);
+ expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight);
+ expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8));
+ expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight);
+ expect(['auto', 'scroll']).toContain(paneMetrics.overflowY);
+
+ await pane.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ await expect
+ .poll(() => pane.evaluate((element) => element.scrollTop))
+ .toBeGreaterThan(0);
+
+ await closeButton.click();
+ await expect(pane).toBeHidden();
+ await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
+});
diff --git a/index.html b/index.html
index 9c3e01a..25ae55e 100644
--- a/index.html
+++ b/index.html
@@ -7,24 +7,64 @@
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
Fleeting Garden
@@ -43,6 +83,7 @@
to paint coloured paths, then use the toolbar to change colours, erase, export,
adjust the config overlay, restart, or open more information.
+
@@ -50,7 +91,7 @@
Fleeting Garden
- Draw coloured paths and watch them bloom into a living WebGPU garden.
+ Tend it while you can. The garden returns to weather either way.
Start
@@ -85,22 +126,24 @@
Fleeting Garden
- A living sketchpad where each stroke becomes a trail that agents follow,
- branch from, and weave into the scene.
+ A garden is what we tend; the wild is what we get the moment we look away.
+ Both happen here at once. Your strokes plant colour, small agents follow them,
+ branch off, and slowly rewrite the patch you laid down into something you
+ didn't quite plan.
- Paint with the three colour swatches, carve space with the eraser, and raise
- the mirror control when you want radial patterns instead of a single line.
+ Three swatches plant the line. The eraser carves a clearing. The mirror folds
+ one gesture into many, like footpaths around a hidden well.
- 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 an internal buffer snapshot.
+ Switch vibes to change the season; your shapes stay, the light moves. Add or
+ quiet the piano. Restart when you want a fresh field. Take a snapshot if you
+ want to keep one particular instant of weather.
- Built with WebGPU and running locally in your browser. Source on
- GitHub schmelczer.dev.
diff --git a/package.json b/package.json
index dbc6789..4a6d8f7 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "0.2.0",
"private": true,
"type": "module",
- "description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.",
+ "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
index 23a137e..c61d444 100644
--- a/public/manifest.webmanifest
+++ b/public/manifest.webmanifest
@@ -1,8 +1,9 @@
{
"name": "Fleeting Garden",
"short_name": "Garden",
- "description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
+ "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"start_url": "./",
+ "scope": "./",
"display": "fullscreen",
"background_color": "#10151f",
"theme_color": "#10151f",
diff --git a/public/og-image.jpg b/public/og-image.jpg
index 98ae6a5..03c8939 100644
Binary files a/public/og-image.jpg and b/public/og-image.jpg differ
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..0775d05
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://schmelczer.dev/fleeting/sitemap.xml
diff --git a/public/sitemap.xml b/public/sitemap.xml
new file mode 100644
index 0000000..d349665
--- /dev/null
+++ b/public/sitemap.xml
@@ -0,0 +1,6 @@
+
+
+
+ https://schmelczer.dev/fleeting/
+
+
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index 6aeedc4..7fb3c3b 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
+import { DEFAULT_AUDIO_VOLUME } from '../consts';
import type { PianoNoteRole } from './garden-audio-types';
export interface GardenAudioChord {
diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts
index 3f59886..951268b 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -38,6 +38,7 @@ export class GardenAudio {
private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
+ private startRequestId = 0;
public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume);
@@ -56,6 +57,14 @@ export class GardenAudio {
return;
}
+ if (
+ this.lifecycle === 'started' &&
+ this.currentVibeId === vibe.id &&
+ this.graph.context?.state === 'running'
+ ) {
+ return;
+ }
+
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
@@ -108,25 +117,49 @@ export class GardenAudio {
return;
}
+ const startRequestId = ++this.startRequestId;
+ void this.piano
+ .load(context)
+ .then(() => {
+ if (!this.canCompleteStart(context, startRequestId)) {
+ return;
+ }
+
+ this.activateStart(vibe, context, startupRampSeconds, true);
+ })
+ .catch((error) => {
+ if (this.canCompleteStart(context, startRequestId)) {
+ this.activateStart(vibe, context, startupRampSeconds, false);
+ }
+ ErrorHandler.addException(error, {
+ fallbackMessage: 'Could not load piano samples.',
+ severity: Severity.WARNING,
+ });
+ });
+ }
+
+ private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
+ return (
+ this.graph.context === context &&
+ this.lifecycle !== 'destroyed' &&
+ !this.isMuted &&
+ this.startRequestId === startRequestId
+ );
+ }
+
+ private activateStart(
+ vibe: VibePreset,
+ context: AudioContext,
+ startupRampSeconds: number,
+ cuePiano: boolean
+ ): void {
this.lifecycle = 'started';
- this.applyVibe(vibe);
- this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe));
+ this.currentVibeId = vibe.id;
+ this.graph.applyDelayProfile();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
- const pianoLoad = this.piano.loadIfIdle(context);
- if (pianoLoad) {
- void pianoLoad
- .then(() => {
- if (this.graph.context === context && this.lifecycle !== 'destroyed') {
- this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
- }
- })
- .catch((error) => {
- ErrorHandler.addException(error, {
- fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
- severity: Severity.WARNING,
- });
- });
+ if (cuePiano) {
+ this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
}
diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts
index 3ccb46c..49f469f 100644
--- a/src/audio/generative-piano.ts
+++ b/src/audio/generative-piano.ts
@@ -46,13 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb
type GardenAudioStyleIndex = 0 | 1 | 2;
-interface TouchDownRequest {
- vibe: VibePreset;
- now: number;
- strength: number;
- maniaAmount?: number;
-}
-
interface PitchCandidate {
midi: number;
preference: number;
@@ -152,7 +145,12 @@ export class GenerativePianoEngine {
now,
strength,
maniaAmount = 0,
- }: TouchDownRequest): void {
+ }: {
+ vibe: VibePreset;
+ now: number;
+ strength: number;
+ maniaAmount?: number;
+ }): void {
const normalizedStrength = clamp01(strength);
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index 142fcda..a8b7110 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -21,9 +21,6 @@ const pianoSamplerTuning = {
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
- synthGainScale: 0.34,
- synthMaxDurationSeconds: 1.8,
- synthOscillatorType: 'triangle' as OscillatorType,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
@@ -39,9 +36,9 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph
) {}
- public loadIfIdle(context: BaseAudioContext): Promise | null {
- if (this.loadState !== 'idle') {
- return null;
+ public load(context: BaseAudioContext): Promise {
+ if (this.loadState === 'loaded') {
+ return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
@@ -80,82 +77,32 @@ export class PianoSampler {
return;
}
+ const sample = this.findNearestSample(midi);
+ if (!sample) {
+ 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 = this.computeNoteGain(noteVelocity);
- const sustainSeconds =
- profileSustainSeconds *
- (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 = this.computeNoteGain(
- noteVelocity,
- pianoSamplerTuning.synthGainScale
- );
- const releaseAt =
- scheduledStart +
- clamp(
- durationSeconds + profileSustainSeconds * 0.5,
- pianoSamplerTuning.minDurationSeconds,
- pianoSamplerTuning.synthMaxDurationSeconds
- );
+ const noteGainValue = this.computeNoteGain(noteVelocity);
+ const sustainSeconds =
+ profileSustainSeconds *
+ (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.createOscillator();
+ const source = context.createBufferSource();
- 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
+ );
this.scheduleVoice({
source,
@@ -171,6 +118,17 @@ export class PianoSampler {
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,
@@ -312,6 +270,3 @@ export class PianoSampler {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}
-
-const getMidiFrequency = (midi: number): number =>
- 440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE);
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index e132697..8b3b6c4 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -1,7 +1,38 @@
import type { LoadedPianoSample } from './garden-audio-types';
+import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
+import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
+import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
+import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
+import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
+import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
+import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
+import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
+import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
+import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
+import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
+import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
+import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
+import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
+import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
+import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
+import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
+import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
+import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
+import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
+import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
+import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
+import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
+import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
+import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
+import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
+import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
+import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
+import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
+import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
interface PianoSampleDefinition {
midi: number;
+ path: string;
url: string;
}
@@ -10,48 +41,39 @@ export interface PianoSampleLoadProgress {
totalCount: number;
}
-const sampleFiles: Array<[fileName: string, midi: number]> = [
- ['A0v12.m4a', 21],
- ['C1v12.m4a', 24],
- ['Dsharp1v12.m4a', 27],
- ['Fsharp1v12.m4a', 30],
- ['A1v12.m4a', 33],
- ['C2v12.m4a', 36],
- ['Dsharp2v12.m4a', 39],
- ['Fsharp2v12.m4a', 42],
- ['A2v12.m4a', 45],
- ['C3v12.m4a', 48],
- ['Dsharp3v12.m4a', 51],
- ['Fsharp3v12.m4a', 54],
- ['A3v12.m4a', 57],
- ['C4v12.m4a', 60],
- ['Dsharp4v12.m4a', 63],
- ['Fsharp4v12.m4a', 66],
- ['A4v12.m4a', 69],
- ['C5v12.m4a', 72],
- ['Dsharp5v12.m4a', 75],
- ['Fsharp5v12.m4a', 78],
- ['A5v12.m4a', 81],
- ['C6v12.m4a', 84],
- ['Dsharp6v12.m4a', 87],
- ['Fsharp6v12.m4a', 90],
- ['A6v12.m4a', 93],
- ['C7v12.m4a', 96],
- ['Dsharp7v12.m4a', 99],
- ['Fsharp7v12.m4a', 102],
- ['A7v12.m4a', 105],
- ['C8v12.m4a', 108],
+const pianoSampleDefinitions: Array = [
+ { url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
+ { url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
+ { url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
+ { url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
+ { url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
+ { url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
+ { url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
+ { url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
+ { url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
+ { url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
+ { url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
+ { url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
+ { url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
+ { url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
+ { url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
+ { url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
+ { url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
+ { url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
+ { url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
+ { url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
+ { url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
+ { url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
+ { url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
+ { url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
+ { url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
+ { url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
+ { url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
+ { url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
+ { url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
+ { url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 },
];
-const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
-
-const pianoSampleDefinitions: Array = sampleFiles
- .map(([fileName, midi]) => ({
- midi,
- url: `${sampleBaseUrl}${fileName}`,
- }))
- .sort((a, b) => a.midi - b.midi);
-
let loadedPianoSamples: Array | null = null;
let pianoSampleLoadPromise: Promise> | null = null;
@@ -111,11 +133,11 @@ export const loadPianoSamples = (
}
).then(
(samples) => {
- loadedPianoSamples = samples
- .filter((sample): sample is LoadedPianoSample => sample !== null)
- .sort((a, b) => a.midi - b.midi);
- if (loadedPianoSamples.length === 0) {
- throw new Error('Unable to load any piano samples.');
+ loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
+ if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
+ throw new Error(
+ `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
+ );
}
return [...loadedPianoSamples];
},
@@ -138,7 +160,7 @@ const loadPianoSample = async (
): Promise => {
const response = await fetch(sample.url, { signal });
if (!response.ok) {
- throw new Error(`Unable to load piano sample ${sample.url}`);
+ throw new Error(`Unable to load piano sample ${sample.path}`);
}
const audioData = await response.arrayBuffer();
@@ -148,17 +170,13 @@ const loadPianoSample = async (
const loadPianoSampleBatch = async (
samples: Array,
- loadSample: (
- sample: PianoSampleDefinition
- ) => Promise
-): Promise> => {
- const results: Array = [];
+ loadSample: (sample: PianoSampleDefinition) => Promise
+): Promise> => {
+ const results: Array = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
- const batchResults = await Promise.all(
- batch.map((sample) => loadSample(sample).catch(() => null))
- );
+ const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
results.push(...batchResults);
}
diff --git a/public/audio/A0v12.m4a b/src/audio/samples/A0v12.m4a
similarity index 100%
rename from public/audio/A0v12.m4a
rename to src/audio/samples/A0v12.m4a
diff --git a/public/audio/A1v12.m4a b/src/audio/samples/A1v12.m4a
similarity index 100%
rename from public/audio/A1v12.m4a
rename to src/audio/samples/A1v12.m4a
diff --git a/public/audio/A2v12.m4a b/src/audio/samples/A2v12.m4a
similarity index 100%
rename from public/audio/A2v12.m4a
rename to src/audio/samples/A2v12.m4a
diff --git a/public/audio/A3v12.m4a b/src/audio/samples/A3v12.m4a
similarity index 100%
rename from public/audio/A3v12.m4a
rename to src/audio/samples/A3v12.m4a
diff --git a/public/audio/A4v12.m4a b/src/audio/samples/A4v12.m4a
similarity index 100%
rename from public/audio/A4v12.m4a
rename to src/audio/samples/A4v12.m4a
diff --git a/public/audio/A5v12.m4a b/src/audio/samples/A5v12.m4a
similarity index 100%
rename from public/audio/A5v12.m4a
rename to src/audio/samples/A5v12.m4a
diff --git a/public/audio/A6v12.m4a b/src/audio/samples/A6v12.m4a
similarity index 100%
rename from public/audio/A6v12.m4a
rename to src/audio/samples/A6v12.m4a
diff --git a/public/audio/A7v12.m4a b/src/audio/samples/A7v12.m4a
similarity index 100%
rename from public/audio/A7v12.m4a
rename to src/audio/samples/A7v12.m4a
diff --git a/public/audio/C1v12.m4a b/src/audio/samples/C1v12.m4a
similarity index 100%
rename from public/audio/C1v12.m4a
rename to src/audio/samples/C1v12.m4a
diff --git a/public/audio/C2v12.m4a b/src/audio/samples/C2v12.m4a
similarity index 100%
rename from public/audio/C2v12.m4a
rename to src/audio/samples/C2v12.m4a
diff --git a/public/audio/C3v12.m4a b/src/audio/samples/C3v12.m4a
similarity index 100%
rename from public/audio/C3v12.m4a
rename to src/audio/samples/C3v12.m4a
diff --git a/public/audio/C4v12.m4a b/src/audio/samples/C4v12.m4a
similarity index 100%
rename from public/audio/C4v12.m4a
rename to src/audio/samples/C4v12.m4a
diff --git a/public/audio/C5v12.m4a b/src/audio/samples/C5v12.m4a
similarity index 100%
rename from public/audio/C5v12.m4a
rename to src/audio/samples/C5v12.m4a
diff --git a/public/audio/C6v12.m4a b/src/audio/samples/C6v12.m4a
similarity index 100%
rename from public/audio/C6v12.m4a
rename to src/audio/samples/C6v12.m4a
diff --git a/public/audio/C7v12.m4a b/src/audio/samples/C7v12.m4a
similarity index 100%
rename from public/audio/C7v12.m4a
rename to src/audio/samples/C7v12.m4a
diff --git a/public/audio/C8v12.m4a b/src/audio/samples/C8v12.m4a
similarity index 100%
rename from public/audio/C8v12.m4a
rename to src/audio/samples/C8v12.m4a
diff --git a/public/audio/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a
similarity index 100%
rename from public/audio/Dsharp1v12.m4a
rename to src/audio/samples/Dsharp1v12.m4a
diff --git a/public/audio/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a
similarity index 100%
rename from public/audio/Dsharp2v12.m4a
rename to src/audio/samples/Dsharp2v12.m4a
diff --git a/public/audio/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a
similarity index 100%
rename from public/audio/Dsharp3v12.m4a
rename to src/audio/samples/Dsharp3v12.m4a
diff --git a/public/audio/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a
similarity index 100%
rename from public/audio/Dsharp4v12.m4a
rename to src/audio/samples/Dsharp4v12.m4a
diff --git a/public/audio/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a
similarity index 100%
rename from public/audio/Dsharp5v12.m4a
rename to src/audio/samples/Dsharp5v12.m4a
diff --git a/public/audio/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a
similarity index 100%
rename from public/audio/Dsharp6v12.m4a
rename to src/audio/samples/Dsharp6v12.m4a
diff --git a/public/audio/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a
similarity index 100%
rename from public/audio/Dsharp7v12.m4a
rename to src/audio/samples/Dsharp7v12.m4a
diff --git a/public/audio/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a
similarity index 100%
rename from public/audio/Fsharp1v12.m4a
rename to src/audio/samples/Fsharp1v12.m4a
diff --git a/public/audio/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a
similarity index 100%
rename from public/audio/Fsharp2v12.m4a
rename to src/audio/samples/Fsharp2v12.m4a
diff --git a/public/audio/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a
similarity index 100%
rename from public/audio/Fsharp3v12.m4a
rename to src/audio/samples/Fsharp3v12.m4a
diff --git a/public/audio/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a
similarity index 100%
rename from public/audio/Fsharp4v12.m4a
rename to src/audio/samples/Fsharp4v12.m4a
diff --git a/public/audio/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a
similarity index 100%
rename from public/audio/Fsharp5v12.m4a
rename to src/audio/samples/Fsharp5v12.m4a
diff --git a/public/audio/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a
similarity index 100%
rename from public/audio/Fsharp6v12.m4a
rename to src/audio/samples/Fsharp6v12.m4a
diff --git a/public/audio/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a
similarity index 100%
rename from public/audio/Fsharp7v12.m4a
rename to src/audio/samples/Fsharp7v12.m4a
diff --git a/public/audio/README.md b/src/audio/samples/README.md
similarity index 100%
rename from public/audio/README.md
rename to src/audio/samples/README.md
diff --git a/src/config.ts b/src/config.ts
index 7e8375c..e9dab15 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,9 +1,9 @@
-import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants';
import { createGardenAudioConfig } from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
+import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
export type {
GardenAppConfig,
@@ -60,7 +60,9 @@ export const appConfig = {
brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,
- maxDevicePixelRatio: 2,
+ // How long the source map continues to be diffused after a brush stroke ends.
+ // 600 frames at ~60 FPS ≈ 10 seconds.
+ sourceActiveFramesAfterWrite: 600,
intro: {
angleJitterRadians: Math.PI * 0.08,
angleEaseEnd: 1,
@@ -103,7 +105,6 @@ export const appConfig = {
introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35,
stroke: {
- angleJitterRadians: Math.PI * 0.7,
densityMultiplier: 110,
maxAgentCount: 2_400,
minAgentCount: 140,
@@ -178,7 +179,7 @@ export const appConfig = {
tuningPane: {
showFpsOverlay: import.meta.env.DEV,
startHidden: true,
- title: 'Garden Config',
+ title: 'Garden Settings',
},
vibes: {
defaultVibeId,
diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts
index 0ca5d70..4e3ebc6 100644
--- a/src/config/color-interactions.ts
+++ b/src/config/color-interactions.ts
@@ -19,8 +19,8 @@ export const colorInteractionControl = (label: string): NumberControlConfig => (
max: 1,
step: 1,
options: {
- Follow: 1,
- Avoid: -1,
+ 'Move Toward': 1,
Ignore: 0,
+ 'Move Away': -1,
},
});
diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts
index 77b3b36..a1ee786 100644
--- a/src/config/default-settings.ts
+++ b/src/config/default-settings.ts
@@ -1,6 +1,33 @@
+import { colorInteractionSettings } from './color-interactions';
+import { runtimeControls } from './runtime-controls';
import type { GardenAppConfig } from './types';
+// Mirrors the historical render-scale cap so the default render area stays
+// roughly equivalent to native rendering on high-DPR phones without the
+// pipeline applying its own clamp. The slider can override freely.
+const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
+const INTERNAL_RENDER_AREA_BOUNDS = {
+ min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5,
+ max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6,
+};
+
+const computeDefaultInternalRenderAreaMegapixels = (): number => {
+ const rawDpr =
+ typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
+ ? window.devicePixelRatio
+ : 1;
+ const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
+ const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
+ const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
+ const cssMegapixels = Math.max(cssWidth, 1) * Math.max(cssHeight, 1) / 1_000_000;
+ return Math.min(
+ INTERNAL_RENDER_AREA_BOUNDS.max,
+ Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
+ );
+};
+
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
+ ...colorInteractionSettings,
selectedColorIndex: 0,
turnWhenLost: 0.8,
@@ -31,6 +58,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushCurveMirrorResolutionExponent: 0.5,
brushCurveSegmentBrushRadiusRatio: 0.65,
brushSmoothingMinSampleDistance: 0.5,
+ strokeAngleJitterRadians: Math.PI * 0.7,
brushAlpha: 1,
brushDiscardThreshold: 0.02,
@@ -49,7 +77,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000,
- internalRenderAreaMegapixels: 8.3,
+ internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
maxAgentCount: 700_000,
renderTraceNormalizationFloor: 1,
diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts
index 4e6a429..8962456 100644
--- a/src/config/runtime-controls.ts
+++ b/src/config/runtime-controls.ts
@@ -2,49 +2,59 @@ import { colorInteractionControl } from './color-interactions';
import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
+const formatRadiansAsDegrees = (value: number): string =>
+ `${Math.round((value * 180) / Math.PI)} deg`;
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
- color1ToColor1: colorInteractionControl('1 -> 1'),
- color1ToColor2: colorInteractionControl('1 -> 2'),
- color1ToColor3: colorInteractionControl('1 -> 3'),
- color2ToColor1: colorInteractionControl('2 -> 1'),
- color2ToColor2: colorInteractionControl('2 -> 2'),
- color2ToColor3: colorInteractionControl('2 -> 3'),
- color3ToColor1: colorInteractionControl('3 -> 1'),
- color3ToColor2: colorInteractionControl('3 -> 2'),
- color3ToColor3: colorInteractionControl('3 -> 3'),
+ color1ToColor1: colorInteractionControl('Primary Follows Primary'),
+ color1ToColor2: colorInteractionControl('Primary Follows Secondary'),
+ color1ToColor3: colorInteractionControl('Primary Follows Accent'),
+ color2ToColor1: colorInteractionControl('Secondary Follows Primary'),
+ color2ToColor2: colorInteractionControl('Secondary Follows Secondary'),
+ color2ToColor3: colorInteractionControl('Secondary Follows Accent'),
+ color3ToColor1: colorInteractionControl('Accent Follows Primary'),
+ color3ToColor2: colorInteractionControl('Accent Follows Secondary'),
+ color3ToColor3: colorInteractionControl('Accent Follows Accent'),
brushSize: {
folder: 'Brush',
- label: 'brush size',
+ label: 'Brush Size',
min: 1,
max: 60,
step: 0.25,
},
spawnPerPixel: {
folder: 'Brush',
- label: 'agents per brush pixel',
+ label: 'Agent Density',
min: 0.01,
max: 1,
step: 0.001,
},
+ strokeAngleJitterRadians: {
+ folder: 'Brush',
+ format: formatRadiansAsDegrees,
+ label: 'Spawn Spread',
+ min: 0,
+ max: Math.PI * 2,
+ step: 0.01,
+ },
sensorOffsetDistance: {
folder: 'Agents',
- label: 'sensor distance',
+ label: 'Sensor Reach',
min: 0,
max: 200,
step: 1,
},
moveSpeed: {
folder: 'Agents',
- label: 'move speed',
+ label: 'Travel Speed',
min: 10,
max: 500,
step: 1,
},
turnSpeed: {
folder: 'Agents',
- label: 'turn speed',
+ label: 'Turning Speed',
min: 1,
max: 200,
step: 1,
@@ -52,28 +62,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
forwardRotationScale: {
folder: 'Agents',
format: formatPercent,
- label: 'following sensor %',
+ label: 'Forward Focus',
min: 0,
max: 1,
step: 0.01,
},
turnWhenLost: {
folder: 'Agents',
- label: 'turn when lost',
+ label: 'Wander Turn',
min: 0,
max: 6.28,
step: 0.01,
},
individualTrailWeight: {
folder: 'Agents',
- label: 'individual trail weight',
+ label: 'Trail Strength',
min: 0,
max: 1,
step: 0.001,
},
decayRateTrails: {
folder: 'Agents',
- label: 'trail decay',
+ label: 'Trail Fade',
min: 800,
max: 1000,
step: 1,
@@ -81,14 +91,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
clarity: {
folder: 'Look',
- label: 'clarity',
+ label: 'Sharpness',
min: 0.00001,
max: 1,
step: 0.001,
},
backgroundGrainStrength: {
folder: 'Look',
- label: 'grain strength',
+ label: 'Background Grain',
min: 0,
max: 0.12,
step: 0.001,
@@ -97,14 +107,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
maxAgentCount: {
folder: 'Performance',
integer: true,
- label: 'max agent count',
+ label: 'Agent Limit',
min: 0,
- max: 2_000_000,
step: 10_000,
},
internalRenderAreaMegapixels: {
folder: 'Performance',
- label: 'internal resolution (MP)',
+ label: 'Render Quality (MP)',
min: 0.5,
max: 16.6,
step: 0.1,
diff --git a/src/config/types.ts b/src/config/types.ts
index df58ec4..2429bd0 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -41,6 +41,7 @@ export type GardenRuntimeSettings = {
maxAgentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
+ strokeAngleJitterRadians: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
@@ -55,15 +56,6 @@ type GardenVibeSettings = Pick<
| 'backgroundGrainStrength'
| 'brushSize'
| 'clarity'
- | 'color1ToColor1'
- | 'color1ToColor2'
- | 'color1ToColor3'
- | 'color2ToColor1'
- | 'color2ToColor2'
- | 'color2ToColor3'
- | 'color3ToColor1'
- | 'color3ToColor2'
- | 'color3ToColor3'
| 'decayRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
@@ -144,7 +136,7 @@ export interface GardenAppConfig {
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;
- maxDevicePixelRatio: number;
+ sourceActiveFramesAfterWrite: number;
intro: {
angleJitterRadians: number;
angleEaseEnd: number;
@@ -187,7 +179,6 @@ export interface GardenAppConfig {
introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number;
stroke: {
- angleJitterRadians: number;
densityMultiplier: number;
maxAgentCount: number;
minAgentCount: number;
diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts
index f05b72b..3eab271 100644
--- a/src/config/vibe-presets.ts
+++ b/src/config/vibe-presets.ts
@@ -1,5 +1,4 @@
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
-import { colorInteractionSettings } from './color-interactions';
import { VibeId, type VibePreset } from './types';
const defaultAudioSettings = {
@@ -24,7 +23,6 @@ export const vibePresets: Array = [
],
backgroundColor: [16, 21, 31],
settings: {
- ...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 14,
clarity: 0.62,
@@ -50,7 +48,6 @@ export const vibePresets: Array = [
],
backgroundColor: [23, 32, 22],
settings: {
- ...colorInteractionSettings,
backgroundGrainStrength: 0.014,
brushSize: 16,
clarity: 0.68,
@@ -76,7 +73,6 @@ export const vibePresets: Array = [
],
backgroundColor: [15, 24, 34],
settings: {
- ...colorInteractionSettings,
backgroundGrainStrength: 0.022,
brushSize: 13,
clarity: 0.58,
@@ -102,7 +98,6 @@ export const vibePresets: Array = [
],
backgroundColor: [20, 18, 29],
settings: {
- ...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 12,
clarity: 0.64,
@@ -128,7 +123,6 @@ export const vibePresets: Array = [
],
backgroundColor: [25, 23, 22],
settings: {
- ...colorInteractionSettings,
backgroundGrainStrength: 0.024,
brushSize: 15,
clarity: 0.55,
@@ -154,7 +148,6 @@ export const vibePresets: Array = [
],
backgroundColor: [16, 24, 32],
settings: {
- ...colorInteractionSettings,
backgroundGrainStrength: 0.012,
brushSize: 18,
clarity: 0.7,
diff --git a/src/app-constants.ts b/src/consts.ts
similarity index 100%
rename from src/app-constants.ts
rename to src/consts.ts
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 172cdf2..064914f 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -165,9 +165,7 @@ export class AgentPopulation {
const t = count === 1 ? 1 : i / (count - 1);
const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t;
- const angle =
- baseAngle +
- (Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
+ const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts
index bf6b309..a788bfd 100644
--- a/src/game-loop/export-snapshot-renderer.ts
+++ b/src/game-loop/export-snapshot-renderer.ts
@@ -10,6 +10,7 @@ interface ExportSnapshotRendererOptions {
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
+ getSourceActive?: () => boolean;
getVibeId: () => VibeId;
}
@@ -70,7 +71,8 @@ export class ExportSnapshotRenderer {
commandEncoder,
this.options.getColorTextureView(),
this.options.getSourceTextureView(),
- texture.createView()
+ texture.createView(),
+ this.options.getSourceActive?.() ?? true
);
commandEncoder.copyTextureToBuffer(
{ texture },
diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts
index 55650c2..183189c 100644
--- a/src/game-loop/game-loop-resources.ts
+++ b/src/game-loop/game-loop-resources.ts
@@ -12,6 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
+import { GpuProfiler } from './gpu-profiler';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
@@ -36,6 +37,7 @@ export class GameLoopResources {
public readonly eraserTexturePipeline: EraserTexturePipeline;
public readonly diffusionPipeline: DiffusionPipeline;
public readonly renderPipeline: RenderPipeline;
+ public readonly gpuProfiler: GpuProfiler | null;
private readonly frameRenderer: SimulationFrameRenderer;
@@ -52,7 +54,6 @@ export class GameLoopResources {
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize,
- time: 0,
});
this.agentGenerationPipeline = new AgentGenerationPipeline(
@@ -73,15 +74,21 @@ export class GameLoopResources {
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
+ this.gpuProfiler = GpuProfiler.create(this.device);
- this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
- agentPipeline: this.agentPipeline,
- brushPipeline: this.brushPipeline,
- eraserAgentPipeline: this.eraserAgentPipeline,
- eraserTexturePipeline: this.eraserTexturePipeline,
- diffusionPipeline: this.diffusionPipeline,
- renderPipeline: this.renderPipeline,
- });
+ this.frameRenderer = new SimulationFrameRenderer(
+ this.device,
+ this.textures,
+ {
+ agentPipeline: this.agentPipeline,
+ brushPipeline: this.brushPipeline,
+ eraserAgentPipeline: this.eraserAgentPipeline,
+ eraserTexturePipeline: this.eraserTexturePipeline,
+ diffusionPipeline: this.diffusionPipeline,
+ renderPipeline: this.renderPipeline,
+ },
+ this.gpuProfiler
+ );
}
public resizeSimulationTo(nextSize: vec2): vec2 | null {
@@ -93,6 +100,10 @@ export class GameLoopResources {
this.frameRenderer.resetSourceMapActivity();
}
+ public get isSourceMapActive(): boolean {
+ return this.frameRenderer.isSourceMapActive;
+ }
+
public setFrameParameters({
time,
deltaTime,
@@ -107,7 +118,6 @@ export class GameLoopResources {
}: FrameParameters): void {
this.commonState.setParameters({
canvasSize,
- time,
});
this.agentPipeline.setParameters({
...settings,
@@ -130,11 +140,13 @@ export class GameLoopResources {
this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({
...settings,
+ backgroundGrainStrength: 0,
channelColors,
backgroundColor,
});
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
+ eraserSize: eraserPixelSize,
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
maskSize: canvasSize,
});
@@ -163,6 +175,7 @@ export class GameLoopResources {
this.eraserTexturePipeline.destroy();
this.diffusionPipeline.destroy();
this.renderPipeline.destroy();
+ this.gpuProfiler?.destroy();
this.commonState.destroy();
this.textures.destroy();
}
diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts
index 6b3ecfe..2dba0c9 100644
--- a/src/game-loop/game-loop-types.ts
+++ b/src/game-loop/game-loop-types.ts
@@ -3,9 +3,10 @@ import { vec2 } from 'gl-matrix';
import type { RgbColor } from '../utils/rgb-color';
export interface GardenUi {
- prompt: HTMLElement;
eraserPreview: HTMLElement;
exportStatus: HTMLElement;
+ grainOverlay: HTMLElement;
+ prompt: HTMLElement;
toolbar: HTMLElement;
}
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index 2f731af..370cf0a 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -6,7 +6,6 @@ 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 { EraserPreview } from './eraser-preview';
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance';
@@ -14,6 +13,7 @@ import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
+import { PerfStatsOverlay } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input';
import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
@@ -27,7 +27,7 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
- private devStatsOverlay: DevStatsOverlay | null = null;
+ private perfStatsOverlay: PerfStatsOverlay | null = null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
@@ -36,6 +36,7 @@ export default class GameLoop {
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = '';
+ private previousGrainStrength = Number.NaN;
private hasFinished = false;
private readonly finished = Promise.withResolvers();
@@ -43,7 +44,7 @@ export default class GameLoop {
private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
- ui: GardenUi
+ private readonly ui: GardenUi
) {
this.resize();
this.resources = new GameLoopResources(
@@ -94,12 +95,13 @@ export default class GameLoop {
},
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
+ getSourceActive: () => this.resources.isSourceMapActive,
getVibeId: () => activeVibe.id,
});
window.addEventListener('resize', this.resizeListener);
this.eraserPreview.attach();
- this.syncDevStatsOverlay();
+ this.syncPerfStatsOverlay();
}
public attachPointerInput(): void {
@@ -117,7 +119,7 @@ export default class GameLoop {
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
- this.syncDevStatsOverlay();
+ this.syncPerfStatsOverlay();
}
public setAudioMuted(isMuted: boolean): void {
@@ -152,8 +154,8 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach();
this.eraserPreview.detach();
- this.devStatsOverlay?.destroy();
- this.devStatsOverlay = null;
+ this.perfStatsOverlay?.destroy();
+ this.perfStatsOverlay = null;
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
await this.agentPopulation.waitForCompaction();
@@ -183,6 +185,7 @@ export default class GameLoop {
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.updateAccentColor(accentColor);
+ this.updateGrainOverlay(settings.backgroundGrainStrength);
this.audio.update({
vibe: activeVibe,
isErasing,
@@ -208,7 +211,7 @@ export default class GameLoop {
this.pointerInput.clearSwipesIfIdle();
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
- this.devStatsOverlay?.update({
+ this.perfStatsOverlay?.update({
time,
fps: this.framePerformance.measuredFps,
agentCount: this.agentPopulation.activeAgentCount,
@@ -220,16 +223,16 @@ export default class GameLoop {
requestAnimationFrame(this.render);
};
- private syncDevStatsOverlay(): void {
+ private syncPerfStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) {
- this.devStatsOverlay ??= new DevStatsOverlay(
+ this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
return;
}
- this.devStatsOverlay?.destroy();
- this.devStatsOverlay = null;
+ this.perfStatsOverlay?.destroy();
+ this.perfStatsOverlay = null;
}
private updateAccentColor(color: RgbColor): void {
@@ -242,12 +245,22 @@ export default class GameLoop {
document.documentElement.style.setProperty('--accent-color', accentColor);
}
+ private updateGrainOverlay(strength: number): void {
+ const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0;
+ if (Object.is(this.previousGrainStrength, safeStrength)) {
+ return;
+ }
+
+ this.previousGrainStrength = safeStrength;
+ this.grainOverlay.hidden = safeStrength <= 0;
+ this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength));
+ }
+
private resize(): void {
const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({
clientHeight: rect.height || this.canvas.clientHeight,
clientWidth: rect.width || this.canvas.clientWidth,
- maxPixelScale: appConfig.simulation.maxDevicePixelRatio,
maxTextureDimension: this.device.limits.maxTextureDimension2D,
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
});
@@ -318,4 +331,8 @@ export default class GameLoop {
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
);
}
+
+ private get grainOverlay(): HTMLElement {
+ return this.ui.grainOverlay;
+ }
}
diff --git a/src/game-loop/gpu-profiler.ts b/src/game-loop/gpu-profiler.ts
new file mode 100644
index 0000000..7fd0288
--- /dev/null
+++ b/src/game-loop/gpu-profiler.ts
@@ -0,0 +1,180 @@
+const PASS_NAMES = [
+ 'brush',
+ 'eraserTexture',
+ 'eraserAgent',
+ 'agent',
+ 'trailDiffusion',
+ 'render',
+ 'sourceDiffusion',
+] as const;
+
+export type GpuPassName = (typeof PASS_NAMES)[number];
+
+interface GpuProfilerSample {
+ frame: number;
+ passes: Partial>;
+ totalPassMs: number;
+}
+
+interface FleetingGardenPerf {
+ latest?: GpuProfilerSample;
+ samples: Array;
+}
+
+interface ActivePass {
+ endQueryIndex: number;
+ name: GpuPassName;
+ startQueryIndex: number;
+}
+
+interface ReadbackSlot {
+ buffer: GPUBuffer;
+ state: 'idle' | 'encoding' | 'mapping';
+}
+
+declare global {
+ interface Window {
+ __fleetingGardenPerf?: FleetingGardenPerf;
+ }
+}
+
+const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
+const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
+const READBACK_SLOT_COUNT = 4;
+const MAX_SAMPLE_COUNT = 600;
+
+export class GpuProfiler {
+ private readonly querySet: GPUQuerySet;
+ private readonly resolveBuffer: GPUBuffer;
+ private readonly readbackSlots: Array;
+ private activePasses: Array = [];
+ private nextQueryIndex = 0;
+ private frame = 0;
+
+ public static create(device: GPUDevice): GpuProfiler | null {
+ if (!device.features.has('timestamp-query')) {
+ return null;
+ }
+ return new GpuProfiler(device);
+ }
+
+ private constructor(device: GPUDevice) {
+ this.querySet = device.createQuerySet({
+ type: 'timestamp',
+ count: MAX_QUERY_COUNT,
+ });
+ this.resolveBuffer = device.createBuffer({
+ size: MAX_QUERY_COUNT * QUERY_BYTES,
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
+ });
+ this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({
+ buffer: device.createBuffer({
+ size: MAX_QUERY_COUNT * QUERY_BYTES,
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
+ }),
+ state: 'idle' as const,
+ }));
+ }
+
+ public beginFrame(): void {
+ this.frame += 1;
+ this.activePasses = [];
+ this.nextQueryIndex = 0;
+ }
+
+ public timestampWrites(
+ name: GpuPassName
+ ): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
+ if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
+ return undefined;
+ }
+
+ const startQueryIndex = this.nextQueryIndex;
+ const endQueryIndex = this.nextQueryIndex + 1;
+ this.nextQueryIndex += 2;
+ this.activePasses.push({
+ endQueryIndex,
+ name,
+ startQueryIndex,
+ });
+
+ return {
+ querySet: this.querySet,
+ beginningOfPassWriteIndex: startQueryIndex,
+ endOfPassWriteIndex: endQueryIndex,
+ };
+ }
+
+ public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null {
+ const queryCount = this.nextQueryIndex;
+ if (queryCount === 0 || this.activePasses.length === 0) {
+ return null;
+ }
+
+ const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle');
+ if (!slot) {
+ return null;
+ }
+
+ const byteLength = queryCount * QUERY_BYTES;
+ const passes = this.activePasses.slice();
+ const frame = this.frame;
+ slot.state = 'encoding';
+ commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0);
+ commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength);
+
+ return () => {
+ slot.state = 'mapping';
+ void slot.buffer
+ .mapAsync(GPUMapMode.READ, 0, byteLength)
+ .then(() => {
+ this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength));
+ slot.buffer.unmap();
+ slot.state = 'idle';
+ })
+ .catch(() => {
+ slot.state = 'idle';
+ });
+ };
+ }
+
+ public destroy(): void {
+ this.querySet.destroy();
+ this.resolveBuffer.destroy();
+ this.readbackSlots.forEach((slot) => {
+ slot.buffer.destroy();
+ });
+ }
+
+ private publishSample(
+ frame: number,
+ passes: Array,
+ mappedRange: ArrayBuffer
+ ): void {
+ const timestamps = new BigUint64Array(mappedRange);
+ const sample: GpuProfilerSample = {
+ frame,
+ passes: {},
+ totalPassMs: 0,
+ };
+
+ passes.forEach(({ endQueryIndex, name, startQueryIndex }) => {
+ const start = timestamps[startQueryIndex];
+ const end = timestamps[endQueryIndex];
+ if (end < start) {
+ return;
+ }
+
+ const elapsedMs = Number(end - start) / 1_000_000;
+ sample.passes[name] = elapsedMs;
+ sample.totalPassMs += elapsedMs;
+ });
+
+ const perf = (window.__fleetingGardenPerf ??= { samples: [] });
+ perf.latest = sample;
+ perf.samples.push(sample);
+ if (perf.samples.length > MAX_SAMPLE_COUNT) {
+ perf.samples.splice(0, perf.samples.length - MAX_SAMPLE_COUNT);
+ }
+ }
+}
diff --git a/src/game-loop/internal-render-size.ts b/src/game-loop/internal-render-size.ts
index 841416e..5184618 100644
--- a/src/game-loop/internal-render-size.ts
+++ b/src/game-loop/internal-render-size.ts
@@ -3,7 +3,6 @@ const MEGAPIXEL = 1_000_000;
export interface InternalRenderSizeOptions {
clientHeight: number;
clientWidth: number;
- maxPixelScale: number;
maxTextureDimension: number;
targetAreaMegapixels: number;
}
@@ -18,15 +17,9 @@ const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): numb
? targetAreaMegapixels
: 1;
-const getSafeMaxPixelScale = (maxPixelScale: number): number =>
- Number.isFinite(maxPixelScale) && maxPixelScale > 0
- ? maxPixelScale
- : Number.POSITIVE_INFINITY;
-
export const getInternalRenderSize = ({
clientHeight,
clientWidth,
- maxPixelScale,
maxTextureDimension,
targetAreaMegapixels,
}: InternalRenderSizeOptions): InternalRenderSize => {
@@ -41,7 +34,6 @@ export const getInternalRenderSize = ({
const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight));
const dimensionScale = Math.min(
areaScale,
- getSafeMaxPixelScale(maxPixelScale),
safeMaxTextureDimension / safeClientWidth,
safeMaxTextureDimension / safeClientHeight
);
diff --git a/src/game-loop/dev-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts
similarity index 65%
rename from src/game-loop/dev-stats-overlay.ts
rename to src/game-loop/perf-stats-overlay.ts
index b81ad38..f241017 100644
--- a/src/game-loop/dev-stats-overlay.ts
+++ b/src/game-loop/perf-stats-overlay.ts
@@ -1,9 +1,9 @@
-const DEV_STATS_REFRESH_MS = 200;
+const PERF_STATS_REFRESH_MS = 200;
const ZERO_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0';
-interface DevStatsSnapshot {
+interface PerfStatsSnapshot {
time: DOMHighResTimeStamp;
fps: number;
agentCount: number;
@@ -12,14 +12,14 @@ interface DevStatsSnapshot {
renderHeight: number;
}
-export class DevStatsOverlay {
+export class PerfStatsOverlay {
private readonly element: HTMLDivElement;
private previousUpdateTime = Number.NEGATIVE_INFINITY;
private previousText = '';
public constructor(parent: HTMLElement) {
this.element = document.createElement('div');
- this.element.className = 'dev-stats-overlay';
+ this.element.className = 'perf-stats-overlay';
this.element.setAttribute('aria-hidden', 'true');
parent.append(this.element);
}
@@ -31,13 +31,14 @@ export class DevStatsOverlay {
frameTimeMs,
renderWidth,
renderHeight,
- }: DevStatsSnapshot): void {
- if (time - this.previousUpdateTime < DEV_STATS_REFRESH_MS) {
+ }: PerfStatsSnapshot): void {
+ if (time - this.previousUpdateTime < PERF_STATS_REFRESH_MS) {
return;
}
this.previousUpdateTime = time;
- const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
+ const gpuPassTimeMs = window.__fleetingGardenPerf?.latest?.totalPassMs;
+ const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
if (text !== this.previousText) {
this.element.textContent = text;
this.previousText = text;
@@ -57,10 +58,14 @@ const formatAgentCount = (agentCount: number): string =>
? Math.max(0, Math.round(agentCount)).toLocaleString('en-US')
: ZERO_STAT_TEXT;
-const formatFrameTime = (frameTimeMs: number): string =>
- Number.isFinite(frameTimeMs)
- ? `${Math.max(0, frameTimeMs).toFixed(frameTimeMs < 10 ? 1 : 0)}ms`
- : ZERO_FRAME_TIME_TEXT;
+const formatFrameTime = (frameTimeMs: number | undefined): string => {
+ if (typeof frameTimeMs !== 'number' || !Number.isFinite(frameTimeMs)) {
+ return ZERO_FRAME_TIME_TEXT;
+ }
+
+ const safeFrameTimeMs = Math.max(0, frameTimeMs);
+ return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`;
+};
const formatResolution = (width: number, height: number): string =>
Number.isFinite(width) && Number.isFinite(height)
diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts
index f9c068f..538ed45 100644
--- a/src/game-loop/simulation-frame.ts
+++ b/src/game-loop/simulation-frame.ts
@@ -1,10 +1,13 @@
+import { appConfig } from '../config';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
+import { settings } from '../settings';
import { CanvasReadbackRequest } from './game-loop-types';
+import { GpuProfiler } from './gpu-profiler';
import { SimulationTextures } from './simulation-textures';
interface SimulationFramePipelines {
@@ -17,15 +20,14 @@ interface SimulationFramePipelines {
}
export class SimulationFrameRenderer {
- private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
-
private sourceActiveFramesRemaining = 0;
private sourceMapsCleared = true;
public constructor(
private readonly device: GPUDevice,
private readonly textures: SimulationTextures,
- private readonly pipelines: SimulationFramePipelines
+ private readonly pipelines: SimulationFramePipelines,
+ private readonly gpuProfiler: GpuProfiler | null = null
) {}
public resetSourceMapActivity(): void {
@@ -33,11 +35,16 @@ export class SimulationFrameRenderer {
this.sourceMapsCleared = true;
}
+ public get isSourceMapActive(): boolean {
+ return this.sourceActiveFramesRemaining > 0;
+ }
+
public execute(
isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null
): void {
const commandEncoder = this.device.createCommandEncoder();
+ this.gpuProfiler?.beginFrame();
this.textures.copyTrailMapAToB(commandEncoder);
let wroteSourceMap = false;
@@ -48,24 +55,29 @@ export class SimulationFrameRenderer {
commandEncoder,
eraserMask,
this.textures.sourceMapA.getTextureView(),
- this.textures.trailMapB.getTextureView()
+ this.textures.trailMapB.getTextureView(),
+ this.gpuProfiler?.timestampWrites('eraserTexture')
+ );
+ this.pipelines.eraserAgentPipeline.execute(
+ commandEncoder,
+ eraserMask,
+ this.gpuProfiler?.timestampWrites('eraserAgent')
);
- this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask);
}
} else {
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget(
commandEncoder,
- this.textures.sourceMapA.getTextureView()
+ this.textures.sourceMapA.getTextureView(),
+ this.gpuProfiler?.timestampWrites('brush')
);
}
if (wroteSourceMap) {
- this.sourceActiveFramesRemaining =
- SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE;
+ this.sourceActiveFramesRemaining = getSourceActiveFrameCount();
this.sourceMapsCleared = false;
}
- const useSourceMap = this.sourceActiveFramesRemaining > 0;
+ const useSourceMap = this.isSourceMapActive;
if (!useSourceMap && !this.sourceMapsCleared) {
this.textures.clearSourceMaps(commandEncoder);
this.sourceMapsCleared = true;
@@ -74,19 +86,22 @@ export class SimulationFrameRenderer {
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
- this.textures.trailMapB.getTextureView()
+ this.textures.trailMapB.getTextureView(),
+ this.gpuProfiler?.timestampWrites('agent')
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getTextureView(),
- this.textures.trailMapA.getSize()
+ this.textures.trailMapA.getSize(),
+ this.gpuProfiler?.timestampWrites('trailDiffusion')
);
const canvasTexture = this.pipelines.renderPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.sourceMapA.getTextureView(),
- useSourceMap
+ useSourceMap,
+ this.gpuProfiler?.timestampWrites('render')
);
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
@@ -95,10 +110,13 @@ export class SimulationFrameRenderer {
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(),
- this.textures.sourceMapB.getSize()
+ this.textures.sourceMapB.getSize(),
+ this.gpuProfiler?.timestampWrites('sourceDiffusion')
);
}
+ const afterGpuProfileSubmit = this.gpuProfiler?.resolve(commandEncoder);
this.device.queue.submit([commandEncoder.finish()]);
+ afterGpuProfileSubmit?.();
canvasReadbackRequest?.afterSubmit();
if (useSourceMap) {
this.textures.swapSourceMaps();
@@ -106,3 +124,12 @@ export class SimulationFrameRenderer {
}
}
}
+
+const getSourceActiveFrameCount = (): number => {
+ const frameCount =
+ settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
+ if (Number.isFinite(frameCount) && frameCount > 0) {
+ return Math.ceil(frameCount);
+ }
+ return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite);
+};
diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts
index 90eb9be..4c75928 100644
--- a/src/game-loop/simulation-textures.ts
+++ b/src/game-loop/simulation-textures.ts
@@ -1,14 +1,18 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
-import { ResizableTexture } from '../utils/graphics/resizable-texture';
+import {
+ ResizableTexture,
+ type PendingTextureResize,
+} from '../utils/graphics/resizable-texture';
export class SimulationTextures {
public readonly trailMapA: ResizableTexture;
public readonly trailMapB: ResizableTexture;
+ public readonly eraserMask: ResizableTexture;
+ // A/B are swapped each frame to ping-pong the diffusion pass.
public sourceMapA: ResizableTexture;
public sourceMapB: ResizableTexture;
- public eraserMask: ResizableTexture;
public constructor(
private readonly device: GPUDevice,
@@ -28,11 +32,31 @@ export class SimulationTextures {
}
const scale = vec2.div(vec2.create(), nextSize, previousSize);
- this.trailMapA.resize(nextSize);
- this.trailMapB.resize(nextSize);
- this.sourceMapA.resize(nextSize);
- this.sourceMapB.resize(nextSize);
- this.eraserMask.resize(nextSize);
+ const resizes = [
+ this.trailMapA,
+ this.trailMapB,
+ this.sourceMapA,
+ this.sourceMapB,
+ this.eraserMask,
+ ]
+ .map((texture): [ResizableTexture, PendingTextureResize] | null => {
+ const resize = texture.prepareResize(nextSize);
+ return resize ? [texture, resize] : null;
+ })
+ .filter((resize): resize is [ResizableTexture, PendingTextureResize] =>
+ Boolean(resize)
+ );
+
+ if (resizes.length > 0) {
+ const commandEncoder = this.device.createCommandEncoder();
+ resizes.forEach(([texture, resize]) => {
+ texture.encodeResize(commandEncoder, resize);
+ });
+ this.device.queue.submit([commandEncoder.finish()]);
+ resizes.forEach(([texture, resize]) => {
+ texture.commitResize(resize);
+ });
+ }
return scale;
}
diff --git a/src/game-loop/stroke-output.ts b/src/game-loop/stroke-output.ts
index 6680525..1e9650c 100644
--- a/src/game-loop/stroke-output.ts
+++ b/src/game-loop/stroke-output.ts
@@ -22,7 +22,7 @@ export class PipelineStrokeOutput implements StrokeOutput {
}
public addEraseSegment(from: vec2, to: vec2): void {
- this.eraserAgentPipeline.addSwipeSegment();
+ this.eraserAgentPipeline.addSwipeSegment(from, to);
this.eraserTexturePipeline.addSwipeSegment(from, to);
}
diff --git a/src/index.scss b/src/index.scss
index c9ee7e1..d6d8b85 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -6,4 +6,3 @@
@use 'style/config-pane';
@use 'style/panels';
@use 'style/loading';
-@use 'style/motion';
diff --git a/src/index.ts b/src/index.ts
index ee47f5b..c840a33 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,356 +3,24 @@ import GameLoop from './game-loop/game-loop';
import './index.scss';
import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics';
-import {
- APP_STORAGE_KEYS,
- DEFAULT_AUDIO_VOLUME,
- DISABLED_FLAG_VALUE,
- ENABLED_FLAG_VALUE,
- UNIT_INTERVAL_INPUT_MAX,
- UNIT_INTERVAL_INPUT_MIN,
-} from './app-constants';
import { preloadPianoSamples } from './audio/piano-samples';
+import { AudioControl } from './page/audio-control';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane';
+import { EraserSizeControl } from './page/eraser-size-control';
+import { ErrorPresenter } from './page/error-presenter';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
-import { activeVibe, applyVibeSettings, settings } from './settings';
-import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
+import { MirrorSegmentControl } from './page/mirror-segment-control';
+import { PaletteControl } from './page/palette-control';
+import { SplashScreen } from './page/splash-screen';
+import { VibeNavigator } from './page/vibe-navigator';
+import { getMaxSupportedAgentCount } from './pipelines/agents/agent-limits';
+import { activeVibe } from './settings';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
-import { queryRequiredElement, queryRequiredElements } from './utils/dom';
-import {
- ErrorHandler,
- getErrorMessage,
- RuntimeError,
- Severity,
-} from './utils/error-handler';
+import { queryRequiredElement } from './utils/dom';
+import { ErrorHandler, Severity } 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;
-
-const ERASER_CONTROL_SCALE_MAX = 1.33;
-const ERASER_CONTROL_SCALE_MIN = 0.75;
-const ERASER_SIZE_DEFAULT = 96;
-const ERASER_SIZE_MAX = 240;
-const ERASER_SIZE_MIN = 24;
-const ERASER_SIZE_STEP = 1;
-
-const MIRROR_SEGMENT_DEFAULT = 1;
-const MIRROR_SEGMENT_MAX = 12;
-const MIRROR_SEGMENT_MIN = 1;
-const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
-const MIRROR_SEGMENT_STEP = 1;
-const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
-
-const APP_SELECTORS = {
- aside: 'aside',
- canvas: 'canvas',
- eraserPreview: '.eraser-preview',
- eraserSizeControl: '.eraser-size-control',
- eraserSizeSlider: '.eraser-size-slider',
- errorContainer: '.errors-container',
- export4k: '.export-4k',
- exportStatus: '.export-status',
- infoButton: 'button.info',
- infoElement: '.info-page',
- loadingBar: '.loading-bar',
- loadingProgress: '.loading-progress',
- loadingStatus: '.loading-status',
- maximizeFullScreenButton: 'button.maximize-full-screen',
- minimizeFullScreenButton: 'button.minimize-full-screen',
- mirrorSegmentControl: '.mirror-segment-control',
- mirrorSegmentSlider: '.mirror-segment-slider',
- nextVibe: '.next-vibe',
- previousVibe: '.previous-vibe',
- prompt: '.garden-prompt',
- restartButton: 'button.restart',
- settingsButton: 'button.settings',
- soundButton: 'button.sound',
- splash: '.splash',
- startButton: '.start-button',
- swatches: '.color-swatch',
- toolbarRow: '.toolbar-row',
- volumeControl: '.volume-control',
- volumeSlider: '.volume-slider',
-} as const;
-
-const clampEraserSize = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
- return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
-};
-
-const getEraserSizeRatio = (size: number): number =>
- (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
-
-const clampMirrorSegmentCount = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
- return Math.min(
- MIRROR_SEGMENT_MAX,
- Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
- );
-};
-
-const getMirrorSegmentRatio = (count: number): number =>
- (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
-
-const formatMirrorSegmentCount = (count: number): string =>
- count === MIRROR_SEGMENT_DEFAULT
- ? MIRROR_SEGMENT_OFF_LABEL
- : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
-
-const clampAudioVolume = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
- return clamp01(safeValue);
-};
-
-const getAudioVolumePercent = (volume: number): number =>
- Math.round(clampAudioVolume(volume) * 100);
-
-const readInitialAudioVolume = (): number => {
- const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
- return storedVolume === null
- ? DEFAULT_AUDIO_VOLUME
- : clampAudioVolume(Number(storedVolume));
-};
-
-const formatStoredAudioVolume = (volume: number): string =>
- clampAudioVolume(volume).toFixed(2);
-
-type RuntimeUiError = Parameters<
- Parameters[0]
->[0];
-
-const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
- const message = document.createElement('pre');
- message.className = error.severity;
- message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
- message.setAttribute(
- 'role',
- error.severity === Severity.ERROR ? 'alert' : 'status'
- );
- message.setAttribute(
- 'aria-live',
- error.severity === Severity.ERROR
- ? 'assertive'
- : 'polite'
- );
- container.append(message);
-
- if (error.severity === Severity.ERROR) {
- message.tabIndex = -1;
- message.focus({ preventScroll: true });
- }
-};
-
-const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
- severity: Severity.ERROR,
- message: getErrorMessage(exception),
- ...(exception instanceof RuntimeError ? { code: exception.code } : {}),
-});
-
-const renderStartupException = (exception: unknown) => {
- const existingContainer = document.querySelector(APP_SELECTORS.errorContainer);
- const container =
- existingContainer instanceof HTMLElement
- ? existingContainer
- : document.createElement('div');
-
- if (!(existingContainer instanceof HTMLElement)) {
- container.className = 'errors-container';
- document.body.append(container);
- }
-
- container.setAttribute('aria-live', 'assertive');
- renderRuntimeMessage(container, getRuntimeUiError(exception));
-};
-
-const queryAppElements = () => ({
- aside: queryRequiredElement(APP_SELECTORS.aside, HTMLElement),
- toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement),
- infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement),
- infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement),
- minimizeFullScreenButton: queryRequiredElement(
- APP_SELECTORS.minimizeFullScreenButton,
- HTMLButtonElement
- ),
- maximizeFullScreenButton: queryRequiredElement(
- APP_SELECTORS.maximizeFullScreenButton,
- HTMLButtonElement
- ),
- settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement),
- soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement),
- volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement),
- volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement),
- restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement),
- canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement),
- eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement),
- errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement),
- previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement),
- nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement),
- swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement),
- eraserSizeControl: queryRequiredElement(
- APP_SELECTORS.eraserSizeControl,
- HTMLLabelElement
- ),
- eraserSizeSlider: queryRequiredElement(
- APP_SELECTORS.eraserSizeSlider,
- HTMLInputElement
- ),
- mirrorSegmentControl: queryRequiredElement(
- APP_SELECTORS.mirrorSegmentControl,
- HTMLLabelElement
- ),
- mirrorSegmentSlider: queryRequiredElement(
- APP_SELECTORS.mirrorSegmentSlider,
- HTMLInputElement
- ),
- export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement),
- exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement),
- prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement),
- loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement),
- loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement),
- splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement),
- loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement),
- startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement),
-});
-
-type AppElements = ReturnType;
-
-let elements: AppElements;
-
-const setLoadingStage = (label: string, ratio: number) => {
- const percent = Math.round(clamp01(ratio) * 100);
- elements.loadingStatus.textContent = label;
- elements.loadingProgress.style.setProperty(
- '--loading-progress',
- `${percent}%`
- );
- elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
-};
-
-let audioVolume = readInitialAudioVolume();
-let isAudioMuted =
- readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
- audioVolume <= 0;
-let isEraserActive = false;
-
-const persistAudioUiState = () => {
- writeBrowserStorage(
- APP_STORAGE_KEYS.audioMuted,
- isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
- );
- writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume));
-};
-
-const renderAudioUi = (game: GameLoop | null) => {
- audioVolume = clampAudioVolume(audioVolume);
- const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
- const volumePercent = getAudioVolumePercent(audioVolume);
-
- elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
- elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
- elements.soundButton.setAttribute(
- 'aria-label',
- isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
- );
- elements.soundButton.title = isEffectivelyMuted
- ? 'Unmute audio'
- : 'Mute audio';
-
- elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
- elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
- elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
- elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
- elements.volumeSlider.setAttribute(
- 'aria-valuetext',
- isEffectivelyMuted
- ? `${'Muted'}, ${volumePercent}%`
- : `${volumePercent}%`
- );
- elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
- elements.volumeControl.title = isEffectivelyMuted
- ? `${'Muted'}, ${volumePercent}% ${'volume'}`
- : `${volumePercent}% ${'volume'}`;
- elements.volumeControl.style.setProperty(
- '--volume-progress',
- `${volumePercent}%`
- );
-
- game?.setAudioVolume(audioVolume);
- game?.setAudioMuted(isEffectivelyMuted);
-};
-
-const renderPaletteUi = (game: GameLoop | null) => {
- elements.swatches.forEach((swatch, index) => {
- swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
- swatch.classList.toggle(
- 'active',
- settings.selectedColorIndex === index && !isEraserActive
- );
- });
- elements.eraserSizeControl.classList.toggle('active', isEraserActive);
- game?.setEraseMode(isEraserActive);
- document.documentElement.style.setProperty(
- '--garden-background',
- rgbColorToCss(activeVibe.backgroundColor)
- );
-};
-
-const renderEraserSizeUi = (game: GameLoop | null) => {
- const size = clampEraserSize(settings.eraserSize);
- if (settings.eraserSize !== size) {
- settings.eraserSize = size;
- }
-
- elements.eraserSizeSlider.min = ERASER_SIZE_MIN.toString();
- elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
- elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
- elements.eraserSizeSlider.value = size.toString();
- elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
-
- const ratio = getEraserSizeRatio(size);
- const scale =
- ERASER_CONTROL_SCALE_MIN +
- (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
- elements.eraserSizeControl.style.setProperty(
- '--eraser-progress',
- `${ratio * 100}%`
- );
- elements.eraserSizeControl.style.setProperty(
- '--eraser-control-scale',
- scale.toFixed(3)
- );
- game?.updateEraserPreview();
-};
-
-const renderMirrorSegmentUi = () => {
- const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
- if (settings.mirrorSegmentCount !== count) {
- settings.mirrorSegmentCount = count;
- }
-
- elements.mirrorSegmentSlider.min = MIRROR_SEGMENT_MIN.toString();
- elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString();
- elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString();
- elements.mirrorSegmentSlider.value = count.toString();
-
- const label = formatMirrorSegmentCount(count);
- const ratio = getMirrorSegmentRatio(count);
- elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
- elements.mirrorSegmentControl.title = label;
- elements.mirrorSegmentControl.classList.toggle('active', count > 1);
- elements.mirrorSegmentControl.style.setProperty(
- '--mirror-progress',
- `${ratio * 100}%`
- );
- elements.mirrorSegmentControl.style.setProperty(
- '--mirror-angle',
- `${(360 / count).toFixed(3)}deg`
- );
-};
const main = async () => {
let hasRuntimeErrorListener = false;
@@ -363,14 +31,13 @@ const main = async () => {
let hasStarted = false;
let game: GameLoop | null = null;
let configPane: ConfigPane | null = null;
+ const getGame = () => game;
- elements = queryAppElements();
- elements.errorContainer.setAttribute(
- 'aria-live',
- 'assertive'
+ const errorPresenter = new ErrorPresenter(
+ queryRequiredElement('.errors-container', HTMLElement)
);
ErrorHandler.addOnErrorListener((error) => {
- renderRuntimeMessage(elements.errorContainer, error);
+ errorPresenter.render(error);
if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading');
game?.destroy();
@@ -379,174 +46,100 @@ const main = async () => {
});
hasRuntimeErrorListener = true;
- const syncRuntimeUi = (activeGame = game) => {
- renderEraserSizeUi(game);
- renderMirrorSegmentUi();
- renderPaletteUi(activeGame);
+ const aside = queryRequiredElement('aside', HTMLElement);
+ const canvas = queryRequiredElement('canvas', HTMLCanvasElement);
+ const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement);
+ const eraserPreview = queryRequiredElement('.eraser-preview', HTMLDivElement);
+ const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
+ const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
+ const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
+ const settingsButton = queryRequiredElement('button.settings', HTMLButtonElement);
+ const restartButton = queryRequiredElement('button.restart', HTMLButtonElement);
+ const infoButton = queryRequiredElement('button.info', HTMLButtonElement);
+ const infoElement = queryRequiredElement('.info-page', HTMLElement);
+ const minimizeFullScreenButton = queryRequiredElement(
+ 'button.minimize-full-screen',
+ HTMLButtonElement
+ );
+ const maximizeFullScreenButton = queryRequiredElement(
+ 'button.maximize-full-screen',
+ HTMLButtonElement
+ );
+ const export4kButton = queryRequiredElement('.export-4k', HTMLButtonElement);
+
+ const splash = new SplashScreen();
+ const paletteControl = new PaletteControl({
+ getGame,
+ onChange: () => configPane?.refresh(),
+ });
+ const eraserSizeControl = new EraserSizeControl({
+ getGame,
+ onActivate: () => paletteControl.setEraserActive(true),
+ onChange: () => configPane?.refresh(),
+ });
+ const mirrorSegmentControl = new MirrorSegmentControl({
+ onChange: () => {
+ paletteControl.setEraserActive(false);
+ configPane?.refresh();
+ },
+ });
+ const audioControl = new AudioControl({
+ getGame,
+ hasStarted: () => hasStarted,
+ startButton: splash.startButton,
+ });
+
+ const syncRuntimeUi = () => {
+ eraserSizeControl.render();
+ mirrorSegmentControl.render();
+ paletteControl.render();
};
- const infoPageHandler = new CollapsiblePanelAnimator(
- elements.infoButton,
- elements.infoElement,
- elements.aside
- );
-
+ const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
new MenuHider(
- elements.aside,
+ aside,
() =>
FullScreenHandler.isInFullScreenMode() &&
!configPane?.isOpen &&
!infoPageHandler.isOpen
);
new FullScreenHandler(
- elements.minimizeFullScreenButton,
- elements.maximizeFullScreenButton,
+ minimizeFullScreenButton,
+ maximizeFullScreenButton,
document.documentElement
);
- const startAudioFromUserGesture = (event: Event) => {
- if (
- !hasStarted ||
- isAudioMuted ||
- (event.target instanceof Node && elements.startButton.contains(event.target)) ||
- (event.target instanceof Node && elements.soundButton.contains(event.target))
- ) {
- return;
- }
-
- game?.startAudio(true);
- };
-
- window.addEventListener('touchstart', startAudioFromUserGesture, {
- capture: true,
- passive: true,
- });
- window.addEventListener('pointerdown', startAudioFromUserGesture, {
- capture: true,
- passive: true,
- });
- window.addEventListener('touchend', startAudioFromUserGesture, {
- capture: true,
- passive: true,
- });
- window.addEventListener('pointerup', startAudioFromUserGesture, {
- capture: true,
- passive: true,
- });
- window.addEventListener('click', startAudioFromUserGesture, {
- capture: true,
- });
- window.addEventListener('keydown', startAudioFromUserGesture, {
- capture: true,
- });
-
- elements.restartButton.addEventListener('click', () => game?.destroy());
- elements.soundButton.addEventListener('click', () => {
- const shouldUnmute = isAudioMuted || audioVolume <= 0;
- if (shouldUnmute && audioVolume <= 0) {
- audioVolume = DEFAULT_AUDIO_VOLUME;
- }
- isAudioMuted = !shouldUnmute;
- persistAudioUiState();
- renderAudioUi(game);
- if (!isAudioMuted) {
- game?.startAudio(true);
- }
- });
- elements.volumeSlider.addEventListener('input', () => {
- audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
- isAudioMuted = audioVolume <= 0;
- persistAudioUiState();
- renderAudioUi(game);
- if (!isAudioMuted) {
- game?.startAudio(true);
- }
- });
-
- const selectRelativeVibe = (offset: number, source: string) => {
- const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
- const vibe =
- VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
- const activePreset = applyVibeSettings(vibe);
- trackVibeChange({
- vibeId: activePreset.id,
- vibeName: activePreset.name,
- source,
- });
- game?.onVibeChanged();
- syncRuntimeUi();
- configPane?.refresh();
- game?.playVibeChangeAudio(true);
- };
-
- elements.previousVibe.addEventListener('click', () =>
- selectRelativeVibe(-1, 'previous-button')
- );
-
- elements.nextVibe.addEventListener('click', () =>
- selectRelativeVibe(1, 'next-button')
- );
-
- elements.swatches.forEach((swatch, index) => {
- swatch.addEventListener('click', () => {
- settings.selectedColorIndex = index;
- isEraserActive = false;
- renderPaletteUi(game);
+ new VibeNavigator({
+ onChange: ({ vibeId, vibeName, source }) => {
+ trackVibeChange({ vibeId, vibeName, source });
+ game?.onVibeChanged();
+ syncRuntimeUi();
configPane?.refresh();
- });
+ game?.playVibeChangeAudio(true);
+ },
});
- const activateEraser = () => {
- isEraserActive = true;
- renderPaletteUi(game);
- };
+ restartButton.addEventListener('click', () => game?.destroy());
- elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
- elements.eraserSizeControl.addEventListener('click', activateEraser);
- elements.eraserSizeSlider.addEventListener('focus', activateEraser);
-
- elements.eraserSizeSlider.addEventListener('input', () => {
- settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
- isEraserActive = true;
- renderEraserSizeUi(game);
- renderPaletteUi(game);
- configPane?.refresh();
- });
-
- elements.mirrorSegmentSlider.addEventListener('input', () => {
- settings.mirrorSegmentCount = clampMirrorSegmentCount(
- Number(elements.mirrorSegmentSlider.value)
- );
- isEraserActive = false;
- renderMirrorSegmentUi();
- renderPaletteUi(game);
- configPane?.refresh();
- });
-
- elements.export4k.addEventListener('click', async () => {
- if (!game || elements.export4k.disabled) {
+ export4kButton.addEventListener('click', async () => {
+ if (!game || export4kButton.disabled) {
return;
}
- elements.export4k.disabled = true;
+ export4kButton.disabled = true;
try {
await game.exportSnapshot();
trackExport({ vibeId: activeVibe.id });
} catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING });
} finally {
- elements.export4k.disabled = false;
+ export4kButton.disabled = false;
}
});
- renderPaletteUi(game);
- renderEraserSizeUi(game);
- renderMirrorSegmentUi();
- renderAudioUi(game);
-
- // Loading runs in the background while the splash (title + description +
- // Start button) is shown. The Start tap is the user gesture that unlocks
- // the AudioContext on iOS, and gates the intro.
+ // Samples load before Start is enabled so the first audible piano note
+ // always uses the sampler. The Start tap still unlocks the AudioContext.
+ splash.showLoadingBar();
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load fonts.',
@@ -555,17 +148,18 @@ const main = async () => {
});
const gpuPromise = initializeGpu();
- let isPreloadComplete = false;
const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
- setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio);
+ splash.setLoadingStage(
+ `Loading piano samples ${loadedCount}/${totalCount}…`,
+ ratio
+ );
}).then(
() => {
- isPreloadComplete = true;
- setLoadingStage('Ready', 1);
+ splash.setLoadingStage('Ready', 1);
},
(error: unknown) => {
- isPreloadComplete = true;
+ splash.setLoadingStage('Piano unavailable', 1);
ErrorHandler.addException(error, {
fallbackMessage: 'Could not preload piano samples.',
severity: Severity.WARNING,
@@ -575,7 +169,8 @@ const main = async () => {
const gpu = await gpuPromise;
configPane = new ConfigPane({
- settingsButton: elements.settingsButton,
+ maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
+ settingsButton,
onConfigChange: () => {
game?.onVibeChanged();
syncRuntimeUi();
@@ -584,59 +179,36 @@ const main = async () => {
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
await fontsReady;
+ await preloadPromise;
+ splash.hideLoadingBar();
const deltaTimeCalculator = new DeltaTimeCalculator();
let isFirstStart = true;
while (!shouldStop) {
- game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
- toolbar: elements.toolbarRow,
- prompt: elements.prompt,
- eraserPreview: elements.eraserPreview,
- exportStatus: elements.exportStatus,
+ game = new GameLoop(canvas, gpu, deltaTimeCalculator, {
+ toolbar: toolbarRow,
+ prompt: promptElement,
+ eraserPreview,
+ grainOverlay,
+ exportStatus,
});
- renderPaletteUi(game);
- renderEraserSizeUi(game);
- renderMirrorSegmentUi();
- renderAudioUi(game);
+ syncRuntimeUi();
+ audioControl.render();
if (isFirstStart) {
isFirstStart = false;
// Splash is in the DOM by default; enable the button now that the
// audio system (GameLoop) is constructed and ready to be unlocked.
- elements.startButton.disabled = false;
- await new Promise((resolve) => {
- const onClick = () => {
- elements.startButton.removeEventListener('click', onClick);
- hasStarted = true;
- game?.startAudio(true);
- trackStart();
- elements.splash.hidden = true;
- resolve();
- };
- elements.startButton.addEventListener('click', onClick);
+ await splash.awaitStart(() => {
+ hasStarted = true;
+ game?.startAudio(true);
+ trackStart();
});
- if (!isPreloadComplete) {
- elements.loadingBar.hidden = false;
- void preloadPromise.finally(() => {
- elements.loadingBar.hidden = true;
- });
- }
-
- // Keep the dev stats overlay hidden until the user actually starts drawing.
- document.body.classList.add('pre-drawing');
- elements.canvas.addEventListener(
- 'pointerdown',
- () => document.body.classList.remove('pre-drawing'),
- { once: true }
- );
-
requestAnimationFrame(() =>
- requestAnimationFrame(() =>
- document.body.classList.remove('is-loading')
- )
+ requestAnimationFrame(() => document.body.classList.remove('is-loading'))
);
}
game.attachPointerInput();
@@ -647,7 +219,7 @@ const main = async () => {
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {
- renderStartupException(e);
+ ErrorPresenter.renderStartup(e);
ErrorHandler.addException(e);
}
console.error(e);
diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts
new file mode 100644
index 0000000..278194f
--- /dev/null
+++ b/src/page/audio-control.ts
@@ -0,0 +1,158 @@
+import {
+ APP_STORAGE_KEYS,
+ DEFAULT_AUDIO_VOLUME,
+ DISABLED_FLAG_VALUE,
+ ENABLED_FLAG_VALUE,
+ UNIT_INTERVAL_INPUT_MAX,
+ UNIT_INTERVAL_INPUT_MIN,
+} from '../consts';
+import type GameLoop from '../game-loop/game-loop';
+import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
+import { queryRequiredElement } from '../utils/dom';
+import { clamp01 } from '../utils/math';
+
+const AUDIO_VOLUME_STEP = 0.01;
+
+const clampAudioVolume = (value: number): number => {
+ const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
+ return clamp01(safeValue);
+};
+
+const readInitialAudioVolume = (): number => {
+ const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
+ return storedVolume === null
+ ? DEFAULT_AUDIO_VOLUME
+ : clampAudioVolume(Number(storedVolume));
+};
+
+const formatStoredAudioVolume = (volume: number): string =>
+ clampAudioVolume(volume).toFixed(2);
+
+interface AudioControlOptions {
+ getGame: () => GameLoop | null;
+ hasStarted: () => boolean;
+ startButton: HTMLElement;
+}
+
+export class AudioControl {
+ private readonly soundButton = queryRequiredElement(
+ 'button.sound',
+ HTMLButtonElement
+ );
+ private readonly volumeControl = queryRequiredElement(
+ '.volume-control',
+ HTMLLabelElement
+ );
+ private readonly volumeSlider = queryRequiredElement(
+ '.volume-slider',
+ HTMLInputElement
+ );
+
+ private audioVolume = readInitialAudioVolume();
+ private isMutedState =
+ readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
+ this.audioVolume <= 0;
+
+ public constructor(private readonly options: AudioControlOptions) {
+ this.soundButton.addEventListener('click', this.onToggleMute);
+ this.volumeSlider.addEventListener('input', this.onVolumeInput);
+
+ const passiveCaptureOptions = { capture: true, passive: true } as const;
+ const captureOptions = { capture: true } as const;
+ (
+ [
+ ['touchstart', passiveCaptureOptions],
+ ['pointerdown', passiveCaptureOptions],
+ ['touchend', passiveCaptureOptions],
+ ['pointerup', passiveCaptureOptions],
+ ['click', captureOptions],
+ ['keydown', captureOptions],
+ ] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]>
+ ).forEach(([event, opts]) => {
+ window.addEventListener(event, this.onUserGesture, opts);
+ });
+
+ this.render();
+ }
+
+ public get isMuted(): boolean {
+ return this.isMutedState || this.audioVolume <= 0;
+ }
+
+ public render(): void {
+ this.audioVolume = clampAudioVolume(this.audioVolume);
+ const isEffectivelyMuted = this.isMuted;
+ const volumePercent = Math.round(this.audioVolume * 100);
+
+ this.soundButton.classList.toggle('muted', isEffectivelyMuted);
+ this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
+ const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
+ this.soundButton.setAttribute('aria-label', muteLabel);
+ this.soundButton.title = muteLabel;
+
+ this.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
+ this.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
+ this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
+ this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
+ this.volumeSlider.setAttribute(
+ 'aria-valuetext',
+ isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
+ );
+ this.volumeControl.classList.toggle('muted', isEffectivelyMuted);
+ this.volumeControl.title = isEffectivelyMuted
+ ? `Muted, ${volumePercent}% volume`
+ : `${volumePercent}% volume`;
+ this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
+
+ const game = this.options.getGame();
+ game?.setAudioVolume(this.audioVolume);
+ game?.setAudioMuted(isEffectivelyMuted);
+ }
+
+ private readonly onToggleMute = () => {
+ const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
+ if (shouldUnmute && this.audioVolume <= 0) {
+ this.audioVolume = DEFAULT_AUDIO_VOLUME;
+ }
+ this.isMutedState = !shouldUnmute;
+ this.persist();
+ this.render();
+ if (!this.isMutedState) {
+ this.options.getGame()?.startAudio(true);
+ }
+ };
+
+ private readonly onVolumeInput = () => {
+ this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value));
+ this.isMutedState = this.audioVolume <= 0;
+ this.persist();
+ this.render();
+ if (!this.isMutedState) {
+ this.options.getGame()?.startAudio(true);
+ }
+ };
+
+ private readonly onUserGesture = (event: Event) => {
+ if (
+ !this.options.hasStarted() ||
+ this.isMutedState ||
+ (event.target instanceof Node &&
+ this.options.startButton.contains(event.target)) ||
+ (event.target instanceof Node && this.soundButton.contains(event.target))
+ ) {
+ return;
+ }
+ this.options.getGame()?.startAudio(true);
+ };
+
+ private persist(): void {
+ writeBrowserStorage(
+ APP_STORAGE_KEYS.audioMuted,
+ this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
+ );
+ writeBrowserStorage(
+ APP_STORAGE_KEYS.audioVolume,
+ formatStoredAudioVolume(this.audioVolume)
+ );
+ }
+}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index b4917a8..ba3f38c 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -28,11 +28,11 @@ interface PaneState extends GardenAudioVibeSettings {
color3: string;
}
-const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
+const COLOR_REACTION_LABELS = ['Primary', 'Secondary', 'Accent'] as const;
const COLOR_REACTION_STATES = [
- { id: 'follow', label: 'Follow', value: 1 },
+ { id: 'follow', label: 'Move Toward', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 },
- { id: 'avoid', label: 'Avoid', value: -1 },
+ { id: 'avoid', label: 'Move Away', value: -1 },
] as const;
const colorReactionRows = [
@@ -56,6 +56,7 @@ const colorReactionRows = [
const brushControlKeys = [
'brushSize',
'spawnPerPixel',
+ 'strokeAngleJitterRadians',
] satisfies Array;
const agentControlKeys = [
@@ -85,16 +86,17 @@ const MUSIC_CONTROLS: ReadonlyArray<{
max: number;
step: number;
}> = [
- { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 },
- { key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 },
- { key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
- { key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
- { key: 'noteLength', label: 'note length', min: 0.1, max: 1.8, step: 0.01 },
- { key: 'notePitchOffset', label: 'higher / lower notes', min: -12, max: 12, step: 1 },
- { key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 },
+ { key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 },
+ { key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 },
+ { key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 },
+ { key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 },
+ { key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 },
+ { key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 },
+ { key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 },
];
interface ConfigPaneOptions {
+ maxSupportedAgentCount: number;
onConfigChange: () => void;
onRuntimeChange: () => void;
settingsButton: HTMLButtonElement;
@@ -152,6 +154,7 @@ const getNextColorReactionState = (
export class ConfigPane {
private readonly container: HTMLDivElement;
+ private readonly closeButton: HTMLButtonElement;
private readonly pane: Pane;
private readonly colorReactionButtons = new Map<
ColorReactionKey,
@@ -176,17 +179,15 @@ export class ConfigPane {
public constructor(private readonly options: ConfigPaneOptions) {
this.container = document.createElement('div');
this.container.className = 'config-pane-container';
- Object.assign(this.container.style, {
- boxSizing: 'border-box',
- maxHeight: 'calc(100vh - 24px)',
- pointerEvents: 'none',
- position: 'fixed',
- right: 'max(12px, env(safe-area-inset-right, 0px))',
- top: 'max(12px, env(safe-area-inset-top, 0px))',
- width:
- 'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
- zIndex: '20',
- });
+
+ this.closeButton = document.createElement('button');
+ this.closeButton.className = 'config-pane-close';
+ this.closeButton.type = 'button';
+ this.closeButton.setAttribute('aria-label', 'Hide config overlay');
+ this.closeButton.title = 'Hide config overlay';
+ this.closeButton.addEventListener('click', () => this.setHidden(true));
+ this.container.appendChild(this.closeButton);
+
document.body.appendChild(this.container);
this.pane = new Pane({
@@ -196,13 +197,14 @@ export class ConfigPane {
});
this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.element.classList.add('config-pane');
- this.pane.element.style.boxSizing = 'border-box';
- this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
- this.pane.element.style.overflowY = 'auto';
- this.pane.element.style.pointerEvents = 'auto';
- this.pane.element.style.width = '100%';
+ this.pane.element.id = 'config-pane';
+ this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id);
this.options.settingsButton.addEventListener('click', this.toggle);
+ document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, {
+ passive: true,
+ });
+ document.addEventListener('keydown', this.dismissOnEscape);
this.setUpTuningPane(this.pane);
this.syncOpenState();
@@ -228,6 +230,27 @@ export class ConfigPane {
this.syncOpenState();
};
+ private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
+ if (!this.isOpen || !(event.target instanceof Node)) {
+ return;
+ }
+
+ if (
+ this.container.contains(event.target) ||
+ this.options.settingsButton.contains(event.target)
+ ) {
+ return;
+ }
+
+ this.setHidden(true);
+ };
+
+ private readonly dismissOnEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && this.isOpen) {
+ this.setHidden(true);
+ }
+ };
+
private setHidden(isHidden: boolean): void {
this.pane.hidden = isHidden;
this.syncOpenState();
@@ -252,26 +275,26 @@ export class ConfigPane {
private setUpVibeSection(container: PaneContainer): void {
const folder = container.addFolder({
- title: 'Vibe',
+ title: 'Colors',
expanded: true,
});
- this.addColorBinding(folder, 'color1', 'colour 1', (color) => {
+ this.addColorBinding(folder, 'color1', 'Primary Color', (color) => {
activeVibe.colors[0] = color;
});
- this.addColorBinding(folder, 'color2', 'colour 2', (color) => {
+ this.addColorBinding(folder, 'color2', 'Secondary Color', (color) => {
activeVibe.colors[1] = color;
});
- this.addColorBinding(folder, 'color3', 'colour 3', (color) => {
+ this.addColorBinding(folder, 'color3', 'Accent Color', (color) => {
activeVibe.colors[2] = color;
});
- this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => {
+ this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
activeVibe.backgroundColor = color;
});
if (import.meta.env.DEV) {
folder
- .addButton({ title: 'Copy vibe preset' })
+ .addButton({ title: 'Copy Vibe Preset' })
.on('click', () => void this.copyVibePresetToClipboard());
}
}
@@ -313,7 +336,7 @@ export class ConfigPane {
}
private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
- const config = appConfig.runtimeSettings.controls[key];
+ const config = this.getRuntimeControlConfig(key);
if (!config) {
return;
}
@@ -332,71 +355,75 @@ export class ConfigPane {
});
}
+ private getRuntimeControlConfig(
+ key: RuntimeControlKey
+ ): NumberControlConfig | undefined {
+ const config = appConfig.runtimeSettings.controls[key];
+ if (!config || key !== 'maxAgentCount') {
+ return config;
+ }
+
+ return {
+ ...config,
+ max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)),
+ };
+ }
+
private addFpsOverlayBinding(container: PaneContainer): void {
container
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
- label: 'FPS overlay',
+ label: 'Show FPS',
})
.on('change', () => this.options.onConfigChange());
}
private addColorReactionMatrix(container: PaneContainer): void {
const folder = container.addFolder({
- title: 'Follow / Ignore / Avoid',
+ title: 'Color Behavior',
expanded: true,
});
folder.element.classList.add('color-reaction-folder');
- const content = Array.from(folder.element.children).find((child) =>
- child.classList.contains('tp-fldv_c')
- );
- if (!(content instanceof HTMLElement)) {
- return;
- }
-
- const doc = folder.element.ownerDocument;
- const matrix = doc.createElement('div');
+ const matrix = document.createElement('div');
matrix.className = 'color-reaction-matrix';
- matrix.appendChild(this.createColorReactionCorner(doc));
+ matrix.appendChild(this.createColorReactionCorner());
colorReactionRows.forEach((row) => {
- matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+ matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
});
colorReactionRows.forEach((row) => {
- matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+ matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
row.keys.forEach((key, columnIndex) => {
matrix.appendChild(
- this.createColorReactionCell(doc, key, row.colorIndex, columnIndex)
+ this.createColorReactionCell(key, row.colorIndex, columnIndex)
);
});
});
- content.appendChild(matrix);
+ const matrixBlade = folder.addBlade({ view: 'separator' });
+ matrixBlade.element.classList.add('color-reaction-matrix-blade');
+ matrixBlade.element.replaceChildren(matrix);
this.syncColorReactionMatrix();
}
- private createColorReactionCorner(doc: Document): HTMLDivElement {
- const corner = doc.createElement('div');
+ private createColorReactionCorner(): HTMLDivElement {
+ const corner = document.createElement('div');
corner.className = 'color-reaction-matrix__corner';
- corner.textContent = 'agent';
+ corner.textContent = 'agents';
return corner;
}
- private createColorReactionHeader(
- doc: Document,
- colorIndex: number,
- label: string
- ): HTMLDivElement {
- const header = doc.createElement('div');
+ private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
+ const header = document.createElement('div');
header.className = 'color-reaction-matrix__header';
- const swatch = doc.createElement('span');
+ const swatch = document.createElement('span');
swatch.className = 'color-reaction-matrix__swatch';
this.colorReactionSwatches.push({ colorIndex, element: swatch });
header.appendChild(swatch);
- const text = doc.createElement('span');
+ const text = document.createElement('span');
text.textContent = label;
header.appendChild(text);
@@ -404,12 +431,11 @@ export class ConfigPane {
}
private createColorReactionCell(
- doc: Document,
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): HTMLDivElement {
- const cell = doc.createElement('div');
+ const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key];
@@ -417,11 +443,11 @@ export class ConfigPane {
return cell;
}
- const button = doc.createElement('button');
+ const button = document.createElement('button');
button.className = 'color-reaction-matrix__button';
button.type = 'button';
- const icon = doc.createElement('span');
+ const icon = document.createElement('span');
icon.className = 'color-reaction-matrix__icon';
button.appendChild(icon);
@@ -470,15 +496,15 @@ export class ConfigPane {
const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]);
- const sourceLabel = sourceColorIndex + 1;
- const targetLabel = targetColorIndex + 1;
+ const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
+ const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
button.dataset.reaction = state.id;
button.setAttribute(
'aria-label',
- `Color ${sourceLabel} agents ${state.label.toLowerCase()} color ${targetLabel}; click to switch to ${nextState.label.toLowerCase()}`
+ `${sourceLabel} agents ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
);
- button.title = state.label;
+ button.title = `${sourceLabel} agents: ${state.label} ${targetLabel} trails`;
}
private setUpMusicSection(container: PaneContainer): void {
@@ -541,5 +567,7 @@ export class ConfigPane {
settingsButton.setAttribute('aria-expanded', String(this.isOpen));
settingsButton.setAttribute('aria-label', label);
settingsButton.title = label;
+ this.container.classList.toggle('config-pane-container--open', this.isOpen);
+ this.closeButton.hidden = !this.isOpen;
}
}
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
new file mode 100644
index 0000000..4b4bb5d
--- /dev/null
+++ b/src/page/eraser-size-control.ts
@@ -0,0 +1,68 @@
+import type GameLoop from '../game-loop/game-loop';
+import { settings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+
+const ERASER_CONTROL_SCALE_MAX = 1.33;
+const ERASER_CONTROL_SCALE_MIN = 0.75;
+const ERASER_SIZE_DEFAULT = 96;
+const ERASER_SIZE_MAX = 240;
+const ERASER_SIZE_MIN = 24;
+const ERASER_SIZE_STEP = 1;
+
+const clampEraserSize = (value: number): number => {
+ const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
+ return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
+};
+
+const getEraserSizeRatio = (size: number): number =>
+ (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
+
+interface EraserSizeControlOptions {
+ getGame: () => GameLoop | null;
+ onActivate: () => void;
+ onChange: () => void;
+}
+
+export class EraserSizeControl {
+ private readonly control = queryRequiredElement(
+ '.eraser-size-control',
+ HTMLLabelElement
+ );
+ private readonly slider = queryRequiredElement(
+ '.eraser-size-slider',
+ HTMLInputElement
+ );
+
+ public constructor(private readonly options: EraserSizeControlOptions) {
+ this.control.addEventListener('pointerdown', this.options.onActivate);
+ this.control.addEventListener('click', this.options.onActivate);
+ this.slider.addEventListener('focus', this.options.onActivate);
+ this.slider.addEventListener('input', () => {
+ settings.eraserSize = clampEraserSize(Number(this.slider.value));
+ this.options.onActivate();
+ this.render();
+ this.options.onChange();
+ });
+ }
+
+ public render(): void {
+ const size = clampEraserSize(settings.eraserSize);
+ if (settings.eraserSize !== size) {
+ settings.eraserSize = size;
+ }
+
+ this.slider.min = ERASER_SIZE_MIN.toString();
+ this.slider.max = ERASER_SIZE_MAX.toString();
+ this.slider.step = ERASER_SIZE_STEP.toString();
+ this.slider.value = size.toString();
+ this.slider.setAttribute('aria-valuetext', `${size}px`);
+
+ const ratio = getEraserSizeRatio(size);
+ const scale =
+ ERASER_CONTROL_SCALE_MIN +
+ (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
+ this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
+ this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
+ this.options.getGame()?.updateEraserPreview();
+ }
+}
diff --git a/src/page/error-presenter.ts b/src/page/error-presenter.ts
new file mode 100644
index 0000000..835c902
--- /dev/null
+++ b/src/page/error-presenter.ts
@@ -0,0 +1,62 @@
+import {
+ ErrorHandler,
+ getErrorMessage,
+ RuntimeError,
+ Severity,
+} from '../utils/error-handler';
+
+type RuntimeUiError = Parameters<
+ Parameters[0]
+>[0];
+
+const ERROR_CONTAINER_SELECTOR = '.errors-container';
+const ERROR_CONTAINER_CLASS = 'errors-container';
+
+const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => {
+ const message = document.createElement('pre');
+ message.className = error.severity;
+ message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
+ message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
+ message.setAttribute(
+ 'aria-live',
+ error.severity === Severity.ERROR ? 'assertive' : 'polite'
+ );
+ container.append(message);
+
+ if (error.severity === Severity.ERROR) {
+ message.tabIndex = -1;
+ message.focus({ preventScroll: true });
+ }
+};
+
+const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
+ severity: Severity.ERROR,
+ message: getErrorMessage(exception),
+ ...(exception instanceof RuntimeError ? { code: exception.code } : {}),
+});
+
+export class ErrorPresenter {
+ public constructor(private readonly container: HTMLElement) {
+ container.setAttribute('aria-live', 'assertive');
+ }
+
+ public render(error: RuntimeUiError): void {
+ renderRuntimeMessage(this.container, error);
+ }
+
+ public static renderStartup(exception: unknown): void {
+ const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR);
+ const container =
+ existingContainer instanceof HTMLElement
+ ? existingContainer
+ : document.createElement('div');
+
+ if (!(existingContainer instanceof HTMLElement)) {
+ container.className = ERROR_CONTAINER_CLASS;
+ document.body.append(container);
+ }
+
+ container.setAttribute('aria-live', 'assertive');
+ renderRuntimeMessage(container, getRuntimeUiError(exception));
+ }
+}
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
new file mode 100644
index 0000000..c484571
--- /dev/null
+++ b/src/page/mirror-segment-control.ts
@@ -0,0 +1,68 @@
+import { settings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+
+const MIRROR_SEGMENT_DEFAULT = 1;
+const MIRROR_SEGMENT_MAX = 12;
+const MIRROR_SEGMENT_MIN = 1;
+const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
+const MIRROR_SEGMENT_STEP = 1;
+const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
+
+const clampMirrorSegmentCount = (value: number): number => {
+ const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
+ return Math.min(
+ MIRROR_SEGMENT_MAX,
+ Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
+ );
+};
+
+const getMirrorSegmentRatio = (count: number): number =>
+ (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
+
+const formatMirrorSegmentCount = (count: number): string =>
+ count === MIRROR_SEGMENT_DEFAULT
+ ? MIRROR_SEGMENT_OFF_LABEL
+ : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
+
+interface MirrorSegmentControlOptions {
+ onChange: () => void;
+}
+
+export class MirrorSegmentControl {
+ private readonly control = queryRequiredElement(
+ '.mirror-segment-control',
+ HTMLLabelElement
+ );
+ private readonly slider = queryRequiredElement(
+ '.mirror-segment-slider',
+ HTMLInputElement
+ );
+
+ public constructor(private readonly options: MirrorSegmentControlOptions) {
+ this.slider.addEventListener('input', () => {
+ settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value));
+ this.render();
+ this.options.onChange();
+ });
+ }
+
+ public render(): void {
+ const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
+ if (settings.mirrorSegmentCount !== count) {
+ settings.mirrorSegmentCount = count;
+ }
+
+ this.slider.min = MIRROR_SEGMENT_MIN.toString();
+ this.slider.max = MIRROR_SEGMENT_MAX.toString();
+ this.slider.step = MIRROR_SEGMENT_STEP.toString();
+ this.slider.value = count.toString();
+
+ const label = formatMirrorSegmentCount(count);
+ const ratio = getMirrorSegmentRatio(count);
+ this.slider.setAttribute('aria-valuetext', label);
+ this.control.title = label;
+ this.control.classList.toggle('active', count > 1);
+ this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`);
+ this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`);
+ }
+}
diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts
new file mode 100644
index 0000000..316b11d
--- /dev/null
+++ b/src/page/palette-control.ts
@@ -0,0 +1,54 @@
+import type GameLoop from '../game-loop/game-loop';
+import { activeVibe, settings } from '../settings';
+import { queryRequiredElement, queryRequiredElements } from '../utils/dom';
+import { rgbColorToCss } from '../utils/rgb-color';
+
+interface PaletteControlOptions {
+ getGame: () => GameLoop | null;
+ onChange: () => void;
+}
+
+export class PaletteControl {
+ private readonly swatches = queryRequiredElements('.color-swatch', HTMLButtonElement);
+ private readonly eraserControl = queryRequiredElement(
+ '.eraser-size-control',
+ HTMLLabelElement
+ );
+ private isEraserActiveState = false;
+
+ public constructor(private readonly options: PaletteControlOptions) {
+ this.swatches.forEach((swatch, index) => {
+ swatch.addEventListener('click', () => {
+ settings.selectedColorIndex = index;
+ this.isEraserActiveState = false;
+ this.render();
+ this.options.onChange();
+ });
+ });
+ }
+
+ public get isEraserActive(): boolean {
+ return this.isEraserActiveState;
+ }
+
+ public setEraserActive(active: boolean): void {
+ this.isEraserActiveState = active;
+ this.render();
+ }
+
+ public render(): void {
+ this.swatches.forEach((swatch, index) => {
+ swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
+ swatch.classList.toggle(
+ 'active',
+ settings.selectedColorIndex === index && !this.isEraserActiveState
+ );
+ });
+ this.eraserControl.classList.toggle('active', this.isEraserActiveState);
+ this.options.getGame()?.setEraseMode(this.isEraserActiveState);
+ document.documentElement.style.setProperty(
+ '--garden-background',
+ rgbColorToCss(activeVibe.backgroundColor)
+ );
+ }
+}
diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts
new file mode 100644
index 0000000..fb25225
--- /dev/null
+++ b/src/page/splash-screen.ts
@@ -0,0 +1,47 @@
+import { queryRequiredElement } from '../utils/dom';
+import { clamp01 } from '../utils/math';
+
+export class SplashScreen {
+ public readonly startButton = queryRequiredElement(
+ '.start-button',
+ HTMLButtonElement
+ );
+ private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
+ private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
+ private readonly loadingStatus = queryRequiredElement(
+ '.loading-status',
+ HTMLDivElement
+ );
+ private readonly loadingProgress = queryRequiredElement(
+ '.loading-progress',
+ HTMLDivElement
+ );
+
+ public setLoadingStage(label: string, ratio: number): void {
+ const percent = Math.round(clamp01(ratio) * 100);
+ this.loadingStatus.textContent = label;
+ this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
+ this.loadingProgress.setAttribute('aria-valuenow', String(percent));
+ }
+
+ public awaitStart(onStart: () => void): Promise {
+ this.startButton.disabled = false;
+ return new Promise((resolve) => {
+ const onClick = () => {
+ this.startButton.removeEventListener('click', onClick);
+ onStart();
+ this.splash.hidden = true;
+ resolve();
+ };
+ this.startButton.addEventListener('click', onClick);
+ });
+ }
+
+ public showLoadingBar(): void {
+ this.loadingBar.hidden = false;
+ }
+
+ public hideLoadingBar(): void {
+ this.loadingBar.hidden = true;
+ }
+}
diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts
new file mode 100644
index 0000000..2e37feb
--- /dev/null
+++ b/src/page/vibe-navigator.ts
@@ -0,0 +1,40 @@
+import { activeVibe, applyVibeSettings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+import { VIBE_PRESETS, type VibeId } from '../vibes';
+
+interface VibeSelection {
+ source: string;
+ vibeId: VibeId;
+ vibeName: string;
+}
+
+interface VibeNavigatorOptions {
+ onChange: (selection: VibeSelection) => void;
+}
+
+export class VibeNavigator {
+ private readonly previousButton = queryRequiredElement(
+ '.previous-vibe',
+ HTMLButtonElement
+ );
+ private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
+
+ public constructor(private readonly options: VibeNavigatorOptions) {
+ this.previousButton.addEventListener('click', () =>
+ this.select(-1, 'previous-button')
+ );
+ this.nextButton.addEventListener('click', () => this.select(1, 'next-button'));
+ }
+
+ private select(offset: number, source: string): void {
+ const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
+ const vibe =
+ VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
+ const activePreset = applyVibeSettings(vibe);
+ this.options.onChange({
+ vibeId: activePreset.id,
+ vibeName: activePreset.name,
+ source,
+ });
+ }
+}
diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts
index 3fd4477..bce5e18 100644
--- a/src/pipelines/agents/agent-dispatch.ts
+++ b/src/pipelines/agents/agent-dispatch.ts
@@ -1,5 +1,4 @@
-const AGENT_WORKGROUP_SIZE = 64;
-export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE;
+export const AGENT_WORKGROUP_SIZE = 64;
export const dispatchAgentWorkgroups = (
passEncoder: GPUComputePassEncoder,
diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
index d76015f..a4022e7 100644
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
+++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
@@ -2,13 +2,13 @@ import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
import { smartCompile } from '../../../utils/graphics/smart-compile';
-import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
+import { dispatchAgentWorkgroups } from '../agent-dispatch';
+import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
import compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
-export const AGENT_FLOAT_COUNT = 8;
-const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
+export { AGENT_FLOAT_COUNT } from '../agent-limits';
export class AgentGenerationPipeline {
private static readonly UNIFORM_COUNT = 4;
@@ -224,15 +224,7 @@ export class AgentGenerationPipeline {
? Math.floor(value)
: 0;
return Math.min(
- Number.isFinite(this.maxAgentCountUpperLimit)
- ? this.maxAgentCountUpperLimit
- : Number.POSITIVE_INFINITY,
- Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
- Math.floor(
- ((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ??
- this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES
- ),
- AGENT_MAX_DISPATCHABLE_COUNT,
+ getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit),
Math.max(0, requestedMaxAgentCount)
);
}
diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts
new file mode 100644
index 0000000..560ad06
--- /dev/null
+++ b/src/pipelines/agents/agent-limits.ts
@@ -0,0 +1,25 @@
+import { AGENT_WORKGROUP_SIZE } from './agent-dispatch';
+
+export const AGENT_FLOAT_COUNT = 8;
+export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
+
+export const getMaxSupportedAgentCount = (
+ device: GPUDevice,
+ maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
+): number => {
+ const storageBufferBindingSize =
+ device.limits.maxStorageBufferBindingSize ?? device.limits.maxBufferSize;
+ const upperLimit = Number.isFinite(maxAgentCountUpperLimit)
+ ? Math.floor(maxAgentCountUpperLimit)
+ : Number.POSITIVE_INFINITY;
+
+ return Math.max(
+ 0,
+ Math.min(
+ upperLimit,
+ Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
+ Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
+ Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * AGENT_WORKGROUP_SIZE
+ )
+ );
+};
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index 330fafd..7e67b53 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -1,3 +1,4 @@
+import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@@ -38,43 +39,91 @@ export interface AgentSettings {
randomTimeScale: number;
}
-export class AgentPipeline {
- private static readonly UNIFORM_COUNT = 30;
+const UNIFORM_COUNT = 30;
+export class AgentPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
+ private readonly normalPipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
- private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
+ private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
- private readonly uniformCache = createCachedFloat32BufferWrite(
- AgentPipeline.UNIFORM_COUNT
- );
- private readonly bindGroupsByAgentsBuffer = new WeakMap<
+ private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
+ private readonly bindGroupCache = createBindGroupCache3<
GPUBuffer,
- WeakMap>
- >();
+ GPUTextureView,
+ GPUTextureView
+ >((agentsBuffer, trailMapIn, trailMapOut) =>
+ this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ { binding: 0, resource: { buffer: this.uniforms } },
+ { binding: 1, resource: { buffer: agentsBuffer } },
+ { binding: 2, resource: trailMapIn },
+ { binding: 3, resource: trailMapOut },
+ ],
+ })
+ );
private agentCount = 0;
+ private useIntroPipeline = true;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
- private readonly getAgentsBuffer: () => GPUBuffer // doesn't get destroyed
+ private readonly getAgentsBuffer: () => GPUBuffer
) {
- this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout);
+ this.bindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: { type: 'uniform' },
+ },
+ {
+ binding: 1,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: { type: 'storage' },
+ },
+ {
+ binding: 2,
+ visibility: GPUShaderStage.COMPUTE,
+ texture: { sampleType: 'float' },
+ },
+ {
+ binding: 3,
+ visibility: GPUShaderStage.COMPUTE,
+ storageTexture: { format: 'rgba16float' },
+ },
+ ],
+ });
+ const shaderModule = smartCompile(
+ device,
+ CommonState.shaderCode,
+ agentSchema,
+ shader
+ );
+ const pipelineLayout = device.createPipelineLayout({
+ bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+ });
this.pipeline = device.createComputePipeline({
- layout: device.createPipelineLayout({
- bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
- }),
+ layout: pipelineLayout,
compute: {
- module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
+ module: shaderModule,
entryPoint: 'main',
},
});
+ this.normalPipeline = device.createComputePipeline({
+ layout: pipelineLayout,
+ compute: {
+ module: shaderModule,
+ entryPoint: 'mainNormal',
+ },
+ });
- this.uniforms = this.device.createBuffer({
- size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+ this.uniforms = device.createBuffer({
+ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
@@ -118,6 +167,7 @@ export class AgentPipeline {
introProgress?: number;
}) {
this.agentCount = agentCount;
+ this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff;
this.uniformValues[0] = moveSpeed * deltaTime;
this.uniformValues[1] = turnSpeed * deltaTime;
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
@@ -160,110 +210,27 @@ export class AgentPipeline {
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
- trailMapOut: GPUTextureView
+ trailMapOut: GPUTextureView,
+ timestampWrites?: GPUComputePassTimestampWrites
) {
if (this.agentCount <= 0) {
return;
}
- const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
-
- const passEncoder = commandEncoder.beginComputePass();
- passEncoder.setPipeline(this.pipeline);
+ const passEncoder = commandEncoder.beginComputePass(
+ timestampWrites ? { timestampWrites } : undefined
+ );
+ passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline);
this.commonState.execute(passEncoder);
- passEncoder.setBindGroup(1, bindGroup);
+ passEncoder.setBindGroup(
+ 1,
+ this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
+ );
dispatchAgentWorkgroups(passEncoder, this.agentCount);
passEncoder.end();
}
- private getBindGroup(
- trailMapIn: GPUTextureView,
- trailMapOut: GPUTextureView
- ): GPUBindGroup {
- const agentsBuffer = this.getAgentsBuffer();
- let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
- if (!textureCache) {
- textureCache = new WeakMap>();
- this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
- }
-
- let outputCache = textureCache.get(trailMapIn);
- if (!outputCache) {
- outputCache = new WeakMap();
- textureCache.set(trailMapIn, outputCache);
- }
-
- const cached = outputCache.get(trailMapOut);
- if (cached) {
- return cached;
- }
-
- const bindGroup = this.device.createBindGroup({
- layout: this.bindGroupLayout,
- entries: [
- {
- binding: 0,
- resource: {
- buffer: this.uniforms,
- },
- },
- {
- binding: 1,
- resource: {
- buffer: agentsBuffer,
- },
- },
- {
- binding: 2,
- resource: trailMapIn,
- },
- {
- binding: 3,
- resource: trailMapOut,
- },
- ],
- });
-
- outputCache.set(trailMapOut, bindGroup);
- return bindGroup;
- }
-
public destroy() {
this.uniforms.destroy();
}
-
- private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
- return {
- entries: [
- {
- binding: 0,
- visibility: GPUShaderStage.COMPUTE,
- buffer: {
- type: 'uniform',
- },
- },
- {
- binding: 1,
- visibility: GPUShaderStage.COMPUTE,
- buffer: {
- type: 'storage',
- },
- },
- {
- binding: 2,
- visibility: GPUShaderStage.COMPUTE,
- texture: {
- sampleType: 'float',
- },
- },
- {
- binding: 3,
- visibility: GPUShaderStage.COMPUTE,
- storageTexture: {
- format: 'rgba16float',
- },
- },
- ],
- };
- }
}
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index 025fe39..31b2081 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -1,3 +1,5 @@
+const PI: f32 = 3.14159265359;
+
struct Settings {
moveRate: f32,
turnRate: f32,
@@ -142,7 +144,80 @@ fn main(
let nextPosition = clamp(position + step, vec2(0, 0), maxPosition);
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
- rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
+ rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
+ }
+
+ var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
+ trailBelow = vec4(
+ trailBelow.rgb + channelMask * settings.individualTrailWeight,
+ max(trailBelow.a, 0.0)
+ );
+
+ textureStore(trailMapOut, vec2(nextPosition), trailBelow);
+ agents[id].angle = angle + rotation;
+ agents[id].position = nextPosition;
+}
+
+@compute @workgroup_size(64)
+fn mainNormal(
+ @builtin(global_invocation_id) global_id: vec3
+) {
+ let id = get_id(global_id);
+
+ if id >= settings.agentCount {
+ return;
+ }
+
+ let colorIndex = agents[id].colorIndex;
+ if colorIndex < 0.0 || colorIndex >= 2.5 {
+ return;
+ }
+
+ var position = agents[id].position;
+ var angle = agents[id].angle;
+ let channelMask = get_channel_mask(colorIndex);
+ let reactionMask = get_reaction_mask(colorIndex);
+ let randomSeed = random_seed(id);
+ let maxPosition = state.size - vec2(1.0, 1.0);
+ let randomTurn = random_float(randomSeed);
+ let direction = vec2(cos(angle), sin(angle));
+
+ let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
+ let leftSensor = sensor_position(
+ position,
+ rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
+ settings.sensorOffset,
+ maxPosition
+ );
+ let rightSensor = sensor_position(
+ position,
+ rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
+ settings.sensorOffset,
+ maxPosition
+ );
+
+ 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);
+
+ var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
+ if weightForward >= weightLeft && weightForward >= weightRight {
+ rotation = rotation * settings.forwardRotationScale;
+ } else {
+ rotation += sign(weightLeft - weightRight) * settings.turnRate;
+ }
+
+ let nextPosition = clamp(
+ position + direction * settings.moveRate,
+ vec2(0, 0),
+ maxPosition
+ );
+ if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
+ rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
}
var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index d57b5a2..5192bce 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -7,6 +7,12 @@ import {
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
+import {
+ LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
+ LINE_SEGMENT_VERTICES,
+ LineSegmentBuffer,
+} from '../common/line-segment-buffer';
+import lineSegmentShader from '../common/line-segment.wgsl?raw';
import shader from './brush.wgsl?raw';
export interface BrushSettings {
@@ -20,12 +26,7 @@ export interface BrushSettings {
brushGrainMaxStrength: number;
}
-interface LineSegment {
- from: vec2;
- to: vec2;
-}
-
-interface BrushParameterSettings extends BrushSettings {
+interface BrushParameters extends BrushSettings {
pixelRatio?: number;
selectedColorIndex: number;
}
@@ -35,6 +36,8 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
? pixelRatio
: 1;
+const UNIFORM_COUNT = 16;
+
const setBrushUniformValues = (
target: Float32Array,
{
@@ -48,15 +51,14 @@ const setBrushUniformValues = (
brushGrainMaxStrength,
selectedColorIndex,
pixelRatio,
- }: BrushParameterSettings
+ }: BrushParameters
): void => {
const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2;
target[0] = brushRadius;
target[1] = brushRadius * brushRadius;
- target[2] = 0;
- target[3] = 0;
+ // target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader.
target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
@@ -70,78 +72,81 @@ const setBrushUniformValues = (
};
export class BrushPipeline {
- private static readonly UNIFORM_COUNT = 16;
- private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
- private static readonly VERTICES_PER_LINE_SEGMENT = 6;
- private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4;
-
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
- private readonly multiTargetPipeline: GPURenderPipeline;
+ private readonly renderPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
- private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
- private readonly uniformCache = createCachedFloat32BufferWrite(
- BrushPipeline.UNIFORM_COUNT
- );
- private readonly vertexBuffer: GPUBuffer;
- private readonly vertexUploadData = new Float32Array(
- BrushPipeline.MAX_LINE_COUNT *
- BrushPipeline.VERTICES_PER_LINE_SEGMENT *
- BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT
- );
-
- private lineSegments: Array = [];
- private actualSegments: Array = [];
+ private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
+ private readonly segments: LineSegmentBuffer;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout);
+ this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
- this.vertexBuffer = device.createBuffer({
- size:
- BrushPipeline.MAX_LINE_COUNT *
- BrushPipeline.VERTICES_PER_LINE_SEGMENT *
- BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT *
- Float32Array.BYTES_PER_ELEMENT,
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
- });
-
- const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
- this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 1);
-
- this.uniforms = this.device.createBuffer({
- size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
- });
-
- this.bindGroup = this.device.createBindGroup({
- layout: this.bindGroupLayout,
+ this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
- resource: {
- buffer: this.uniforms,
- },
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+ buffer: { type: 'uniform' },
},
],
});
- }
- public addSwipeSegment(from: vec2, to: vec2) {
- this.lineSegments.push({
- from: vec2.clone(from),
- to: vec2.clone(to),
+ const shaderModule = smartCompile(
+ device,
+ CommonState.shaderCode,
+ lineSegmentShader,
+ shader
+ );
+ this.renderPipeline = device.createRenderPipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
+ }),
+ vertex: {
+ module: shaderModule,
+ entryPoint: 'vertex',
+ buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
+ },
+ fragment: {
+ module: shaderModule,
+ entryPoint: 'fragmentMrt',
+ targets: [
+ {
+ format: 'rgba16float',
+ blend: {
+ color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
+ alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
+ },
+ },
+ ],
+ },
+ primitive: { topology: 'triangle-list' },
+ });
+
+ this.uniforms = device.createBuffer({
+ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ this.bindGroup = device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
});
}
- public clearSwipes() {
- this.lineSegments.length = 0;
- this.actualSegments.length = 0;
+ public addSwipeSegment(from: vec2, to: vec2): void {
+ this.segments.add(from, to);
}
- public setParameters(parameters: BrushParameterSettings) {
+ public clearSwipes(): void {
+ this.segments.clear();
+ }
+
+ public setParameters(parameters: BrushParameters): void {
setBrushUniformValues(this.uniformValues, parameters);
writeFloat32BufferIfChanged(
this.device,
@@ -149,188 +154,34 @@ export class BrushPipeline {
this.uniformValues,
this.uniformCache
);
-
- this.actualSegments = this.lineSegments.slice();
- this.lineSegments.length = 0;
-
- if (this.actualSegments.length === 0) {
- return;
- }
-
- if (this.actualSegments.length > BrushPipeline.MAX_LINE_COUNT) {
- this.actualSegments = BrushPipeline.subsampleSegments(this.actualSegments);
- }
-
- const lineCount = this.lineCount;
- let floatOffset = 0;
- for (let i = 0; i < lineCount; i++) {
- const segment = this.actualSegments[i];
- floatOffset = this.writeSegmentVertices(
- this.vertexUploadData,
- floatOffset,
- segment.from,
- segment.to
- );
- }
-
- this.device.queue.writeBuffer(
- this.vertexBuffer,
- 0,
- this.vertexUploadData,
- 0,
- floatOffset
- );
- }
-
- private static subsampleSegments(segments: Array): Array {
- if (segments.length <= BrushPipeline.MAX_LINE_COUNT) {
- return segments;
- }
-
- const result: Array = [];
- for (let i = 0; i < BrushPipeline.MAX_LINE_COUNT; i++) {
- const index = Math.round(
- (i * (segments.length - 1)) / (BrushPipeline.MAX_LINE_COUNT - 1)
- );
- result.push(segments[index]);
- }
-
- return result;
- }
-
- private writeSegmentVertices(
- target: Float32Array,
- offset: number,
- from: vec2,
- to: vec2
- ): number {
- target[offset++] = from[0];
- target[offset++] = from[1];
- target[offset++] = to[0];
- target[offset++] = to[1];
- return offset;
+ this.segments.flush();
}
public executeMultiTarget(
commandEncoder: GPUCommandEncoder,
- sourceMapOut: GPUTextureView
+ sourceMapOut: GPUTextureView,
+ timestampWrites?: GPURenderPassTimestampWrites
): boolean {
- return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
- sourceMapOut,
- ]);
- }
-
- private executeWithPipeline(
- commandEncoder: GPUCommandEncoder,
- pipeline: GPURenderPipeline,
- textureViews: Array
- ): boolean {
- if (this.lineCount === 0) {
+ const lineCount = this.segments.activeCount;
+ if (lineCount === 0) {
return false;
}
- const renderPassDescriptor: GPURenderPassDescriptor = {
- colorAttachments: textureViews.map((view) => ({
- view,
- loadOp: 'load',
- storeOp: 'store',
- })),
- };
-
- const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
- passEncoder.setPipeline(pipeline);
+ const passEncoder = commandEncoder.beginRenderPass({
+ colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
+ timestampWrites,
+ });
+ passEncoder.setPipeline(this.renderPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
- passEncoder.setVertexBuffer(0, this.vertexBuffer);
- passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
+ passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
+ passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end();
return true;
}
- public destroy() {
- this.vertexBuffer.destroy();
+ public destroy(): void {
+ this.segments.destroy();
this.uniforms.destroy();
}
-
- private createPipeline(
- shaderModule: GPUShaderModule,
- fragmentEntryPoint: string,
- colorTargetCount: number
- ): GPURenderPipeline {
- return this.device.createRenderPipeline({
- layout: this.device.createPipelineLayout({
- bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
- }),
- vertex: {
- module: shaderModule,
- entryPoint: 'vertex',
- buffers: [
- {
- arrayStride:
- Float32Array.BYTES_PER_ELEMENT * BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT,
- stepMode: 'instance',
- attributes: [
- {
- shaderLocation: 0,
- format: 'float32x2',
- offset: 0,
- },
- {
- shaderLocation: 1,
- format: 'float32x2',
- offset: Float32Array.BYTES_PER_ELEMENT * 2,
- },
- ],
- },
- ],
- },
- fragment: {
- module: shaderModule,
- entryPoint: fragmentEntryPoint,
- targets: Array.from(
- { length: colorTargetCount },
- () => BrushPipeline.colorTarget
- ),
- },
- primitive: {
- topology: 'triangle-list',
- },
- });
- }
-
- private static get colorTarget(): GPUColorTargetState {
- return {
- format: 'rgba16float',
- blend: {
- color: {
- operation: 'max',
- srcFactor: 'one',
- dstFactor: 'one',
- },
- alpha: {
- operation: 'max',
- srcFactor: 'one',
- dstFactor: 'one',
- },
- },
- };
- }
-
- private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
- return {
- entries: [
- {
- binding: 0,
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
- buffer: {
- type: 'uniform',
- },
- },
- ],
- };
- }
-
- private get lineCount() {
- return this.actualSegments.length;
- }
}
diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl
index 946e8b7..30fb694 100644
--- a/src/pipelines/brush/brush.wgsl
+++ b/src/pipelines/brush/brush.wgsl
@@ -1,3 +1,5 @@
+const SEGMENT_LENGTH_EPSILON: f32 = 0.0001;
+
struct Settings {
brushRadius: f32,
brushRadiusSquared: f32,
@@ -36,7 +38,7 @@ fn vertex(
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
- if denominator > 0.0001 {
+ if denominator > SEGMENT_LENGTH_EPSILON {
inverseLengthSquared = 1.0 / denominator;
}
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius);
@@ -68,7 +70,7 @@ fn brushStrength(
direction: vec2,
inverseLengthSquared: f32
) -> f32 {
- let distanceSquared = distanceSquaredFromLine(
+ let distanceSquared = distance_squared_from_segment(
screenPosition,
start,
direction,
@@ -78,11 +80,15 @@ fn brushStrength(
return 0.0;
}
- let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared);
- if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
+ let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength);
+ if maxGrainStrength < settings.brushDiscardThreshold {
return 0.0;
}
+ if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
+ return settings.brushGrainMinStrength;
+ }
+
let grainNoise = textureSampleLevel(
noise,
noiseSampler,
@@ -90,52 +96,9 @@ fn brushStrength(
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0
).r;
- return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
+ return mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
}
fn brushOutput(strength: f32) -> vec4 {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
-
-fn distanceSquaredFromLine(
- position: vec2,
- start: vec2,
- direction: vec2,
- inverseLengthSquared: f32
-) -> f32 {
- let pa = position - start;
-
- let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
- let nearestOffset = pa - direction * q;
- return dot(nearestOffset, nearestOffset);
-}
-
-fn segment_vertex_position(
- vertexIndex: u32,
- start: vec2,
- end: vec2,
- radius: f32
-) -> vec2 {
- let directionVector = end - start;
- let segmentLength = length(directionVector);
- var direction = vec2(1.0, 0.0);
- if segmentLength > 0.0 {
- direction = directionVector / segmentLength;
- }
- let perpendicular = vec2(direction.y, -direction.x);
- let corner = segment_vertex_corner(vertexIndex % 6u);
- let center = mix(start, end, (corner.x + 1.0) * 0.5);
- return center + direction * corner.x * radius + perpendicular * corner.y * radius;
-}
-
-fn segment_vertex_corner(index: u32) -> vec2 {
- let corners = array, 6>(
- vec2(-1.0, 1.0),
- vec2(-1.0, -1.0),
- vec2(1.0, 1.0),
- vec2(-1.0, -1.0),
- vec2(1.0, 1.0),
- vec2(1.0, -1.0),
- );
- return corners[index];
-}
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 14a710e..97b223b 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -23,7 +23,7 @@ export class CommonState {
public static readonly shaderCode = /* wgsl */ `
struct State {
size: vec2,
- time: f32,
+ _padding: vec2,
};
@group(0) @binding(0) var state: State;
@@ -96,10 +96,9 @@ export class CommonState {
});
}
- public setParameters({ canvasSize, time }: { canvasSize: vec2; time: number }) {
+ public setParameters({ canvasSize }: { canvasSize: vec2 }) {
this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1];
- this.uniformValues[2] = time;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
diff --git a/src/pipelines/common/line-segment-buffer.ts b/src/pipelines/common/line-segment-buffer.ts
new file mode 100644
index 0000000..417e971
--- /dev/null
+++ b/src/pipelines/common/line-segment-buffer.ts
@@ -0,0 +1,92 @@
+import { vec2 } from 'gl-matrix';
+
+export interface LineSegment {
+ from: vec2;
+ to: vec2;
+}
+
+export const LINE_SEGMENT_VERTICES = 6;
+const LINE_SEGMENT_ATTRIBUTES = 4;
+
+export const LINE_SEGMENT_VERTEX_BUFFER_LAYOUT: GPUVertexBufferLayout = {
+ arrayStride: Float32Array.BYTES_PER_ELEMENT * LINE_SEGMENT_ATTRIBUTES,
+ stepMode: 'instance',
+ attributes: [
+ { shaderLocation: 0, format: 'float32x2', offset: 0 },
+ {
+ shaderLocation: 1,
+ format: 'float32x2',
+ offset: Float32Array.BYTES_PER_ELEMENT * 2,
+ },
+ ],
+};
+
+export class LineSegmentBuffer {
+ public readonly vertexBuffer: GPUBuffer;
+
+ private readonly device: GPUDevice;
+ private readonly maxSegments: number;
+ private readonly uploadData: Float32Array;
+
+ private pending: Array = [];
+ private active: Array = [];
+
+ public constructor(device: GPUDevice, maxSegments: number) {
+ this.device = device;
+ this.maxSegments = maxSegments;
+ this.uploadData = new Float32Array(maxSegments * LINE_SEGMENT_ATTRIBUTES);
+ this.vertexBuffer = device.createBuffer({
+ size: this.uploadData.byteLength,
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+ });
+ }
+
+ public add(from: vec2, to: vec2): void {
+ this.pending.push({ from: vec2.clone(from), to: vec2.clone(to) });
+ }
+
+ public clear(): void {
+ this.pending.length = 0;
+ this.active.length = 0;
+ }
+
+ public get activeCount(): number {
+ return this.active.length;
+ }
+
+ public flush(): void {
+ this.active = this.pending.slice();
+ this.pending.length = 0;
+
+ if (this.active.length === 0) {
+ return;
+ }
+
+ if (this.active.length > this.maxSegments) {
+ this.active = subsample(this.active, this.maxSegments);
+ }
+
+ let offset = 0;
+ for (const segment of this.active) {
+ this.uploadData[offset++] = segment.from[0];
+ this.uploadData[offset++] = segment.from[1];
+ this.uploadData[offset++] = segment.to[0];
+ this.uploadData[offset++] = segment.to[1];
+ }
+
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, this.uploadData, 0, offset);
+ }
+
+ public destroy(): void {
+ this.vertexBuffer.destroy();
+ }
+}
+
+const subsample = (segments: Array, count: number): Array => {
+ const result: Array = [];
+ for (let i = 0; i < count; i++) {
+ const index = Math.round((i * (segments.length - 1)) / (count - 1));
+ result.push(segments[index]);
+ }
+ return result;
+};
diff --git a/src/pipelines/common/line-segment.wgsl b/src/pipelines/common/line-segment.wgsl
new file mode 100644
index 0000000..8ac3035
--- /dev/null
+++ b/src/pipelines/common/line-segment.wgsl
@@ -0,0 +1,40 @@
+// Six corners forming two triangles for an instanced segment quad.
+// X spans [-1, 1] along the segment direction, Y spans [-1, 1] perpendicular.
+fn segment_vertex_corner(index: u32) -> vec2 {
+ let isRight = index == 2u || index >= 4u;
+ let isTop = index == 0u || index == 2u || index == 4u;
+ return vec2(
+ select(-1.0, 1.0, isRight),
+ select(-1.0, 1.0, isTop)
+ );
+}
+
+fn segment_vertex_position(
+ vertexIndex: u32,
+ start: vec2,
+ end: vec2,
+ radius: f32
+) -> vec2 {
+ let directionVector = end - start;
+ let segmentLength = length(directionVector);
+ var direction = vec2(1.0, 0.0);
+ if segmentLength > 0.0 {
+ direction = directionVector / segmentLength;
+ }
+ let perpendicular = vec2(direction.y, -direction.x);
+ let corner = segment_vertex_corner(vertexIndex % 6u);
+ let center = mix(start, end, (corner.x + 1.0) * 0.5);
+ return center + direction * corner.x * radius + perpendicular * corner.y * radius;
+}
+
+fn distance_squared_from_segment(
+ position: vec2,
+ start: vec2,
+ direction: vec2,
+ inverseLengthSquared: f32
+) -> f32 {
+ let pa = position - start;
+ 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/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl
index 2ecdf0e..8f6db43 100644
--- a/src/pipelines/diffusion/diffuse.wgsl
+++ b/src/pipelines/diffusion/diffuse.wgsl
@@ -11,9 +11,13 @@ struct Settings {
const WORKGROUP_SIZE_X = 16u;
const WORKGROUP_SIZE_Y = 16u;
+// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass
+// can be served from workgroup memory without bounds checks for interior tiles.
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u;
const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
+// 1.0 / 2^32, used to map a 32-bit hash to [0, 1).
+const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
@group(0) @binding(0) var settings: Settings;
@group(0) @binding(1) var trailMap: texture_2d;
@@ -62,16 +66,8 @@ fn main(
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex];
let random = random_from_pixel(pixel);
- 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
);
current += (
@@ -118,15 +114,13 @@ fn random_from_pixel(pixel: vec2) -> f32 {
hash = (hash ^ (hash >> 16u)) * 2246822519u;
hash = (hash ^ (hash >> 13u)) * 3266489917u;
hash = hash ^ (hash >> 16u);
- return f32(hash) * 2.3283064365386963e-10;
+ return f32(hash) * HASH_TO_UNIT_FLOAT;
}
+// Approximates pow(r, inverseRate) piecewise between powers (r, r^2, r^4, r^8, r^16)
+// so we can vary diffusion sharpness without paying for a real pow() per pixel.
fn diffusion_weight(
r: f32,
- r2: f32,
- r4: f32,
- r8: f32,
- r16: f32,
inverseRate: f32
) -> f32 {
if inverseRate < 1.0 {
@@ -137,19 +131,22 @@ fn diffusion_weight(
clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0)
);
}
-
+ let r2 = r * r;
if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0);
}
-
+ let r4 = r2 * r2;
if inverseRate < 4.0 {
+ // (inverseRate - 2.0) / (4.0 - 2.0)
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
}
-
+ let r8 = r4 * r4;
if inverseRate < 8.0 {
+ // (inverseRate - 4.0) / (8.0 - 4.0)
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
}
-
+ let r16 = r8 * r8;
+ // (inverseRate - 8.0) / (16.0 - 8.0); past 16, falls off as 16/inverseRate.
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 75bdae3..3aeb3a2 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -133,11 +133,14 @@ export class DiffusionPipeline {
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
- size: vec2
+ size: vec2,
+ timestampWrites?: GPUComputePassTimestampWrites
) {
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
- const passEncoder = commandEncoder.beginComputePass();
+ const passEncoder = commandEncoder.beginComputePass(
+ timestampWrites ? { timestampWrites } : undefined
+ );
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups(
diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts
index c1dae6a..1f77a9d 100644
--- a/src/pipelines/eraser/eraser-agent-pipeline.ts
+++ b/src/pipelines/eraser/eraser-agent-pipeline.ts
@@ -10,8 +10,15 @@ import { dispatchAgentWorkgroups } from '../agents/agent-dispatch';
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import shader from './eraser-agent.wgsl?raw';
+interface Bounds {
+ maxX: number;
+ maxY: number;
+ minX: number;
+ minY: number;
+}
+
export class EraserAgentPipeline {
- private static readonly UNIFORM_COUNT = 4;
+ private static readonly UNIFORM_COUNT = 8;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
@@ -35,6 +42,7 @@ export class EraserAgentPipeline {
private pendingSegmentCount = 0;
private activeSegmentCount = 0;
+ private pendingBounds: Bounds | null = null;
private agentCount = 0;
public constructor(
@@ -84,32 +92,42 @@ export class EraserAgentPipeline {
});
}
- public addSwipeSegment(): void {
+ public addSwipeSegment(from: vec2, to: vec2): void {
this.pendingSegmentCount += 1;
+ this.pendingBounds = includeSegment(this.pendingBounds, from, to);
}
public clearSwipes(): void {
this.pendingSegmentCount = 0;
this.activeSegmentCount = 0;
+ this.pendingBounds = null;
}
public setParameters({
agentCount,
eraserMaskAlphaThreshold,
+ eraserSize,
maskSize,
}: {
agentCount: number;
eraserMaskAlphaThreshold: number;
+ eraserSize: number;
maskSize: vec2;
}): void {
this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount;
+ const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize);
this.pendingSegmentCount = 0;
+ this.pendingBounds = null;
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1]));
+ this.uniformValues[4] = activeBounds.minX;
+ this.uniformValues[5] = activeBounds.minY;
+ this.uniformValues[6] = activeBounds.maxX;
+ this.uniformValues[7] = activeBounds.maxY;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@@ -122,12 +140,18 @@ export class EraserAgentPipeline {
return this.activeSegmentCount > 0;
}
- public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void {
+ public execute(
+ commandEncoder: GPUCommandEncoder,
+ eraserMask: GPUTextureView,
+ timestampWrites?: GPUComputePassTimestampWrites
+ ): void {
if (!this.hasActiveMask() || this.agentCount === 0) {
return;
}
- const passEncoder = commandEncoder.beginComputePass();
+ const passEncoder = commandEncoder.beginComputePass(
+ timestampWrites ? { timestampWrites } : undefined
+ );
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.agentCount);
@@ -138,3 +162,37 @@ export class EraserAgentPipeline {
this.uniforms.destroy();
}
}
+
+const includeSegment = (bounds: Bounds | null, from: vec2, to: vec2): Bounds => {
+ const minX = Math.min(from[0], to[0]);
+ const minY = Math.min(from[1], to[1]);
+ const maxX = Math.max(from[0], to[0]);
+ const maxY = Math.max(from[1], to[1]);
+ if (!bounds) {
+ return { maxX, maxY, minX, minY };
+ }
+ return {
+ maxX: Math.max(bounds.maxX, maxX),
+ maxY: Math.max(bounds.maxY, maxY),
+ minX: Math.min(bounds.minX, minX),
+ minY: Math.min(bounds.minY, minY),
+ };
+};
+
+const expandBoundsToMask = (
+ bounds: Bounds | null,
+ radius: number,
+ maskSize: vec2
+): Bounds => {
+ const maxX = Math.max(0, maskSize[0] - 1);
+ const maxY = Math.max(0, maskSize[1] - 1);
+ if (!bounds) {
+ return { maxX, maxY, minX: 0, minY: 0 };
+ }
+ return {
+ maxX: Math.min(maxX, bounds.maxX + radius),
+ maxY: Math.min(maxY, bounds.maxY + radius),
+ minX: Math.max(0, bounds.minX - radius),
+ minY: Math.max(0, bounds.minY - radius),
+ };
+};
diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl
index 63452fe..b1ff6ee 100644
--- a/src/pipelines/eraser/eraser-agent.wgsl
+++ b/src/pipelines/eraser/eraser-agent.wgsl
@@ -3,6 +3,8 @@ struct Settings {
eraserMaskAlphaThreshold: f32,
maskWidth: u32,
maskHeight: u32,
+ boundsMin: vec2,
+ boundsMax: vec2,
};
@group(1) @binding(0) var settings: Settings;
@@ -23,9 +25,18 @@ fn main(
return;
}
+ let position = agents[id].position;
+ let outsideBounds = position.x < settings.boundsMin.x ||
+ position.y < settings.boundsMin.y ||
+ position.x > settings.boundsMax.x ||
+ position.y > settings.boundsMax.y;
+ if outsideBounds {
+ return;
+ }
+
let maskSize = vec2(i32(settings.maskWidth), i32(settings.maskHeight));
let maskPosition = clamp(
- vec2(agents[id].position),
+ vec2(position),
vec2(0, 0),
maskSize - vec2(1, 1)
);
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
index 2f6ac26..b6fd9ff 100644
--- a/src/pipelines/eraser/eraser-texture-pipeline.ts
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -7,97 +7,94 @@ import {
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
+import {
+ LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
+ LINE_SEGMENT_VERTICES,
+ LineSegmentBuffer,
+} from '../common/line-segment-buffer';
+import lineSegmentShader from '../common/line-segment.wgsl?raw';
import shader from './eraser-texture.wgsl?raw';
-interface LineSegment {
- from: vec2;
- to: vec2;
+interface EraserTextureParameters {
+ eraserSize: number;
+ eraserLineDistanceEpsilon: number;
+ eraserClearRed: number;
+ eraserClearGreen: number;
+ eraserClearBlue: number;
+ eraserClearAlpha: number;
}
-export class EraserTexturePipeline {
- private static readonly UNIFORM_COUNT = 8;
- private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
- private static readonly VERTICES_PER_LINE_SEGMENT = 6;
- private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4;
+const UNIFORM_COUNT = 8;
+const TARGET_FORMATS: Array = ['r8unorm', 'rgba16float', 'rgba16float'];
+export class EraserTexturePipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly combinedPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
- private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
- private readonly uniformCache = createCachedFloat32BufferWrite(
- EraserTexturePipeline.UNIFORM_COUNT
- );
- private readonly vertexBuffer: GPUBuffer;
- private readonly vertexUploadData = new Float32Array(
- EraserTexturePipeline.MAX_LINE_COUNT *
- EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
- EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
- );
-
- private lineSegments: Array = [];
- private actualSegments: Array = [];
+ private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
+ private readonly segments: LineSegmentBuffer;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
+ this.segments = new LineSegmentBuffer(
+ device,
+ appConfig.pipelines.eraser.maxTextureLineCount
+ );
+
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
- buffer: {
- type: 'uniform',
- },
+ buffer: { type: 'uniform' },
},
],
});
- this.vertexBuffer = device.createBuffer({
- size:
- EraserTexturePipeline.MAX_LINE_COUNT *
- EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
- EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT *
- Float32Array.BYTES_PER_ELEMENT,
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+ const shaderModule = smartCompile(
+ device,
+ CommonState.shaderCode,
+ lineSegmentShader,
+ shader
+ );
+ this.combinedPipeline = device.createRenderPipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
+ }),
+ vertex: {
+ module: shaderModule,
+ entryPoint: 'vertex',
+ buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
+ },
+ fragment: {
+ module: shaderModule,
+ entryPoint: 'fragmentCombined',
+ targets: TARGET_FORMATS.map((format) => ({ format })),
+ },
+ primitive: { topology: 'triangle-list' },
});
- const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
- this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [
- 'r8unorm',
- 'rgba16float',
- 'rgba16float',
- ]);
-
- this.uniforms = this.device.createBuffer({
- size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+ this.uniforms = device.createBuffer({
+ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
- this.bindGroup = this.device.createBindGroup({
+ this.bindGroup = device.createBindGroup({
layout: this.bindGroupLayout,
- entries: [
- {
- binding: 0,
- resource: {
- buffer: this.uniforms,
- },
- },
- ],
+ entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
});
}
public addSwipeSegment(from: vec2, to: vec2): void {
- this.lineSegments.push({
- from: vec2.clone(from),
- to: vec2.clone(to),
- });
+ this.segments.add(from, to);
}
public clearSwipes(): void {
- this.lineSegments.length = 0;
- this.actualSegments.length = 0;
+ this.segments.clear();
}
public setParameters({
@@ -107,14 +104,7 @@ export class EraserTexturePipeline {
eraserClearGreen,
eraserClearBlue,
eraserClearAlpha,
- }: {
- eraserSize: number;
- eraserLineDistanceEpsilon: number;
- eraserClearRed: number;
- eraserClearGreen: number;
- eraserClearBlue: number;
- eraserClearAlpha: number;
- }): void {
+ }: EraserTextureParameters): void {
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius * eraserRadius;
@@ -131,45 +121,18 @@ export class EraserTexturePipeline {
this.uniformCache
);
- this.actualSegments = this.lineSegments.slice();
- this.lineSegments.length = 0;
-
- if (this.actualSegments.length === 0) {
- return;
- }
-
- if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) {
- this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments);
- }
-
- const lineCount = this.lineCount;
- let floatOffset = 0;
- for (let i = 0; i < lineCount; i++) {
- const segment = this.actualSegments[i];
- floatOffset = this.writeSegmentVertices(
- this.vertexUploadData,
- floatOffset,
- segment.from,
- segment.to
- );
- }
-
- this.device.queue.writeBuffer(
- this.vertexBuffer,
- 0,
- this.vertexUploadData,
- 0,
- floatOffset
- );
+ this.segments.flush();
}
public executeCombined(
commandEncoder: GPUCommandEncoder,
eraserMaskOut: GPUTextureView,
sourceMapOut: GPUTextureView,
- trailMapOut: GPUTextureView
+ trailMapOut: GPUTextureView,
+ timestampWrites?: GPURenderPassTimestampWrites
): void {
- if (this.lineCount === 0) {
+ const lineCount = this.segments.activeCount;
+ if (lineCount === 0) {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
@@ -179,12 +142,13 @@ export class EraserTexturePipeline {
storeOp: 'store',
},
],
+ timestampWrites,
});
passEncoder.end();
return;
}
- const renderPassDescriptor: GPURenderPassDescriptor = {
+ const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: eraserMaskOut,
@@ -192,107 +156,21 @@ export class EraserTexturePipeline {
loadOp: 'clear',
storeOp: 'store',
},
- {
- view: sourceMapOut,
- loadOp: 'load',
- storeOp: 'store',
- },
- {
- view: trailMapOut,
- loadOp: 'load',
- storeOp: 'store',
- },
+ { view: sourceMapOut, loadOp: 'load', storeOp: 'store' },
+ { view: trailMapOut, loadOp: 'load', storeOp: 'store' },
],
- };
-
- const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
+ timestampWrites,
+ });
passEncoder.setPipeline(this.combinedPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
- passEncoder.setVertexBuffer(0, this.vertexBuffer);
- passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
+ passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
+ passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end();
}
public destroy(): void {
- this.vertexBuffer.destroy();
+ this.segments.destroy();
this.uniforms.destroy();
}
-
- private createPipeline(
- shaderModule: GPUShaderModule,
- fragmentEntryPoint: string,
- targetFormats: Array
- ): GPURenderPipeline {
- return this.device.createRenderPipeline({
- layout: this.device.createPipelineLayout({
- bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
- }),
- vertex: {
- module: shaderModule,
- entryPoint: 'vertex',
- buffers: [
- {
- arrayStride:
- Float32Array.BYTES_PER_ELEMENT *
- EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT,
- stepMode: 'instance',
- attributes: [
- {
- shaderLocation: 0,
- format: 'float32x2',
- offset: 0,
- },
- {
- shaderLocation: 1,
- format: 'float32x2',
- offset: Float32Array.BYTES_PER_ELEMENT * 2,
- },
- ],
- },
- ],
- },
- fragment: {
- module: shaderModule,
- entryPoint: fragmentEntryPoint,
- targets: targetFormats.map((format) => ({ format })),
- },
- primitive: {
- topology: 'triangle-list',
- },
- });
- }
-
- private static subsampleSegments(segments: Array): Array {
- if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
- return segments;
- }
-
- const result: Array = [];
- for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) {
- const index = Math.round(
- (i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1)
- );
- result.push(segments[index]);
- }
-
- return result;
- }
-
- private writeSegmentVertices(
- target: Float32Array,
- offset: number,
- from: vec2,
- to: vec2
- ): number {
- target[offset++] = from[0];
- target[offset++] = from[1];
- target[offset++] = to[0];
- target[offset++] = to[1];
- return offset;
- }
-
- private get lineCount(): number {
- return this.actualSegments.length;
- }
}
diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl
index 10948ef..0fccc10 100644
--- a/src/pipelines/eraser/eraser-texture.wgsl
+++ b/src/pipelines/eraser/eraser-texture.wgsl
@@ -49,7 +49,13 @@ fn fragmentCombined(
@location(2) @interpolate(flat) direction: vec2,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserCombinedTargets {
- if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
+ let distanceSquared = distance_squared_from_segment(
+ screenPosition,
+ start,
+ direction,
+ inverseLengthSquared
+ );
+ if distanceSquared > settings.eraserRadiusSquared {
discard;
}
@@ -69,55 +75,3 @@ fn getEraserClearValue() -> vec4 {
settings.clearAlpha
);
}
-
-fn shouldDiscardEraserFragment(
- screenPosition: vec2,
- start: vec2,
- direction: vec2,
- inverseLengthSquared: f32
-) -> bool {
- return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared;
-}
-
-fn distanceSquaredFromLine(
- position: vec2,
- start: vec2,
- direction: vec2,
- inverseLengthSquared: f32
-) -> f32 {
- let pa = position - start;
-
- let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
- let nearestOffset = pa - direction * q;
- return dot(nearestOffset, nearestOffset);
-}
-
-fn segment_vertex_position(
- vertexIndex: u32,
- start: vec2,
- end: vec2,
- radius: f32
-) -> vec2 {
- let directionVector = end - start;
- let segmentLength = length(directionVector);
- var direction = vec2(1.0, 0.0);
- if segmentLength > 0.0 {
- direction = directionVector / segmentLength;
- }
- let perpendicular = vec2(direction.y, -direction.x);
- let corner = segment_vertex_corner(vertexIndex % 6u);
- let center = mix(start, end, (corner.x + 1.0) * 0.5);
- return center + direction * corner.x * radius + perpendicular * corner.y * radius;
-}
-
-fn segment_vertex_corner(index: u32) -> vec2 {
- let corners = array, 6>(
- vec2(-1.0, 1.0),
- vec2(-1.0, -1.0),
- vec2(1.0, 1.0),
- vec2(-1.0, -1.0),
- vec2(1.0, 1.0),
- vec2(1.0, -1.0),
- );
- return corners[index];
-}
diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts
index df6b56a..dc99434 100644
--- a/src/pipelines/render/render-pipeline.ts
+++ b/src/pipelines/render/render-pipeline.ts
@@ -17,17 +17,19 @@ export interface RenderSettings {
backgroundGrainStrength: number;
}
-export class RenderPipeline {
- private static readonly UNIFORM_COUNT = 20;
+// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 5 scalars = 20 floats.
+const UNIFORM_COUNT = 20;
+export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly noSourcePipeline: GPURenderPipeline;
+ private readonly noGrainPipeline: GPURenderPipeline;
+ private readonly noSourceNoGrainPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
- private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
- private readonly uniformCache = createCachedFloat32BufferWrite(
- RenderPipeline.UNIFORM_COUNT
- );
+ private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
+ private useBackgroundGrain = true;
private readonly getBindGroup = createBindGroupCache(
(colorTexture, sourceTexture) =>
@@ -46,42 +48,83 @@ export class RenderPipeline {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
+ this.bindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: GPUShaderStage.FRAGMENT,
+ buffer: { type: 'uniform' },
+ },
+ {
+ binding: 2,
+ visibility: GPUShaderStage.FRAGMENT,
+ texture: { sampleType: 'float' },
+ },
+ {
+ binding: 3,
+ visibility: GPUShaderStage.FRAGMENT,
+ texture: { sampleType: 'float' },
+ },
+ ],
+ });
+ const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
const vertex = setUpFullScreenQuad(device);
-
const format = navigator.gpu.getPreferredCanvasFormat();
- this.pipeline = this.createPipeline(format, vertex, 'fragment');
- this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource');
+ const pipelineLayout = device.createPipelineLayout({
+ bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
+ });
+ this.pipeline = this.createPipeline(
+ pipelineLayout,
+ vertex,
+ shaderModule,
+ format,
+ 'fragment'
+ );
+ this.noSourcePipeline = this.createPipeline(
+ pipelineLayout,
+ vertex,
+ shaderModule,
+ format,
+ 'fragmentNoSource'
+ );
+ this.noGrainPipeline = this.createPipeline(
+ pipelineLayout,
+ vertex,
+ shaderModule,
+ format,
+ 'fragmentNoGrain'
+ );
+ this.noSourceNoGrainPipeline = this.createPipeline(
+ pipelineLayout,
+ vertex,
+ shaderModule,
+ format,
+ 'fragmentNoSourceNoGrain'
+ );
- this.uniforms = this.device.createBuffer({
- size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+ this.uniforms = device.createBuffer({
+ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
private createPipeline(
- format: GPUTextureFormat,
+ layout: GPUPipelineLayout,
vertex: GPUVertexState,
+ shaderModule: GPUShaderModule,
+ format: GPUTextureFormat,
fragmentEntryPoint: string
): GPURenderPipeline {
return this.device.createRenderPipeline({
- layout: this.device.createPipelineLayout({
- bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
- }),
+ layout,
vertex,
fragment: {
- module: smartCompile(this.device, CommonState.shaderCode, shader),
+ module: shaderModule,
entryPoint: fragmentEntryPoint,
- targets: [
- {
- format,
- },
- ],
- },
- primitive: {
- topology: 'triangle-list',
+ targets: [{ format }],
},
+ primitive: { topology: 'triangle-list' },
});
}
@@ -101,15 +144,13 @@ export class RenderPipeline {
this.uniformValues[0] = rgbChannelToUnit(a[0]);
this.uniformValues[1] = rgbChannelToUnit(a[1]);
this.uniformValues[2] = rgbChannelToUnit(a[2]);
- this.uniformValues[3] = 0;
+ // uniformValues[3], [7], [11] are WGSL vec3→vec4 alignment padding.
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] = rgbChannelToUnit(c[0]);
this.uniformValues[9] = rgbChannelToUnit(c[1]);
this.uniformValues[10] = rgbChannelToUnit(c[2]);
- this.uniformValues[11] = 0;
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
@@ -118,6 +159,7 @@ export class RenderPipeline {
this.uniformValues[17] = renderBrushColorBase;
this.uniformValues[18] = renderBrushColorStrengthMultiplier;
this.uniformValues[19] = backgroundGrainStrength;
+ this.useBackgroundGrain = backgroundGrainStrength !== 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@@ -130,28 +172,18 @@ export class RenderPipeline {
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView,
- useSourceTexture = true
+ useSourceTexture = true,
+ timestampWrites?: GPURenderPassTimestampWrites
): GPUTexture {
- const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
const canvasTexture = this.context.getCurrentTexture();
-
- const renderPassDescriptor: GPURenderPassDescriptor = {
- colorAttachments: [
- {
- view: canvasTexture.createView(),
- clearValue: { r: 0, g: 0, b: 0, a: 1 },
- loadOp: 'clear',
- storeOp: 'store',
- },
- ],
- };
- const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
- passEncoder.setPipeline(useSourceTexture ? this.pipeline : this.noSourcePipeline);
- this.commonState.execute(passEncoder);
- passEncoder.setBindGroup(1, bindGroup);
- passEncoder.draw(3, 1);
- passEncoder.end();
-
+ this.encodePass(
+ commandEncoder,
+ colorTexture,
+ sourceTexture,
+ canvasTexture.createView(),
+ useSourceTexture,
+ timestampWrites
+ );
return canvasTexture;
}
@@ -159,56 +191,54 @@ export class RenderPipeline {
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView,
- outputTexture: GPUTextureView
+ outputTexture: GPUTextureView,
+ useSourceTexture = true,
+ timestampWrites?: GPURenderPassTimestampWrites
) {
- const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
+ this.encodePass(
+ commandEncoder,
+ colorTexture,
+ sourceTexture,
+ outputTexture,
+ useSourceTexture,
+ timestampWrites
+ );
+ }
+ private encodePass(
+ commandEncoder: GPUCommandEncoder,
+ colorTexture: GPUTextureView,
+ sourceTexture: GPUTextureView,
+ output: GPUTextureView,
+ useSourceTexture: boolean,
+ timestampWrites?: GPURenderPassTimestampWrites
+ ) {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
- view: outputTexture,
+ view: output,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
+ timestampWrites,
});
- passEncoder.setPipeline(this.pipeline);
+ passEncoder.setPipeline(this.getPipeline(useSourceTexture));
this.commonState.execute(passEncoder);
- passEncoder.setBindGroup(1, bindGroup);
+ passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture));
passEncoder.draw(3, 1);
passEncoder.end();
}
+ private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
+ if (useSourceTexture) {
+ return this.useBackgroundGrain ? this.pipeline : this.noGrainPipeline;
+ }
+ return this.useBackgroundGrain ? this.noSourcePipeline : this.noSourceNoGrainPipeline;
+ }
+
public destroy() {
this.uniforms.destroy();
}
-
- private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
- return {
- entries: [
- {
- binding: 0,
- visibility: GPUShaderStage.FRAGMENT,
- buffer: {
- type: 'uniform',
- },
- },
- {
- binding: 2,
- visibility: GPUShaderStage.FRAGMENT,
- texture: {
- sampleType: 'float',
- },
- },
- {
- binding: 3,
- visibility: GPUShaderStage.FRAGMENT,
- texture: {
- sampleType: 'float',
- },
- },
- ],
- };
- }
}
diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl
index 4495601..67651c0 100644
--- a/src/pipelines/render/render.wgsl
+++ b/src/pipelines/render/render.wgsl
@@ -1,10 +1,10 @@
struct Settings {
colorA: vec3,
- backgroundColorPadding0: f32,
+ _colorAPadding: f32,
colorB: vec3,
- backgroundColorPadding1: f32,
+ _colorBPadding: f32,
colorC: vec3,
- backgroundColorPadding2: f32,
+ _colorCPadding: f32,
backgroundColor: vec3,
clarity: f32,
traceNormalizationFloor: f32,
@@ -24,18 +24,32 @@ 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);
+ return renderColor(traces, sources, getTexturedBackground(pixel));
}
@fragment
fn fragmentNoSource(@builtin(position) position: vec4) -> @location(0) vec4 {
let pixel = vec2(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
- return renderColor(traces, vec4(0.0), pixel);
+ return renderColor(traces, vec4(0.0), getTexturedBackground(pixel));
}
-fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4 {
- let background = getTexturedBackground(pixel);
+@fragment
+fn fragmentNoGrain(@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, getFlatBackground());
+}
+
+@fragment
+fn fragmentNoSourceNoGrain(@builtin(position) position: vec4) -> @location(0) vec4 {
+ let pixel = vec2(position.xy);
+ let traces = textureLoad(trailMap, pixel, 0);
+ return renderColor(traces, vec4(0.0), getFlatBackground());
+}
+
+fn renderColor(traces: vec4, sources: vec4, background: vec3) -> vec4 {
let tracesMax = maxComponent(traces.rgb);
let sourcesMax = maxComponent(sources.rgb);
if max(tracesMax, sourcesMax) <= 0.0 {
@@ -93,7 +107,11 @@ fn maxComponent(v: vec3) -> f32 {
}
fn clarity(strength: f32) -> f32 {
- return pow(clamp(strength, 0, 1), settings.clarity);
+ let clamped = clamp(strength, 0, 1);
+ if settings.clarity == 1.0 {
+ return clamped;
+ }
+ return pow(clamped, settings.clarity);
}
fn normalizeColorIntensity(color: vec3) -> vec3 {
@@ -101,10 +119,11 @@ fn normalizeColorIntensity(color: vec3) -> vec3 {
return color / max(settings.traceNormalizationFloor, brightestChannel);
}
+fn getFlatBackground() -> vec3 {
+ return clamp(settings.backgroundColor, vec3(0), vec3(1));
+}
+
fn getTexturedBackground(pixel: vec2) -> vec3 {
- if settings.backgroundGrainStrength == 0.0 {
- return clamp(settings.backgroundColor, vec3(0), vec3(1));
- }
let noiseCoord = vec2(vec2(pixel) & vec2(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
index acb3ae9..7da1f51 100644
--- a/src/style/_app-shell.scss
+++ b/src/style/_app-shell.scss
@@ -1,8 +1,10 @@
-html > body.pre-drawing .dev-stats-overlay,
-html > body.is-loading .dev-stats-overlay {
+html > body.is-loading .perf-stats-overlay {
display: none;
}
+$grain-noise-a: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='257' height='257' viewBox='0 0 257 257'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' seed='17' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='257' height='257' filter='url(%23n)'/%3E%3C/svg%3E");
+$grain-noise-b: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='389' height='389' viewBox='0 0 389 389'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.53' numOctaves='5' seed='41' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='389' height='389' filter='url(%23n)'/%3E%3C/svg%3E");
+
html > body {
width: 100%;
height: 100vh;
@@ -20,16 +22,57 @@ html > body {
overflow: hidden;
> canvas {
+ position: relative;
+ z-index: 0;
height: 100%;
width: 100%;
touch-action: none;
}
+ > .garden-grain {
+ --garden-grain-strength: 0;
+
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ contain: strict;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ }
+
+ &::before {
+ opacity: clamp(0, calc(var(--garden-grain-strength) * 12), 0.44);
+ background-image: $grain-noise-a;
+ background-size: 257px 257px;
+ filter: contrast(190%) brightness(0.66);
+ mix-blend-mode: multiply;
+ }
+
+ &::after {
+ opacity: clamp(0, calc(var(--garden-grain-strength) * 7), 0.24);
+ background-image: $grain-noise-b;
+ background-position: 73px 41px;
+ background-size: 389px 389px;
+ filter: contrast(170%) brightness(1.02);
+ mix-blend-mode: screen;
+ transform: rotate(0.01deg);
+ }
+
+ &[hidden] {
+ display: none;
+ }
+ }
+
> .eraser-preview {
position: absolute;
top: 0;
left: 0;
- z-index: 1;
+ z-index: 3;
width: var(--eraser-preview-size, 96px);
height: var(--eraser-preview-size, 96px);
border: 2px solid rgb(255 234 228 / 88%);
@@ -52,7 +95,7 @@ html > body {
}
}
- > .dev-stats-overlay {
+ > .perf-stats-overlay {
position: absolute;
top: max(8px, env(safe-area-inset-top));
left: max(8px, env(safe-area-inset-left));
diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss
index d21e043..95f6115 100644
--- a/src/style/_config-pane.scss
+++ b/src/style/_config-pane.scss
@@ -1,9 +1,150 @@
-.config-pane {
- .color-reaction-folder > .tp-fldv_c {
- padding: 6px 8px 8px;
+@use 'mixins' as *;
+
+.config-pane-container {
+ --config-pane-available-height: calc(
+ 100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+ );
+
+ position: fixed;
+ top: max(12px, env(safe-area-inset-top, 0px));
+ right: max(12px, env(safe-area-inset-right, 0px));
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 4px;
+ z-index: 20;
+ width: min(
+ 420px,
+ calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px))
+ );
+ max-height: var(--config-pane-available-height);
+ pointer-events: none;
+
+ @supports (height: 100dvh) {
+ --config-pane-available-height: calc(
+ 100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+ );
}
}
+.config-pane-container--open {
+ pointer-events: auto;
+}
+
+.config-pane {
+ width: 100%;
+ max-height: calc(var(--config-pane-available-height) - 36px);
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ pointer-events: auto;
+ scrollbar-width: thin;
+ touch-action: pan-y;
+ -webkit-overflow-scrolling: touch;
+}
+
+.config-pane-close {
+ position: relative;
+ justify-self: end;
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: rgb(235 238 245 / 82%);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0;
+ pointer-events: auto;
+ transition:
+ background-color var(--transition-time),
+ color var(--transition-time);
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ width: 14px;
+ height: 2px;
+ border-radius: 999px;
+ background: currentColor;
+ }
+
+ &::before {
+ transform: rotate(45deg);
+ }
+
+ &::after {
+ transform: rotate(-45deg);
+ }
+
+ &:hover {
+ background: rgb(255 255 255 / 10%);
+ color: white;
+ }
+
+ &:focus-visible {
+ outline: 2px solid white;
+ outline-offset: -2px;
+ }
+
+ &[hidden] {
+ display: none;
+ }
+}
+
+@mixin mobile-config-pane() {
+ .config-pane-container {
+ --config-pane-available-height: min(
+ 64vh,
+ calc(
+ 100vh - 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+ )
+ );
+
+ top: max(8px, env(safe-area-inset-top, 0px));
+ right: auto;
+ left: 50%;
+ width: min(80vw, 420px);
+ transform: translateX(-50%);
+
+ @supports (height: 100dvh) {
+ --config-pane-available-height: min(
+ 64dvh,
+ calc(
+ 100dvh -
+ 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+ )
+ );
+ }
+ }
+
+ .config-pane {
+ --tp-blade-value-width: min(128px, 38vw);
+ --tp-container-unit-size: 18px;
+
+ font-size: 11px;
+ }
+
+ .config-pane-close {
+ width: 32px;
+ height: 32px;
+ }
+}
+
+@include on-small-screen {
+ @include mobile-config-pane;
+}
+
+@media (hover: none) and (pointer: coarse) {
+ @include mobile-config-pane;
+}
+
+.color-reaction-matrix-blade {
+ padding: 6px 8px 8px;
+}
+
.color-reaction-matrix {
display: grid;
grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr));
diff --git a/src/style/_motion.scss b/src/style/_motion.scss
deleted file mode 100644
index 20d1e66..0000000
--- a/src/style/_motion.scss
+++ /dev/null
@@ -1,16 +0,0 @@
-@media (prefers-reduced-motion: reduce) {
- html > body {
- > aside.control-dock {
- > .toolbar-row {
- > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover,
- > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover {
- transform: none;
- }
-
- > nav.buttons > button:hover::after {
- transform: none;
- }
- }
- }
- }
-}
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
index 2c89027..ef7ca8c 100644
--- a/src/style/_toolbar.scss
+++ b/src/style/_toolbar.scss
@@ -1,709 +1,4 @@
-@use 'mixins' as *;
-
-@mixin toolbar-track() {
- height: 7px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- rgb(var(--control-rgb) / 72%) 0 var(--control-progress),
- rgb(255 255 255 / 24%) var(--control-progress) 100%
- );
- box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%);
- cursor: ew-resize;
-}
-
-@mixin toolbar-thumb() {
- width: var(--thumb-width);
- height: var(--thumb-height);
- border: 2px solid rgb(255 255 255 / 92%);
- border-radius: var(--thumb-radius);
- background: var(--thumb-background);
- box-shadow:
- inset 0 1px 2px rgb(255 255 255 / 22%),
- 0 4px 12px rgb(0 0 0 / 30%);
- cursor: ew-resize;
- transform: var(--thumb-transform);
-}
-
-$toolbar-icons: (
- info: 'info',
- maximize-full-screen: 'maximize',
- minimize-full-screen: 'minimize',
- settings: 'settings',
- sound: 'sound',
- export-4k: 'download',
- restart: 'restart',
-);
-
-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;
- justify-content: center;
- width: 100%;
- max-width: 100%;
- margin: 0 auto;
- padding-inline: clamp(8px, 1.4vw, 14px);
- column-gap: 0;
- row-gap: 0;
- border-radius: 12px;
- color: rgb(245 250 244 / 92%);
- background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
- box-shadow:
- inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)),
- inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)),
- 0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%));
- backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px))
- brightness(calc(1 - var(--toolbar-background-strength) * 0.38))
- saturate(calc(1 - var(--toolbar-background-strength) * 0.18));
- font-size: 13px;
- font-weight: 400;
- line-height: 1;
- transition:
- backdrop-filter var(--transition-time-long),
- 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;
- border: 0;
- font: inherit;
- cursor: pointer;
- transition:
- background-color var(--transition-time),
- border-color var(--transition-time),
- color var(--transition-time),
- box-shadow var(--transition-time),
- opacity var(--transition-time),
- transform var(--transition-time);
-
- &:disabled {
- cursor: progress;
- opacity: 0.58;
- }
-
- &:focus-visible {
- outline: 2px solid white;
- outline-offset: 2px;
- }
- }
-
- > .toolbar-shell {
- grid-area: controls;
- display: grid;
- grid-template-areas: 'swatches';
- grid-template-columns: minmax(0, 1fr);
- align-items: center;
- justify-content: center;
- justify-self: center;
- width: min(100%, var(--toolbar-top-max-width));
- min-width: 0;
- padding: 8px 9px;
- }
-
- > .vibe-button {
- position: relative;
- display: grid;
- place-items: center;
- width: 52px;
- height: auto;
- min-height: 66px;
- flex: 0 0 auto;
- padding: 0;
- border-radius: 0;
- background: transparent;
- color: rgb(255 255 255 / 70%);
- font-size: 0;
- line-height: 1;
-
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- width: 18px;
- height: 18px;
- border-color: currentColor;
- border-style: solid;
- border-width: 0 0 3px 3px;
- transform: translate(-35%, -50%) rotate(45deg);
- }
-
- &.next-vibe::before {
- border-width: 3px 3px 0 0;
- transform: translate(-65%, -50%) rotate(45deg);
- }
-
- &:hover {
- color: color-mix(in srgb, var(--accent-color) 70%, white);
- }
-
- &.previous-vibe:hover {
- transform: translateX(-2px);
- }
-
- &.next-vibe:hover {
- transform: translateX(2px);
- }
- }
-
- > .previous-vibe {
- grid-area: previous;
- }
-
- > .next-vibe {
- grid-area: next;
- }
-
- > nav.buttons {
- grid-area: buttons;
- display: flex;
- flex-wrap: nowrap;
- align-items: center;
- justify-content: center;
- justify-self: center;
- gap: 4px;
- width: fit-content;
- max-width: 100%;
- min-width: 0;
-
- > button,
- > .audio-control > button {
- position: relative;
- width: 44px;
- height: 44px;
- flex: 1 1 44px;
- max-width: 54px;
- min-width: 0;
- border: 1px solid transparent;
- border-radius: 8px;
- background: transparent;
-
- &::after {
- content: '';
- position: absolute;
- inset: 0;
- z-index: 1;
- width: 20px;
- height: 20px;
- margin: auto;
- background-color: rgb(245 250 244 / 76%);
- mask-position: center;
- mask-repeat: no-repeat;
- mask-size: contain;
- transition:
- background-color var(--transition-time),
- transform var(--transition-time);
- }
-
- &:hover {
- border-color: rgb(255 255 255 / 10%);
- background: rgb(255 255 255 / 9%);
- }
-
- &:hover::after {
- transform: scale(1.08);
- }
-
- &.active {
- border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%);
- background: color-mix(in srgb, var(--accent-color) 30%, transparent);
- }
-
- &.active::after {
- background-color: white;
- }
-
- @each $class, $icon in $toolbar-icons {
- &.#{$class}::after {
- mask-image: url('../../assets/icons/#{$icon}.svg');
- }
- }
-
- &.sound.muted::before {
- content: '';
- position: absolute;
- inset: 0;
- z-index: 2;
- width: 2px;
- height: 28px;
- margin: auto;
- border-radius: 999px;
- background: white;
- transform: rotate(-45deg);
- transform-origin: center;
- }
-
- &.sound.muted::after {
- background-color: rgb(255 255 255 / 46%);
- }
- }
-
- > .audio-control {
- display: flex;
- align-items: center;
- width: 132px;
- height: 44px;
- flex: 2 1 132px;
- max-width: 150px;
- min-width: 0;
- padding-right: 10px;
- border: 1px solid transparent;
- border-radius: 8px;
- background: rgb(255 255 255 / 4%);
- transition:
- border-color var(--transition-time),
- background-color var(--transition-time),
- box-shadow var(--transition-time),
- opacity var(--transition-time);
-
- &:hover {
- border-color: rgb(255 255 255 / 10%);
- background: rgb(255 255 255 / 7%);
- }
-
- > button {
- flex: 0 0 42px;
- min-width: 42px;
- border-color: transparent;
-
- &:focus-visible {
- outline-offset: -4px;
- }
- }
-
- > .volume-control {
- position: relative;
- display: grid;
- align-items: center;
- height: 44px;
- flex: 1 1 auto;
- min-width: 0;
- padding-left: 3px;
- cursor: ew-resize;
- opacity: 0.96;
- transition: opacity var(--transition-time);
-
- &.muted {
- opacity: 0.56;
- }
- }
-
- > .volume-control input[type='range'] {
- position: relative;
- z-index: 1;
- width: 100%;
- height: 100%;
- appearance: none;
- background: transparent;
- cursor: ew-resize;
- outline: none;
- touch-action: pan-y;
-
- &:focus-visible {
- border-radius: 8px;
- outline: 2px solid white;
- outline-offset: -4px;
- }
-
- &::-webkit-slider-runnable-track {
- height: 4px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--accent-color) 62%, white 8%) 0
- var(--volume-progress, 42%),
- rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100%
- );
- box-shadow:
- inset 0 1px 1px rgb(0 0 0 / 24%),
- 0 1px 0 rgb(255 255 255 / 8%);
- cursor: ew-resize;
- }
-
- &::-webkit-slider-thumb {
- width: 12px;
- height: 12px;
- border: 2px solid rgb(13 18 24);
- border-radius: 50%;
- background: rgb(245 250 244);
- box-shadow:
- 0 0 0 1px rgb(255 255 255 / 46%),
- 0 3px 8px rgb(0 0 0 / 28%);
- margin-top: -4px;
- appearance: none;
- transition:
- box-shadow var(--transition-time),
- transform var(--transition-time);
- }
-
- &::-webkit-slider-thumb:hover {
- box-shadow:
- 0 0 0 1px rgb(255 255 255 / 56%),
- 0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent),
- 0 4px 10px rgb(0 0 0 / 34%);
- transform: scale(1.08);
- }
-
- &::-moz-range-track {
- height: 4px;
- border: 0;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--accent-color) 62%, white 8%) 0
- var(--volume-progress, 42%),
- rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100%
- );
- box-shadow:
- inset 0 1px 1px rgb(0 0 0 / 24%),
- 0 1px 0 rgb(255 255 255 / 8%);
- }
-
- &::-moz-range-thumb {
- width: 12px;
- height: 12px;
- border: 2px solid rgb(13 18 24);
- border-radius: 50%;
- background: rgb(245 250 244);
- box-shadow:
- 0 0 0 1px rgb(255 255 255 / 46%),
- 0 3px 8px rgb(0 0 0 / 28%);
- cursor: ew-resize;
- }
- }
- }
-
- > .export-status {
- flex: 0 1 140px;
- min-height: 20px;
- max-width: 140px;
- overflow: hidden;
- color: rgb(255 255 255 / 82%);
- font-size: 13px;
- line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- &:empty {
- display: none;
- }
- }
- }
-
- > .toolbar-shell > .garden-controls {
- grid-area: swatches;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- min-width: 0;
- padding: 0 4px;
-
- > .swatches {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12px;
- min-height: 58px;
- padding: 6px 10px;
-
- > .color-swatch {
- position: relative;
- width: 44px;
- height: 44px;
- border: 2px solid rgb(255 255 255 / 54%);
- border-radius: 50%;
- box-shadow:
- inset 0 0 0 1px rgb(0 0 0 / 16%),
- 0 3px 10px rgb(0 0 0 / 22%);
-
- &:hover {
- transform: translateY(-2px);
- }
-
- &.active {
- outline: 2px solid rgb(255 255 255 / 96%);
- outline-offset: 3px;
- box-shadow:
- inset 0 0 0 1px rgb(0 0 0 / 14%),
- 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent),
- 0 7px 18px rgb(0 0 0 / 26%);
- }
- }
-
- > .eraser-size-control,
- > .mirror-segment-control {
- --thumb-hover-transform: scale(1.03);
- --thumb-radius: 50%;
- --thumb-transform: none;
-
- position: relative;
- display: grid;
- align-items: center;
- width: 184px;
- height: 46px;
- flex: 0 0 184px;
- padding: 0 12px;
- overflow: hidden;
- border: 1px solid rgb(255 255 255 / 14%);
- border-radius: 8px;
- background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%));
- box-shadow:
- inset 0 0 0 1px rgb(255 255 255 / 6%),
- 0 3px 10px rgb(0 0 0 / 18%);
- cursor: ew-resize;
- transition:
- border-color var(--transition-time),
- background-color var(--transition-time),
- box-shadow var(--transition-time),
- transform var(--transition-time);
-
- &:hover {
- border-color: rgb(255 255 255 / 24%);
- transform: translateY(-2px);
- }
-
- &.active {
- border-color: rgb(var(--control-rgb) / 72%);
- background-color: rgb(var(--control-rgb) / 11%);
- box-shadow:
- inset 0 0 0 1px rgb(255 255 255 / 10%),
- 0 0 0 5px rgb(var(--control-rgb) / 28%),
- 0 6px 15px rgb(0 0 0 / 22%);
- }
-
- input[type='range'] {
- position: relative;
- z-index: 1;
- width: 100%;
- height: 100%;
- appearance: none;
- background: transparent;
- cursor: ew-resize;
- outline: none;
- touch-action: pan-y;
-
- &:focus-visible {
- border-radius: 8px;
- outline: 2px solid white;
- outline-offset: 2px;
- }
-
- &::-webkit-slider-runnable-track {
- @include toolbar-track();
- }
-
- &::-webkit-slider-thumb {
- @include toolbar-thumb();
- margin-top: calc((7px - var(--thumb-height)) / 2);
- appearance: none;
- transition:
- box-shadow var(--transition-time),
- height var(--transition-time),
- margin-top var(--transition-time),
- transform var(--transition-time),
- width var(--transition-time);
- }
-
- &::-webkit-slider-thumb:hover {
- box-shadow:
- inset 0 1px 2px rgb(255 255 255 / 22%),
- 0 0 0 4px rgb(var(--control-rgb) / 22%),
- 0 5px 14px rgb(0 0 0 / 34%);
- transform: var(--thumb-hover-transform);
- }
-
- &::-moz-range-track {
- @include toolbar-track();
- }
-
- &::-moz-range-thumb {
- @include toolbar-thumb();
- }
- }
- }
-
- > .eraser-size-control {
- --control-progress: var(--eraser-progress, 33%);
- --control-rgb: 255 140 117;
- --thumb-background:
- linear-gradient(
- 110deg,
- transparent 0 12%,
- rgb(255 255 255 / 44%) 13% 20%,
- transparent 21% 100%
- ),
- linear-gradient(
- 90deg,
- #ff8fa3 0 52%,
- rgb(54 46 51 / 78%) 53% 56%,
- #f5eee5 57% 100%
- );
- --thumb-height: calc(21px * var(--eraser-control-scale, 1));
- --thumb-hover-transform: rotate(-10deg) scale(1.03);
- --thumb-radius: calc(6px * var(--eraser-control-scale, 1));
- --thumb-transform: rotate(-10deg);
- --thumb-width: calc(34px * var(--eraser-control-scale, 1));
- }
-
- > .mirror-segment-control {
- --control-progress: var(--mirror-progress, 0%);
- --control-rgb: 148 233 203;
- --thumb-background:
- radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px),
- repeating-conic-gradient(
- from -90deg,
- rgb(218 255 241) 0 8deg,
- rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg)
- );
- --thumb-height: 44px;
- --thumb-width: 44px;
- }
- }
- }
-
- @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: 0;
-
- > .vibe-button {
- width: 36px;
- min-height: 44px;
-
- &::before {
- width: 14px;
- height: 14px;
- }
- }
-
- > .toolbar-shell {
- padding: 4px;
- }
-
- > nav.buttons {
- justify-self: stretch;
- justify-content: space-between;
- gap: clamp(1px, 0.55vw, 2px);
- width: auto;
- max-width: none;
- margin-inline: -4px;
-
- > button {
- width: auto;
- height: 38px;
- flex: 1 1 clamp(28px, 8vw, 38px);
- max-width: 38px;
- min-height: 38px;
-
- &::after {
- width: 17px;
- height: 17px;
- }
- }
-
- > .audio-control {
- width: auto;
- height: 38px;
- flex: 2 1 clamp(58px, 18vw, 118px);
- max-width: 118px;
- padding-right: clamp(4px, 1.8vw, 9px);
-
- > button {
- width: auto;
- flex: 1 1 clamp(28px, 8vw, 38px);
- min-width: 0;
- }
-
- > .volume-control {
- height: 38px;
- }
- }
-
- > .export-status {
- flex-basis: 0;
- max-width: 0;
- text-align: center;
- }
- }
-
- > .toolbar-shell {
- > .garden-controls {
- padding: 2px 4px;
-
- > .swatches {
- display: grid;
- grid-template-columns: repeat(6, minmax(0, 1fr));
- justify-items: center;
- justify-content: stretch;
- width: 100%;
- min-width: 0;
- min-height: 48px;
- flex: 1 1 100%;
- 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;
- }
-
- > .eraser-size-control,
- > .mirror-segment-control {
- justify-self: stretch;
- width: 100%;
- min-width: 0;
- height: 38px;
- padding: 0 7px;
- }
-
- > .eraser-size-control {
- grid-column: 1 / span 3;
- }
-
- > .mirror-segment-control {
- --thumb-height: 34px;
- --thumb-width: 34px;
-
- grid-column: 4 / span 3;
- }
- }
- }
- }
- }
-}
+@use 'toolbar/layout';
+@use 'toolbar/buttons';
+@use 'toolbar/garden-controls';
+@use 'toolbar/responsive';
diff --git a/src/style/toolbar/_buttons.scss b/src/style/toolbar/_buttons.scss
new file mode 100644
index 0000000..3551a3b
--- /dev/null
+++ b/src/style/toolbar/_buttons.scss
@@ -0,0 +1,157 @@
+@use 'shared' as *;
+
+html > body > aside.control-dock > .toolbar-row > nav.buttons {
+ grid-area: buttons;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ justify-content: center;
+ justify-self: center;
+ gap: 4px;
+ width: fit-content;
+ max-width: 100%;
+ min-width: 0;
+
+ > button,
+ > .audio-control > button {
+ position: relative;
+ width: 44px;
+ height: 44px;
+ flex: 1 1 44px;
+ max-width: 54px;
+ min-width: 0;
+ @include toolbar-control-surface(transparent, rgb(255 255 255 / 9%));
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ width: 20px;
+ height: 20px;
+ margin: auto;
+ background-color: rgb(245 250 244 / 76%);
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+ transition:
+ background-color var(--transition-time),
+ transform var(--transition-time);
+ }
+
+ &:hover::after {
+ transform: scale(1.08);
+ }
+
+ &.active {
+ border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%);
+ background: color-mix(in srgb, var(--accent-color) 30%, transparent);
+ }
+
+ &.active::after {
+ background-color: white;
+ }
+
+ @each $class, $icon in $toolbar-icons {
+ &.#{$class}::after {
+ mask-image: url('../../../assets/icons/#{$icon}.svg');
+ }
+ }
+
+ &.sound.muted::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ width: 2px;
+ height: 28px;
+ margin: auto;
+ border-radius: 999px;
+ background: white;
+ transform: rotate(-45deg);
+ transform-origin: center;
+ }
+
+ &.sound.muted::after {
+ background-color: rgb(255 255 255 / 46%);
+ }
+ }
+
+ > .audio-control {
+ display: flex;
+ align-items: center;
+ width: 132px;
+ height: 44px;
+ flex: 2 1 132px;
+ max-width: 150px;
+ min-width: 0;
+ padding-right: 10px;
+ @include toolbar-control-surface(rgb(255 255 255 / 4%), rgb(255 255 255 / 7%));
+
+ > button {
+ flex: 0 0 42px;
+ min-width: 42px;
+ border-color: transparent;
+
+ &:focus-visible {
+ outline-offset: -4px;
+ }
+ }
+
+ > .volume-control {
+ --range-progress: var(--volume-progress, 42%);
+ --range-track-height: 4px;
+ --range-fill: color-mix(in srgb, var(--accent-color) 62%, white 8%);
+ --range-empty: rgb(255 255 255 / 18%);
+ --range-track-shadow:
+ inset 0 1px 1px rgb(0 0 0 / 24%), 0 1px 0 rgb(255 255 255 / 8%);
+ --range-thumb-width: 12px;
+ --range-thumb-height: 12px;
+ --range-thumb-border: 2px solid rgb(13 18 24);
+ --range-thumb-radius: 50%;
+ --range-thumb-background: rgb(245 250 244);
+ --range-thumb-shadow: 0 0 0 1px rgb(255 255 255 / 46%), 0 3px 8px rgb(0 0 0 / 28%);
+ --range-thumb-hover-shadow:
+ 0 0 0 1px rgb(255 255 255 / 56%),
+ 0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent),
+ 0 4px 10px rgb(0 0 0 / 34%);
+ --range-thumb-hover-transform: scale(1.08);
+ --range-focus-outline-offset: -4px;
+
+ position: relative;
+ display: grid;
+ align-items: center;
+ height: 44px;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding-left: 3px;
+ cursor: ew-resize;
+ opacity: 0.96;
+ transition: opacity var(--transition-time);
+
+ &.muted {
+ opacity: 0.56;
+ }
+ }
+
+ > .volume-control input[type='range'] {
+ @include toolbar-range-input();
+ }
+ }
+
+ > .export-status {
+ flex: 0 1 140px;
+ min-height: 20px;
+ max-width: 140px;
+ overflow: hidden;
+ color: rgb(255 255 255 / 82%);
+ font-size: 13px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:empty {
+ display: none;
+ }
+ }
+}
diff --git a/src/style/toolbar/_garden-controls.scss b/src/style/toolbar/_garden-controls.scss
new file mode 100644
index 0000000..263dc50
--- /dev/null
+++ b/src/style/toolbar/_garden-controls.scss
@@ -0,0 +1,148 @@
+@use 'shared' as *;
+
+html > body > aside.control-dock > .toolbar-row > .toolbar-shell > .garden-controls {
+ grid-area: swatches;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ padding: 0 4px;
+
+ > .swatches {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 58px;
+ padding: 6px 10px;
+
+ > .color-swatch {
+ position: relative;
+ width: 44px;
+ height: 44px;
+ border: 2px solid rgb(255 255 255 / 54%);
+ border-radius: 50%;
+ box-shadow:
+ inset 0 0 0 1px rgb(0 0 0 / 16%),
+ 0 3px 10px rgb(0 0 0 / 22%);
+
+ &:hover {
+ transform: translateY(-2px);
+ }
+
+ &.active {
+ outline: 2px solid rgb(255 255 255 / 96%);
+ outline-offset: 3px;
+ box-shadow:
+ inset 0 0 0 1px rgb(0 0 0 / 14%),
+ 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent),
+ 0 7px 18px rgb(0 0 0 / 26%);
+ }
+ }
+
+ > .eraser-size-control,
+ > .mirror-segment-control {
+ --control-thumb-hover-transform: scale(1.03);
+ --control-thumb-radius: 50%;
+ --control-thumb-transform: none;
+ --range-progress: var(--control-progress);
+ --range-track-height: 7px;
+ --range-fill: rgb(var(--control-rgb) / 72%);
+ --range-empty: rgb(255 255 255 / 24%);
+ --range-track-shadow: inset 0 1px 2px rgb(0 0 0 / 24%);
+ --range-thumb-width: var(--control-thumb-width);
+ --range-thumb-height: var(--control-thumb-height);
+ --range-thumb-border: 2px solid rgb(255 255 255 / 92%);
+ --range-thumb-radius: var(--control-thumb-radius);
+ --range-thumb-background: var(--control-thumb-background);
+ --range-thumb-shadow:
+ inset 0 1px 2px rgb(255 255 255 / 22%), 0 4px 12px rgb(0 0 0 / 30%);
+ --range-thumb-hover-shadow:
+ inset 0 1px 2px rgb(255 255 255 / 22%), 0 0 0 4px rgb(var(--control-rgb) / 22%),
+ 0 5px 14px rgb(0 0 0 / 34%);
+ --range-thumb-hover-transform: var(--control-thumb-hover-transform);
+ --range-thumb-transform: var(--control-thumb-transform);
+ --range-thumb-transition:
+ box-shadow var(--transition-time), height var(--transition-time),
+ margin-top var(--transition-time), transform var(--transition-time),
+ width var(--transition-time);
+
+ position: relative;
+ display: grid;
+ align-items: center;
+ width: 184px;
+ height: 46px;
+ flex: 0 0 184px;
+ padding: 0 12px;
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 14%);
+ border-radius: 8px;
+ background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%));
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / 6%),
+ 0 3px 10px rgb(0 0 0 / 18%);
+ cursor: ew-resize;
+ transition:
+ border-color var(--transition-time),
+ background-color var(--transition-time),
+ box-shadow var(--transition-time),
+ transform var(--transition-time);
+
+ &:hover {
+ border-color: rgb(255 255 255 / 24%);
+ transform: translateY(-2px);
+ }
+
+ &.active {
+ border-color: rgb(var(--control-rgb) / 72%);
+ background-color: rgb(var(--control-rgb) / 11%);
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / 10%),
+ 0 0 0 5px rgb(var(--control-rgb) / 28%),
+ 0 6px 15px rgb(0 0 0 / 22%);
+ }
+
+ input[type='range'] {
+ @include toolbar-range-input();
+ }
+ }
+
+ > .eraser-size-control {
+ --control-progress: var(--eraser-progress, 33%);
+ --control-rgb: 255 140 117;
+ --control-thumb-background:
+ linear-gradient(
+ 110deg,
+ transparent 0 12%,
+ rgb(255 255 255 / 44%) 13% 20%,
+ transparent 21% 100%
+ ),
+ linear-gradient(
+ 90deg,
+ #ff8fa3 0 52%,
+ rgb(54 46 51 / 78%) 53% 56%,
+ #f5eee5 57% 100%
+ );
+ --control-thumb-height: calc(21px * var(--eraser-control-scale, 1));
+ --control-thumb-hover-transform: rotate(-10deg) scale(1.03);
+ --control-thumb-radius: calc(6px * var(--eraser-control-scale, 1));
+ --control-thumb-transform: rotate(-10deg);
+ --control-thumb-width: calc(34px * var(--eraser-control-scale, 1));
+ }
+
+ > .mirror-segment-control {
+ --control-progress: var(--mirror-progress, 0%);
+ --control-rgb: 148 233 203;
+ --control-thumb-background:
+ radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px),
+ repeating-conic-gradient(
+ from -90deg,
+ rgb(218 255 241) 0 8deg,
+ rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg)
+ );
+ --control-thumb-height: 44px;
+ --control-thumb-width: 44px;
+ }
+ }
+}
diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss
new file mode 100644
index 0000000..8ee7737
--- /dev/null
+++ b/src/style/toolbar/_layout.scss
@@ -0,0 +1,137 @@
+@use 'shared' as *;
+
+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;
+ justify-content: center;
+ width: 100%;
+ max-width: 100%;
+ margin: 0 auto;
+ padding-inline: clamp(8px, 1.4vw, 14px);
+ column-gap: 0;
+ row-gap: 0;
+ border-radius: 12px;
+ color: rgb(245 250 244 / 92%);
+ background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)),
+ inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)),
+ 0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%));
+ backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px))
+ brightness(calc(1 - var(--toolbar-background-strength) * 0.38))
+ saturate(calc(1 - var(--toolbar-background-strength) * 0.18));
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 1;
+ transition:
+ backdrop-filter var(--transition-time-long),
+ 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;
+ border: 0;
+ font: inherit;
+ cursor: pointer;
+ @include toolbar-button-transition();
+
+ &:disabled {
+ cursor: progress;
+ opacity: 0.58;
+ }
+
+ &:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 2px;
+ }
+ }
+
+ > .toolbar-shell {
+ grid-area: controls;
+ display: grid;
+ grid-template-areas: 'swatches';
+ grid-template-columns: minmax(0, 1fr);
+ align-items: center;
+ justify-content: center;
+ justify-self: center;
+ width: min(100%, var(--toolbar-top-max-width));
+ min-width: 0;
+ padding: 8px 9px;
+ }
+
+ > .vibe-button {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 52px;
+ height: auto;
+ min-height: 66px;
+ flex: 0 0 auto;
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ color: rgb(255 255 255 / 70%);
+ font-size: 0;
+ line-height: 1;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 18px;
+ height: 18px;
+ border-color: currentColor;
+ border-style: solid;
+ border-width: 0 0 3px 3px;
+ transform: translate(-35%, -50%) rotate(45deg);
+ }
+
+ &.next-vibe::before {
+ border-width: 3px 3px 0 0;
+ transform: translate(-65%, -50%) rotate(45deg);
+ }
+
+ &:hover {
+ color: color-mix(in srgb, var(--accent-color) 70%, white);
+ }
+
+ &.previous-vibe:hover {
+ transform: translateX(-2px);
+ }
+
+ &.next-vibe:hover {
+ transform: translateX(2px);
+ }
+ }
+
+ > .previous-vibe {
+ grid-area: previous;
+ }
+
+ > .next-vibe {
+ grid-area: next;
+ }
+}
diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss
new file mode 100644
index 0000000..2e66635
--- /dev/null
+++ b/src/style/toolbar/_responsive.scss
@@ -0,0 +1,156 @@
+@use '../mixins' as *;
+
+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: 0;
+
+ > .vibe-button {
+ width: 36px;
+ min-height: 44px;
+
+ &::before {
+ width: 14px;
+ height: 14px;
+ }
+ }
+
+ > .toolbar-shell {
+ padding: 4px;
+ }
+
+ > nav.buttons {
+ justify-self: stretch;
+ justify-content: space-between;
+ gap: clamp(1px, 0.55vw, 2px);
+ width: auto;
+ max-width: none;
+ margin-inline: -4px;
+
+ > button {
+ width: auto;
+ height: 38px;
+ flex: 1 1 clamp(28px, 8vw, 38px);
+ max-width: 38px;
+ min-height: 38px;
+
+ &::after {
+ width: 17px;
+ height: 17px;
+ }
+ }
+
+ > .audio-control {
+ width: auto;
+ height: 38px;
+ flex: 2 1 clamp(58px, 18vw, 118px);
+ max-width: 118px;
+ padding-right: clamp(4px, 1.8vw, 9px);
+
+ > button {
+ width: auto;
+ flex: 1 1 clamp(28px, 8vw, 38px);
+ min-width: 0;
+ }
+
+ > .volume-control {
+ height: 38px;
+ }
+ }
+
+ > .export-status {
+ flex-basis: 0;
+ max-width: 0;
+ text-align: center;
+ }
+ }
+
+ > .toolbar-shell > .garden-controls {
+ padding: 2px 4px;
+
+ > .swatches {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ justify-items: center;
+ justify-content: stretch;
+ width: 100%;
+ min-width: 0;
+ min-height: 48px;
+ flex: 1 1 100%;
+ 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;
+ }
+
+ > .eraser-size-control,
+ > .mirror-segment-control {
+ justify-self: stretch;
+ width: 100%;
+ min-width: 0;
+ height: 38px;
+ padding: 0 7px;
+ }
+
+ > .eraser-size-control {
+ grid-column: 1 / span 3;
+ }
+
+ > .mirror-segment-control {
+ --control-thumb-height: 34px;
+ --control-thumb-width: 34px;
+
+ grid-column: 4 / span 3;
+ }
+ }
+ }
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html > body > aside.control-dock > .toolbar-row {
+ > .vibe-button.previous-vibe:hover,
+ > .vibe-button.next-vibe:hover,
+ > .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover,
+ > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover,
+ > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover {
+ transform: none;
+ }
+
+ > nav.buttons > button:hover::after,
+ > nav.buttons > .audio-control > button:hover::after {
+ transform: none;
+ }
+
+ > nav.buttons > .audio-control > .volume-control input[type='range'] {
+ &::-webkit-slider-thumb:hover {
+ transform: none;
+ }
+ }
+
+ > .toolbar-shell > .garden-controls > .swatches {
+ > .eraser-size-control input[type='range'],
+ > .mirror-segment-control input[type='range'] {
+ &::-webkit-slider-thumb:hover {
+ transform: var(--range-thumb-transform, none);
+ }
+ }
+ }
+ }
+}
diff --git a/src/style/toolbar/_shared.scss b/src/style/toolbar/_shared.scss
new file mode 100644
index 0000000..ce4db3d
--- /dev/null
+++ b/src/style/toolbar/_shared.scss
@@ -0,0 +1,105 @@
+$toolbar-icons: (
+ info: 'info',
+ maximize-full-screen: 'maximize',
+ minimize-full-screen: 'minimize',
+ settings: 'settings',
+ sound: 'sound',
+ export-4k: 'download',
+ restart: 'restart',
+);
+
+@mixin toolbar-button-transition() {
+ transition:
+ background-color var(--transition-time),
+ border-color var(--transition-time),
+ color var(--transition-time),
+ box-shadow var(--transition-time),
+ opacity var(--transition-time),
+ transform var(--transition-time);
+}
+
+@mixin toolbar-control-surface($background, $hover-background) {
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background: $background;
+ transition:
+ border-color var(--transition-time),
+ background-color var(--transition-time),
+ box-shadow var(--transition-time),
+ opacity var(--transition-time);
+
+ &:hover {
+ border-color: rgb(255 255 255 / 10%);
+ background: $hover-background;
+ }
+}
+
+@mixin toolbar-range-track() {
+ height: var(--range-track-height);
+ border: var(--range-track-border, 0);
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ var(--range-fill) 0 var(--range-progress),
+ var(--range-empty) var(--range-progress) 100%
+ );
+ box-shadow: var(--range-track-shadow);
+ cursor: ew-resize;
+}
+
+@mixin toolbar-range-thumb() {
+ width: var(--range-thumb-width);
+ height: var(--range-thumb-height);
+ border: var(--range-thumb-border);
+ border-radius: var(--range-thumb-radius);
+ background: var(--range-thumb-background);
+ box-shadow: var(--range-thumb-shadow);
+ cursor: ew-resize;
+ transform: var(--range-thumb-transform, none);
+}
+
+@mixin toolbar-range-input() {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ appearance: none;
+ background: transparent;
+ cursor: ew-resize;
+ outline: none;
+ touch-action: pan-y;
+
+ &:focus-visible {
+ border-radius: 8px;
+ outline: 2px solid white;
+ outline-offset: var(--range-focus-outline-offset, 2px);
+ }
+
+ &::-webkit-slider-runnable-track {
+ @include toolbar-range-track();
+ }
+
+ &::-webkit-slider-thumb {
+ @include toolbar-range-thumb();
+ margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) / 2);
+ appearance: none;
+ transition: var(
+ --range-thumb-transition,
+ box-shadow var(--transition-time),
+ transform var(--transition-time)
+ );
+ }
+
+ &::-webkit-slider-thumb:hover {
+ box-shadow: var(--range-thumb-hover-shadow, var(--range-thumb-shadow));
+ transform: var(--range-thumb-hover-transform, var(--range-thumb-transform, none));
+ }
+
+ &::-moz-range-track {
+ @include toolbar-range-track();
+ }
+
+ &::-moz-range-thumb {
+ @include toolbar-range-thumb();
+ }
+}
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index 200a7f6..759f04f 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -4,10 +4,7 @@ import { clamp } from './math';
export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null;
- constructor(
- private readonly maxDeltaTimeInSeconds?: number,
- private readonly minDeltaTimeInSeconds?: number
- ) {
+ constructor() {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
@@ -20,7 +17,11 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
- return clamp(delta / 1000, this.minDeltaTime, this.maxDeltaTime);
+ return clamp(
+ delta / 1000,
+ appConfig.deltaTime.minDeltaTimeSeconds,
+ appConfig.deltaTime.maxDeltaTimeSeconds
+ );
}
private handleVisibilityChange() {
@@ -28,12 +29,4 @@ export class DeltaTimeCalculator {
this.previousTime = null;
}
}
-
- private get maxDeltaTime(): number {
- return this.maxDeltaTimeInSeconds ?? appConfig.deltaTime.maxDeltaTimeSeconds;
- }
-
- private get minDeltaTime(): number {
- return this.minDeltaTimeInSeconds ?? appConfig.deltaTime.minDeltaTimeSeconds;
- }
}
diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts
index d4fe444..28ecb84 100644
--- a/src/utils/graphics/bind-group-cache.ts
+++ b/src/utils/graphics/bind-group-cache.ts
@@ -17,3 +17,32 @@ export const createBindGroupCache = (
return bindGroup;
};
};
+
+export const createBindGroupCache3 = <
+ K1 extends object,
+ K2 extends object,
+ K3 extends object,
+>(
+ factory: (key1: K1, key2: K2, key3: K3) => GPUBindGroup
+): ((key1: K1, key2: K2, key3: K3) => GPUBindGroup) => {
+ const outer = new WeakMap>>();
+ return (key1, key2, key3) => {
+ let mid = outer.get(key1);
+ if (!mid) {
+ mid = new WeakMap();
+ outer.set(key1, mid);
+ }
+ let inner = mid.get(key2);
+ if (!inner) {
+ inner = new WeakMap();
+ mid.set(key2, inner);
+ }
+ const cached = inner.get(key3);
+ if (cached) {
+ return cached;
+ }
+ const bindGroup = factory(key1, key2, key3);
+ inner.set(key3, bindGroup);
+ return bindGroup;
+ };
+};
diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts
index 48df957..9108060 100644
--- a/src/utils/graphics/initialize-gpu.ts
+++ b/src/utils/graphics/initialize-gpu.ts
@@ -94,15 +94,21 @@ export const initializeGpu = async (): Promise => {
}
const requiredLimits = getRequiredLimits(adapter.limits);
+ const requiredFeatures: Array = [];
+ if (adapter.features.has('timestamp-query')) {
+ requiredFeatures.push('timestamp-query');
+ }
ErrorHandler.addMetadata('webgpuAdapter', {
features: Array.from(adapter.features).sort(),
info: getAdapterInfo(adapter),
+ requiredFeatures,
requiredLimits,
});
let gpuDevice: GPUDevice;
try {
gpuDevice = await adapter.requestDevice({
+ requiredFeatures,
requiredLimits,
});
} catch (error) {
@@ -113,6 +119,7 @@ export const initializeGpu = async (): Promise => {
cause: error,
details: {
causeMessage: getErrorMessage(error),
+ requiredFeatures,
requiredLimits,
},
}
diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts
index d975edc..bd6ef19 100644
--- a/src/utils/graphics/resizable-texture.ts
+++ b/src/utils/graphics/resizable-texture.ts
@@ -6,6 +6,14 @@ interface ResizableTextureOptions {
usage?: GPUTextureUsageFlags;
}
+export interface PendingTextureResize {
+ copySize: GPUExtent3DStrict;
+ newSize: vec2;
+ newTexture: GPUTexture;
+ newTextureView: GPUTextureView;
+ oldTexture: GPUTexture;
+}
+
export class ResizableTexture {
private texture: GPUTexture;
private textureView: GPUTextureView;
@@ -32,10 +40,22 @@ export class ResizableTexture {
}
public resize(size: vec2): void {
- if (vec2.equals(this.size, size)) {
+ const resize = this.prepareResize(size);
+ if (!resize) {
return;
}
+ const commandEncoder = this.device.createCommandEncoder();
+ this.encodeResize(commandEncoder, resize);
+ this.device.queue.submit([commandEncoder.finish()]);
+ this.commitResize(resize);
+ }
+
+ public prepareResize(size: vec2): PendingTextureResize | null {
+ if (vec2.equals(this.size, size)) {
+ return null;
+ }
+
const newTexture = this.createTexture(size);
const newTextureView = newTexture.createView();
const copySize = {
@@ -43,11 +63,23 @@ export class ResizableTexture {
height: Math.min(this.size[1], size[1]),
};
- const commandEncoder = this.device.createCommandEncoder();
+ return {
+ copySize,
+ newSize: vec2.clone(size),
+ newTexture,
+ newTextureView,
+ oldTexture: this.texture,
+ };
+ }
+
+ public encodeResize(
+ commandEncoder: GPUCommandEncoder,
+ resize: PendingTextureResize
+ ): void {
const clearPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
- view: newTextureView,
+ view: resize.newTextureView,
clearValue: this.clearValue,
loadOp: 'clear',
storeOp: 'store',
@@ -56,16 +88,17 @@ export class ResizableTexture {
});
clearPass.end();
commandEncoder.copyTextureToTexture(
- { texture: this.texture },
- { texture: newTexture },
- copySize
+ { texture: resize.oldTexture },
+ { texture: resize.newTexture },
+ resize.copySize
);
- this.device.queue.submit([commandEncoder.finish()]);
- this.texture.destroy();
+ }
- this.size = vec2.clone(size);
- this.texture = newTexture;
- this.textureView = newTextureView;
+ public commitResize(resize: PendingTextureResize): void {
+ resize.oldTexture.destroy();
+ this.size = resize.newSize;
+ this.texture = resize.newTexture;
+ this.textureView = resize.newTextureView;
}
public getSize(): vec2 {
diff --git a/vite.config.ts b/vite.config.ts
index cfe208b..6c4b293 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -11,7 +11,10 @@ const esbuildTargets = browserslistToEsbuild();
export default defineConfig(({ command }) => ({
base: './',
plugins: [
- viteSingleFile({ useRecommendedBuildConfig: false }),
+ viteSingleFile({
+ inlinePattern: ['index-*.js', 'style-*.css'],
+ useRecommendedBuildConfig: false,
+ }),
...(command === 'serve' ? [basicSsl()] : []),
],
css: {