This commit is contained in:
Andras Schmelczer 2026-05-21 07:43:10 +01:00
parent 2fe3c69963
commit 6bc125be1c
104 changed files with 3088 additions and 2414 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules
dist
test-results

View file

@ -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');
});

View file

@ -7,24 +7,64 @@
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#10151f" />
<meta name="robots" content="index,follow" />
<meta name="author" content="Andras Schmelczer" />
<meta
name="description"
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
<meta property="og:title" content="Fleeting Garden" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Fleeting Garden" />
<meta property="og:locale" content="en_US" />
<meta
property="og:description"
content="Pick a vibe, draw coloured paths, and watch them grow into a living WebGPU garden."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Fleeting Garden social preview image." />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Fleeting Garden" />
<meta
name="twitter:description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
"browserRequirements": "Requires a browser with WebGPU support.",
"author": {
"@type": "Person",
"name": "Andras Schmelczer"
},
"sameAs": "https://github.com/schmelczer/webgpu"
}
</script>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon-180x180.png" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180x180.png" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Fleeting Garden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fleeting Garden</title>
</head>
@ -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.
</p>
<div class="garden-grain" aria-hidden="true"></div>
<div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div>
@ -50,7 +91,7 @@
<div class="splash">
<h1 class="splash-title">Fleeting Garden</h1>
<p class="splash-description">
Draw coloured paths and watch them bloom into a living WebGPU garden.
Tend it while you can. The garden returns to weather either way.
</p>
<button class="start-button" type="button" disabled>Start</button>
</div>
@ -85,22 +126,24 @@
<section>
<h1>Fleeting Garden</h1>
<p>
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.
</p>
<p>
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.
</p>
<p>
Switch vibes to recolour the whole garden without clearing your drawing. Add
or mute the generated piano, restart for a blank canvas, or export the current
frame as 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.
</p>
<p>
Built with WebGPU and running locally in your browser. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
Built with WebGPU, running locally in your browser. More of my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a
>.
</p>
</section>

View file

@ -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",

View file

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://schmelczer.dev/fleeting/sitemap.xml

6
public/sitemap.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://schmelczer.dev/fleeting/</loc>
</url>
</urlset>

View file

@ -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 {

View file

@ -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,26 +117,50 @@ export class GardenAudio {
return;
}
this.lifecycle = 'started';
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe));
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
const pianoLoad = this.piano.loadIfIdle(context);
if (pianoLoad) {
void pianoLoad
const startRequestId = ++this.startRequestId;
void this.piano
.load(context)
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
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. Using synthesized audio.',
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.currentVibeId = vibe.id;
this.graph.applyDelayProfile();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (cuePiano) {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
}
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {

View file

@ -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);

View file

@ -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<void> | null {
if (this.loadState !== 'idle') {
return null;
public load(context: BaseAudioContext): Promise<void> {
if (this.loadState === 'loaded') {
return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
@ -80,14 +77,16 @@ 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 *
@ -137,47 +136,6 @@ export class PianoSampler {
);
},
});
return;
}
const noteGainValue = this.computeNoteGain(
noteVelocity,
pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
clamp(
durationSeconds + profileSustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator();
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope: (gain) => {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
});
}
public stopAll(): void {
@ -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);

View file

@ -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<PianoSampleDefinition> = [
{ 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<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
midi,
url: `${sampleBaseUrl}${fileName}`,
}))
.sort((a, b) => a.midi - b.midi);
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | 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<LoadedPianoSample> => {
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<PianoSampleDefinition>,
loadSample: (
sample: PianoSampleDefinition
) => Promise<LoadedPianoSample | null>
): Promise<Array<LoadedPianoSample | null>> => {
const results: Array<LoadedPianoSample | null> = [];
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
): Promise<Array<LoadedPianoSample>> => {
const results: Array<LoadedPianoSample> = [];
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);
}

View file

@ -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,

View file

@ -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,
},
});

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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<VibePreset> = [
],
backgroundColor: [16, 21, 31],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 14,
clarity: 0.62,
@ -50,7 +48,6 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [23, 32, 22],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.014,
brushSize: 16,
clarity: 0.68,
@ -76,7 +73,6 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [15, 24, 34],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.022,
brushSize: 13,
clarity: 0.58,
@ -102,7 +98,6 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [20, 18, 29],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.018,
brushSize: 12,
clarity: 0.64,
@ -128,7 +123,6 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [25, 23, 22],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.024,
brushSize: 15,
clarity: 0.55,
@ -154,7 +148,6 @@ export const vibePresets: Array<VibePreset> = [
],
backgroundColor: [16, 24, 32],
settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.012,
brushSize: 18,
clarity: 0.7,

