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.

This commit is contained in:
Rezmason
2025-05-25 03:30:26 -07:00
parent 1da1feb356
commit b6570de106
15 changed files with 405 additions and 351 deletions

View File

@@ -1,22 +1,16 @@
TODO: TODO:
WebGPU formulate is expensive Minimum react requirement?
Mirror pass clicks bug
Minify bundles Minify bundles
Naming "matrix" for the github repo, "digital-rain" and "DigitalRain" for everything else Naming "matrix" for the github repo, "digital-rain" and "DigitalRain" for everything else
Minimum react requirement?
Retire fetchLibraries? Retire fetchLibraries?
Preserve time across configure calls, move times into renderer and uniforms
Move off of regl Move off of regl
Unify implementations? Unify implementations?
Responsive changes Reshape all passes to react to config changes, ie. "configure"
Move start time to rain object simple deltas only require updating the uniforms
Matrix component should record, then overwrite it return boolean of whether all deltas are simple
Reshape all passes to react to config changes, ie. "configure" Resource changes are simple if they're cached and loaded, false otherwise
main.js "formulate" --> "configure" remake the pipeline if anything returns false
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 vs full
core core
One embedded MSDF, combined from the two main glyph sets and their configs One embedded MSDF, combined from the two main glyph sets and their configs

View File

