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 node_modules
dist 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-style', 'solid');
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px'); 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" content="width=device-width,initial-scale=1,viewport-fit=cover"
/> />
<meta name="theme-color" content="#10151f" /> <meta name="theme-color" content="#10151f" />
<meta name="robots" content="index,follow" />
<meta name="author" content="Andras Schmelczer" />
<meta <meta
name="description" 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: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 <meta
property="og:description" 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:url" content="https://schmelczer.dev/fleeting/" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" /> <meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta property="og:image:width" content="1920" /> <meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:height" content="1920" /> <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="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" /> <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> <title>Fleeting Garden</title>
</head> </head>
@ -43,6 +83,7 @@
to paint coloured paths, then use the toolbar to change colours, erase, export, to paint coloured paths, then use the toolbar to change colours, erase, export,
adjust the config overlay, restart, or open more information. adjust the config overlay, restart, or open more information.
</p> </p>
<div class="garden-grain" aria-hidden="true"></div>
<div class="eraser-preview" aria-hidden="true"></div> <div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div> <div class="garden-prompt" aria-live="polite"></div>
@ -50,7 +91,7 @@
<div class="splash"> <div class="splash">
<h1 class="splash-title">Fleeting Garden</h1> <h1 class="splash-title">Fleeting Garden</h1>
<p class="splash-description"> <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> </p>
<button class="start-button" type="button" disabled>Start</button> <button class="start-button" type="button" disabled>Start</button>
</div> </div>
@ -85,22 +126,24 @@
<section> <section>
<h1>Fleeting Garden</h1> <h1>Fleeting Garden</h1>
<p> <p>
A living sketchpad where each stroke becomes a trail that agents follow, A garden is what we tend; the wild is what we get the moment we look away.
branch from, and weave into the scene. 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>
<p> <p>
Paint with the three colour swatches, carve space with the eraser, and raise Three swatches plant the line. The eraser carves a clearing. The mirror folds
the mirror control when you want radial patterns instead of a single line. one gesture into many, like footpaths around a hidden well.
</p> </p>
<p> <p>
Switch vibes to recolour the whole garden without clearing your drawing. Add Switch vibes to change the season; your shapes stay, the light moves. Add or
or mute the generated piano, restart for a blank canvas, or export the current quiet the piano. Restart when you want a fresh field. Take a snapshot if you
frame as an internal buffer snapshot. want to keep one particular instant of weather.
</p> </p>
<p> <p>
Built with WebGPU and running locally in your browser. Source on Built with WebGPU, running locally in your browser. More of my work at
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener" <a href="https://schmelczer.dev" target="_blank" rel="noopener"
>GitHub</a >schmelczer.dev</a
>. >.
</p> </p>
</section> </section>

View file

@ -3,7 +3,7 @@
"version": "0.2.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "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": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",

View file

@ -1,8 +1,9 @@
{ {
"name": "Fleeting Garden", "name": "Fleeting Garden",
"short_name": "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": "./", "start_url": "./",
"scope": "./",
"display": "fullscreen", "display": "fullscreen",
"background_color": "#10151f", "background_color": "#10151f",
"theme_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'; import type { PianoNoteRole } from './garden-audio-types';
export interface GardenAudioChord { export interface GardenAudioChord {

View file

@ -38,6 +38,7 @@ export class GardenAudio {
private stopPianoAt: number | null = null; private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY; private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
private startRequestId = 0;
public constructor(private readonly config: GardenAudioConfig) { public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume); this.masterVolume = clamp01(config.masterVolume);
@ -56,6 +57,14 @@ export class GardenAudio {
return; return;
} }
if (
this.lifecycle === 'started' &&
this.currentVibeId === vibe.id &&
this.graph.context?.state === 'running'
) {
return;
}
const context = this.graph.ensureContext(isUserGesture); const context = this.graph.ensureContext(isUserGesture);
if (!context) { if (!context) {
return; return;
@ -108,25 +117,49 @@ export class GardenAudio {
return; return;
} }
const startRequestId = ++this.startRequestId;
void this.piano
.load(context)
.then(() => {
if (!this.canCompleteStart(context, startRequestId)) {
return;
}
this.activateStart(vibe, context, startupRampSeconds, true);
})
.catch((error) => {
if (this.canCompleteStart(context, startRequestId)) {
this.activateStart(vibe, context, startupRampSeconds, false);
}
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples.',
severity: Severity.WARNING,
});
});
}
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
return (
this.graph.context === context &&
this.lifecycle !== 'destroyed' &&
!this.isMuted &&
this.startRequestId === startRequestId
);
}
private activateStart(
vibe: VibePreset,
context: AudioContext,
startupRampSeconds: number,
cuePiano: boolean
): void {
this.lifecycle = 'started'; this.lifecycle = 'started';
this.applyVibe(vibe); this.currentVibeId = vibe.id;
this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe)); this.graph.applyDelayProfile();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds); this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
const pianoLoad = this.piano.loadIfIdle(context); if (cuePiano) {
if (pianoLoad) { this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
void pianoLoad
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
severity: Severity.WARNING,
});
});
} }
} }

View file

@ -46,13 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb
type GardenAudioStyleIndex = 0 | 1 | 2; type GardenAudioStyleIndex = 0 | 1 | 2;
interface TouchDownRequest {
vibe: VibePreset;
now: number;
strength: number;
maniaAmount?: number;
}
interface PitchCandidate { interface PitchCandidate {
midi: number; midi: number;
preference: number; preference: number;
@ -152,7 +145,12 @@ export class GenerativePianoEngine {
now, now,
strength, strength,
maniaAmount = 0, maniaAmount = 0,
}: TouchDownRequest): void { }: {
vibe: VibePreset;
now: number;
strength: number;
maniaAmount?: number;
}): void {
const normalizedStrength = clamp01(strength); const normalizedStrength = clamp01(strength);
const normalizedManiaAmount = clamp01(maniaAmount); const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now); const styleIndex = this.getStyleIndex(now);

View file