View file

@ -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;

View file

@ -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 },

View file

@ -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, {
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();
}

View file

@ -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;
}

View file

@ -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<void>();
@ -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;
}
}

View file

@ -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<Record<GpuPassName, number>>;
totalPassMs: number;
}
interface FleetingGardenPerf {
latest?: GpuProfilerSample;
samples: Array<GpuProfilerSample>;
}
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<ReadbackSlot>;
private activePasses: Array<ActivePass> = [];
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<ActivePass>,
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);
}
}
}

View file

@ -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
);

View file

@ -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)

View file

@ -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);
};

View file

@ -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;
}

View file

@ -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);
}

View file

@ -6,4 +6,3 @@
@use 'style/config-pane';
@use 'style/panels';
@use 'style/loading';
@use 'style/motion';

View file

@ -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<typeof ErrorHandler.addOnErrorListener>[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<typeof queryAppElements>;
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,
});
new VibeNavigator({
onChange: ({ vibeId, vibeName, source }) => {
trackVibeChange({ vibeId, vibeName, 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);
configPane?.refresh();
});
},
});
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<void>((resolve) => {
const onClick = () => {
elements.startButton.removeEventListener('click', onClick);
await splash.awaitStart(() => {
hasStarted = true;
game?.startAudio(true);
trackStart();
elements.splash.hidden = true;
resolve();
};
elements.startButton.addEventListener('click', onClick);
});
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);

158
src/page/audio-control.ts Normal file
View file

@ -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)
);
}
}

View file

@ -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<RuntimeControlKey>;
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;
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,62 @@
import {
ErrorHandler,
getErrorMessage,
RuntimeError,
Severity,
} from '../utils/error-handler';
type RuntimeUiError = Parameters<
Parameters<typeof ErrorHandler.addOnErrorListener>[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));
}
}

View file

@ -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`);
}
}

View file

@ -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)
);
}
}

47
src/page/splash-screen.ts Normal file
View file

@ -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<void> {
this.startButton.disabled = false;
return new Promise<void>((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;
}
}

View file

@ -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,
});
}
}

View file

@ -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,

View file

@ -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)
);
}

View file

@ -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
)
);
};

View file

@ -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, WeakMap<GPUTextureView, GPUBindGroup>>
>();
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' },
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
agentSchema,
shader
);
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
});
this.pipeline = device.createComputePipeline({
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<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
}
let outputCache = textureCache.get(trailMapIn);
if (!outputCache) {
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
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',
},
},
],
};
}
}

View file

@ -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<f32>(0, 0), maxPosition);
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
}
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>(
trailBelow.rgb + channelMask * settings.individualTrailWeight,
max(trailBelow.a, 0.0)
);
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
agents[id].angle = angle + rotation;
agents[id].position = nextPosition;
}
@compute @workgroup_size(64)
fn mainNormal(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
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<f32>(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<f32>(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<i32>(nextPosition), 0);

View file

@ -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<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
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<LineSegment>): Array<LineSegment> {
if (segments.length <= BrushPipeline.MAX_LINE_COUNT) {
return segments;
}
const result: Array<LineSegment> = [];
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<GPUTextureView>
): boolean {
if (this.lineCount === 0) {
const lineCount = this.segments.activeCount;
if (lineCount === 0) {
return false;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((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;
}
}

View file

@ -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<f32>,
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<f32> {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
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<f32>,
end: vec2<f32>,
radius: f32
) -> vec2<f32> {
let directionVector = end - start;
let segmentLength = length(directionVector);
var direction = vec2<f32>(1.0, 0.0);
if segmentLength > 0.0 {
direction = directionVector / segmentLength;
}
let perpendicular = vec2<f32>(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<f32> {
let corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, -1.0),
);
return corners[index];
}

View file

@ -23,7 +23,7 @@ export class CommonState {
public static readonly shaderCode = /* wgsl */ `
struct State {
size: vec2<f32>,
time: f32,
_padding: vec2<f32>,
};
@group(0) @binding(0) var<uniform> 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,

