Compare commits

...

3 commits

Author SHA1 Message Date
0fddad6b45 Clean up configs
All checks were successful
Check & deploy / build (pull_request) Successful in 7m18s
Check & deploy / build (push) Successful in 7m6s
2026-05-25 09:27:39 +01:00
79638d5fa4 Update description 2026-05-24 21:00:03 +01:00
f863588060 Replace og-image 2026-05-24 20:48:03 +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="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/" />
@ -22,7 +22,7 @@
<meta property="og:locale" content="en_US" />
<meta
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:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
@ -35,7 +35,7 @@
<meta name="twitter:title" content="Fleeting Garden" />
<meta
name="twitter:description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
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:alt" content="Fleeting Garden social preview image." />
@ -46,7 +46,7 @@
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"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",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
@ -118,35 +118,47 @@
id="info-panel"
class="hidden info-page"
role="region"
aria-label="About panel"
aria-labelledby="info-panel-title"
aria-hidden="true"
tabindex="-1"
inert
>
<section>
<h1>Fleeting Garden</h1>
<p>
<div class="info-page__content">
<header class="info-page__header">
<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.
Both happen here at once. Your strokes plant colour, small agents follow them,
branch off, and slowly rewrite the patch you laid down into something you
didn't quite plan.
</p>
<p>
Three swatches plant the line. The eraser carves a clearing. The mirror folds
one gesture into many, like footpaths around a hidden well.
</p>
<p>
Switch vibes to change the season; your shapes stay, the light moves. Add or
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
want to keep one particular instant of weather.
</p>
<p>
<ul class="info-page__notes">
<li>Three swatches plant the line; the eraser carves a clearing.</li>
<li>The mirror folds one gesture into many.</li>
<li>The arrows change the season.</li>
</ul>
<p class="info-page__meta">
Built with WebGPU, running locally in your browser. More of my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a
>.
</p>
</section>
</div>
</section>
<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,
} from '@plausible-analytics/tracker';
import { appConfig } from './config';
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;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
@ -24,10 +28,10 @@ export const initAnalytics = () => {
try {
plausibleInit({
domain: appConfig.analytics.domain,
endpoint: appConfig.analytics.endpoint,
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
logging: appConfig.analytics.logging,
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: ANALYTICS_LOGGING,
});
isInitialized = true;
} catch (error) {

View file

@ -1,199 +1,9 @@
import {
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 { defaultSettings } from './config/default-settings';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
export { runtimeControls } from './config/runtime-controls';
export { defaultVibeId, vibePresets } from './config/vibe-presets';
export type {
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;
export type { GardenRuntimeSettings, NumberControlConfig } from './config/types';

View file

@ -1,5 +1,5 @@
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
// 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,
introNearDistanceMin: 28,

View file

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

View file

@ -1,6 +1,6 @@
import { colorInteractionControl } from './color-interactions';
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 formatRadiansAsDegrees = (value: number): string =>
@ -16,7 +16,7 @@ const formatCompactNumber = (value: number): string => {
return `${value}`;
};
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
export const runtimeControls: RuntimeSettingControlConfig = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),

View file

@ -1,7 +1,4 @@
import type {
GardenAudioConfig,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
@ -49,7 +46,7 @@ export type GardenRuntimeSettings = {
DiffusionSettings &
RenderSettings;
type RuntimeSettingControlConfig = Partial<
export type RuntimeSettingControlConfig = Partial<
Record<keyof GardenRuntimeSettings, NumberControlConfig>
>;
@ -79,7 +76,7 @@ export type GardenVibeSettings = Pick<
| 'turnWhenLost'
>;
type GardenDefaultSettings = Omit<
export type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
@ -101,169 +98,3 @@ export interface VibePreset {
settings: GardenVibeSettings;
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 { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
import {
AgentPopulation,
STROKE_AGENT_BATCH_CAPACITY,
STROKE_DENSITY_MULTIPLIER,
} from './agent-population';
import { type FramePerformance } from './frame-performance';
const originalSettings = {
@ -70,7 +73,7 @@ const createPopulation = (): {
};
const setSpawnRate = (agentsPerPixel: number): void => {
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
settings.spawnPerPixel = agentsPerPixel / STROKE_DENSITY_MULTIPLIER;
};
describe('AgentPopulation stroke spawning', () => {
@ -124,7 +127,7 @@ describe('AgentPopulation stroke spawning', () => {
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation();
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
const batchCapacity = STROKE_AGENT_BATCH_CAPACITY;
const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents(

View file

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

View file

@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { defaultSettings } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
@ -143,13 +143,13 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.defaultSettings.brushCurveResolution;
: defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
: defaultSettings.brushSmoothingMinSampleDistance;
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 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 {
device: GPUDevice;
renderPipeline: RenderPipeline;
@ -121,15 +127,15 @@ export class ExportSnapshotRenderer {
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({
type: appConfig.exportSnapshot.mimeType,
type: SNAPSHOT_MIME_TYPE,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
link.download = `${SNAPSHOT_FILENAME_PREFIX}_${this.options.getVibeId()}_${
this.options.seed
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
}_${width}x${height}${SNAPSHOT_FILENAME_SUFFIX}.${SNAPSHOT_FILENAME_EXTENSION}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
@ -154,11 +160,8 @@ const getSnapshotDimension = (value: number): number =>
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight);
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
const bytesPerRow = alignTo(
unpaddedBytesPerRow,
appConfig.exportSnapshot.rowAlignmentBytes
);
const unpaddedBytesPerRow = width * SNAPSHOT_BYTES_PER_PIXEL;
const bytesPerRow = alignTo(unpaddedBytesPerRow, SNAPSHOT_ROW_ALIGNMENT_BYTES);
return {
width,
@ -191,8 +194,8 @@ const readSnapshotPixels = ({
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
const source = sourceOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
const target = targetOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];

View file

@ -1,6 +1,6 @@
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 { AgentPipeline } from '../pipelines/agents/agent-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 { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { perfStatsOverlayState } from './perf-stats-overlay';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
const INTRO_MOVE_SPEED = 280;
interface FrameParameters extends RenderInputs {
time: number;
deltaTime: number;
@ -78,7 +81,7 @@ export class GameLoopResources {
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
this.gpuProfiler = GpuProfiler.create(
this.device,
() => appConfig.tuningPane.showFpsOverlay
() => perfStatsOverlayState.isVisible
);
this.frameRenderer = new SimulationFrameRenderer(
@ -134,7 +137,7 @@ export class GameLoopResources {
deltaTime,
time,
agentCount: activeAgentCount,
introMoveSpeed: appConfig.simulation.introMoveSpeed,
introMoveSpeed: INTRO_MOVE_SPEED,
introProgress,
});
this.brushPipeline.setParameters({

View file

@ -1,7 +1,7 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
import { createGardenAudioConfig } from '../audio/garden-audio-config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
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 { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
import { PerfStatsOverlay } from './perf-stats-overlay';
import { PerfStatsOverlay, perfStatsOverlayState } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input';
import { MAX_MIRROR_SEGMENT_COUNT, MIN_MIRROR_SEGMENT_COUNT } from './stroke-mirroring';
import { PipelineStrokeOutput } from './stroke-output';
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 {
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 eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
@ -250,7 +255,7 @@ export default class GameLoop {
};
private syncPerfStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) {
if (perfStatsOverlayState.isVisible) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
@ -323,13 +328,11 @@ export default class GameLoop {
return;
}
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
return;
}
this.introPrompt.rewindToLeaveRemainingTime(
appConfig.simulation.intro.resizeMinimumRemainingSeconds
);
this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
@ -351,10 +354,10 @@ export default class GameLoop {
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
: appConfig.toolbar.mirror.min;
: MIN_MIRROR_SEGMENT_COUNT;
return Math.min(
appConfig.toolbar.mirror.max,
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
MAX_MIRROR_SEGMENT_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 INTRO_DURATION_SECONDS = 4;
const DRAW_HINT_DELAY_MS = 3000;
export class IntroPrompt {
private introComplete = false;
@ -13,10 +13,7 @@ export class IntroPrompt {
public get progress(): number {
return this.introComplete
? 1
: Math.min(
1,
this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
);
: Math.min(1, this.introElapsedSeconds / INTRO_DURATION_SECONDS);
}
public get shouldRegenerateTitleOnResize(): boolean {
@ -33,7 +30,7 @@ export class IntroPrompt {
: 0;
this.introElapsedSeconds = Math.min(
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;
}
if (
!this.introComplete &&
this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
) {
if (!this.introComplete && this.introElapsedSeconds >= INTRO_DURATION_SECONDS) {
this.complete(now);
}
@ -56,7 +50,7 @@ export class IntroPrompt {
!this.introComplete ||
this.hasStartedDrawing ||
this.introCompletedAt === null ||
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
now - this.introCompletedAt < DRAW_HINT_DELAY_MS
) {
return;
}

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { appConfig } from '../config';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
@ -10,6 +9,11 @@ import { CanvasReadbackRequest } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
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 {
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;
@ -135,10 +139,9 @@ export class SimulationFrameRenderer {
}
const getSourceActiveFrameCount = (): number => {
const frameCount =
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
const frameCount = settings.brushEffectDuration * BRUSH_EFFECT_FRAMES_PER_SECOND;
if (Number.isFinite(frameCount) && frameCount > 0) {
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 { appConfig } from '../config';
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
import {
ResizableTexture,
type PendingTextureResize,
} from '../utils/graphics/resizable-texture';
const SIMULATION_CLEAR_COLOR = { r: 0, g: 0, b: 0, a: 0 };
export class SimulationTextures {
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
// receives the diffuse output; the two swap each frame so the freshly
@ -83,7 +84,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: texture.getTextureView(),
clearValue: appConfig.simulation.clearColor,
clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear',
storeOp: 'store',
},
@ -122,7 +123,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: this.sourceMapA.getTextureView(),
clearValue: appConfig.simulation.clearColor,
clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear',
storeOp: 'store',
},

View file

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

View file

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

View file

@ -71,6 +71,10 @@ const main = async () => {
HTMLButtonElement
);
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
const infoCloseButton = queryRequiredElement(
'[data-control="info-close"]',
HTMLButtonElement
);
const infoElement = queryRequiredElement('.info-page', HTMLElement);
const fullScreenButton = queryRequiredElement(
'[data-control="full-screen"]',
@ -113,6 +117,7 @@ const main = async () => {
};
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
infoCloseButton.addEventListener('click', () => infoPageHandler.close());
new MenuHider(
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 { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
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 { default: defaultVolume, max, min } = appConfig.toolbar.volume;
const safeValue = Number.isFinite(value) ? value : defaultVolume;
return Math.min(max, Math.max(min, clamp01(safeValue)));
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
};
const readInitialAudioVolume = (): number => {
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
return storedVolume === null
? appConfig.toolbar.volume.default
? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume));
};
@ -45,7 +50,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume();
private isMutedState =
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
@ -85,9 +90,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel;
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
this.volumeSlider.min = AUDIO_VOLUME_MIN.toString();
this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute(
'aria-valuetext',
@ -107,7 +112,7 @@ export class AudioControl {
private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) {
this.audioVolume = appConfig.toolbar.volume.default;
this.audioVolume = DEFAULT_AUDIO_VOLUME;
}
this.isMutedState = !shouldUnmute;
this.persist();
@ -141,11 +146,11 @@ export class AudioControl {
private persist(): void {
writeBrowserStorage(
appConfig.storage.audioMutedKey,
AUDIO_MUTED_STORAGE_KEY,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
appConfig.storage.audioVolumeKey,
AUDIO_VOLUME_STORAGE_KEY,
formatStoredAudioVolume(this.audioVolume)
);
}

View file

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

View file

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

View file

@ -1,26 +1,26 @@
import { describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import {
ERASER_SIZE_MAX,
ERASER_SIZE_MIN,
getEraserSizeFromSliderRatio,
getEraserSliderRatioFromSize,
} from './eraser-size-control';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
const { max, min } = appConfig.toolbar.eraser;
expect(getEraserSizeFromSliderRatio(0)).toBe(min);
expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
expect(getEraserSizeFromSliderRatio(1)).toBe(max);
expect(getEraserSizeFromSliderRatio(0)).toBe(ERASER_SIZE_MIN);
expect(getEraserSizeFromSliderRatio(0.5)).toBe(
ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25
);
expect(getEraserSizeFromSliderRatio(1)).toBe(ERASER_SIZE_MAX);
});
it('maps eraser size back to the inverse slider position', () => {
const { max, min } = appConfig.toolbar.eraser;
const quarterRangeSize = min + (max - min) * 0.25;
const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
expect(getEraserSliderRatioFromSize(min)).toBe(0);
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
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 { settings } from '../settings';
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
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 { default: defaultSize, max, min } = appConfig.toolbar.eraser;
const safeValue = Number.isFinite(value) ? value : defaultSize;
return Math.min(max, Math.max(min, Math.round(safeValue)));
const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
const ERASER_SLIDER_MIN = 0;
@ -19,13 +23,14 @@ const clampSliderRatio = (value: number): number => {
};
const getEraserSizeRatio = (size: number): number => {
const { max, min } = appConfig.toolbar.eraser;
return (clampEraserSize(size) - min) / (max - min);
return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
};
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
const { max, min } = appConfig.toolbar.eraser;
return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
return clampEraserSize(
ERASER_SIZE_MIN +
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
);
};
export const getEraserSliderRatioFromSize = (size: number): number =>
@ -72,10 +77,8 @@ export class EraserSizeControl {
const sizeRatio = getEraserSizeRatio(size);
const scale =
appConfig.toolbar.eraser.controlScaleMin +
(appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) *
sizeRatio;
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
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 {
private readonly desktopMediaQuery = window.matchMedia(
appConfig.menuHider.desktopMediaQuery
);
private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
private hideTimeout: number | undefined;
private isHidden = false;
private pointerInside = false;
@ -95,7 +96,7 @@ export class MenuHider {
if (this.canAutoHide) {
this.hide();
}
}, appConfig.menuHider.hideDelayMs);
}, HIDE_DELAY_MS);
}
private reveal(): void {
@ -134,6 +135,6 @@ export class MenuHider {
private isNearViewportBottom(clientY: number): boolean {
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 { settings } from '../settings';
import {
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';
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 { default: defaultCount, max, min } = appConfig.toolbar.mirror;
const safeValue = Number.isFinite(value) ? value : defaultCount;
return Math.min(max, Math.max(min, Math.round(safeValue)));
const safeValue = Number.isFinite(value) ? value : DEFAULT_MIRROR_SEGMENT_COUNT;
return Math.min(
MAX_MIRROR_SEGMENT_COUNT,
Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(safeValue))
);
};
const getMirrorSegmentRatio = (count: number): number => {
const { max, min } = appConfig.toolbar.mirror;
return (count - min) / (max - min);
return (
(count - MIN_MIRROR_SEGMENT_COUNT) /
(MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
);
};
const formatMirrorSegmentCount = (count: number): string =>
count <= 1
? appConfig.toolbar.mirror.offLabel
? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${
appConfig.toolbar.mirror.names[
count as keyof typeof appConfig.toolbar.mirror.names
] ?? appConfig.toolbar.mirror.fallbackSegmentName
MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
MIRROR_SEGMENT_FALLBACK_NAME
}`;
interface MirrorSegmentControlOptions {
@ -50,9 +73,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count;
}
this.slider.min = appConfig.toolbar.mirror.min.toString();
this.slider.max = appConfig.toolbar.mirror.max.toString();
this.slider.step = appConfig.toolbar.mirror.step.toString();
this.slider.min = MIN_MIRROR_SEGMENT_COUNT.toString();
this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
this.slider.step = MIRROR_SEGMENT_STEP.toString();
this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count);

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size';
import {
createCachedBufferWrite,
@ -40,6 +39,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
: 1;
const UNIFORM_COUNT = 16;
const MAX_BRUSH_LINE_COUNT = 240;
const setBrushUniformValues = (
target: Float32Array,
@ -93,7 +93,7 @@ export class BrushPipeline {
private readonly device: GPUDevice,
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({
entries: [

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +1,28 @@
@use 'mixins' as *;
html > body > aside.control-dock > .info-page {
width: min(calc(100vw - 1rem), 560px);
max-height: min(58vh, 520px);
max-height: min(58dvh, 520px);
width: min(100%, 520px);
max-height: min(62vh, 480px);
max-height: min(62dvh, 480px);
margin: 0 auto 10px;
overflow-x: hidden;
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;
background:
linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
rgb(255 255 255);
color: rgb(24 30 27);
linear-gradient(180deg, rgb(252 255 249 / 94%), rgb(234 241 232 / 91%)),
rgb(249 252 247 / 92%);
color: rgb(18 28 24);
box-shadow:
0 20px 54px rgb(0 0 0 / 38%),
0 2px 12px rgb(0 0 0 / 22%);
backdrop-filter: blur(12px);
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%);
backdrop-filter: blur(16px) saturate(118%);
scrollbar-width: thin;
scrollbar-color: var(--main-color) transparent;
scrollbar-color: rgb(69 98 88 / 62%) transparent;
-webkit-overflow-scrolling: touch;
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
@ -32,45 +36,179 @@ html > body > aside.control-dock > .info-page {
}
&::-webkit-scrollbar-thumb {
background-color: var(--main-color);
background-color: rgb(69 98 88 / 62%);
border-radius: 8px;
}
&:focus-visible {
outline: 2px solid white;
outline: 2px solid rgb(17 56 45);
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;
flex-direction: column;
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;
.info-page__header {
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;
}
p {
max-width: 54ch;
margin-bottom: 0;
color: rgb(42 48 45);
font-size: 1.1rem;
line-height: 1.65;
hyphens: auto;
&:hover {
border-color: rgb(34 57 50 / 30%);
background: rgb(255 255 255 / 64%);
transform: translateY(-1px);
}
a {
color: rgb(0 84 120);
font-weight: 400;
&:focus-visible {
outline: 2px solid rgb(17 56 45);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 72%);
}
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
.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;
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 {
color: rgb(0 83 105);
font-weight: 700;
text-decoration-color: rgb(0 83 105 / 34%);
text-underline-offset: 0.18em;
&:hover {
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
}
@ -81,16 +219,51 @@ html > body > aside.control-dock > .info-page {
opacity: 0;
pointer-events: none;
box-shadow: none;
transform: translateY(8px);
transform: translateY(6px) scale(0.985);
visibility: hidden;
}
@include on-small-screen {
max-height: min(54vh, 500px);
max-height: min(54dvh, 500px);
width: min(100%, 520px);
max-height: min(58vh, 500px);
max-height: min(58dvh, 500px);
> section {
padding: var(--small-margin);
.info-page__content {
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';
export const MIN_DELTA_TIME_SECONDS = 1 / 240;
const MAX_DELTA_TIME_SECONDS = 1 / 30;
export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
@ -16,11 +19,7 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
return clamp(
delta / 1000,
appConfig.deltaTime.minDeltaTimeSeconds,
appConfig.deltaTime.maxDeltaTimeSeconds
);
return clamp(delta / 1000, MIN_DELTA_TIME_SECONDS, MAX_DELTA_TIME_SECONDS);
}
private handleVisibilityChange() {

View file

@ -1,7 +1,17 @@
import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad';
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 {
texture: GPUTexture;
view: GPUTextureView;
@ -29,16 +39,16 @@ export const generateNoise = ({
return fract(sin(dot(
uv,
vec2(
${appConfig.pipelines.common.noiseHashX} + seed,
${appConfig.pipelines.common.noiseHashY} + seed
${NOISE_HASH_X} + seed,
${NOISE_HASH_Y} + seed
)
)) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
)) * ${NOISE_HASH_MULTIPLIER} + seed);
}
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4(
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
random_with_seed(uv, ${NOISE_CHANNEL_SEEDS[0]}),
0.0,
0.0,
1.0,
@ -48,7 +58,7 @@ export const generateNoise = ({
entryPoint: 'fragment',
targets: [
{
format: appConfig.pipelines.common.noiseTextureFormat,
format: NOISE_TEXTURE_FORMAT,
},
],
},
@ -63,7 +73,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
format: appConfig.pipelines.common.noiseTextureFormat,
format: NOISE_TEXTURE_FORMAT,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@ -71,7 +81,7 @@ export const generateNoise = ({
colorAttachments: [
{
view: colorTexture.createView(),
clearValue: appConfig.pipelines.common.noiseClearValue,
clearValue: NOISE_CLEAR_VALUE,
loadOp: 'clear',
storeOp: 'store',
},
@ -82,10 +92,7 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.draw(
appConfig.pipelines.common.noiseDrawVertexCount,
appConfig.pipelines.common.noiseDrawInstanceCount
);
passEncoder.draw(NOISE_DRAW_VERTEX_COUNT, NOISE_DRAW_INSTANCE_COUNT);
passEncoder.end();
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';
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
export const VIBE_PRESETS: Array<VibePreset> = vibePresets;
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
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 { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
@ -8,6 +8,8 @@ export { VibeId };
export { getVibeById, VIBE_PRESETS };
export type { VibePreset };
export const VIBE_STORAGE_KEY = 'fleeting-garden:vibe';
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
const isVibeId = (value: unknown): value is VibeId =>
@ -15,12 +17,11 @@ const isVibeId = (value: unknown): value is VibeId =>
export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId();
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
const storedVibeId = readBrowserStorage(VIBE_STORAGE_KEY);
const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
const initialVibeId =
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
const initialVibeId = uriVibeId ?? storedOrLegacyVibeId ?? defaultVibeId;
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
};