This commit is contained in:
parent
6bc125be1c
commit
ed5a4379db
76 changed files with 1418 additions and 988 deletions
|
|
@ -29,18 +29,15 @@ jobs:
|
||||||
npm ci
|
npm ci
|
||||||
npx playwright install --with-deps chromium
|
npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint:check
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: |
|
|
||||||
npm run unused:check
|
|
||||||
npm run typecheck
|
|
||||||
npm run typecheck:e2e
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
|
npm run lint:check
|
||||||
|
npm run typecheck
|
||||||
|
npm run typecheck:e2e
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
|
- name: Test E2E
|
||||||
|
run: |
|
||||||
npm run test:e2e
|
npm run test:e2e
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- `npm test` runs the Vitest unit suite.
|
- `npm test` runs the Vitest unit suite.
|
||||||
- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium
|
- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright
|
||||||
smoke test.
|
config builds the production bundle before serving it.
|
||||||
- `npx playwright install chromium` installs the local browser binary when needed.
|
- `npx playwright install chromium` installs the local browser binary when needed.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
import prettierConfig from 'eslint-config-prettier';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
20
index.html
20
index.html
|
|
@ -88,14 +88,14 @@
|
||||||
<div class="garden-prompt" aria-live="polite"></div>
|
<div class="garden-prompt" aria-live="polite"></div>
|
||||||
|
|
||||||
<div class="loading-indicator" role="status">
|
<div class="loading-indicator" role="status">
|
||||||
<div class="splash">
|
<div class="splash" data-visible="true">
|
||||||
<h1 class="splash-title">Fleeting Garden</h1>
|
<h1 class="splash-title">Fleeting Garden</h1>
|
||||||
<p class="splash-description">
|
<p class="splash-description">
|
||||||
Tend it while you can. The garden returns to weather either way.
|
Tend it while you can. The garden returns to weather either way.
|
||||||
</p>
|
</p>
|
||||||
<button class="start-button" type="button" disabled>Start</button>
|
<button class="start-button" type="button" disabled>Start</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="loading-bar" hidden>
|
<div class="loading-bar" data-visible="false" aria-hidden="true" inert>
|
||||||
<div class="loading-status">Starting up…</div>
|
<div class="loading-status">Starting up…</div>
|
||||||
<div
|
<div
|
||||||
class="loading-progress"
|
class="loading-progress"
|
||||||
|
|
@ -160,7 +160,7 @@
|
||||||
|
|
||||||
<div class="toolbar-shell">
|
<div class="toolbar-shell">
|
||||||
<section class="garden-controls" aria-label="Garden controls">
|
<section class="garden-controls" aria-label="Garden controls">
|
||||||
<div class="swatches" aria-label="Drawing colours">
|
<div class="swatches" role="group" aria-label="Drawing colours">
|
||||||
<button
|
<button
|
||||||
class="color-swatch"
|
class="color-swatch"
|
||||||
aria-label="Draw colour 1"
|
aria-label="Draw colour 1"
|
||||||
|
|
@ -193,24 +193,21 @@
|
||||||
<nav class="buttons" aria-label="App controls">
|
<nav class="buttons" aria-label="App controls">
|
||||||
<button
|
<button
|
||||||
class="info"
|
class="info"
|
||||||
|
data-control="info"
|
||||||
aria-label="About"
|
aria-label="About"
|
||||||
aria-controls="info-panel"
|
aria-controls="info-panel"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
title="About"
|
title="About"
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
class="maximize-full-screen"
|
class="full-screen-toggle"
|
||||||
|
data-control="full-screen"
|
||||||
aria-label="Enter fullscreen"
|
aria-label="Enter fullscreen"
|
||||||
title="Enter fullscreen"
|
title="Enter fullscreen"
|
||||||
></button>
|
></button>
|
||||||
<button
|
|
||||||
class="minimize-full-screen"
|
|
||||||
aria-label="Exit fullscreen"
|
|
||||||
hidden
|
|
||||||
title="Exit fullscreen"
|
|
||||||
></button>
|
|
||||||
<button
|
<button
|
||||||
class="settings"
|
class="settings"
|
||||||
|
data-control="settings"
|
||||||
aria-label="Show config overlay"
|
aria-label="Show config overlay"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
title="Show config overlay"
|
title="Show config overlay"
|
||||||
|
|
@ -218,6 +215,7 @@
|
||||||
<div class="audio-control">
|
<div class="audio-control">
|
||||||
<button
|
<button
|
||||||
class="sound"
|
class="sound"
|
||||||
|
data-control="sound"
|
||||||
aria-label="Mute audio"
|
aria-label="Mute audio"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
title="Mute audio"
|
title="Mute audio"
|
||||||
|
|
@ -228,12 +226,14 @@
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="export-4k"
|
class="export-4k"
|
||||||
|
data-control="export"
|
||||||
aria-label="Download internal buffer snapshot"
|
aria-label="Download internal buffer snapshot"
|
||||||
title="Download internal buffer snapshot"
|
title="Download internal buffer snapshot"
|
||||||
></button>
|
></button>
|
||||||
<span class="export-status" aria-live="polite"></span>
|
<span class="export-status" aria-live="polite"></span>
|
||||||
<button
|
<button
|
||||||
class="restart"
|
class="restart"
|
||||||
|
data-control="restart"
|
||||||
aria-label="Restart simulation"
|
aria-label="Restart simulation"
|
||||||
title="Restart simulation"
|
title="Restart simulation"
|
||||||
></button>
|
></button>
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -8,15 +8,15 @@
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint:check": "eslint \"src/**/*.ts\" && npm run unused:check",
|
"lint:check": "eslint . && npm run unused:check",
|
||||||
"lint:fix": "eslint --fix \"src/**/*.ts\"",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
"format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||||
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
"format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
|
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "npm run build && playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "npm run build && playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
|
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
|
||||||
"generate-icons": "pwa-assets-generator"
|
"generate-icons": "pwa-assets-generator"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml"
|
"type": "image/svg+xml"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "pwa-64x64.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "pwa-192x192.png",
|
"src": "pwa-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,22 @@ export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||||
progression: Array<GardenAudioChord>;
|
progression: Array<GardenAudioChord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
|
||||||
|
idleIntensity: 0.08,
|
||||||
|
bpm: 74,
|
||||||
|
rampUpIntensity: 0.85,
|
||||||
|
rampUpTime: 0.08,
|
||||||
|
noteLength: 0.42,
|
||||||
|
notePitchOffset: 0,
|
||||||
|
brightness: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export const createGardenAudioConfig = () => ({
|
export const createGardenAudioConfig = () => ({
|
||||||
masterVolume: DEFAULT_AUDIO_VOLUME,
|
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||||
fadeInSeconds: 0.45,
|
fadeInSeconds: 0.45,
|
||||||
updateRampSeconds: 0.08,
|
updateRampSeconds: 0.08,
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: 0.46,
|
timeSeconds: 0.405,
|
||||||
feedback: 0.12,
|
feedback: 0.12,
|
||||||
wetGain: 0.044,
|
wetGain: 0.044,
|
||||||
erasingActivity: 0.12,
|
erasingActivity: 0.12,
|
||||||
|
|
@ -43,9 +53,9 @@ export const createGardenAudioConfig = () => ({
|
||||||
maxVoices: 24,
|
maxVoices: 24,
|
||||||
gain: 0.48,
|
gain: 0.48,
|
||||||
sustainSeconds: 0.42,
|
sustainSeconds: 0.42,
|
||||||
sustainLevel: 0.32,
|
sustainLevel: 0.26,
|
||||||
releaseSeconds: 0.24,
|
releaseSeconds: 0.34,
|
||||||
lowpassHz: 7600,
|
lowpassHz: 7000,
|
||||||
gainAttackSeconds: 0.006,
|
gainAttackSeconds: 0.006,
|
||||||
lowpassMaxHz: 12000,
|
lowpassMaxHz: 12000,
|
||||||
lowpassMinHz: 1400,
|
lowpassMinHz: 1400,
|
||||||
|
|
@ -53,8 +63,8 @@ export const createGardenAudioConfig = () => ({
|
||||||
sustainVelocityRange: 0.55,
|
sustainVelocityRange: 0.55,
|
||||||
},
|
},
|
||||||
rhythm: {
|
rhythm: {
|
||||||
idleIntensity: 0.08,
|
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
|
||||||
bpm: 74,
|
bpm: defaultGardenAudioVibeSettings.bpm,
|
||||||
stepsPerBeat: 4,
|
stepsPerBeat: 4,
|
||||||
stepsPerBar: 16,
|
stepsPerBar: 16,
|
||||||
sparseActivity: 0.055,
|
sparseActivity: 0.055,
|
||||||
|
|
@ -69,9 +79,7 @@ export const createGardenAudioConfig = () => ({
|
||||||
pianoActivity: 0,
|
pianoActivity: 0,
|
||||||
},
|
},
|
||||||
energy: {
|
energy: {
|
||||||
attackSeconds: 0.08,
|
|
||||||
decaySeconds: 0.9,
|
decaySeconds: 0.9,
|
||||||
immediateActivityScale: 0.85,
|
|
||||||
releaseSeconds: 1.15,
|
releaseSeconds: 1.15,
|
||||||
strokeDecaySeconds: 0.32,
|
strokeDecaySeconds: 0.32,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
const distancePixels = Math.hypot(dx, dy);
|
const distancePixels = Math.hypot(dx, dy);
|
||||||
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
||||||
const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
|
const normalizationPixels = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
|
||||||
|
);
|
||||||
const normalizedDistance = distancePixels / normalizationPixels;
|
const normalizedDistance = distancePixels / normalizationPixels;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -260,11 +260,10 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
public stroke(stroke: GardenAudioStroke): void {
|
public stroke(stroke: GardenAudioStroke): void {
|
||||||
if (this.lifecycle === 'destroyed' || this.isMuted) {
|
if (this.lifecycle !== 'started' || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.start(stroke.vibe);
|
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -207,8 +207,8 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
midiMin: 62,
|
midiMin: 62,
|
||||||
midiMax: 81,
|
midiMax: 78,
|
||||||
preferredMidi: 72,
|
preferredMidi: 70,
|
||||||
pan: 0.18,
|
pan: 0.18,
|
||||||
scaleDegrees: [2, 3, 4, 6],
|
scaleDegrees: [2, 3, 4, 6],
|
||||||
},
|
},
|
||||||
|
|
@ -254,10 +254,10 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
expressionMultiplier: 0.9,
|
expressionMultiplier: 0.9,
|
||||||
},
|
},
|
||||||
padChord: {
|
padChord: {
|
||||||
velocities: [0.052, 0.041, 0.033],
|
velocities: [0.046, 0.036, 0.029],
|
||||||
expressionVelocityWeight: 0.02,
|
expressionVelocityWeight: 0.018,
|
||||||
delaySend: 0.008,
|
delaySend: 0.008,
|
||||||
lowpassExpressionWeight: 0.28,
|
lowpassExpressionWeight: 0.24,
|
||||||
},
|
},
|
||||||
supportNote: {
|
supportNote: {
|
||||||
velocityBase: 0.105,
|
velocityBase: 0.105,
|
||||||
|
|
@ -316,7 +316,7 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
brushStream: {
|
brushStream: {
|
||||||
inferredManiaThreshold: 0.82,
|
inferredManiaThreshold: 0.82,
|
||||||
inferredManiaRange: 0.18,
|
inferredManiaRange: 0.18,
|
||||||
registerManiaShift: 0.45,
|
registerManiaShift: 0.3,
|
||||||
chordToneEverySteps: 4,
|
chordToneEverySteps: 4,
|
||||||
durationBaseSeconds: 0.48,
|
durationBaseSeconds: 0.48,
|
||||||
durationIntensitySeconds: 0.08,
|
durationIntensitySeconds: 0.08,
|
||||||
|
|
@ -329,22 +329,22 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
delaySendMin: 0.006,
|
delaySendMin: 0.006,
|
||||||
delaySendMax: 0.032,
|
delaySendMax: 0.032,
|
||||||
velocityBase: 0.1,
|
velocityBase: 0.1,
|
||||||
velocityIntensityWeight: 0.13,
|
velocityIntensityWeight: 0.1,
|
||||||
lowpassBaseExpression: 0.39,
|
lowpassBaseExpression: 0.39,
|
||||||
lowpassIntensityWeight: 0.48,
|
lowpassIntensityWeight: 0.48,
|
||||||
lowpassManiaWeight: 0.18,
|
lowpassManiaWeight: 0.18,
|
||||||
intenseThreshold: 0.62,
|
intenseThreshold: 0.68,
|
||||||
activeThreshold: 0.34,
|
activeThreshold: 0.34,
|
||||||
},
|
},
|
||||||
brushStreamEcho: {
|
brushStreamEcho: {
|
||||||
maniaThreshold: 0.86,
|
maniaThreshold: 0.92,
|
||||||
stepModulo: 2,
|
stepModulo: 3,
|
||||||
stepRemainder: 1,
|
stepRemainder: 1,
|
||||||
intensityThreshold: 0.95,
|
intensityThreshold: 0.95,
|
||||||
octaveSemitones: 12,
|
octaveSemitones: 12,
|
||||||
maxMidi: 88,
|
maxMidi: 84,
|
||||||
velocityBase: 0.045,
|
velocityBase: 0.035,
|
||||||
velocityIntensityWeight: 0.05,
|
velocityIntensityWeight: 0.04,
|
||||||
durationMinSeconds: 0.11,
|
durationMinSeconds: 0.11,
|
||||||
durationScale: 0.68,
|
durationScale: 0.68,
|
||||||
panScale: -0.75,
|
panScale: -0.75,
|
||||||
|
|
@ -361,7 +361,7 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
lowOffset: -1,
|
lowOffset: -1,
|
||||||
},
|
},
|
||||||
registerBias: {
|
registerBias: {
|
||||||
maniaShiftSemitones: 4,
|
maniaShiftSemitones: 2,
|
||||||
midiMin: 36,
|
midiMin: 36,
|
||||||
midiMaxForMin: 86,
|
midiMaxForMin: 86,
|
||||||
minimumSpan: 4,
|
minimumSpan: 4,
|
||||||
|
|
@ -375,7 +375,7 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
lowpass: {
|
lowpass: {
|
||||||
midiBase: 48,
|
midiBase: 48,
|
||||||
midiRange: 33,
|
midiRange: 33,
|
||||||
midiLiftHz: 720,
|
midiLiftHz: 500,
|
||||||
expressionBase: 0.58,
|
expressionBase: 0.58,
|
||||||
expressionWeight: 0.32,
|
expressionWeight: 0.32,
|
||||||
},
|
},
|
||||||
|
|
@ -396,16 +396,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
strokeAccentMinSteps: 12,
|
strokeAccentMinSteps: 12,
|
||||||
strokeAccentThreshold: 0.58,
|
strokeAccentThreshold: 0.58,
|
||||||
maxBrushPhraseLayers: 3,
|
maxBrushPhraseLayers: 3,
|
||||||
maxBrushStreamNotesPerBar: 9,
|
maxBrushStreamNotesPerBar: 7,
|
||||||
brushLayerBaseSeconds: 5.5,
|
brushLayerBaseSeconds: 5.5,
|
||||||
brushLayerEnergySeconds: 2.5,
|
brushLayerEnergySeconds: 2.5,
|
||||||
brushLayerMinIntensity: 0.12,
|
brushLayerMinIntensity: 0.12,
|
||||||
brushStreamIdleIntervalBeats: 2,
|
brushStreamIdleIntervalBeats: 2,
|
||||||
brushStreamActiveIntervalBeats: 1,
|
brushStreamActiveIntervalBeats: 1,
|
||||||
brushStreamIntenseIntervalBeats: 0.5,
|
brushStreamIntenseIntervalBeats: 0.75,
|
||||||
brushMotifMaxSteps: 8,
|
brushMotifMaxSteps: 8,
|
||||||
brushMotifCanonDelaySeconds: 0.055,
|
brushMotifCanonDelaySeconds: 0.055,
|
||||||
padDurationBarScale: 0.46,
|
padDurationBarScale: 0.82,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const styleVoices: [
|
export const styleVoices: [
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,8 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isWaitingForGestureAccent &&
|
this.isWaitingForGestureAccent &&
|
||||||
now - this.lastGestureAccentAt >= generativePianoTuning.gestureAccentMinIntervalSeconds
|
now - this.lastGestureAccentAt >=
|
||||||
|
generativePianoTuning.gestureAccentMinIntervalSeconds
|
||||||
) {
|
) {
|
||||||
this.recordTouchDown({
|
this.recordTouchDown({
|
||||||
vibe,
|
vibe,
|
||||||
|
|
@ -554,7 +555,9 @@ export class GenerativePianoEngine {
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const degrees = this.rotate(
|
const degrees = this.rotate(
|
||||||
pool.scaleDegrees,
|
pool.scaleDegrees,
|
||||||
Math.round(strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier)
|
Math.round(
|
||||||
|
strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const midi = this.chooseMidi(
|
const midi = this.chooseMidi(
|
||||||
|
|
@ -705,7 +708,9 @@ export class GenerativePianoEngine {
|
||||||
);
|
);
|
||||||
layer.motifOffsets.push(this.getMotifOffset(strength));
|
layer.motifOffsets.push(this.getMotifOffset(strength));
|
||||||
if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) {
|
if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) {
|
||||||
layer.motifOffsets = layer.motifOffsets.slice(-generativePianoTuning.brushMotifMaxSteps);
|
layer.motifOffsets = layer.motifOffsets.slice(
|
||||||
|
-generativePianoTuning.brushMotifMaxSteps
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -789,7 +794,9 @@ export class GenerativePianoEngine {
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const useChordTone =
|
const useChordTone =
|
||||||
this.brushStreamNoteIndex % generativePianoTuning.brushStream.chordToneEverySteps === 0;
|
this.brushStreamNoteIndex %
|
||||||
|
generativePianoTuning.brushStream.chordToneEverySteps ===
|
||||||
|
0;
|
||||||
const source = useChordTone
|
const source = useChordTone
|
||||||
? {
|
? {
|
||||||
baseMidi: rootMidi,
|
baseMidi: rootMidi,
|
||||||
|
|
@ -897,7 +904,8 @@ export class GenerativePianoEngine {
|
||||||
layer.energy *
|
layer.energy *
|
||||||
this.getBrushPhraseFade(layer, startTime) *
|
this.getBrushPhraseFade(layer, startTime) *
|
||||||
(generativePianoTuning.brushPhrase.layerIntensityBase +
|
(generativePianoTuning.brushPhrase.layerIntensityBase +
|
||||||
layer.maniaAmount * generativePianoTuning.brushPhrase.layerIntensityManiaWeight),
|
layer.maniaAmount *
|
||||||
|
generativePianoTuning.brushPhrase.layerIntensityManiaWeight),
|
||||||
}));
|
}));
|
||||||
const dominant = layerStates.reduce<{
|
const dominant = layerStates.reduce<{
|
||||||
layer: BrushPhraseLayer;
|
layer: BrushPhraseLayer;
|
||||||
|
|
@ -1073,7 +1081,8 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
barIndex % generativePianoTuning.supportBarSpacing === generativePianoTuning.supportBarOffset
|
barIndex % generativePianoTuning.supportBarSpacing ===
|
||||||
|
generativePianoTuning.supportBarOffset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1136,7 +1145,8 @@ export class GenerativePianoEngine {
|
||||||
): number {
|
): number {
|
||||||
const midiLift =
|
const midiLift =
|
||||||
clamp01(
|
clamp01(
|
||||||
(midi - generativePianoTuning.lowpass.midiBase) / generativePianoTuning.lowpass.midiRange
|
(midi - generativePianoTuning.lowpass.midiBase) /
|
||||||
|
generativePianoTuning.lowpass.midiRange
|
||||||
) * generativePianoTuning.lowpass.midiLiftHz;
|
) * generativePianoTuning.lowpass.midiLiftHz;
|
||||||
return clamp(
|
return clamp(
|
||||||
this.config.piano.lowpassHz *
|
this.config.piano.lowpassHz *
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||||
|
|
||||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||||
|
|
||||||
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
|
||||||
|
|
||||||
interface ActivePianoVoice {
|
interface ActivePianoVoice {
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
source: AudioScheduledSourceNode;
|
source: AudioScheduledSourceNode;
|
||||||
|
|
@ -27,7 +25,6 @@ const pianoSamplerTuning = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PianoSampler {
|
export class PianoSampler {
|
||||||
private loadState: PianoLoadState = 'idle';
|
|
||||||
private samples: Array<LoadedPianoSample> = [];
|
private samples: Array<LoadedPianoSample> = [];
|
||||||
private activeVoices: Array<ActivePianoVoice> = [];
|
private activeVoices: Array<ActivePianoVoice> = [];
|
||||||
|
|
||||||
|
|
@ -37,26 +34,18 @@ export class PianoSampler {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public load(context: BaseAudioContext): Promise<void> {
|
public load(context: BaseAudioContext): Promise<void> {
|
||||||
if (this.loadState === 'loaded') {
|
if (this.samples.length > 0) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedSamples = getLoadedPianoSamples();
|
const loadedSamples = getLoadedPianoSamples();
|
||||||
if (loadedSamples) {
|
if (loadedSamples) {
|
||||||
this.setSamples(loadedSamples);
|
this.setSamples(loadedSamples);
|
||||||
this.loadState = 'loaded';
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadState = 'loading';
|
return loadPianoSamples(context).then((samples) => {
|
||||||
return loadPianoSamples(context)
|
|
||||||
.then((samples) => {
|
|
||||||
this.setSamples(samples);
|
this.setSamples(samples);
|
||||||
this.loadState = 'loaded';
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.loadState = 'idle';
|
|
||||||
throw error;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +143,6 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.loadState = 'idle';
|
|
||||||
this.samples = [];
|
this.samples = [];
|
||||||
this.activeVoices = [];
|
this.activeVoices = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ import type { GardenAppConfig } from './config/types';
|
||||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
|
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeNumberControlValue,
|
||||||
|
normalizeRuntimeSettings,
|
||||||
|
} from './config/normalize-runtime-settings';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GardenAppConfig,
|
GardenAppConfig,
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
|
|
@ -107,8 +112,6 @@ export const appConfig = {
|
||||||
stroke: {
|
stroke: {
|
||||||
densityMultiplier: 110,
|
densityMultiplier: 110,
|
||||||
maxAgentCount: 2_400,
|
maxAgentCount: 2_400,
|
||||||
minAgentCount: 140,
|
|
||||||
minSegmentLengthPx: 1,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||||
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
||||||
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||||
const cssMegapixels = Math.max(cssWidth, 1) * Math.max(cssHeight, 1) / 1_000_000;
|
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||||
return Math.min(
|
return Math.min(
|
||||||
INTERNAL_RENDER_AREA_BOUNDS.max,
|
INTERNAL_RENDER_AREA_BOUNDS.max,
|
||||||
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
|
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
|
||||||
|
|
|
||||||
46
src/config/normalize-runtime-settings.ts
Normal file
46
src/config/normalize-runtime-settings.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type {
|
||||||
|
GardenAppConfig,
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
NumberControlConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
|
||||||
|
|
||||||
|
export const normalizeNumberControlValue = (
|
||||||
|
value: number,
|
||||||
|
config: NumberControlConfig
|
||||||
|
): number => {
|
||||||
|
if (config.options) {
|
||||||
|
const optionValues = Object.values(config.options);
|
||||||
|
if (optionValues.includes(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
||||||
|
const max = config.max ?? Number.POSITIVE_INFINITY;
|
||||||
|
const fallbackValue = config.min ?? 0;
|
||||||
|
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
|
||||||
|
const clampedValue = Math.min(max, Math.max(min, finiteValue));
|
||||||
|
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRuntimeSettings = (
|
||||||
|
settings: GardenRuntimeSettings,
|
||||||
|
controls: RuntimeSettingControls
|
||||||
|
): GardenRuntimeSettings => {
|
||||||
|
const normalized = { ...settings };
|
||||||
|
|
||||||
|
(
|
||||||
|
Object.entries(controls) as Array<
|
||||||
|
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
|
||||||
|
>
|
||||||
|
).forEach(([key, config]) => {
|
||||||
|
if (config) {
|
||||||
|
normalized[key] = normalizeNumberControlValue(normalized[key], config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
@ -6,15 +6,15 @@ const formatRadiansAsDegrees = (value: number): string =>
|
||||||
`${Math.round((value * 180) / Math.PI)} deg`;
|
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||||
|
|
||||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
color1ToColor1: colorInteractionControl('Primary Follows Primary'),
|
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||||
color1ToColor2: colorInteractionControl('Primary Follows Secondary'),
|
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||||
color1ToColor3: colorInteractionControl('Primary Follows Accent'),
|
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||||
color2ToColor1: colorInteractionControl('Secondary Follows Primary'),
|
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
|
||||||
color2ToColor2: colorInteractionControl('Secondary Follows Secondary'),
|
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
|
||||||
color2ToColor3: colorInteractionControl('Secondary Follows Accent'),
|
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
|
||||||
color3ToColor1: colorInteractionControl('Accent Follows Primary'),
|
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
|
||||||
color3ToColor2: colorInteractionControl('Accent Follows Secondary'),
|
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
|
||||||
color3ToColor3: colorInteractionControl('Accent Follows Accent'),
|
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
|
||||||
|
|
||||||
brushSize: {
|
brushSize: {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
|
|
@ -25,7 +25,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
},
|
},
|
||||||
spawnPerPixel: {
|
spawnPerPixel: {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
label: 'Agent Density',
|
label: 'Density',
|
||||||
min: 0.01,
|
min: 0.01,
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
|
|
@ -39,28 +39,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
sensorOffsetDistance: {
|
sensorOffsetDistance: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
label: 'Sensor Reach',
|
label: 'Sensor Reach',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 200,
|
max: 200,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
moveSpeed: {
|
moveSpeed: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
label: 'Travel Speed',
|
label: 'Travel Speed',
|
||||||
min: 10,
|
min: 10,
|
||||||
max: 500,
|
max: 500,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
turnSpeed: {
|
turnSpeed: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
label: 'Turning Speed',
|
label: 'Turning Speed',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 200,
|
max: 200,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
forwardRotationScale: {
|
forwardRotationScale: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
format: formatPercent,
|
format: formatPercent,
|
||||||
label: 'Forward Focus',
|
label: 'Forward Focus',
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -68,21 +68,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
turnWhenLost: {
|
turnWhenLost: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
label: 'Wander Turn',
|
label: 'Wander Turn',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 6.28,
|
max: 6.28,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
individualTrailWeight: {
|
individualTrailWeight: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
label: 'Trail Strength',
|
label: 'Trail Strength',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
decayRateTrails: {
|
decayRateTrails: {
|
||||||
folder: 'Agents',
|
folder: 'Movement',
|
||||||
label: 'Trail Fade',
|
label: 'Trail Fade',
|
||||||
min: 800,
|
min: 800,
|
||||||
max: 1000,
|
max: 1000,
|
||||||
|
|
@ -107,7 +107,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
maxAgentCount: {
|
maxAgentCount: {
|
||||||
folder: 'Performance',
|
folder: 'Performance',
|
||||||
integer: true,
|
integer: true,
|
||||||
label: 'Agent Limit',
|
label: 'Population Limit',
|
||||||
min: 0,
|
min: 0,
|
||||||
step: 10_000,
|
step: 10_000,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface NumberControlConfig {
|
||||||
export type GardenRuntimeSettings = {
|
export type GardenRuntimeSettings = {
|
||||||
adaptiveCapInitial: number;
|
adaptiveCapInitial: number;
|
||||||
adaptiveCapMin: number;
|
adaptiveCapMin: number;
|
||||||
|
backgroundGrainStrength: number;
|
||||||
brushCurveResolution: number;
|
brushCurveResolution: number;
|
||||||
brushCurveMinBrushRadius: number;
|
brushCurveMinBrushRadius: number;
|
||||||
brushCurveMinSegmentSpacing: number;
|
brushCurveMinSegmentSpacing: number;
|
||||||
|
|
@ -70,12 +71,14 @@ type GardenDefaultSettings = Omit<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export enum VibeId {
|
export enum VibeId {
|
||||||
CandyRain = 'candy-rain',
|
AuroraMycelium = 'aurora-mycelium',
|
||||||
SunlitMoss = 'sunlit-moss',
|
EmberCircuit = 'ember-circuit',
|
||||||
CoralTide = 'coral-tide',
|
VelvetObservatory = 'velvet-observatory',
|
||||||
MoonOrchid = 'moon-orchid',
|
LichenSignal = 'lichen-signal',
|
||||||
PeachNeon = 'peach-neon',
|
UltravioletSiren = 'ultraviolet-siren',
|
||||||
FrostBloom = 'frost-bloom',
|
TidepoolLantern = 'tidepool-lantern',
|
||||||
|
PaperLanternFog = 'paper-lantern-fog',
|
||||||
|
ChromePollen = 'chrome-pollen',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VibePreset {
|
export interface VibePreset {
|
||||||
|
|
@ -181,8 +184,6 @@ export interface GardenAppConfig {
|
||||||
stroke: {
|
stroke: {
|
||||||
densityMultiplier: number;
|
densityMultiplier: number;
|
||||||
maxAgentCount: number;
|
maxAgentCount: number;
|
||||||
minAgentCount: number;
|
|
||||||
minSegmentLengthPx: number;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
storage: {
|
storage: {
|
||||||
|
|
|
||||||
|
|
@ -1,166 +1,255 @@
|
||||||
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||||
import { VibeId, type VibePreset } from './types';
|
import { VibeId, type VibePreset } from './types';
|
||||||
|
|
||||||
const defaultAudioSettings = {
|
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||||
idleIntensity: 0.08,
|
|
||||||
bpm: 74,
|
|
||||||
rampUpIntensity: 0.85,
|
|
||||||
rampUpTime: 0.08,
|
|
||||||
noteLength: 0.42,
|
|
||||||
notePitchOffset: 0,
|
|
||||||
} satisfies Omit<GardenAudioVibeSettings, 'brightness'>;
|
|
||||||
|
|
||||||
export const defaultVibeId = VibeId.CandyRain;
|
|
||||||
|
|
||||||
export const vibePresets: Array<VibePreset> = [
|
export const vibePresets: Array<VibePreset> = [
|
||||||
{
|
{
|
||||||
id: VibeId.CandyRain,
|
id: VibeId.AuroraMycelium,
|
||||||
name: 'Candy Rain',
|
name: 'Aurora Mycelium',
|
||||||
colors: [
|
colors: [
|
||||||
[255, 93, 162],
|
[78, 255, 176],
|
||||||
[54, 215, 208],
|
[154, 99, 255],
|
||||||
[255, 216, 77],
|
[169, 238, 255],
|
||||||
],
|
],
|
||||||
backgroundColor: [16, 21, 31],
|
backgroundColor: [6, 13, 22],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.018,
|
backgroundGrainStrength: 0.016,
|
||||||
brushSize: 14,
|
brushSize: 20,
|
||||||
clarity: 0.62,
|
clarity: 0.52,
|
||||||
decayRateTrails: 965,
|
decayRateTrails: 988,
|
||||||
individualTrailWeight: 0.07,
|
individualTrailWeight: 0.085,
|
||||||
moveSpeed: 82,
|
moveSpeed: 54,
|
||||||
sensorOffsetDistance: 38,
|
sensorOffsetDistance: 72,
|
||||||
spawnPerPixel: 0.22,
|
spawnPerPixel: 0.13,
|
||||||
turnSpeed: 58,
|
turnSpeed: 35,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultAudioSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
brightness: 1.04,
|
idleIntensity: 0.12,
|
||||||
|
bpm: 60,
|
||||||
|
rampUpIntensity: 0.7,
|
||||||
|
rampUpTime: 0.14,
|
||||||
|
noteLength: 0.86,
|
||||||
|
notePitchOffset: -2,
|
||||||
|
brightness: 0.84,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.SunlitMoss,
|
id: VibeId.EmberCircuit,
|
||||||
name: 'Sunlit Moss',
|
name: 'Ember Circuit',
|
||||||
colors: [
|
colors: [
|
||||||
[131, 212, 131],
|
[255, 95, 38],
|
||||||
[246, 215, 107],
|
[255, 43, 132],
|
||||||
[94, 193, 161],
|
[43, 219, 255],
|
||||||
],
|
],
|
||||||
backgroundColor: [23, 32, 22],
|
backgroundColor: [17, 10, 8],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.014,
|
backgroundGrainStrength: 0.03,
|
||||||
brushSize: 16,
|
brushSize: 8,
|
||||||
clarity: 0.68,
|
clarity: 0.82,
|
||||||
decayRateTrails: 975,
|
decayRateTrails: 918,
|
||||||
individualTrailWeight: 0.06,
|
individualTrailWeight: 0.04,
|
||||||
moveSpeed: 70,
|
moveSpeed: 150,
|
||||||
sensorOffsetDistance: 46,
|
sensorOffsetDistance: 24,
|
||||||
spawnPerPixel: 0.18,
|
spawnPerPixel: 0.31,
|
||||||
turnSpeed: 44,
|
turnSpeed: 130,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultAudioSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
brightness: 0.92,
|
idleIntensity: 0.03,
|
||||||
|
bpm: 124,
|
||||||
|
rampUpIntensity: 1.35,
|
||||||
|
rampUpTime: 0.04,
|
||||||
|
noteLength: 0.18,
|
||||||
|
notePitchOffset: 7,
|
||||||
|
brightness: 1.34,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.CoralTide,
|
id: VibeId.VelvetObservatory,
|
||||||
name: 'Coral Tide',
|
name: 'Velvet Observatory',
|
||||||
colors: [
|
colors: [
|
||||||
[255, 127, 110],
|
[72, 98, 255],
|
||||||
[64, 184, 255],
|
[255, 89, 176],
|
||||||
[244, 240, 166],
|
[235, 236, 255],
|
||||||
],
|
],
|
||||||
backgroundColor: [15, 24, 34],
|
backgroundColor: [7, 8, 20],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.022,
|
backgroundGrainStrength: 0.01,
|
||||||
brushSize: 13,
|
brushSize: 24,
|
||||||
clarity: 0.58,
|
clarity: 0.45,
|
||||||
decayRateTrails: 955,
|
decayRateTrails: 992,
|
||||||
individualTrailWeight: 0.055,
|
individualTrailWeight: 0.095,
|
||||||
moveSpeed: 90,
|
moveSpeed: 45,
|
||||||
sensorOffsetDistance: 35,
|
sensorOffsetDistance: 86,
|
||||||
spawnPerPixel: 0.25,
|
spawnPerPixel: 0.1,
|
||||||
turnSpeed: 62,
|
turnSpeed: 24,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultAudioSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
brightness: 1,
|
idleIntensity: 0.14,
|
||||||
|
bpm: 56,
|
||||||
|
rampUpIntensity: 0.6,
|
||||||
|
rampUpTime: 0.16,
|
||||||
|
noteLength: 1.15,
|
||||||
|
notePitchOffset: -5,
|
||||||
|
brightness: 0.72,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.MoonOrchid,
|
id: VibeId.LichenSignal,
|
||||||
name: 'Moon Orchid',
|
name: 'Lichen Signal',
|
||||||
colors: [
|
colors: [
|
||||||
[201, 147, 255],
|
[174, 205, 91],
|
||||||
[125, 216, 255],
|
[71, 162, 126],
|
||||||
[240, 244, 255],
|
[229, 117, 71],
|
||||||
],
|
],
|
||||||
backgroundColor: [20, 18, 29],
|
backgroundColor: [18, 24, 17],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.018,
|
backgroundGrainStrength: 0.028,
|
||||||
brushSize: 12,
|
brushSize: 17,
|
||||||
clarity: 0.64,
|
clarity: 0.66,
|
||||||
decayRateTrails: 968,
|
decayRateTrails: 974,
|
||||||
individualTrailWeight: 0.065,
|
individualTrailWeight: 0.065,
|
||||||
moveSpeed: 76,
|
moveSpeed: 68,
|
||||||
sensorOffsetDistance: 42,
|
sensorOffsetDistance: 52,
|
||||||
spawnPerPixel: 0.2,
|
spawnPerPixel: 0.19,
|
||||||
turnSpeed: 52,
|
turnSpeed: 38,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultAudioSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
brightness: 0.9,
|
idleIntensity: 0.1,
|
||||||
|
bpm: 68,
|
||||||
|
rampUpIntensity: 0.8,
|
||||||
|
rampUpTime: 0.1,
|
||||||
|
noteLength: 0.62,
|
||||||
|
notePitchOffset: -3,
|
||||||
|
brightness: 0.82,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.PeachNeon,
|
id: VibeId.UltravioletSiren,
|
||||||
name: 'Peach Neon',
|
name: 'Ultraviolet Siren',
|
||||||
colors: [
|
colors: [
|
||||||
[255, 155, 115],
|
[184, 75, 255],
|
||||||
[91, 240, 169],
|
[0, 224, 255],
|
||||||
[110, 168, 255],
|
[214, 255, 72],
|
||||||
],
|
],
|
||||||
backgroundColor: [25, 23, 22],
|
backgroundColor: [13, 9, 31],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.024,
|
backgroundGrainStrength: 0.02,
|
||||||
brushSize: 15,
|
brushSize: 11,
|
||||||
clarity: 0.55,
|
clarity: 0.72,
|
||||||
decayRateTrails: 948,
|
decayRateTrails: 946,
|
||||||
individualTrailWeight: 0.05,
|
individualTrailWeight: 0.052,
|
||||||
moveSpeed: 96,
|
moveSpeed: 118,
|
||||||
sensorOffsetDistance: 32,
|
sensorOffsetDistance: 30,
|
||||||
spawnPerPixel: 0.24,
|
spawnPerPixel: 0.28,
|
||||||
turnSpeed: 70,
|
turnSpeed: 96,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultAudioSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
brightness: 1.08,
|
idleIntensity: 0.04,
|
||||||
|
bpm: 112,
|
||||||
|
rampUpIntensity: 1.2,
|
||||||
|
rampUpTime: 0.05,
|
||||||
|
noteLength: 0.25,
|
||||||
|
notePitchOffset: 5,
|
||||||
|
brightness: 1.22,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.FrostBloom,
|
id: VibeId.TidepoolLantern,
|
||||||
name: 'Frost Bloom',
|
name: 'Tidepool Lantern',
|
||||||
colors: [
|
colors: [
|
||||||
[180, 247, 255],
|
[30, 219, 194],
|
||||||
[158, 200, 255],
|
[61, 118, 255],
|
||||||
[255, 184, 210],
|
[255, 191, 91],
|
||||||
],
|
],
|
||||||
backgroundColor: [16, 24, 32],
|
backgroundColor: [5, 20, 28],
|
||||||
|
settings: {
|
||||||
|
backgroundGrainStrength: 0.018,
|
||||||
|
brushSize: 15,
|
||||||
|
clarity: 0.6,
|
||||||
|
decayRateTrails: 963,
|
||||||
|
individualTrailWeight: 0.058,
|
||||||
|
moveSpeed: 88,
|
||||||
|
sensorOffsetDistance: 44,
|
||||||
|
spawnPerPixel: 0.22,
|
||||||
|
turnSpeed: 60,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.08,
|
||||||
|
bpm: 82,
|
||||||
|
rampUpIntensity: 0.95,
|
||||||
|
rampUpTime: 0.08,
|
||||||
|
noteLength: 0.48,
|
||||||
|
notePitchOffset: 0,
|
||||||
|
brightness: 0.98,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.PaperLanternFog,
|
||||||
|
name: 'Paper Lantern Fog',
|
||||||
|
colors: [
|
||||||
|
[255, 174, 104],
|
||||||
|
[242, 102, 107],
|
||||||
|
[132, 211, 185],
|
||||||
|
],
|
||||||
|
backgroundColor: [31, 23, 20],
|
||||||
|
settings: {
|
||||||
|
backgroundGrainStrength: 0.036,
|
||||||
|
brushSize: 22,
|
||||||
|
clarity: 0.5,
|
||||||
|
decayRateTrails: 984,
|
||||||
|
individualTrailWeight: 0.08,
|
||||||
|
moveSpeed: 56,
|
||||||
|
sensorOffsetDistance: 64,
|
||||||
|
spawnPerPixel: 0.14,
|
||||||
|
turnSpeed: 32,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.13,
|
||||||
|
bpm: 64,
|
||||||
|
rampUpIntensity: 0.72,
|
||||||
|
rampUpTime: 0.12,
|
||||||
|
noteLength: 0.9,
|
||||||
|
notePitchOffset: -4,
|
||||||
|
brightness: 0.76,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.ChromePollen,
|
||||||
|
name: 'Chrome Pollen',
|
||||||
|
colors: [
|
||||||
|
[235, 255, 238],
|
||||||
|
[255, 214, 48],
|
||||||
|
[77, 240, 157],
|
||||||
|
],
|
||||||
|
backgroundColor: [9, 13, 12],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.012,
|
backgroundGrainStrength: 0.012,
|
||||||
brushSize: 18,
|
brushSize: 10,
|
||||||
clarity: 0.7,
|
clarity: 0.9,
|
||||||
decayRateTrails: 982,
|
decayRateTrails: 935,
|
||||||
individualTrailWeight: 0.075,
|
individualTrailWeight: 0.045,
|
||||||
moveSpeed: 62,
|
moveSpeed: 104,
|
||||||
sensorOffsetDistance: 52,
|
sensorOffsetDistance: 36,
|
||||||
spawnPerPixel: 0.16,
|
spawnPerPixel: 0.24,
|
||||||
turnSpeed: 40,
|
turnSpeed: 78,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultAudioSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
brightness: 0.88,
|
idleIntensity: 0.05,
|
||||||
|
bpm: 96,
|
||||||
|
rampUpIntensity: 1.05,
|
||||||
|
rampUpTime: 0.07,
|
||||||
|
noteLength: 0.3,
|
||||||
|
notePitchOffset: 3,
|
||||||
|
brightness: 1.18,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
export const ENABLED_FLAG_VALUE = '1';
|
export const ENABLED_FLAG_VALUE = '1';
|
||||||
export const DISABLED_FLAG_VALUE = '0';
|
export const DISABLED_FLAG_VALUE = '0';
|
||||||
|
|
||||||
export const UNIT_INTERVAL_INPUT_MIN = '0';
|
|
||||||
export const UNIT_INTERVAL_INPUT_MAX = '1';
|
|
||||||
|
|
||||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||||
|
|
||||||
export const APP_STORAGE_KEYS = {
|
export const APP_STORAGE_KEYS = {
|
||||||
|
|
|
||||||
166
src/game-loop/agent-population.test.ts
Normal file
166
src/game-loop/agent-population.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { AgentPopulation } from './agent-population';
|
||||||
|
import { type FramePerformance } from './frame-performance';
|
||||||
|
|
||||||
|
const originalSettings = {
|
||||||
|
brushSize: settings.brushSize,
|
||||||
|
maxAgentCount: settings.maxAgentCount,
|
||||||
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
|
spawnPerPixel: settings.spawnPerPixel,
|
||||||
|
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingAgentGenerationPipeline {
|
||||||
|
public readonly writtenAgentCounts: Array<number> = [];
|
||||||
|
public readonly writtenAgentOffsets: Array<number> = [];
|
||||||
|
public readonly writtenBatches: Array<Float32Array> = [];
|
||||||
|
public readonly maxSupportedAgentCount = 1_000_000;
|
||||||
|
public maxAgentCount = 1_000_000;
|
||||||
|
private compactResolver: ((compactedAgentCount: number) => void) | null = null;
|
||||||
|
|
||||||
|
public ensureMaxAgentCount(requestedMaxAgentCount: number): number {
|
||||||
|
this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount);
|
||||||
|
return this.maxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeAgents(agentOffset: number, data: Float32Array): void {
|
||||||
|
this.writtenAgentOffsets.push(agentOffset);
|
||||||
|
this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT);
|
||||||
|
this.writtenBatches.push(data.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
public compactAgents(): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.compactResolver = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public finishCompaction(compactedAgentCount: number): void {
|
||||||
|
this.compactResolver?.(compactedAgentCount);
|
||||||
|
this.compactResolver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const framePerformance = {
|
||||||
|
adaptiveCapDecreaseAgents: 0,
|
||||||
|
adaptiveCapInitial: 1_000_000,
|
||||||
|
adaptiveCapMin: 0,
|
||||||
|
hasAdaptiveCapHeadroom: true,
|
||||||
|
} as FramePerformance;
|
||||||
|
|
||||||
|
const createPopulation = (): {
|
||||||
|
pipeline: RecordingAgentGenerationPipeline;
|
||||||
|
population: AgentPopulation;
|
||||||
|
} => {
|
||||||
|
const pipeline = new RecordingAgentGenerationPipeline();
|
||||||
|
const population = new AgentPopulation(
|
||||||
|
pipeline as unknown as AgentGenerationPipeline,
|
||||||
|
0,
|
||||||
|
() => 1,
|
||||||
|
framePerformance
|
||||||
|
);
|
||||||
|
population.beginStroke();
|
||||||
|
return { pipeline, population };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSpawnRate = (agentsPerPixel: number): void => {
|
||||||
|
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AgentPopulation stroke spawning', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.brushSize = 0;
|
||||||
|
settings.maxAgentCount = 1_000_000;
|
||||||
|
settings.selectedColorIndex = 0;
|
||||||
|
settings.strokeAngleJitterRadians = 0;
|
||||||
|
setSpawnRate(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(settings, originalSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns the same count for the same stroke length regardless of segmentation', () => {
|
||||||
|
const segmented = createPopulation();
|
||||||
|
for (let x = 0; x < 10; x++) {
|
||||||
|
segmented.population.spawnStrokeAgents(
|
||||||
|
vec2.fromValues(x, 0),
|
||||||
|
vec2.fromValues(x + 1, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleSegment = createPopulation();
|
||||||
|
singleSegment.population.spawnStrokeAgents(
|
||||||
|
vec2.fromValues(0, 0),
|
||||||
|
vec2.fromValues(10, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(segmented.population.activeAgentCount).toBe(10);
|
||||||
|
expect(singleSegment.population.activeAgentCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries fractional spawn budget within a stroke', () => {
|
||||||
|
setSpawnRate(0.5);
|
||||||
|
const { population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(0);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(1);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(1);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
|
||||||
|
const { pipeline, population } = createPopulation();
|
||||||
|
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
|
||||||
|
const expectedAgentCount = batchCapacity + 10;
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(
|
||||||
|
vec2.fromValues(0, 0),
|
||||||
|
vec2.fromValues(expectedAgentCount, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(expectedAgentCount);
|
||||||
|
expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns agents in the movement direction', () => {
|
||||||
|
const { pipeline, population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(3);
|
||||||
|
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues stroke writes while async compaction is in flight', async () => {
|
||||||
|
const { pipeline, population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0));
|
||||||
|
population.requestCompactionAfterErase();
|
||||||
|
population.compactAfterErase(false);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(10);
|
||||||
|
expect(pipeline.writtenAgentCounts).toEqual([10]);
|
||||||
|
|
||||||
|
pipeline.finishCompaction(6);
|
||||||
|
await population.waitForCompaction();
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(11);
|
||||||
|
expect(pipeline.writtenAgentOffsets).toEqual([0, 6]);
|
||||||
|
expect(pipeline.writtenAgentCounts).toEqual([10, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import {
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
AGENT_FLOAT_COUNT,
|
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||||
AgentGenerationPipeline,
|
|
||||||
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
|
||||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import type { FramePerformance } from './frame-performance';
|
import type { FramePerformance } from './frame-performance';
|
||||||
|
|
@ -20,8 +18,8 @@ export class AgentPopulation {
|
||||||
private shouldCompactAfterErase = false;
|
private shouldCompactAfterErase = false;
|
||||||
private isCompacting = false;
|
private isCompacting = false;
|
||||||
private pendingCompaction: Promise<void> | null = null;
|
private pendingCompaction: Promise<void> | null = null;
|
||||||
// Highest active slot written while async compaction is running.
|
private readonly queuedAgentBatches: Array<Float32Array> = [];
|
||||||
private postCompactionWriteEnd = 0;
|
private pendingStrokeAgentCount = 0;
|
||||||
private readonly strokeAgentData = new Float32Array(
|
private readonly strokeAgentData = new Float32Array(
|
||||||
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||||
);
|
);
|
||||||
|
|
@ -64,16 +62,20 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pipeline.writeAgents(0, data);
|
this.pipeline.writeAgents(0, data);
|
||||||
this.markPostCompactionWrite(0, data.length / AGENT_FLOAT_COUNT);
|
|
||||||
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
||||||
this.replacementCursor = 0;
|
this.replacementCursor = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onVibeChanged(): void {
|
public onVibeChanged(): void {
|
||||||
|
this.pendingStrokeAgentCount = 0;
|
||||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||||
this.trimActiveCountToBudget();
|
this.trimActiveCountToBudget();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public beginStroke(): void {
|
||||||
|
this.pendingStrokeAgentCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
public resizeAgents(scale: vec2): void {
|
public resizeAgents(scale: vec2): void {
|
||||||
this.pipeline.resizeAgents(this.activeCount, scale);
|
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||||
}
|
}
|
||||||
|
|
@ -93,17 +95,13 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isCompacting = true;
|
this.isCompacting = true;
|
||||||
this.postCompactionWriteEnd = 0;
|
|
||||||
this.pendingCompaction = this.pipeline
|
this.pendingCompaction = this.pipeline
|
||||||
.compactAgents(this.activeCount)
|
.compactAgents(this.activeCount)
|
||||||
.then((compactedAgentCount) => {
|
.then((compactedAgentCount) => {
|
||||||
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
|
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
|
||||||
? Math.max(0, Math.floor(compactedAgentCount))
|
? Math.max(0, Math.floor(compactedAgentCount))
|
||||||
: 0;
|
: 0;
|
||||||
this.activeCount = Math.min(
|
this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
|
||||||
this.activeCount,
|
|
||||||
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
|
|
||||||
);
|
|
||||||
this.clampReplacementCursor();
|
this.clampReplacementCursor();
|
||||||
this.trimActiveCountToBudget();
|
this.trimActiveCountToBudget();
|
||||||
})
|
})
|
||||||
|
|
@ -113,7 +111,7 @@ export class AgentPopulation {
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.isCompacting = false;
|
this.isCompacting = false;
|
||||||
this.pendingCompaction = null;
|
this.pendingCompaction = null;
|
||||||
this.postCompactionWriteEnd = 0;
|
this.flushQueuedAgentBatches();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,40 +142,86 @@ export class AgentPopulation {
|
||||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||||
const deltaX = to[0] - from[0];
|
const deltaX = to[0] - from[0];
|
||||||
const deltaY = to[1] - from[1];
|
const deltaY = to[1] - from[1];
|
||||||
const length = Math.max(
|
const length = Math.hypot(deltaX, deltaY);
|
||||||
appConfig.simulation.stroke.minSegmentLengthPx,
|
const spawnRate = getStrokeSpawnRate();
|
||||||
Math.hypot(deltaX, deltaY)
|
if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
|
||||||
);
|
return;
|
||||||
const count = Math.max(
|
}
|
||||||
appConfig.simulation.stroke.minAgentCount,
|
|
||||||
Math.min(
|
const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
|
||||||
appConfig.simulation.stroke.maxAgentCount,
|
if (!Number.isFinite(expectedAgentCount)) {
|
||||||
this.strokeAgentData.length / AGENT_FLOAT_COUNT,
|
this.pendingStrokeAgentCount = 0;
|
||||||
Math.ceil(
|
return;
|
||||||
length * settings.spawnPerPixel * appConfig.simulation.stroke.densityMultiplier
|
}
|
||||||
)
|
|
||||||
)
|
const count = Math.floor(expectedAgentCount);
|
||||||
);
|
this.pendingStrokeAgentCount = expectedAgentCount - count;
|
||||||
|
|
||||||
|
if (count <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const baseAngle = Math.atan2(deltaY, deltaX);
|
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||||
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
|
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||||
|
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
||||||
|
if (batchCapacity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let written = 0; written < count; written += batchCapacity) {
|
||||||
const t = count === 1 ? 1 : i / (count - 1);
|
const batchCount = Math.min(batchCapacity, count - written);
|
||||||
|
this.populateStrokeAgentBatch({
|
||||||
|
baseAngle,
|
||||||
|
batchCount,
|
||||||
|
from,
|
||||||
|
spread,
|
||||||
|
to,
|
||||||
|
totalCount: count,
|
||||||
|
written,
|
||||||
|
});
|
||||||
|
this.writeAgentBatch(
|
||||||
|
this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private populateStrokeAgentBatch({
|
||||||
|
baseAngle,
|
||||||
|
batchCount,
|
||||||
|
from,
|
||||||
|
spread,
|
||||||
|
to,
|
||||||
|
totalCount,
|
||||||
|
written,
|
||||||
|
}: {
|
||||||
|
baseAngle: number;
|
||||||
|
batchCount: number;
|
||||||
|
from: vec2;
|
||||||
|
spread: number;
|
||||||
|
to: vec2;
|
||||||
|
totalCount: number;
|
||||||
|
written: number;
|
||||||
|
}): void {
|
||||||
|
for (let i = 0; i < batchCount; i++) {
|
||||||
|
const agentIndex = written + i;
|
||||||
|
const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 1);
|
||||||
const x = from[0] + (to[0] - from[0]) * t;
|
const x = from[0] + (to[0] - from[0]) * t;
|
||||||
const y = from[1] + (to[1] - from[1]) * t;
|
const y = from[1] + (to[1] - from[1]) * t;
|
||||||
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
|
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
|
||||||
const base = i * AGENT_FLOAT_COUNT;
|
const positionX = x + (Math.random() - 0.5) * spread;
|
||||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
const positionY = y + (Math.random() - 0.5) * spread;
|
||||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
|
||||||
this.strokeAgentData[base + 2] = angle;
|
|
||||||
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
|
||||||
this.strokeAgentData[base + 4] = -1;
|
|
||||||
this.strokeAgentData[base + 5] = -1;
|
|
||||||
this.strokeAgentData[base + 6] = angle;
|
|
||||||
this.strokeAgentData[base + 7] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
|
writeAgentValues(this.strokeAgentData, i, {
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
angle,
|
||||||
|
colorIndex: settings.selectedColorIndex,
|
||||||
|
targetPositionX: -1,
|
||||||
|
targetPositionY: -1,
|
||||||
|
targetAngle: angle,
|
||||||
|
introDelay: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeAgentBatch(data: Float32Array): void {
|
private writeAgentBatch(data: Float32Array): void {
|
||||||
|
|
@ -185,6 +229,11 @@ export class AgentPopulation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isCompacting) {
|
||||||
|
this.queuedAgentBatches.push(data.slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const count = data.length / AGENT_FLOAT_COUNT;
|
const count = data.length / AGENT_FLOAT_COUNT;
|
||||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||||
this.expandAdaptiveCapForPendingAgents(count);
|
this.expandAdaptiveCapForPendingAgents(count);
|
||||||
|
|
@ -197,7 +246,6 @@ export class AgentPopulation {
|
||||||
this.activeCount,
|
this.activeCount,
|
||||||
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
||||||
);
|
);
|
||||||
this.markPostCompactionWrite(this.activeCount, appendCount);
|
|
||||||
this.activeCount += appendCount;
|
this.activeCount += appendCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,22 +264,15 @@ export class AgentPopulation {
|
||||||
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
this.markPostCompactionWrite(targetAgentOffset, chunkAgentCount);
|
|
||||||
|
|
||||||
sourceAgentOffset += chunkAgentCount;
|
sourceAgentOffset += chunkAgentCount;
|
||||||
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private markPostCompactionWrite(agentOffset: number, agentCount: number): void {
|
private flushQueuedAgentBatches(): void {
|
||||||
if (!this.isCompacting || agentCount <= 0) {
|
const batches = this.queuedAgentBatches.splice(0);
|
||||||
return;
|
batches.forEach((batch) => this.writeAgentBatch(batch));
|
||||||
}
|
|
||||||
|
|
||||||
this.postCompactionWriteEnd = Math.max(
|
|
||||||
this.postCompactionWriteEnd,
|
|
||||||
Math.ceil(agentOffset + agentCount)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||||
|
|
@ -279,3 +320,13 @@ export class AgentPopulation {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStrokeSpawnRate = (): number => {
|
||||||
|
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
|
||||||
|
? settings.spawnPerPixel
|
||||||
|
: 0;
|
||||||
|
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
|
||||||
|
? appConfig.simulation.stroke.densityMultiplier
|
||||||
|
: 0;
|
||||||
|
return Math.max(0, spawnPerPixel * densityMultiplier);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { VibeId } from '../vibes';
|
||||||
interface ExportSnapshotRendererOptions {
|
interface ExportSnapshotRendererOptions {
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
renderPipeline: RenderPipeline;
|
renderPipeline: RenderPipeline;
|
||||||
|
canvasFormat: GPUTextureFormat;
|
||||||
statusElement: HTMLElement;
|
statusElement: HTMLElement;
|
||||||
seed: string;
|
seed: string;
|
||||||
getSourceSize: () => { width: number; height: number };
|
getSourceSize: () => { width: number; height: number };
|
||||||
|
|
@ -50,7 +51,6 @@ export class ExportSnapshotRenderer {
|
||||||
|
|
||||||
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
|
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
|
||||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
|
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
|
||||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
||||||
let texture: GPUTexture | null = null;
|
let texture: GPUTexture | null = null;
|
||||||
let output: GPUBuffer | null = null;
|
let output: GPUBuffer | null = null;
|
||||||
let isOutputMapped = false;
|
let isOutputMapped = false;
|
||||||
|
|
@ -58,7 +58,7 @@ export class ExportSnapshotRenderer {
|
||||||
try {
|
try {
|
||||||
texture = this.device.createTexture({
|
texture = this.device.createTexture({
|
||||||
size: { width, height },
|
size: { width, height },
|
||||||
format,
|
format: this.options.canvasFormat,
|
||||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||||
});
|
});
|
||||||
output = this.device.createBuffer({
|
output = this.device.createBuffer({
|
||||||
|
|
@ -89,7 +89,7 @@ export class ExportSnapshotRenderer {
|
||||||
height,
|
height,
|
||||||
unpaddedBytesPerRow,
|
unpaddedBytesPerRow,
|
||||||
bytesPerRow,
|
bytesPerRow,
|
||||||
isBgra: format === 'bgra8unorm',
|
isBgra: this.options.canvasFormat === 'bgra8unorm',
|
||||||
});
|
});
|
||||||
output.unmap();
|
output.unmap();
|
||||||
isOutputMapped = false;
|
isOutputMapped = false;
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,11 @@ export class GameLoopResources {
|
||||||
public constructor(
|
public constructor(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
|
private readonly canvasFormat: GPUTextureFormat,
|
||||||
canvasSize: vec2,
|
canvasSize: vec2,
|
||||||
initialAgentCapacity: number
|
initialAgentCapacity: number
|
||||||
) {
|
) {
|
||||||
const context = initializeContext({ device, canvas });
|
const context = initializeContext({ device, canvas, format: canvasFormat });
|
||||||
|
|
||||||
this.textures = new SimulationTextures(this.device, canvasSize);
|
this.textures = new SimulationTextures(this.device, canvasSize);
|
||||||
|
|
||||||
|
|
@ -73,8 +74,16 @@ export class GameLoopResources {
|
||||||
);
|
);
|
||||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
this.renderPipeline = new RenderPipeline(
|
||||||
this.gpuProfiler = GpuProfiler.create(this.device);
|
context,
|
||||||
|
this.device,
|
||||||
|
this.commonState,
|
||||||
|
this.canvasFormat
|
||||||
|
);
|
||||||
|
this.gpuProfiler = GpuProfiler.create(
|
||||||
|
this.device,
|
||||||
|
() => appConfig.tuningPane.showFpsOverlay
|
||||||
|
);
|
||||||
|
|
||||||
this.frameRenderer = new SimulationFrameRenderer(
|
this.frameRenderer = new SimulationFrameRenderer(
|
||||||
this.device,
|
this.device,
|
||||||
|
|
@ -104,6 +113,10 @@ export class GameLoopResources {
|
||||||
return this.frameRenderer.isSourceMapActive;
|
return this.frameRenderer.isSourceMapActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get gpuPassTimeMs(): number | undefined {
|
||||||
|
return this.gpuProfiler?.latestTotalPassMs;
|
||||||
|
}
|
||||||
|
|
||||||
public setFrameParameters({
|
public setFrameParameters({
|
||||||
time,
|
time,
|
||||||
deltaTime,
|
deltaTime,
|
||||||
|
|
@ -140,7 +153,6 @@ export class GameLoopResources {
|
||||||
this.diffusionPipeline.setParameters(settings);
|
this.diffusionPipeline.setParameters(settings);
|
||||||
this.renderPipeline.setParameters({
|
this.renderPipeline.setParameters({
|
||||||
...settings,
|
...settings,
|
||||||
backgroundGrainStrength: 0,
|
|
||||||
channelColors,
|
channelColors,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,14 @@ export default class GameLoop {
|
||||||
private previousAccentColor = '';
|
private previousAccentColor = '';
|
||||||
private previousGrainStrength = Number.NaN;
|
private previousGrainStrength = Number.NaN;
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private destroyPromise: Promise<void> | null = null;
|
||||||
private readonly finished = Promise.withResolvers<void>();
|
private readonly finished = Promise.withResolvers<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
private readonly canvas: HTMLCanvasElement,
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
|
private readonly canvasFormat: GPUTextureFormat,
|
||||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||||
private readonly ui: GardenUi
|
private readonly ui: GardenUi
|
||||||
) {
|
) {
|
||||||
|
|
@ -50,11 +53,17 @@ export default class GameLoop {
|
||||||
this.resources = new GameLoopResources(
|
this.resources = new GameLoopResources(
|
||||||
canvas,
|
canvas,
|
||||||
device,
|
device,
|
||||||
|
this.canvasFormat,
|
||||||
this.canvasSize,
|
this.canvasSize,
|
||||||
this.framePerformance.adaptiveCapInitial
|
this.framePerformance.adaptiveCapInitial
|
||||||
);
|
);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
|
||||||
|
canvas,
|
||||||
|
ui.toolbar,
|
||||||
|
device,
|
||||||
|
this.canvasFormat
|
||||||
|
);
|
||||||
this.agentPopulation = new AgentPopulation(
|
this.agentPopulation = new AgentPopulation(
|
||||||
this.resources.agentGenerationPipeline,
|
this.resources.agentGenerationPipeline,
|
||||||
this.seedValue,
|
this.seedValue,
|
||||||
|
|
@ -72,7 +81,10 @@ export default class GameLoop {
|
||||||
),
|
),
|
||||||
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
||||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||||
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
onStartDrawing: () => {
|
||||||
|
this.introPrompt.markStartedDrawing();
|
||||||
|
this.agentPopulation.beginStroke();
|
||||||
|
},
|
||||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||||
});
|
});
|
||||||
|
|
@ -84,6 +96,7 @@ export default class GameLoop {
|
||||||
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
||||||
device,
|
device,
|
||||||
renderPipeline: this.resources.renderPipeline,
|
renderPipeline: this.resources.renderPipeline,
|
||||||
|
canvasFormat: this.canvasFormat,
|
||||||
statusElement: ui.exportStatus,
|
statusElement: ui.exportStatus,
|
||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
getSourceSize: () => {
|
getSourceSize: () => {
|
||||||
|
|
@ -139,7 +152,9 @@ export default class GameLoop {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
requestAnimationFrame(this.render);
|
if (this.animationFrameId === null && !this.hasFinished) {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.render);
|
||||||
|
}
|
||||||
return this.finished.promise;
|
return this.finished.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,8 +163,17 @@ export default class GameLoop {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
|
this.destroyPromise ??= this.dispose();
|
||||||
|
return this.destroyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispose(): Promise<void> {
|
||||||
this.hasFinished = true;
|
this.hasFinished = true;
|
||||||
await this.finished.promise;
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
this.finished.resolve();
|
||||||
|
|
||||||
window.removeEventListener('resize', this.resizeListener);
|
window.removeEventListener('resize', this.resizeListener);
|
||||||
this.pointerInput.detach();
|
this.pointerInput.detach();
|
||||||
|
|
@ -164,6 +188,7 @@ export default class GameLoop {
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly render = (time: DOMHighResTimeStamp) => {
|
private readonly render = (time: DOMHighResTimeStamp) => {
|
||||||
|
this.animationFrameId = null;
|
||||||
if (this.hasFinished) {
|
if (this.hasFinished) {
|
||||||
this.finished.resolve();
|
this.finished.resolve();
|
||||||
return;
|
return;
|
||||||
|
|
@ -216,11 +241,12 @@ export default class GameLoop {
|
||||||
fps: this.framePerformance.measuredFps,
|
fps: this.framePerformance.measuredFps,
|
||||||
agentCount: this.agentPopulation.activeAgentCount,
|
agentCount: this.agentPopulation.activeAgentCount,
|
||||||
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
|
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
|
||||||
|
gpuPassTimeMs: this.resources.gpuPassTimeMs,
|
||||||
renderWidth: this.canvas.width,
|
renderWidth: this.canvas.width,
|
||||||
renderHeight: this.canvas.height,
|
renderHeight: this.canvas.height,
|
||||||
});
|
});
|
||||||
|
|
||||||
requestAnimationFrame(this.render);
|
this.animationFrameId = requestAnimationFrame(this.render);
|
||||||
};
|
};
|
||||||
|
|
||||||
private syncPerfStatsOverlay(): void {
|
private syncPerfStatsOverlay(): void {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ interface GpuProfilerSample {
|
||||||
totalPassMs: number;
|
totalPassMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FleetingGardenPerf {
|
|
||||||
latest?: GpuProfilerSample;
|
|
||||||
samples: Array<GpuProfilerSample>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivePass {
|
interface ActivePass {
|
||||||
endQueryIndex: number;
|
endQueryIndex: number;
|
||||||
name: GpuPassName;
|
name: GpuPassName;
|
||||||
|
|
@ -32,33 +27,29 @@ interface ReadbackSlot {
|
||||||
state: 'idle' | 'encoding' | 'mapping';
|
state: 'idle' | 'encoding' | 'mapping';
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__fleetingGardenPerf?: FleetingGardenPerf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
|
const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
|
||||||
const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
|
const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
|
||||||
const READBACK_SLOT_COUNT = 4;
|
const READBACK_SLOT_COUNT = 4;
|
||||||
const MAX_SAMPLE_COUNT = 600;
|
|
||||||
|
|
||||||
export class GpuProfiler {
|
export class GpuProfiler {
|
||||||
private readonly querySet: GPUQuerySet;
|
private readonly querySet: GPUQuerySet;
|
||||||
private readonly resolveBuffer: GPUBuffer;
|
private readonly resolveBuffer: GPUBuffer;
|
||||||
private readonly readbackSlots: Array<ReadbackSlot>;
|
private readonly readbackSlots: Array<ReadbackSlot>;
|
||||||
|
private readonly isEnabled: () => boolean;
|
||||||
private activePasses: Array<ActivePass> = [];
|
private activePasses: Array<ActivePass> = [];
|
||||||
private nextQueryIndex = 0;
|
private nextQueryIndex = 0;
|
||||||
private frame = 0;
|
private frame = 0;
|
||||||
|
private latestSample: GpuProfilerSample | null = null;
|
||||||
|
|
||||||
public static create(device: GPUDevice): GpuProfiler | null {
|
public static create(device: GPUDevice, isEnabled: () => boolean): GpuProfiler | null {
|
||||||
if (!device.features.has('timestamp-query')) {
|
if (!device.features.has('timestamp-query')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new GpuProfiler(device);
|
return new GpuProfiler(device, isEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(device: GPUDevice) {
|
private constructor(device: GPUDevice, isEnabled: () => boolean) {
|
||||||
|
this.isEnabled = isEnabled;
|
||||||
this.querySet = device.createQuerySet({
|
this.querySet = device.createQuerySet({
|
||||||
type: 'timestamp',
|
type: 'timestamp',
|
||||||
count: MAX_QUERY_COUNT,
|
count: MAX_QUERY_COUNT,
|
||||||
|
|
@ -85,6 +76,9 @@ export class GpuProfiler {
|
||||||
public timestampWrites(
|
public timestampWrites(
|
||||||
name: GpuPassName
|
name: GpuPassName
|
||||||
): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
|
): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
|
if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +140,10 @@ export class GpuProfiler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get latestTotalPassMs(): number | undefined {
|
||||||
|
return this.latestSample?.totalPassMs;
|
||||||
|
}
|
||||||
|
|
||||||
private publishSample(
|
private publishSample(
|
||||||
frame: number,
|
frame: number,
|
||||||
passes: Array<ActivePass>,
|
passes: Array<ActivePass>,
|
||||||
|
|
@ -170,11 +168,6 @@ export class GpuProfiler {
|
||||||
sample.totalPassMs += elapsedMs;
|
sample.totalPassMs += elapsedMs;
|
||||||
});
|
});
|
||||||
|
|
||||||
const perf = (window.__fleetingGardenPerf ??= { samples: [] });
|
this.latestSample = sample;
|
||||||
perf.latest = sample;
|
|
||||||
perf.samples.push(sample);
|
|
||||||
if (perf.samples.length > MAX_SAMPLE_COUNT) {
|
|
||||||
perf.samples.splice(0, perf.samples.length - MAX_SAMPLE_COUNT);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||||
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
||||||
|
|
||||||
interface IntroTitlePoint {
|
interface IntroTitlePoint {
|
||||||
|
|
@ -114,15 +114,16 @@ export const createIntroTitleAgents = ({
|
||||||
pathProgress
|
pathProgress
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const base = i * AGENT_FLOAT_COUNT;
|
writeAgentValues(data, i, {
|
||||||
data[base] = mix(startX, targetX, pathProgress);
|
positionX: mix(startX, targetX, pathProgress),
|
||||||
data[base + 1] = mix(startY, targetY, pathProgress);
|
positionY: mix(startY, targetY, pathProgress),
|
||||||
data[base + 2] = currentAngle;
|
angle: currentAngle,
|
||||||
data[base + 3] = point.colorIndex;
|
colorIndex: point.colorIndex,
|
||||||
data[base + 4] = targetX;
|
targetPositionX: targetX,
|
||||||
data[base + 5] = targetY;
|
targetPositionY: targetY,
|
||||||
data[base + 6] = targetAngle;
|
targetAngle,
|
||||||
data[base + 7] = introDelay;
|
introDelay,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface PerfStatsSnapshot {
|
||||||
fps: number;
|
fps: number;
|
||||||
agentCount: number;
|
agentCount: number;
|
||||||
frameTimeMs: number;
|
frameTimeMs: number;
|
||||||
|
gpuPassTimeMs?: number;
|
||||||
renderWidth: number;
|
renderWidth: number;
|
||||||
renderHeight: number;
|
renderHeight: number;
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +30,7 @@ export class PerfStatsOverlay {
|
||||||
fps,
|
fps,
|
||||||
agentCount,
|
agentCount,
|
||||||
frameTimeMs,
|
frameTimeMs,
|
||||||
|
gpuPassTimeMs,
|
||||||
renderWidth,
|
renderWidth,
|
||||||
renderHeight,
|
renderHeight,
|
||||||
}: PerfStatsSnapshot): void {
|
}: PerfStatsSnapshot): void {
|
||||||
|
|
@ -37,7 +39,6 @@ export class PerfStatsOverlay {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousUpdateTime = time;
|
this.previousUpdateTime = time;
|
||||||
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)}`;
|
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
|
||||||
if (text !== this.previousText) {
|
if (text !== this.previousText) {
|
||||||
this.element.textContent = text;
|
this.element.textContent = text;
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ export class GardenPointerInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.options.audio.start(activeVibe, { userGesture: true });
|
|
||||||
this.options.audio.beginGesture();
|
this.options.audio.beginGesture();
|
||||||
this.options.onStartDrawing();
|
this.options.onStartDrawing();
|
||||||
this.activePointerId = event.pointerId;
|
this.activePointerId = event.pointerId;
|
||||||
|
|
@ -117,7 +116,6 @@ export class GardenPointerInput {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
if (event.pointerId !== this.activePointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.options.audio.start(activeVibe, { userGesture: true });
|
|
||||||
this.addSwipeAt(event, { emitAudio: false });
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
this.finishBrushStroke();
|
this.finishBrushStroke();
|
||||||
this.options.audio.endGesture();
|
this.options.audio.endGesture();
|
||||||
|
|
@ -166,12 +164,7 @@ export class GardenPointerInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
private addBrushSample(sample: PointerSample): void {
|
private addBrushSample(sample: PointerSample): void {
|
||||||
this.addBrushSegments(this.brushSmoother.addSample(sample.position));
|
this.emitBrushSegments(this.brushSmoother.addSample(sample.position));
|
||||||
this.getMirroredSegments(sample.previousPosition, sample.position).forEach(
|
|
||||||
(segment) => {
|
|
||||||
this.options.spawnStrokeAgents(segment.from, segment.to);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addEraseSample(sample: PointerSample): void {
|
private addEraseSample(sample: PointerSample): void {
|
||||||
|
|
@ -199,13 +192,14 @@ export class GardenPointerInput {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addBrushSegments(segments: Array<StrokeSegment>): void {
|
private emitBrushSegments(segments: Array<StrokeSegment>): void {
|
||||||
segments.forEach((segment) => {
|
segments.forEach((segment) => {
|
||||||
this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
|
this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
|
||||||
this.options.strokeOutput.addBrushSegment(
|
this.options.strokeOutput.addBrushSegment(
|
||||||
mirroredSegment.from,
|
mirroredSegment.from,
|
||||||
mirroredSegment.to
|
mirroredSegment.to
|
||||||
);
|
);
|
||||||
|
this.options.spawnStrokeAgents(mirroredSegment.from, mirroredSegment.to);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +209,7 @@ export class GardenPointerInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addBrushSegments(this.brushSmoother.finish());
|
this.emitBrushSegments(this.brushSmoother.finish());
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
|
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
|
||||||
import {
|
import {
|
||||||
ResizableTexture,
|
ResizableTexture,
|
||||||
type PendingTextureResize,
|
type PendingTextureResize,
|
||||||
|
|
@ -96,13 +97,17 @@ export class SimulationTextures {
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
||||||
|
// Only sourceMapA needs clearing — sourceMapB gets fully overwritten by
|
||||||
|
// the diffusion pass on the next active frame before it's ever sampled.
|
||||||
const passEncoder = commandEncoder.beginRenderPass({
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
colorAttachments: [this.sourceMapA, this.sourceMapB].map((texture) => ({
|
colorAttachments: [
|
||||||
view: texture.getTextureView(),
|
{
|
||||||
|
view: this.sourceMapA.getTextureView(),
|
||||||
clearValue: appConfig.simulation.clearColor,
|
clearValue: appConfig.simulation.clearColor,
|
||||||
loadOp: 'clear',
|
loadOp: 'clear',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
})),
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +131,7 @@ export class SimulationTextures {
|
||||||
private createEraserMask(size: vec2): ResizableTexture {
|
private createEraserMask(size: vec2): ResizableTexture {
|
||||||
return new ResizableTexture(this.device, size, {
|
return new ResizableTexture(this.device, size, {
|
||||||
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
format: 'r8unorm',
|
format: ERASER_MASK_TEXTURE_FORMAT,
|
||||||
usage:
|
usage:
|
||||||
GPUTextureUsage.TEXTURE_BINDING |
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,10 @@ export class ToolbarContrastMonitor {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
private readonly canvas: HTMLCanvasElement,
|
||||||
private readonly toolbar: HTMLElement,
|
private readonly toolbar: HTMLElement,
|
||||||
private readonly device: GPUDevice
|
private readonly device: GPUDevice,
|
||||||
|
canvasFormat: GPUTextureFormat
|
||||||
) {
|
) {
|
||||||
this.isBgra = navigator.gpu?.getPreferredCanvasFormat() === 'bgra8unorm';
|
this.isBgra = canvasFormat === 'bgra8unorm';
|
||||||
}
|
}
|
||||||
|
|
||||||
public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null {
|
public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null {
|
||||||
|
|
|
||||||
61
src/index.ts
61
src/index.ts
|
|
@ -32,6 +32,15 @@ const main = async () => {
|
||||||
let game: GameLoop | null = null;
|
let game: GameLoop | null = null;
|
||||||
let configPane: ConfigPane | null = null;
|
let configPane: ConfigPane | null = null;
|
||||||
const getGame = () => game;
|
const getGame = () => game;
|
||||||
|
const destroyCurrentGame = async () => {
|
||||||
|
const currentGame = game;
|
||||||
|
if (!currentGame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game = null;
|
||||||
|
await currentGame.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
const errorPresenter = new ErrorPresenter(
|
const errorPresenter = new ErrorPresenter(
|
||||||
queryRequiredElement('.errors-container', HTMLElement)
|
queryRequiredElement('.errors-container', HTMLElement)
|
||||||
|
|
@ -40,7 +49,7 @@ const main = async () => {
|
||||||
errorPresenter.render(error);
|
errorPresenter.render(error);
|
||||||
if (error.severity === Severity.ERROR) {
|
if (error.severity === Severity.ERROR) {
|
||||||
document.body.classList.remove('is-loading');
|
document.body.classList.remove('is-loading');
|
||||||
game?.destroy();
|
void destroyCurrentGame();
|
||||||
shouldStop = true;
|
shouldStop = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -53,19 +62,24 @@ const main = async () => {
|
||||||
const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
|
const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
|
||||||
const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
|
const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
|
||||||
const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
|
const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
|
||||||
const settingsButton = queryRequiredElement('button.settings', HTMLButtonElement);
|
const settingsButton = queryRequiredElement(
|
||||||
const restartButton = queryRequiredElement('button.restart', HTMLButtonElement);
|
'[data-control="settings"]',
|
||||||
const infoButton = queryRequiredElement('button.info', HTMLButtonElement);
|
HTMLButtonElement
|
||||||
|
);
|
||||||
|
const restartButton = queryRequiredElement(
|
||||||
|
'[data-control="restart"]',
|
||||||
|
HTMLButtonElement
|
||||||
|
);
|
||||||
|
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
|
||||||
const infoElement = queryRequiredElement('.info-page', HTMLElement);
|
const infoElement = queryRequiredElement('.info-page', HTMLElement);
|
||||||
const minimizeFullScreenButton = queryRequiredElement(
|
const fullScreenButton = queryRequiredElement(
|
||||||
'button.minimize-full-screen',
|
'[data-control="full-screen"]',
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
);
|
);
|
||||||
const maximizeFullScreenButton = queryRequiredElement(
|
const export4kButton = queryRequiredElement(
|
||||||
'button.maximize-full-screen',
|
'[data-control="export"]',
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
);
|
);
|
||||||
const export4kButton = queryRequiredElement('.export-4k', HTMLButtonElement);
|
|
||||||
|
|
||||||
const splash = new SplashScreen();
|
const splash = new SplashScreen();
|
||||||
const paletteControl = new PaletteControl({
|
const paletteControl = new PaletteControl({
|
||||||
|
|
@ -103,11 +117,7 @@ const main = async () => {
|
||||||
!configPane?.isOpen &&
|
!configPane?.isOpen &&
|
||||||
!infoPageHandler.isOpen
|
!infoPageHandler.isOpen
|
||||||
);
|
);
|
||||||
new FullScreenHandler(
|
new FullScreenHandler(fullScreenButton, document.documentElement);
|
||||||
minimizeFullScreenButton,
|
|
||||||
maximizeFullScreenButton,
|
|
||||||
document.documentElement
|
|
||||||
);
|
|
||||||
|
|
||||||
new VibeNavigator({
|
new VibeNavigator({
|
||||||
onChange: ({ vibeId, vibeName, source }) => {
|
onChange: ({ vibeId, vibeName, source }) => {
|
||||||
|
|
@ -119,16 +129,17 @@ const main = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
restartButton.addEventListener('click', () => game?.destroy());
|
restartButton.addEventListener('click', () => void destroyCurrentGame());
|
||||||
|
|
||||||
export4kButton.addEventListener('click', async () => {
|
export4kButton.addEventListener('click', async () => {
|
||||||
if (!game || export4kButton.disabled) {
|
const currentGame = game;
|
||||||
|
if (!currentGame || export4kButton.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
export4kButton.disabled = true;
|
export4kButton.disabled = true;
|
||||||
try {
|
try {
|
||||||
await game.exportSnapshot();
|
await currentGame.exportSnapshot();
|
||||||
trackExport({ vibeId: activeVibe.id });
|
trackExport({ vibeId: activeVibe.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
||||||
|
|
@ -168,9 +179,15 @@ const main = async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const gpu = await gpuPromise;
|
const gpu = await gpuPromise;
|
||||||
|
const gpuNavigator = navigator.gpu;
|
||||||
|
if (!gpuNavigator) {
|
||||||
|
throw new Error('WebGPU is no longer available after initialization.');
|
||||||
|
}
|
||||||
|
const canvasFormat = gpuNavigator.getPreferredCanvasFormat();
|
||||||
configPane = new ConfigPane({
|
configPane = new ConfigPane({
|
||||||
maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
|
maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
|
||||||
settingsButton,
|
settingsButton,
|
||||||
|
onOpen: () => infoPageHandler.close(),
|
||||||
onConfigChange: () => {
|
onConfigChange: () => {
|
||||||
game?.onVibeChanged();
|
game?.onVibeChanged();
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
|
|
@ -186,13 +203,14 @@ const main = async () => {
|
||||||
|
|
||||||
let isFirstStart = true;
|
let isFirstStart = true;
|
||||||
while (!shouldStop) {
|
while (!shouldStop) {
|
||||||
game = new GameLoop(canvas, gpu, deltaTimeCalculator, {
|
const loop = new GameLoop(canvas, gpu, canvasFormat, deltaTimeCalculator, {
|
||||||
toolbar: toolbarRow,
|
toolbar: toolbarRow,
|
||||||
prompt: promptElement,
|
prompt: promptElement,
|
||||||
eraserPreview,
|
eraserPreview,
|
||||||
grainOverlay,
|
grainOverlay,
|
||||||
exportStatus,
|
exportStatus,
|
||||||
});
|
});
|
||||||
|
game = loop;
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
audioControl.render();
|
audioControl.render();
|
||||||
|
|
||||||
|
|
@ -211,8 +229,11 @@ const main = async () => {
|
||||||
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
|
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
game.attachPointerInput();
|
loop.attachPointerInput();
|
||||||
await game.start();
|
await loop.start();
|
||||||
|
if (game === loop) {
|
||||||
|
game = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.classList.remove('is-loading');
|
document.body.classList.remove('is-loading');
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,20 @@
|
||||||
import {
|
import { appConfig } from '../config';
|
||||||
APP_STORAGE_KEYS,
|
import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts';
|
||||||
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 type GameLoop from '../game-loop/game-loop';
|
||||||
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
import { queryRequiredElement } from '../utils/dom';
|
||||||
import { clamp01 } from '../utils/math';
|
import { clamp01 } from '../utils/math';
|
||||||
|
|
||||||
const AUDIO_VOLUME_STEP = 0.01;
|
|
||||||
|
|
||||||
const clampAudioVolume = (value: number): number => {
|
const clampAudioVolume = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
|
const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
|
||||||
return clamp01(safeValue);
|
const safeValue = Number.isFinite(value) ? value : defaultVolume;
|
||||||
|
return Math.min(max, Math.max(min, clamp01(safeValue)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const readInitialAudioVolume = (): number => {
|
const readInitialAudioVolume = (): number => {
|
||||||
const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
|
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
|
||||||
return storedVolume === null
|
return storedVolume === null
|
||||||
? DEFAULT_AUDIO_VOLUME
|
? appConfig.toolbar.volume.default
|
||||||
: clampAudioVolume(Number(storedVolume));
|
: clampAudioVolume(Number(storedVolume));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,7 +29,7 @@ interface AudioControlOptions {
|
||||||
|
|
||||||
export class AudioControl {
|
export class AudioControl {
|
||||||
private readonly soundButton = queryRequiredElement(
|
private readonly soundButton = queryRequiredElement(
|
||||||
'button.sound',
|
'[data-control="sound"]',
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
);
|
);
|
||||||
private readonly volumeControl = queryRequiredElement(
|
private readonly volumeControl = queryRequiredElement(
|
||||||
|
|
@ -50,7 +43,7 @@ export class AudioControl {
|
||||||
|
|
||||||
private audioVolume = readInitialAudioVolume();
|
private audioVolume = readInitialAudioVolume();
|
||||||
private isMutedState =
|
private isMutedState =
|
||||||
readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
|
readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE ||
|
||||||
this.audioVolume <= 0;
|
this.audioVolume <= 0;
|
||||||
|
|
||||||
public constructor(private readonly options: AudioControlOptions) {
|
public constructor(private readonly options: AudioControlOptions) {
|
||||||
|
|
@ -90,9 +83,9 @@ export class AudioControl {
|
||||||
this.soundButton.setAttribute('aria-label', muteLabel);
|
this.soundButton.setAttribute('aria-label', muteLabel);
|
||||||
this.soundButton.title = muteLabel;
|
this.soundButton.title = muteLabel;
|
||||||
|
|
||||||
this.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
|
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
|
||||||
this.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
|
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
|
||||||
this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
|
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
|
||||||
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
|
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
|
||||||
this.volumeSlider.setAttribute(
|
this.volumeSlider.setAttribute(
|
||||||
'aria-valuetext',
|
'aria-valuetext',
|
||||||
|
|
@ -112,7 +105,7 @@ export class AudioControl {
|
||||||
private readonly onToggleMute = () => {
|
private readonly onToggleMute = () => {
|
||||||
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
|
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
|
||||||
if (shouldUnmute && this.audioVolume <= 0) {
|
if (shouldUnmute && this.audioVolume <= 0) {
|
||||||
this.audioVolume = DEFAULT_AUDIO_VOLUME;
|
this.audioVolume = appConfig.toolbar.volume.default;
|
||||||
}
|
}
|
||||||
this.isMutedState = !shouldUnmute;
|
this.isMutedState = !shouldUnmute;
|
||||||
this.persist();
|
this.persist();
|
||||||
|
|
@ -136,8 +129,7 @@ export class AudioControl {
|
||||||
if (
|
if (
|
||||||
!this.options.hasStarted() ||
|
!this.options.hasStarted() ||
|
||||||
this.isMutedState ||
|
this.isMutedState ||
|
||||||
(event.target instanceof Node &&
|
(event.target instanceof Node && this.options.startButton.contains(event.target)) ||
|
||||||
this.options.startButton.contains(event.target)) ||
|
|
||||||
(event.target instanceof Node && this.soundButton.contains(event.target))
|
(event.target instanceof Node && this.soundButton.contains(event.target))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -147,11 +139,11 @@ export class AudioControl {
|
||||||
|
|
||||||
private persist(): void {
|
private persist(): void {
|
||||||
writeBrowserStorage(
|
writeBrowserStorage(
|
||||||
APP_STORAGE_KEYS.audioMuted,
|
appConfig.storage.audioMutedKey,
|
||||||
this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
|
this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
|
||||||
);
|
);
|
||||||
writeBrowserStorage(
|
writeBrowserStorage(
|
||||||
APP_STORAGE_KEYS.audioVolume,
|
appConfig.storage.audioVolumeKey,
|
||||||
formatStoredAudioVolume(this.audioVolume)
|
formatStoredAudioVolume(this.audioVolume)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Pane } from 'tweakpane';
|
||||||
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||||
import {
|
import {
|
||||||
appConfig,
|
appConfig,
|
||||||
|
normalizeNumberControlValue,
|
||||||
type GardenRuntimeSettings,
|
type GardenRuntimeSettings,
|
||||||
type NumberControlConfig,
|
type NumberControlConfig,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
|
|
@ -28,7 +29,7 @@ interface PaneState extends GardenAudioVibeSettings {
|
||||||
color3: string;
|
color3: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_REACTION_LABELS = ['Primary', 'Secondary', 'Accent'] as const;
|
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
|
||||||
const COLOR_REACTION_STATES = [
|
const COLOR_REACTION_STATES = [
|
||||||
{ id: 'follow', label: 'Move Toward', value: 1 },
|
{ id: 'follow', label: 'Move Toward', value: 1 },
|
||||||
{ id: 'ignore', label: 'Ignore', value: 0 },
|
{ id: 'ignore', label: 'Ignore', value: 0 },
|
||||||
|
|
@ -53,31 +54,7 @@ const colorReactionRows = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const brushControlKeys = [
|
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
|
||||||
'brushSize',
|
|
||||||
'spawnPerPixel',
|
|
||||||
'strokeAngleJitterRadians',
|
|
||||||
] satisfies Array<RuntimeControlKey>;
|
|
||||||
|
|
||||||
const agentControlKeys = [
|
|
||||||
'sensorOffsetDistance',
|
|
||||||
'moveSpeed',
|
|
||||||
'turnSpeed',
|
|
||||||
'forwardRotationScale',
|
|
||||||
'turnWhenLost',
|
|
||||||
'individualTrailWeight',
|
|
||||||
'decayRateTrails',
|
|
||||||
] satisfies Array<RuntimeControlKey>;
|
|
||||||
|
|
||||||
const lookControlKeys = [
|
|
||||||
'clarity',
|
|
||||||
'backgroundGrainStrength',
|
|
||||||
] satisfies Array<RuntimeControlKey>;
|
|
||||||
|
|
||||||
const performanceControlKeys = [
|
|
||||||
'maxAgentCount',
|
|
||||||
'internalRenderAreaMegapixels',
|
|
||||||
] satisfies Array<RuntimeControlKey>;
|
|
||||||
|
|
||||||
const MUSIC_CONTROLS: ReadonlyArray<{
|
const MUSIC_CONTROLS: ReadonlyArray<{
|
||||||
key: VibeNumberKey;
|
key: VibeNumberKey;
|
||||||
|
|
@ -98,26 +75,19 @@ const MUSIC_CONTROLS: ReadonlyArray<{
|
||||||
interface ConfigPaneOptions {
|
interface ConfigPaneOptions {
|
||||||
maxSupportedAgentCount: number;
|
maxSupportedAgentCount: number;
|
||||||
onConfigChange: () => void;
|
onConfigChange: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
onRuntimeChange: () => void;
|
onRuntimeChange: () => void;
|
||||||
settingsButton: HTMLButtonElement;
|
settingsButton: HTMLButtonElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
|
const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
|
||||||
if (config.options) {
|
(
|
||||||
const optionValues = Object.values(config.options);
|
Object.entries(appConfig.runtimeSettings.controls) as Array<
|
||||||
if (optionValues.includes(value)) {
|
[RuntimeControlKey, NumberControlConfig | undefined]
|
||||||
return value;
|
>
|
||||||
}
|
)
|
||||||
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
|
.filter(([, config]) => config?.folder === folder)
|
||||||
}
|
.map(([key]) => key);
|
||||||
|
|
||||||
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
|
||||||
const max = config.max ?? Number.POSITIVE_INFINITY;
|
|
||||||
const fallbackValue = config.min ?? 0;
|
|
||||||
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
|
|
||||||
const clampedValue = Math.min(max, Math.max(min, finiteValue));
|
|
||||||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||||
const params: BindingParams = {
|
const params: BindingParams = {
|
||||||
|
|
@ -226,8 +196,7 @@ export class ConfigPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly toggle = () => {
|
private readonly toggle = () => {
|
||||||
this.pane.hidden = !this.pane.hidden;
|
this.setHidden(!this.pane.hidden);
|
||||||
this.syncOpenState();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
|
private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
|
||||||
|
|
@ -252,20 +221,23 @@ export class ConfigPane {
|
||||||
};
|
};
|
||||||
|
|
||||||
private setHidden(isHidden: boolean): void {
|
private setHidden(isHidden: boolean): void {
|
||||||
|
const wasOpen = this.isOpen;
|
||||||
this.pane.hidden = isHidden;
|
this.pane.hidden = isHidden;
|
||||||
this.syncOpenState();
|
this.syncOpenState();
|
||||||
|
if (!wasOpen && this.isOpen) {
|
||||||
|
this.options.onOpen?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setUpTuningPane(container: PaneContainer): void {
|
private setUpTuningPane(container: PaneContainer): void {
|
||||||
this.setUpVibeSection(container);
|
this.setUpVibeSection(container);
|
||||||
this.addRuntimeSection(container, 'Brush', brushControlKeys, true);
|
this.addRuntimeSection(container, runtimeFolderOrder[0], true);
|
||||||
this.addRuntimeSection(container, 'Agents', agentControlKeys, true);
|
this.addRuntimeSection(container, runtimeFolderOrder[1], true);
|
||||||
this.addColorReactionMatrix(container);
|
this.addColorReactionMatrix(container);
|
||||||
this.addRuntimeSection(container, 'Look', lookControlKeys, true);
|
this.addRuntimeSection(container, runtimeFolderOrder[2], true);
|
||||||
const performanceFolder = this.addRuntimeSection(
|
const performanceFolder = this.addRuntimeSection(
|
||||||
container,
|
container,
|
||||||
'Performance',
|
runtimeFolderOrder[3],
|
||||||
performanceControlKeys,
|
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
this.addFpsOverlayBinding(performanceFolder);
|
this.addFpsOverlayBinding(performanceFolder);
|
||||||
|
|
@ -279,13 +251,13 @@ export class ConfigPane {
|
||||||
expanded: true,
|
expanded: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addColorBinding(folder, 'color1', 'Primary Color', (color) => {
|
this.addColorBinding(folder, 'color1', '', (color) => {
|
||||||
activeVibe.colors[0] = color;
|
activeVibe.colors[0] = color;
|
||||||
});
|
});
|
||||||
this.addColorBinding(folder, 'color2', 'Secondary Color', (color) => {
|
this.addColorBinding(folder, 'color2', '', (color) => {
|
||||||
activeVibe.colors[1] = color;
|
activeVibe.colors[1] = color;
|
||||||
});
|
});
|
||||||
this.addColorBinding(folder, 'color3', 'Accent Color', (color) => {
|
this.addColorBinding(folder, 'color3', '', (color) => {
|
||||||
activeVibe.colors[2] = color;
|
activeVibe.colors[2] = color;
|
||||||
});
|
});
|
||||||
this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
|
this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
|
||||||
|
|
@ -327,11 +299,10 @@ export class ConfigPane {
|
||||||
private addRuntimeSection(
|
private addRuntimeSection(
|
||||||
container: PaneContainer,
|
container: PaneContainer,
|
||||||
title: string,
|
title: string,
|
||||||
keys: ReadonlyArray<RuntimeControlKey>,
|
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
): PaneContainer {
|
): PaneContainer {
|
||||||
const folder = container.addFolder({ title, expanded });
|
const folder = container.addFolder({ title, expanded });
|
||||||
keys.forEach((key) => this.addRuntimeBinding(folder, key));
|
getRuntimeControlKeys(title).forEach((key) => this.addRuntimeBinding(folder, key));
|
||||||
return folder;
|
return folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,12 +312,12 @@ export class ConfigPane {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings[key] = normalizeNumber(settings[key], config);
|
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||||
|
|
||||||
container
|
container
|
||||||
.addBinding(settings, key, getNumberBindingParams(config))
|
.addBinding(settings, key, getNumberBindingParams(config))
|
||||||
.on('change', () => {
|
.on('change', () => {
|
||||||
const nextValue = normalizeNumber(settings[key], config);
|
const nextValue = normalizeNumberControlValue(settings[key], config);
|
||||||
if (nextValue !== settings[key]) {
|
if (nextValue !== settings[key]) {
|
||||||
settings[key] = nextValue;
|
settings[key] = nextValue;
|
||||||
this.pane.refresh();
|
this.pane.refresh();
|
||||||
|
|
@ -382,7 +353,6 @@ export class ConfigPane {
|
||||||
title: 'Color Behavior',
|
title: 'Color Behavior',
|
||||||
expanded: true,
|
expanded: true,
|
||||||
});
|
});
|
||||||
folder.element.classList.add('color-reaction-folder');
|
|
||||||
|
|
||||||
const matrix = document.createElement('div');
|
const matrix = document.createElement('div');
|
||||||
matrix.className = 'color-reaction-matrix';
|
matrix.className = 'color-reaction-matrix';
|
||||||
|
|
@ -410,23 +380,20 @@ export class ConfigPane {
|
||||||
private createColorReactionCorner(): HTMLDivElement {
|
private createColorReactionCorner(): HTMLDivElement {
|
||||||
const corner = document.createElement('div');
|
const corner = document.createElement('div');
|
||||||
corner.className = 'color-reaction-matrix__corner';
|
corner.className = 'color-reaction-matrix__corner';
|
||||||
corner.textContent = 'agents';
|
|
||||||
return corner;
|
return corner;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
|
private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'color-reaction-matrix__header';
|
header.className = 'color-reaction-matrix__header';
|
||||||
|
header.setAttribute('aria-label', label);
|
||||||
|
header.title = label;
|
||||||
|
|
||||||
const swatch = document.createElement('span');
|
const swatch = document.createElement('span');
|
||||||
swatch.className = 'color-reaction-matrix__swatch';
|
swatch.className = 'color-reaction-matrix__swatch';
|
||||||
this.colorReactionSwatches.push({ colorIndex, element: swatch });
|
this.colorReactionSwatches.push({ colorIndex, element: swatch });
|
||||||
header.appendChild(swatch);
|
header.appendChild(swatch);
|
||||||
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent = label;
|
|
||||||
header.appendChild(text);
|
|
||||||
|
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,7 +419,7 @@ export class ConfigPane {
|
||||||
button.appendChild(icon);
|
button.appendChild(icon);
|
||||||
|
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const currentValue = normalizeNumber(settings[key], config);
|
const currentValue = normalizeNumberControlValue(settings[key], config);
|
||||||
const nextState = getNextColorReactionState(currentValue);
|
const nextState = getNextColorReactionState(currentValue);
|
||||||
settings[key] = nextState.value;
|
settings[key] = nextState.value;
|
||||||
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
|
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
|
||||||
|
|
@ -492,7 +459,7 @@ export class ConfigPane {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings[key] = normalizeNumber(settings[key], config);
|
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||||
|
|
||||||
const state = getColorReactionState(settings[key]);
|
const state = getColorReactionState(settings[key]);
|
||||||
const nextState = getNextColorReactionState(settings[key]);
|
const nextState = getNextColorReactionState(settings[key]);
|
||||||
|
|
@ -502,9 +469,9 @@ export class ConfigPane {
|
||||||
button.dataset.reaction = state.id;
|
button.dataset.reaction = state.id;
|
||||||
button.setAttribute(
|
button.setAttribute(
|
||||||
'aria-label',
|
'aria-label',
|
||||||
`${sourceLabel} agents ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
|
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
|
||||||
);
|
);
|
||||||
button.title = `${sourceLabel} agents: ${state.label} ${targetLabel} trails`;
|
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setUpMusicSection(container: PaneContainer): void {
|
private setUpMusicSection(container: PaneContainer): void {
|
||||||
|
|
@ -519,12 +486,12 @@ export class ConfigPane {
|
||||||
key: VibeNumberKey,
|
key: VibeNumberKey,
|
||||||
config: NumberControlConfig
|
config: NumberControlConfig
|
||||||
): void {
|
): void {
|
||||||
this.state[key] = normalizeNumber(this.state[key], config);
|
this.state[key] = normalizeNumberControlValue(this.state[key], config);
|
||||||
|
|
||||||
container
|
container
|
||||||
.addBinding(this.state, key, getNumberBindingParams(config))
|
.addBinding(this.state, key, getNumberBindingParams(config))
|
||||||
.on('change', () => {
|
.on('change', () => {
|
||||||
const nextValue = normalizeNumber(this.state[key], config);
|
const nextValue = normalizeNumberControlValue(this.state[key], config);
|
||||||
if (nextValue !== this.state[key]) {
|
if (nextValue !== this.state[key]) {
|
||||||
this.state[key] = nextValue;
|
this.state[key] = nextValue;
|
||||||
this.pane.refresh();
|
this.pane.refresh();
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
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 clampEraserSize = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
|
const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
|
||||||
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
const safeValue = Number.isFinite(value) ? value : defaultSize;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEraserSizeRatio = (size: number): number =>
|
const getEraserSizeRatio = (size: number): number => {
|
||||||
(size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
|
const { max, min } = appConfig.toolbar.eraser;
|
||||||
|
return (size - min) / (max - min);
|
||||||
|
};
|
||||||
|
|
||||||
interface EraserSizeControlOptions {
|
interface EraserSizeControlOptions {
|
||||||
getGame: () => GameLoop | null;
|
getGame: () => GameLoop | null;
|
||||||
|
|
@ -28,10 +25,7 @@ export class EraserSizeControl {
|
||||||
'.eraser-size-control',
|
'.eraser-size-control',
|
||||||
HTMLLabelElement
|
HTMLLabelElement
|
||||||
);
|
);
|
||||||
private readonly slider = queryRequiredElement(
|
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
||||||
'.eraser-size-slider',
|
|
||||||
HTMLInputElement
|
|
||||||
);
|
|
||||||
|
|
||||||
public constructor(private readonly options: EraserSizeControlOptions) {
|
public constructor(private readonly options: EraserSizeControlOptions) {
|
||||||
this.control.addEventListener('pointerdown', this.options.onActivate);
|
this.control.addEventListener('pointerdown', this.options.onActivate);
|
||||||
|
|
@ -51,16 +45,18 @@ export class EraserSizeControl {
|
||||||
settings.eraserSize = size;
|
settings.eraserSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.slider.min = ERASER_SIZE_MIN.toString();
|
this.slider.min = appConfig.toolbar.eraser.min.toString();
|
||||||
this.slider.max = ERASER_SIZE_MAX.toString();
|
this.slider.max = appConfig.toolbar.eraser.max.toString();
|
||||||
this.slider.step = ERASER_SIZE_STEP.toString();
|
this.slider.step = appConfig.toolbar.eraser.step.toString();
|
||||||
this.slider.value = size.toString();
|
this.slider.value = size.toString();
|
||||||
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
||||||
|
|
||||||
const ratio = getEraserSizeRatio(size);
|
const ratio = getEraserSizeRatio(size);
|
||||||
const scale =
|
const scale =
|
||||||
ERASER_CONTROL_SCALE_MIN +
|
appConfig.toolbar.eraser.controlScaleMin +
|
||||||
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
|
(appConfig.toolbar.eraser.controlScaleMax -
|
||||||
|
appConfig.toolbar.eraser.controlScaleMin) *
|
||||||
|
ratio;
|
||||||
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||||
this.options.getGame()?.updateEraserPreview();
|
this.options.getGame()?.updateEraserPreview();
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
export class FullScreenHandler {
|
export class FullScreenHandler {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly minimizeButton: HTMLElement,
|
private readonly toggleButton: HTMLElement,
|
||||||
private readonly maximizeButton: HTMLElement,
|
|
||||||
target: HTMLElement
|
target: HTMLElement
|
||||||
) {
|
) {
|
||||||
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
|
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
|
||||||
minimizeButton.hidden = true;
|
toggleButton.hidden = true;
|
||||||
maximizeButton.hidden = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateButtons();
|
this.updateButtons();
|
||||||
|
|
||||||
addEventListener('fullscreenchange', this.updateButtons.bind(this));
|
addEventListener('fullscreenchange', this.updateButtons.bind(this));
|
||||||
maximizeButton.addEventListener('click', () => {
|
toggleButton.addEventListener('click', () => {
|
||||||
|
if (FullScreenHandler.isInFullScreenMode()) {
|
||||||
|
void document.exitFullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void target.requestFullscreen().catch(() => undefined);
|
void target.requestFullscreen().catch(() => undefined);
|
||||||
});
|
});
|
||||||
minimizeButton.addEventListener('click', () => document.exitFullscreen());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static isInFullScreenMode(): boolean {
|
public static isInFullScreenMode(): boolean {
|
||||||
|
|
@ -25,7 +27,9 @@ export class FullScreenHandler {
|
||||||
|
|
||||||
private updateButtons(): void {
|
private updateButtons(): void {
|
||||||
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||||
this.minimizeButton.hidden = !isInFullScreenMode;
|
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
|
||||||
this.maximizeButton.hidden = isInFullScreenMode;
|
this.toggleButton.classList.toggle('active', isInFullScreenMode);
|
||||||
|
this.toggleButton.setAttribute('aria-label', label);
|
||||||
|
this.toggleButton.title = label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,26 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
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 clampMirrorSegmentCount = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
|
const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
|
||||||
return Math.min(
|
const safeValue = Number.isFinite(value) ? value : defaultCount;
|
||||||
MIRROR_SEGMENT_MAX,
|
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||||
Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMirrorSegmentRatio = (count: number): number =>
|
const getMirrorSegmentRatio = (count: number): number => {
|
||||||
(count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
|
const { max, min } = appConfig.toolbar.mirror;
|
||||||
|
return (count - min) / (max - min);
|
||||||
|
};
|
||||||
|
|
||||||
const formatMirrorSegmentCount = (count: number): string =>
|
const formatMirrorSegmentCount = (count: number): string =>
|
||||||
count === MIRROR_SEGMENT_DEFAULT
|
count === appConfig.toolbar.mirror.default
|
||||||
? MIRROR_SEGMENT_OFF_LABEL
|
? appConfig.toolbar.mirror.offLabel
|
||||||
: `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
|
: `${count} ${
|
||||||
|
appConfig.toolbar.mirror.names[
|
||||||
|
count as keyof typeof appConfig.toolbar.mirror.names
|
||||||
|
] ?? appConfig.toolbar.mirror.fallbackSegmentName
|
||||||
|
}`;
|
||||||
|
|
||||||
interface MirrorSegmentControlOptions {
|
interface MirrorSegmentControlOptions {
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
|
|
@ -52,9 +50,9 @@ export class MirrorSegmentControl {
|
||||||
settings.mirrorSegmentCount = count;
|
settings.mirrorSegmentCount = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.slider.min = MIRROR_SEGMENT_MIN.toString();
|
this.slider.min = appConfig.toolbar.mirror.min.toString();
|
||||||
this.slider.max = MIRROR_SEGMENT_MAX.toString();
|
this.slider.max = appConfig.toolbar.mirror.max.toString();
|
||||||
this.slider.step = MIRROR_SEGMENT_STEP.toString();
|
this.slider.step = appConfig.toolbar.mirror.step.toString();
|
||||||
this.slider.value = count.toString();
|
this.slider.value = count.toString();
|
||||||
|
|
||||||
const label = formatMirrorSegmentCount(count);
|
const label = formatMirrorSegmentCount(count);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { queryRequiredElement, queryRequiredElements } from '../utils/dom';
|
import { queryRequiredElement } from '../utils/dom';
|
||||||
|
import { ErrorCode, RuntimeError } from '../utils/error-handler';
|
||||||
import { rgbColorToCss } from '../utils/rgb-color';
|
import { rgbColorToCss } from '../utils/rgb-color';
|
||||||
|
|
||||||
interface PaletteControlOptions {
|
interface PaletteControlOptions {
|
||||||
|
|
@ -9,7 +10,7 @@ interface PaletteControlOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PaletteControl {
|
export class PaletteControl {
|
||||||
private readonly swatches = queryRequiredElements('.color-swatch', HTMLButtonElement);
|
private readonly swatches = queryRequiredColorSwatches();
|
||||||
private readonly eraserControl = queryRequiredElement(
|
private readonly eraserControl = queryRequiredElement(
|
||||||
'.eraser-size-control',
|
'.eraser-size-control',
|
||||||
HTMLLabelElement
|
HTMLLabelElement
|
||||||
|
|
@ -52,3 +53,28 @@ export class PaletteControl {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryRequiredColorSwatches = (): Array<HTMLButtonElement> => {
|
||||||
|
const selector = '.color-swatch';
|
||||||
|
const swatches = Array.from(document.querySelectorAll(selector));
|
||||||
|
const expectedCount = activeVibe.colors.length;
|
||||||
|
const hasExpectedSwatches =
|
||||||
|
swatches.length === expectedCount &&
|
||||||
|
swatches.every((swatch) => swatch instanceof HTMLButtonElement);
|
||||||
|
|
||||||
|
if (!hasExpectedSwatches) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.DOM_ELEMENT_MISSING,
|
||||||
|
`Expected ${expectedCount} color swatches.`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
actualCount: swatches.length,
|
||||||
|
expectedCount,
|
||||||
|
selector,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return swatches as Array<HTMLButtonElement>;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { queryRequiredElement } from '../utils/dom';
|
||||||
import { clamp01 } from '../utils/math';
|
import { clamp01 } from '../utils/math';
|
||||||
|
|
||||||
export class SplashScreen {
|
export class SplashScreen {
|
||||||
public readonly startButton = queryRequiredElement(
|
public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
|
||||||
'.start-button',
|
|
||||||
HTMLButtonElement
|
|
||||||
);
|
|
||||||
private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
|
private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
|
||||||
private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
|
private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
|
||||||
private readonly loadingStatus = queryRequiredElement(
|
private readonly loadingStatus = queryRequiredElement(
|
||||||
|
|
@ -17,6 +14,12 @@ export class SplashScreen {
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private setVisible(element: HTMLElement, isVisible: boolean): void {
|
||||||
|
element.dataset.visible = String(isVisible);
|
||||||
|
element.setAttribute('aria-hidden', String(!isVisible));
|
||||||
|
element.inert = !isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
public setLoadingStage(label: string, ratio: number): void {
|
public setLoadingStage(label: string, ratio: number): void {
|
||||||
const percent = Math.round(clamp01(ratio) * 100);
|
const percent = Math.round(clamp01(ratio) * 100);
|
||||||
this.loadingStatus.textContent = label;
|
this.loadingStatus.textContent = label;
|
||||||
|
|
@ -30,7 +33,7 @@ export class SplashScreen {
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
this.startButton.removeEventListener('click', onClick);
|
this.startButton.removeEventListener('click', onClick);
|
||||||
onStart();
|
onStart();
|
||||||
this.splash.hidden = true;
|
this.setVisible(this.splash, false);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
this.startButton.addEventListener('click', onClick);
|
this.startButton.addEventListener('click', onClick);
|
||||||
|
|
@ -38,10 +41,10 @@ export class SplashScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
public showLoadingBar(): void {
|
public showLoadingBar(): void {
|
||||||
this.loadingBar.hidden = false;
|
this.setVisible(this.loadingBar, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public hideLoadingBar(): void {
|
public hideLoadingBar(): void {
|
||||||
this.loadingBar.hidden = true;
|
this.setVisible(this.loadingBar, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
export const AGENT_WORKGROUP_SIZE = 64;
|
// Use the device's max workgroup size so we get full SIMD/wave occupancy on
|
||||||
|
// hardware that supports more than the WebGPU minimum of 256.
|
||||||
|
export const getAgentWorkgroupSize = (device: GPUDevice): number =>
|
||||||
|
device.limits.maxComputeInvocationsPerWorkgroup;
|
||||||
|
|
||||||
|
export const substituteAgentWorkgroupSize = (
|
||||||
|
device: GPUDevice,
|
||||||
|
shaderCode: string
|
||||||
|
): string =>
|
||||||
|
shaderCode.replaceAll(
|
||||||
|
'__AGENT_WORKGROUP_SIZE__',
|
||||||
|
String(getAgentWorkgroupSize(device))
|
||||||
|
);
|
||||||
|
|
||||||
export const dispatchAgentWorkgroups = (
|
export const dispatchAgentWorkgroups = (
|
||||||
passEncoder: GPUComputePassEncoder,
|
passEncoder: GPUComputePassEncoder,
|
||||||
|
workgroupSize: number,
|
||||||
agentCount: number
|
agentCount: number
|
||||||
): void => {
|
): void => {
|
||||||
passEncoder.dispatchWorkgroups(Math.ceil(agentCount / AGENT_WORKGROUP_SIZE), 1);
|
passEncoder.dispatchWorkgroups(Math.ceil(agentCount / workgroupSize), 1);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,40 +15,53 @@ const clearCompactedTailStride = 4u;
|
||||||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||||
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
||||||
|
|
||||||
var<workgroup> workgroupAliveCount: atomic<u32>;
|
|
||||||
var<workgroup> workgroupCompactedOffset: u32;
|
var<workgroup> workgroupCompactedOffset: u32;
|
||||||
|
var<workgroup> scanData: array<u32, agentWorkgroupSize>;
|
||||||
var<workgroup> clearAliveAgentCount: u32;
|
var<workgroup> clearAliveAgentCount: u32;
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
@builtin(local_invocation_id) local_id: vec3<u32>
|
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id);
|
let id = get_id(global_id);
|
||||||
|
let lid = local_id.x;
|
||||||
|
|
||||||
if local_id.x == 0u {
|
|
||||||
atomicStore(&workgroupAliveCount, 0u);
|
|
||||||
}
|
|
||||||
|
|
||||||
workgroupBarrier();
|
|
||||||
|
|
||||||
var localCompactedIndex = 0u;
|
|
||||||
var agent: Agent;
|
|
||||||
var isAlive = false;
|
var isAlive = false;
|
||||||
|
var agent: Agent;
|
||||||
if id < settings.agentCount {
|
if id < settings.agentCount {
|
||||||
isAlive = agents[id].colorIndex >= 0.0;
|
isAlive = agents[id].colorIndex >= 0.0;
|
||||||
if isAlive {
|
if isAlive {
|
||||||
agent = agents[id];
|
agent = agents[id];
|
||||||
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hillis-Steele inclusive prefix sum across the workgroup. Replaces a
|
||||||
|
// per-thread atomicAdd to a workgroup counter, eliminating serialization
|
||||||
|
// on dense workgroups.
|
||||||
|
scanData[lid] = select(0u, 1u, isAlive);
|
||||||
workgroupBarrier();
|
workgroupBarrier();
|
||||||
|
|
||||||
if local_id.x == 0u {
|
var offset: u32 = 1u;
|
||||||
let groupAliveCount = atomicLoad(&workgroupAliveCount);
|
while offset < agentWorkgroupSize {
|
||||||
if groupAliveCount > 0u {
|
let own = scanData[lid];
|
||||||
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount);
|
var contribution: u32 = 0u;
|
||||||
|
if lid >= offset {
|
||||||
|
contribution = scanData[lid - offset];
|
||||||
|
}
|
||||||
|
workgroupBarrier();
|
||||||
|
scanData[lid] = own + contribution;
|
||||||
|
workgroupBarrier();
|
||||||
|
offset = offset * 2u;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inclusivePrefix = scanData[lid];
|
||||||
|
let workgroupAliveTotal = scanData[agentWorkgroupSize - 1u];
|
||||||
|
let exclusivePrefix = inclusivePrefix - select(0u, 1u, isAlive);
|
||||||
|
|
||||||
|
if lid == 0u {
|
||||||
|
if workgroupAliveTotal > 0u {
|
||||||
|
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, workgroupAliveTotal);
|
||||||
} else {
|
} else {
|
||||||
workgroupCompactedOffset = 0u;
|
workgroupCompactedOffset = 0u;
|
||||||
}
|
}
|
||||||
|
|
@ -57,11 +70,11 @@ fn main(
|
||||||
workgroupBarrier();
|
workgroupBarrier();
|
||||||
|
|
||||||
if isAlive {
|
if isAlive {
|
||||||
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
|
compactedAgents[workgroupCompactedOffset + exclusivePrefix] = agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn clearCompactedTail(
|
fn clearCompactedTail(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
@builtin(local_invocation_id) local_id: vec3<u32>
|
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
|
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
|
||||||
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
||||||
import { dispatchAgentWorkgroups } from '../agent-dispatch';
|
import {
|
||||||
|
dispatchAgentWorkgroups,
|
||||||
|
getAgentWorkgroupSize,
|
||||||
|
substituteAgentWorkgroupSize,
|
||||||
|
} from '../agent-dispatch';
|
||||||
import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
|
import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
|
||||||
import compactionShader from './agent-compaction.wgsl?raw';
|
import compactionShader from './agent-compaction.wgsl?raw';
|
||||||
import resizeShader from './agent-resize.wgsl?raw';
|
import resizeShader from './agent-resize.wgsl?raw';
|
||||||
import agentSchema from './agent-schema.wgsl?raw';
|
import agentSchema from './agent-schema.wgsl?raw';
|
||||||
|
|
||||||
export { AGENT_FLOAT_COUNT } from '../agent-limits';
|
|
||||||
|
|
||||||
export class AgentGenerationPipeline {
|
export class AgentGenerationPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
private static readonly COUNTER_COUNT = 1;
|
private static readonly COUNTER_COUNT = 1;
|
||||||
|
|
@ -34,6 +36,7 @@ export class AgentGenerationPipeline {
|
||||||
private readonly resizePipeline: GPUComputePipeline;
|
private readonly resizePipeline: GPUComputePipeline;
|
||||||
private readonly compactionPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
||||||
|
private readonly workgroupSize: number;
|
||||||
|
|
||||||
private activeAgentsBuffer: GPUBuffer;
|
private activeAgentsBuffer: GPUBuffer;
|
||||||
private inactiveAgentsBuffer: GPUBuffer;
|
private inactiveAgentsBuffer: GPUBuffer;
|
||||||
|
|
@ -90,7 +93,7 @@ export class AgentGenerationPipeline {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activeAgentsBuffer = this.createAgentsBuffer();
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer();
|
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
|
||||||
this.countersBuffer = this.device.createBuffer({
|
this.countersBuffer = this.device.createBuffer({
|
||||||
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -107,17 +110,20 @@ export class AgentGenerationPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.workgroupSize = getAgentWorkgroupSize(device);
|
||||||
|
const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema);
|
||||||
|
|
||||||
this.resizePipeline = device.createComputePipeline({
|
this.resizePipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(device, agentSchema, resizeShader),
|
module: smartCompile(device, sizedSchema, resizeShader),
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const compactionModule = smartCompile(device, agentSchema, compactionShader);
|
const compactionModule = smartCompile(device, sizedSchema, compactionShader);
|
||||||
|
|
||||||
this.compactionPipeline = device.createComputePipeline({
|
this.compactionPipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
|
|
@ -151,16 +157,6 @@ export class AgentGenerationPipeline {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The inactive slot only needs a real allocation during compaction. The rest of
|
|
||||||
// the time we keep a one-agent placeholder so the bind group at binding 3 stays
|
|
||||||
// valid for resize without holding a second N-agent buffer in GPU memory.
|
|
||||||
private createInactivePlaceholderBuffer(): GPUBuffer {
|
|
||||||
return this.device.createBuffer({
|
|
||||||
size: AGENT_SIZE_IN_BYTES,
|
|
||||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
public get maxAgentCount(): number {
|
||||||
return this.allocatedMaxAgentCount;
|
return this.allocatedMaxAgentCount;
|
||||||
}
|
}
|
||||||
|
|
@ -187,9 +183,11 @@ export class AgentGenerationPipeline {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
|
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
|
||||||
|
const previousInactiveAgentsBuffer = this.inactiveAgentsBuffer;
|
||||||
const previousMaxAgentCount = this.allocatedMaxAgentCount;
|
const previousMaxAgentCount = this.allocatedMaxAgentCount;
|
||||||
this.allocatedMaxAgentCount = nextMaxAgentCount;
|
this.allocatedMaxAgentCount = nextMaxAgentCount;
|
||||||
this.activeAgentsBuffer = this.createAgentsBuffer();
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
|
||||||
const copyAgentCount = Math.min(
|
const copyAgentCount = Math.min(
|
||||||
Math.max(0, Math.floor(activeAgentCount)),
|
Math.max(0, Math.floor(activeAgentCount)),
|
||||||
|
|
@ -209,10 +207,9 @@ export class AgentGenerationPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPUBuffer.destroy() defers actual freeing until pending submissions
|
// GPUBuffer.destroy() defers actual freeing until pending submissions
|
||||||
// finish, so calling it synchronously after submit is safe and avoids the
|
// finish, so calling it synchronously after submit is safe.
|
||||||
// transient 4-buffers-live spike that pushes iOS Safari past its per-tab
|
|
||||||
// memory ceiling.
|
|
||||||
previousActiveAgentsBuffer.destroy();
|
previousActiveAgentsBuffer.destroy();
|
||||||
|
previousInactiveAgentsBuffer.destroy();
|
||||||
return this.allocatedMaxAgentCount;
|
return this.allocatedMaxAgentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,7 +248,7 @@ export class AgentGenerationPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.resizePipeline);
|
passEncoder.setPipeline(this.resizePipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
@ -262,12 +259,6 @@ export class AgentGenerationPipeline {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stash the placeholder, swap in a real N-agent destination buffer just
|
|
||||||
// for this compaction so the rest of the time we only carry one full
|
|
||||||
// agent buffer in memory.
|
|
||||||
const placeholder = this.inactiveAgentsBuffer;
|
|
||||||
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
|
||||||
|
|
||||||
this.agentCountUniformValues[0] = agentCount;
|
this.agentCountUniformValues[0] = agentCount;
|
||||||
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
||||||
|
|
||||||
|
|
@ -276,10 +267,11 @@ export class AgentGenerationPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.compactionPipeline);
|
passEncoder.setPipeline(this.compactionPipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
dispatchAgentWorkgroups(passEncoder, agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
|
||||||
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||||
dispatchAgentWorkgroups(
|
dispatchAgentWorkgroups(
|
||||||
passEncoder,
|
passEncoder,
|
||||||
|
this.workgroupSize,
|
||||||
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
||||||
);
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
@ -295,13 +287,6 @@ export class AgentGenerationPipeline {
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
this.swapAgentBuffers();
|
this.swapAgentBuffers();
|
||||||
|
|
||||||
// After swap, inactive is the previous active (full size). Destroy it and
|
|
||||||
// restore the placeholder; the destroy is deferred by WebGPU until the
|
|
||||||
// submitted compaction work has finished.
|
|
||||||
const previousActiveAgentsBuffer = this.inactiveAgentsBuffer;
|
|
||||||
this.inactiveAgentsBuffer = placeholder;
|
|
||||||
previousActiveAgentsBuffer.destroy();
|
|
||||||
|
|
||||||
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
||||||
const compactedCount = new Uint32Array(
|
const compactedCount = new Uint32Array(
|
||||||
this.countersStagingBuffer.getMappedRange(),
|
this.countersStagingBuffer.getMappedRange(),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ struct ResizeSettings {
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
|
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ struct Agent {
|
||||||
|
|
||||||
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
||||||
|
|
||||||
const agentWorkgroupSize = 64u;
|
const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
|
||||||
|
|
||||||
fn get_id(global_id: vec3<u32>) -> u32 {
|
fn get_id(global_id: vec3<u32>) -> u32 {
|
||||||
return global_id.x;
|
return global_id.x;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,46 @@
|
||||||
import { AGENT_WORKGROUP_SIZE } from './agent-dispatch';
|
import { getAgentWorkgroupSize } from './agent-dispatch';
|
||||||
|
|
||||||
export const AGENT_FLOAT_COUNT = 8;
|
export const AGENT_FLOAT_COUNT = 8;
|
||||||
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
|
const AGENT_LAYOUT = {
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 1,
|
||||||
|
angle: 2,
|
||||||
|
colorIndex: 3,
|
||||||
|
targetPositionX: 4,
|
||||||
|
targetPositionY: 5,
|
||||||
|
targetAngle: 6,
|
||||||
|
introDelay: 7,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface AgentLayoutValues {
|
||||||
|
angle: number;
|
||||||
|
colorIndex: number;
|
||||||
|
introDelay: number;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
targetAngle: number;
|
||||||
|
targetPositionX: number;
|
||||||
|
targetPositionY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const writeAgentValues = (
|
||||||
|
target: Float32Array,
|
||||||
|
agentIndex: number,
|
||||||
|
values: AgentLayoutValues
|
||||||
|
): void => {
|
||||||
|
const base = agentIndex * AGENT_FLOAT_COUNT;
|
||||||
|
target[base + AGENT_LAYOUT.positionX] = values.positionX;
|
||||||
|
target[base + AGENT_LAYOUT.positionY] = values.positionY;
|
||||||
|
target[base + AGENT_LAYOUT.angle] = values.angle;
|
||||||
|
target[base + AGENT_LAYOUT.colorIndex] = values.colorIndex;
|
||||||
|
target[base + AGENT_LAYOUT.targetPositionX] = values.targetPositionX;
|
||||||
|
target[base + AGENT_LAYOUT.targetPositionY] = values.targetPositionY;
|
||||||
|
target[base + AGENT_LAYOUT.targetAngle] = values.targetAngle;
|
||||||
|
target[base + AGENT_LAYOUT.introDelay] = values.introDelay;
|
||||||
|
};
|
||||||
|
|
||||||
export const getMaxSupportedAgentCount = (
|
export const getMaxSupportedAgentCount = (
|
||||||
device: GPUDevice,
|
device: GPUDevice,
|
||||||
maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
|
maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
|
||||||
|
|
@ -19,7 +57,8 @@ export const getMaxSupportedAgentCount = (
|
||||||
upperLimit,
|
upperLimit,
|
||||||
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
||||||
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
|
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
|
||||||
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * AGENT_WORKGROUP_SIZE
|
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
|
||||||
|
getAgentWorkgroupSize(device)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
|
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
import { dispatchAgentWorkgroups } from './agent-dispatch';
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
|
||||||
|
import {
|
||||||
|
dispatchAgentWorkgroups,
|
||||||
|
getAgentWorkgroupSize,
|
||||||
|
substituteAgentWorkgroupSize,
|
||||||
|
} from './agent-dispatch';
|
||||||
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
||||||
import shader from './agent.wgsl?raw';
|
import shader from './agent.wgsl?raw';
|
||||||
|
|
||||||
|
|
@ -44,11 +49,13 @@ const UNIFORM_COUNT = 30;
|
||||||
export class AgentPipeline {
|
export class AgentPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
private readonly normalPipeline: GPUComputePipeline;
|
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
|
private readonly workgroupSize: number;
|
||||||
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
private readonly bindGroupCache = createBindGroupCache3<
|
private readonly bindGroupCache = createBindGroupCache3<
|
||||||
GPUBuffer,
|
GPUBuffer,
|
||||||
GPUTextureView,
|
GPUTextureView,
|
||||||
|
|
@ -66,7 +73,6 @@ export class AgentPipeline {
|
||||||
);
|
);
|
||||||
|
|
||||||
private agentCount = 0;
|
private agentCount = 0;
|
||||||
private useIntroPipeline = true;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
|
|
@ -93,15 +99,16 @@ export class AgentPipeline {
|
||||||
{
|
{
|
||||||
binding: 3,
|
binding: 3,
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
storageTexture: { format: 'rgba16float' },
|
storageTexture: { format: TRAIL_SOURCE_TEXTURE_FORMAT },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.workgroupSize = getAgentWorkgroupSize(device);
|
||||||
const shaderModule = smartCompile(
|
const shaderModule = smartCompile(
|
||||||
device,
|
device,
|
||||||
CommonState.shaderCode,
|
CommonState.shaderCode,
|
||||||
agentSchema,
|
substituteAgentWorkgroupSize(device, agentSchema),
|
||||||
shader
|
shader
|
||||||
);
|
);
|
||||||
const pipelineLayout = device.createPipelineLayout({
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
|
|
@ -114,13 +121,6 @@ export class AgentPipeline {
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.normalPipeline = device.createComputePipeline({
|
|
||||||
layout: pipelineLayout,
|
|
||||||
compute: {
|
|
||||||
module: shaderModule,
|
|
||||||
entryPoint: 'mainNormal',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.uniforms = device.createBuffer({
|
this.uniforms = device.createBuffer({
|
||||||
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -167,7 +167,6 @@ export class AgentPipeline {
|
||||||
introProgress?: number;
|
introProgress?: number;
|
||||||
}) {
|
}) {
|
||||||
this.agentCount = agentCount;
|
this.agentCount = agentCount;
|
||||||
this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff;
|
|
||||||
this.uniformValues[0] = moveSpeed * deltaTime;
|
this.uniformValues[0] = moveSpeed * deltaTime;
|
||||||
this.uniformValues[1] = turnSpeed * deltaTime;
|
this.uniformValues[1] = turnSpeed * deltaTime;
|
||||||
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
||||||
|
|
@ -199,7 +198,7 @@ export class AgentPipeline {
|
||||||
this.uniformValues[27] = introNearMoveMultiplier;
|
this.uniformValues[27] = introNearMoveMultiplier;
|
||||||
this.uniformValues[28] = introStepStopDistance;
|
this.uniformValues[28] = introStepStopDistance;
|
||||||
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
||||||
writeFloat32BufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
@ -220,13 +219,13 @@ export class AgentPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass(
|
const passEncoder = commandEncoder.beginComputePass(
|
||||||
timestampWrites ? { timestampWrites } : undefined
|
timestampWrites ? { timestampWrites } : undefined
|
||||||
);
|
);
|
||||||
passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setBindGroup(
|
passEncoder.setBindGroup(
|
||||||
1,
|
1,
|
||||||
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
|
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
|
||||||
);
|
);
|
||||||
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
const PI: f32 = 3.14159265359;
|
const PI: f32 = 3.14159265359;
|
||||||
|
const TAU: f32 = 6.28318530718;
|
||||||
|
const INV_TAU: f32 = 0.15915494309;
|
||||||
|
|
||||||
struct Settings {
|
struct Settings {
|
||||||
moveRate: f32,
|
moveRate: f32,
|
||||||
|
|
@ -35,9 +37,9 @@ struct Settings {
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
||||||
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
|
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
) {
|
) {
|
||||||
|
|
@ -158,79 +160,6 @@ fn main(
|
||||||
agents[id].position = nextPosition;
|
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);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sensor_position(
|
fn sensor_position(
|
||||||
agentPosition: vec2<f32>,
|
agentPosition: vec2<f32>,
|
||||||
direction: vec2<f32>,
|
direction: vec2<f32>,
|
||||||
|
|
@ -290,7 +219,8 @@ fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||||
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
|
// Wraps to (-π, π] via fract(); replaces atan2(sin(d), cos(d)).
|
||||||
|
return (fract((targetAngle - sourceAngle) * INV_TAU + 0.5) - 0.5) * TAU;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn random_seed(id: u32) -> u32 {
|
fn random_seed(id: u32) -> u32 {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
LineSegmentBuffer,
|
LineSegmentBuffer,
|
||||||
} from '../common/line-segment-buffer';
|
} from '../common/line-segment-buffer';
|
||||||
import lineSegmentShader from '../common/line-segment.wgsl?raw';
|
import lineSegmentShader from '../common/line-segment.wgsl?raw';
|
||||||
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
|
||||||
import shader from './brush.wgsl?raw';
|
import shader from './brush.wgsl?raw';
|
||||||
|
|
||||||
export interface BrushSettings {
|
export interface BrushSettings {
|
||||||
|
|
@ -77,7 +78,9 @@ export class BrushPipeline {
|
||||||
private readonly renderPipeline: GPURenderPipeline;
|
private readonly renderPipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
private readonly segments: LineSegmentBuffer;
|
private readonly segments: LineSegmentBuffer;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -116,7 +119,7 @@ export class BrushPipeline {
|
||||||
entryPoint: 'fragmentMrt',
|
entryPoint: 'fragmentMrt',
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: 'rgba16float',
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
blend: {
|
blend: {
|
||||||
color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
|
color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
|
||||||
alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
|
alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
|
||||||
|
|
@ -148,7 +151,7 @@ export class BrushPipeline {
|
||||||
|
|
||||||
public setParameters(parameters: BrushParameters): void {
|
public setParameters(parameters: BrushParameters): void {
|
||||||
setBrushUniformValues(this.uniformValues, parameters);
|
setBrushUniformValues(this.uniformValues, parameters);
|
||||||
writeFloat32BufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@ fn vertex(
|
||||||
let direction = end - start;
|
let direction = end - start;
|
||||||
let denominator = dot(direction, direction);
|
let denominator = dot(direction, direction);
|
||||||
var inverseLengthSquared = 0.0;
|
var inverseLengthSquared = 0.0;
|
||||||
|
var normalizedDirection = vec2<f32>(1.0, 0.0);
|
||||||
if denominator > SEGMENT_LENGTH_EPSILON {
|
if denominator > SEGMENT_LENGTH_EPSILON {
|
||||||
inverseLengthSquared = 1.0 / denominator;
|
inverseLengthSquared = 1.0 / denominator;
|
||||||
|
normalizedDirection = direction * inverseSqrt(denominator);
|
||||||
}
|
}
|
||||||
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius);
|
let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.brushRadius);
|
||||||
let uv = screenPosition / state.size;
|
let uv = screenPosition / state.size;
|
||||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||||
|
|
@ -85,8 +87,16 @@ fn brushStrength(
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// smoothstep(0.35, 1.0, sqrt(d²/r²)) reparameterized to squared distance:
|
||||||
|
// squaring the edges gives smoothstep(0.1225·r², r², d²), avoiding the sqrt.
|
||||||
|
let safeRadiusSquared = max(settings.brushRadiusSquared, 0.0001);
|
||||||
|
let feather = 1.0 - smoothstep(0.1225 * safeRadiusSquared, safeRadiusSquared, distanceSquared);
|
||||||
|
if feather <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
|
if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
|
||||||
return settings.brushGrainMinStrength;
|
return settings.brushGrainMinStrength * feather;
|
||||||
}
|
}
|
||||||
|
|
||||||
let grainNoise = textureSampleLevel(
|
let grainNoise = textureSampleLevel(
|
||||||
|
|
@ -96,7 +106,12 @@ fn brushStrength(
|
||||||
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
|
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
|
||||||
0.0
|
0.0
|
||||||
).r;
|
).r;
|
||||||
return mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
|
let grainStrength = mix(
|
||||||
|
settings.brushGrainMinStrength,
|
||||||
|
settings.brushGrainMaxStrength,
|
||||||
|
grainNoise
|
||||||
|
);
|
||||||
|
return grainStrength * feather;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn brushOutput(strength: f32) -> vec4<f32> {
|
fn brushOutput(strength: f32) -> vec4<f32> {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { generateNoise } from '../../utils/graphics/noise';
|
import { generateNoise } from '../../utils/graphics/noise';
|
||||||
|
|
||||||
|
|
@ -12,10 +12,10 @@ export class CommonState {
|
||||||
|
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
CommonState.UNIFORM_COUNT
|
CommonState.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
private readonly noise: GPUTextureView;
|
private readonly noise: GPUTexture;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
|
|
||||||
public readonly bindGroupLayout: GPUBindGroupLayout;
|
public readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
|
@ -37,11 +37,12 @@ export class CommonState {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.noise = generateNoise({
|
const noise = generateNoise({
|
||||||
device,
|
device,
|
||||||
width: appConfig.pipelines.common.noiseTextureSize,
|
width: appConfig.pipelines.common.noiseTextureSize,
|
||||||
height: appConfig.pipelines.common.noiseTextureSize,
|
height: appConfig.pipelines.common.noiseTextureSize,
|
||||||
});
|
});
|
||||||
|
this.noise = noise.texture;
|
||||||
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
entries: [
|
entries: [
|
||||||
|
|
@ -90,7 +91,7 @@ export class CommonState {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
resource: this.noise,
|
resource: noise.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -99,7 +100,7 @@ export class CommonState {
|
||||||
public setParameters({ canvasSize }: { canvasSize: vec2 }) {
|
public setParameters({ canvasSize }: { canvasSize: vec2 }) {
|
||||||
this.uniformValues[0] = canvasSize[0];
|
this.uniformValues[0] = canvasSize[0];
|
||||||
this.uniformValues[1] = canvasSize[1];
|
this.uniformValues[1] = canvasSize[1];
|
||||||
writeFloat32BufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
@ -113,5 +114,6 @@ export class CommonState {
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
|
this.noise.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,9 @@ fn segment_vertex_position(
|
||||||
vertexIndex: u32,
|
vertexIndex: u32,
|
||||||
start: vec2<f32>,
|
start: vec2<f32>,
|
||||||
end: vec2<f32>,
|
end: vec2<f32>,
|
||||||
|
direction: vec2<f32>,
|
||||||
radius: f32
|
radius: f32
|
||||||
) -> vec2<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 perpendicular = vec2<f32>(direction.y, -direction.x);
|
||||||
let corner = segment_vertex_corner(vertexIndex % 6u);
|
let corner = segment_vertex_corner(vertexIndex % 6u);
|
||||||
let center = mix(start, end, (corner.x + 1.0) * 0.5);
|
let center = mix(start, end, (corner.x + 1.0) * 0.5);
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,13 @@ struct Settings {
|
||||||
padding2: f32,
|
padding2: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORKGROUP_SIZE_X = 16u;
|
const WORKGROUP_SIZE_X = __WORKGROUP_SIZE__u;
|
||||||
const WORKGROUP_SIZE_Y = 16u;
|
const WORKGROUP_SIZE_Y = __WORKGROUP_SIZE__u;
|
||||||
|
// Half a quantization step of rgba8unorm (1/255 ≈ 0.00392). Subtracted from
|
||||||
|
// RGB each frame so multiplicative decay can fall through the unorm
|
||||||
|
// quantization floor; without it, the smallest nonzero level (1/255) is a
|
||||||
|
// fixed point and trails never reach pure black.
|
||||||
|
const TRAIL_RGB_DECAY_SUBTRACT: f32 = 0.00196;
|
||||||
// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass
|
// 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.
|
// can be served from workgroup memory without bounds checks for interior tiles.
|
||||||
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
|
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
|
||||||
|
|
@ -21,34 +26,29 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> settings: Settings;
|
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
||||||
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba16float, write>;
|
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
var<workgroup> tile: array<vec4<f32>, 324>;
|
var<workgroup> tile: array<vec4<f32>, 324>;
|
||||||
var<workgroup> tileTrailStrength: array<f32, 324>;
|
var<workgroup> tileTrailStrength: array<f32, 324>;
|
||||||
|
|
||||||
@compute @workgroup_size(16, 16)
|
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||||
@builtin(workgroup_id) workgroup_id: vec3<u32>
|
@builtin(workgroup_id) workgroup_id: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
||||||
let textureSizeU32 = vec2<u32>(textureSize);
|
let textureBound = textureSize - vec2<i32>(1, 1);
|
||||||
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
|
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
|
||||||
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
|
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
|
||||||
let isInteriorTile =
|
|
||||||
workgroupOrigin.x > 0u &&
|
|
||||||
workgroupOrigin.y > 0u &&
|
|
||||||
workgroupOrigin.x + WORKGROUP_SIZE_X < textureSizeU32.x &&
|
|
||||||
workgroupOrigin.y + WORKGROUP_SIZE_Y < textureSizeU32.y;
|
|
||||||
|
|
||||||
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
|
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
|
||||||
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
|
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
|
||||||
let unclampedSourcePixel = vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1);
|
let sourcePixel = clamp(
|
||||||
var sourcePixel = unclampedSourcePixel;
|
vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1),
|
||||||
if !isInteriorTile {
|
vec2<i32>(0, 0),
|
||||||
sourcePixel = clamp(unclampedSourcePixel, vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1));
|
textureBound
|
||||||
}
|
);
|
||||||
let texel = textureLoad(trailMap, sourcePixel, 0);
|
let texel = textureLoad(trailMap, sourcePixel, 0);
|
||||||
tile[tileIndex] = texel;
|
tile[tileIndex] = texel;
|
||||||
tileTrailStrength[tileIndex] = length(texel.rgb);
|
tileTrailStrength[tileIndex] = length(texel.rgb);
|
||||||
|
|
@ -57,53 +57,67 @@ fn main(
|
||||||
workgroupBarrier();
|
workgroupBarrier();
|
||||||
|
|
||||||
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
|
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
|
||||||
let inBounds = pixel.x < textureSize.x && pixel.y < textureSize.y;
|
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
|
||||||
if !inBounds {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
|
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
|
||||||
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
|
let c = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
|
||||||
var current = tile[centerTileIndex];
|
let rowNorth = c - TILE_SIZE_X;
|
||||||
|
let rowSouth = c + TILE_SIZE_X;
|
||||||
|
|
||||||
|
// Batch-load all 8 neighbour texels and strengths into registers up front
|
||||||
|
// so the compiler can schedule LDS reads in parallel.
|
||||||
|
let current = tile[c];
|
||||||
|
let nTL = tile[rowNorth - 1u];
|
||||||
|
let nT = tile[rowNorth];
|
||||||
|
let nTR = tile[rowNorth + 1u];
|
||||||
|
let nL = tile[c - 1u];
|
||||||
|
let nR = tile[c + 1u];
|
||||||
|
let nBL = tile[rowSouth - 1u];
|
||||||
|
let nB = tile[rowSouth];
|
||||||
|
let nBR = tile[rowSouth + 1u];
|
||||||
|
|
||||||
|
let sTL = tileTrailStrength[rowNorth - 1u];
|
||||||
|
let sT = tileTrailStrength[rowNorth];
|
||||||
|
let sTR = tileTrailStrength[rowNorth + 1u];
|
||||||
|
let sL = tileTrailStrength[c - 1u];
|
||||||
|
let sR = tileTrailStrength[c + 1u];
|
||||||
|
let sBL = tileTrailStrength[rowSouth - 1u];
|
||||||
|
let sB = tileTrailStrength[rowSouth];
|
||||||
|
let sBR = tileTrailStrength[rowSouth + 1u];
|
||||||
|
|
||||||
let random = random_from_pixel(pixel);
|
let random = random_from_pixel(pixel);
|
||||||
let trailWeight = diffusion_weight(
|
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
|
||||||
random,
|
|
||||||
settings.inverseDiffusionRateTrails
|
|
||||||
);
|
|
||||||
current += (
|
|
||||||
propagate(centerTileIndex, -1, -1, current, trailWeight)
|
|
||||||
+ propagate(centerTileIndex, -1, 1, current, trailWeight)
|
|
||||||
+ propagate(centerTileIndex, 1, -1, current, trailWeight)
|
|
||||||
+ propagate(centerTileIndex, 1, 1, current, trailWeight)
|
|
||||||
|
|
||||||
+ propagate(centerTileIndex, -1, 0, current, trailWeight)
|
let propagated =
|
||||||
+ propagate(centerTileIndex, 0, -1, current, trailWeight)
|
propagate_value(nTL, sTL, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 1, 0, current, trailWeight)
|
+ propagate_value(nT, sT, current, trailWeight)
|
||||||
+ propagate(centerTileIndex, 0, 1, current, trailWeight)
|
+ propagate_value(nTR, sTR, current, trailWeight)
|
||||||
) * settings.diffusionNeighborScale;
|
+ propagate_value(nL, sL, current, trailWeight)
|
||||||
|
+ propagate_value(nR, sR, current, trailWeight)
|
||||||
|
+ propagate_value(nBL, sBL, current, trailWeight)
|
||||||
|
+ propagate_value(nB, sB, current, trailWeight)
|
||||||
|
+ propagate_value(nBR, sBR, current, trailWeight);
|
||||||
|
|
||||||
|
let updated = current + propagated * settings.diffusionNeighborScale;
|
||||||
let decayed = clamp(vec4(
|
let decayed = clamp(vec4(
|
||||||
current.rgb * settings.decayRateTrails,
|
updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
|
||||||
max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract)
|
updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
|
||||||
), vec4(0), vec4(1));
|
), vec4(0), vec4(1));
|
||||||
|
|
||||||
textureStore(trailMapOut, pixel, decayed);
|
textureStore(trailMapOut, pixel, decayed);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn propagate(
|
fn propagate_value(
|
||||||
centerTileIndex: u32,
|
neighbour: vec4<f32>,
|
||||||
offsetX: i32,
|
neighbourStrength: f32,
|
||||||
offsetY: i32,
|
current: vec4<f32>,
|
||||||
currentColor: vec4<f32>,
|
|
||||||
trailWeight: f32
|
trailWeight: f32
|
||||||
) -> vec4<f32> {
|
) -> vec4<f32> {
|
||||||
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
|
let difference = clamp(neighbour - current, vec4(0), vec4(1));
|
||||||
let neighbourTileIndex = u32(neighbourIndex);
|
|
||||||
let neighbour = tile[neighbourTileIndex];
|
|
||||||
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
|
|
||||||
|
|
||||||
return vec4(
|
return vec4(
|
||||||
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
|
vec3(neighbourStrength * trailWeight),
|
||||||
neighbour.a * trailWeight
|
neighbour.a * trailWeight
|
||||||
) * difference;
|
) * difference;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ import { vec2 } from 'gl-matrix';
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
|
||||||
import shader from './diffuse.wgsl?raw';
|
import shader from './diffuse.wgsl?raw';
|
||||||
|
|
||||||
export interface DiffusionSettings {
|
export interface DiffusionSettings {
|
||||||
|
|
@ -69,8 +70,8 @@ export class DiffusionPipeline {
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
DiffusionPipeline.UNIFORM_COUNT
|
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
||||||
(trailMapIn, trailMapOut) =>
|
(trailMapIn, trailMapOut) =>
|
||||||
|
|
@ -94,7 +95,7 @@ export class DiffusionPipeline {
|
||||||
bindGroupLayouts: [this.bindGroupLayout],
|
bindGroupLayouts: [this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(device, shader),
|
module: smartCompile(device, this.shaderCode),
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -121,7 +122,7 @@ export class DiffusionPipeline {
|
||||||
diffusionNeighborDivisor,
|
diffusionNeighborDivisor,
|
||||||
brushDecayAlphaOffset,
|
brushDecayAlphaOffset,
|
||||||
});
|
});
|
||||||
writeFloat32BufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
@ -176,10 +177,17 @@ export class DiffusionPipeline {
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
storageTexture: {
|
storageTexture: {
|
||||||
access: 'write-only',
|
access: 'write-only',
|
||||||
format: 'rgba16float',
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get shaderCode(): string {
|
||||||
|
return shader.replaceAll(
|
||||||
|
'__WORKGROUP_SIZE__',
|
||||||
|
DiffusionPipeline.WORKGROUP_SIZE.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { dispatchAgentWorkgroups } from '../agents/agent-dispatch';
|
import {
|
||||||
|
dispatchAgentWorkgroups,
|
||||||
|
getAgentWorkgroupSize,
|
||||||
|
substituteAgentWorkgroupSize,
|
||||||
|
} from '../agents/agent-dispatch';
|
||||||
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
||||||
import shader from './eraser-agent.wgsl?raw';
|
import shader from './eraser-agent.wgsl?raw';
|
||||||
|
|
||||||
|
|
@ -25,8 +29,8 @@ export class EraserAgentPipeline {
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
EraserAgentPipeline.UNIFORM_COUNT
|
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
|
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
|
||||||
(agentsBuffer, eraserMask) =>
|
(agentsBuffer, eraserMask) =>
|
||||||
|
|
@ -44,6 +48,7 @@ export class EraserAgentPipeline {
|
||||||
private activeSegmentCount = 0;
|
private activeSegmentCount = 0;
|
||||||
private pendingBounds: Bounds | null = null;
|
private pendingBounds: Bounds | null = null;
|
||||||
private agentCount = 0;
|
private agentCount = 0;
|
||||||
|
private readonly workgroupSize: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
|
|
@ -81,12 +86,17 @@ export class EraserAgentPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.workgroupSize = getAgentWorkgroupSize(device);
|
||||||
this.pipeline = device.createComputePipeline({
|
this.pipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(device, agentSchema, shader),
|
module: smartCompile(
|
||||||
|
device,
|
||||||
|
substituteAgentWorkgroupSize(device, agentSchema),
|
||||||
|
shader
|
||||||
|
),
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -128,7 +138,7 @@ export class EraserAgentPipeline {
|
||||||
this.uniformValues[5] = activeBounds.minY;
|
this.uniformValues[5] = activeBounds.minY;
|
||||||
this.uniformValues[6] = activeBounds.maxX;
|
this.uniformValues[6] = activeBounds.maxX;
|
||||||
this.uniformValues[7] = activeBounds.maxY;
|
this.uniformValues[7] = activeBounds.maxY;
|
||||||
writeFloat32BufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
@ -154,7 +164,7 @@ export class EraserAgentPipeline {
|
||||||
);
|
);
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
|
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
|
||||||
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ struct Settings {
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(1) @binding(2) var eraserMask: texture_2d<f32>;
|
@group(1) @binding(2) var eraserMask: texture_2d<f32>;
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
) {
|
) {
|
||||||
|
|
@ -26,11 +26,7 @@ fn main(
|
||||||
}
|
}
|
||||||
|
|
||||||
let position = agents[id].position;
|
let position = agents[id].position;
|
||||||
let outsideBounds = position.x < settings.boundsMin.x ||
|
if any(position < settings.boundsMin) || any(position > settings.boundsMax) {
|
||||||
position.y < settings.boundsMin.y ||
|
|
||||||
position.x > settings.boundsMax.x ||
|
|
||||||
position.y > settings.boundsMax.y;
|
|
||||||
if outsideBounds {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
|
|
@ -13,6 +13,10 @@ import {
|
||||||
LineSegmentBuffer,
|
LineSegmentBuffer,
|
||||||
} from '../common/line-segment-buffer';
|
} from '../common/line-segment-buffer';
|
||||||
import lineSegmentShader from '../common/line-segment.wgsl?raw';
|
import lineSegmentShader from '../common/line-segment.wgsl?raw';
|
||||||
|
import {
|
||||||
|
ERASER_MASK_TEXTURE_FORMAT,
|
||||||
|
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
} from '../texture-formats';
|
||||||
import shader from './eraser-texture.wgsl?raw';
|
import shader from './eraser-texture.wgsl?raw';
|
||||||
|
|
||||||
interface EraserTextureParameters {
|
interface EraserTextureParameters {
|
||||||
|
|
@ -25,7 +29,11 @@ interface EraserTextureParameters {
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNIFORM_COUNT = 8;
|
const UNIFORM_COUNT = 8;
|
||||||
const TARGET_FORMATS: Array<GPUTextureFormat> = ['r8unorm', 'rgba16float', 'rgba16float'];
|
const TARGET_FORMATS: Array<GPUTextureFormat> = [
|
||||||
|
ERASER_MASK_TEXTURE_FORMAT,
|
||||||
|
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
];
|
||||||
|
|
||||||
export class EraserTexturePipeline {
|
export class EraserTexturePipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
|
@ -33,7 +41,9 @@ export class EraserTexturePipeline {
|
||||||
private readonly combinedPipeline: GPURenderPipeline;
|
private readonly combinedPipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
private readonly segments: LineSegmentBuffer;
|
private readonly segments: LineSegmentBuffer;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -114,7 +124,7 @@ export class EraserTexturePipeline {
|
||||||
this.uniformValues[4] = eraserClearBlue;
|
this.uniformValues[4] = eraserClearBlue;
|
||||||
this.uniformValues[5] = eraserClearAlpha;
|
this.uniformValues[5] = eraserClearAlpha;
|
||||||
this.uniformValues[6] = eraserRadius;
|
this.uniformValues[6] = eraserRadius;
|
||||||
writeFloat32BufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,12 @@ fn vertex(
|
||||||
let direction = end - start;
|
let direction = end - start;
|
||||||
let denominator = dot(direction, direction);
|
let denominator = dot(direction, direction);
|
||||||
var inverseLengthSquared = 0.0;
|
var inverseLengthSquared = 0.0;
|
||||||
|
var normalizedDirection = vec2<f32>(1.0, 0.0);
|
||||||
if denominator > settings.lineDistanceEpsilon {
|
if denominator > settings.lineDistanceEpsilon {
|
||||||
inverseLengthSquared = 1.0 / denominator;
|
inverseLengthSquared = 1.0 / denominator;
|
||||||
|
normalizedDirection = direction * inverseSqrt(denominator);
|
||||||
}
|
}
|
||||||
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.eraserRadius);
|
let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.eraserRadius);
|
||||||
let uv = screenPosition / state.size;
|
let uv = screenPosition / state.size;
|
||||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedBufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeBufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
|
|
@ -14,22 +14,21 @@ export interface RenderSettings {
|
||||||
renderTraceNormalizationFloor: number;
|
renderTraceNormalizationFloor: number;
|
||||||
renderBrushColorBase: number;
|
renderBrushColorBase: number;
|
||||||
renderBrushColorStrengthMultiplier: number;
|
renderBrushColorStrengthMultiplier: number;
|
||||||
backgroundGrainStrength: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 5 scalars = 20 floats.
|
// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 4 scalars,
|
||||||
|
// rounded up to 20 floats for 16-byte uniform alignment.
|
||||||
const UNIFORM_COUNT = 20;
|
const UNIFORM_COUNT = 20;
|
||||||
|
|
||||||
export class RenderPipeline {
|
export class RenderPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPURenderPipeline;
|
||||||
private readonly noSourcePipeline: GPURenderPipeline;
|
private readonly noSourcePipeline: GPURenderPipeline;
|
||||||
private readonly noGrainPipeline: GPURenderPipeline;
|
|
||||||
private readonly noSourceNoGrainPipeline: GPURenderPipeline;
|
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT);
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
private useBackgroundGrain = true;
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
|
||||||
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
||||||
(colorTexture, sourceTexture) =>
|
(colorTexture, sourceTexture) =>
|
||||||
|
|
@ -46,7 +45,8 @@ export class RenderPipeline {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly context: GPUCanvasContext,
|
private readonly context: GPUCanvasContext,
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState
|
private readonly commonState: CommonState,
|
||||||
|
private readonly canvasFormat: GPUTextureFormat
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
entries: [
|
entries: [
|
||||||
|
|
@ -70,7 +70,6 @@ export class RenderPipeline {
|
||||||
|
|
||||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||||
const vertex = setUpFullScreenQuad(device);
|
const vertex = setUpFullScreenQuad(device);
|
||||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
||||||
const pipelineLayout = device.createPipelineLayout({
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
||||||
});
|
});
|
||||||
|
|
@ -78,30 +77,16 @@ export class RenderPipeline {
|
||||||
pipelineLayout,
|
pipelineLayout,
|
||||||
vertex,
|
vertex,
|
||||||
shaderModule,
|
shaderModule,
|
||||||
format,
|
this.canvasFormat,
|
||||||
'fragment'
|
'fragment'
|
||||||
);
|
);
|
||||||
this.noSourcePipeline = this.createPipeline(
|
this.noSourcePipeline = this.createPipeline(
|
||||||
pipelineLayout,
|
pipelineLayout,
|
||||||
vertex,
|
vertex,
|
||||||
shaderModule,
|
shaderModule,
|
||||||
format,
|
this.canvasFormat,
|
||||||
'fragmentNoSource'
|
'fragmentNoSource'
|
||||||
);
|
);
|
||||||
this.noGrainPipeline = this.createPipeline(
|
|
||||||
pipelineLayout,
|
|
||||||
vertex,
|
|
||||||
shaderModule,
|
|
||||||
format,
|
|
||||||
'fragmentNoGrain'
|
|
||||||
);
|
|
||||||
this.noSourceNoGrainPipeline = this.createPipeline(
|
|
||||||
pipelineLayout,
|
|
||||||
vertex,
|
|
||||||
shaderModule,
|
|
||||||
format,
|
|
||||||
'fragmentNoSourceNoGrain'
|
|
||||||
);
|
|
||||||
|
|
||||||
this.uniforms = device.createBuffer({
|
this.uniforms = device.createBuffer({
|
||||||
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -135,7 +120,6 @@ export class RenderPipeline {
|
||||||
renderTraceNormalizationFloor,
|
renderTraceNormalizationFloor,
|
||||||
renderBrushColorBase,
|
renderBrushColorBase,
|
||||||
renderBrushColorStrengthMultiplier,
|
renderBrushColorStrengthMultiplier,
|
||||||
backgroundGrainStrength,
|
|
||||||
}: RenderSettings & {
|
}: RenderSettings & {
|
||||||
channelColors: [RgbColor, RgbColor, RgbColor];
|
channelColors: [RgbColor, RgbColor, RgbColor];
|
||||||
backgroundColor: RgbColor;
|
backgroundColor: RgbColor;
|
||||||
|
|
@ -158,9 +142,7 @@ export class RenderPipeline {
|
||||||
this.uniformValues[16] = renderTraceNormalizationFloor;
|
this.uniformValues[16] = renderTraceNormalizationFloor;
|
||||||
this.uniformValues[17] = renderBrushColorBase;
|
this.uniformValues[17] = renderBrushColorBase;
|
||||||
this.uniformValues[18] = renderBrushColorStrengthMultiplier;
|
this.uniformValues[18] = renderBrushColorStrengthMultiplier;
|
||||||
this.uniformValues[19] = backgroundGrainStrength;
|
writeBufferIfChanged(
|
||||||
this.useBackgroundGrain = backgroundGrainStrength !== 0;
|
|
||||||
writeFloat32BufferIfChanged(
|
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
this.uniformValues,
|
this.uniformValues,
|
||||||
|
|
@ -232,10 +214,7 @@ export class RenderPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
|
private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
|
||||||
if (useSourceTexture) {
|
return useSourceTexture ? this.pipeline : this.noSourcePipeline;
|
||||||
return this.useBackgroundGrain ? this.pipeline : this.noGrainPipeline;
|
|
||||||
}
|
|
||||||
return this.useBackgroundGrain ? this.noSourcePipeline : this.noSourceNoGrainPipeline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
|
|
||||||
|
|
@ -10,32 +10,14 @@ struct Settings {
|
||||||
traceNormalizationFloor: f32,
|
traceNormalizationFloor: f32,
|
||||||
brushColorBase: f32,
|
brushColorBase: f32,
|
||||||
brushColorStrengthMultiplier: f32,
|
brushColorStrengthMultiplier: f32,
|
||||||
backgroundGrainStrength: f32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
||||||
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
||||||
|
|
||||||
const NOISE_TEXTURE_MASK = 2047u;
|
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
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, 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), getTexturedBackground(pixel));
|
|
||||||
}
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fragmentNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
|
||||||
let pixel = vec2<i32>(position.xy);
|
let pixel = vec2<i32>(position.xy);
|
||||||
let traces = textureLoad(trailMap, pixel, 0);
|
let traces = textureLoad(trailMap, pixel, 0);
|
||||||
let sources = textureLoad(sourceMap, pixel, 0);
|
let sources = textureLoad(sourceMap, pixel, 0);
|
||||||
|
|
@ -43,39 +25,30 @@ fn fragmentNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragmentNoSourceNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
let pixel = vec2<i32>(position.xy);
|
let pixel = vec2<i32>(position.xy);
|
||||||
let traces = textureLoad(trailMap, pixel, 0);
|
let traces = textureLoad(trailMap, pixel, 0);
|
||||||
return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
|
return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
|
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
|
||||||
let tracesMax = maxComponent(traces.rgb);
|
let traceStrengths = clarity(traces.rgb);
|
||||||
let sourcesMax = maxComponent(sources.rgb);
|
let sourceStrengths = clarity(sources.rgb);
|
||||||
if max(tracesMax, sourcesMax) <= 0.0 {
|
let traceStrength = maxComponent(traceStrengths);
|
||||||
|
let brushStrength = maxComponent(sourceStrengths);
|
||||||
|
if max(traceStrength, brushStrength) <= 0.0 {
|
||||||
return vec4(background, 1);
|
return vec4(background, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let traceStrengths = vec3(
|
if brushStrength <= 0.0 {
|
||||||
clarity(traces.r),
|
|
||||||
clarity(traces.g),
|
|
||||||
clarity(traces.b)
|
|
||||||
);
|
|
||||||
if sourcesMax <= 0.0 {
|
|
||||||
let traceColor =
|
let traceColor =
|
||||||
traceStrengths.r * settings.colorA
|
traceStrengths.r * settings.colorA
|
||||||
+ traceStrengths.g * settings.colorB
|
+ traceStrengths.g * settings.colorB
|
||||||
+ traceStrengths.b * settings.colorC;
|
+ traceStrengths.b * settings.colorC;
|
||||||
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
||||||
let traceStrength = maxComponent(traceStrengths);
|
|
||||||
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
|
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceStrengths = vec3(
|
|
||||||
clarity(sources.r),
|
|
||||||
clarity(sources.g),
|
|
||||||
clarity(sources.b)
|
|
||||||
);
|
|
||||||
let strengths = max(traceStrengths, sourceStrengths);
|
let strengths = max(traceStrengths, sourceStrengths);
|
||||||
let traceColor =
|
let traceColor =
|
||||||
strengths.r * settings.colorA
|
strengths.r * settings.colorA
|
||||||
|
|
@ -87,7 +60,6 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
|
||||||
+ sourceStrengths.g * settings.colorB
|
+ sourceStrengths.g * settings.colorB
|
||||||
+ sourceStrengths.b * settings.colorC;
|
+ sourceStrengths.b * settings.colorC;
|
||||||
let normalizedBrushColor = normalizeColorIntensity(brushColor);
|
let normalizedBrushColor = normalizeColorIntensity(brushColor);
|
||||||
let brushStrength = maxComponent(sourceStrengths);
|
|
||||||
let brushVisibility = clamp(
|
let brushVisibility = clamp(
|
||||||
brushStrength * (
|
brushStrength * (
|
||||||
settings.brushColorBase +
|
settings.brushColorBase +
|
||||||
|
|
@ -106,12 +78,8 @@ fn maxComponent(v: vec3<f32>) -> f32 {
|
||||||
return max(max(v.r, v.g), v.b);
|
return max(max(v.r, v.g), v.b);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clarity(strength: f32) -> f32 {
|
fn clarity(strength: vec3<f32>) -> vec3<f32> {
|
||||||
let clamped = clamp(strength, 0, 1);
|
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
|
||||||
if settings.clarity == 1.0 {
|
|
||||||
return clamped;
|
|
||||||
}
|
|
||||||
return pow(clamped, settings.clarity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||||
|
|
@ -122,14 +90,3 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||||
fn getFlatBackground() -> vec3<f32> {
|
fn getFlatBackground() -> vec3<f32> {
|
||||||
return clamp(settings.backgroundColor, vec3(0), vec3(1));
|
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;
|
|
||||||
|
|
||||||
return clamp(
|
|
||||||
settings.backgroundColor + vec3(grain * settings.backgroundGrainStrength),
|
|
||||||
vec3(0),
|
|
||||||
vec3(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
2
src/pipelines/texture-formats.ts
Normal file
2
src/pipelines/texture-formats.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
|
||||||
|
export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;
|
||||||
|
|
@ -1,32 +1,63 @@
|
||||||
import { appConfig, type GardenRuntimeSettings } from './config';
|
import {
|
||||||
|
appConfig,
|
||||||
|
normalizeRuntimeSettings,
|
||||||
|
type GardenRuntimeSettings,
|
||||||
|
} from './config';
|
||||||
import { writeBrowserStorage } from './utils/browser-storage';
|
import { writeBrowserStorage } from './utils/browser-storage';
|
||||||
import { getInitialVibe, type VibePreset } from './vibes';
|
import { getInitialVibe, type VibePreset } from './vibes';
|
||||||
|
|
||||||
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => ({
|
const preservedRuntimeSettingKeys = [
|
||||||
|
'eraserSize',
|
||||||
|
'adaptiveCapInitial',
|
||||||
|
'adaptiveCapMin',
|
||||||
|
'internalRenderAreaMegapixels',
|
||||||
|
'maxAgentCount',
|
||||||
|
'mirrorSegmentCount',
|
||||||
|
] satisfies ReadonlyArray<keyof GardenRuntimeSettings>;
|
||||||
|
|
||||||
|
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
|
||||||
|
[...color] as T;
|
||||||
|
|
||||||
|
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
||||||
|
...vibe,
|
||||||
|
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
|
||||||
|
backgroundColor: cloneRgbColor(vibe.backgroundColor),
|
||||||
|
settings: { ...vibe.settings },
|
||||||
|
audio: { ...vibe.audio },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
||||||
|
normalizeRuntimeSettings(
|
||||||
|
{
|
||||||
...appConfig.defaultSettings,
|
...appConfig.defaultSettings,
|
||||||
eraserSize: appConfig.toolbar.eraser.default,
|
eraserSize: appConfig.toolbar.eraser.default,
|
||||||
mirrorSegmentCount: appConfig.toolbar.mirror.default,
|
mirrorSegmentCount: appConfig.toolbar.mirror.default,
|
||||||
...vibe.settings,
|
...vibe.settings,
|
||||||
});
|
},
|
||||||
|
appConfig.runtimeSettings.controls
|
||||||
|
);
|
||||||
|
|
||||||
export let activeVibe = getInitialVibe();
|
export let activeVibe = cloneVibePreset(getInitialVibe());
|
||||||
|
|
||||||
export const settings: GardenRuntimeSettings = {
|
export const settings: GardenRuntimeSettings = {
|
||||||
...buildSettings(activeVibe),
|
...buildSettings(activeVibe),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyVibeSettings = (vibe: VibePreset) => {
|
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||||
activeVibe = vibe;
|
activeVibe = cloneVibePreset(vibe);
|
||||||
Object.assign(settings, {
|
const nextSettings = buildSettings(activeVibe);
|
||||||
...buildSettings(vibe),
|
preservedRuntimeSettingKeys.forEach((key) => {
|
||||||
eraserSize: settings.eraserSize,
|
nextSettings[key] = settings[key];
|
||||||
adaptiveCapInitial: settings.adaptiveCapInitial,
|
|
||||||
adaptiveCapMin: settings.adaptiveCapMin,
|
|
||||||
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
|
|
||||||
maxAgentCount: settings.maxAgentCount,
|
|
||||||
mirrorSegmentCount: settings.mirrorSegmentCount,
|
|
||||||
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
|
|
||||||
});
|
});
|
||||||
|
nextSettings.selectedColorIndex = Math.min(
|
||||||
|
settings.selectedColorIndex,
|
||||||
|
activeVibe.colors.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
settings,
|
||||||
|
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
||||||
|
);
|
||||||
|
|
||||||
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
|
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,19 +46,19 @@ html > body {
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
opacity: clamp(0, calc(var(--garden-grain-strength) * 12), 0.44);
|
opacity: clamp(0, calc(var(--garden-grain-strength) * 4.25), 0.24);
|
||||||
background-image: $grain-noise-a;
|
background-image: $grain-noise-a;
|
||||||
background-size: 257px 257px;
|
background-size: 257px 257px;
|
||||||
filter: contrast(190%) brightness(0.66);
|
filter: contrast(145%) brightness(0.82);
|
||||||
mix-blend-mode: multiply;
|
mix-blend-mode: multiply;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
opacity: clamp(0, calc(var(--garden-grain-strength) * 7), 0.24);
|
opacity: clamp(0, calc(var(--garden-grain-strength) * 2.5), 0.12);
|
||||||
background-image: $grain-noise-b;
|
background-image: $grain-noise-b;
|
||||||
background-position: 73px 41px;
|
background-position: 73px 41px;
|
||||||
background-size: 389px 389px;
|
background-size: 389px 389px;
|
||||||
filter: contrast(170%) brightness(1.02);
|
filter: contrast(135%) brightness(1);
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
transform: rotate(0.01deg);
|
transform: rotate(0.01deg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
@use 'mixins' as *;
|
|
||||||
|
|
||||||
.config-pane-container {
|
.config-pane-container {
|
||||||
--config-pane-available-height: calc(
|
--config-pane-available-height: calc(
|
||||||
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
||||||
|
|
@ -31,6 +29,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-pane {
|
.config-pane {
|
||||||
|
--tp-blade-value-width: min(260px, 64%);
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: calc(var(--config-pane-available-height) - 36px);
|
max-height: calc(var(--config-pane-available-height) - 36px);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
@ -40,6 +40,19 @@
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
.tp-lblv_l {
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-sldtxtv_s {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-sldtxtv_t {
|
||||||
|
flex: 0 0 54px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-pane-close {
|
.config-pane-close {
|
||||||
|
|
@ -121,10 +134,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-pane {
|
.config-pane {
|
||||||
--tp-blade-value-width: min(128px, 38vw);
|
--tp-blade-value-width: min(210px, 62%);
|
||||||
--tp-container-unit-size: 18px;
|
--tp-container-unit-size: 18px;
|
||||||
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
||||||
|
.tp-sldtxtv_t {
|
||||||
|
flex-basis: 48px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-pane-close {
|
.config-pane-close {
|
||||||
|
|
@ -133,11 +150,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include on-small-screen {
|
@media (max-width: 599px), (hover: none) and (pointer: coarse) {
|
||||||
@include mobile-config-pane;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) and (pointer: coarse) {
|
|
||||||
@include mobile-config-pane;
|
@include mobile-config-pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +160,7 @@
|
||||||
|
|
||||||
.color-reaction-matrix {
|
.color-reaction-matrix {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr));
|
grid-template-columns: 28px repeat(3, minmax(0, 1fr));
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +178,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-reaction-matrix__header {
|
.color-reaction-matrix__header {
|
||||||
gap: 5px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-reaction-matrix__corner {
|
.color-reaction-matrix__corner {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
.loading-indicator {
|
.loading-indicator {
|
||||||
|
--loading-gap: 22px;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 22px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity var(--transition-time-long);
|
transition: opacity var(--transition-time-long);
|
||||||
|
contain: layout;
|
||||||
|
|
||||||
> .splash {
|
> .splash {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -20,9 +22,19 @@
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-time),
|
||||||
|
visibility 0s linear 0s;
|
||||||
|
|
||||||
&[hidden] {
|
&[data-visible='false'] {
|
||||||
display: none;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-time),
|
||||||
|
visibility 0s linear var(--transition-time);
|
||||||
}
|
}
|
||||||
|
|
||||||
> .splash-title {
|
> .splash-title {
|
||||||
|
|
@ -89,14 +101,28 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
> .loading-bar {
|
> .loading-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--loading-gap));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-time),
|
||||||
|
visibility 0s linear var(--transition-time);
|
||||||
|
|
||||||
&[hidden] {
|
&[data-visible='true'] {
|
||||||
display: none;
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-time),
|
||||||
|
visibility 0s linear 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .loading-status {
|
> .loading-status {
|
||||||
|
|
@ -147,7 +173,7 @@ html > body.is-loading {
|
||||||
}
|
}
|
||||||
|
|
||||||
.eraser-preview {
|
.eraser-preview {
|
||||||
display: none;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
aside.control-dock {
|
aside.control-dock {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@use 'shared' as *;
|
@use 'shared' as *;
|
||||||
|
|
||||||
html > body > aside.control-dock > .toolbar-row > nav.buttons {
|
.buttons {
|
||||||
grid-area: buttons;
|
grid-area: buttons;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
|
@ -58,6 +58,10 @@ html > body > aside.control-dock > .toolbar-row > nav.buttons {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.full-screen-toggle.active::after {
|
||||||
|
mask-image: url('../../../assets/icons/minimize.svg');
|
||||||
|
}
|
||||||
|
|
||||||
&.sound.muted::before {
|
&.sound.muted::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@use 'shared' as *;
|
@use 'shared' as *;
|
||||||
|
|
||||||
html > body > aside.control-dock > .toolbar-row > .toolbar-shell > .garden-controls {
|
.garden-controls {
|
||||||
grid-area: swatches;
|
grid-area: swatches;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@use 'shared' as *;
|
@use 'shared' as *;
|
||||||
|
|
||||||
html > body > aside.control-dock > .toolbar-row {
|
.toolbar-row {
|
||||||
--toolbar-background-opacity: 0%;
|
--toolbar-background-opacity: 0%;
|
||||||
--toolbar-background-strength: 0;
|
--toolbar-background-strength: 0;
|
||||||
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
|
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
html > body > aside.control-dock > .toolbar-row {
|
.toolbar-row {
|
||||||
@include on-small-screen {
|
@include on-small-screen {
|
||||||
--toolbar-divider-space: 4px;
|
--toolbar-divider-space: 4px;
|
||||||
--toolbar-top-max-width: 329px;
|
--toolbar-top-max-width: 329px;
|
||||||
|
|
@ -124,7 +124,7 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html > body > aside.control-dock > .toolbar-row {
|
.toolbar-row {
|
||||||
> .vibe-button.previous-vibe:hover,
|
> .vibe-button.previous-vibe:hover,
|
||||||
> .vibe-button.next-vibe:hover,
|
> .vibe-button.next-vibe:hover,
|
||||||
> .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover,
|
> .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
$toolbar-icons: (
|
$toolbar-icons: (
|
||||||
info: 'info',
|
info: 'info',
|
||||||
maximize-full-screen: 'maximize',
|
full-screen-toggle: 'maximize',
|
||||||
minimize-full-screen: 'minimize',
|
|
||||||
settings: 'settings',
|
settings: 'settings',
|
||||||
sound: 'sound',
|
sound: 'sound',
|
||||||
export-4k: 'download',
|
export-4k: 'download',
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,13 @@ import { clamp } from './math';
|
||||||
|
|
||||||
export class DeltaTimeCalculator {
|
export class DeltaTimeCalculator {
|
||||||
private previousTime: DOMHighResTimeStamp | null = null;
|
private previousTime: DOMHighResTimeStamp | null = null;
|
||||||
|
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
document.addEventListener('visibilitychange', this.visibilityChangeListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
public calculateDeltaTimeInSeconds(
|
public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
|
||||||
currentTime: DOMHighResTimeStamp
|
|
||||||
): DOMHighResTimeStamp {
|
|
||||||
if (this.previousTime === null) {
|
if (this.previousTime === null) {
|
||||||
this.previousTime = currentTime;
|
this.previousTime = currentTime;
|
||||||
}
|
}
|
||||||
|
|
@ -29,4 +28,8 @@ export class DeltaTimeCalculator {
|
||||||
this.previousTime = null;
|
this.previousTime = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,40 +22,3 @@ export const queryRequiredElement = <T extends Element>(
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queryRequiredElements = <T extends Element>(
|
|
||||||
selector: string,
|
|
||||||
constructor: ElementConstructor<T>
|
|
||||||
): Array<T> => {
|
|
||||||
const elements = Array.from(document.querySelectorAll(selector));
|
|
||||||
if (elements.length === 0) {
|
|
||||||
throw new RuntimeError(
|
|
||||||
ErrorCode.DOM_ELEMENT_MISSING,
|
|
||||||
`Missing required DOM elements: ${selector}`,
|
|
||||||
{
|
|
||||||
details: {
|
|
||||||
expectedType: constructor.name,
|
|
||||||
selector,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements.map((element) => {
|
|
||||||
if (!(element instanceof constructor)) {
|
|
||||||
throw new RuntimeError(
|
|
||||||
ErrorCode.DOM_ELEMENT_MISSING,
|
|
||||||
`DOM element has the wrong type: ${selector}`,
|
|
||||||
{
|
|
||||||
details: {
|
|
||||||
actualType: element.constructor.name,
|
|
||||||
expectedType: constructor.name,
|
|
||||||
selector,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
|
||||||
38
src/utils/graphics/cached-buffer-write.test.ts
Normal file
38
src/utils/graphics/cached-buffer-write.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
updateCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from './cached-buffer-write';
|
||||||
|
|
||||||
|
describe('cached buffer writes', () => {
|
||||||
|
it('compares raw bytes so aliased uint changes are detected', () => {
|
||||||
|
const values = new Float32Array(1);
|
||||||
|
const uintValues = new Uint32Array(values.buffer);
|
||||||
|
const cache = createCachedBufferWrite(values.byteLength);
|
||||||
|
|
||||||
|
uintValues[0] = 0x7fc00001;
|
||||||
|
expect(updateCachedBufferWrite(values, cache)).toBe(true);
|
||||||
|
expect(updateCachedBufferWrite(values, cache)).toBe(false);
|
||||||
|
|
||||||
|
uintValues[0] = 0x7fc00002;
|
||||||
|
expect(Number.isNaN(values[0])).toBe(true);
|
||||||
|
expect(updateCachedBufferWrite(values, cache)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes to the GPU queue only when the raw buffer changed', () => {
|
||||||
|
const values = new Uint32Array([1, 2, 3, 4]);
|
||||||
|
const writeBuffer = vi.fn();
|
||||||
|
const device = { queue: { writeBuffer } } as unknown as GPUDevice;
|
||||||
|
const buffer = {} as GPUBuffer;
|
||||||
|
const cache = createCachedBufferWrite(values.byteLength);
|
||||||
|
|
||||||
|
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
|
||||||
|
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(false);
|
||||||
|
|
||||||
|
values[2] = 5;
|
||||||
|
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
|
||||||
|
expect(writeBuffer).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,36 +1,46 @@
|
||||||
interface CachedFloat32BufferWrite {
|
interface CachedBufferWrite {
|
||||||
hasValue: boolean;
|
hasValue: boolean;
|
||||||
previous: Float32Array;
|
previous: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCachedFloat32BufferWrite = (
|
export const createCachedBufferWrite = (byteLength: number): CachedBufferWrite => ({
|
||||||
length: number
|
|
||||||
): CachedFloat32BufferWrite => ({
|
|
||||||
hasValue: false,
|
hasValue: false,
|
||||||
previous: new Float32Array(length),
|
previous: new Uint8Array(byteLength),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const writeFloat32BufferIfChanged = (
|
export const updateCachedBufferWrite = (
|
||||||
device: GPUDevice,
|
values: ArrayBufferView,
|
||||||
buffer: GPUBuffer,
|
cache: CachedBufferWrite
|
||||||
values: Float32Array,
|
|
||||||
cache: CachedFloat32BufferWrite
|
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (values.length !== cache.previous.length) {
|
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||||
|
if (bytes.length !== cache.previous.length) {
|
||||||
throw new Error('Cached buffer write length mismatch');
|
throw new Error('Cached buffer write length mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasChanged = !cache.hasValue;
|
let hasChanged = !cache.hasValue;
|
||||||
for (let i = 0; i < values.length && !hasChanged; i++) {
|
for (let i = 0; i < bytes.length && !hasChanged; i++) {
|
||||||
hasChanged = !Object.is(values[i], cache.previous[i]);
|
hasChanged = bytes[i] !== cache.previous[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasChanged) {
|
if (!hasChanged) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.previous.set(values);
|
cache.previous.set(bytes);
|
||||||
cache.hasValue = true;
|
cache.hasValue = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeBufferIfChanged = (
|
||||||
|
device: GPUDevice,
|
||||||
|
buffer: GPUBuffer,
|
||||||
|
values: ArrayBufferView,
|
||||||
|
cache: CachedBufferWrite
|
||||||
|
): boolean => {
|
||||||
|
if (!updateCachedBufferWrite(values, cache)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
device.queue.writeBuffer(buffer, 0, values);
|
device.queue.writeBuffer(buffer, 0, values);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
|
||||||
export const initializeContext = ({
|
export const initializeContext = ({
|
||||||
device,
|
device,
|
||||||
canvas,
|
canvas,
|
||||||
|
format,
|
||||||
}: {
|
}: {
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
|
format: GPUTextureFormat;
|
||||||
}): GPUCanvasContext => {
|
}): GPUCanvasContext => {
|
||||||
const context = canvas.getContext('webgpu');
|
const context = canvas.getContext('webgpu');
|
||||||
|
|
||||||
|
|
@ -22,18 +24,10 @@ export const initializeContext = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gpu = navigator.gpu;
|
|
||||||
if (!gpu) {
|
|
||||||
throw new RuntimeError(
|
|
||||||
ErrorCode.WEBGPU_UNSUPPORTED,
|
|
||||||
'WebGPU is no longer available while configuring the canvas context.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
context.configure({
|
context.configure({
|
||||||
device: device,
|
device: device,
|
||||||
format: gpu.getPreferredCanvasFormat(),
|
format,
|
||||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||||
alphaMode: 'opaque',
|
alphaMode: 'opaque',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { appConfig } from '../../config';
|
||||||
import { setUpFullScreenQuad } from './full-screen-quad';
|
import { setUpFullScreenQuad } from './full-screen-quad';
|
||||||
import { smartCompile } from './smart-compile';
|
import { smartCompile } from './smart-compile';
|
||||||
|
|
||||||
const textureCache = new WeakMap<GPUDevice, Map<string, GPUTexture>>();
|
export interface GeneratedNoiseTexture {
|
||||||
|
texture: GPUTexture;
|
||||||
|
view: GPUTextureView;
|
||||||
|
}
|
||||||
|
|
||||||
export const generateNoise = ({
|
export const generateNoise = ({
|
||||||
device,
|
device,
|
||||||
|
|
@ -12,19 +15,7 @@ export const generateNoise = ({
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): GPUTextureView => {
|
}): GeneratedNoiseTexture => {
|
||||||
const cacheKey = `${width}x${height}:${appConfig.pipelines.common.noiseTextureFormat}`;
|
|
||||||
let deviceCache = textureCache.get(device);
|
|
||||||
if (!deviceCache) {
|
|
||||||
deviceCache = new Map<string, GPUTexture>();
|
|
||||||
textureCache.set(device, deviceCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = deviceCache.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached.createView();
|
|
||||||
}
|
|
||||||
|
|
||||||
const vertex = setUpFullScreenQuad(device);
|
const vertex = setUpFullScreenQuad(device);
|
||||||
|
|
||||||
const pipeline = device.createRenderPipeline({
|
const pipeline = device.createRenderPipeline({
|
||||||
|
|
@ -98,6 +89,8 @@ export const generateNoise = ({
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
device.queue.submit([commandEncoder.finish()]);
|
device.queue.submit([commandEncoder.finish()]);
|
||||||
deviceCache.set(cacheKey, colorTexture);
|
return {
|
||||||
return colorTexture.createView();
|
texture: colorTexture,
|
||||||
|
view: colorTexture.createView(),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../../pipelines/texture-formats';
|
||||||
|
|
||||||
interface ResizableTextureOptions {
|
interface ResizableTextureOptions {
|
||||||
clearValue?: GPUColor;
|
clearValue?: GPUColor;
|
||||||
format?: GPUTextureFormat;
|
format?: GPUTextureFormat;
|
||||||
|
|
@ -27,7 +29,7 @@ export class ResizableTexture {
|
||||||
size: vec2,
|
size: vec2,
|
||||||
{
|
{
|
||||||
clearValue = { r: 0, g: 0, b: 0, a: 0 },
|
clearValue = { r: 0, g: 0, b: 0, a: 0 },
|
||||||
format = 'rgba16float',
|
format = TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
usage = defaultTextureUsage,
|
usage = defaultTextureUsage,
|
||||||
}: ResizableTextureOptions = {}
|
}: ResizableTextureOptions = {}
|
||||||
) {
|
) {
|
||||||
|
|
@ -39,18 +41,6 @@ export class ResizableTexture {
|
||||||
this.textureView = this.texture.createView();
|
this.textureView = this.texture.createView();
|
||||||
}
|
}
|
||||||
|
|
||||||
public resize(size: vec2): void {
|
|
||||||
const resize = this.prepareResize(size);
|
|
||||||
if (!resize) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
|
||||||
this.encodeResize(commandEncoder, resize);
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
|
||||||
this.commitResize(resize);
|
|
||||||
}
|
|
||||||
|
|
||||||
public prepareResize(size: vec2): PendingTextureResize | null {
|
public prepareResize(size: vec2): PendingTextureResize | null {
|
||||||
if (vec2.equals(this.size, size)) {
|
if (vec2.equals(this.size, size)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,7 @@ const cssTargets = browserslistToTargets(browserslist());
|
||||||
const esbuildTargets = browserslistToEsbuild();
|
const esbuildTargets = browserslistToEsbuild();
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
base: './',
|
plugins: [viteSingleFile(), ...(command === 'serve' ? [basicSsl()] : [])],
|
||||||
plugins: [
|
|
||||||
viteSingleFile({
|
|
||||||
inlinePattern: ['index-*.js', 'style-*.css'],
|
|
||||||
useRecommendedBuildConfig: false,
|
|
||||||
}),
|
|
||||||
...(command === 'serve' ? [basicSsl()] : []),
|
|
||||||
],
|
|
||||||
css: {
|
css: {
|
||||||
transformer: 'lightningcss',
|
transformer: 'lightningcss',
|
||||||
lightningcss: {
|
lightningcss: {
|
||||||
|
|
@ -25,14 +18,12 @@ export default defineConfig(({ command }) => ({
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: esbuildTargets,
|
target: esbuildTargets,
|
||||||
cssCodeSplit: false,
|
|
||||||
cssMinify: 'lightningcss',
|
cssMinify: 'lightningcss',
|
||||||
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
|
||||||
assetsDir: '',
|
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
host: true,
|
host: true,
|
||||||
|
hmr: false,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue