diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 232fbf9..b6e190b 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -28,11 +28,14 @@ jobs: run: npm ci - name: Lint - run: npm run lint -- --check || true + run: npm run lint - name: Typecheck run: npm run typecheck + - name: Test + run: npm test + - name: Build run: npm run build diff --git a/README.md b/README.md index a55e624..56bf279 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,7 @@ -# Just a bunch of blobs +# Fleeting Garden -[![Deploy to GitHub Pages](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml/badge.svg)](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml) +Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette, +draw persistent coloured paths, spawn agents from those strokes, erase locally, +and export the scene as a 4K wallpaper. -## todo - -- add info page description -- add share link -- settings page - add reset link -- shareable settings -- graceful error messages when no support -- fix up generation id automatically - -Check out the [agent's logic](./src/pipelines/agents/agent.wgsl). +Check out the [agent logic](./src/pipelines/agents/agent.wgsl). diff --git a/assets/icons/download.svg b/assets/icons/download.svg new file mode 100644 index 0000000..f880e05 --- /dev/null +++ b/assets/icons/download.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg new file mode 100644 index 0000000..78dbb2b --- /dev/null +++ b/assets/icons/sound.svg @@ -0,0 +1,3 @@ + + + diff --git a/index.html b/index.html index 17b5847..948774e 100644 --- a/index.html +++ b/index.html @@ -9,13 +9,13 @@ - + @@ -27,38 +27,42 @@ - Just a bunch of blobs + Fleeting Garden
- + + Your browser cannot display the interactive WebGPU garden canvas. Use a browser + with WebGPU support to draw coloured paths and watch the garden grow. + +

+ Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene + to paint coloured paths, then use the toolbar to change colours, erase, adjust + settings, export, restart, or open more information. +

+ +
- diff --git a/package-lock.json b/package-lock.json index fc94ae1..6cad549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "webgpu-seed", + "name": "fleeting-garden", "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "webgpu-seed", + "name": "fleeting-garden", "version": "0.2.0", "license": "Unlicense", "dependencies": { diff --git a/package.json b/package.json index ce5a93e..e1d3fe7 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { - "name": "webgpu-seed", + "name": "fleeting-garden", "version": "0.2.0", "private": true, "type": "module", - "description": "A WebGPU-powered slime-mold-meets-territory-control simulation.", + "description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.", "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", - "lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"", + "lint": "npm run lint:check", + "lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", + "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -33,10 +37,8 @@ "browserslist": [ "supports webgpu and last 2 years" ], - "dependencies": { - "gl-matrix": "^3.4.4" - }, "devDependencies": { + "gl-matrix": "^3.4.4", "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@types/node": "^25.6.0", diff --git a/public/audio/piano/README.md b/public/audio/piano/README.md new file mode 100644 index 0000000..96dd958 --- /dev/null +++ b/public/audio/piano/README.md @@ -0,0 +1,6 @@ +Piano samples are Salamander Grand Piano V3 OGG samples by Alexander Holm, +distributed under CC BY 3.0. + +Source package: @audio-samples/piano-velocity12 +Source recording: https://archive.org/details/SalamanderGrandPianoV3 +License: https://creativecommons.org/licenses/by/3.0/ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index f7b70ec..2bfc44c 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,14 +1,14 @@ { - "name": "Just a bunch of blobs", - "short_name": "Blobs", - "description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.", + "name": "Fleeting Garden", + "short_name": "Garden", + "description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.", "start_url": "/", "scope": "/", "display": "fullscreen", "display_override": ["fullscreen", "standalone", "minimal-ui"], "orientation": "any", - "background_color": "#b7455e", - "theme_color": "#b7455e", + "background_color": "#10151f", + "theme_color": "#10151f", "icons": [ { "src": "/favicon.svg", diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts new file mode 100644 index 0000000..2028426 --- /dev/null +++ b/src/audio/piano-samples.ts @@ -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 = sampleFiles + .map(([fileName, midi]) => ({ + midi, + url: `${sampleBaseUrl}${fileName}`, + })) + .sort((a, b) => a.midi - b.midi); diff --git a/src/index.dom-contract.test.ts b/src/index.dom-contract.test.ts new file mode 100644 index 0000000..ade0170 --- /dev/null +++ b/src/index.dom-contract.test.ts @@ -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 = /^#(?[\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 = /^(?[a-z]+)\.(?[\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); + }); +}); diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts index 1dda653..4f53890 100644 --- a/src/pipelines/common-state/common-state.ts +++ b/src/pipelines/common-state/common-state.ts @@ -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 ); } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 3bb3422..b1a924f 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -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 ); } diff --git a/src/style/common.scss b/src/style/common.scss index 8954439..f33c2e1 100644 --- a/src/style/common.scss +++ b/src/style/common.scss @@ -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; diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index 5e21820..94d29c1 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -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; }; diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts index 18ba035..a47140f 100644 --- a/src/utils/graphics/initialize-gpu.ts +++ b/src/utils/graphics/initialize-gpu.ts @@ -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; + +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 => { + 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 => { + 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 => { + 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; }; diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts index 720af9c..da68d39 100644 --- a/src/utils/graphics/noise.ts +++ b/src/utils/graphics/noise.ts @@ -1,7 +1,8 @@ import { setUpFullScreenQuad } from './full-screen-quad'; import { smartCompile } from './smart-compile'; -const textureCache = new Map(); +const textureCache = new WeakMap>(); +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(); + 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(); }; diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts index 54b21ed..44770e7 100644 --- a/src/utils/graphics/resizable-texture.ts +++ b/src/utils/graphics/resizable-texture.ts @@ -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; } diff --git a/vite.config.ts b/vite.config.ts index 7215fa6..56f50e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import browserslist from 'browserslist'; import { browserslistToTargets } from 'lightningcss'; -import { defineConfig } from 'vitest/config'; import { viteSingleFile } from 'vite-plugin-singlefile'; +import { defineConfig } from 'vitest/config'; const cssTargets = browserslistToTargets(browserslist());