diff --git a/src/analytics.ts b/src/analytics.ts index b19958a..427f871 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -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) { diff --git a/src/config.ts b/src/config.ts index 06d2309..37e7f78 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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: '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; +export type { GardenRuntimeSettings, NumberControlConfig } from './config/types'; diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index 37ce510..ba59a1e 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -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, diff --git a/src/config/normalize-runtime-settings.ts b/src/config/normalize-runtime-settings.ts index ec4dcf5..60cbf38 100644 --- a/src/config/normalize-runtime-settings.ts +++ b/src/config/normalize-runtime-settings.ts @@ -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 }; diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index e6b8a70..463735a 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -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'), diff --git a/src/config/types.ts b/src/config/types.ts index 6f9aa29..cd94c9a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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 >; @@ -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; - 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; - }; -} diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index 2bc0e26..7232781 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -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( diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 1d0390f..019a74f 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -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 = []; 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); }; diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts index 3d45283..918b95c 100644 --- a/src/game-loop/brush-stroke-smoother.ts +++ b/src/game-loop/brush-stroke-smoother.ts @@ -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; }; diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts index 0be8911..db675cc 100644 --- a/src/game-loop/export-snapshot-renderer.ts +++ b/src/game-loop/export-snapshot-renderer.ts @@ -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]; diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 64c9680..a380c23 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -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({ diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index aed5289..25d2323 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -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)) ); } diff --git a/src/game-loop/intro-prompt.ts b/src/game-loop/intro-prompt.ts index cd9468d..483f7f0 100644 --- a/src/game-loop/intro-prompt.ts +++ b/src/game-loop/intro-prompt.ts @@ -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; } diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts index a34bbf4..168c106 100644 --- a/src/game-loop/intro-title-agents.ts +++ b/src/game-loop/intro-title-agents.ts @@ -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 => { - 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 = []; 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; } diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts index 9e6717f..e9853d9 100644 --- a/src/game-loop/perf-stats-overlay.ts +++ b/src/game-loop/perf-stats-overlay.ts @@ -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; diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index bee83ef..c04196b 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -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 ); diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index 36629e8..9064af2 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -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); }; diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 7166595..bf62766 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -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', }, diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts index 9bfb8d4..ffa9412 100644 --- a/src/game-loop/stroke-mirroring.ts +++ b/src/game-loop/stroke-mirroring.ts @@ -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, diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts index 8898123..df074d4 100644 --- a/src/game-loop/toolbar-contrast-monitor.ts +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -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(); - 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, }; diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts index 7677bad..159c6c2 100644 --- a/src/page/audio-control.ts +++ b/src/page/audio-control.ts @@ -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) ); } diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts index ca78cbb..756fd63 100644 --- a/src/page/color-reaction-matrix-control.ts +++ b/src/page/color-reaction-matrix-control.ts @@ -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; } diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 8bb7f28..bdae8f7 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -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 => ( - 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()); diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts index 6b2bbe8..1d93778 100644 --- a/src/page/eraser-size-control.test.ts +++ b/src/page/eraser-size-control.test.ts @@ -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); }); }); diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts index 2c0e8a8..8f93d51 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -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(); diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts index ed8aa59..4f371c6 100644 --- a/src/page/menu-hider.ts +++ b/src/page/menu-hider.ts @@ -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; } } diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts index 1ea8ddc..f058429 100644 --- a/src/page/mirror-segment-control.ts +++ b/src/page/mirror-segment-control.ts @@ -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; + 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); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index b759c4f..4897020 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -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: [ diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts index 5157459..deeaa8a 100644 --- a/src/pipelines/common-state/common-state.ts +++ b/src/pipelines/common-state/common-state.ts @@ -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; diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index b1c763d..ef9bfef 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -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, diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index 694777f..cf3e84a 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -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 = [ 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: [ diff --git a/src/settings.ts b/src/settings.ts index c91ac83..bceadd9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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(); diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts index 78489c2..bff0468 100644 --- a/src/utils/delta-time-calculator.ts +++ b/src/utils/delta-time-calculator.ts @@ -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() { diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts index c7cdc96..4e171fd 100644 --- a/src/utils/graphics/noise.ts +++ b/src/utils/graphics/noise.ts @@ -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) -> @location(0) vec4 { 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()]); diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts index 0b0ae54..ad16554 100644 --- a/src/vibe-registry.ts +++ b/src/vibe-registry.ts @@ -1,7 +1,7 @@ -import { appConfig } from './config'; +import { vibePresets } from './config'; import type { VibeId, VibePreset } from './config/types'; -export const VIBE_PRESETS: Array = appConfig.vibes.presets; +export const VIBE_PRESETS: Array = vibePresets; export const getVibeById = (vibeId: VibeId): VibePreset | undefined => VIBE_PRESETS.find((vibe) => vibe.id === vibeId); diff --git a/src/vibes.ts b/src/vibes.ts index 95d1f7c..28aed48 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -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(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]; };