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