@@ -31,7 +31,7 @@ import makeConfig from "./utils/config";
* volumetric?: boolean, * volumetric?: boolean,
* loops?: boolean, * loops?: boolean,
* skipIntro?: boolean, * skipIntro?: boolean,
* renderer?: "regl" | "three" | string, * renderer?: "regl" | "webgpu" | string,
* suppressWarnings?: boolean, * suppressWarnings?: boolean,
* useHalfFloat?: boolean, * useHalfFloat?: boolean,
* isometric?: boolean, * isometric?: boolean,
@@ -109,9 +109,10 @@ export const Matrix = memo((props) => {
const elProps = { style, className }; const elProps = { style, className };
const domElement = useRef(null); const domElement = useRef(null);
const [rRenderer, setRenderer] = useState(null); const [rRenderer, setRenderer] = useState(null);
const rendererType = useRef(null);
const [rSize, setSize] = useState([1, 1]); const [rSize, setSize] = useState([1, 1]);
const [rConfig, setConfig] = useState(makeConfig({})); const [rConfig, setConfig] = useState(makeConfig({}));
const rendererClasses = {}; const rendererModules = {};
const resizeObserver = new ResizeObserver(entries => { const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) { for (const entry of entries) {
@@ -126,14 +127,6 @@ export const Matrix = memo((props) => {
resizeObserver.observe(domElement.current); resizeObserver.observe(domElement.current);
}, [domElement]); }, [domElement]);
useEffect(() => {
setConfig(makeConfig({
...Object.fromEntries(
Object.entries(rawConfigProps).filter(([_, value]) => value != null),
)
}));
}, [props]);
const supportsWebGPU = () => { const supportsWebGPU = () => {
return ( return (
window.GPUQueue != null && 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 = () => { const cleanup = () => {
if (rRenderer == null) return; if (rRenderer == null) return;
rRenderer.canvas.remove(); rRenderer.canvas.remove();
@@ -150,37 +155,40 @@ export const Matrix = memo((props) => {
}; };
useEffect(() => { useEffect(() => {
const useWebGPU = supportsWebGPU() && rConfig.renderer === "webgpu"; rendererType.current = rConfig.renderer;
const isWebGPU = rRenderer?.type === "webgpu"; 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 () => { (async () => {
let renderer; const rendererClass = (await rendererModule).default;
if (useWebGPU) { if (rendererType.current !== rConfig.renderer) return;
rendererClasses.webgpu ??= (await import("./webgpu/renderer.js")).default; const renderer = new rendererClass();
renderer = new (rendererClasses.webgpu)();
} else {
rendererClasses.regl ??= (await import("./regl/renderer.js")).default;
renderer = new (rendererClasses.regl)();
}
setRenderer(renderer);
await renderer.ready; 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; const canvas = renderer.canvas;
canvas.style.width = "100%"; canvas.style.width = "100%";
canvas.style.height = "100%"; canvas.style.height = "100%";
domElement.current.appendChild(canvas); domElement.current.appendChild(canvas);
}; })();
if (rRenderer == null || useWebGPU !== isWebGPU) {
cleanup();
loadRain();
}
return cleanup; return cleanup;
}, [rConfig.renderer]); }, [rConfig.renderer]);
useEffect(() => { useEffect(() => {
if (rRenderer?.destroyed ?? true) return; if (rRenderer?.destroyed ?? true) return;
rRenderer.formulate(rConfig); rRenderer.configure(rConfig);
}, [rRenderer, rConfig]); }, [rRenderer, rConfig]);
useEffect(() => { useEffect(() => {

View File

@@ -37,7 +37,7 @@ document.body.onload = async () => {
renderer.fullscreen = !renderer.fullscreen; renderer.fullscreen = !renderer.fullscreen;
}); });
document.body.appendChild(renderer.canvas); document.body.appendChild(renderer.canvas);
await renderer.formulate(config); await renderer.configure(config);
}; };
if (isRunningSwiftShader() && !config.suppressWarnings) { if (isRunningSwiftShader() && !config.suppressWarnings) {

View File

@@ -1,19 +1,21 @@
import { loadText, makePassFBO, makePass } from "./utils.js"; import { loadText, makePassFBO, makePass } from "./utils.js";
let start; export default ({ regl, canvas, cache, config, cameraTex, cameraAspectRatio }, inputs) => {
const numClicks = 5;
const clicks = Array(numClicks).fill([0, 0, -Infinity]).flat();
let aspectRatio = 1;
let index = 0; let start;
window.onclick = (e) => { const numClicks = 5;
clicks[index * 3 + 0] = 0 + e.clientX / e.srcElement.clientWidth; const clicks = Array(numClicks).fill().map(_ => ([0, 0, -Infinity]));
clicks[index * 3 + 1] = 1 - e.clientY / e.srcElement.clientHeight; let aspectRatio = 1;
clicks[index * 3 + 2] = (Date.now() - start) / 1000;
index = (index + 1) % numClicks; 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 output = makePassFBO(regl, config.useHalfFloat);
const mirrorPassFrag = loadText(cache, "shaders/glsl/mirrorPass.frag.glsl"); const mirrorPassFrag = loadText(cache, "shaders/glsl/mirrorPass.frag.glsl");
const render = regl({ const render = regl({
@@ -23,14 +25,19 @@ export default ({ regl, cache, config, cameraTex, cameraAspectRatio }, inputs) =
tex: inputs.primary, tex: inputs.primary,
bloomTex: inputs.bloom, bloomTex: inputs.bloom,
cameraTex, 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, aspectRatio: () => aspectRatio,
cameraAspectRatio, cameraAspectRatio,
}, },
framebuffer: output, framebuffer: output,
}); });
start = Date.now(); start = performance.now();
return makePass( return makePass(
{ {

View File

@@ -24,7 +24,7 @@ const effects = {
export default class REGLRenderer extends Renderer { export default class REGLRenderer extends Renderer {
#tick; #renderFunc;
#regl; #regl;
#glMatrix; #glMatrix;
@@ -43,26 +43,24 @@ export default class REGLRenderer extends Renderer {
}); });
} }
async formulate(config) { async configure(config) {
await super.formulate(config); await super.configure(config);
const canvas = this.canvas;
const cache = this.cache;
const regl = this.#regl;
const glMatrix = this.#glMatrix;
const dimensions = { width: 1, height: 1 };
if (config.useCamera) { if (config.useCamera) {
await setupCamera(); 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); const cameraTex = regl.texture(cameraCanvas);
// All this takes place in a full screen quad. // All this takes place in a full screen quad.
const fullScreenQuad = makeFullScreenQuad(regl); const fullScreenQuad = makeFullScreenQuad(regl);
const effectName = config.effect in effects ? config.effect : "palette"; 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 pipeline = makePipeline(context, [makeRain, makeBloomPass, effects[effectName]]);
const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary }; const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary };
@@ -75,17 +73,15 @@ export default class REGLRenderer extends Renderer {
const targetFrameTimeMilliseconds = 1000 / config.fps; const targetFrameTimeMilliseconds = 1000 / config.fps;
let last = NaN; let last = NaN;
resetREGLTime: { const reset = regl.frame((reglContext) => {
const reset = regl.frame((o) => { reglContext.tick = 0;
o.time = 0; reset.cancel();
o.tick = 0; });
reset.cancel();
}); this.#renderFunc = (reglContext) => {
}
const tick = regl.frame(({ viewportWidth, viewportHeight }) => {
if (config.once) { if (config.once) {
tick.cancel(); this.stop();
} }
const now = regl.now() * 1000; const now = regl.now() * 1000;
@@ -106,6 +102,7 @@ export default class REGLRenderer extends Renderer {
if (config.useCamera) { if (config.useCamera) {
cameraTex(cameraCanvas); cameraTex(cameraCanvas);
} }
const {viewportWidth, viewportHeight} = reglContext;
if (dimensions.width !== viewportWidth || dimensions.height !== viewportHeight) { if (dimensions.width !== viewportWidth || dimensions.height !== viewportHeight) {
dimensions.width = viewportWidth; dimensions.width = viewportWidth;
dimensions.height = viewportHeight; dimensions.height = viewportHeight;
@@ -119,20 +116,31 @@ export default class REGLRenderer extends Renderer {
} }
drawToScreen(); drawToScreen();
}); });
};
const frame = this.#regl.frame(o => {
this.#renderFunc(o);
frame.cancel();
}); });
}
if (this.#tick != null) { stop() {
this.#tick.cancel(); super.stop();
this.#renderFunc = null;
}
update(now) {
if (this.#renderFunc != null) {
const frame = this.#regl.frame(o => {
this.#renderFunc(o);
frame.cancel();
})
} }
super.update(now);
this.#tick = tick;
} }
destroy() { destroy() {
if (this.destroyed) { if (this.destroyed) return;
return;
}
this.#tick.cancel(); // stop RAF
this.#regl.destroy(); // releases all GPU resources & event listeners this.#regl.destroy(); // releases all GPU resources & event listeners
super.destroy(); super.destroy();
} }

View File

@@ -11,6 +11,7 @@ export default class Renderer {
#fullscreen = false; #fullscreen = false;
#cache = new Map(); #cache = new Map();
#destroyed = false; #destroyed = false;
#running = false;
constructor(type, ready) { constructor(type, ready) {
this.#type = type; this.#type = type;
@@ -18,48 +19,46 @@ export default class Renderer {
this.#ready = Renderer.libraries.then(libraries => { this.#ready = Renderer.libraries.then(libraries => {
this.#cache = new Map(libraries.staticAssets); this.#cache = new Map(libraries.staticAssets);
}).then(ready); }).then(ready);
this.#ready.then(() => this.start());
} }
get canvas() { get running() { return this.#running; }
return this.#canvas;
start() {
this.#running = true;
this.update();
} }
get cache() { stop() {
return this.#cache; this.#running = false;
} }
get type () { update(now) {
return this.#type; if (!this.#running) return;
requestAnimationFrame(now => this.update(now));
} }
get ready () { get canvas() { return this.#canvas; }
return this.#ready;
}
get size() { get cache() { return this.#cache; }
return [this.#width, this.#height];
} get type () { return this.#type; }
get ready () { return this.#ready; }
get size() { return ([this.#width, this.#height]); }
set size([width, height]) { set size([width, height]) {
[width, height] = [Math.ceil(width), Math.ceil(height)]; [width, height] = [Math.ceil(width), Math.ceil(height)];
if (width === this.#width && height === this.#height) { if (width === this.#width && height === this.#height) return;
return;
}
[this.#canvas.width, this.#canvas.height] = [this.#width, this.#height] = [width, height]; [this.#canvas.width, this.#canvas.height] = [this.#width, this.#height] = [width, height];
} }
get fullscreen() { get fullscreen() { return this.#fullscreen; }
return this.#fullscreen;
}
set fullscreen(value) { set fullscreen(value) {
if (!!value === this.#fullscreen) { if (!!value === this.#fullscreen) return;
return; if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) return;
}
if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) {
return;
}
this.#fullscreen = value; this.#fullscreen = value;
if (document.fullscreenElement != null) { if (document.fullscreenElement != null) {
@@ -74,18 +73,17 @@ export default class Renderer {
} }
} }
async formulate(config) { async configure(config) {
await this.ready; await this.ready;
if (this.destroyed) { if (this.destroyed) {
throw new Error("Cannot formulate a destroyed rain instance."); throw new Error("Cannot configure a destroyed rain instance.");
} }
} }
get destroyed() { get destroyed() { return this.#destroyed; }
return this.#destroyed;
}
destroy() { destroy() {
this.stop();
this.#destroyed = true; this.#destroyed = true;
this.#cache.clear(); this.#cache.clear();
} }

View File

@@ -26,7 +26,7 @@ const makePyramid = (device, size, pyramidHeight) =>
.map((_, index) => .map((_, index) =>
makeComputeTarget( makeComputeTarget(
device, 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) => { const build = (screenSize, inputs) => {
// Since the bloom is blurry, we downscale everything // 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); destroyPyramid(hBlurPyramid);
hBlurPyramid = makePyramid(device, scaledScreenSize, pyramidHeight); hBlurPyramid = makePyramid(device, scaledScreenSize, pyramidHeight);
@@ -169,8 +169,8 @@ export default ({ config, device, cache }) => {
computePass.setPipeline(blurPipeline); computePass.setPipeline(blurPipeline);
for (let i = 0; i < pyramidHeight; i++) { for (let i = 0; i < pyramidHeight; i++) {
const dispatchSize = [ const dispatchSize = [
Math.ceil(Math.floor(scaledScreenSize[0] * 2 ** -i) / 32), Math.max(1, Math.ceil(Math.floor(scaledScreenSize[0] * 2 ** -i) / 32)),
Math.floor(Math.floor(scaledScreenSize[1] * 2 ** -i)), Math.max(1, Math.floor(Math.floor(scaledScreenSize[1] * 2 ** -i))),
1, 1,
]; ];
computePass.setBindGroup(0, hBlurBindGroups[i]); computePass.setBindGroup(0, hBlurBindGroups[i]);

View File

@@ -49,7 +49,7 @@ export default ({ device, cache, canvasFormat, canvasContext }) => {
nearestSampler, nearestSampler,
inputs.primary.createView(), inputs.primary.createView(),
]); ]);
return null; return {};
}; };
const run = (encoder, shouldRender) => { const run = (encoder, shouldRender) => {

View File

@@ -7,24 +7,7 @@ import {
makePass, makePass,
} from "./utils.js"; } from "./utils.js";
let start; export default ({ config, device, canvas, cache, cameraTex, cameraAspectRatio, timeBuffer }) => {
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 }) => {
const assets = [loadShader(device, cache, "shaders/wgsl/mirrorPass.wgsl")]; const assets = [loadShader(device, cache, "shaders/wgsl/mirrorPass.wgsl")];
const linearSampler = device.createSampler({ const linearSampler = device.createSampler({
@@ -32,6 +15,24 @@ export default ({ config, device, cache, cameraTex, cameraAspectRatio, timeBuffe
minFilter: "linear", 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 computePipeline;
let configBuffer; let configBuffer;
let sceneUniforms; let sceneUniforms;
@@ -109,7 +110,7 @@ export default ({ config, device, cache, cameraTex, cameraAspectRatio, timeBuffe
computePass.end(); computePass.end();
}; };
start = Date.now(); start = performance.now();
return makePass("Mirror", loaded, build, run); return makePass("Mirror", loaded, build, run);
}; };

View File

@@ -27,152 +27,185 @@ const effects = {
export default class REGLRenderer extends Renderer { export default class REGLRenderer extends Renderer {
#glMatrix; #glMatrix;
#canvasContext;
#adapter;
#device; #device;
#renderLoop; #canvasContext;
#canvasFormat;
#renderFunc;
#renewingDevice;
#configureIndex = 0;
#rebuildingPipeline;
constructor() { constructor() {
super("webgpu", async () => { super("webgpu", async () => {
const libraries = await Renderer.libraries; const libraries = await Renderer.libraries;
this.#glMatrix = libraries.glMatrix; 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) { async configure(config) {
await super.formulate(config); const index = ++this.#configureIndex;
await super.configure(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;
if (config.useCamera) { if (config.useCamera) {
await setupCamera(); await setupCamera();
} }
const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); if (this.#rebuildingPipeline != null) {
await this.#rebuildingPipeline;
// 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);
} }
renderLoop(performance.now()); const oldDevice = this.#device;
this.#renderLoop = renderLoop;
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() { destroy() {
if (this.destroyed) { if (this.destroyed) return;
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(); super.destroy();
} }
} }

View File

@@ -1,20 +1,23 @@
const loadTexture = async (device, cache, url) => { 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) { if (url == null) {
texture = device.createTexture({ return device.createTexture({
size: [1, 1, 1], size: [1, 1, 1],
format: "rgba8unorm", format,
usage: usage,
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
}); });
}
let source;
const key = url;
if (cache.has(key)) {
source = cache.get(key);
} else { } else {
let imageURL; let imageURL;
if (typeof cache.get(`url::${url}`) === "function") { if (typeof cache.get(`url::${url}`) === "function") {
@@ -25,23 +28,17 @@ const loadTexture = async (device, cache, url) => {
const response = await fetch(imageURL); const response = await fetch(imageURL);
const data = await response.blob(); const data = await response.blob();
const source = await createImageBitmap(data); source = await createImageBitmap(data);
const size = [source.width, source.height, 1]; cache.set(key, source);
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); const size = [source.width, source.height, 1];
const texture = device.createTexture({
size,
format,
usage,
});
device.queue.copyExternalImageToTexture({ source, flipY: true }, { texture }, size);
return texture; return texture;
}; };
@@ -71,14 +68,16 @@ const makeComputeTarget = (device, size, mipLevelCount = 1) =>
const loadShader = async (device, cache, url) => { const loadShader = async (device, cache, url) => {
const key = url; const key = url;
if (cache.has(key)) {
return cache.get(key);
}
let code; let code;
if (typeof cache.get(`raw::${url}`) === "function") { if (cache.has(key)) {
code = (await cache.get(`raw::${url}`)()).default; code = cache.get(key);
} else { } 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 { return {
code, code,

View File

@@ -11,6 +11,7 @@
"README.md" "README.md"
], ],
"scripts": { "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", "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", "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}'" "format": "eslint . && prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,mjs,json}' '*.{js,jsx,mjs,json,html}' 'assets/**/*.{json,css}'"

View File

@@ -283,7 +283,6 @@ fn computeSymbol (simTime : f32, isFirstFrame : bool, glyphPos : vec2<f32>, scre
var symbol = previousSymbol; var symbol = previousSymbol;
if (time.frames % config.cycleFrameSkip == 0) { if (time.frames % config.cycleFrameSkip == 0) {
age += cycleSpeed * f32(config.cycleFrameSkip); age += cycleSpeed * f32(config.cycleFrameSkip);
var advance = floor(age);
if (age > 1.0) { if (age > 1.0) {
symbol = floor(config.glyphSequenceLength * randomFloat(screenPos + simTime)); symbol = floor(config.glyphSequenceLength * randomFloat(screenPos + simTime));
age = fract(age); age = fract(age);

View File

@@ -24,7 +24,7 @@
const renderer = new RendererClass(); const renderer = new RendererClass();
await renderer.ready; await renderer.ready;
document.querySelector("#test-core-bundled").appendChild(renderer.canvas); document.querySelector("#test-core-bundled").appendChild(renderer.canvas);
await renderer.formulate(makeConfig({once: false})); await renderer.configure(makeConfig({once: false}));
</script> </script>
</details> </details>
<details id="test-core-react-bundled"> <details id="test-core-react-bundled">
@@ -41,7 +41,7 @@
const renderer = new RendererClass(); const renderer = new RendererClass();
await renderer.ready; await renderer.ready;
document.querySelector("#test-full-bundled").appendChild(renderer.canvas); document.querySelector("#test-full-bundled").appendChild(renderer.canvas);
await renderer.formulate(makeConfig({once: false, version: "twilight"})); await renderer.configure(makeConfig({once: false, version: "twilight"}));
</script> </script>
</details> </details>
</body> </body>

View File

@@ -38,7 +38,7 @@ const App = () => {
const [resolution, setResolution] = useState(0.75); const [resolution, setResolution] = useState(0.75);
const [cursorColor, setCursorColor] = useState(null); const [cursorColor, setCursorColor] = useState(null);
const [backgroundColor, setBackgroundColor] = useState("0,0,0"); const [backgroundColor, setBackgroundColor] = useState("0,0,0");
const [rendererType, setRendererType] = useState(null); const [rendererType, setRendererType] = useState("webgpu");
const [density, setDensity] = useState(2); const [density, setDensity] = useState(2);
const [destroyed, setDestroyed] = useState(false); const [destroyed, setDestroyed] = useState(false);
const onVersionButtonClick = () => { const onVersionButtonClick = () => {
@@ -68,7 +68,7 @@ const App = () => {
setBackgroundColor(null); setBackgroundColor(null);
}; };
const onRendererButtonClick = () => { const onRendererButtonClick = () => {
setRendererType(() => (rendererType === "webgpu" ? "regl" : "webgpu")); setRendererType(rendererType === "webgpu" ? "regl" : "webgpu");
}; };
const onDestroyButtonClick = () => { const onDestroyButtonClick = () => {
setDestroyed(true); setDestroyed(true);
@@ -76,54 +76,60 @@ const App = () => {
return ( return (
<> <>
<button onClick={onVersionButtonClick}>Version: "{version}"</button> <div>
<button onClick={onEffectButtonClick}>Effect: "{effect}"</button> <button onClick={onVersionButtonClick}>Version: "{version}"</button>
<button onClick={onRendererButtonClick}>Renderer: {rendererType ?? "default (regl)"}</button> <button onClick={onEffectButtonClick}>Effect: "{effect}"</button>
<button onClick={onDestroyButtonClick}>Destroy</button> <button onClick={onRendererButtonClick}>Renderer: {rendererType ?? "default (regl)"}</button>
<label htmlFor="cursor-color">Cursor color: </label> <button onClick={onDestroyButtonClick}>Destroy</button>
<input </div>
name="cursor-color" <div>
type="color" <label htmlFor="cursor-color">Cursor color: </label>
onChange={(e) => { <input
const values = e.target.value name="cursor-color"
.match(/[\da-fA-F]{2}/g) type="color"
.map((s) => parseInt(s, 16) / 0xff) onChange={(e) => {
.join(","); const values = e.target.value
setCursorColor(values); .match(/[\da-fA-F]{2}/g)
}} .map((s) => parseInt(s, 16) / 0xff)
/> .join(",");
<label htmlFor="background-color">Background color: </label> setCursorColor(values);
<input }}
name="background-color" />
type="color" <label htmlFor="background-color">Background color: </label>
onChange={(e) => { <input
const values = e.target.value name="background-color"
.match(/[\da-fA-F]{2}/g) type="color"
.map((s) => parseInt(s, 16) / 0xff) onChange={(e) => {
.join(","); const values = e.target.value
setBackgroundColor(values); .match(/[\da-fA-F]{2}/g)
}} .map((s) => parseInt(s, 16) / 0xff)
/> .join(",");
<label htmlFor="num-columns"># of columns:</label> setBackgroundColor(values);
<input }}
name="num-columns" />
type="range" </div>
value={numColumns} <div>
min="10" <label htmlFor="num-columns"># of columns:</label>
max="160" <input
step="1" name="num-columns"
onInput={(e) => setNumColumns(parseInt(e.target.value))} type="range"
/> value={numColumns}
<label htmlFor="resolution">resolution:</label> min="10"
<input max="160"
name="resolution" step="1"
type="range" onInput={(e) => setNumColumns(parseInt(e.target.value))}
value={resolution} />
min="0" <label htmlFor="resolution">resolution:</label>
max="1" <input
step="0.01" name="resolution"
onInput={(e) => setResolution(parseFloat(e.target.value))} type="range"
/> value={resolution}
min="0"
max="1"
step="0.01"
onInput={(e) => setResolution(parseFloat(e.target.value))}
/>
</div>
{!destroyed && ( {!destroyed && (
<Matrix <Matrix