From b6570de1069d95a167906f4cf0e29fd5c2f456ad Mon Sep 17 00:00:00 2001 From: Rezmason Date: Sun, 25 May 2025 03:30:26 -0700 Subject: [PATCH] Fixed some major bugs: the WebGPU cache should store loaded images and text, never GPU resource handles; renamed renderer "formulate" to "configure"; WebGPU renderer's configure function needs early returns after each major await, in case there's a new config; the render loops are now locally stored closures; renderers now have start and stop functions; fixed bugs in the REGL and WebGPU mirror passes; WebGPU bloom pass now enforces texture dimensions are greater than zero; the react component now stores the renderer type in a useRef and returns early from renderer init awaits to prevent multiple renderers from instantiating. --- TODO.txt | 20 +-- js/Matrix.jsx | 66 +++++---- js/main.js | 2 +- js/regl/mirrorPass.js | 35 +++-- js/regl/renderer.js | 64 +++++---- js/renderer.js | 58 ++++---- js/webgpu/bloomPass.js | 8 +- js/webgpu/endPass.js | 2 +- js/webgpu/mirrorPass.js | 39 ++--- js/webgpu/renderer.js | 287 +++++++++++++++++++++---------------- js/webgpu/utils.js | 63 ++++---- package.json | 1 + shaders/wgsl/rainPass.wgsl | 1 - tools/test/index.html | 4 +- tools/test/test-react.jsx | 106 +++++++------- 15 files changed, 405 insertions(+), 351 deletions(-) diff --git a/TODO.txt b/TODO.txt index 314399a..b80d11a 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,22 +1,16 @@ TODO: - WebGPU formulate is expensive - Mirror pass clicks bug - + Minimum react requirement? Minify bundles Naming "matrix" for the github repo, "digital-rain" and "DigitalRain" for everything else - Minimum react requirement? Retire fetchLibraries? + Preserve time across configure calls, move times into renderer and uniforms Move off of regl Unify implementations? - Responsive changes - Move start time to rain object - Matrix component should record, then overwrite it - Reshape all passes to react to config changes, ie. "configure" - main.js "formulate" --> "configure" - simple deltas only require updating the uniforms - return boolean of whether all deltas are simple - Resource changes are simple if they're cached and loaded, false otherwise - remake the pipeline if anything returns false + Reshape all passes to react to config changes, ie. "configure" + simple deltas only require updating the uniforms + return boolean of whether all deltas are simple + Resource changes are simple if they're cached and loaded, false otherwise + remake the pipeline if anything returns false Core vs full core One embedded MSDF, combined from the two main glyph sets and their configs diff --git a/js/Matrix.jsx b/js/Matrix.jsx index 8e0872e..1b87136 100644 --- a/js/Matrix.jsx +++ b/js/Matrix.jsx @@ -31,7 +31,7 @@ import makeConfig from "./utils/config"; * volumetric?: boolean, * loops?: boolean, * skipIntro?: boolean, - * renderer?: "regl" | "three" | string, + * renderer?: "regl" | "webgpu" | string, * suppressWarnings?: boolean, * useHalfFloat?: boolean, * isometric?: boolean, @@ -109,9 +109,10 @@ export const Matrix = memo((props) => { const elProps = { style, className }; const domElement = useRef(null); const [rRenderer, setRenderer] = useState(null); + const rendererType = useRef(null); const [rSize, setSize] = useState([1, 1]); const [rConfig, setConfig] = useState(makeConfig({})); - const rendererClasses = {}; + const rendererModules = {}; const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { @@ -126,14 +127,6 @@ export const Matrix = memo((props) => { resizeObserver.observe(domElement.current); }, [domElement]); - useEffect(() => { - setConfig(makeConfig({ - ...Object.fromEntries( - Object.entries(rawConfigProps).filter(([_, value]) => value != null), - ) - })); - }, [props]); - const supportsWebGPU = () => { return ( window.GPUQueue != null && @@ -142,6 +135,18 @@ export const Matrix = memo((props) => { ); }; + useEffect(() => { + const config = makeConfig({ + ...Object.fromEntries( + Object.entries(rawConfigProps).filter(([_, value]) => value != null), + ) + }); + if (config.renderer === "webgpu" && !supportsWebGPU()) { + config.renderer = "regl"; + } + setConfig(config); + }, [props]); + const cleanup = () => { if (rRenderer == null) return; rRenderer.canvas.remove(); @@ -150,37 +155,40 @@ export const Matrix = memo((props) => { }; useEffect(() => { - const useWebGPU = supportsWebGPU() && rConfig.renderer === "webgpu"; - const isWebGPU = rRenderer?.type === "webgpu"; + rendererType.current = rConfig.renderer; + let rendererModule; + if (rConfig.renderer === "webgpu") { + rendererModules.webgpu ??= import("./webgpu/renderer.js"); + rendererModule = rendererModules.webgpu; + } else { + rendererModules.regl ??= import("./regl/renderer.js"); + rendererModule = rendererModules.regl; + } - const loadRain = async () => { - let renderer; - if (useWebGPU) { - rendererClasses.webgpu ??= (await import("./webgpu/renderer.js")).default; - renderer = new (rendererClasses.webgpu)(); - } else { - rendererClasses.regl ??= (await import("./regl/renderer.js")).default; - renderer = new (rendererClasses.regl)(); - } - setRenderer(renderer); + (async () => { + const rendererClass = (await rendererModule).default; + if (rendererType.current !== rConfig.renderer) return; + const renderer = new rendererClass(); await renderer.ready; + if (rendererType.current !== rConfig.renderer) { + console.warn("Destroyed a redundant renderer late."); + renderer.destroy(); + return; + } + cleanup(); + setRenderer(renderer); const canvas = renderer.canvas; canvas.style.width = "100%"; canvas.style.height = "100%"; domElement.current.appendChild(canvas); - }; - - if (rRenderer == null || useWebGPU !== isWebGPU) { - cleanup(); - loadRain(); - } + })(); return cleanup; }, [rConfig.renderer]); useEffect(() => { if (rRenderer?.destroyed ?? true) return; - rRenderer.formulate(rConfig); + rRenderer.configure(rConfig); }, [rRenderer, rConfig]); useEffect(() => { diff --git a/js/main.js b/js/main.js index 45b1bb2..3fec6ca 100644 --- a/js/main.js +++ b/js/main.js @@ -37,7 +37,7 @@ document.body.onload = async () => { renderer.fullscreen = !renderer.fullscreen; }); document.body.appendChild(renderer.canvas); - await renderer.formulate(config); + await renderer.configure(config); }; if (isRunningSwiftShader() && !config.suppressWarnings) { diff --git a/js/regl/mirrorPass.js b/js/regl/mirrorPass.js index 5a63d6a..c0eb8f3 100644 --- a/js/regl/mirrorPass.js +++ b/js/regl/mirrorPass.js @@ -1,19 +1,21 @@ import { loadText, makePassFBO, makePass } from "./utils.js"; -let start; -const numClicks = 5; -const clicks = Array(numClicks).fill([0, 0, -Infinity]).flat(); -let aspectRatio = 1; +export default ({ regl, canvas, cache, config, cameraTex, cameraAspectRatio }, inputs) => { -let index = 0; -window.onclick = (e) => { - clicks[index * 3 + 0] = 0 + e.clientX / e.srcElement.clientWidth; - clicks[index * 3 + 1] = 1 - e.clientY / e.srcElement.clientHeight; - clicks[index * 3 + 2] = (Date.now() - start) / 1000; - index = (index + 1) % numClicks; -}; + let start; + const numClicks = 5; + const clicks = Array(numClicks).fill().map(_ => ([0, 0, -Infinity])); + let aspectRatio = 1; + + let index = 0; + canvas.onmousedown = (e) => { + const rect = e.srcElement.getBoundingClientRect(); + clicks[index][0] = 0 + (e.clientX - rect.x) / rect.width; + clicks[index][1] = 1 - (e.clientY - rect.y) / rect.height; + clicks[index][2] = (performance.now() - start) / 1000; + index = (index + 1) % numClicks; + }; -export default ({ regl, cache, config, cameraTex, cameraAspectRatio }, inputs) => { const output = makePassFBO(regl, config.useHalfFloat); const mirrorPassFrag = loadText(cache, "shaders/glsl/mirrorPass.frag.glsl"); const render = regl({ @@ -23,14 +25,19 @@ export default ({ regl, cache, config, cameraTex, cameraAspectRatio }, inputs) = tex: inputs.primary, bloomTex: inputs.bloom, cameraTex, - clicks: () => clicks, + // REGL bug can misinterpret array uniforms + ["clicks[0]"]: () => clicks[0], + ["clicks[1]"]: () => clicks[1], + ["clicks[2]"]: () => clicks[2], + ["clicks[3]"]: () => clicks[3], + ["clicks[4]"]: () => clicks[4], aspectRatio: () => aspectRatio, cameraAspectRatio, }, framebuffer: output, }); - start = Date.now(); + start = performance.now(); return makePass( { diff --git a/js/regl/renderer.js b/js/regl/renderer.js index 71f4600..7fde8a1 100644 --- a/js/regl/renderer.js +++ b/js/regl/renderer.js @@ -24,7 +24,7 @@ const effects = { export default class REGLRenderer extends Renderer { - #tick; + #renderFunc; #regl; #glMatrix; @@ -43,26 +43,24 @@ export default class REGLRenderer extends Renderer { }); } - async formulate(config) { - await super.formulate(config); - - const canvas = this.canvas; - const cache = this.cache; - const regl = this.#regl; - const glMatrix = this.#glMatrix; - - const dimensions = { width: 1, height: 1 }; + async configure(config) { + await super.configure(config); if (config.useCamera) { await setupCamera(); } + const canvas = this.canvas; + const cache = this.cache; + const regl = this.#regl; + const glMatrix = this.#glMatrix; + const dimensions = { width: 1, height: 1 }; const cameraTex = regl.texture(cameraCanvas); // All this takes place in a full screen quad. const fullScreenQuad = makeFullScreenQuad(regl); const effectName = config.effect in effects ? config.effect : "palette"; - const context = { regl, cache, config, cameraTex, cameraAspectRatio, glMatrix }; + const context = { regl, canvas, cache, config, cameraTex, cameraAspectRatio, glMatrix }; const pipeline = makePipeline(context, [makeRain, makeBloomPass, effects[effectName]]); const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary }; @@ -75,17 +73,15 @@ export default class REGLRenderer extends Renderer { const targetFrameTimeMilliseconds = 1000 / config.fps; let last = NaN; - resetREGLTime: { - const reset = regl.frame((o) => { - o.time = 0; - o.tick = 0; - reset.cancel(); - }); - } + const reset = regl.frame((reglContext) => { + reglContext.tick = 0; + reset.cancel(); + }); + + this.#renderFunc = (reglContext) => { - const tick = regl.frame(({ viewportWidth, viewportHeight }) => { if (config.once) { - tick.cancel(); + this.stop(); } const now = regl.now() * 1000; @@ -106,6 +102,7 @@ export default class REGLRenderer extends Renderer { if (config.useCamera) { cameraTex(cameraCanvas); } + const {viewportWidth, viewportHeight} = reglContext; if (dimensions.width !== viewportWidth || dimensions.height !== viewportHeight) { dimensions.width = viewportWidth; dimensions.height = viewportHeight; @@ -119,20 +116,31 @@ export default class REGLRenderer extends Renderer { } drawToScreen(); }); + }; + + const frame = this.#regl.frame(o => { + this.#renderFunc(o); + frame.cancel(); }); + } - if (this.#tick != null) { - this.#tick.cancel(); + stop() { + super.stop(); + this.#renderFunc = null; + } + + update(now) { + if (this.#renderFunc != null) { + const frame = this.#regl.frame(o => { + this.#renderFunc(o); + frame.cancel(); + }) } - - this.#tick = tick; + super.update(now); } destroy() { - if (this.destroyed) { - return; - } - this.#tick.cancel(); // stop RAF + if (this.destroyed) return; this.#regl.destroy(); // releases all GPU resources & event listeners super.destroy(); } diff --git a/js/renderer.js b/js/renderer.js index 0cc5805..5e5b153 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -11,6 +11,7 @@ export default class Renderer { #fullscreen = false; #cache = new Map(); #destroyed = false; + #running = false; constructor(type, ready) { this.#type = type; @@ -18,48 +19,46 @@ export default class Renderer { this.#ready = Renderer.libraries.then(libraries => { this.#cache = new Map(libraries.staticAssets); }).then(ready); + this.#ready.then(() => this.start()); } - get canvas() { - return this.#canvas; + get running() { return this.#running; } + + start() { + this.#running = true; + this.update(); } - get cache() { - return this.#cache; + stop() { + this.#running = false; } - get type () { - return this.#type; + update(now) { + if (!this.#running) return; + requestAnimationFrame(now => this.update(now)); } - get ready () { - return this.#ready; - } + get canvas() { return this.#canvas; } - get size() { - return [this.#width, this.#height]; - } + get cache() { return this.#cache; } + + get type () { return this.#type; } + + get ready () { return this.#ready; } + + get size() { return ([this.#width, this.#height]); } set size([width, height]) { [width, height] = [Math.ceil(width), Math.ceil(height)]; - if (width === this.#width && height === this.#height) { - return; - } + if (width === this.#width && height === this.#height) return; [this.#canvas.width, this.#canvas.height] = [this.#width, this.#height] = [width, height]; } - get fullscreen() { - return this.#fullscreen; - } + get fullscreen() { return this.#fullscreen; } set fullscreen(value) { - if (!!value === this.#fullscreen) { - return; - } - - if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) { - return; - } + if (!!value === this.#fullscreen) return; + if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) return; this.#fullscreen = value; if (document.fullscreenElement != null) { @@ -74,18 +73,17 @@ export default class Renderer { } } - async formulate(config) { + async configure(config) { await this.ready; if (this.destroyed) { - throw new Error("Cannot formulate a destroyed rain instance."); + throw new Error("Cannot configure a destroyed rain instance."); } } - get destroyed() { - return this.#destroyed; - } + get destroyed() { return this.#destroyed; } destroy() { + this.stop(); this.#destroyed = true; this.#cache.clear(); } diff --git a/js/webgpu/bloomPass.js b/js/webgpu/bloomPass.js index d6cdf16..5f42cde 100644 --- a/js/webgpu/bloomPass.js +++ b/js/webgpu/bloomPass.js @@ -26,7 +26,7 @@ const makePyramid = (device, size, pyramidHeight) => .map((_, index) => makeComputeTarget( device, - size.map((x) => Math.floor(x * 2 ** -index)), + size.map((x) => Math.max(1, Math.floor(x * 2 ** -index))), ), ); @@ -111,7 +111,7 @@ export default ({ config, device, cache }) => { const build = (screenSize, inputs) => { // Since the bloom is blurry, we downscale everything - scaledScreenSize = screenSize.map((x) => Math.floor(x * bloomSize)); + scaledScreenSize = screenSize.map((x) => Math.max(1, Math.floor(x * bloomSize))); destroyPyramid(hBlurPyramid); hBlurPyramid = makePyramid(device, scaledScreenSize, pyramidHeight); @@ -169,8 +169,8 @@ export default ({ config, device, cache }) => { computePass.setPipeline(blurPipeline); for (let i = 0; i < pyramidHeight; i++) { const dispatchSize = [ - Math.ceil(Math.floor(scaledScreenSize[0] * 2 ** -i) / 32), - Math.floor(Math.floor(scaledScreenSize[1] * 2 ** -i)), + Math.max(1, Math.ceil(Math.floor(scaledScreenSize[0] * 2 ** -i) / 32)), + Math.max(1, Math.floor(Math.floor(scaledScreenSize[1] * 2 ** -i))), 1, ]; computePass.setBindGroup(0, hBlurBindGroups[i]); diff --git a/js/webgpu/endPass.js b/js/webgpu/endPass.js index 141ec7b..14c7318 100644 --- a/js/webgpu/endPass.js +++ b/js/webgpu/endPass.js @@ -49,7 +49,7 @@ export default ({ device, cache, canvasFormat, canvasContext }) => { nearestSampler, inputs.primary.createView(), ]); - return null; + return {}; }; const run = (encoder, shouldRender) => { diff --git a/js/webgpu/mirrorPass.js b/js/webgpu/mirrorPass.js index 3dc929a..2fe945a 100644 --- a/js/webgpu/mirrorPass.js +++ b/js/webgpu/mirrorPass.js @@ -7,24 +7,7 @@ import { makePass, } from "./utils.js"; -let start; -const numTouches = 5; -const touches = Array(numTouches) - .fill() - .map((_) => [0, 0, -Infinity, 0]); -let aspectRatio = 1; - -let index = 0; -let touchesChanged = true; -window.onclick = (e) => { - touches[index][0] = 0 + e.clientX / e.srcElement.clientWidth; - touches[index][1] = 1 - e.clientY / e.srcElement.clientHeight; - touches[index][2] = (Date.now() - start) / 1000; - index = (index + 1) % numTouches; - touchesChanged = true; -}; - -export default ({ config, device, cache, cameraTex, cameraAspectRatio, timeBuffer }) => { +export default ({ config, device, canvas, cache, cameraTex, cameraAspectRatio, timeBuffer }) => { const assets = [loadShader(device, cache, "shaders/wgsl/mirrorPass.wgsl")]; const linearSampler = device.createSampler({ @@ -32,6 +15,24 @@ export default ({ config, device, cache, cameraTex, cameraAspectRatio, timeBuffe minFilter: "linear", }); + let start; + const numTouches = 5; + const touches = Array(numTouches) + .fill() + .map((_) => [0, 0, -Infinity, 0]); + let aspectRatio = 1; + + let index = 0; + let touchesChanged = true; + canvas.onmousedown = (e) => { + const rect = e.srcElement.getBoundingClientRect(); + touches[index][0] = 0 + (e.clientX - rect.x) / rect.width; + touches[index][1] = 1 - (e.clientY - rect.y) / rect.height; + touches[index][2] = (performance.now() - start) / 1000; + index = (index + 1) % numTouches; + touchesChanged = true; + }; + let computePipeline; let configBuffer; let sceneUniforms; @@ -109,7 +110,7 @@ export default ({ config, device, cache, cameraTex, cameraAspectRatio, timeBuffe computePass.end(); }; - start = Date.now(); + start = performance.now(); return makePass("Mirror", loaded, build, run); }; diff --git a/js/webgpu/renderer.js b/js/webgpu/renderer.js index b1a553a..a3e85e0 100644 --- a/js/webgpu/renderer.js +++ b/js/webgpu/renderer.js @@ -27,152 +27,185 @@ const effects = { export default class REGLRenderer extends Renderer { #glMatrix; - #canvasContext; - #adapter; #device; - #renderLoop; + #canvasContext; + #canvasFormat; + #renderFunc; + + #renewingDevice; + #configureIndex = 0; + #rebuildingPipeline; constructor() { super("webgpu", async () => { const libraries = await Renderer.libraries; this.#glMatrix = libraries.glMatrix; - this.#canvasContext = this.canvas.getContext("webgpu"); - this.#adapter = await navigator.gpu.requestAdapter(); - this.#device = await this.#adapter.requestDevice(); }); } - async formulate(config) { - await super.formulate(config); - - const canvas = this.canvas; - const cache = this.cache; - const canvasContext = this.#canvasContext; - const adapter = this.#adapter; - const device = this.#device; - const glMatrix = this.#glMatrix; - + async configure(config) { + const index = ++this.#configureIndex; + await super.configure(config); if (config.useCamera) { await setupCamera(); } - const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); - - // console.table(device.limits); - - canvasContext.configure({ - device, - format: canvasFormat, - alphaMode: "opaque", - usage: - // GPUTextureUsage.STORAGE_BINDING | - GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST, - }); - - const timeUniforms = structs.from(`struct Time { seconds : f32, frames : i32, };`).Time; - const timeBuffer = makeUniformBuffer(device, timeUniforms); - const cameraTex = device.createTexture({ - size: cameraSize, - format: "rgba8unorm", - usage: - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.RENDER_ATTACHMENT, - }); - - const context = { - config, - cache, - adapter, - device, - canvasContext, - timeBuffer, - canvasFormat, - cameraTex, - cameraAspectRatio, - cameraSize, - glMatrix, - }; - - const effectName = config.effect in effects ? config.effect : "palette"; - const pipeline = await makePipeline(context, [ - makeRain, - makeBloomPass, - effects[effectName], - makeEndPass, - ]); - - const targetFrameTimeMilliseconds = 1000 / config.fps; - let frames = 0; - let start = NaN; - let last = NaN; - let outputs; - - const renderLoop = (now) => { - if (isNaN(start)) { - start = now; - } - - if (isNaN(last)) { - last = start; - } - - const shouldRender = - config.fps >= 60 || now - last >= targetFrameTimeMilliseconds || config.once; - if (shouldRender) { - while (now - targetFrameTimeMilliseconds > last) { - last += targetFrameTimeMilliseconds; - } - } - - const devicePixelRatio = window.devicePixelRatio ?? 1; - const size = this.size; - const [width, height] = size; - if (outputs == null || canvas.width !== width || canvas.height !== height) { - [canvas.width, canvas.height] = size; - outputs = pipeline.build(size); - } - - if (config.useCamera) { - device.queue.copyExternalImageToTexture( - { source: cameraCanvas }, - { texture: cameraTex }, - cameraSize, - ); - } - - device.queue.writeBuffer( - timeBuffer, - 0, - timeUniforms.toBuffer({ seconds: (now - start) / 1000, frames }), - ); - frames++; - - const encoder = device.createCommandEncoder(); - pipeline.run(encoder, shouldRender); - // Eventually, when WebGPU allows it, we'll remove the endPass and just copy from our pipeline's output to the canvas texture. - // encoder.copyTextureToTexture({ texture: outputs?.primary }, { texture: canvasContext.getCurrentTexture() }, canvasSize); - device.queue.submit([encoder.finish()]); - - if (!config.once) { - requestAnimationFrame(renderLoop); - } - }; - - if (this.#renderLoop != null) { - cancelAnimationFrame(this.#renderLoop); + if (this.#rebuildingPipeline != null) { + await this.#rebuildingPipeline; } - renderLoop(performance.now()); - this.#renderLoop = renderLoop; + const oldDevice = this.#device; + + if (this.#renewingDevice == null) { + this.#renewingDevice = (async () => { + this.#canvasContext = this.canvas.getContext("webgpu"); + this.#canvasFormat = navigator.gpu.getPreferredCanvasFormat(); + const adapter = await navigator.gpu.requestAdapter(); + this.#device = await adapter.requestDevice(); + })(); + } + await this.#renewingDevice; + this.#renewingDevice = null; + + if (this.#configureIndex !== index || this.destroyed) { + return; + } + + this.#rebuildingPipeline = (async () => { + + const glMatrix = this.#glMatrix; + const canvas = this.canvas; + const cache = this.cache; + const device = this.#device; + const canvasContext = this.#canvasContext; + const canvasFormat = this.#canvasFormat; + const dimensions = { width: 1, height: 1 }; + + const timeUniforms = structs.from(`struct Time { seconds : f32, frames : i32, };`).Time; + const timeBuffer = makeUniformBuffer(device, timeUniforms); + const cameraTex = device.createTexture({ + size: cameraSize, + format: "rgba8unorm", + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const context = { + glMatrix, + config, + cache, + device, + canvas, + canvasContext, + canvasFormat, + timeBuffer, + cameraTex, + cameraAspectRatio, + cameraSize, + }; + + const effectName = config.effect in effects ? config.effect : "palette"; + const pipeline = await makePipeline(context, [ + makeRain, + makeBloomPass, + effects[effectName], + makeEndPass, + ]); + + this.#canvasContext.configure({ + device: this.#device, + format: this.#canvasFormat, + alphaMode: "opaque", + usage: + // GPUTextureUsage.STORAGE_BINDING | + GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST, + }); + + dimensions.width = canvas.width; + dimensions.height = canvas.height; + const targetFrameTimeMilliseconds = 1000 / config.fps; + let frames = 0; + let start = NaN; + let last = NaN; + let outputs; + + this.#renderFunc = (now) => { + if (config.once) { + this.stop(); + } + + if (isNaN(start)) { + start = now; + } + + if (isNaN(last)) { + last = start; + } + + const shouldRender = + config.fps >= 60 || now - last >= targetFrameTimeMilliseconds || config.once; + if (shouldRender) { + while (now - targetFrameTimeMilliseconds > last) { + last += targetFrameTimeMilliseconds; + } + } + + const size = this.size; + const [width, height] = size; + if (outputs == null || dimensions.width !== width || dimensions.height !== height) { + [dimensions.width, dimensions.height] = size; + outputs = pipeline.build(size); + } + + if (config.useCamera) { + device.queue.copyExternalImageToTexture( + { source: cameraCanvas }, + { texture: cameraTex }, + cameraSize, + ); + } + + device.queue.writeBuffer( + timeBuffer, + 0, + timeUniforms.toBuffer({ seconds: (now - start) / 1000, frames }), + ); + frames++; + + const encoder = device.createCommandEncoder(); + pipeline.run(encoder, shouldRender); + // Eventually, when WebGPU allows it, we'll remove the endPass and just copy from our pipeline's output to the canvas texture. + // encoder.copyTextureToTexture({ texture: outputs?.primary }, { texture: canvasContext.getCurrentTexture() }, canvasSize); + device.queue.submit([encoder.finish()]); + }; + })(); + await this.#rebuildingPipeline; + this.#renderFunc(performance.now()); + if (oldDevice != null) { + oldDevice.destroy(); + } + } + + stop() { + super.stop(); + this.#renderFunc = null; + } + + update(now) { + if (this.#renderFunc != null) { + this.#renderFunc(now); + } + super.update(now); } destroy() { - if (this.destroyed) { - return; + if (this.destroyed) return; + if (this.#device != null) { + this.#device.destroy(); // This also destroys any objects created with the device + this.#device = null; } - cancelAnimationFrame(this.#renderLoop); // stop RAF - this.#device.destroy(); // This also destroys any objects created with the device super.destroy(); } } diff --git a/js/webgpu/utils.js b/js/webgpu/utils.js index 3ddbf2d..73eb49b 100644 --- a/js/webgpu/utils.js +++ b/js/webgpu/utils.js @@ -1,20 +1,23 @@ const loadTexture = async (device, cache, url) => { - const key = url; - if (cache.has(key)) { - return cache.get(key); - } - let texture; + const format = "rgba8unorm"; + const usage = + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT; if (url == null) { - texture = device.createTexture({ + return device.createTexture({ size: [1, 1, 1], - format: "rgba8unorm", - usage: - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.RENDER_ATTACHMENT, + format, + usage, }); + } + + let source; + const key = url; + if (cache.has(key)) { + source = cache.get(key); } else { let imageURL; if (typeof cache.get(`url::${url}`) === "function") { @@ -25,23 +28,17 @@ const loadTexture = async (device, cache, url) => { const response = await fetch(imageURL); const data = await response.blob(); - const source = await createImageBitmap(data); - const size = [source.width, source.height, 1]; - - texture = device.createTexture({ - size, - format: "rgba8unorm", - usage: - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.RENDER_ATTACHMENT, - }); - - device.queue.copyExternalImageToTexture({ source, flipY: true }, { texture }, size); + source = await createImageBitmap(data); + cache.set(key, source); } - cache.set(key, texture); - + const size = [source.width, source.height, 1]; + const texture = device.createTexture({ + size, + format, + usage, + }); + device.queue.copyExternalImageToTexture({ source, flipY: true }, { texture }, size); return texture; }; @@ -71,14 +68,16 @@ const makeComputeTarget = (device, size, mipLevelCount = 1) => const loadShader = async (device, cache, url) => { const key = url; - if (cache.has(key)) { - return cache.get(key); - } let code; - if (typeof cache.get(`raw::${url}`) === "function") { - code = (await cache.get(`raw::${url}`)()).default; + if (cache.has(key)) { + code = cache.get(key); } else { - code = await (await fetch(url)).text(); + if (typeof cache.get(`raw::${url}`) === "function") { + code = (await cache.get(`raw::${url}`)()).default; + } else { + code = await (await fetch(url)).text(); + } + cache.set(key, code); } return { code, diff --git a/package.json b/package.json index c9e1d11..5a4d0c2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "README.md" ], "scripts": { + "test": "npm run format && npm run build && vite --config tools/dev.config.js --open /tools/test/index.html", "dev": "npm run format && vite --config tools/dev.config.js", "build": "npm run format && rm -rf ./dist/* && vite build --config tools/build/core.config.js && vite build --config tools/build/full.config.js", "format": "eslint . && prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,mjs,json}' '*.{js,jsx,mjs,json,html}' 'assets/**/*.{json,css}'" diff --git a/shaders/wgsl/rainPass.wgsl b/shaders/wgsl/rainPass.wgsl index af5f2a8..9a457fd 100644 --- a/shaders/wgsl/rainPass.wgsl +++ b/shaders/wgsl/rainPass.wgsl @@ -283,7 +283,6 @@ fn computeSymbol (simTime : f32, isFirstFrame : bool, glyphPos : vec2, scre var symbol = previousSymbol; if (time.frames % config.cycleFrameSkip == 0) { age += cycleSpeed * f32(config.cycleFrameSkip); - var advance = floor(age); if (age > 1.0) { symbol = floor(config.glyphSequenceLength * randomFloat(screenPos + simTime)); age = fract(age); diff --git a/tools/test/index.html b/tools/test/index.html index 45eca59..bbc507e 100644 --- a/tools/test/index.html +++ b/tools/test/index.html @@ -24,7 +24,7 @@ const renderer = new RendererClass(); await renderer.ready; document.querySelector("#test-core-bundled").appendChild(renderer.canvas); - await renderer.formulate(makeConfig({once: false})); + await renderer.configure(makeConfig({once: false}));
@@ -41,7 +41,7 @@ const renderer = new RendererClass(); await renderer.ready; document.querySelector("#test-full-bundled").appendChild(renderer.canvas); - await renderer.formulate(makeConfig({once: false, version: "twilight"})); + await renderer.configure(makeConfig({once: false, version: "twilight"}));
diff --git a/tools/test/test-react.jsx b/tools/test/test-react.jsx index 8b5d0b6..2468ee1 100644 --- a/tools/test/test-react.jsx +++ b/tools/test/test-react.jsx @@ -38,7 +38,7 @@ const App = () => { const [resolution, setResolution] = useState(0.75); const [cursorColor, setCursorColor] = useState(null); const [backgroundColor, setBackgroundColor] = useState("0,0,0"); - const [rendererType, setRendererType] = useState(null); + const [rendererType, setRendererType] = useState("webgpu"); const [density, setDensity] = useState(2); const [destroyed, setDestroyed] = useState(false); const onVersionButtonClick = () => { @@ -68,7 +68,7 @@ const App = () => { setBackgroundColor(null); }; const onRendererButtonClick = () => { - setRendererType(() => (rendererType === "webgpu" ? "regl" : "webgpu")); + setRendererType(rendererType === "webgpu" ? "regl" : "webgpu"); }; const onDestroyButtonClick = () => { setDestroyed(true); @@ -76,54 +76,60 @@ const App = () => { return ( <> - - - - - - { - const values = e.target.value - .match(/[\da-fA-F]{2}/g) - .map((s) => parseInt(s, 16) / 0xff) - .join(","); - setCursorColor(values); - }} - /> - - { - const values = e.target.value - .match(/[\da-fA-F]{2}/g) - .map((s) => parseInt(s, 16) / 0xff) - .join(","); - setBackgroundColor(values); - }} - /> - - setNumColumns(parseInt(e.target.value))} - /> - - setResolution(parseFloat(e.target.value))} - /> +
+ + + + +
+
+ + { + const values = e.target.value + .match(/[\da-fA-F]{2}/g) + .map((s) => parseInt(s, 16) / 0xff) + .join(","); + setCursorColor(values); + }} + /> + + { + const values = e.target.value + .match(/[\da-fA-F]{2}/g) + .map((s) => parseInt(s, 16) / 0xff) + .join(","); + setBackgroundColor(values); + }} + /> +
+
+ + setNumColumns(parseInt(e.target.value))} + /> + + setResolution(parseFloat(e.target.value))} + /> +
{!destroyed && (