Clean up #2

Merged
andras merged 3 commits from asch/clean-up into main 2026-05-25 09:42:49 +01:00
41 changed files with 628 additions and 710 deletions

5
assets/icons/close.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19 5 17.6 10.6 12 5 6.4z"
/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

View file

@ -11,7 +11,7 @@
<meta name="author" content="Andras Schmelczer" /> <meta name="author" content="Andras Schmelczer" />
<meta <meta
name="description" name="description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser." content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
/> />
<link rel="canonical" href="https://schmelczer.dev/fleeting/" /> <link rel="canonical" href="https://schmelczer.dev/fleeting/" />
@ -22,7 +22,7 @@
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<meta <meta
property="og:description" property="og:description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser." content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
/> />
<meta property="og:url" content="https://schmelczer.dev/fleeting/" /> <meta property="og:url" content="https://schmelczer.dev/fleeting/" />
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" /> <meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
@ -35,7 +35,7 @@
<meta name="twitter:title" content="Fleeting Garden" /> <meta name="twitter:title" content="Fleeting Garden" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser." content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
/> />
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" /> <meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." /> <meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
@ -46,7 +46,7 @@
"@type": "WebApplication", "@type": "WebApplication",
"name": "Fleeting Garden", "name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/", "url": "https://schmelczer.dev/fleeting/",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", "description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg", "image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication", "applicationCategory": "DesignApplication",
"operatingSystem": "Any", "operatingSystem": "Any",
@ -118,35 +118,47 @@
id="info-panel" id="info-panel"
class="hidden info-page" class="hidden info-page"
role="region" role="region"
aria-label="About panel" aria-labelledby="info-panel-title"
aria-hidden="true" aria-hidden="true"
tabindex="-1" tabindex="-1"
inert inert
> >
<section> <div class="info-page__content">
<h1>Fleeting Garden</h1> <header class="info-page__header">
<p> <span class="info-page__mark" aria-hidden="true"></span>
<div class="info-page__heading">
<p class="info-page__eyebrow">About</p>
<h2 id="info-panel-title">Fleeting Garden</h2>
</div>
<button
class="info-page__close"
data-control="info-close"
type="button"
aria-label="Close about panel"
title="Close"
></button>
</header>
<p class="info-page__lede">
A garden is what we tend; the wild is what we get the moment we look away. A garden is what we tend; the wild is what we get the moment we look away.
Both happen here at once. Your strokes plant colour, small agents follow them, Both happen here at once. Your strokes plant colour, small agents follow them,
branch off, and slowly rewrite the patch you laid down into something you branch off, and slowly rewrite the patch you laid down into something you
didn't quite plan. didn't quite plan.
</p> </p>
<p>
Three swatches plant the line. The eraser carves a clearing. The mirror folds <ul class="info-page__notes">
one gesture into many, like footpaths around a hidden well. <li>Three swatches plant the line; the eraser carves a clearing.</li>
</p> <li>The mirror folds one gesture into many.</li>
<p> <li>The arrows change the season.</li>
Switch vibes to change the season; your shapes stay, the light moves. Add or </ul>
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
want to keep one particular instant of weather. <p class="info-page__meta">
</p>
<p>
Built with WebGPU, running locally in your browser. More of my work at Built with WebGPU, running locally in your browser. More of my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener" <a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a >schmelczer.dev</a
>. >.
</p> </p>
</section> </div>
</section> </section>
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar"> <div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Before After
Before After

View file