@ -21,9 +21,6 @@ const pianoSamplerTuning = {
minDurationSeconds: 0.08, minDurationSeconds: 0.08,
minFadeSeconds: 0.08, minFadeSeconds: 0.08,
minGain: 0.0001, minGain: 0.0001,
synthGainScale: 0.34,
synthMaxDurationSeconds: 1.8,
synthOscillatorType: 'triangle' as OscillatorType,
tailStopExtraSeconds: 0.05, tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025, voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05, voiceStealStopSeconds: 0.05,
@ -39,9 +36,9 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph private readonly graph: GardenAudioGraph
) {} ) {}
public loadIfIdle(context: BaseAudioContext): Promise<void> | null { public load(context: BaseAudioContext): Promise<void> {
if (this.loadState !== 'idle') { if (this.loadState === 'loaded') {
return null; return Promise.resolve();
} }
const loadedSamples = getLoadedPianoSamples(); const loadedSamples = getLoadedPianoSamples();
@ -80,82 +77,32 @@ export class PianoSampler {
return; return;
} }
const sample = this.findNearestSample(midi);
if (!sample) {
return;
}
const scheduledStart = Math.max( const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime startTime
); );
const noteVelocity = clamp01(velocity); const noteVelocity = clamp01(velocity);
const sample = this.findNearestSample(midi); const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
if (sample) { profileSustainSeconds *
const noteGainValue = this.computeNoteGain(noteVelocity); (this.config.piano.sustainBase +
const sustainSeconds = noteVelocity * this.config.piano.sustainVelocityRange);
profileSustainSeconds * const sustainAt =
(this.config.piano.sustainBase + scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
noteVelocity * this.config.piano.sustainVelocityRange); const releaseAt = sustainAt + sustainSeconds;
const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createBufferSource();
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope: (gain) => {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
});
return;
}
const noteGainValue = this.computeNoteGain(
noteVelocity,
pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
clamp(
durationSeconds + profileSustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds; const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator(); const source = context.createBufferSource();
source.type = pianoSamplerTuning.synthOscillatorType; source.buffer = sample.buffer;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart); source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({ this.scheduleVoice({
source, source,
@ -171,6 +118,17 @@ export class PianoSampler {
noteGainValue, noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds scheduledStart + this.config.piano.gainAttackSeconds
); );
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime( gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain, pianoSamplerTuning.minGain,
releaseAt, releaseAt,
@ -312,6 +270,3 @@ export class PianoSampler {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi); 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 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 { interface PianoSampleDefinition {
midi: number; midi: number;
path: string;
url: string; url: string;
} }
@ -10,48 +41,39 @@ export interface PianoSampleLoadProgress {
totalCount: number; totalCount: number;
} }
const sampleFiles: Array<[fileName: string, midi: number]> = [ const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
['A0v12.m4a', 21], { url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
['C1v12.m4a', 24], { url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
['Dsharp1v12.m4a', 27], { url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
['Fsharp1v12.m4a', 30], { url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
['A1v12.m4a', 33], { url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
['C2v12.m4a', 36], { url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
['Dsharp2v12.m4a', 39], { url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
['Fsharp2v12.m4a', 42], { url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
['A2v12.m4a', 45], { url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
['C3v12.m4a', 48], { url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
['Dsharp3v12.m4a', 51], { url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
['Fsharp3v12.m4a', 54], { url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
['A3v12.m4a', 57], { url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
['C4v12.m4a', 60], { url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
['Dsharp4v12.m4a', 63], { url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
['Fsharp4v12.m4a', 66], { url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
['A4v12.m4a', 69], { url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
['C5v12.m4a', 72], { url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
['Dsharp5v12.m4a', 75], { url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
['Fsharp5v12.m4a', 78], { url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
['A5v12.m4a', 81], { url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
['C6v12.m4a', 84], { url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
['Dsharp6v12.m4a', 87], { url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
['Fsharp6v12.m4a', 90], { url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
['A6v12.m4a', 93], { url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
['C7v12.m4a', 96], { url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
['Dsharp7v12.m4a', 99], { url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
['Fsharp7v12.m4a', 102], { url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
['A7v12.m4a', 105], { url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
['C8v12.m4a', 108], { 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 loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null; let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
@ -111,11 +133,11 @@ export const loadPianoSamples = (
} }
).then( ).then(
(samples) => { (samples) => {
loadedPianoSamples = samples loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
.filter((sample): sample is LoadedPianoSample => sample !== null) if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
.sort((a, b) => a.midi - b.midi); throw new Error(
if (loadedPianoSamples.length === 0) { `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
throw new Error('Unable to load any piano samples.'); );
} }
return [...loadedPianoSamples]; return [...loadedPianoSamples];
}, },
@ -138,7 +160,7 @@ const loadPianoSample = async (
): Promise<LoadedPianoSample> => { ): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url, { signal }); const response = await fetch(sample.url, { signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`); throw new Error(`Unable to load piano sample ${sample.path}`);
} }
const audioData = await response.arrayBuffer(); const audioData = await response.arrayBuffer();
@ -148,17 +170,13 @@ const loadPianoSample = async (
const loadPianoSampleBatch = async ( const loadPianoSampleBatch = async (
samples: Array<PianoSampleDefinition>, samples: Array<PianoSampleDefinition>,
loadSample: ( loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
sample: PianoSampleDefinition ): Promise<Array<LoadedPianoSample>> => {
) => Promise<LoadedPianoSample | null> const results: Array<LoadedPianoSample> = [];
): Promise<Array<LoadedPianoSample | null>> => {
const results: Array<LoadedPianoSample | null> = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency); const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
const batchResults = await Promise.all( const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
batch.map((sample) => loadSample(sample).catch(() => null))
);
results.push(...batchResults); 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 { createGardenAudioConfig } from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings'; import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls'; import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types'; import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets'; import { defaultVibeId, vibePresets } from './config/vibe-presets';
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
export type { export type {
GardenAppConfig, GardenAppConfig,
@ -60,7 +60,9 @@ export const appConfig = {
brushEffectFramesPerSecond: 60, brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 }, clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000, initialAgentCount: 180_000,
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: { intro: {
angleJitterRadians: Math.PI * 0.08, angleJitterRadians: Math.PI * 0.08,
angleEaseEnd: 1, angleEaseEnd: 1,
@ -103,7 +105,6 @@ export const appConfig = {
introMoveSpeedBaseMultiplier: 1.8, introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35, introMoveSpeedProgressMultiplier: 0.35,
stroke: { stroke: {
angleJitterRadians: Math.PI * 0.7,
densityMultiplier: 110, densityMultiplier: 110,
maxAgentCount: 2_400, maxAgentCount: 2_400,
minAgentCount: 140, minAgentCount: 140,
@ -178,7 +179,7 @@ export const appConfig = {
tuningPane: { tuningPane: {
showFpsOverlay: import.meta.env.DEV, showFpsOverlay: import.meta.env.DEV,
startHidden: true, startHidden: true,
title: 'Garden Config', title: 'Garden Settings',
}, },
vibes: { vibes: {
defaultVibeId, defaultVibeId,

View file

@ -19,8 +19,8 @@ export const colorInteractionControl = (label: string): NumberControlConfig => (
max: 1, max: 1,
step: 1, step: 1,
options: { options: {
Follow: 1, 'Move Toward': 1,
Avoid: -1,
Ignore: 0, 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'; 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'] = { export const defaultSettings: GardenAppConfig['defaultSettings'] = {
...colorInteractionSettings,
selectedColorIndex: 0, selectedColorIndex: 0,
turnWhenLost: 0.8, turnWhenLost: 0.8,
@ -31,6 +58,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushCurveMirrorResolutionExponent: 0.5, brushCurveMirrorResolutionExponent: 0.5,
brushCurveSegmentBrushRadiusRatio: 0.65, brushCurveSegmentBrushRadiusRatio: 0.65,
brushSmoothingMinSampleDistance: 0.5, brushSmoothingMinSampleDistance: 0.5,
strokeAngleJitterRadians: Math.PI * 0.7,
brushAlpha: 1, brushAlpha: 1,
brushDiscardThreshold: 0.02, brushDiscardThreshold: 0.02,
@ -49,7 +77,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
adaptiveCapInitial: 1_000_000, adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000, adaptiveCapMin: 50_000,
internalRenderAreaMegapixels: 8.3, internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
maxAgentCount: 700_000, maxAgentCount: 700_000,
renderTraceNormalizationFloor: 1, renderTraceNormalizationFloor: 1,

View file

@ -2,49 +2,59 @@ import { colorInteractionControl } from './color-interactions';
import type { GardenAppConfig } from './types'; import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; 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'] = { export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('1 -> 1'), color1ToColor1: colorInteractionControl('Primary Follows Primary'),
color1ToColor2: colorInteractionControl('1 -> 2'), color1ToColor2: colorInteractionControl('Primary Follows Secondary'),
color1ToColor3: colorInteractionControl('1 -> 3'), color1ToColor3: colorInteractionControl('Primary Follows Accent'),
color2ToColor1: colorInteractionControl('2 -> 1'), color2ToColor1: colorInteractionControl('Secondary Follows Primary'),
color2ToColor2: colorInteractionControl('2 -> 2'), color2ToColor2: colorInteractionControl('Secondary Follows Secondary'),
color2ToColor3: colorInteractionControl('2 -> 3'), color2ToColor3: colorInteractionControl('Secondary Follows Accent'),
color3ToColor1: colorInteractionControl('3 -> 1'), color3ToColor1: colorInteractionControl('Accent Follows Primary'),
color3ToColor2: colorInteractionControl('3 -> 2'), color3ToColor2: colorInteractionControl('Accent Follows Secondary'),
color3ToColor3: colorInteractionControl('3 -> 3'), color3ToColor3: colorInteractionControl('Accent Follows Accent'),
brushSize: { brushSize: {
folder: 'Brush', folder: 'Brush',
label: 'brush size', label: 'Brush Size',
min: 1, min: 1,
max: 60, max: 60,
step: 0.25, step: 0.25,
}, },
spawnPerPixel: { spawnPerPixel: {
folder: 'Brush', folder: 'Brush',
label: 'agents per brush pixel', label: 'Agent Density',
min: 0.01, min: 0.01,
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
strokeAngleJitterRadians: {
folder: 'Brush',
format: formatRadiansAsDegrees,
label: 'Spawn Spread',
min: 0,
max: Math.PI * 2,
step: 0.01,
},
sensorOffsetDistance: { sensorOffsetDistance: {
folder: 'Agents', folder: 'Agents',
label: 'sensor distance', label: 'Sensor Reach',
min: 0, min: 0,
max: 200, max: 200,
step: 1, step: 1,
}, },
moveSpeed: { moveSpeed: {
folder: 'Agents', folder: 'Agents',
label: 'move speed', label: 'Travel Speed',
min: 10, min: 10,
max: 500, max: 500,
step: 1, step: 1,
}, },
turnSpeed: { turnSpeed: {
folder: 'Agents', folder: 'Agents',
label: 'turn speed', label: 'Turning Speed',
min: 1, min: 1,
max: 200, max: 200,
step: 1, step: 1,
@ -52,28 +62,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
forwardRotationScale: { forwardRotationScale: {
folder: 'Agents', folder: 'Agents',
format: formatPercent, format: formatPercent,
label: 'following sensor %', label: 'Forward Focus',
min: 0, min: 0,
max: 1, max: 1,
step: 0.01, step: 0.01,
}, },
turnWhenLost: { turnWhenLost: {
folder: 'Agents', folder: 'Agents',
label: 'turn when lost', label: 'Wander Turn',
min: 0, min: 0,
max: 6.28, max: 6.28,
step: 0.01, step: 0.01,
}, },
individualTrailWeight: { individualTrailWeight: {
folder: 'Agents', folder: 'Agents',
label: 'individual trail weight', label: 'Trail Strength',
min: 0, min: 0,
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
decayRateTrails: { decayRateTrails: {
folder: 'Agents', folder: 'Agents',
label: 'trail decay', label: 'Trail Fade',
min: 800, min: 800,
max: 1000, max: 1000,
step: 1, step: 1,
@ -81,14 +91,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
clarity: { clarity: {
folder: 'Look', folder: 'Look',
label: 'clarity', label: 'Sharpness',
min: 0.00001, min: 0.00001,
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
backgroundGrainStrength: { backgroundGrainStrength: {
folder: 'Look', folder: 'Look',
label: 'grain strength', label: 'Background Grain',
min: 0, min: 0,
max: 0.12, max: 0.12,
step: 0.001, step: 0.001,
@ -97,14 +107,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
maxAgentCount: { maxAgentCount: {
folder: 'Performance', folder: 'Performance',
integer: true, integer: true,
label: 'max agent count', label: 'Agent Limit',
min: 0, min: 0,
max: 2_000_000,
step: 10_000, step: 10_000,
}, },
internalRenderAreaMegapixels: { internalRenderAreaMegapixels: {
folder: 'Performance', folder: 'Performance',
label: 'internal resolution (MP)', label: 'Render Quality (MP)',
min: 0.5, min: 0.5,
max: 16.6, max: 16.6,
step: 0.1, step: 0.1,

View file

@ -41,6 +41,7 @@ export type GardenRuntimeSettings = {
maxAgentCount: number; maxAgentCount: number;
selectedColorIndex: number; selectedColorIndex: number;
spawnPerPixel: number; spawnPerPixel: number;
strokeAngleJitterRadians: number;
} & AgentSettings & } & AgentSettings &
BrushSettings & BrushSettings &
DiffusionSettings & DiffusionSettings &
@ -55,15 +56,6 @@ type GardenVibeSettings = Pick<
| 'backgroundGrainStrength' | 'backgroundGrainStrength'
| 'brushSize' | 'brushSize'
| 'clarity' | 'clarity'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'decayRateTrails' | 'decayRateTrails'
| 'individualTrailWeight' | 'individualTrailWeight'
| 'moveSpeed' | 'moveSpeed'
@ -144,7 +136,7 @@ export interface GardenAppConfig {
brushEffectFramesPerSecond: number; brushEffectFramesPerSecond: number;
clearColor: GPUColor; clearColor: GPUColor;
initialAgentCount: number; initialAgentCount: number;
maxDevicePixelRatio: number; sourceActiveFramesAfterWrite: number;
intro: { intro: {
angleJitterRadians: number; angleJitterRadians: number;
angleEaseEnd: number; angleEaseEnd: number;
@ -187,7 +179,6 @@ export interface GardenAppConfig {
introMoveSpeedBaseMultiplier: number; introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number; introMoveSpeedProgressMultiplier: number;
stroke: { stroke: {
angleJitterRadians: number;
densityMultiplier: number; densityMultiplier: number;
maxAgentCount: number; maxAgentCount: number;
minAgentCount: number; minAgentCount: number;

View file

@ -1,5 +1,4 @@
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import { colorInteractionSettings } from './color-interactions';
import { VibeId, type VibePreset } from './types'; import { VibeId, type VibePreset } from './types';
const defaultAudioSettings = { const defaultAudioSettings = {
@ -24,7 +23,6 @@ export const vibePresets: Array<VibePreset> = [
], ],
backgroundColor: [16, 21, 31], backgroundColor: [16, 21, 31],
settings: { settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.018,
brushSize: 14, brushSize: 14,
clarity: 0.62, clarity: 0.62,
@ -50,7 +48,6 @@ export const vibePresets: Array<VibePreset> = [
], ],
backgroundColor: [23, 32, 22], backgroundColor: [23, 32, 22],
settings: { settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.014, backgroundGrainStrength: 0.014,
brushSize: 16, brushSize: 16,
clarity: 0.68, clarity: 0.68,
@ -76,7 +73,6 @@ export const vibePresets: Array<VibePreset> = [
], ],
backgroundColor: [15, 24, 34], backgroundColor: [15, 24, 34],
settings: { settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.022, backgroundGrainStrength: 0.022,
brushSize: 13, brushSize: 13,
clarity: 0.58, clarity: 0.58,
@ -102,7 +98,6 @@ export const vibePresets: Array<VibePreset> = [
], ],
backgroundColor: [20, 18, 29], backgroundColor: [20, 18, 29],
settings: { settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.018,
brushSize: 12, brushSize: 12,
clarity: 0.64, clarity: 0.64,
@ -128,7 +123,6 @@ export const vibePresets: Array<VibePreset> = [
], ],
backgroundColor: [25, 23, 22], backgroundColor: [25, 23, 22],
settings: { settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.024, backgroundGrainStrength: 0.024,
brushSize: 15, brushSize: 15,
clarity: 0.55, clarity: 0.55,
@ -154,7 +148,6 @@ export const vibePresets: Array<VibePreset> = [
], ],
backgroundColor: [16, 24, 32], backgroundColor: [16, 24, 32],
settings: { settings: {
...colorInteractionSettings,
backgroundGrainStrength: 0.012, backgroundGrainStrength: 0.012,
brushSize: 18, brushSize: 18,
clarity: 0.7, clarity: 0.7,

View file

@ -165,9 +165,7 @@ export class AgentPopulation {
const t = count === 1 ? 1 : i / (count - 1); const t = count === 1 ? 1 : i / (count - 1);
const x = from[0] + (to[0] - from[0]) * t; const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t; const y = from[1] + (to[1] - from[1]) * t;
const angle = const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT; const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread; this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread; this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;

View file

@ -10,6 +10,7 @@ interface ExportSnapshotRendererOptions {
getSourceSize: () => { width: number; height: number }; getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView; getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView; getSourceTextureView: () => GPUTextureView;
getSourceActive?: () => boolean;
getVibeId: () => VibeId; getVibeId: () => VibeId;
} }
@ -70,7 +71,8 @@ export class ExportSnapshotRenderer {
commandEncoder, commandEncoder,
this.options.getColorTextureView(), this.options.getColorTextureView(),
this.options.getSourceTextureView(), this.options.getSourceTextureView(),
texture.createView() texture.createView(),
this.options.getSourceActive?.() ?? true
); );
commandEncoder.copyTextureToBuffer( commandEncoder.copyTextureToBuffer(
{ texture }, { texture },

View file

@ -12,6 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings'; import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context'; import { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures'; import { SimulationTextures } from './simulation-textures';
@ -36,6 +37,7 @@ export class GameLoopResources {
public readonly eraserTexturePipeline: EraserTexturePipeline; public readonly eraserTexturePipeline: EraserTexturePipeline;
public readonly diffusionPipeline: DiffusionPipeline; public readonly diffusionPipeline: DiffusionPipeline;
public readonly renderPipeline: RenderPipeline; public readonly renderPipeline: RenderPipeline;
public readonly gpuProfiler: GpuProfiler | null;
private readonly frameRenderer: SimulationFrameRenderer; private readonly frameRenderer: SimulationFrameRenderer;
@ -52,7 +54,6 @@ export class GameLoopResources {
this.commonState = new CommonState(this.device); this.commonState = new CommonState(this.device);
this.commonState.setParameters({ this.commonState.setParameters({
canvasSize, canvasSize,
time: 0,
}); });
this.agentGenerationPipeline = new AgentGenerationPipeline( this.agentGenerationPipeline = new AgentGenerationPipeline(
@ -73,15 +74,21 @@ export class GameLoopResources {
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device); this.diffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); 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(
agentPipeline: this.agentPipeline, this.device,
brushPipeline: this.brushPipeline, this.textures,
eraserAgentPipeline: this.eraserAgentPipeline, {
eraserTexturePipeline: this.eraserTexturePipeline, agentPipeline: this.agentPipeline,
diffusionPipeline: this.diffusionPipeline, brushPipeline: this.brushPipeline,
renderPipeline: this.renderPipeline, eraserAgentPipeline: this.eraserAgentPipeline,
}); eraserTexturePipeline: this.eraserTexturePipeline,
diffusionPipeline: this.diffusionPipeline,
renderPipeline: this.renderPipeline,
},
this.gpuProfiler
);
} }
public resizeSimulationTo(nextSize: vec2): vec2 | null { public resizeSimulationTo(nextSize: vec2): vec2 | null {
@ -93,6 +100,10 @@ export class GameLoopResources {
this.frameRenderer.resetSourceMapActivity(); this.frameRenderer.resetSourceMapActivity();
} }
public get isSourceMapActive(): boolean {
return this.frameRenderer.isSourceMapActive;
}
public setFrameParameters({ public setFrameParameters({
time, time,
deltaTime, deltaTime,
@ -107,7 +118,6 @@ export class GameLoopResources {
}: FrameParameters): void { }: FrameParameters): void {
this.commonState.setParameters({ this.commonState.setParameters({
canvasSize, canvasSize,
time,
}); });
this.agentPipeline.setParameters({ this.agentPipeline.setParameters({
...settings, ...settings,
@ -130,11 +140,13 @@ export class GameLoopResources {
this.diffusionPipeline.setParameters(settings); this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({ this.renderPipeline.setParameters({
...settings, ...settings,
backgroundGrainStrength: 0,
channelColors, channelColors,
backgroundColor, backgroundColor,
}); });
this.eraserAgentPipeline.setParameters({ this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount, agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold, eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
maskSize: canvasSize, maskSize: canvasSize,
}); });
@ -163,6 +175,7 @@ export class GameLoopResources {
this.eraserTexturePipeline.destroy(); this.eraserTexturePipeline.destroy();
this.diffusionPipeline.destroy(); this.diffusionPipeline.destroy();
this.renderPipeline.destroy(); this.renderPipeline.destroy();
this.gpuProfiler?.destroy();
this.commonState.destroy(); this.commonState.destroy();
this.textures.destroy(); this.textures.destroy();
} }

View file

@ -3,9 +3,10 @@ import { vec2 } from 'gl-matrix';
import type { RgbColor } from '../utils/rgb-color'; import type { RgbColor } from '../utils/rgb-color';
export interface GardenUi { export interface GardenUi {
prompt: HTMLElement;
eraserPreview: HTMLElement; eraserPreview: HTMLElement;
exportStatus: HTMLElement; exportStatus: HTMLElement;
grainOverlay: HTMLElement;
prompt: HTMLElement;
toolbar: HTMLElement; toolbar: HTMLElement;
} }

View file

@ -6,7 +6,6 @@ import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population'; import { AgentPopulation } from './agent-population';
import { DevStatsOverlay } from './dev-stats-overlay';
import { EraserPreview } from './eraser-preview'; import { EraserPreview } from './eraser-preview';
import { ExportSnapshotRenderer } from './export-snapshot-renderer'; import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance'; import { FramePerformance } from './frame-performance';
@ -14,6 +13,7 @@ import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types'; import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size'; import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt'; import { IntroPrompt } from './intro-prompt';
import { PerfStatsOverlay } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input'; import { GardenPointerInput } from './pointer-input';
import { PipelineStrokeOutput } from './stroke-output'; import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
@ -27,7 +27,7 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation; private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer; private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance(); private readonly framePerformance = new FramePerformance();
private devStatsOverlay: DevStatsOverlay | null = null; private perfStatsOverlay: PerfStatsOverlay | null = null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16); private readonly seed = this.seedValue.toString(16);
@ -36,6 +36,7 @@ export default class GameLoop {
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = ''; private previousAccentColor = '';
private previousGrainStrength = Number.NaN;
private hasFinished = false; private hasFinished = false;
private readonly finished = Promise.withResolvers<void>(); private readonly finished = Promise.withResolvers<void>();
@ -43,7 +44,7 @@ export default class GameLoop {
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly deltaTimeCalculator: DeltaTimeCalculator, private readonly deltaTimeCalculator: DeltaTimeCalculator,
ui: GardenUi private readonly ui: GardenUi
) { ) {
this.resize(); this.resize();
this.resources = new GameLoopResources( this.resources = new GameLoopResources(
@ -94,12 +95,13 @@ export default class GameLoop {
}, },
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(), getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getSourceActive: () => this.resources.isSourceMapActive,
getVibeId: () => activeVibe.id, getVibeId: () => activeVibe.id,
}); });
window.addEventListener('resize', this.resizeListener); window.addEventListener('resize', this.resizeListener);
this.eraserPreview.attach(); this.eraserPreview.attach();
this.syncDevStatsOverlay(); this.syncPerfStatsOverlay();
} }
public attachPointerInput(): void { public attachPointerInput(): void {
@ -117,7 +119,7 @@ export default class GameLoop {
public onVibeChanged(): void { public onVibeChanged(): void {
this.agentPopulation.onVibeChanged(); this.agentPopulation.onVibeChanged();
this.syncDevStatsOverlay(); this.syncPerfStatsOverlay();
} }
public setAudioMuted(isMuted: boolean): void { public setAudioMuted(isMuted: boolean): void {
@ -152,8 +154,8 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener); window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach(); this.pointerInput.detach();
this.eraserPreview.detach(); this.eraserPreview.detach();
this.devStatsOverlay?.destroy(); this.perfStatsOverlay?.destroy();
this.devStatsOverlay = null; this.perfStatsOverlay = null;
this.toolbarContrastMonitor.destroy(); this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy(); this.introPrompt.destroy();
await this.agentPopulation.waitForCompaction(); await this.agentPopulation.waitForCompaction();
@ -183,6 +185,7 @@ export default class GameLoop {
const isErasing = this.pointerInput.isEraseMode; const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.updateAccentColor(accentColor); this.updateAccentColor(accentColor);
this.updateGrainOverlay(settings.backgroundGrainStrength);
this.audio.update({ this.audio.update({
vibe: activeVibe, vibe: activeVibe,
isErasing, isErasing,
@ -208,7 +211,7 @@ export default class GameLoop {
this.pointerInput.clearSwipesIfIdle(); this.pointerInput.clearSwipesIfIdle();
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.devStatsOverlay?.update({ this.perfStatsOverlay?.update({
time, time,
fps: this.framePerformance.measuredFps, fps: this.framePerformance.measuredFps,
agentCount: this.agentPopulation.activeAgentCount, agentCount: this.agentPopulation.activeAgentCount,
@ -220,16 +223,16 @@ export default class GameLoop {
requestAnimationFrame(this.render); requestAnimationFrame(this.render);
}; };
private syncDevStatsOverlay(): void { private syncPerfStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) { if (appConfig.tuningPane.showFpsOverlay) {
this.devStatsOverlay ??= new DevStatsOverlay( this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body this.canvas.parentElement ?? document.body
); );
return; return;
} }
this.devStatsOverlay?.destroy(); this.perfStatsOverlay?.destroy();
this.devStatsOverlay = null; this.perfStatsOverlay = null;
} }
private updateAccentColor(color: RgbColor): void { private updateAccentColor(color: RgbColor): void {
@ -242,12 +245,22 @@ export default class GameLoop {
document.documentElement.style.setProperty('--accent-color', accentColor); 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 { private resize(): void {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({ const { width, height } = getInternalRenderSize({
clientHeight: rect.height || this.canvas.clientHeight, clientHeight: rect.height || this.canvas.clientHeight,
clientWidth: rect.width || this.canvas.clientWidth, clientWidth: rect.width || this.canvas.clientWidth,
maxPixelScale: appConfig.simulation.maxDevicePixelRatio,
maxTextureDimension: this.device.limits.maxTextureDimension2D, maxTextureDimension: this.device.limits.maxTextureDimension2D,
targetAreaMegapixels: settings.internalRenderAreaMegapixels, targetAreaMegapixels: settings.internalRenderAreaMegapixels,
}); });
@ -318,4 +331,8 @@ export default class GameLoop {
Math.max(appConfig.toolbar.mirror.min, Math.round(count)) 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 { export interface InternalRenderSizeOptions {
clientHeight: number; clientHeight: number;
clientWidth: number; clientWidth: number;
maxPixelScale: number;
maxTextureDimension: number; maxTextureDimension: number;
targetAreaMegapixels: number; targetAreaMegapixels: number;
} }
@ -18,15 +17,9 @@ const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): numb
? targetAreaMegapixels ? targetAreaMegapixels
: 1; : 1;
const getSafeMaxPixelScale = (maxPixelScale: number): number =>
Number.isFinite(maxPixelScale) && maxPixelScale > 0
? maxPixelScale
: Number.POSITIVE_INFINITY;
export const getInternalRenderSize = ({ export const getInternalRenderSize = ({
clientHeight, clientHeight,
clientWidth, clientWidth,
maxPixelScale,
maxTextureDimension, maxTextureDimension,
targetAreaMegapixels, targetAreaMegapixels,
}: InternalRenderSizeOptions): InternalRenderSize => { }: InternalRenderSizeOptions): InternalRenderSize => {
@ -41,7 +34,6 @@ export const getInternalRenderSize = ({
const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight)); const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight));
const dimensionScale = Math.min( const dimensionScale = Math.min(
areaScale, areaScale,
getSafeMaxPixelScale(maxPixelScale),
safeMaxTextureDimension / safeClientWidth, safeMaxTextureDimension / safeClientWidth,
safeMaxTextureDimension / safeClientHeight 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_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms'; const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0'; const ZERO_RESOLUTION_TEXT = '0x0';
interface DevStatsSnapshot { interface PerfStatsSnapshot {
time: DOMHighResTimeStamp; time: DOMHighResTimeStamp;
fps: number; fps: number;
agentCount: number; agentCount: number;
@ -12,14 +12,14 @@ interface DevStatsSnapshot {
renderHeight: number; renderHeight: number;
} }
export class DevStatsOverlay { export class PerfStatsOverlay {
private readonly element: HTMLDivElement; private readonly element: HTMLDivElement;
private previousUpdateTime = Number.NEGATIVE_INFINITY; private previousUpdateTime = Number.NEGATIVE_INFINITY;
private previousText = ''; private previousText = '';
public constructor(parent: HTMLElement) { public constructor(parent: HTMLElement) {
this.element = document.createElement('div'); this.element = document.createElement('div');
this.element.className = 'dev-stats-overlay'; this.element.className = 'perf-stats-overlay';
this.element.setAttribute('aria-hidden', 'true'); this.element.setAttribute('aria-hidden', 'true');
parent.append(this.element); parent.append(this.element);
} }
@ -31,13 +31,14 @@ export class DevStatsOverlay {
frameTimeMs, frameTimeMs,
renderWidth, renderWidth,
renderHeight, renderHeight,
}: DevStatsSnapshot): void { }: PerfStatsSnapshot): void {
if (time - this.previousUpdateTime < DEV_STATS_REFRESH_MS) { if (time - this.previousUpdateTime < PERF_STATS_REFRESH_MS) {
return; return;
} }
this.previousUpdateTime = time; 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) { if (text !== this.previousText) {
this.element.textContent = text; this.element.textContent = text;
this.previousText = text; this.previousText = text;
@ -57,10 +58,14 @@ const formatAgentCount = (agentCount: number): string =>
? Math.max(0, Math.round(agentCount)).toLocaleString('en-US') ? Math.max(0, Math.round(agentCount)).toLocaleString('en-US')
: ZERO_STAT_TEXT; : ZERO_STAT_TEXT;
const formatFrameTime = (frameTimeMs: number): string => const formatFrameTime = (frameTimeMs: number | undefined): string => {
Number.isFinite(frameTimeMs) if (typeof frameTimeMs !== 'number' || !Number.isFinite(frameTimeMs)) {
? `${Math.max(0, frameTimeMs).toFixed(frameTimeMs < 10 ? 1 : 0)}ms` return ZERO_FRAME_TIME_TEXT;
: 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 => const formatResolution = (width: number, height: number): string =>
Number.isFinite(width) && Number.isFinite(height) Number.isFinite(width) && Number.isFinite(height)

View file

@ -1,10 +1,13 @@
import { appConfig } from '../config';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { CanvasReadbackRequest } from './game-loop-types'; import { CanvasReadbackRequest } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { SimulationTextures } from './simulation-textures'; import { SimulationTextures } from './simulation-textures';
interface SimulationFramePipelines { interface SimulationFramePipelines {
@ -17,15 +20,14 @@ interface SimulationFramePipelines {
} }
export class SimulationFrameRenderer { export class SimulationFrameRenderer {
private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
private sourceActiveFramesRemaining = 0; private sourceActiveFramesRemaining = 0;
private sourceMapsCleared = true; private sourceMapsCleared = true;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly textures: SimulationTextures, private readonly textures: SimulationTextures,
private readonly pipelines: SimulationFramePipelines private readonly pipelines: SimulationFramePipelines,
private readonly gpuProfiler: GpuProfiler | null = null
) {} ) {}
public resetSourceMapActivity(): void { public resetSourceMapActivity(): void {
@ -33,11 +35,16 @@ export class SimulationFrameRenderer {
this.sourceMapsCleared = true; this.sourceMapsCleared = true;
} }
public get isSourceMapActive(): boolean {
return this.sourceActiveFramesRemaining > 0;
}
public execute( public execute(
isErasing: boolean, isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null canvasReadbackRequest?: CanvasReadbackRequest | null
): void { ): void {
const commandEncoder = this.device.createCommandEncoder(); const commandEncoder = this.device.createCommandEncoder();
this.gpuProfiler?.beginFrame();
this.textures.copyTrailMapAToB(commandEncoder); this.textures.copyTrailMapAToB(commandEncoder);
let wroteSourceMap = false; let wroteSourceMap = false;
@ -48,24 +55,29 @@ export class SimulationFrameRenderer {
commandEncoder, commandEncoder,
eraserMask, eraserMask,
this.textures.sourceMapA.getTextureView(), 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 { } else {
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget( wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget(
commandEncoder, commandEncoder,
this.textures.sourceMapA.getTextureView() this.textures.sourceMapA.getTextureView(),
this.gpuProfiler?.timestampWrites('brush')
); );
} }
if (wroteSourceMap) { if (wroteSourceMap) {
this.sourceActiveFramesRemaining = this.sourceActiveFramesRemaining = getSourceActiveFrameCount();
SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE;
this.sourceMapsCleared = false; this.sourceMapsCleared = false;
} }
const useSourceMap = this.sourceActiveFramesRemaining > 0; const useSourceMap = this.isSourceMapActive;
if (!useSourceMap && !this.sourceMapsCleared) { if (!useSourceMap && !this.sourceMapsCleared) {
this.textures.clearSourceMaps(commandEncoder); this.textures.clearSourceMaps(commandEncoder);
this.sourceMapsCleared = true; this.sourceMapsCleared = true;
@ -74,19 +86,22 @@ export class SimulationFrameRenderer {
this.pipelines.agentPipeline.execute( this.pipelines.agentPipeline.execute(
commandEncoder, commandEncoder,
this.textures.trailMapA.getTextureView(), this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView() this.textures.trailMapB.getTextureView(),
this.gpuProfiler?.timestampWrites('agent')
); );
this.pipelines.diffusionPipeline.execute( this.pipelines.diffusionPipeline.execute(
commandEncoder, commandEncoder,
this.textures.trailMapB.getTextureView(), this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getTextureView(), this.textures.trailMapA.getTextureView(),
this.textures.trailMapA.getSize() this.textures.trailMapA.getSize(),
this.gpuProfiler?.timestampWrites('trailDiffusion')
); );
const canvasTexture = this.pipelines.renderPipeline.execute( const canvasTexture = this.pipelines.renderPipeline.execute(
commandEncoder, commandEncoder,
this.textures.trailMapA.getTextureView(), this.textures.trailMapA.getTextureView(),
this.textures.sourceMapA.getTextureView(), this.textures.sourceMapA.getTextureView(),
useSourceMap useSourceMap,
this.gpuProfiler?.timestampWrites('render')
); );
canvasReadbackRequest?.encode(commandEncoder, canvasTexture); canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
@ -95,10 +110,13 @@ export class SimulationFrameRenderer {
commandEncoder, commandEncoder,
this.textures.sourceMapA.getTextureView(), this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(), this.textures.sourceMapB.getTextureView(),
this.textures.sourceMapB.getSize() this.textures.sourceMapB.getSize(),
this.gpuProfiler?.timestampWrites('sourceDiffusion')
); );
} }
const afterGpuProfileSubmit = this.gpuProfiler?.resolve(commandEncoder);
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
afterGpuProfileSubmit?.();
canvasReadbackRequest?.afterSubmit(); canvasReadbackRequest?.afterSubmit();
if (useSourceMap) { if (useSourceMap) {
this.textures.swapSourceMaps(); 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 { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { ResizableTexture } from '../utils/graphics/resizable-texture'; import {
ResizableTexture,
type PendingTextureResize,
} from '../utils/graphics/resizable-texture';
export class SimulationTextures { export class SimulationTextures {
public readonly trailMapA: ResizableTexture; public readonly trailMapA: ResizableTexture;
public readonly trailMapB: 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 sourceMapA: ResizableTexture;
public sourceMapB: ResizableTexture; public sourceMapB: ResizableTexture;
public eraserMask: ResizableTexture;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
@ -28,11 +32,31 @@ export class SimulationTextures {
} }
const scale = vec2.div(vec2.create(), nextSize, previousSize); const scale = vec2.div(vec2.create(), nextSize, previousSize);
this.trailMapA.resize(nextSize); const resizes = [
this.trailMapB.resize(nextSize); this.trailMapA,
this.sourceMapA.resize(nextSize); this.trailMapB,
this.sourceMapB.resize(nextSize); this.sourceMapA,
this.eraserMask.resize(nextSize); 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; return scale;
} }

View file

@ -22,7 +22,7 @@ export class PipelineStrokeOutput implements StrokeOutput {
} }
public addEraseSegment(from: vec2, to: vec2): void { public addEraseSegment(from: vec2, to: vec2): void {
this.eraserAgentPipeline.addSwipeSegment(); this.eraserAgentPipeline.addSwipeSegment(from, to);
this.eraserTexturePipeline.addSwipeSegment(from, to); this.eraserTexturePipeline.addSwipeSegment(from, to);
} }

View file

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

View file

@ -3,356 +3,24 @@ import GameLoop from './game-loop/game-loop';
import './index.scss'; import './index.scss';
import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics'; 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 { preloadPianoSamples } from './audio/piano-samples';
import { AudioControl } from './page/audio-control';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane'; 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 { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider'; import { MenuHider } from './page/menu-hider';
import { activeVibe, applyVibeSettings, settings } from './settings'; import { MirrorSegmentControl } from './page/mirror-segment-control';
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; 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 { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement, queryRequiredElements } from './utils/dom'; import { queryRequiredElement } from './utils/dom';
import { import { ErrorHandler, Severity } from './utils/error-handler';
ErrorHandler,
getErrorMessage,
RuntimeError,
Severity,
} from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu'; 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 () => { const main = async () => {
let hasRuntimeErrorListener = false; let hasRuntimeErrorListener = false;
@ -363,14 +31,13 @@ const main = async () => {
let hasStarted = false; let hasStarted = false;
let game: GameLoop | null = null; let game: GameLoop | null = null;
let configPane: ConfigPane | null = null; let configPane: ConfigPane | null = null;
const getGame = () => game;
elements = queryAppElements(); const errorPresenter = new ErrorPresenter(
elements.errorContainer.setAttribute( queryRequiredElement('.errors-container', HTMLElement)
'aria-live',
'assertive'
); );
ErrorHandler.addOnErrorListener((error) => { ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error); errorPresenter.render(error);
if (error.severity === Severity.ERROR) { if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading'); document.body.classList.remove('is-loading');
game?.destroy(); game?.destroy();
@ -379,174 +46,100 @@ const main = async () => {
}); });
hasRuntimeErrorListener = true; hasRuntimeErrorListener = true;
const syncRuntimeUi = (activeGame = game) => { const aside = queryRequiredElement('aside', HTMLElement);
renderEraserSizeUi(game); const canvas = queryRequiredElement('canvas', HTMLCanvasElement);
renderMirrorSegmentUi(); const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement);
renderPaletteUi(activeGame); 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( const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
elements.infoButton,
elements.infoElement,
elements.aside
);
new MenuHider( new MenuHider(
elements.aside, aside,
() => () =>
FullScreenHandler.isInFullScreenMode() && FullScreenHandler.isInFullScreenMode() &&
!configPane?.isOpen && !configPane?.isOpen &&
!infoPageHandler.isOpen !infoPageHandler.isOpen
); );
new FullScreenHandler( new FullScreenHandler(
elements.minimizeFullScreenButton, minimizeFullScreenButton,
elements.maximizeFullScreenButton, maximizeFullScreenButton,
document.documentElement document.documentElement
); );
const startAudioFromUserGesture = (event: Event) => { new VibeNavigator({
if ( onChange: ({ vibeId, vibeName, source }) => {
!hasStarted || trackVibeChange({ vibeId, vibeName, source });
isAudioMuted || game?.onVibeChanged();
(event.target instanceof Node && elements.startButton.contains(event.target)) || syncRuntimeUi();
(event.target instanceof Node && elements.soundButton.contains(event.target))
) {
return;
}
game?.startAudio(true);
};
window.addEventListener('touchstart', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('pointerdown', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('touchend', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('pointerup', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('click', startAudioFromUserGesture, {
capture: true,
});
window.addEventListener('keydown', startAudioFromUserGesture, {
capture: true,
});
elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener('click', () => {
const shouldUnmute = isAudioMuted || audioVolume <= 0;
if (shouldUnmute && audioVolume <= 0) {
audioVolume = DEFAULT_AUDIO_VOLUME;
}
isAudioMuted = !shouldUnmute;
persistAudioUiState();
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(true);
}
});
elements.volumeSlider.addEventListener('input', () => {
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
isAudioMuted = audioVolume <= 0;
persistAudioUiState();
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(true);
}
});
const selectRelativeVibe = (offset: number, source: string) => {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const vibe =
VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
const activePreset = applyVibeSettings(vibe);
trackVibeChange({
vibeId: activePreset.id,
vibeName: activePreset.name,
source,
});
game?.onVibeChanged();
syncRuntimeUi();
configPane?.refresh();
game?.playVibeChangeAudio(true);
};
elements.previousVibe.addEventListener('click', () =>
selectRelativeVibe(-1, 'previous-button')
);
elements.nextVibe.addEventListener('click', () =>
selectRelativeVibe(1, 'next-button')
);
elements.swatches.forEach((swatch, index) => {
swatch.addEventListener('click', () => {
settings.selectedColorIndex = index;
isEraserActive = false;
renderPaletteUi(game);
configPane?.refresh(); configPane?.refresh();
}); game?.playVibeChangeAudio(true);
},
}); });
const activateEraser = () => { restartButton.addEventListener('click', () => game?.destroy());
isEraserActive = true;
renderPaletteUi(game);
};
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser); export4kButton.addEventListener('click', async () => {
elements.eraserSizeControl.addEventListener('click', activateEraser); if (!game || export4kButton.disabled) {
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) {
return; return;
} }
elements.export4k.disabled = true; export4kButton.disabled = true;
try { try {
await game.exportSnapshot(); await game.exportSnapshot();
trackExport({ vibeId: activeVibe.id }); trackExport({ vibeId: activeVibe.id });
} catch (error) { } catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING }); ErrorHandler.addException(error, { severity: Severity.WARNING });
} finally { } finally {
elements.export4k.disabled = false; export4kButton.disabled = false;
} }
}); });
renderPaletteUi(game); // Samples load before Start is enabled so the first audible piano note
renderEraserSizeUi(game); // always uses the sampler. The Start tap still unlocks the AudioContext.
renderMirrorSegmentUi(); splash.showLoadingBar();
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.
const fontsReady = document.fonts.ready.catch((error) => { const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, { ErrorHandler.addException(error, {
fallbackMessage: 'Could not load fonts.', fallbackMessage: 'Could not load fonts.',
@ -555,17 +148,18 @@ const main = async () => {
}); });
const gpuPromise = initializeGpu(); const gpuPromise = initializeGpu();
let isPreloadComplete = false;
const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => { const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
const ratio = totalCount > 0 ? loadedCount / totalCount : 0; const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}`, ratio); splash.setLoadingStage(
`Loading piano samples ${loadedCount}/${totalCount}`,
ratio
);
}).then( }).then(
() => { () => {
isPreloadComplete = true; splash.setLoadingStage('Ready', 1);
setLoadingStage('Ready', 1);
}, },
(error: unknown) => { (error: unknown) => {
isPreloadComplete = true; splash.setLoadingStage('Piano unavailable', 1);
ErrorHandler.addException(error, { ErrorHandler.addException(error, {
fallbackMessage: 'Could not preload piano samples.', fallbackMessage: 'Could not preload piano samples.',
severity: Severity.WARNING, severity: Severity.WARNING,
@ -575,7 +169,8 @@ const main = async () => {
const gpu = await gpuPromise; const gpu = await gpuPromise;
configPane = new ConfigPane({ configPane = new ConfigPane({
settingsButton: elements.settingsButton, maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
settingsButton,
onConfigChange: () => { onConfigChange: () => {
game?.onVibeChanged(); game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
@ -584,59 +179,36 @@ const main = async () => {
}); });
infoPageHandler.onOpen = configPane.close.bind(configPane); infoPageHandler.onOpen = configPane.close.bind(configPane);
await fontsReady; await fontsReady;
await preloadPromise;
splash.hideLoadingBar();
const deltaTimeCalculator = new DeltaTimeCalculator(); const deltaTimeCalculator = new DeltaTimeCalculator();
let isFirstStart = true; let isFirstStart = true;
while (!shouldStop) { while (!shouldStop) {
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { game = new GameLoop(canvas, gpu, deltaTimeCalculator, {
toolbar: elements.toolbarRow, toolbar: toolbarRow,
prompt: elements.prompt, prompt: promptElement,
eraserPreview: elements.eraserPreview, eraserPreview,
exportStatus: elements.exportStatus, grainOverlay,
exportStatus,
}); });
renderPaletteUi(game); syncRuntimeUi();
renderEraserSizeUi(game); audioControl.render();
renderMirrorSegmentUi();
renderAudioUi(game);
if (isFirstStart) { if (isFirstStart) {
isFirstStart = false; isFirstStart = false;
// Splash is in the DOM by default; enable the button now that the // Splash is in the DOM by default; enable the button now that the
// audio system (GameLoop) is constructed and ready to be unlocked. // audio system (GameLoop) is constructed and ready to be unlocked.
elements.startButton.disabled = false; await splash.awaitStart(() => {
await new Promise<void>((resolve) => { hasStarted = true;
const onClick = () => { game?.startAudio(true);
elements.startButton.removeEventListener('click', onClick); trackStart();
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(() =>
requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('is-loading'))
document.body.classList.remove('is-loading')
)
); );
} }
game.attachPointerInput(); game.attachPointerInput();
@ -647,7 +219,7 @@ const main = async () => {
if (hasRuntimeErrorListener) { if (hasRuntimeErrorListener) {
ErrorHandler.addException(e); ErrorHandler.addException(e);
} else { } else {
renderStartupException(e); ErrorPresenter.renderStartup(e);
ErrorHandler.addException(e); ErrorHandler.addException(e);
} }
console.error(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; color3: string;
} }
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; const COLOR_REACTION_LABELS = ['Primary', 'Secondary', 'Accent'] as const;
const COLOR_REACTION_STATES = [ const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Follow', value: 1 }, { id: 'follow', label: 'Move Toward', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 }, { id: 'ignore', label: 'Ignore', value: 0 },
{ id: 'avoid', label: 'Avoid', value: -1 }, { id: 'avoid', label: 'Move Away', value: -1 },
] as const; ] as const;
const colorReactionRows = [ const colorReactionRows = [
@ -56,6 +56,7 @@ const colorReactionRows = [
const brushControlKeys = [ const brushControlKeys = [
'brushSize', 'brushSize',
'spawnPerPixel', 'spawnPerPixel',
'strokeAngleJitterRadians',
] satisfies Array<RuntimeControlKey>; ] satisfies Array<RuntimeControlKey>;
const agentControlKeys = [ const agentControlKeys = [
@ -85,16 +86,17 @@ const MUSIC_CONTROLS: ReadonlyArray<{
max: number; max: number;
step: number; step: number;
}> = [ }> = [
{ key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 }, { key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 },
{ key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 }, { key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 },
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 }, { key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 },
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, 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: '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: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 },
{ key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 }, { key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 },
]; ];
interface ConfigPaneOptions { interface ConfigPaneOptions {
maxSupportedAgentCount: number;
onConfigChange: () => void; onConfigChange: () => void;
onRuntimeChange: () => void; onRuntimeChange: () => void;
settingsButton: HTMLButtonElement; settingsButton: HTMLButtonElement;
@ -152,6 +154,7 @@ const getNextColorReactionState = (
export class ConfigPane { export class ConfigPane {
private readonly container: HTMLDivElement; private readonly container: HTMLDivElement;
private readonly closeButton: HTMLButtonElement;
private readonly pane: Pane; private readonly pane: Pane;
private readonly colorReactionButtons = new Map< private readonly colorReactionButtons = new Map<
ColorReactionKey, ColorReactionKey,
@ -176,17 +179,15 @@ export class ConfigPane {
public constructor(private readonly options: ConfigPaneOptions) { public constructor(private readonly options: ConfigPaneOptions) {
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.className = 'config-pane-container'; this.container.className = 'config-pane-container';
Object.assign(this.container.style, {
boxSizing: 'border-box', this.closeButton = document.createElement('button');
maxHeight: 'calc(100vh - 24px)', this.closeButton.className = 'config-pane-close';
pointerEvents: 'none', this.closeButton.type = 'button';
position: 'fixed', this.closeButton.setAttribute('aria-label', 'Hide config overlay');
right: 'max(12px, env(safe-area-inset-right, 0px))', this.closeButton.title = 'Hide config overlay';
top: 'max(12px, env(safe-area-inset-top, 0px))', this.closeButton.addEventListener('click', () => this.setHidden(true));
width: this.container.appendChild(this.closeButton);
'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
zIndex: '20',
});
document.body.appendChild(this.container); document.body.appendChild(this.container);
this.pane = new Pane({ this.pane = new Pane({
@ -196,13 +197,14 @@ export class ConfigPane {
}); });
this.pane.hidden = appConfig.tuningPane.startHidden; this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.element.classList.add('config-pane'); this.pane.element.classList.add('config-pane');
this.pane.element.style.boxSizing = 'border-box'; this.pane.element.id = 'config-pane';
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.options.settingsButton.setAttribute('aria-controls', this.pane.element.id);
this.options.settingsButton.addEventListener('click', this.toggle); this.options.settingsButton.addEventListener('click', this.toggle);
document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, {
passive: true,
});
document.addEventListener('keydown', this.dismissOnEscape);
this.setUpTuningPane(this.pane); this.setUpTuningPane(this.pane);
this.syncOpenState(); this.syncOpenState();
@ -228,6 +230,27 @@ export class ConfigPane {
this.syncOpenState(); 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 { private setHidden(isHidden: boolean): void {
this.pane.hidden = isHidden; this.pane.hidden = isHidden;
this.syncOpenState(); this.syncOpenState();
@ -252,26 +275,26 @@ export class ConfigPane {
private setUpVibeSection(container: PaneContainer): void { private setUpVibeSection(container: PaneContainer): void {
const folder = container.addFolder({ const folder = container.addFolder({
title: 'Vibe', title: 'Colors',
expanded: true, expanded: true,
}); });
this.addColorBinding(folder, 'color1', 'colour 1', (color) => { this.addColorBinding(folder, 'color1', 'Primary Color', (color) => {
activeVibe.colors[0] = color; activeVibe.colors[0] = color;
}); });
this.addColorBinding(folder, 'color2', 'colour 2', (color) => { this.addColorBinding(folder, 'color2', 'Secondary Color', (color) => {
activeVibe.colors[1] = color; activeVibe.colors[1] = color;
}); });
this.addColorBinding(folder, 'color3', 'colour 3', (color) => { this.addColorBinding(folder, 'color3', 'Accent Color', (color) => {
activeVibe.colors[2] = color; activeVibe.colors[2] = color;
}); });
this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => { this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
activeVibe.backgroundColor = color; activeVibe.backgroundColor = color;
}); });
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
folder folder
.addButton({ title: 'Copy vibe preset' }) .addButton({ title: 'Copy Vibe Preset' })
.on('click', () => void this.copyVibePresetToClipboard()); .on('click', () => void this.copyVibePresetToClipboard());
} }
} }
@ -313,7 +336,7 @@ export class ConfigPane {
} }
private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void { private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
const config = appConfig.runtimeSettings.controls[key]; const config = this.getRuntimeControlConfig(key);
if (!config) { if (!config) {
return; 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 { private addFpsOverlayBinding(container: PaneContainer): void {
container container
.addBinding(appConfig.tuningPane, 'showFpsOverlay', { .addBinding(appConfig.tuningPane, 'showFpsOverlay', {
label: 'FPS overlay', label: 'Show FPS',
}) })
.on('change', () => this.options.onConfigChange()); .on('change', () => this.options.onConfigChange());
} }
private addColorReactionMatrix(container: PaneContainer): void { private addColorReactionMatrix(container: PaneContainer): void {
const folder = container.addFolder({ const folder = container.addFolder({
title: 'Follow / Ignore / Avoid', title: 'Color Behavior',
expanded: true, expanded: true,
}); });
folder.element.classList.add('color-reaction-folder'); folder.element.classList.add('color-reaction-folder');
const content = Array.from(folder.element.children).find((child) => const matrix = document.createElement('div');
child.classList.contains('tp-fldv_c')
);
if (!(content instanceof HTMLElement)) {
return;
}
const doc = folder.element.ownerDocument;
const matrix = doc.createElement('div');
matrix.className = 'color-reaction-matrix'; matrix.className = 'color-reaction-matrix';
matrix.appendChild(this.createColorReactionCorner(doc)); matrix.appendChild(this.createColorReactionCorner());
colorReactionRows.forEach((row) => { colorReactionRows.forEach((row) => {
matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
}); });
colorReactionRows.forEach((row) => { 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) => { row.keys.forEach((key, columnIndex) => {
matrix.appendChild( 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(); this.syncColorReactionMatrix();
} }
private createColorReactionCorner(doc: Document): HTMLDivElement { private createColorReactionCorner(): HTMLDivElement {
const corner = doc.createElement('div'); const corner = document.createElement('div');
corner.className = 'color-reaction-matrix__corner'; corner.className = 'color-reaction-matrix__corner';
corner.textContent = 'agent'; corner.textContent = 'agents';
return corner; return corner;
} }
private createColorReactionHeader( private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
doc: Document, const header = document.createElement('div');
colorIndex: number,
label: string
): HTMLDivElement {
const header = doc.createElement('div');
header.className = 'color-reaction-matrix__header'; header.className = 'color-reaction-matrix__header';
const swatch = doc.createElement('span'); const swatch = document.createElement('span');
swatch.className = 'color-reaction-matrix__swatch'; swatch.className = 'color-reaction-matrix__swatch';
this.colorReactionSwatches.push({ colorIndex, element: swatch }); this.colorReactionSwatches.push({ colorIndex, element: swatch });
header.appendChild(swatch); header.appendChild(swatch);
const text = doc.createElement('span'); const text = document.createElement('span');
text.textContent = label; text.textContent = label;
header.appendChild(text); header.appendChild(text);
@ -404,12 +431,11 @@ export class ConfigPane {
} }
private createColorReactionCell( private createColorReactionCell(
doc: Document,
key: ColorReactionKey, key: ColorReactionKey,
sourceColorIndex: number, sourceColorIndex: number,
targetColorIndex: number targetColorIndex: number
): HTMLDivElement { ): HTMLDivElement {
const cell = doc.createElement('div'); const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell'; cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key]; const config = appConfig.runtimeSettings.controls[key];
@ -417,11 +443,11 @@ export class ConfigPane {
return cell; return cell;
} }
const button = doc.createElement('button'); const button = document.createElement('button');
button.className = 'color-reaction-matrix__button'; button.className = 'color-reaction-matrix__button';
button.type = 'button'; button.type = 'button';
const icon = doc.createElement('span'); const icon = document.createElement('span');
icon.className = 'color-reaction-matrix__icon'; icon.className = 'color-reaction-matrix__icon';
button.appendChild(icon); button.appendChild(icon);
@ -470,15 +496,15 @@ export class ConfigPane {
const state = getColorReactionState(settings[key]); const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]); const nextState = getNextColorReactionState(settings[key]);
const sourceLabel = sourceColorIndex + 1; const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
const targetLabel = targetColorIndex + 1; const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
button.dataset.reaction = state.id; button.dataset.reaction = state.id;
button.setAttribute( button.setAttribute(
'aria-label', '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 { private setUpMusicSection(container: PaneContainer): void {
@ -541,5 +567,7 @@ export class ConfigPane {
settingsButton.setAttribute('aria-expanded', String(this.isOpen)); settingsButton.setAttribute('aria-expanded', String(this.isOpen));
settingsButton.setAttribute('aria-label', label); settingsButton.setAttribute('aria-label', label);
settingsButton.title = 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_WORKGROUP_SIZE = 64;
export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE;
export const dispatchAgentWorkgroups = ( export const dispatchAgentWorkgroups = (
passEncoder: GPUComputePassEncoder, passEncoder: GPUComputePassEncoder,

View file

@ -2,13 +2,13 @@ import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
import { smartCompile } from '../../../utils/graphics/smart-compile'; 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 compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw';
export const AGENT_FLOAT_COUNT = 8; export { AGENT_FLOAT_COUNT } from '../agent-limits';
const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
export class AgentGenerationPipeline { export class AgentGenerationPipeline {
private static readonly UNIFORM_COUNT = 4; private static readonly UNIFORM_COUNT = 4;
@ -224,15 +224,7 @@ export class AgentGenerationPipeline {
? Math.floor(value) ? Math.floor(value)
: 0; : 0;
return Math.min( return Math.min(
Number.isFinite(this.maxAgentCountUpperLimit) getMaxSupportedAgentCount(this.device, 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,
Math.max(0, requestedMaxAgentCount) 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 { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
@ -38,43 +39,91 @@ export interface AgentSettings {
randomTimeScale: number; randomTimeScale: number;
} }
export class AgentPipeline { const UNIFORM_COUNT = 30;
private static readonly UNIFORM_COUNT = 30;
export class AgentPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline; private readonly pipeline: GPUComputePipeline;
private readonly normalPipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer; 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 uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
AgentPipeline.UNIFORM_COUNT private readonly bindGroupCache = createBindGroupCache3<
);
private readonly bindGroupsByAgentsBuffer = new WeakMap<
GPUBuffer, 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 agentCount = 0;
private useIntroPipeline = true;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState, private readonly commonState: CommonState,
private readonly getAgentsBuffer: () => GPUBuffer // doesn't get destroyed private readonly getAgentsBuffer: () => GPUBuffer
) { ) {
this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout); this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: 'uniform' },
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: 'storage' },
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
texture: { sampleType: 'float' },
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
storageTexture: { format: 'rgba16float' },
},
],
});
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
agentSchema,
shader
);
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
});
this.pipeline = device.createComputePipeline({ this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: pipelineLayout,
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: { compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader), module: shaderModule,
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
this.normalPipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: shaderModule,
entryPoint: 'mainNormal',
},
});
this.uniforms = this.device.createBuffer({ this.uniforms = device.createBuffer({
size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
} }
@ -118,6 +167,7 @@ export class AgentPipeline {
introProgress?: number; introProgress?: number;
}) { }) {
this.agentCount = agentCount; this.agentCount = agentCount;
this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff;
this.uniformValues[0] = moveSpeed * deltaTime; this.uniformValues[0] = moveSpeed * deltaTime;
this.uniformValues[1] = turnSpeed * deltaTime; this.uniformValues[1] = turnSpeed * deltaTime;
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
@ -160,110 +210,27 @@ export class AgentPipeline {
public execute( public execute(
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView, trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView trailMapOut: GPUTextureView,
timestampWrites?: GPUComputePassTimestampWrites
) { ) {
if (this.agentCount <= 0) { if (this.agentCount <= 0) {
return; return;
} }
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
const passEncoder = commandEncoder.beginComputePass(); );
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup); passEncoder.setBindGroup(
1,
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
);
dispatchAgentWorkgroups(passEncoder, this.agentCount); dispatchAgentWorkgroups(passEncoder, this.agentCount);
passEncoder.end(); 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() { public destroy() {
this.uniforms.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 { struct Settings {
moveRate: f32, moveRate: f32,
turnRate: f32, turnRate: f32,
@ -142,7 +144,80 @@ fn main(
let nextPosition = clamp(position + step, vec2<f32>(0, 0), maxPosition); 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 { 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); var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);

View file

@ -7,6 +7,12 @@ import {
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; 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'; import shader from './brush.wgsl?raw';
export interface BrushSettings { export interface BrushSettings {
@ -20,12 +26,7 @@ export interface BrushSettings {
brushGrainMaxStrength: number; brushGrainMaxStrength: number;
} }
interface LineSegment { interface BrushParameters extends BrushSettings {
from: vec2;
to: vec2;
}
interface BrushParameterSettings extends BrushSettings {
pixelRatio?: number; pixelRatio?: number;
selectedColorIndex: number; selectedColorIndex: number;
} }
@ -35,6 +36,8 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
? pixelRatio ? pixelRatio
: 1; : 1;
const UNIFORM_COUNT = 16;
const setBrushUniformValues = ( const setBrushUniformValues = (
target: Float32Array, target: Float32Array,
{ {
@ -48,15 +51,14 @@ const setBrushUniformValues = (
brushGrainMaxStrength, brushGrainMaxStrength,
selectedColorIndex, selectedColorIndex,
pixelRatio, pixelRatio,
}: BrushParameterSettings }: BrushParameters
): void => { ): void => {
const safePixelRatio = getSafePixelRatio(pixelRatio); const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2; const brushRadius = (brushSize * safePixelRatio) / 2;
target[0] = brushRadius; target[0] = brushRadius;
target[1] = brushRadius * brushRadius; target[1] = brushRadius * brushRadius;
target[2] = 0; // target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader.
target[3] = 0;
target[4] = selectedColorIndex === 0 ? 1 : 0; target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0; target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0; target[6] = selectedColorIndex === 2 ? 1 : 0;
@ -70,78 +72,81 @@ const setBrushUniformValues = (
}; };
export class BrushPipeline { 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 bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup; private readonly bindGroup: GPUBindGroup;
private readonly multiTargetPipeline: GPURenderPipeline; private readonly renderPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
BrushPipeline.UNIFORM_COUNT private readonly segments: LineSegmentBuffer;
);
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> = [];
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState private readonly commonState: CommonState
) { ) {
this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout); this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
this.vertexBuffer = device.createBuffer({ this.bindGroupLayout = device.createBindGroupLayout({
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,
entries: [ entries: [
{ {
binding: 0, binding: 0,
resource: { visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: this.uniforms, buffer: { type: 'uniform' },
},
}, },
], ],
}); });
}
public addSwipeSegment(from: vec2, to: vec2) { const shaderModule = smartCompile(
this.lineSegments.push({ device,
from: vec2.clone(from), CommonState.shaderCode,
to: vec2.clone(to), 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() { public addSwipeSegment(from: vec2, to: vec2): void {
this.lineSegments.length = 0; this.segments.add(from, to);
this.actualSegments.length = 0;
} }
public setParameters(parameters: BrushParameterSettings) { public clearSwipes(): void {
this.segments.clear();
}
public setParameters(parameters: BrushParameters): void {
setBrushUniformValues(this.uniformValues, parameters); setBrushUniformValues(this.uniformValues, parameters);
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
@ -149,188 +154,34 @@ export class BrushPipeline {
this.uniformValues, this.uniformValues,
this.uniformCache this.uniformCache
); );
this.segments.flush();
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;
} }
public executeMultiTarget( public executeMultiTarget(
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView sourceMapOut: GPUTextureView,
timestampWrites?: GPURenderPassTimestampWrites
): boolean { ): boolean {
return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ const lineCount = this.segments.activeCount;
sourceMapOut, if (lineCount === 0) {
]);
}
private executeWithPipeline(
commandEncoder: GPUCommandEncoder,
pipeline: GPURenderPipeline,
textureViews: Array<GPUTextureView>
): boolean {
if (this.lineCount === 0) {
return false; return false;
} }
const renderPassDescriptor: GPURenderPassDescriptor = { const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({ colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
view, timestampWrites,
loadOp: 'load', });
storeOp: 'store', passEncoder.setPipeline(this.renderPipeline);
})),
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer); passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end(); passEncoder.end();
return true; return true;
} }
public destroy() { public destroy(): void {
this.vertexBuffer.destroy(); this.segments.destroy();
this.uniforms.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 { struct Settings {
brushRadius: f32, brushRadius: f32,
brushRadiusSquared: f32, brushRadiusSquared: f32,
@ -36,7 +38,7 @@ fn vertex(
let direction = end - start; let direction = end - start;
let denominator = dot(direction, direction); let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0; var inverseLengthSquared = 0.0;
if denominator > 0.0001 { if denominator > SEGMENT_LENGTH_EPSILON {
inverseLengthSquared = 1.0 / denominator; inverseLengthSquared = 1.0 / denominator;
} }
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius); let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius);
@ -68,7 +70,7 @@ fn brushStrength(
direction: vec2<f32>, direction: vec2<f32>,
inverseLengthSquared: f32 inverseLengthSquared: f32
) -> f32 { ) -> f32 {
let distanceSquared = distanceSquaredFromLine( let distanceSquared = distance_squared_from_segment(
screenPosition, screenPosition,
start, start,
direction, direction,
@ -78,11 +80,15 @@ fn brushStrength(
return 0.0; return 0.0;
} }
let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared); let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength);
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold { if maxGrainStrength < settings.brushDiscardThreshold {
return 0.0; return 0.0;
} }
if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
return settings.brushGrainMinStrength;
}
let grainNoise = textureSampleLevel( let grainNoise = textureSampleLevel(
noise, noise,
noiseSampler, noiseSampler,
@ -90,52 +96,9 @@ fn brushStrength(
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY), vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0 0.0
).r; ).r;
return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise); return mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
} }
fn brushOutput(strength: f32) -> vec4<f32> { fn brushOutput(strength: f32) -> vec4<f32> {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
} }
fn distanceSquaredFromLine(
position: vec2<f32>,
start: vec2<f32>,
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 */ ` public static readonly shaderCode = /* wgsl */ `
struct State { struct State {
size: vec2<f32>, size: vec2<f32>,
time: f32, _padding: vec2<f32>,
}; };
@group(0) @binding(0) var<uniform> state: State; @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[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1]; this.uniformValues[1] = canvasSize[1];
this.uniformValues[2] = time;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, 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_X = 16u;
const WORKGROUP_SIZE_Y = 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_X = WORKGROUP_SIZE_X + 2u;
const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u; const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u;
const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y; 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(0) var<uniform> settings: Settings;
@group(0) @binding(1) var trailMap: texture_2d<f32>; @group(0) @binding(1) var trailMap: texture_2d<f32>;
@ -62,16 +66,8 @@ fn main(
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x; let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex]; var current = tile[centerTileIndex];
let random = random_from_pixel(pixel); let random = random_from_pixel(pixel);
let r2 = random * random;
let r4 = r2 * r2;
let r8 = r4 * r4;
let r16 = r8 * r8;
let trailWeight = diffusion_weight( let trailWeight = diffusion_weight(
random, random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateTrails settings.inverseDiffusionRateTrails
); );
current += ( current += (
@ -118,15 +114,13 @@ fn random_from_pixel(pixel: vec2<i32>) -> f32 {
hash = (hash ^ (hash >> 16u)) * 2246822519u; hash = (hash ^ (hash >> 16u)) * 2246822519u;
hash = (hash ^ (hash >> 13u)) * 3266489917u; hash = (hash ^ (hash >> 13u)) * 3266489917u;
hash = hash ^ (hash >> 16u); 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( fn diffusion_weight(
r: f32, r: f32,
r2: f32,
r4: f32,
r8: f32,
r16: f32,
inverseRate: f32 inverseRate: f32
) -> f32 { ) -> f32 {
if inverseRate < 1.0 { if inverseRate < 1.0 {
@ -137,19 +131,22 @@ fn diffusion_weight(
clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0) clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0)
); );
} }
let r2 = r * r;
if inverseRate < 2.0 { if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0); return mix(r, r2, inverseRate - 1.0);
} }
let r4 = r2 * r2;
if inverseRate < 4.0 { if inverseRate < 4.0 {
// (inverseRate - 2.0) / (4.0 - 2.0)
return mix(r2, r4, (inverseRate - 2.0) * 0.5); return mix(r2, r4, (inverseRate - 2.0) * 0.5);
} }
let r8 = r4 * r4;
if inverseRate < 8.0 { if inverseRate < 8.0 {
// (inverseRate - 4.0) / (8.0 - 4.0)
return mix(r4, r8, (inverseRate - 4.0) * 0.25); 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)) return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
* min(1.0, 16.0 / inverseRate); * min(1.0, 16.0 / inverseRate);
} }

View file

@ -133,11 +133,14 @@ export class DiffusionPipeline {
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView, trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView, trailMapOut: GPUTextureView,
size: vec2 size: vec2,
timestampWrites?: GPUComputePassTimestampWrites
) { ) {
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(0, bindGroup); passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups( 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 agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import shader from './eraser-agent.wgsl?raw'; import shader from './eraser-agent.wgsl?raw';
interface Bounds {
maxX: number;
maxY: number;
minX: number;
minY: number;
}
export class EraserAgentPipeline { export class EraserAgentPipeline {
private static readonly UNIFORM_COUNT = 4; private static readonly UNIFORM_COUNT = 8;
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline; private readonly pipeline: GPUComputePipeline;
@ -35,6 +42,7 @@ export class EraserAgentPipeline {
private pendingSegmentCount = 0; private pendingSegmentCount = 0;
private activeSegmentCount = 0; private activeSegmentCount = 0;
private pendingBounds: Bounds | null = null;
private agentCount = 0; private agentCount = 0;
public constructor( public constructor(
@ -84,32 +92,42 @@ export class EraserAgentPipeline {
}); });
} }
public addSwipeSegment(): void { public addSwipeSegment(from: vec2, to: vec2): void {
this.pendingSegmentCount += 1; this.pendingSegmentCount += 1;
this.pendingBounds = includeSegment(this.pendingBounds, from, to);
} }
public clearSwipes(): void { public clearSwipes(): void {
this.pendingSegmentCount = 0; this.pendingSegmentCount = 0;
this.activeSegmentCount = 0; this.activeSegmentCount = 0;
this.pendingBounds = null;
} }
public setParameters({ public setParameters({
agentCount, agentCount,
eraserMaskAlphaThreshold, eraserMaskAlphaThreshold,
eraserSize,
maskSize, maskSize,
}: { }: {
agentCount: number; agentCount: number;
eraserMaskAlphaThreshold: number; eraserMaskAlphaThreshold: number;
eraserSize: number;
maskSize: vec2; maskSize: vec2;
}): void { }): void {
this.agentCount = agentCount; this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount; this.activeSegmentCount = this.pendingSegmentCount;
const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize);
this.pendingSegmentCount = 0; this.pendingSegmentCount = 0;
this.pendingBounds = null;
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount)); this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
this.uniformValues[1] = eraserMaskAlphaThreshold; this.uniformValues[1] = eraserMaskAlphaThreshold;
this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0])); this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1])); 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( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
@ -122,12 +140,18 @@ export class EraserAgentPipeline {
return this.activeSegmentCount > 0; 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) { if (!this.hasActiveMask() || this.agentCount === 0) {
return; return;
} }
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask)); passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.agentCount); dispatchAgentWorkgroups(passEncoder, this.agentCount);
@ -138,3 +162,37 @@ export class EraserAgentPipeline {
this.uniforms.destroy(); 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, eraserMaskAlphaThreshold: f32,
maskWidth: u32, maskWidth: u32,
maskHeight: u32, maskHeight: u32,
boundsMin: vec2<f32>,
boundsMax: vec2<f32>,
}; };
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@ -23,9 +25,18 @@ fn main(
return; 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 maskSize = vec2<i32>(i32(settings.maskWidth), i32(settings.maskHeight));
let maskPosition = clamp( let maskPosition = clamp(
vec2<i32>(agents[id].position), vec2<i32>(position),
vec2<i32>(0, 0), vec2<i32>(0, 0),
maskSize - vec2<i32>(1, 1) maskSize - vec2<i32>(1, 1)
); );

View file

@ -7,97 +7,94 @@ import {
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; 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'; import shader from './eraser-texture.wgsl?raw';
interface LineSegment { interface EraserTextureParameters {
from: vec2; eraserSize: number;
to: vec2; eraserLineDistanceEpsilon: number;
eraserClearRed: number;
eraserClearGreen: number;
eraserClearBlue: number;
eraserClearAlpha: number;
} }
export class EraserTexturePipeline { const UNIFORM_COUNT = 8;
private static readonly UNIFORM_COUNT = 8; const TARGET_FORMATS: Array<GPUTextureFormat> = ['r8unorm', 'rgba16float', 'rgba16float'];
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;
export class EraserTexturePipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup; private readonly bindGroup: GPUBindGroup;
private readonly combinedPipeline: GPURenderPipeline; private readonly combinedPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
EraserTexturePipeline.UNIFORM_COUNT private readonly segments: LineSegmentBuffer;
);
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> = [];
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState private readonly commonState: CommonState
) { ) {
this.segments = new LineSegmentBuffer(
device,
appConfig.pipelines.eraser.maxTextureLineCount
);
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [
{ {
binding: 0, binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { buffer: { type: 'uniform' },
type: 'uniform',
},
}, },
], ],
}); });
this.vertexBuffer = device.createBuffer({ const shaderModule = smartCompile(
size: device,
EraserTexturePipeline.MAX_LINE_COUNT * CommonState.shaderCode,
EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * lineSegmentShader,
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT * shader
Float32Array.BYTES_PER_ELEMENT, );
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, 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.uniforms = device.createBuffer({
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
'r8unorm',
'rgba16float',
'rgba16float',
]);
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.bindGroup = this.device.createBindGroup({ this.bindGroup = device.createBindGroup({
layout: this.bindGroupLayout, layout: this.bindGroupLayout,
entries: [ entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
],
}); });
} }
public addSwipeSegment(from: vec2, to: vec2): void { public addSwipeSegment(from: vec2, to: vec2): void {
this.lineSegments.push({ this.segments.add(from, to);
from: vec2.clone(from),
to: vec2.clone(to),
});
} }
public clearSwipes(): void { public clearSwipes(): void {
this.lineSegments.length = 0; this.segments.clear();
this.actualSegments.length = 0;
} }
public setParameters({ public setParameters({
@ -107,14 +104,7 @@ export class EraserTexturePipeline {
eraserClearGreen, eraserClearGreen,
eraserClearBlue, eraserClearBlue,
eraserClearAlpha, eraserClearAlpha,
}: { }: EraserTextureParameters): void {
eraserSize: number;
eraserLineDistanceEpsilon: number;
eraserClearRed: number;
eraserClearGreen: number;
eraserClearBlue: number;
eraserClearAlpha: number;
}): void {
const eraserRadius = eraserSize / 2; const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius * eraserRadius; this.uniformValues[0] = eraserRadius * eraserRadius;
@ -131,45 +121,18 @@ export class EraserTexturePipeline {
this.uniformCache this.uniformCache
); );
this.actualSegments = this.lineSegments.slice(); this.segments.flush();
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
);
} }
public executeCombined( public executeCombined(
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
eraserMaskOut: GPUTextureView, eraserMaskOut: GPUTextureView,
sourceMapOut: GPUTextureView, sourceMapOut: GPUTextureView,
trailMapOut: GPUTextureView trailMapOut: GPUTextureView,
timestampWrites?: GPURenderPassTimestampWrites
): void { ): void {
if (this.lineCount === 0) { const lineCount = this.segments.activeCount;
if (lineCount === 0) {
const passEncoder = commandEncoder.beginRenderPass({ const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [ colorAttachments: [
{ {
@ -179,12 +142,13 @@ export class EraserTexturePipeline {
storeOp: 'store', storeOp: 'store',
}, },
], ],
timestampWrites,
}); });
passEncoder.end(); passEncoder.end();
return; return;
} }
const renderPassDescriptor: GPURenderPassDescriptor = { const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [ colorAttachments: [
{ {
view: eraserMaskOut, view: eraserMaskOut,
@ -192,107 +156,21 @@ export class EraserTexturePipeline {
loadOp: 'clear', loadOp: 'clear',
storeOp: 'store', storeOp: 'store',
}, },
{ { view: sourceMapOut, loadOp: 'load', storeOp: 'store' },
view: sourceMapOut, { view: trailMapOut, loadOp: 'load', storeOp: 'store' },
loadOp: 'load',
storeOp: 'store',
},
{
view: trailMapOut,
loadOp: 'load',
storeOp: 'store',
},
], ],
}; timestampWrites,
});
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.combinedPipeline); passEncoder.setPipeline(this.combinedPipeline);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer); passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end(); passEncoder.end();
} }
public destroy(): void { public destroy(): void {
this.vertexBuffer.destroy(); this.segments.destroy();
this.uniforms.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(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32 @location(3) @interpolate(flat) inverseLengthSquared: f32
) -> EraserCombinedTargets { ) -> EraserCombinedTargets {
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) { let distanceSquared = distance_squared_from_segment(
screenPosition,
start,
direction,
inverseLengthSquared
);
if distanceSquared > settings.eraserRadiusSquared {
discard; discard;
} }
@ -69,55 +75,3 @@ fn getEraserClearValue() -> vec4<f32> {
settings.clearAlpha 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; backgroundGrainStrength: number;
} }
export class RenderPipeline { // 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 5 scalars = 20 floats.
private static readonly UNIFORM_COUNT = 20; const UNIFORM_COUNT = 20;
export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline; private readonly pipeline: GPURenderPipeline;
private readonly noSourcePipeline: GPURenderPipeline; private readonly noSourcePipeline: GPURenderPipeline;
private readonly noGrainPipeline: GPURenderPipeline;
private readonly noSourceNoGrainPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
RenderPipeline.UNIFORM_COUNT private useBackgroundGrain = true;
);
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>( private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
(colorTexture, sourceTexture) => (colorTexture, sourceTexture) =>
@ -46,42 +48,83 @@ export class RenderPipeline {
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState 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 vertex = setUpFullScreenQuad(device);
const format = navigator.gpu.getPreferredCanvasFormat(); const format = navigator.gpu.getPreferredCanvasFormat();
this.pipeline = this.createPipeline(format, vertex, 'fragment'); const pipelineLayout = device.createPipelineLayout({
this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource'); 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({ this.uniforms = device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
} }
private createPipeline( private createPipeline(
format: GPUTextureFormat, layout: GPUPipelineLayout,
vertex: GPUVertexState, vertex: GPUVertexState,
shaderModule: GPUShaderModule,
format: GPUTextureFormat,
fragmentEntryPoint: string fragmentEntryPoint: string
): GPURenderPipeline { ): GPURenderPipeline {
return this.device.createRenderPipeline({ return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({ layout,
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex, vertex,
fragment: { fragment: {
module: smartCompile(this.device, CommonState.shaderCode, shader), module: shaderModule,
entryPoint: fragmentEntryPoint, entryPoint: fragmentEntryPoint,
targets: [ targets: [{ format }],
{
format,
},
],
},
primitive: {
topology: 'triangle-list',
}, },
primitive: { topology: 'triangle-list' },
}); });
} }
@ -101,15 +144,13 @@ export class RenderPipeline {
this.uniformValues[0] = rgbChannelToUnit(a[0]); this.uniformValues[0] = rgbChannelToUnit(a[0]);
this.uniformValues[1] = rgbChannelToUnit(a[1]); this.uniformValues[1] = rgbChannelToUnit(a[1]);
this.uniformValues[2] = rgbChannelToUnit(a[2]); 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[4] = rgbChannelToUnit(b[0]);
this.uniformValues[5] = rgbChannelToUnit(b[1]); this.uniformValues[5] = rgbChannelToUnit(b[1]);
this.uniformValues[6] = rgbChannelToUnit(b[2]); this.uniformValues[6] = rgbChannelToUnit(b[2]);
this.uniformValues[7] = 0;
this.uniformValues[8] = rgbChannelToUnit(c[0]); this.uniformValues[8] = rgbChannelToUnit(c[0]);
this.uniformValues[9] = rgbChannelToUnit(c[1]); this.uniformValues[9] = rgbChannelToUnit(c[1]);
this.uniformValues[10] = rgbChannelToUnit(c[2]); this.uniformValues[10] = rgbChannelToUnit(c[2]);
this.uniformValues[11] = 0;
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]); this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]); this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]); this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
@ -118,6 +159,7 @@ export class RenderPipeline {
this.uniformValues[17] = renderBrushColorBase; this.uniformValues[17] = renderBrushColorBase;
this.uniformValues[18] = renderBrushColorStrengthMultiplier; this.uniformValues[18] = renderBrushColorStrengthMultiplier;
this.uniformValues[19] = backgroundGrainStrength; this.uniformValues[19] = backgroundGrainStrength;
this.useBackgroundGrain = backgroundGrainStrength !== 0;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
@ -130,28 +172,18 @@ export class RenderPipeline {
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView, colorTexture: GPUTextureView,
sourceTexture: GPUTextureView, sourceTexture: GPUTextureView,
useSourceTexture = true useSourceTexture = true,
timestampWrites?: GPURenderPassTimestampWrites
): GPUTexture { ): GPUTexture {
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
const canvasTexture = this.context.getCurrentTexture(); const canvasTexture = this.context.getCurrentTexture();
this.encodePass(
const renderPassDescriptor: GPURenderPassDescriptor = { commandEncoder,
colorAttachments: [ colorTexture,
{ sourceTexture,
view: canvasTexture.createView(), canvasTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 }, useSourceTexture,
loadOp: 'clear', timestampWrites
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();
return canvasTexture; return canvasTexture;
} }
@ -159,56 +191,54 @@ export class RenderPipeline {
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView, colorTexture: GPUTextureView,
sourceTexture: 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({ const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [ colorAttachments: [
{ {
view: outputTexture, view: output,
clearValue: { r: 0, g: 0, b: 0, a: 1 }, clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear', loadOp: 'clear',
storeOp: 'store', storeOp: 'store',
}, },
], ],
timestampWrites,
}); });
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.getPipeline(useSourceTexture));
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup); passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture));
passEncoder.draw(3, 1); passEncoder.draw(3, 1);
passEncoder.end(); 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() { public destroy() {
this.uniforms.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 { struct Settings {
colorA: vec3<f32>, colorA: vec3<f32>,
backgroundColorPadding0: f32, _colorAPadding: f32,
colorB: vec3<f32>, colorB: vec3<f32>,
backgroundColorPadding1: f32, _colorBPadding: f32,
colorC: vec3<f32>, colorC: vec3<f32>,
backgroundColorPadding2: f32, _colorCPadding: f32,
backgroundColor: vec3<f32>, backgroundColor: vec3<f32>,
clarity: f32, clarity: f32,
traceNormalizationFloor: 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 pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0); let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0); let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, pixel); return renderColor(traces, sources, getTexturedBackground(pixel));
} }
@fragment @fragment
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> { fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy); let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0); 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> { @fragment
let background = getTexturedBackground(pixel); 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 tracesMax = maxComponent(traces.rgb);
let sourcesMax = maxComponent(sources.rgb); let sourcesMax = maxComponent(sources.rgb);
if max(tracesMax, sourcesMax) <= 0.0 { if max(tracesMax, sourcesMax) <= 0.0 {
@ -93,7 +107,11 @@ fn maxComponent(v: vec3<f32>) -> f32 {
} }
fn clarity(strength: 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> { fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
@ -101,10 +119,11 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
return color / max(settings.traceNormalizationFloor, brightestChannel); return color / max(settings.traceNormalizationFloor, brightestChannel);
} }
fn getFlatBackground() -> vec3<f32> {
return clamp(settings.backgroundColor, vec3(0), vec3(1));
}
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> { fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
if settings.backgroundGrainStrength == 0.0 {
return clamp(settings.backgroundColor, vec3(0), vec3(1));
}
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK)); let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5; 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 .perf-stats-overlay {
html > body.is-loading .dev-stats-overlay {
display: none; 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 { html > body {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
@ -20,16 +22,57 @@ html > body {
overflow: hidden; overflow: hidden;
> canvas { > canvas {
position: relative;
z-index: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
touch-action: none; 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 { > .eraser-preview {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 1; z-index: 3;
width: var(--eraser-preview-size, 96px); width: var(--eraser-preview-size, 96px);
height: var(--eraser-preview-size, 96px); height: var(--eraser-preview-size, 96px);
border: 2px solid rgb(255 234 228 / 88%); border: 2px solid rgb(255 234 228 / 88%);
@ -52,7 +95,7 @@ html > body {
} }
} }
> .dev-stats-overlay { > .perf-stats-overlay {
position: absolute; position: absolute;
top: max(8px, env(safe-area-inset-top)); top: max(8px, env(safe-area-inset-top));
left: max(8px, env(safe-area-inset-left)); left: max(8px, env(safe-area-inset-left));

View file

@ -1,9 +1,150 @@
.config-pane { @use 'mixins' as *;
.color-reaction-folder > .tp-fldv_c {
padding: 6px 8px 8px; .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 { .color-reaction-matrix {
display: grid; display: grid;
grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr)); 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 *; @use 'toolbar/layout';
@use 'toolbar/buttons';
@mixin toolbar-track() { @use 'toolbar/garden-controls';
height: 7px; @use 'toolbar/responsive';
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;
}
}
}
}
}
}

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 { export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null; private previousTime: DOMHighResTimeStamp | null = null;
constructor( constructor() {
private readonly maxDeltaTimeInSeconds?: number,
private readonly minDeltaTimeInSeconds?: number
) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
} }
@ -20,7 +17,11 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime; const delta = currentTime - this.previousTime;
this.previousTime = currentTime; this.previousTime = currentTime;
return clamp(delta / 1000, this.minDeltaTime, this.maxDeltaTime); return clamp(
delta / 1000,
appConfig.deltaTime.minDeltaTimeSeconds,
appConfig.deltaTime.maxDeltaTimeSeconds
);
} }
private handleVisibilityChange() { private handleVisibilityChange() {
@ -28,12 +29,4 @@ export class DeltaTimeCalculator {
this.previousTime = null; 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