Added WebGPU support and added caching to the WebGPU version.

This commit is contained in:
Rezmason
2025-05-05 19:07:36 -07:00
parent 664f484723
commit f3cd449c7d
13 changed files with 129 additions and 84 deletions

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef, memo } from "react"; import React, { useEffect, useState, useRef, memo } from "react";
import { init as initRain, formulate as refreshRain, destroy as destroyRain } from "./regl/main"; // import { init as initRain, formulate as refreshRain, destroy as destroyRain } from "./regl/main";
import { init as initRain, formulate as refreshRain, destroy as destroyRain } from "./webgpu/main";
import makeConfig from "./utils/config"; import makeConfig from "./utils/config";
/** /**
@@ -119,7 +120,7 @@ export const Matrix = memo((props) => {
canvas.style.height = "100%"; canvas.style.height = "100%";
const init = async () => { const init = async () => {
setRain(await initRain(canvas)); setRain(await initRain(canvas));
} };
init(); init();
return () => { return () => {

View File

@@ -43,7 +43,12 @@ const App = () => {
<h1>Rain</h1> <h1>Rain</h1>
<button onClick={onButtonClick}>Change</button> <button onClick={onButtonClick}>Change</button>
{/* <button onClick={newNum}>change number</button> */} {/* <button onClick={newNum}>change number</button> */}
<Matrix style={{width: "80vw", height: "45vh"}} version={version} numColumns={numColumns} density={2.0} /> <Matrix
style={{ width: "80vw", height: "45vh" }}
version={version}
numColumns={numColumns}
density={2.0}
/>
</div> </div>
); );
}; };

View File