@ -4,9 +4,13 @@ import {
type PlausibleEventOptions, type PlausibleEventOptions,
} from '@plausible-analytics/tracker'; } from '@plausible-analytics/tracker';
import { appConfig } from './config';
import type { VibeId } from './vibes'; import type { VibeId } from './vibes';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'schmelczer.dev/fleeting';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = import.meta.env.DEV;
let isInitialized = false; let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => { const track = (eventName: string, options: PlausibleEventOptions = {}) => {
@ -24,10 +28,10 @@ export const initAnalytics = () => {
try { try {
plausibleInit({ plausibleInit({
domain: appConfig.analytics.domain, domain: ANALYTICS_DOMAIN,
endpoint: appConfig.analytics.endpoint, endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: appConfig.analytics.autoCapturePageviews, autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: appConfig.analytics.logging, logging: ANALYTICS_LOGGING,
}); });
isInitialized = true; isInitialized = true;
} catch (error) { } catch (error) {

View file

@ -1,199 +1,9 @@
import { export { defaultSettings } from './config/default-settings';
createGardenAudioConfig,
DEFAULT_AUDIO_VOLUME,
} from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
export { export {
normalizeNumberControlValue, normalizeNumberControlValue,
normalizeRuntimeSettings, normalizeRuntimeSettings,
} from './config/normalize-runtime-settings'; } from './config/normalize-runtime-settings';
export { runtimeControls } from './config/runtime-controls';
export { defaultVibeId, vibePresets } from './config/vibe-presets';
export type { export type { GardenRuntimeSettings, NumberControlConfig } from './config/types';
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './config/types';
export const appConfig = {
audio: createGardenAudioConfig(),
analytics: {
autoCapturePageviews: true,
domain: 'fleeting.garden',
endpoint: 'https://stats.schmelczer.dev/status',
logging: import.meta.env.DEV,
},
deltaTime: {
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
exportSnapshot: {
bytesPerPixel: 4,
filenameExtension: 'png',
filenamePrefix: 'fleeting-garden',
filenameSuffix: '-snapshot',
mimeType: 'image/png',
rowAlignmentBytes: 256,
},
menuHider: {
bottomRevealDistancePx: 96,
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
hideDelayMs: 3000,
},
pipelines: {
common: {
noiseChannelSeeds: [0, 1, 2, 3],
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
noiseDrawInstanceCount: 1,
noiseDrawVertexCount: 3,
noiseHashMultiplier: 43758.5453123,
noiseHashX: 12.9898,
noiseHashY: 78.233,
noiseTextureFormat: 'r8unorm',
noiseTextureSize: 2048,
},
brush: {
maxLineCount: 240,
},
diffusion: {
minDiffusionRate: 0.000001,
},
eraser: {
maxTextureLineCount: 384,
},
},
defaultSettings,
runtimeSettings: {
controls: runtimeControls,
},
simulation: {
brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,
// How long the source map continues to be diffused after a brush stroke ends.
// 600 frames at ~60 FPS ≈ 10 seconds.
sourceActiveFramesAfterWrite: 600,
intro: {
angleJitterRadians: Math.PI * 0.08,
angleEaseEnd: 1,
angleEaseStart: 0.6,
circleMaxSideRatio: 0.46,
circleMinSideRatio: 0.32,
drawHintDelayMs: 3000,
durationSeconds: 4,
entryJitterSideRatio: 0.035,
fontScaleDown: 0.94,
fontFamily: '"Open Sans", sans-serif',
initialFontHeightRatio: 0.28,
initialFontWidthRatio: 0.19,
letterSpacingEm: 0.07,
maskAlphaThreshold: 32,
maskGradientThreshold: 8,
maskMaxPixels: 1_000_000,
maskSampleDensity: 540,
maxHeightRatio: 0.25,
maxWidthRatio: 0.76,
minEntryJitterPx: 6,
minFontSizePx: 18,
minTargetJitterPx: 1,
pathEasing: 'easeOutQuad',
pathProgressEpsilon: 0.001,
radialJitterRatio: 0.35,
radialStartEpsilon: 0.001,
resizeMinimumRemainingSeconds: 1.4,
resizeSettleMs: 120,
targetDelayDistanceMultiplier: 0.12,
targetDelayMax: 0.22,
targetDelayRandomMultiplier: 0.06,
targetJitterSideRatio: 0.0035,
title: 'Fleeting',
titleColorCutLetters: [2, 5],
titleRadiusMultiplier: 1.55,
titleStrokeWidthMinPx: 6,
titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47,
},
introMoveSpeed: 280,
stroke: {
densityMultiplier: 110,
maxAgentCount: 2_400,
},
},
storage: {
audioMutedKey: 'fleeting-garden:audio-muted',
audioVolumeKey: 'fleeting-garden:audio-volume',
vibeKey: 'fleeting-garden:vibe',
},
toolbar: {
eraser: {
controlScaleMax: 1.34,
controlScaleMin: 0.74,
default: 96,
max: 480,
min: 24,
step: 1,
},
mirror: {
default: 8,
fallbackSegmentName: 'slices',
max: 12,
min: 1,
names: {
2: 'halves',
3: 'thirds',
4: 'quarters',
5: 'fifths',
6: 'sixths',
7: 'sevenths',
8: 'eighths',
9: 'ninths',
10: 'tenths',
11: 'elevenths',
12: 'twelfths',
},
offLabel: 'Mirror off',
step: 1,
},
contrast: {
backgroundOpacityMax: 0.82,
brightLuminanceThreshold: 0.32,
brightWeight: 0.65,
bytesPerSample: 4,
contrastOffset: 0.05,
linearChannelBreakpoint: 0.03928,
linearChannelDivisor: 12.92,
linearChannelGamma: 2.4,
linearChannelOffset: 0.055,
linearChannelScale: 1.055,
lowContrastThreshold: 3,
lowContrastWeight: 1.8,
luminanceBase: 0.11,
luminanceBlueWeight: 0.0722,
luminanceGreenWeight: 0.7152,
luminanceRange: 0.28,
luminanceRedWeight: 0.2126,
sampleColumns: 13,
sampleIntervalMs: 300,
sampleRows: 7,
whiteContrastNumerator: 1.05,
},
volume: {
default: DEFAULT_AUDIO_VOLUME,
max: 1,
min: 0,
step: 0.01,
},
},
tuningPane: {
showFpsOverlay: import.meta.env.DEV,
startHidden: true,
title: 'Garden Settings',
},
vibes: {
defaultVibeId,
presets: vibePresets,
},
} satisfies GardenAppConfig;

View file

@ -1,5 +1,5 @@
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds'; import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
import type { GardenAppConfig } from './types'; import type { GardenDefaultSettings } from './types';
// Mirrors the historical render-scale cap so the default render area stays // Mirrors the historical render-scale cap so the default render area stays
// roughly equivalent to native rendering on high-DPR phones without the // roughly equivalent to native rendering on high-DPR phones without the
@ -21,7 +21,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
); );
}; };
export const defaultSettings: GardenAppConfig['defaultSettings'] = { export const defaultSettings: GardenDefaultSettings = {
selectedColorIndex: 0, selectedColorIndex: 0,
introNearDistanceMin: 28, introNearDistanceMin: 28,

View file

@ -1,11 +1,9 @@
import type { import type {
GardenAppConfig,
GardenRuntimeSettings, GardenRuntimeSettings,
NumberControlConfig, NumberControlConfig,
RuntimeSettingControlConfig,
} from './types'; } from './types';
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
export const normalizeNumberControlValue = ( export const normalizeNumberControlValue = (
value: number, value: number,
config: NumberControlConfig config: NumberControlConfig
@ -28,7 +26,7 @@ export const normalizeNumberControlValue = (
export const normalizeRuntimeSettings = ( export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings, settings: GardenRuntimeSettings,
controls: RuntimeSettingControls controls: RuntimeSettingControlConfig
): GardenRuntimeSettings => { ): GardenRuntimeSettings => {
const normalized = { ...settings }; const normalized = { ...settings };

View file

@ -1,6 +1,6 @@
import { colorInteractionControl } from './color-interactions'; import { colorInteractionControl } from './color-interactions';
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds'; import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
import type { GardenAppConfig } from './types'; import type { RuntimeSettingControlConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
const formatRadiansAsDegrees = (value: number): string => const formatRadiansAsDegrees = (value: number): string =>
@ -16,7 +16,7 @@ const formatCompactNumber = (value: number): string => {
return `${value}`; return `${value}`;
}; };
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { export const runtimeControls: RuntimeSettingControlConfig = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'), color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'), color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'), color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),

View file

@ -1,7 +1,4 @@
import type { import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
GardenAudioConfig,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-pipeline'; import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline'; import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline'; import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
@ -49,7 +46,7 @@ export type GardenRuntimeSettings = {
DiffusionSettings & DiffusionSettings &
RenderSettings; RenderSettings;
type RuntimeSettingControlConfig = Partial< export type RuntimeSettingControlConfig = Partial<
Record<keyof GardenRuntimeSettings, NumberControlConfig> Record<keyof GardenRuntimeSettings, NumberControlConfig>
>; >;
@ -79,7 +76,7 @@ export type GardenVibeSettings = Pick<
| 'turnWhenLost' | 'turnWhenLost'
>; >;
type GardenDefaultSettings = Omit< export type GardenDefaultSettings = Omit<
GardenRuntimeSettings, GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount' keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>; >;
@ -101,169 +98,3 @@ export interface VibePreset {
settings: GardenVibeSettings; settings: GardenVibeSettings;
audio: GardenAudioVibeSettings; audio: GardenAudioVibeSettings;
} }
export interface GardenAppConfig {
audio: GardenAudioConfig;
analytics: {
autoCapturePageviews: boolean;
domain: string;
endpoint: string;
logging: boolean;
};
deltaTime: {
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
exportSnapshot: {
bytesPerPixel: number;
filenameExtension: string;
filenamePrefix: string;
filenameSuffix: string;
mimeType: string;
rowAlignmentBytes: number;
};
menuHider: {
bottomRevealDistancePx: number;
desktopMediaQuery: string;
hideDelayMs: number;
};
pipelines: {
common: {
noiseChannelSeeds: [number, number, number, number];
noiseClearValue: GPUColor;
noiseDrawInstanceCount: number;
noiseDrawVertexCount: number;
noiseHashMultiplier: number;
noiseHashX: number;
noiseHashY: number;
noiseTextureFormat: GPUTextureFormat;
noiseTextureSize: number;
};
brush: {
maxLineCount: number;
};
diffusion: {
minDiffusionRate: number;
};
eraser: {
maxTextureLineCount: number;
};
};
defaultSettings: GardenDefaultSettings;
runtimeSettings: {
controls: RuntimeSettingControlConfig;
};
simulation: {
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;
sourceActiveFramesAfterWrite: number;
intro: {
angleJitterRadians: number;
angleEaseEnd: number;
angleEaseStart: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
fontScaleDown: number;
fontFamily: string;
initialFontHeightRatio: number;
initialFontWidthRatio: number;
letterSpacingEm: number;
maskAlphaThreshold: number;
maskGradientThreshold: number;
maskMaxPixels: number;
maskSampleDensity: number;
maxHeightRatio: number;
maxWidthRatio: number;
minEntryJitterPx: number;
minFontSizePx: number;
minTargetJitterPx: number;
pathEasing: 'easeOutQuad' | 'linear';
pathProgressEpsilon: number;
radialJitterRatio: number;
radialStartEpsilon: number;
resizeMinimumRemainingSeconds: number;
resizeSettleMs: number;
targetDelayDistanceMultiplier: number;
targetDelayMax: number;
targetDelayRandomMultiplier: number;
targetJitterSideRatio: number;
title: string;
titleColorCutLetters: [number, number];
titleRadiusMultiplier: number;
titleStrokeWidthMinPx: number;
titleStrokeWidthRatio: number;
verticalAnchor: number;
};
introMoveSpeed: number;
stroke: {
densityMultiplier: number;
maxAgentCount: number;
};
};
storage: {
audioMutedKey: string;
audioVolumeKey: string;
vibeKey: string;
};
toolbar: {
eraser: {
controlScaleMax: number;
controlScaleMin: number;
default: number;
max: number;
min: number;
step: number;
};
mirror: {
default: number;
fallbackSegmentName: string;
max: number;
min: number;
names: Record<number, string>;
offLabel: string;
step: number;
};
contrast: {
backgroundOpacityMax: number;
brightLuminanceThreshold: number;
brightWeight: number;
bytesPerSample: number;
contrastOffset: number;
linearChannelBreakpoint: number;
linearChannelDivisor: number;
linearChannelGamma: number;
linearChannelOffset: number;
linearChannelScale: number;
lowContrastThreshold: number;
lowContrastWeight: number;
luminanceBase: number;
luminanceBlueWeight: number;
luminanceGreenWeight: number;
luminanceRange: number;
luminanceRedWeight: number;
sampleColumns: number;
sampleIntervalMs: number;
sampleRows: number;
whiteContrastNumerator: number;
};
volume: {
default: number;
max: number;
min: number;
step: number;
};
};
tuningPane: {
showFpsOverlay: boolean;
startHidden: boolean;
title: string;
};
vibes: {
defaultVibeId: VibeId;
presets: Array<VibePreset>;
};
}

View file

@ -1,11 +1,14 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits'; import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
import { settings } from '../settings'; import { settings } from '../settings';
import { AgentPopulation } from './agent-population'; import {
AgentPopulation,
STROKE_AGENT_BATCH_CAPACITY,
STROKE_DENSITY_MULTIPLIER,
} from './agent-population';
import { type FramePerformance } from './frame-performance'; import { type FramePerformance } from './frame-performance';
const originalSettings = { const originalSettings = {
@ -70,7 +73,7 @@ const createPopulation = (): {
}; };
const setSpawnRate = (agentsPerPixel: number): void => { const setSpawnRate = (agentsPerPixel: number): void => {
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier; settings.spawnPerPixel = agentsPerPixel / STROKE_DENSITY_MULTIPLIER;
}; };
describe('AgentPopulation stroke spawning', () => { describe('AgentPopulation stroke spawning', () => {
@ -124,7 +127,7 @@ describe('AgentPopulation stroke spawning', () => {
it('chunks long stroke writes without clipping length-linear spawn counts', () => { it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation(); const { pipeline, population } = createPopulation();
const batchCapacity = appConfig.simulation.stroke.maxAgentCount; const batchCapacity = STROKE_AGENT_BATCH_CAPACITY;
const expectedAgentCount = batchCapacity + 10; const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents( population.spawnStrokeAgents(

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size'; import { getRenderQualityBrushSize } from '../config/brush-size';
import { 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 { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
@ -9,6 +8,11 @@ import { settings } from '../settings';
import type { FramePerformance } from './frame-performance'; import type { FramePerformance } from './frame-performance';
import { createIntroTitleAgents } from './intro-title-agents'; import { createIntroTitleAgents } from './intro-title-agents';
export const STROKE_AGENT_BATCH_CAPACITY = 2_400;
export const STROKE_DENSITY_MULTIPLIER = 110;
const INITIAL_INTRO_AGENT_COUNT = 180_000;
export class AgentPopulation { export class AgentPopulation {
private activeCount = 0; private activeCount = 0;
// Current performance-aware limit; new agents above it replace old agents. // Current performance-aware limit; new agents above it replace old agents.
@ -22,7 +26,7 @@ export class AgentPopulation {
private readonly queuedAgentBatches: Array<Float32Array> = []; private readonly queuedAgentBatches: Array<Float32Array> = [];
private pendingStrokeAgentCount = 0; private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array( private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT STROKE_AGENT_BATCH_CAPACITY * AGENT_FLOAT_COUNT
); );
public constructor( public constructor(
@ -46,10 +50,7 @@ export class AgentPopulation {
public replaceIntroAgents(canvasSize: vec2, progress: number): void { public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min( const introAgentCount = Math.min(this.adaptiveCap, INITIAL_INTRO_AGENT_COUNT);
this.adaptiveCap,
appConfig.simulation.initialAgentCount
);
const data = createIntroTitleAgents({ const data = createIntroTitleAgents({
count: introAgentCount, count: introAgentCount,
width: canvasSize[0], width: canvasSize[0],
@ -332,8 +333,5 @@ const getStrokeSpawnRate = (): number => {
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel) const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
? settings.spawnPerPixel ? settings.spawnPerPixel
: 0; : 0;
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier) return Math.max(0, spawnPerPixel * STROKE_DENSITY_MULTIPLIER);
? appConfig.simulation.stroke.densityMultiplier
: 0;
return Math.max(0, spawnPerPixel * densityMultiplier);
}; };

View file

@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { defaultSettings } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size'; import { getRenderQualityBrushSize } from '../config/brush-size';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings'; import { settings } from '../settings';
@ -143,13 +143,13 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => { const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution) const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution ? settings.brushCurveResolution
: appConfig.defaultSettings.brushCurveResolution; : defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution)); return Math.max(1, Math.floor(resolution));
}; };
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => { const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance) const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance ? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance; : defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2; return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
}; };

View file

@ -1,7 +1,13 @@
import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes'; import type { VibeId } from '../vibes';
const SNAPSHOT_BYTES_PER_PIXEL = 4;
const SNAPSHOT_FILENAME_EXTENSION = 'png';
const SNAPSHOT_FILENAME_PREFIX = 'fleeting-garden';
const SNAPSHOT_FILENAME_SUFFIX = '-snapshot';
const SNAPSHOT_MIME_TYPE = 'image/png';
const SNAPSHOT_ROW_ALIGNMENT_BYTES = 256;
interface ExportSnapshotRendererOptions { interface ExportSnapshotRendererOptions {
device: GPUDevice; device: GPUDevice;
renderPipeline: RenderPipeline; renderPipeline: RenderPipeline;
@ -121,15 +127,15 @@ export class ExportSnapshotRenderer {
context.putImageData(new ImageData(pixels, width, height), 0, 0); context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({ const blob = await canvas.convertToBlob({
type: appConfig.exportSnapshot.mimeType, type: SNAPSHOT_MIME_TYPE,
}); });
const link = document.createElement('a'); const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
try { try {
link.href = objectUrl; link.href = objectUrl;
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${ link.download = `${SNAPSHOT_FILENAME_PREFIX}_${this.options.getVibeId()}_${
this.options.seed this.options.seed
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`; }_${width}x${height}${SNAPSHOT_FILENAME_SUFFIX}.${SNAPSHOT_FILENAME_EXTENSION}`;
link.click(); link.click();
} finally { } finally {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
@ -154,11 +160,8 @@ const getSnapshotDimension = (value: number): number =>
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => { const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth); const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight); const height = getSnapshotDimension(sourceHeight);
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel; const unpaddedBytesPerRow = width * SNAPSHOT_BYTES_PER_PIXEL;
const bytesPerRow = alignTo( const bytesPerRow = alignTo(unpaddedBytesPerRow, SNAPSHOT_ROW_ALIGNMENT_BYTES);
unpaddedBytesPerRow,
appConfig.exportSnapshot.rowAlignmentBytes
);
return { return {
width, width,
@ -191,8 +194,8 @@ const readSnapshotPixels = ({
const sourceOffset = y * bytesPerRow; const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow; const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel; const source = sourceOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel; const target = targetOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source]; pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1]; pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2]; pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];

View file

@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig, type GardenRuntimeSettings } from '../config'; import { type GardenRuntimeSettings } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
@ -12,9 +12,12 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { initializeContext } from '../utils/graphics/initialize-context'; import { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler'; import { GpuProfiler } from './gpu-profiler';
import { perfStatsOverlayState } from './perf-stats-overlay';
import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures'; import { SimulationTextures } from './simulation-textures';
const INTRO_MOVE_SPEED = 280;
interface FrameParameters extends RenderInputs { interface FrameParameters extends RenderInputs {
time: number; time: number;
deltaTime: number; deltaTime: number;
@ -78,7 +81,7 @@ export class GameLoopResources {
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat); this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
this.gpuProfiler = GpuProfiler.create( this.gpuProfiler = GpuProfiler.create(
this.device, this.device,
() => appConfig.tuningPane.showFpsOverlay () => perfStatsOverlayState.isVisible
); );
this.frameRenderer = new SimulationFrameRenderer( this.frameRenderer = new SimulationFrameRenderer(
@ -134,7 +137,7 @@ export class GameLoopResources {
deltaTime, deltaTime,
time, time,
agentCount: activeAgentCount, agentCount: activeAgentCount,
introMoveSpeed: appConfig.simulation.introMoveSpeed, introMoveSpeed: INTRO_MOVE_SPEED,
introProgress, introProgress,
}); });
this.brushPipeline.setParameters({ this.brushPipeline.setParameters({

View file

@ -1,7 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio'; import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config'; import { createGardenAudioConfig } from '../audio/garden-audio-config';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@ -13,14 +13,19 @@ import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types'; import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size'; import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt'; import { IntroPrompt } from './intro-prompt';
import { PerfStatsOverlay } from './perf-stats-overlay'; import { PerfStatsOverlay, perfStatsOverlayState } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input'; import { GardenPointerInput } from './pointer-input';
import { MAX_MIRROR_SEGMENT_COUNT, MIN_MIRROR_SEGMENT_COUNT } from './stroke-mirroring';
import { PipelineStrokeOutput } from './stroke-output'; import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
const INTRO_RESIZE_SETTLE_MS = 120;
const INTRO_RESIZE_MINIMUM_REMAINING_SECONDS = 1.4;
const GARDEN_AUDIO_CONFIG = createGardenAudioConfig();
export default class GameLoop { export default class GameLoop {
private readonly resources: GameLoopResources; private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(appConfig.audio); private readonly audio = new GardenAudio(GARDEN_AUDIO_CONFIG);
private readonly introPrompt: IntroPrompt; private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview; private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput; private readonly pointerInput: GardenPointerInput;
@ -250,7 +255,7 @@ export default class GameLoop {
}; };
private syncPerfStatsOverlay(): void { private syncPerfStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) { if (perfStatsOverlayState.isVisible) {
this.perfStatsOverlay ??= new PerfStatsOverlay( this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body this.canvas.parentElement ?? document.body
); );
@ -323,13 +328,11 @@ export default class GameLoop {
return; return;
} }
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) { if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
return; return;
} }
this.introPrompt.rewindToLeaveRemainingTime( this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
appConfig.simulation.intro.resizeMinimumRemainingSeconds
);
this.resources.clearSimulation(); this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress); this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null; this.pendingIntroResizeAt = null;
@ -351,10 +354,10 @@ export default class GameLoop {
private get mirrorSegmentCount(): number { private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount) const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount ? settings.mirrorSegmentCount
: appConfig.toolbar.mirror.min; : MIN_MIRROR_SEGMENT_COUNT;
return Math.min( return Math.min(
appConfig.toolbar.mirror.max, MAX_MIRROR_SEGMENT_COUNT,
Math.max(appConfig.toolbar.mirror.min, Math.round(count)) Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(count))
); );
} }

View file

@ -1,6 +1,6 @@
import { appConfig } from '../config';
const DRAW_HINT_CLASS = 'draw-hint'; const DRAW_HINT_CLASS = 'draw-hint';
const INTRO_DURATION_SECONDS = 4;
const DRAW_HINT_DELAY_MS = 3000;
export class IntroPrompt { export class IntroPrompt {
private introComplete = false; private introComplete = false;
@ -13,10 +13,7 @@ export class IntroPrompt {
public get progress(): number { public get progress(): number {
return this.introComplete return this.introComplete
? 1 ? 1
: Math.min( : Math.min(1, this.introElapsedSeconds / INTRO_DURATION_SECONDS);
1,
this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
);
} }
public get shouldRegenerateTitleOnResize(): boolean { public get shouldRegenerateTitleOnResize(): boolean {
@ -33,7 +30,7 @@ export class IntroPrompt {
: 0; : 0;
this.introElapsedSeconds = Math.min( this.introElapsedSeconds = Math.min(
this.introElapsedSeconds, this.introElapsedSeconds,
Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds) Math.max(0, INTRO_DURATION_SECONDS - safeRemainingSeconds)
); );
} }
@ -45,10 +42,7 @@ export class IntroPrompt {
this.introElapsedSeconds += safeDeltaTime; this.introElapsedSeconds += safeDeltaTime;
} }
if ( if (!this.introComplete && this.introElapsedSeconds >= INTRO_DURATION_SECONDS) {
!this.introComplete &&
this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
) {
this.complete(now); this.complete(now);
} }
@ -56,7 +50,7 @@ export class IntroPrompt {
!this.introComplete || !this.introComplete ||
this.hasStartedDrawing || this.hasStartedDrawing ||
this.introCompletedAt === null || this.introCompletedAt === null ||
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs now - this.introCompletedAt < DRAW_HINT_DELAY_MS
) { ) {
return; return;
} }

View file

@ -1,4 +1,3 @@
import { appConfig, type GardenAppConfig } from '../config';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math'; import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
@ -18,9 +17,43 @@ interface IntroTitleAgentOptions {
} }
type RandomSource = () => number; type RandomSource = () => number;
type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing']; type IntroPathEasing = 'easeOutQuad' | 'linear';
const INTRO_TITLE = 'Fleeting';
const INTRO_ANGLE_JITTER_RADIANS = Math.PI * 0.08;
const INTRO_ANGLE_EASE_START = 0.6;
const INTRO_ANGLE_EASE_END = 1;
const INTRO_CIRCLE_MIN_SIDE_RATIO = 0.32;
const INTRO_CIRCLE_MAX_SIDE_RATIO = 0.46;
const INTRO_ENTRY_JITTER_SIDE_RATIO = 0.035;
const INTRO_FONT_FAMILY = '"Open Sans", sans-serif';
const INTRO_FONT_SCALE_DOWN = 0.94;
const INTRO_INITIAL_FONT_HEIGHT_RATIO = 0.28;
const INTRO_INITIAL_FONT_WIDTH_RATIO = 0.19;
const INTRO_LETTER_SPACING_EM = 0.07;
const INTRO_MASK_ALPHA_THRESHOLD = 32;
const INTRO_MASK_GRADIENT_THRESHOLD = 8;
const INTRO_MASK_MAX_PIXELS = 1_000_000;
const INTRO_MASK_SAMPLE_DENSITY = 540;
const INTRO_MAX_HEIGHT_RATIO = 0.25;
const INTRO_MAX_WIDTH_RATIO = 0.76;
const INTRO_MIN_ENTRY_JITTER_PX = 6;
const INTRO_MIN_FONT_SIZE_PX = 18;
const INTRO_MIN_TARGET_JITTER_PX = 1;
const INTRO_PATH_EASING: IntroPathEasing = 'easeOutQuad';
const INTRO_PATH_PROGRESS_EPSILON = 0.001;
const INTRO_RADIAL_JITTER_RATIO = 0.35;
const INTRO_RADIAL_START_EPSILON = 0.001;
const INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER = 0.12;
const INTRO_TARGET_DELAY_MAX = 0.22;
const INTRO_TARGET_DELAY_RANDOM_MULTIPLIER = 0.06;
const INTRO_TARGET_JITTER_SIDE_RATIO = 0.0035;
const INTRO_TITLE_COLOR_CUT_LETTERS = [2, 5] as const;
const INTRO_TITLE_RADIUS_MULTIPLIER = 1.55;
const INTRO_TITLE_STROKE_WIDTH_MIN_PX = 6;
const INTRO_TITLE_STROKE_WIDTH_RATIO = 0.11;
const INTRO_VERTICAL_ANCHOR = 0.47;
const INTRO_TITLE = appConfig.simulation.intro.title;
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean => const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
pathEasing === 'linear'; pathEasing === 'linear';
@ -47,30 +80,27 @@ export const createIntroTitleAgents = ({
const data = new Float32Array(count * AGENT_FLOAT_COUNT); const data = new Float32Array(count * AGENT_FLOAT_COUNT);
const minSide = Math.min(safeWidth, safeHeight); const minSide = Math.min(safeWidth, safeHeight);
const targetJitter = Math.max( const targetJitter = Math.max(
appConfig.simulation.intro.minTargetJitterPx, INTRO_MIN_TARGET_JITTER_PX,
minSide * appConfig.simulation.intro.targetJitterSideRatio minSide * INTRO_TARGET_JITTER_SIDE_RATIO
); );
const entryJitter = Math.max( const entryJitter = Math.max(
appConfig.simulation.intro.minEntryJitterPx, INTRO_MIN_ENTRY_JITTER_PX,
minSide * appConfig.simulation.intro.entryJitterSideRatio minSide * INTRO_ENTRY_JITTER_SIDE_RATIO
); );
const titleRadius = points.reduce( const titleRadius = points.reduce(
(radius, point) => (radius, point) =>
Math.max( Math.max(
radius, radius,
Math.hypot( Math.hypot(point.x - safeWidth / 2, point.y - safeHeight * INTRO_VERTICAL_ANCHOR)
point.x - safeWidth / 2,
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
)
), ),
0 0
); );
const introCircleRadius = Math.min( const introCircleRadius = Math.min(
Math.max( Math.max(
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier, titleRadius * INTRO_TITLE_RADIUS_MULTIPLIER,
minSide * appConfig.simulation.intro.circleMinSideRatio minSide * INTRO_CIRCLE_MIN_SIDE_RATIO
), ),
minSide * appConfig.simulation.intro.circleMaxSideRatio minSide * INTRO_CIRCLE_MAX_SIDE_RATIO
); );
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@ -101,21 +131,16 @@ export const createIntroTitleAgents = ({
const distanceFraction = const distanceFraction =
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight); Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
const introDelay = Math.min( const introDelay = Math.min(
appConfig.simulation.intro.targetDelayMax, INTRO_TARGET_DELAY_MAX,
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier + distanceFraction * INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER +
random() * appConfig.simulation.intro.targetDelayRandomMultiplier random() * INTRO_TARGET_DELAY_RANDOM_MULTIPLIER
); );
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay); const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
const initialAngle = const initialAngle = approachAngle + (random() - 0.5) * INTRO_ANGLE_JITTER_RADIANS;
approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
const currentAngle = mixAngle( const currentAngle = mixAngle(
initialAngle, initialAngle,
targetAngle, targetAngle,
smoothstep( smoothstep(INTRO_ANGLE_EASE_START, INTRO_ANGLE_EASE_END, pathProgress)
appConfig.simulation.intro.angleEaseStart,
appConfig.simulation.intro.angleEaseEnd,
pathProgress
)
); );
writeAgentValues(data, i, { writeAgentValues(data, i, {
positionX: mix(startX, targetX, pathProgress), positionX: mix(startX, targetX, pathProgress),
@ -142,12 +167,12 @@ const getIntroRadialStart = (
random: RandomSource random: RandomSource
): [number, number] => { ): [number, number] => {
const centerX = width / 2; const centerX = width / 2;
const centerY = height * appConfig.simulation.intro.verticalAnchor; const centerY = height * INTRO_VERTICAL_ANCHOR;
const offsetX = targetX - centerX; const offsetX = targetX - centerX;
const offsetY = targetY - centerY; const offsetY = targetY - centerY;
const length = Math.hypot(offsetX, offsetY); const length = Math.hypot(offsetX, offsetY);
const angle = const angle =
length > appConfig.simulation.intro.radialStartEpsilon length > INTRO_RADIAL_START_EPSILON
? Math.atan2(offsetY, offsetX) ? Math.atan2(offsetY, offsetX)
: random() * Math.PI * 2; : random() * Math.PI * 2;
const directionX = Math.cos(angle); const directionX = Math.cos(angle);
@ -155,8 +180,7 @@ const getIntroRadialStart = (
const tangentX = -directionY; const tangentX = -directionY;
const tangentY = directionX; const tangentY = directionX;
const tangentJitter = (random() - 0.5) * jitter; const tangentJitter = (random() - 0.5) * jitter;
const radialJitter = const radialJitter = (random() - 0.5) * jitter * INTRO_RADIAL_JITTER_RATIO;
(random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
const startX = const startX =
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter; centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
const startY = const startY =
@ -172,7 +196,7 @@ const createIntroTitlePoints = (
width: number, width: number,
height: number height: number
): Array<IntroTitlePoint> => { ): Array<IntroTitlePoint> => {
const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels); const safeMaxPixels = Math.max(1, INTRO_MASK_MAX_PIXELS);
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height))); const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
const maskWidth = Math.max(1, Math.round(width * maskScale)); const maskWidth = Math.max(1, Math.round(width * maskScale));
const maskHeight = Math.max(1, Math.round(height * maskScale)); const maskHeight = Math.max(1, Math.round(height * maskScale));
@ -188,28 +212,28 @@ const createIntroTitlePoints = (
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight); const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
context.clearRect(0, 0, maskWidth, maskHeight); context.clearRect(0, 0, maskWidth, maskHeight);
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`; context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
context.textAlign = 'center'; context.textAlign = 'center';
context.textBaseline = 'middle'; context.textBaseline = 'middle';
context.fillStyle = '#fff'; context.fillStyle = '#fff';
context.strokeStyle = '#fff'; context.strokeStyle = '#fff';
context.lineJoin = 'round'; context.lineJoin = 'round';
context.lineWidth = Math.max( context.lineWidth = Math.max(
appConfig.simulation.intro.titleStrokeWidthMinPx, INTRO_TITLE_STROKE_WIDTH_MIN_PX,
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio fontSize * INTRO_TITLE_STROKE_WIDTH_RATIO
); );
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm; const letterSpacing = fontSize * INTRO_LETTER_SPACING_EM;
drawIntroTitleText( drawIntroTitleText(
context, context,
maskWidth / 2, maskWidth / 2,
maskHeight * appConfig.simulation.intro.verticalAnchor, maskHeight * INTRO_VERTICAL_ANCHOR,
letterSpacing, letterSpacing,
'stroke' 'stroke'
); );
drawIntroTitleText( drawIntroTitleText(
context, context,
maskWidth / 2, maskWidth / 2,
maskHeight * appConfig.simulation.intro.verticalAnchor, maskHeight * INTRO_VERTICAL_ANCHOR,
letterSpacing, letterSpacing,
'fill' 'fill'
); );
@ -217,9 +241,7 @@ const createIntroTitlePoints = (
const { data } = context.getImageData(0, 0, maskWidth, maskHeight); const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
const step = Math.max( const step = Math.max(
1, 1,
Math.floor( Math.floor(Math.min(maskWidth, maskHeight) / INTRO_MASK_SAMPLE_DENSITY)
Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
)
); );
const points: Array<IntroTitlePoint> = []; const points: Array<IntroTitlePoint> = [];
const characterColorBoundaries = getIntroTitleColorBoundaries( const characterColorBoundaries = getIntroTitleColorBoundaries(
@ -231,7 +253,7 @@ const createIntroTitlePoints = (
for (let y = 0; y < maskHeight; y += step) { for (let y = 0; y < maskHeight; y += step) {
for (let x = 0; x < maskWidth; x += step) { for (let x = 0; x < maskWidth; x += step) {
const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y); const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) { if (alpha < INTRO_MASK_ALPHA_THRESHOLD) {
continue; continue;
} }
@ -255,9 +277,9 @@ const getIntroTitleColorBoundaries = (
const letters = Array.from(INTRO_TITLE); const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing); const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = width / 2 - totalWidth / 2; let x = width / 2 - totalWidth / 2;
const cutLetters = appConfig.simulation.intro.titleColorCutLetters const cutLetters = INTRO_TITLE_COLOR_CUT_LETTERS.map((cutLetter) =>
.map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter)))) Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter)))
.sort((a, b) => a - b); ).sort((a, b) => a - b);
const [firstCutLetter, secondCutLetter] = cutLetters; const [firstCutLetter, secondCutLetter] = cutLetters;
const letterBoxes = letters.map((letter, index) => { const letterBoxes = letters.map((letter, index) => {
const letterWidth = context.measureText(letter).width; const letterWidth = context.measureText(letter).width;
@ -330,17 +352,17 @@ const getIntroTitleFontSize = (
width: number, width: number,
height: number height: number
): number => { ): number => {
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio; const maxWidth = width * INTRO_MAX_WIDTH_RATIO;
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio; const maxHeight = height * INTRO_MAX_HEIGHT_RATIO;
let fontSize = Math.floor( let fontSize = Math.floor(
Math.min( Math.min(
height * appConfig.simulation.intro.initialFontHeightRatio, height * INTRO_INITIAL_FONT_HEIGHT_RATIO,
width * appConfig.simulation.intro.initialFontWidthRatio width * INTRO_INITIAL_FONT_WIDTH_RATIO
) )
); );
while (fontSize > appConfig.simulation.intro.minFontSizePx) { while (fontSize > INTRO_MIN_FONT_SIZE_PX) {
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`; context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
const metrics = context.measureText(INTRO_TITLE); const metrics = context.measureText(INTRO_TITLE);
const measuredHeight = const measuredHeight =
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize; metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
@ -349,7 +371,7 @@ const getIntroTitleFontSize = (
return fontSize; return fontSize;
} }
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown); fontSize = Math.floor(fontSize * INTRO_FONT_SCALE_DOWN);
} }
return fontSize; return fontSize;
@ -369,10 +391,7 @@ const estimateMaskTangent = (
getMaskAlpha(data, width, height, x, y + 1) - getMaskAlpha(data, width, height, x, y + 1) -
getMaskAlpha(data, width, height, x, y - 1); getMaskAlpha(data, width, height, x, y - 1);
if ( if (Math.abs(gradientX) + Math.abs(gradientY) < INTRO_MASK_GRADIENT_THRESHOLD) {
Math.abs(gradientX) + Math.abs(gradientY) <
appConfig.simulation.intro.maskGradientThreshold
) {
return null; return null;
} }
@ -397,8 +416,7 @@ const getIntroAgentPathProgress = (introProgress: number, introDelay: number): n
} }
const activeProgress = const activeProgress =
(introProgress - introDelay) / (introProgress - introDelay) / Math.max(INTRO_PATH_PROGRESS_EPSILON, 1 - introDelay);
Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
return easePathProgress(clamp(activeProgress, 0, 1)); return easePathProgress(clamp(activeProgress, 0, 1));
}; };
@ -414,7 +432,7 @@ const createSeededRandom = (seed: number): RandomSource => {
}; };
const easePathProgress = (amount: number): number => { const easePathProgress = (amount: number): number => {
if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) { if (isLinearPathEasing(INTRO_PATH_EASING)) {
return amount; return amount;
} }

View file

@ -4,6 +4,10 @@ const ZERO_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms'; const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0'; const ZERO_RESOLUTION_TEXT = '0x0';
export const perfStatsOverlayState = {
isVisible: import.meta.env.DEV,
};
interface PerfStatsSnapshot { interface PerfStatsSnapshot {
time: DOMHighResTimeStamp; time: DOMHighResTimeStamp;
fps: number; fps: number;

View file

@ -1,9 +1,9 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio'; import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { activeVibe } from '../settings'; import { activeVibe } from '../settings';
import { MIN_DELTA_TIME_SECONDS } from '../utils/delta-time-calculator';
import { BrushStrokeSmoother } from './brush-stroke-smoother'; import { BrushStrokeSmoother } from './brush-stroke-smoother';
import { type StrokeSegment } from './game-loop-types'; import { type StrokeSegment } from './game-loop-types';
import { getMirroredStrokeSegments } from './stroke-mirroring'; import { getMirroredStrokeSegments } from './stroke-mirroring';
@ -155,7 +155,7 @@ export class GardenPointerInput {
const previousPosition = this.lastPointerPosition ?? position; const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
const elapsedSeconds = Math.max( const elapsedSeconds = Math.max(
appConfig.deltaTime.minDeltaTimeSeconds, MIN_DELTA_TIME_SECONDS,
(event.timeStamp - previousTimeMs) / 1000 (event.timeStamp - previousTimeMs) / 1000
); );

View file

@ -1,4 +1,3 @@
import { appConfig } from '../config';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
@ -10,6 +9,11 @@ import { CanvasReadbackRequest } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler'; import { GpuProfiler } from './gpu-profiler';
import { SimulationTextures } from './simulation-textures'; import { SimulationTextures } from './simulation-textures';
const BRUSH_EFFECT_FRAMES_PER_SECOND = 60;
// How long the source map continues to be diffused after a brush stroke ends.
// 600 frames at ~60 FPS is roughly 10 seconds.
const SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
interface SimulationFramePipelines { interface SimulationFramePipelines {
agentPipeline: AgentPipeline; agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline; brushPipeline: BrushPipeline;
@ -135,10 +139,9 @@ export class SimulationFrameRenderer {
} }
const getSourceActiveFrameCount = (): number => { const getSourceActiveFrameCount = (): number => {
const frameCount = const frameCount = settings.brushEffectDuration * BRUSH_EFFECT_FRAMES_PER_SECOND;
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
if (Number.isFinite(frameCount) && frameCount > 0) { if (Number.isFinite(frameCount) && frameCount > 0) {
return Math.ceil(frameCount); return Math.ceil(frameCount);
} }
return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite); return Math.max(1, SOURCE_ACTIVE_FRAMES_AFTER_WRITE);
}; };

View file

@ -1,12 +1,13 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats'; import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
import { import {
ResizableTexture, ResizableTexture,
type PendingTextureResize, type PendingTextureResize,
} from '../utils/graphics/resizable-texture'; } from '../utils/graphics/resizable-texture';
const SIMULATION_CLEAR_COLOR = { r: 0, g: 0, b: 0, a: 0 };
export class SimulationTextures { export class SimulationTextures {
// trailMapA holds the current trail (read by agent and diffuse). trailMapB // trailMapA holds the current trail (read by agent and diffuse). trailMapB
// receives the diffuse output; the two swap each frame so the freshly // receives the diffuse output; the two swap each frame so the freshly
@ -83,7 +84,7 @@ export class SimulationTextures {
colorAttachments: [ colorAttachments: [
{ {
view: texture.getTextureView(), view: texture.getTextureView(),
clearValue: appConfig.simulation.clearColor, clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear', loadOp: 'clear',
storeOp: 'store', storeOp: 'store',
}, },
@ -122,7 +123,7 @@ export class SimulationTextures {
colorAttachments: [ colorAttachments: [
{ {
view: this.sourceMapA.getTextureView(), view: this.sourceMapA.getTextureView(),
clearValue: appConfig.simulation.clearColor, clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear', loadOp: 'clear',
storeOp: 'store', storeOp: 'store',
}, },

View file

@ -2,6 +2,9 @@ import { vec2 } from 'gl-matrix';
import { type StrokeSegment } from './game-loop-types'; import { type StrokeSegment } from './game-loop-types';
export const MIN_MIRROR_SEGMENT_COUNT = 1;
export const MAX_MIRROR_SEGMENT_COUNT = 12;
export const getMirroredStrokeSegments = ( export const getMirroredStrokeSegments = (
from: vec2, from: vec2,
to: vec2, to: vec2,

View file

@ -1,4 +1,3 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/math'; import { clamp01 } from '../utils/math';
import type { CanvasReadbackRequest } from './game-loop-types'; import type { CanvasReadbackRequest } from './game-loop-types';
@ -24,21 +23,41 @@ interface ToolbarContrastMetrics {
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity'; const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength'; const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
const BACKGROUND_OPACITY_MAX = 0.82;
const BRIGHT_LUMINANCE_THRESHOLD = 0.32;
const BRIGHT_WEIGHT = 0.65;
const BYTES_PER_SAMPLE = 4;
const CONTRAST_OFFSET = 0.05;
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256; const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
const LINEAR_CHANNEL_BREAKPOINT = 0.03928;
const LINEAR_CHANNEL_DIVISOR = 12.92;
const LINEAR_CHANNEL_GAMMA = 2.4;
const LINEAR_CHANNEL_OFFSET = 0.055;
const LINEAR_CHANNEL_SCALE = 1.055;
const LOW_CONTRAST_THRESHOLD = 3;
const LOW_CONTRAST_WEIGHT = 1.8;
const LUMINANCE_BASE = 0.11;
const LUMINANCE_BLUE_WEIGHT = 0.0722;
const LUMINANCE_GREEN_WEIGHT = 0.7152;
const LUMINANCE_RANGE = 0.28;
const LUMINANCE_RED_WEIGHT = 0.2126;
const SAMPLE_COLUMNS = 13;
const SAMPLE_INTERVAL_MS = 300;
const SAMPLE_ROWS = 7;
const WHITE_CONTRAST_NUMERATOR = 1.05;
const getLinearChannel = (channel: number): number => { const getLinearChannel = (channel: number): number => {
const normalized = channel / 255; const normalized = channel / 255;
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint return normalized <= LINEAR_CHANNEL_BREAKPOINT
? normalized / appConfig.toolbar.contrast.linearChannelDivisor ? normalized / LINEAR_CHANNEL_DIVISOR
: ((normalized + appConfig.toolbar.contrast.linearChannelOffset) / : ((normalized + LINEAR_CHANNEL_OFFSET) / LINEAR_CHANNEL_SCALE) **
appConfig.toolbar.contrast.linearChannelScale) ** LINEAR_CHANNEL_GAMMA;
appConfig.toolbar.contrast.linearChannelGamma;
}; };
const getRelativeLuminance = (red: number, green: number, blue: number): number => const getRelativeLuminance = (red: number, green: number, blue: number): number =>
appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) + LUMINANCE_RED_WEIGHT * getLinearChannel(red) +
appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) + LUMINANCE_GREEN_WEIGHT * getLinearChannel(green) +
appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue); LUMINANCE_BLUE_WEIGHT * getLinearChannel(blue);
const getToolbarContrastMetrics = ( const getToolbarContrastMetrics = (
pixels: Uint8Array, pixels: Uint8Array,
@ -46,8 +65,7 @@ const getToolbarContrastMetrics = (
isBgra: boolean isBgra: boolean
): ToolbarContrastMetrics => { ): ToolbarContrastMetrics => {
const count = sampleOffsets.filter( const count = sampleOffsets.filter(
(offset) => (offset) => offset >= 0 && offset + BYTES_PER_SAMPLE <= pixels.length
offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
).length; ).length;
if (count === 0) { if (count === 0) {
return { return {
@ -63,10 +81,7 @@ const getToolbarContrastMetrics = (
let lowContrastCount = 0; let lowContrastCount = 0;
sampleOffsets.forEach((offset) => { sampleOffsets.forEach((offset) => {
if ( if (offset < 0 || offset + BYTES_PER_SAMPLE > pixels.length) {
offset < 0 ||
offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
) {
return; return;
} }
@ -74,15 +89,13 @@ const getToolbarContrastMetrics = (
const green = pixels[offset + 1]; const green = pixels[offset + 1];
const blue = pixels[offset + (isBgra ? 0 : 2)]; const blue = pixels[offset + (isBgra ? 0 : 2)];
const luminance = getRelativeLuminance(red, green, blue); const luminance = getRelativeLuminance(red, green, blue);
const contrastWithWhite = const contrastWithWhite = WHITE_CONTRAST_NUMERATOR / (luminance + CONTRAST_OFFSET);
appConfig.toolbar.contrast.whiteContrastNumerator /
(luminance + appConfig.toolbar.contrast.contrastOffset);
luminanceTotal += luminance; luminanceTotal += luminance;
if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) { if (luminance > BRIGHT_LUMINANCE_THRESHOLD) {
brightCount++; brightCount++;
} }
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) { if (contrastWithWhite < LOW_CONTRAST_THRESHOLD) {
lowContrastCount++; lowContrastCount++;
} }
}); });
@ -91,13 +104,11 @@ const getToolbarContrastMetrics = (
const brightRatio = brightCount / count; const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count; const lowContrastRatio = lowContrastCount / count;
const backgroundStrength = clamp01( const backgroundStrength = clamp01(
Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) / Math.max(0, averageLuminance - LUMINANCE_BASE) / LUMINANCE_RANGE +
appConfig.toolbar.contrast.luminanceRange + brightRatio * BRIGHT_WEIGHT +
brightRatio * appConfig.toolbar.contrast.brightWeight + lowContrastRatio * LOW_CONTRAST_WEIGHT
lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
); );
const backgroundOpacity = const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
return { return {
averageLuminance, averageLuminance,
@ -128,7 +139,7 @@ export class ToolbarContrastMonitor {
if ( if (
this.isDestroyed || this.isDestroyed ||
this.isReadbackPending || this.isReadbackPending ||
time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs time - this.lastSampleAt < SAMPLE_INTERVAL_MS
) { ) {
return null; return null;
} }
@ -211,12 +222,12 @@ export class ToolbarContrastMonitor {
private setToolbarBackgroundOpacity(backgroundOpacity: number): void { private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
const safeBackgroundOpacity = Math.min( const safeBackgroundOpacity = Math.min(
appConfig.toolbar.contrast.backgroundOpacityMax, BACKGROUND_OPACITY_MAX,
Math.max(0, backgroundOpacity) Math.max(0, backgroundOpacity)
); );
const backgroundStrength = const backgroundStrength =
appConfig.toolbar.contrast.backgroundOpacityMax > 0 BACKGROUND_OPACITY_MAX > 0
? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax) ? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
: 0; : 0;
this.toolbar.style.setProperty( this.toolbar.style.setProperty(
@ -279,22 +290,20 @@ export class ToolbarContrastMonitor {
} }
const bytesPerRow = alignTo( const bytesPerRow = alignTo(
width * appConfig.toolbar.contrast.bytesPerSample, width * BYTES_PER_SAMPLE,
GPU_COPY_BYTES_PER_ROW_ALIGNMENT GPU_COPY_BYTES_PER_ROW_ALIGNMENT
); );
const points = new Map<string, CanvasSamplePoint>(); const points = new Map<string, CanvasSamplePoint>();
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) { for (let row = 0; row < SAMPLE_ROWS; row++) {
const cssY = const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * cssHeight;
top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
const y = Math.min( const y = Math.min(
this.canvas.height - 1, this.canvas.height - 1,
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale)) Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
); );
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) { for (let column = 0; column < SAMPLE_COLUMNS; column++) {
const cssX = const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * cssWidth;
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
const x = Math.min( const x = Math.min(
this.canvas.width - 1, this.canvas.width - 1,
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale)) Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
@ -309,8 +318,7 @@ export class ToolbarContrastMonitor {
origin, origin,
sampleOffsets: [...points.values()].map( sampleOffsets: [...points.values()].map(
(point) => (point) =>
(point.y - origin.y) * bytesPerRow + (point.y - origin.y) * bytesPerRow + (point.x - origin.x) * BYTES_PER_SAMPLE
(point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
), ),
width, width,
}; };

View file

@ -71,6 +71,10 @@ const main = async () => {
HTMLButtonElement HTMLButtonElement
); );
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement); const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
const infoCloseButton = queryRequiredElement(
'[data-control="info-close"]',
HTMLButtonElement
);
const infoElement = queryRequiredElement('.info-page', HTMLElement); const infoElement = queryRequiredElement('.info-page', HTMLElement);
const fullScreenButton = queryRequiredElement( const fullScreenButton = queryRequiredElement(
'[data-control="full-screen"]', '[data-control="full-screen"]',
@ -113,6 +117,7 @@ const main = async () => {
}; };
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside); const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
infoCloseButton.addEventListener('click', () => infoPageHandler.close());
new MenuHider( new MenuHider(
aside, aside,
() => () =>

View file

@ -1,19 +1,24 @@
import { appConfig } from '../config'; import { DEFAULT_AUDIO_VOLUME } from '../audio/garden-audio-config';
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math'; import { clamp01 } from '../utils/math';
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
const AUDIO_VOLUME_MIN = 0;
const AUDIO_VOLUME_MAX = 1;
const AUDIO_VOLUME_STEP = 0.01;
const clampAudioVolume = (value: number): number => { const clampAudioVolume = (value: number): number => {
const { default: defaultVolume, max, min } = appConfig.toolbar.volume; const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
const safeValue = Number.isFinite(value) ? value : defaultVolume; return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
return Math.min(max, Math.max(min, clamp01(safeValue)));
}; };
const readInitialAudioVolume = (): number => { const readInitialAudioVolume = (): number => {
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey); const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
return storedVolume === null return storedVolume === null
? appConfig.toolbar.volume.default ? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume)); : clampAudioVolume(Number(storedVolume));
}; };
@ -45,7 +50,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume(); private audioVolume = readInitialAudioVolume();
private isMutedState = private isMutedState =
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE || readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
this.audioVolume <= 0; this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) { public constructor(private readonly options: AudioControlOptions) {
@ -85,9 +90,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel); this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel; this.soundButton.title = muteLabel;
this.volumeSlider.min = appConfig.toolbar.volume.min.toString(); this.volumeSlider.min = AUDIO_VOLUME_MIN.toString();
this.volumeSlider.max = appConfig.toolbar.volume.max.toString(); this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
this.volumeSlider.step = appConfig.toolbar.volume.step.toString(); this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume); this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute( this.volumeSlider.setAttribute(
'aria-valuetext', 'aria-valuetext',
@ -107,7 +112,7 @@ export class AudioControl {
private readonly onToggleMute = () => { private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0; const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) { if (shouldUnmute && this.audioVolume <= 0) {
this.audioVolume = appConfig.toolbar.volume.default; this.audioVolume = DEFAULT_AUDIO_VOLUME;
} }
this.isMutedState = !shouldUnmute; this.isMutedState = !shouldUnmute;
this.persist(); this.persist();
@ -141,11 +146,11 @@ export class AudioControl {
private persist(): void { private persist(): void {
writeBrowserStorage( writeBrowserStorage(
appConfig.storage.audioMutedKey, AUDIO_MUTED_STORAGE_KEY,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
); );
writeBrowserStorage( writeBrowserStorage(
appConfig.storage.audioVolumeKey, AUDIO_VOLUME_STORAGE_KEY,
formatStoredAudioVolume(this.audioVolume) formatStoredAudioVolume(this.audioVolume)
); );
} }

View file

@ -1,6 +1,6 @@
import type { FolderApi } from '@tweakpane/core'; import type { FolderApi } from '@tweakpane/core';
import { appConfig, normalizeNumberControlValue } from '../config'; import { normalizeNumberControlValue, runtimeControls } from '../config';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color'; import { rgbColorToCss } from '../utils/rgb-color';
@ -128,7 +128,7 @@ export class ColorReactionMatrixControl {
const cell = document.createElement('div'); const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell'; cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key]; const config = runtimeControls[key];
if (!config) { if (!config) {
return cell; return cell;
} }
@ -165,7 +165,7 @@ export class ColorReactionMatrixControl {
sourceColorIndex: number, sourceColorIndex: number,
targetColorIndex: number targetColorIndex: number
): void { ): void {
const config = appConfig.runtimeSettings.controls[key]; const config = runtimeControls[key];
if (!config) { if (!config) {
return; return;
} }

View file

@ -3,11 +3,12 @@ import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import { import {
appConfig,
normalizeNumberControlValue, normalizeNumberControlValue,
runtimeControls,
type GardenRuntimeSettings, type GardenRuntimeSettings,
type NumberControlConfig, type NumberControlConfig,
} from '../config'; } from '../config';
import { perfStatsOverlayState } from '../game-loop/perf-stats-overlay';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color'; import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
import { ColorReactionMatrixControl } from './color-reaction-matrix-control'; import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
@ -29,6 +30,8 @@ interface PaneState extends GardenAudioVibeSettings {
} }
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const; const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
const CONFIG_PANE_TITLE = 'Garden Settings';
const CONFIG_PANE_START_HIDDEN = true;
const MUSIC_CONTROLS: ReadonlyArray<{ const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey; key: VibeNumberKey;
@ -56,7 +59,7 @@ interface ConfigPaneOptions {
const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> => const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
( (
Object.entries(appConfig.runtimeSettings.controls) as Array< Object.entries(runtimeControls) as Array<
[RuntimeControlKey, NumberControlConfig | undefined] [RuntimeControlKey, NumberControlConfig | undefined]
> >
) )
@ -123,10 +126,10 @@ export class ConfigPane {
this.pane = new Pane({ this.pane = new Pane({
container: this.container, container: this.container,
title: appConfig.tuningPane.title, title: CONFIG_PANE_TITLE,
expanded: true, expanded: true,
}); });
this.pane.hidden = appConfig.tuningPane.startHidden; this.pane.hidden = CONFIG_PANE_START_HIDDEN;
this.pane.element.classList.add('config-pane'); this.pane.element.classList.add('config-pane');
this.pane.element.id = 'config-pane'; this.pane.element.id = 'config-pane';
@ -310,7 +313,7 @@ export class ConfigPane {
private getRuntimeControlConfig( private getRuntimeControlConfig(
key: RuntimeControlKey key: RuntimeControlKey
): NumberControlConfig | undefined { ): NumberControlConfig | undefined {
const config = appConfig.runtimeSettings.controls[key]; const config = runtimeControls[key];
if (!config || key !== 'maxAgentCount') { if (!config || key !== 'maxAgentCount') {
return config; return config;
} }
@ -323,7 +326,7 @@ export class ConfigPane {
private addFpsOverlayBinding(container: PaneContainer): void { private addFpsOverlayBinding(container: PaneContainer): void {
container container
.addBinding(appConfig.tuningPane, 'showFpsOverlay', { .addBinding(perfStatsOverlayState, 'isVisible', {
label: 'Show FPS', label: 'Show FPS',
}) })
.on('change', () => this.options.onConfigChange()); .on('change', () => this.options.onConfigChange());

View file

@ -1,26 +1,26 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { import {
ERASER_SIZE_MAX,
ERASER_SIZE_MIN,
getEraserSizeFromSliderRatio, getEraserSizeFromSliderRatio,
getEraserSliderRatioFromSize, getEraserSliderRatioFromSize,
} from './eraser-size-control'; } from './eraser-size-control';
describe('eraser size slider mapping', () => { describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => { it('maps slider position quadratically to eraser size', () => {
const { max, min } = appConfig.toolbar.eraser; expect(getEraserSizeFromSliderRatio(0)).toBe(ERASER_SIZE_MIN);
expect(getEraserSizeFromSliderRatio(0.5)).toBe(
expect(getEraserSizeFromSliderRatio(0)).toBe(min); ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25
expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25); );
expect(getEraserSizeFromSliderRatio(1)).toBe(max); expect(getEraserSizeFromSliderRatio(1)).toBe(ERASER_SIZE_MAX);
}); });
it('maps eraser size back to the inverse slider position', () => { it('maps eraser size back to the inverse slider position', () => {
const { max, min } = appConfig.toolbar.eraser; const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
const quarterRangeSize = min + (max - min) * 0.25;
expect(getEraserSliderRatioFromSize(min)).toBe(0); expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5); expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
expect(getEraserSliderRatioFromSize(max)).toBe(1); expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
}); });
}); });

View file

@ -1,12 +1,16 @@
import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { settings } from '../settings'; import { DEFAULT_ERASER_SIZE, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
export const ERASER_SIZE_MIN = 24;
export const ERASER_SIZE_MAX = 480;
const ERASER_CONTROL_SCALE_MIN = 0.74;
const ERASER_CONTROL_SCALE_MAX = 1.34;
const clampEraserSize = (value: number): number => { const clampEraserSize = (value: number): number => {
const { default: defaultSize, max, min } = appConfig.toolbar.eraser; const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
const safeValue = Number.isFinite(value) ? value : defaultSize; return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
return Math.min(max, Math.max(min, Math.round(safeValue)));
}; };
const ERASER_SLIDER_MIN = 0; const ERASER_SLIDER_MIN = 0;
@ -19,13 +23,14 @@ const clampSliderRatio = (value: number): number => {
}; };
const getEraserSizeRatio = (size: number): number => { const getEraserSizeRatio = (size: number): number => {
const { max, min } = appConfig.toolbar.eraser; return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
return (clampEraserSize(size) - min) / (max - min);
}; };
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => { export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
const { max, min } = appConfig.toolbar.eraser; return clampEraserSize(
return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2); ERASER_SIZE_MIN +
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
);
}; };
export const getEraserSliderRatioFromSize = (size: number): number => export const getEraserSliderRatioFromSize = (size: number): number =>
@ -72,10 +77,8 @@ export class EraserSizeControl {
const sizeRatio = getEraserSizeRatio(size); const sizeRatio = getEraserSizeRatio(size);
const scale = const scale =
appConfig.toolbar.eraser.controlScaleMin + ERASER_CONTROL_SCALE_MIN +
(appConfig.toolbar.eraser.controlScaleMax - (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
appConfig.toolbar.eraser.controlScaleMin) *
sizeRatio;
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`); this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState(); this.syncActiveState();

View file

@ -1,9 +1,10 @@
import { appConfig } from '../config'; const DESKTOP_AUTO_HIDE_MEDIA_QUERY =
'(min-width: 600px) and (hover: hover) and (pointer: fine)';
const HIDE_DELAY_MS = 3000;
const BOTTOM_REVEAL_DISTANCE_PX = 96;
export class MenuHider { export class MenuHider {
private readonly desktopMediaQuery = window.matchMedia( private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
appConfig.menuHider.desktopMediaQuery
);
private hideTimeout: number | undefined; private hideTimeout: number | undefined;
private isHidden = false; private isHidden = false;
private pointerInside = false; private pointerInside = false;
@ -95,7 +96,7 @@ export class MenuHider {
if (this.canAutoHide) { if (this.canAutoHide) {
this.hide(); this.hide();
} }
}, appConfig.menuHider.hideDelayMs); }, HIDE_DELAY_MS);
} }
private reveal(): void { private reveal(): void {
@ -134,6 +135,6 @@ export class MenuHider {
private isNearViewportBottom(clientY: number): boolean { private isNearViewportBottom(clientY: number): boolean {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx; return clientY >= viewportHeight - BOTTOM_REVEAL_DISTANCE_PX;
} }
} }

View file

@ -1,25 +1,48 @@
import { appConfig } from '../config'; import {
import { settings } from '../settings'; MAX_MIRROR_SEGMENT_COUNT,
MIN_MIRROR_SEGMENT_COUNT,
} from '../game-loop/stroke-mirroring';
import { DEFAULT_MIRROR_SEGMENT_COUNT, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_FALLBACK_NAME = 'slices';
const MIRROR_SEGMENT_NAMES = {
2: 'halves',
3: 'thirds',
4: 'quarters',
5: 'fifths',
6: 'sixths',
7: 'sevenths',
8: 'eighths',
9: 'ninths',
10: 'tenths',
11: 'elevenths',
12: 'twelfths',
} satisfies Record<number, string>;
const clampMirrorSegmentCount = (value: number): number => { const clampMirrorSegmentCount = (value: number): number => {
const { default: defaultCount, max, min } = appConfig.toolbar.mirror; const safeValue = Number.isFinite(value) ? value : DEFAULT_MIRROR_SEGMENT_COUNT;
const safeValue = Number.isFinite(value) ? value : defaultCount; return Math.min(
return Math.min(max, Math.max(min, Math.round(safeValue))); MAX_MIRROR_SEGMENT_COUNT,
Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(safeValue))
);
}; };
const getMirrorSegmentRatio = (count: number): number => { const getMirrorSegmentRatio = (count: number): number => {
const { max, min } = appConfig.toolbar.mirror; return (
return (count - min) / (max - min); (count - MIN_MIRROR_SEGMENT_COUNT) /
(MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
);
}; };
const formatMirrorSegmentCount = (count: number): string => const formatMirrorSegmentCount = (count: number): string =>
count <= 1 count <= 1
? appConfig.toolbar.mirror.offLabel ? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${ : `${count} ${
appConfig.toolbar.mirror.names[ MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
count as keyof typeof appConfig.toolbar.mirror.names MIRROR_SEGMENT_FALLBACK_NAME
] ?? appConfig.toolbar.mirror.fallbackSegmentName
}`; }`;
interface MirrorSegmentControlOptions { interface MirrorSegmentControlOptions {
@ -50,9 +73,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count; settings.mirrorSegmentCount = count;
} }
this.slider.min = appConfig.toolbar.mirror.min.toString(); this.slider.min = MIN_MIRROR_SEGMENT_COUNT.toString();
this.slider.max = appConfig.toolbar.mirror.max.toString(); this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
this.slider.step = appConfig.toolbar.mirror.step.toString(); this.slider.step = MIRROR_SEGMENT_STEP.toString();
this.slider.value = count.toString(); this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count); const label = formatMirrorSegmentCount(count);

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size'; import { getRenderQualityBrushSize } from '../../config/brush-size';
import { import {
createCachedBufferWrite, createCachedBufferWrite,
@ -40,6 +39,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
: 1; : 1;
const UNIFORM_COUNT = 16; const UNIFORM_COUNT = 16;
const MAX_BRUSH_LINE_COUNT = 240;
const setBrushUniformValues = ( const setBrushUniformValues = (
target: Float32Array, target: Float32Array,
@ -93,7 +93,7 @@ export class BrushPipeline {
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState private readonly commonState: CommonState
) { ) {
this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount); this.segments = new LineSegmentBuffer(device, MAX_BRUSH_LINE_COUNT);
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [

View file

@ -1,11 +1,10 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { import {
createCachedBufferWrite, createCachedBufferWrite,
writeBufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { generateNoise } from '../../utils/graphics/noise'; import { generateNoise, NOISE_TEXTURE_SIZE } from '../../utils/graphics/noise';
export class CommonState { export class CommonState {
private static readonly UNIFORM_COUNT = 4; private static readonly UNIFORM_COUNT = 4;
@ -39,8 +38,8 @@ export class CommonState {
const noise = generateNoise({ const noise = generateNoise({
device, device,
width: appConfig.pipelines.common.noiseTextureSize, width: NOISE_TEXTURE_SIZE,
height: appConfig.pipelines.common.noiseTextureSize, height: NOISE_TEXTURE_SIZE,
}); });
this.noise = noise.texture; this.noise = noise.texture;

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedBufferWrite, createCachedBufferWrite,
@ -29,12 +28,13 @@ type DiffusionUniformSettings = Pick<
| 'brushDecayAlphaOffset' | 'brushDecayAlphaOffset'
>; >;
const MIN_DIFFUSION_RATE = 0.000001;
const getSafeInverseDiffusionRate = (diffusionRate: number): number => const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 / 1 /
(Number.isFinite(diffusionRate) && (Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
? diffusionRate ? diffusionRate
: appConfig.pipelines.diffusion.minDiffusionRate); : MIN_DIFFUSION_RATE);
const setDiffusionUniformValues = ( const setDiffusionUniformValues = (
target: Float32Array, target: Float32Array,

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { import {
createCachedBufferWrite, createCachedBufferWrite,
writeBufferIfChanged, writeBufferIfChanged,
@ -29,6 +28,7 @@ interface EraserTextureParameters {
} }
const UNIFORM_COUNT = 8; const UNIFORM_COUNT = 8;
const MAX_ERASER_TEXTURE_LINE_COUNT = 384;
const TARGET_FORMATS: Array<GPUTextureFormat> = [ const TARGET_FORMATS: Array<GPUTextureFormat> = [
ERASER_MASK_TEXTURE_FORMAT, ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT, TRAIL_SOURCE_TEXTURE_FORMAT,
@ -50,10 +50,7 @@ export class EraserTexturePipeline {
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState private readonly commonState: CommonState
) { ) {
this.segments = new LineSegmentBuffer( this.segments = new LineSegmentBuffer(device, MAX_ERASER_TEXTURE_LINE_COUNT);
device,
appConfig.pipelines.eraser.maxTextureLineCount
);
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [

View file

@ -1,10 +1,14 @@
import { import {
appConfig, defaultSettings,
normalizeRuntimeSettings, normalizeRuntimeSettings,
runtimeControls,
type GardenRuntimeSettings, type GardenRuntimeSettings,
} from './config'; } from './config';
import { writeBrowserStorage } from './utils/browser-storage'; import { writeBrowserStorage } from './utils/browser-storage';
import { getInitialVibe, type VibePreset } from './vibes'; import { getInitialVibe, VIBE_STORAGE_KEY, type VibePreset } from './vibes';
export const DEFAULT_ERASER_SIZE = 96;
export const DEFAULT_MIRROR_SEGMENT_COUNT = 8;
const preservedRuntimeSettingKeys = [ const preservedRuntimeSettingKeys = [
'eraserSize', 'eraserSize',
@ -37,12 +41,12 @@ const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
normalizeRuntimeSettings( normalizeRuntimeSettings(
{ {
...appConfig.defaultSettings, ...defaultSettings,
eraserSize: appConfig.toolbar.eraser.default, eraserSize: DEFAULT_ERASER_SIZE,
mirrorSegmentCount: appConfig.toolbar.mirror.default, mirrorSegmentCount: DEFAULT_MIRROR_SEGMENT_COUNT,
...vibe.settings, ...vibe.settings,
}, },
appConfig.runtimeSettings.controls runtimeControls
); );
export let activeVibe = cloneVibePreset(getInitialVibe()); export let activeVibe = cloneVibePreset(getInitialVibe());
@ -52,7 +56,7 @@ export const settings: GardenRuntimeSettings = {
}; };
export const rememberActiveVibeSelection = (): void => { export const rememberActiveVibeSelection = (): void => {
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id); writeBrowserStorage(VIBE_STORAGE_KEY, activeVibe.id);
}; };
export const applyVibeSettings = (vibe: VibePreset) => { export const applyVibeSettings = (vibe: VibePreset) => {
@ -66,10 +70,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe.colors.length - 1 activeVibe.colors.length - 1
); );
Object.assign( Object.assign(settings, normalizeRuntimeSettings(nextSettings, runtimeControls));
settings,
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
);
rememberActiveVibeSelection(); rememberActiveVibeSelection();

View file

@ -1,24 +1,28 @@
@use 'mixins' as *; @use 'mixins' as *;
html > body > aside.control-dock > .info-page { html > body > aside.control-dock > .info-page {
width: min(calc(100vw - 1rem), 560px); width: min(100%, 520px);
max-height: min(58vh, 520px); max-height: min(62vh, 480px);
max-height: min(58dvh, 520px); max-height: min(62dvh, 480px);
margin: 0 auto 10px; margin: 0 auto 10px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
border: 1px solid rgb(255 255 255 / 78%); overscroll-behavior: contain;
touch-action: pan-y;
border: 1px solid rgb(255 255 255 / 46%);
border-radius: 8px; border-radius: 8px;
background: background:
linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)), linear-gradient(180deg, rgb(252 255 249 / 94%), rgb(234 241 232 / 91%)),
rgb(255 255 255); rgb(249 252 247 / 92%);
color: rgb(24 30 27); color: rgb(18 28 24);
box-shadow: box-shadow:
0 20px 54px rgb(0 0 0 / 38%), inset 0 1px 0 rgb(255 255 255 / 58%),
0 2px 12px rgb(0 0 0 / 22%); 0 16px 42px rgb(0 0 0 / 30%),
backdrop-filter: blur(12px); 0 2px 10px rgb(0 0 0 / 18%);
backdrop-filter: blur(16px) saturate(118%);
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--main-color) transparent; scrollbar-color: rgb(69 98 88 / 62%) transparent;
-webkit-overflow-scrolling: touch;
transition: transition:
max-height var(--transition-time-long), max-height var(--transition-time-long),
opacity var(--transition-time-long), opacity var(--transition-time-long),
@ -32,47 +36,181 @@ html > body > aside.control-dock > .info-page {
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: var(--main-color); background-color: rgb(69 98 88 / 62%);
border-radius: 8px; border-radius: 8px;
} }
&:focus-visible { &:focus-visible {
outline: 2px solid white; outline: 2px solid rgb(17 56 45);
outline-offset: 3px; outline-offset: 3px;
box-shadow:
0 0 0 5px rgb(255 255 255 / 68%),
inset 0 1px 0 rgb(255 255 255 / 58%),
0 16px 42px rgb(0 0 0 / 30%),
0 2px 10px rgb(0 0 0 / 18%);
} }
> section { .info-page__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.85rem; gap: 0.85rem;
padding: var(--normal-margin); padding: 18px 20px 16px;
h1 {
margin-bottom: 0;
color: rgb(16 24 20);
font-size: 2rem;
line-height: 1.1;
} }
p { .info-page__header {
max-width: 54ch; display: flex;
align-items: center;
gap: 0.7rem;
padding-bottom: 0.1rem;
}
.info-page__heading {
min-width: 0;
}
.info-page__mark {
display: grid;
flex: 0 0 auto;
place-items: center;
width: 1.8rem;
height: 1.8rem;
border: 1px solid rgb(42 74 65 / 24%);
border-radius: 8px;
background:
linear-gradient(180deg, rgb(255 255 255 / 74%), rgb(217 231 222 / 70%)),
rgb(232 240 235);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 76%),
0 5px 14px rgb(21 44 39 / 12%);
&::before {
content: '';
width: 1rem;
height: 1rem;
background: rgb(19 57 48);
mask: url('../../assets/icons/info.svg') center / contain no-repeat;
}
}
.info-page__close {
position: relative;
flex: 0 0 auto;
width: 2rem;
height: 2rem;
margin-left: auto;
border: 1px solid rgb(34 57 50 / 18%);
border-radius: 8px;
background: rgb(255 255 255 / 36%);
cursor: pointer;
transition:
background-color var(--transition-time),
border-color var(--transition-time),
transform var(--transition-time);
&::before {
content: '';
position: absolute;
inset: 0;
width: 0.9rem;
height: 0.9rem;
margin: auto;
background: rgb(28 45 39);
mask: url('../../assets/icons/close.svg') center / contain no-repeat;
}
&:hover {
border-color: rgb(34 57 50 / 30%);
background: rgb(255 255 255 / 64%);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid rgb(17 56 45);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 72%);
}
}
.info-page__eyebrow {
margin-bottom: 0.12rem;
color: rgb(73 91 85);
font-size: 0.68rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0;
text-transform: uppercase;
}
h2 {
overflow-wrap: break-word;
margin-bottom: 0;
color: rgb(9 21 17);
font-size: 1.12rem;
font-weight: 700;
line-height: 1.18;
}
.info-page__lede,
.info-page__notes,
.info-page__meta {
max-width: 56ch;
overflow-wrap: break-word;
color: rgb(25 35 32);
font-size: 0.95rem;
line-height: 1.56;
}
.info-page__lede,
.info-page__meta {
margin-bottom: 0; margin-bottom: 0;
color: rgb(42 48 45);
font-size: 1.1rem;
line-height: 1.65;
hyphens: auto; hyphens: auto;
} }
.info-page__notes {
display: grid;
gap: 0.45rem;
margin: 0.1rem 0;
padding-left: 1.1rem;
list-style: disc;
li {
padding-left: 0.1rem;
}
li::marker {
color: rgb(25 108 82);
font-size: 0.85em;
}
}
.info-page__meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: baseline;
margin-top: 0.2rem;
padding-top: 0.75rem;
border-top: 1px solid rgb(43 66 57 / 16%);
color: rgb(67 82 77);
font-size: 0.8rem;
line-height: 1.45;
}
a { a {
color: rgb(0 84 120); color: rgb(0 83 105);
font-weight: 400; font-weight: 700;
text-decoration-color: rgb(0 83 105 / 34%);
text-underline-offset: 0.18em;
&:hover {
text-decoration-color: currentColor;
}
&:focus-visible { &:focus-visible {
outline: 2px solid currentColor; outline: 2px solid currentColor;
outline-offset: 3px; outline-offset: 3px;
} }
} }
}
&.hidden { &.hidden {
max-height: 0; max-height: 0;
@ -81,16 +219,51 @@ html > body > aside.control-dock > .info-page {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
box-shadow: none; box-shadow: none;
transform: translateY(8px); transform: translateY(6px) scale(0.985);
visibility: hidden; visibility: hidden;
} }
@include on-small-screen { @include on-small-screen {
max-height: min(54vh, 500px); width: min(100%, 520px);
max-height: min(54dvh, 500px); max-height: min(58vh, 500px);
max-height: min(58dvh, 500px);
> section { .info-page__content {
padding: var(--small-margin); gap: 0.75rem;
padding: 14px;
}
.info-page__lede,
.info-page__notes {
font-size: 0.95rem;
line-height: 1.52;
}
.info-page__meta {
font-size: 0.875rem;
line-height: 1.45;
} }
} }
@media (max-height: 420px) {
max-height: min(
58vh,
max(
10rem,
calc(
100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
)
)
);
max-height: min(
58dvh,
max(
10rem,
calc(
100dvh -
168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
)
)
);
}
} }

View file

@ -1,6 +1,9 @@
import { appConfig } from '../config';
import { clamp } from './math'; import { clamp } from './math';
export const MIN_DELTA_TIME_SECONDS = 1 / 240;
const MAX_DELTA_TIME_SECONDS = 1 / 30;
export class DeltaTimeCalculator { export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null; private previousTime: DOMHighResTimeStamp | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange(); private readonly visibilityChangeListener = () => this.handleVisibilityChange();
@ -16,11 +19,7 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime; const delta = currentTime - this.previousTime;
this.previousTime = currentTime; this.previousTime = currentTime;
return clamp( return clamp(delta / 1000, MIN_DELTA_TIME_SECONDS, MAX_DELTA_TIME_SECONDS);
delta / 1000,
appConfig.deltaTime.minDeltaTimeSeconds,
appConfig.deltaTime.maxDeltaTimeSeconds
);
} }
private handleVisibilityChange() { private handleVisibilityChange() {

View file

@ -1,7 +1,17 @@
import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad'; import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile'; import { smartCompile } from './smart-compile';
export const NOISE_TEXTURE_SIZE = 2048;
const NOISE_CHANNEL_SEEDS = [0, 1, 2, 3] as const;
const NOISE_CLEAR_VALUE = { r: 1, g: 1, b: 1, a: 1 };
const NOISE_DRAW_INSTANCE_COUNT = 1;
const NOISE_DRAW_VERTEX_COUNT = 3;
const NOISE_HASH_MULTIPLIER = 43758.5453123;
const NOISE_HASH_X = 12.9898;
const NOISE_HASH_Y = 78.233;
const NOISE_TEXTURE_FORMAT = 'r8unorm';
export interface GeneratedNoiseTexture { export interface GeneratedNoiseTexture {
texture: GPUTexture; texture: GPUTexture;
view: GPUTextureView; view: GPUTextureView;
@ -29,16 +39,16 @@ export const generateNoise = ({
return fract(sin(dot( return fract(sin(dot(
uv, uv,
vec2( vec2(
${appConfig.pipelines.common.noiseHashX} + seed, ${NOISE_HASH_X} + seed,
${appConfig.pipelines.common.noiseHashY} + seed ${NOISE_HASH_Y} + seed
) )
)) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed); )) * ${NOISE_HASH_MULTIPLIER} + seed);
} }
@fragment @fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> { fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4( return vec4(
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}), random_with_seed(uv, ${NOISE_CHANNEL_SEEDS[0]}),
0.0, 0.0,
0.0, 0.0,
1.0, 1.0,
@ -48,7 +58,7 @@ export const generateNoise = ({
entryPoint: 'fragment', entryPoint: 'fragment',
targets: [ targets: [
{ {
format: appConfig.pipelines.common.noiseTextureFormat, format: NOISE_TEXTURE_FORMAT,
}, },
], ],
}, },
@ -63,7 +73,7 @@ export const generateNoise = ({
height, height,
depthOrArrayLayers: 1, depthOrArrayLayers: 1,
}, },
format: appConfig.pipelines.common.noiseTextureFormat, format: NOISE_TEXTURE_FORMAT,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
}); });
@ -71,7 +81,7 @@ export const generateNoise = ({
colorAttachments: [ colorAttachments: [
{ {
view: colorTexture.createView(), view: colorTexture.createView(),
clearValue: appConfig.pipelines.common.noiseClearValue, clearValue: NOISE_CLEAR_VALUE,
loadOp: 'clear', loadOp: 'clear',
storeOp: 'store', storeOp: 'store',
}, },
@ -82,10 +92,7 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline); passEncoder.setPipeline(pipeline);
passEncoder.draw( passEncoder.draw(NOISE_DRAW_VERTEX_COUNT, NOISE_DRAW_INSTANCE_COUNT);
appConfig.pipelines.common.noiseDrawVertexCount,
appConfig.pipelines.common.noiseDrawInstanceCount
);
passEncoder.end(); passEncoder.end();
device.queue.submit([commandEncoder.finish()]); device.queue.submit([commandEncoder.finish()]);

View file

@ -1,7 +1,7 @@
import { appConfig } from './config'; import { vibePresets } from './config';
import type { VibeId, VibePreset } from './config/types'; import type { VibeId, VibePreset } from './config/types';
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets; export const VIBE_PRESETS: Array<VibePreset> = vibePresets;
export const getVibeById = (vibeId: VibeId): VibePreset | undefined => export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
VIBE_PRESETS.find((vibe) => vibe.id === vibeId); VIBE_PRESETS.find((vibe) => vibe.id === vibeId);

View file

@ -1,4 +1,4 @@
import { appConfig } from './config'; import { defaultVibeId } from './config';
import { VibeId, type VibePreset } from './config/types'; import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage'; import { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry'; import { getVibeById, VIBE_PRESETS } from './vibe-registry';
@ -8,6 +8,8 @@ export { VibeId };
export { getVibeById, VIBE_PRESETS }; export { getVibeById, VIBE_PRESETS };
export type { VibePreset }; export type { VibePreset };
export const VIBE_STORAGE_KEY = 'fleeting-garden:vibe';
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id)); const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
const isVibeId = (value: unknown): value is VibeId => const isVibeId = (value: unknown): value is VibeId =>
@ -15,12 +17,11 @@ const isVibeId = (value: unknown): value is VibeId =>
export const getInitialVibe = (): VibePreset => { export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId(); const uriVibeId = getCurrentUriVibeId();
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey); const storedVibeId = readBrowserStorage(VIBE_STORAGE_KEY);
const storedOrLegacyVibeId = isVibeId(storedVibeId) const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId ? storedVibeId
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`); : getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
const initialVibeId = const initialVibeId = uriVibeId ?? storedOrLegacyVibeId ?? defaultVibeId;
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0]; return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
}; };