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 { 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";
/**
@@ -119,7 +120,7 @@ export const Matrix = memo((props) => {
canvas.style.height = "100%";
const init = async () => {
setRain(await initRain(canvas));
}
};
init();
return () => {

View File

@@ -43,7 +43,12 @@ const App = () => {
<h1>Rain</h1>
<button onClick={onButtonClick}>Change</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>
);
};

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
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.
// Until then, this render pass does the job.
@@ -21,7 +23,7 @@ export default ({ device, canvasFormat, canvasContext }) => {
let renderPipeline;
let renderBindGroup;
const assets = [loadShader(device, "shaders/wgsl/endPass.wgsl")];
const assets = [loadShader(device, endPassShader)];
const loaded = (async () => {
const [imageShader] = await Promise.all(assets);

View File

@@ -7,15 +7,16 @@ import {
makeBindGroup,
makePass,
} from "./utils.js";
import imagePassShader from "../../shaders/wgsl/imagePass.wgsl";
// Multiplies the rendered rain and bloom by a loaded in image
const defaultBGURL =
"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 assets = [loadTexture(device, bgURL), loadShader(device, "shaders/wgsl/imagePass.wgsl")];
const assets = [loadTexture(device, cache, bgURL), loadShader(device, imagePassShader)];
const linearSampler = device.createSampler({
magFilter: "linear",

View File

@@ -8,16 +8,7 @@ import makeStripePass from "./stripePass.js";
import makeImagePass from "./imagePass.js";
import makeMirrorPass from "./mirrorPass.js";
import makeEndPass from "./endPass.js";
import { setupCamera, cameraCanvas, cameraAspectRatio, cameraSize } from "../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);
});
import { setupCamera, cameraCanvas, cameraAspectRatio, cameraSize } from "../utils/camera.js";
const effects = {
none: null,
@@ -32,31 +23,52 @@ const effects = {
mirror: makeMirrorPass,
};
export default async (canvas, config) => {
await loadJS("lib/gl-matrix.js");
export const init = async (canvas) => {
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) {
window.ondblclick = () => {
if (document.fullscreenElement == null) {
if (canvas.webkitRequestFullscreen != null) {
canvas.webkitRequestFullscreen();
} else {
canvas.requestFullscreen();
}
} else {
document.exitFullscreen();
}
};
}
const doubleClick = () => {
if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) {
return;
}
if (document.fullscreenElement != null) {
document.exitFullscreen();
return;
}
if (canvas.webkitRequestFullscreen != null) {
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) {
await setupCamera();
}
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);
@@ -82,6 +94,7 @@ export default async (canvas, config) => {
const context = {
config,
cache,
adapter,
device,
canvasContext,
@@ -127,7 +140,7 @@ export default async (canvas, config) => {
const canvasWidth = Math.ceil(canvas.clientWidth * devicePixelRatio * config.resolution);
const canvasHeight = Math.ceil(canvas.clientHeight * devicePixelRatio * config.resolution);
const canvasSize = [canvasWidth, canvasHeight];
if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
if (outputs == null || canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
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,
makePass,
} from "./utils.js";
import mirrorPassShader from "../../shaders/wgsl/mirrorPass.wgsl";
let start;
const numTouches = 5;
@@ -25,7 +26,7 @@ window.onclick = (e) => {
};
export default ({ config, device, cameraTex, cameraAspectRatio, timeBuffer }) => {
const assets = [loadShader(device, "shaders/wgsl/mirrorPass.wgsl")];
const assets = [loadShader(device, mirrorPassShader)];
const linearSampler = device.createSampler({
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 {
loadShader,
@@ -7,6 +7,7 @@ import {
makeComputeTarget,
makePass,
} from "./utils.js";
import palettePassShader from "../../shaders/wgsl/palettePass.wgsl";
// Maps the brightness of the rendered rain and bloom to colors
// in a linear gradient buffer generated from the passed-in color sequence
@@ -86,7 +87,7 @@ export default ({ config, device, timeBuffer }) => {
let output;
let screenSize;
const assets = [loadShader(device, "shaders/wgsl/palettePass.wgsl")];
const assets = [loadShader(device, palettePassShader)];
const loaded = (async () => {
const [paletteShader] = await Promise.all(assets);

View File

@@ -7,6 +7,8 @@ import {
makeBindGroup,
makePass,
} from "./utils.js";
import { mat2, mat4, vec2, vec3 } from "gl-matrix";
import rainPassShader from "../../shaders/wgsl/rainPass.wgsl";
const rippleTypes = {
box: 0,
@@ -29,18 +31,17 @@ const makeConfigBuffer = (device, configUniforms, config, density, gridSize, gly
};
// console.table(configData);
console.log(configUniforms, configData);
return makeUniformBuffer(device, configUniforms, configData);
};
export default ({ config, device, timeBuffer }) => {
const { mat2, mat4, vec2, vec3 } = glMatrix;
export default ({ config, cache, device, timeBuffer }) => {
const assets = [
loadTexture(device, config.glyphMSDFURL),
loadTexture(device, config.glintMSDFURL),
loadTexture(device, config.baseTextureURL, false, true),
loadTexture(device, config.glintTextureURL, false, true),
loadShader(device, "shaders/wgsl/rainPass.wgsl"),
loadTexture(device, cache, config.glyphMSDFURL),
loadTexture(device, cache, config.glintMSDFURL),
loadTexture(device, cache, config.baseTextureURL, false, true),
loadTexture(device, cache, config.glintTextureURL, false, true),
loadShader(device, rainPassShader),
];
// 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 {
loadShader,
@@ -8,6 +8,7 @@ import {
makeComputeTarget,
makePass,
} from "./utils.js";
import stripePassShader from "../../shaders/wgsl/stripePass.wgsl";
// Multiplies the rendered rain and bloom by a 1D gradient texture
// generated from the passed-in color sequence
@@ -68,7 +69,7 @@ export default ({ config, device, timeBuffer }) => {
let output;
let screenSize;
const assets = [loadShader(device, "shaders/wgsl/stripePass.wgsl")];
const assets = [loadShader(device, stripePassShader)];
const loaded = (async () => {
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) {
return device.createTexture({
texture = device.createTexture({
size: [1, 1, 1],
format: "rgba8unorm",
usage:
@@ -8,23 +16,25 @@ const loadTexture = async (device, url) => {
GPUTextureUsage.COPY_DST |
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);
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);
cache.set(key, texture);
return texture;
};
@@ -53,9 +63,9 @@ const makeComputeTarget = (device, size, mipLevelCount = 1) =>
GPUTextureUsage.STORAGE_BINDING,
});
const loadShader = async (device, url) => {
const response = await fetch(url);
const code = await response.text();
const loadShader = async (device, code /*text*/) => {
// const response = await fetch(url);
// const code = await response.text();
return {
code,
module: device.createShaderModule({ code }),

View File

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