Clean up #2

Merged
andras merged 3 commits from asch/clean-up into main 2026-05-25 09:42:49 +01:00
36 changed files with 375 additions and 652 deletions
Showing only changes of commit 0fddad6b45 - Show all commits

View file

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

View file

@ -1,199 +1,9 @@
import {
createGardenAudioConfig,
DEFAULT_AUDIO_VOLUME,
} from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
export { defaultSettings } from './config/default-settings';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
export { runtimeControls } from './config/runtime-controls';
export { defaultVibeId, vibePresets } from './config/vibe-presets';
export type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './config/types';
export const appConfig = {
audio: createGardenAudioConfig(),
analytics: {
autoCapturePageviews: true,
domain: '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';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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