Refactoring start

This commit is contained in:
Andras Schmelczer 2026-05-09 22:09:04 +01:00
parent 6588930911
commit b1acdff594
19 changed files with 528 additions and 97 deletions

View file

@ -0,0 +1,46 @@
export interface PianoSampleDefinition {
midi: number;
url: string;
}
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
const sampleFiles: Array<[fileName: string, midi: number]> = [
['A0v12.ogg', 21],
['C1v12.ogg', 24],
['Dsharp1v12.ogg', 27],
['Fsharp1v12.ogg', 30],
['A1v12.ogg', 33],
['C2v12.ogg', 36],
['Dsharp2v12.ogg', 39],
['Fsharp2v12.ogg', 42],
['A2v12.ogg', 45],
['C3v12.ogg', 48],
['Dsharp3v12.ogg', 51],
['Fsharp3v12.ogg', 54],
['A3v12.ogg', 57],
['C4v12.ogg', 60],
['Dsharp4v12.ogg', 63],
['Fsharp4v12.ogg', 66],
['A4v12.ogg', 69],
['C5v12.ogg', 72],
['Dsharp5v12.ogg', 75],
['Fsharp5v12.ogg', 78],
['A5v12.ogg', 81],
['C6v12.ogg', 84],
['Dsharp6v12.ogg', 87],
['Fsharp6v12.ogg', 90],
['A6v12.ogg', 93],
['C7v12.ogg', 96],
['Dsharp7v12.ogg', 99],
['Fsharp7v12.ogg', 102],
['A7v12.ogg', 105],
['C8v12.ogg', 108],
];
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
midi,
url: `${sampleBaseUrl}${fileName}`,
}))
.sort((a, b) => a.midi - b.midi);

View file

@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const projectRoot = process.cwd();
const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8');
const html = readFileSync(join(projectRoot, 'index.html'), 'utf8');
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const hasClass = (className: string, tagName?: string) => {
const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*';
return new RegExp(
`${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`,
'i'
).test(html);
};
const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html);
const hasTag = (tagName: string) =>
new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html);
const selectorExists = (selector: string) => {
const idSelector = /^#(?<id>[\w-]+)$/.exec(selector);
if (idSelector?.groups?.id) {
return hasId(idSelector.groups.id);
}
const classSelector = /^\.([\w-]+)$/.exec(selector);
if (classSelector?.[1]) {
return hasClass(classSelector[1]);
}
const tagClassSelector = /^(?<tagName>[a-z]+)\.(?<className>[\w-]+)$/.exec(selector);
if (tagClassSelector?.groups) {
return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName);
}
if (/^[a-z]+$/.test(selector)) {
return hasTag(selector);
}
throw new Error(`Unsupported selector contract syntax: ${selector}`);
};
describe('index DOM selector contract', () => {
it('keeps every boot-time querySelector target present in index.html', () => {
const selectors = Array.from(
indexSource.matchAll(/document\.querySelector(?:All)?\(\s*'([^']+)'\s*\)/g),
(match) => match[1]
);
expect(selectors.length).toBeGreaterThan(0);
expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]);
});
it('keeps the three color swatches expected by the palette UI', () => {
const colorSwatchCount = Array.from(
html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g)
).length;
expect(colorSwatchCount).toBe(3);
});
});

View file

@ -1,11 +1,20 @@
import { vec2 } from 'gl-matrix';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
private static readonly NOISE_TEXTURE_SIZE = 1024;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
CommonState.UNIFORM_COUNT
);
private readonly noise: GPUTextureView;
private readonly bindGroup: GPUBindGroup;
@ -31,8 +40,8 @@ export class CommonState {
this.noise = generateNoise({
device,
width: 2048,
height: 2048,
width: CommonState.NOISE_TEXTURE_SIZE,
height: CommonState.NOISE_TEXTURE_SIZE,
});
this.bindGroupLayout = device.createBindGroupLayout({
@ -95,10 +104,15 @@ export class CommonState {
deltaTime: number;
time: number;
}) {
this.device.queue.writeBuffer(
this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1];
this.uniformValues[2] = deltaTime;
this.uniformValues[3] = time;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([...canvasSize, deltaTime, time])
this.uniformValues,
this.uniformCache
);
}

View file

@ -1,3 +1,7 @@
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@ -5,11 +9,15 @@ import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
export class DiffusionPipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly UNIFORM_COUNT = 5;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
DiffusionPipeline.UNIFORM_COUNT
);
private readonly vertexBuffer: GPUBuffer;
private bindGroup?: GPUBindGroup;
@ -56,16 +64,18 @@ export class DiffusionPipeline {
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
anisotropy,
}: DiffusionSettings) {
this.device.queue.writeBuffer(
this.uniformValues[0] = 1 / diffusionRateTrails;
this.uniformValues[1] = decayRateTrails / 1000;
this.uniformValues[2] = 1 / diffusionRateBrush;
this.uniformValues[3] = decayRateBrush / 1000;
this.uniformValues[4] = anisotropy;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([
1 / diffusionRateTrails,
decayRateTrails / 1000,
1 / diffusionRateBrush,
decayRateBrush / 1000,
])
this.uniformValues,
this.uniformCache
);
}