@@ -64,15 +64,7 @@ export const init = async (canvas) => {
return rain; return rain;
}; };
export const destroy = (rain) => {
rain.resizeObserver.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("dblclick", doubleClick);
cache.clear();
};
export const formulate = async (rain, config) => { export const formulate = async (rain, config) => {
const { resize, canvas, cache, regl } = rain; const { resize, canvas, cache, regl } = rain;
rain.resolution = config.resolution; rain.resolution = config.resolution;
resize(); resize();
@@ -108,11 +100,11 @@ export const formulate = async (rain, config) => {
let last = NaN; let last = NaN;
resetREGLTime: { resetREGLTime: {
const reset = regl.frame(o => { const reset = regl.frame((o) => {
o.time = 0; o.time = 0;
o.tick = 0; o.tick = 0;
reset.cancel(); reset.cancel();
}) });
} }
const tick = regl.frame(({ viewportWidth, viewportHeight }) => { const tick = regl.frame(({ viewportWidth, viewportHeight }) => {
@@ -160,9 +152,10 @@ export const formulate = async (rain, config) => {
rain.tick = tick; rain.tick = tick;
}; };
export const destroyRain = ({ regl, cache, tick, canvas }) => { export const destroy = ({ regl, resize, doubleClick, cache, tick, canvas }) => {
window.removeEventListener("resize", resize);
window.removeEventListener("dblclick", doubleClick);
cache.clear(); cache.clear();
tick.cancel(); // stop RAF tick.cancel(); // stop RAF
regl.destroy(); // release all GPU resources & event listeners regl.destroy(); // release all GPU resources & event listeners
//canvas.remove(); // drop from the DOM
}; };

View File

@@ -6,6 +6,8 @@ import {
makeBindGroup, makeBindGroup,
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
import bloomBlurShader from "../../shaders/wgsl/bloomBlur.wgsl";
import bloomCombineShader from "../../shaders/wgsl/bloomCombine.wgsl";
// const makePyramid = makeComputeTarget; // const makePyramid = makeComputeTarget;
@@ -54,8 +56,8 @@ export default ({ config, device }) => {
} }
const assets = [ const assets = [
loadShader(device, "shaders/wgsl/bloomBlur.wgsl"), loadShader(device, bloomBlurShader),
loadShader(device, "shaders/wgsl/bloomCombine.wgsl"), loadShader(device, bloomCombineShader),
]; ];
const linearSampler = device.createSampler({ const linearSampler = device.createSampler({

View File

@@ -1,5 +1,7 @@
import { loadShader, makeBindGroup, makePass } from "./utils.js"; import { loadShader, makeBindGroup, makePass } from "./utils.js";
import endPassShader from "../../shaders/wgsl/endPass.wgsl";
// Eventually, WebGPU will allow the output of the final pass in the pipeline to be copied to the canvas texture. // Eventually, WebGPU will allow the output of the final pass in the pipeline to be copied to the canvas texture.
// Until then, this render pass does the job. // Until then, this render pass does the job.
@@ -21,7 +23,7 @@ export default ({ device, canvasFormat, canvasContext }) => {
let renderPipeline; let renderPipeline;
let renderBindGroup; let renderBindGroup;
const assets = [loadShader(device, "shaders/wgsl/endPass.wgsl")]; const assets = [loadShader(device, endPassShader)];
const loaded = (async () => { const loaded = (async () => {
const [imageShader] = await Promise.all(assets); const [imageShader] = await Promise.all(assets);

View File

@@ -7,15 +7,16 @@ import {
makeBindGroup, makeBindGroup,
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
import imagePassShader from "../../shaders/wgsl/imagePass.wgsl";
// Multiplies the rendered rain and bloom by a loaded in image // Multiplies the rendered rain and bloom by a loaded in image
const defaultBGURL = const defaultBGURL =
"https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Flammarion_Colored.jpg/917px-Flammarion_Colored.jpg"; "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Flammarion_Colored.jpg/917px-Flammarion_Colored.jpg";
export default ({ config, device }) => { export default ({ config, cache, device }) => {
const bgURL = "bgURL" in config ? config.bgURL : defaultBGURL; const bgURL = "bgURL" in config ? config.bgURL : defaultBGURL;
const assets = [loadTexture(device, bgURL), loadShader(device, "shaders/wgsl/imagePass.wgsl")]; const assets = [loadTexture(device, cache, bgURL), loadShader(device, imagePassShader)];
const linearSampler = device.createSampler({ const linearSampler = device.createSampler({
magFilter: "linear", magFilter: "linear",

View File

@@ -8,16 +8,7 @@ import makeStripePass from "./stripePass.js";
import makeImagePass from "./imagePass.js"; import makeImagePass from "./imagePass.js";
import makeMirrorPass from "./mirrorPass.js"; import makeMirrorPass from "./mirrorPass.js";
import makeEndPass from "./endPass.js"; import makeEndPass from "./endPass.js";
import { setupCamera, cameraCanvas, cameraAspectRatio, cameraSize } from "../camera.js"; import { setupCamera, cameraCanvas, cameraAspectRatio, cameraSize } from "../utils/camera.js";
const loadJS = (src) =>
new Promise((resolve, reject) => {
const tag = document.createElement("script");
tag.onload = resolve;
tag.onerror = reject;
tag.src = src;
document.body.appendChild(tag);
});
const effects = { const effects = {
none: null, none: null,
@@ -32,31 +23,52 @@ const effects = {
mirror: makeMirrorPass, mirror: makeMirrorPass,
}; };
export default async (canvas, config) => { export const init = async (canvas) => {
await loadJS("lib/gl-matrix.js"); const resize = () => {
const devicePixelRatio = window.devicePixelRatio ?? 1;
canvas.width = Math.ceil(canvas.clientWidth * devicePixelRatio * rain.resolution);
canvas.height = Math.ceil(canvas.clientHeight * devicePixelRatio * rain.resolution);
};
if (document.fullscreenEnabled || document.webkitFullscreenEnabled) { const doubleClick = () => {
window.ondblclick = () => { if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) {
if (document.fullscreenElement == null) { return;
if (canvas.webkitRequestFullscreen != null) { }
canvas.webkitRequestFullscreen(); if (document.fullscreenElement != null) {
} else { document.exitFullscreen();
canvas.requestFullscreen(); return;
} }
} else { if (canvas.webkitRequestFullscreen != null) {
document.exitFullscreen(); canvas.webkitRequestFullscreen();
} } else {
}; canvas.requestFullscreen();
} }
};
const canvasContext = canvas.getContext("webgpu");
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const cache = new Map();
const rain = { canvas, resize, doubleClick, cache, canvasContext, adapter, device, resolution: 1 };
window.addEventListener("dblclick", doubleClick);
window.addEventListener("resize", resize);
resize();
return rain;
};
export const formulate = async (rain, config) => {
const { resize, canvas, cache, canvasContext, adapter, device } = rain;
rain.resolution = config.resolution;
resize();
if (config.useCamera) { if (config.useCamera) {
await setupCamera(); await setupCamera();
} }
const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const canvasContext = canvas.getContext("webgpu");
// console.table(device.limits); // console.table(device.limits);
@@ -82,6 +94,7 @@ export default async (canvas, config) => {
const context = { const context = {
config, config,
cache,
adapter, adapter,
device, device,
canvasContext, canvasContext,
@@ -127,7 +140,7 @@ export default async (canvas, config) => {
const canvasWidth = Math.ceil(canvas.clientWidth * devicePixelRatio * config.resolution); const canvasWidth = Math.ceil(canvas.clientWidth * devicePixelRatio * config.resolution);
const canvasHeight = Math.ceil(canvas.clientHeight * devicePixelRatio * config.resolution); const canvasHeight = Math.ceil(canvas.clientHeight * devicePixelRatio * config.resolution);
const canvasSize = [canvasWidth, canvasHeight]; const canvasSize = [canvasWidth, canvasHeight];
if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) { if (outputs == null || canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
canvas.width = canvasWidth; canvas.width = canvasWidth;
canvas.height = canvasHeight; canvas.height = canvasHeight;
outputs = pipeline.build(canvasSize); outputs = pipeline.build(canvasSize);
@@ -159,5 +172,19 @@ export default async (canvas, config) => {
} }
}; };
requestAnimationFrame(renderLoop); if (rain.renderLoop != null) {
cancelAnimationFrame(rain.renderLoop);
}
renderLoop(performance.now());
rain.renderLoop = renderLoop;
};
export const destroy = ({ device, resize, doubleClick, cache, canvas }) => {
window.removeEventListener("resize", resize);
window.removeEventListener("dblclick", doubleClick);
cache.clear();
tick.cancel(); // stop RAF
// TODO: destroy WebGPU resources
}; };

View File

@@ -6,6 +6,7 @@ import {
makeBindGroup, makeBindGroup,
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
import mirrorPassShader from "../../shaders/wgsl/mirrorPass.wgsl";
let start; let start;
const numTouches = 5; const numTouches = 5;
@@ -25,7 +26,7 @@ window.onclick = (e) => {
}; };
export default ({ config, device, cameraTex, cameraAspectRatio, timeBuffer }) => { export default ({ config, device, cameraTex, cameraAspectRatio, timeBuffer }) => {
const assets = [loadShader(device, "shaders/wgsl/mirrorPass.wgsl")]; const assets = [loadShader(device, mirrorPassShader)];
const linearSampler = device.createSampler({ const linearSampler = device.createSampler({
magFilter: "linear", magFilter: "linear",

View File

@@ -1,4 +1,4 @@
import colorToRGB from "../colorToRGB.js"; import colorToRGB from "../utils/colorToRGB.js";
import { structs } from "../../lib/gpu-buffer.js"; import { structs } from "../../lib/gpu-buffer.js";
import { import {
loadShader, loadShader,
@@ -7,6 +7,7 @@ import {
makeComputeTarget, makeComputeTarget,
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
import palettePassShader from "../../shaders/wgsl/palettePass.wgsl";
// Maps the brightness of the rendered rain and bloom to colors // Maps the brightness of the rendered rain and bloom to colors
// in a linear gradient buffer generated from the passed-in color sequence // in a linear gradient buffer generated from the passed-in color sequence
@@ -86,7 +87,7 @@ export default ({ config, device, timeBuffer }) => {
let output; let output;
let screenSize; let screenSize;
const assets = [loadShader(device, "shaders/wgsl/palettePass.wgsl")]; const assets = [loadShader(device, palettePassShader)];
const loaded = (async () => { const loaded = (async () => {
const [paletteShader] = await Promise.all(assets); const [paletteShader] = await Promise.all(assets);

View File

@@ -7,6 +7,8 @@ import {
makeBindGroup, makeBindGroup,
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
import { mat2, mat4, vec2, vec3 } from "gl-matrix";
import rainPassShader from "../../shaders/wgsl/rainPass.wgsl";
const rippleTypes = { const rippleTypes = {
box: 0, box: 0,
@@ -29,18 +31,17 @@ const makeConfigBuffer = (device, configUniforms, config, density, gridSize, gly
}; };
// console.table(configData); // console.table(configData);
console.log(configUniforms, configData);
return makeUniformBuffer(device, configUniforms, configData); return makeUniformBuffer(device, configUniforms, configData);
}; };
export default ({ config, device, timeBuffer }) => { export default ({ config, cache, device, timeBuffer }) => {
const { mat2, mat4, vec2, vec3 } = glMatrix;
const assets = [ const assets = [
loadTexture(device, config.glyphMSDFURL), loadTexture(device, cache, config.glyphMSDFURL),
loadTexture(device, config.glintMSDFURL), loadTexture(device, cache, config.glintMSDFURL),
loadTexture(device, config.baseTextureURL, false, true), loadTexture(device, cache, config.baseTextureURL, false, true),
loadTexture(device, config.glintTextureURL, false, true), loadTexture(device, cache, config.glintTextureURL, false, true),
loadShader(device, "shaders/wgsl/rainPass.wgsl"), loadShader(device, rainPassShader),
]; ];
// The volumetric mode multiplies the number of columns // The volumetric mode multiplies the number of columns

View File

@@ -1,4 +1,4 @@
import colorToRGB from "../colorToRGB.js"; import colorToRGB from "../utils/colorToRGB.js";
import { structs } from "../../lib/gpu-buffer.js"; import { structs } from "../../lib/gpu-buffer.js";
import { import {
loadShader, loadShader,
@@ -8,6 +8,7 @@ import {
makeComputeTarget, makeComputeTarget,
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
import stripePassShader from "../../shaders/wgsl/stripePass.wgsl";
// Multiplies the rendered rain and bloom by a 1D gradient texture // Multiplies the rendered rain and bloom by a 1D gradient texture
// generated from the passed-in color sequence // generated from the passed-in color sequence
@@ -68,7 +69,7 @@ export default ({ config, device, timeBuffer }) => {
let output; let output;
let screenSize; let screenSize;
const assets = [loadShader(device, "shaders/wgsl/stripePass.wgsl")]; const assets = [loadShader(device, stripePassShader)];
const loaded = (async () => { const loaded = (async () => {
const [stripeShader] = await Promise.all(assets); const [stripeShader] = await Promise.all(assets);

View File

@@ -1,6 +1,14 @@
const loadTexture = async (device, url) => { const loadTexture = async (device, cache, url) => {
const key = url;
if (cache.has(key)) {
return cache.get(key);
}
let texture;
if (url == null) { if (url == null) {
return device.createTexture({ texture = device.createTexture({
size: [1, 1, 1], size: [1, 1, 1],
format: "rgba8unorm", format: "rgba8unorm",
usage: usage:
@@ -8,23 +16,25 @@ const loadTexture = async (device, url) => {
GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT, GPUTextureUsage.RENDER_ATTACHMENT,
}); });
} else {
const response = await fetch(url);
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);
} }
const response = await fetch(url); cache.set(key, texture);
const data = await response.blob();
const source = await createImageBitmap(data);
const size = [source.width, source.height, 1];
const texture = device.createTexture({
size,
format: "rgba8unorm",
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
device.queue.copyExternalImageToTexture({ source, flipY: true }, { texture }, size);
return texture; return texture;
}; };
@@ -53,9 +63,9 @@ const makeComputeTarget = (device, size, mipLevelCount = 1) =>
GPUTextureUsage.STORAGE_BINDING, GPUTextureUsage.STORAGE_BINDING,
}); });
const loadShader = async (device, url) => { const loadShader = async (device, code /*text*/) => {
const response = await fetch(url); // const response = await fetch(url);
const code = await response.text(); // const code = await response.text();
return { return {
code, code,
module: device.createShaderModule({ code }), module: device.createShaderModule({ code }),

View File

@@ -21,7 +21,7 @@ module.exports = {
type: "asset/resource", type: "asset/resource",
}, },
{ {
test: /\.(glsl|frag|vert)$/i, test: /\.(glsl|frag|vert|wgsl)$/i,
exclude: /node_modules/, exclude: /node_modules/,
use: ["raw-loader"], use: ["raw-loader"],
}, },