View file

@ -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<LineSegment> = [];
private active: Array<LineSegment> = [];
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<LineSegment>, count: number): Array<LineSegment> => {
const result: Array<LineSegment> = [];
for (let i = 0; i < count; i++) {
const index = Math.round((i * (segments.length - 1)) / (count - 1));
result.push(segments[index]);
}
return result;
};

View file

@ -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<f32> {
let isRight = index == 2u || index >= 4u;
let isTop = index == 0u || index == 2u || index == 4u;
return vec2<f32>(
select(-1.0, 1.0, isRight),
select(-1.0, 1.0, isTop)
);
}
fn segment_vertex_position(
vertexIndex: u32,
start: vec2<f32>,
end: vec2<f32>,
radius: f32
) -> vec2<f32> {
let directionVector = end - start;
let segmentLength = length(directionVector);
var direction = vec2<f32>(1.0, 0.0);
if segmentLength > 0.0 {
direction = directionVector / segmentLength;
}
let perpendicular = vec2<f32>(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<f32>,
start: vec2<f32>,
direction: vec2<f32>,
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);
}

View file

@ -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<uniform> settings: Settings;
@group(0) @binding(1) var trailMap: texture_2d<f32>;
@ -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<i32>) -> 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);
}

View file

@ -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(

View file

@ -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),
};
};

View file

@ -3,6 +3,8 @@ struct Settings {
eraserMaskAlphaThreshold: f32,
maskWidth: u32,
maskHeight: u32,
boundsMin: vec2<f32>,
boundsMax: vec2<f32>,
};
@group(1) @binding(0) var<uniform> 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>(i32(settings.maskWidth), i32(settings.maskHeight));
let maskPosition = clamp(
vec2<i32>(agents[id].position),
vec2<i32>(position),
vec2<i32>(0, 0),
maskSize - vec2<i32>(1, 1)
);

View file

@ -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<GPUTextureFormat> = ['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<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
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<GPUTextureFormat>
): 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<LineSegment>): Array<LineSegment> {
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
return segments;
}
const result: Array<LineSegment> = [];
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;
}
}

View file

@ -49,7 +49,13 @@ fn fragmentCombined(
@location(2) @interpolate(flat) direction: vec2<f32>,
@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<f32> {
settings.clearAlpha
);
}
fn shouldDiscardEraserFragment(
screenPosition: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
inverseLengthSquared: f32
) -> bool {
return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared;
}
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
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<f32>,
end: vec2<f32>,
radius: f32
) -> vec2<f32> {
let directionVector = end - start;
let segmentLength = length(directionVector);
var direction = vec2<f32>(1.0, 0.0);
if segmentLength > 0.0 {
direction = directionVector / segmentLength;
}
let perpendicular = vec2<f32>(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<f32> {
let corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, -1.0),
);
return corners[index];
}

View file

@ -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<GPUTextureView, GPUTextureView>(
(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',
},
},
],
};
}
}

View file

@ -1,10 +1,10 @@
struct Settings {
colorA: vec3<f32>,
backgroundColorPadding0: f32,
_colorAPadding: f32,
colorB: vec3<f32>,
backgroundColorPadding1: f32,
_colorBPadding: f32,
colorC: vec3<f32>,
backgroundColorPadding2: f32,
_colorCPadding: f32,
backgroundColor: vec3<f32>,
clarity: f32,
traceNormalizationFloor: f32,
@ -24,18 +24,32 @@ fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, pixel);
return renderColor(traces, sources, getTexturedBackground(pixel));
}
@fragment
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), pixel);
return renderColor(traces, vec4<f32>(0.0), getTexturedBackground(pixel));
}
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
let background = getTexturedBackground(pixel);
@fragment
fn fragmentNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, getFlatBackground());
}
@fragment
fn fragmentNoSourceNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
}
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
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>) -> 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<f32>) -> vec3<f32> {
@ -101,10 +119,11 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
return color / max(settings.traceNormalizationFloor, brightestChannel);
}
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
if settings.backgroundGrainStrength == 0.0 {
fn getFlatBackground() -> vec3<f32> {
return clamp(settings.backgroundColor, vec3(0), vec3(1));
}
}
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;

View file

@ -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));

View file

@ -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));

View file

@ -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;
}
}
}
}
}

View file

@ -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';

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show more