View file

@ -9,7 +9,7 @@
padding: 0;
box-sizing: border-box;
@media (prefers-reduced-motion) {
@media (prefers-reduced-motion: reduce) {
transition: none !important;
animation: none !important;
}
@ -36,7 +36,21 @@ html {
text-rendering: optimizeLegibility;
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0 0 0 0) !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
border: 0 !important;
}
.large-button {
min-height: 44px;
border: none;
background-color: var(--accent-color);
cursor: pointer;

View file

@ -1,3 +1,5 @@
import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
export const initializeContext = ({
device,
canvas,
@ -5,13 +7,49 @@ export const initializeContext = ({
device: GPUDevice;
canvas: HTMLCanvasElement;
}): GPUCanvasContext => {
const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null;
context.configure({
device: device,
format: navigator.gpu.getPreferredCanvasFormat(),
alphaMode: 'premultiplied',
});
if (!context) {
throw new RuntimeError(
ErrorCode.WEBGPU_CONTEXT_UNAVAILABLE,
'Could not create a WebGPU canvas context.',
{
details: {
canvasHeight: canvas.height,
canvasWidth: canvas.width,
},
}
);
}
const gpu = navigator.gpu;
if (!gpu) {
throw new RuntimeError(
ErrorCode.WEBGPU_UNSUPPORTED,
'WebGPU is no longer available while configuring the canvas context.'
);
}
try {
context.configure({
device: device,
format: gpu.getPreferredCanvasFormat(),
alphaMode: 'premultiplied',
});
} catch (error) {
throw new RuntimeError(
ErrorCode.WEBGPU_CONTEXT_CONFIGURATION_FAILED,
'Could not configure the WebGPU canvas context.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
canvasHeight: canvas.height,
canvasWidth: canvas.width,
},
}
);
}
return context;
};

View file

@ -1,33 +1,150 @@
import { ErrorHandler, Severity } from '../error-handler';
import {
ErrorCode,
ErrorHandler,
getErrorMessage,
RuntimeError,
Severity,
} from '../error-handler';
const WEBGPU_BROWSER_SUPPORT_MESSAGE =
'Fleeting Garden needs WebGPU. Try the latest Chrome, Edge, or another browser with WebGPU enabled.';
const REQUESTED_LIMIT_NAMES = [
'maxBufferSize',
'maxStorageBufferBindingSize',
'maxComputeWorkgroupsPerDimension',
] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>;
const getRequiredLimits = (
limits: GPUSupportedLimits
): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
(typeof REQUESTED_LIMIT_NAMES)[number],
number
>;
const getAdapterInfo = (adapter: GPUAdapter): Record<string, unknown> => {
try {
const info = adapter.info;
return {
architecture: info.architecture,
description: info.description,
device: info.device,
isFallbackAdapter: info.isFallbackAdapter,
subgroupMaxSize: info.subgroupMaxSize,
subgroupMinSize: info.subgroupMinSize,
vendor: info.vendor,
};
} catch (error) {
return {
unavailableReason: getErrorMessage(error),
};
}
};
const requestAdapter = async (
gpu: GPU,
options?: GPURequestAdapterOptions
): Promise<GPUAdapter | null> => {
try {
return await gpu.requestAdapter(options);
} catch (error) {
throw new RuntimeError(
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'Could not request a WebGPU adapter.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
powerPreference: options?.powerPreference ?? 'default',
},
}
);
}
};
export const initializeGpu = async (): Promise<GPUDevice> => {
if (window.isSecureContext === false) {
throw new RuntimeError(
ErrorCode.WEBGPU_INSECURE_CONTEXT,
'WebGPU requires a secure context. Open Fleeting Garden over HTTPS or from localhost.'
);
}
const gpu = navigator.gpu;
if (!gpu) {
throw new Error('WebGPU is not supported in your browser');
throw new RuntimeError(ErrorCode.WEBGPU_UNSUPPORTED, WEBGPU_BROWSER_SUPPORT_MESSAGE, {
details: {
hasNavigatorGpu: false,
isSecureContext: window.isSecureContext,
},
});
}
const adapter = await gpu.requestAdapter({
powerPreference: 'high-performance',
});
const adapter =
(await requestAdapter(gpu, {
powerPreference: 'high-performance',
})) ?? (await requestAdapter(gpu));
if (!adapter) {
throw new Error('Could not request adatper');
throw new RuntimeError(
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
);
}
ErrorHandler.addMetadata('features', adapter.features);
ErrorHandler.addMetadata('limits', adapter.limits);
const gpuDevice = await adapter.requestDevice({
requiredLimits: {
maxBufferSize: adapter.limits.maxBufferSize,
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
},
const requiredLimits = getRequiredLimits(adapter.limits);
ErrorHandler.addMetadata('webgpuAdapter', {
features: Array.from(adapter.features).sort(),
info: getAdapterInfo(adapter),
requiredLimits,
});
let gpuDevice: GPUDevice;
try {
gpuDevice = await adapter.requestDevice({
requiredLimits,
});
} catch (error) {
throw new RuntimeError(
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
'Could not create a WebGPU device for this adapter.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
requiredLimits,
},
}
);
}
if (!gpuDevice) {
throw new RuntimeError(
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
'The browser returned an empty WebGPU device.'
);
}
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
ErrorHandler.addError(Severity.ERROR, event.error.message)
ErrorHandler.addException(event.error, {
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
severity: Severity.ERROR,
})
);
gpuDevice.lost.then((info) => {
if (info.reason === 'destroyed') {
return;
}
ErrorHandler.addError(Severity.ERROR, info.message || 'The WebGPU device was lost.', {
code: ErrorCode.WEBGPU_DEVICE_LOST,
details: {
reason: info.reason,
},
});
});
return gpuDevice;
};

