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