Clean up configs
All checks were successful
Check & deploy / build (pull_request) Successful in 7m18s
Check & deploy / build (push) Successful in 7m6s

This commit is contained in:
Andras Schmelczer 2026-05-25 09:27:39 +01:00
commit 0fddad6b45
36 changed files with 375 additions and 652 deletions

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: 'schmelczer.dev/fleeting',
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

@ -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,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];
}; };