View file

@ -1,7 +1,8 @@
import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile';
const textureCache = new Map<string, GPUTexture>();
const textureCache = new WeakMap<GPUDevice, Map<string, GPUTexture>>();
const NOISE_TEXTURE_FORMAT: GPUTextureFormat = 'rgba8unorm';
export const generateNoise = ({
device,
@ -13,7 +14,13 @@ export const generateNoise = ({
height: number;
}): GPUTextureView => {
const cacheKey = `${width}x${height}`;
const cached = textureCache.get(cacheKey);
let deviceCache = textureCache.get(device);
if (!deviceCache) {
deviceCache = new Map<string, GPUTexture>();
textureCache.set(device, deviceCache);
}
const cached = deviceCache.get(cacheKey);
if (cached) {
return cached.createView();
}
@ -45,7 +52,7 @@ export const generateNoise = ({
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
format: NOISE_TEXTURE_FORMAT,
},
],
},
@ -60,7 +67,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
format: 'rgba16float',
format: NOISE_TEXTURE_FORMAT,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@ -84,6 +91,6 @@ export const generateNoise = ({
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
textureCache.set(cacheKey, colorTexture);
deviceCache.set(cacheKey, colorTexture);
return colorTexture.createView();
};

View file

@ -13,7 +13,7 @@ export class ResizableTexture {
size: vec2
) {
this.copyPipeline = new CopyPipeline(this.device);
this.size = size;
this.size = vec2.clone(size);
this.texture = this.createTexture(size);
this.textureView = this.texture.createView();
}
@ -36,11 +36,15 @@ export class ResizableTexture {
this.device.queue.submit([commandEncoder.finish()]);
this.texture.destroy();
this.size = size;
this.size = vec2.clone(size);
this.texture = newTexture;
this.textureView = newTextureView;
}
public getSize(): vec2 {
return vec2.clone(this.size);
}
public getTextureView(): GPUTextureView {
return this.textureView;
}