Massive overhaul: the renderers are now classes that implement Renderer; replaced webpack and rollup with vite; converted bundle-contents to "core" and "full" bundle profiles; renamed "inclusions" to "staticAssets", which are "url" base64-encoded images and "raw" text strings; renamed the Matrix component module to the JSX extension; built out a test scaffold at tools/test/index.html to manually test the various deploy options.

This commit is contained in:
Rezmason
2025-05-23 12:49:10 -07:00
parent 658f07c6ab
commit 3b837c6f06
29 changed files with 2338 additions and 6918 deletions

View File

@@ -1,6 +0,0 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}

View File

@@ -1,8 +1,11 @@
TODO: TODO:
Make sure component works right WebGPU formulate is expensive
bundled, of course Mirror pass clicks bug
webpack?
Minify bundles
Naming "matrix" for the github repo, "digital-rain" and "DigitalRain" for everything else
Minimum react requirement? Minimum react requirement?
Retire fetchLibraries?
Move off of regl Move off of regl
Unify implementations? Unify implementations?
Responsive changes Responsive changes
@@ -14,10 +17,10 @@ TODO:
return boolean of whether all deltas are simple return boolean of whether all deltas are simple
Resource changes are simple if they're cached and loaded, false otherwise Resource changes are simple if they're cached and loaded, false otherwise
remake the pipeline if anything returns false remake the pipeline if anything returns false
Create multiple distributions 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
fun full
Other MSDFs and configs Other MSDFs and configs
and then one with built-in MSDF generation and then one with built-in MSDF generation
(TTF + glyphString) --> MSDF (TTF + glyphString) --> MSDF

31
eslint.config.js Normal file
View File

@@ -0,0 +1,31 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{ ignores: ["dist", "lib"] },
{
files: ["**/*.{js,jsx,mjs}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module",
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"no-unused-vars": "off",
"no-unused-labels": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
];

View File

@@ -5,19 +5,27 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0, viewport-fit=cover" /> <meta
name="viewport"
content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0, viewport-fit=cover"
/>
<style> <style>
html,
body {
height: 100%;
overflow: hidden;
margin: 0;
}
@supports (padding-top: env(safe-area-inset-top)) { @supports (padding-top: env(safe-area-inset-top)) {
body { body {
padding: 0; padding: 0;
height: calc(100% + env(safe-area-inset-top)); height: calc(100% + env(safe-area-inset-top));
} }
} }
body { body {
background: black; background: black;
overflow: hidden;
margin: 0;
font-family: monospace; font-family: monospace;
font-size: 2em; font-size: 2em;
text-align: center; text-align: center;

View File

@@ -107,14 +107,32 @@ import makeConfig from "./utils/config";
export const Matrix = memo((props) => { export const Matrix = memo((props) => {
const { style, className, ...rawConfigProps } = props; const { style, className, ...rawConfigProps } = props;
const elProps = { style, className }; const elProps = { style, className };
const matrix = useRef(null); const domElement = useRef(null);
const [rCanvas, setCanvas] = useState(null);
const [rRenderer, setRenderer] = useState(null); const [rRenderer, setRenderer] = useState(null);
const [rRain, setRain] = useState(null); const [rSize, setSize] = useState([1, 1]);
const [rConfig, setConfig] = useState(makeConfig({}));
const rendererClasses = {};
const configProps = Object.fromEntries( const resizeObserver = new ResizeObserver(entries => {
Object.entries(rawConfigProps).filter(([_, value]) => value != null), for (const entry of entries) {
); const contentBoxSize = entry.contentBoxSize[0];
setSize([contentBoxSize.inlineSize, contentBoxSize.blockSize]);
return;
}
});
useEffect(() => {
if (domElement.current == null) return;
resizeObserver.observe(domElement.current);
}, [domElement]);
useEffect(() => {
setConfig(makeConfig({
...Object.fromEntries(
Object.entries(rawConfigProps).filter(([_, value]) => value != null),
)
}));
}, [props]);
const supportsWebGPU = () => { const supportsWebGPU = () => {
return ( return (
@@ -125,62 +143,50 @@ export const Matrix = memo((props) => {
}; };
const cleanup = () => { const cleanup = () => {
if (rCanvas != null) { if (rRenderer == null) return;
rCanvas.remove(); rRenderer.canvas.remove();
setCanvas(null); rRenderer.destroy();
} setRenderer(null);
if (rRain != null) {
rRenderer?.destroy(rRain);
setRain(null);
}
if (rRenderer != null) {
setRenderer(null);
}
}; };
useEffect(() => { useEffect(() => {
const useWebGPU = supportsWebGPU() && ["webgpu"].includes(configProps.renderer?.toLowerCase()); const useWebGPU = supportsWebGPU() && rConfig.renderer === "webgpu";
const isWebGPU = rRenderer?.type === "webgpu"; const isWebGPU = rRenderer?.type === "webgpu";
if (rRenderer != null && useWebGPU === isWebGPU) {
return;
}
cleanup();
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
canvas.style.height = "100%";
matrix.current.appendChild(canvas);
setCanvas(canvas);
const loadRain = async () => { const loadRain = async () => {
let renderer; let renderer;
if (useWebGPU) { if (useWebGPU) {
renderer = await import("./webgpu/main.js"); rendererClasses.webgpu ??= (await import("./webgpu/renderer.js")).default;
renderer = new (rendererClasses.webgpu)();
} else { } else {
renderer = await import("./regl/main.js"); rendererClasses.regl ??= (await import("./regl/renderer.js")).default;
renderer = new (rendererClasses.regl)();
} }
setRenderer(renderer); setRenderer(renderer);
const rain = await renderer.init(canvas); await renderer.ready;
setRain(rain); const canvas = renderer.canvas;
canvas.style.width = "100%";
canvas.style.height = "100%";
domElement.current.appendChild(canvas);
}; };
loadRain();
if (rRenderer == null || useWebGPU !== isWebGPU) {
cleanup();
loadRain();
}
return cleanup; return cleanup;
}, [props.renderer]); }, [rConfig.renderer]);
useEffect(() => { useEffect(() => {
if (rRain == null || rRain.destroyed) { if (rRenderer?.destroyed ?? true) return;
return; rRenderer.formulate(rConfig);
} }, [rRenderer, rConfig]);
const refresh = async () => {
await rRenderer.formulate(rRain, makeConfig(configProps));
};
refresh();
}, [props, rRain]);
return <div ref={matrix} {...elProps}></div>; useEffect(() => {
if (rRenderer?.destroyed ?? true) return;
rRenderer.size = rSize.map(n => n * rConfig.resolution);
}, [rRenderer, rConfig.resolution, rSize]);
return <div ref={domElement} {...elProps}></div>;
}); });

View File

@@ -1,5 +0,0 @@
import { Matrix } from "./Matrix";
import inclusions from "./inclusions";
import * as reglRenderer from "./regl/main";
import * as webgpuRenderer from "./webgpu/main";
export { inclusions, reglRenderer, webgpuRenderer, Matrix };

6
js/bundles/core.js Normal file
View File

@@ -0,0 +1,6 @@
import { Matrix } from "../Matrix";
import staticAssets from "../staticAssets";
import makeConfig from "../utils/config";
import REGLRenderer from "../regl/renderer";
import WebGPURenderer from "../webgpu/renderer";
export { staticAssets, REGLRenderer, WebGPURenderer, Matrix, makeConfig };

6
js/bundles/full.js Normal file
View File

@@ -0,0 +1,6 @@
import { Matrix } from "../Matrix";
import staticAssets from "../staticAssets";
import makeConfig from "../utils/config";
import REGLRenderer from "../regl/renderer";
import WebGPURenderer from "../webgpu/renderer";
export { staticAssets, REGLRenderer, WebGPURenderer, Matrix, makeConfig };

View File

@@ -1,10 +1,10 @@
export default async () => { export default async () => {
let glMatrix, createREGL, inclusions; let glMatrix, createREGL, staticAssets;
try { try {
glMatrix = await import("gl-matrix"); glMatrix = await import("gl-matrix");
createREGL = (await import("regl")).default; createREGL = (await import("regl")).default;
inclusions = (await import("./inclusions.js")).default; staticAssets = (await import("./staticAssets.js")).default;
} catch { } catch {
const loadJS = (src) => const loadJS = (src) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@@ -15,8 +15,8 @@ export default async () => {
await Promise.all([loadJS("lib/regl.min.js"), loadJS("lib/gl-matrix.js")]); await Promise.all([loadJS("lib/regl.min.js"), loadJS("lib/gl-matrix.js")]);
glMatrix = globalThis.glMatrix; glMatrix = globalThis.glMatrix;
createREGL = globalThis.createREGL; createREGL = globalThis.createREGL;
inclusions = []; staticAssets = [];
} }
return { glMatrix, createREGL, inclusions }; return { glMatrix, createREGL, staticAssets };
}; };

View File

@@ -1,74 +0,0 @@
export default [
[
"import::shaders/glsl/bloomPass.highPass.frag.glsl",
() => import("../shaders/glsl/bloomPass.highPass.frag.glsl"),
],
[
"import::shaders/glsl/bloomPass.blur.frag.glsl",
() => import("../shaders/glsl/bloomPass.blur.frag.glsl"),
],
[
"import::shaders/glsl/bloomPass.combine.frag.glsl",
() => import("../shaders/glsl/bloomPass.combine.frag.glsl"),
],
["import::shaders/glsl/imagePass.frag.glsl", () => import("../shaders/glsl/imagePass.frag.glsl")],
[
"import::shaders/glsl/mirrorPass.frag.glsl",
() => import("../shaders/glsl/mirrorPass.frag.glsl"),
],
[
"import::shaders/glsl/palettePass.frag.glsl",
() => import("../shaders/glsl/palettePass.frag.glsl"),
],
[
"import::shaders/glsl/rainPass.intro.frag.glsl",
() => import("../shaders/glsl/rainPass.intro.frag.glsl"),
],
[
"import::shaders/glsl/rainPass.raindrop.frag.glsl",
() => import("../shaders/glsl/rainPass.raindrop.frag.glsl"),
],
[
"import::shaders/glsl/rainPass.symbol.frag.glsl",
() => import("../shaders/glsl/rainPass.symbol.frag.glsl"),
],
[
"import::shaders/glsl/rainPass.effect.frag.glsl",
() => import("../shaders/glsl/rainPass.effect.frag.glsl"),
],
["import::shaders/glsl/rainPass.vert.glsl", () => import("../shaders/glsl/rainPass.vert.glsl")],
["import::shaders/glsl/rainPass.frag.glsl", () => import("../shaders/glsl/rainPass.frag.glsl")],
[
"import::shaders/glsl/stripePass.frag.glsl",
() => import("../shaders/glsl/stripePass.frag.glsl"),
],
["import::assets/coptic_msdf.png", () => import("../assets/coptic_msdf.png")],
["import::assets/gothic_msdf.png", () => import("../assets/gothic_msdf.png")],
["import::assets/matrixcode_msdf.png", () => import("../assets/matrixcode_msdf.png")],
["import::assets/resurrections_msdf.png", () => import("../assets/resurrections_msdf.png")],
["import::assets/megacity_msdf.png", () => import("../assets/megacity_msdf.png")],
[
"import::assets/resurrections_glint_msdf.png",
() => import("../assets/resurrections_glint_msdf.png"),
],
["import::assets/huberfish_a_msdf.png", () => import("../assets/huberfish_a_msdf.png")],
["import::assets/huberfish_d_msdf.png", () => import("../assets/huberfish_d_msdf.png")],
[
"import::assets/gtarg_tenretniolleh_msdf.png",
() => import("../assets/gtarg_tenretniolleh_msdf.png"),
],
["import::assets/gtarg_alientext_msdf.png", () => import("../assets/gtarg_alientext_msdf.png")],
["import::assets/neomatrixology_msdf.png", () => import("../assets/neomatrixology_msdf.png")],
["import::assets/sand.png", () => import("../assets/sand.png")],
["import::assets/pixel_grid.png", () => import("../assets/pixel_grid.png")],
["import::assets/mesh.png", () => import("../assets/mesh.png")],
["import::assets/metal.png", () => import("../assets/metal.png")],
["import::shaders/wgsl/bloomBlur.wgsl", () => import("../shaders/wgsl/bloomBlur.wgsl")],
["import::shaders/wgsl/bloomCombine.wgsl", () => import("../shaders/wgsl/bloomCombine.wgsl")],
["import::shaders/wgsl/endPass.wgsl", () => import("../shaders/wgsl/endPass.wgsl")],
["import::shaders/wgsl/imagePass.wgsl", () => import("../shaders/wgsl/imagePass.wgsl")],
["import::shaders/wgsl/mirrorPass.wgsl", () => import("../shaders/wgsl/mirrorPass.wgsl")],
["import::shaders/wgsl/palettePass.wgsl", () => import("../shaders/wgsl/palettePass.wgsl")],
["import::shaders/wgsl/rainPass.wgsl", () => import("../shaders/wgsl/rainPass.wgsl")],
["import::shaders/wgsl/stripePass.wgsl", () => import("../shaders/wgsl/stripePass.wgsl")],
];

View File

@@ -1,7 +1,5 @@
import makeConfig from "./utils/config.js"; import makeConfig from "./utils/config.js";
const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
document.addEventListener("touchmove", (e) => e.preventDefault(), { document.addEventListener("touchmove", (e) => e.preventDefault(), {
passive: false, passive: false,
}); });
@@ -25,12 +23,21 @@ document.body.onload = async () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const config = makeConfig(Object.fromEntries(urlParams.entries())); const config = makeConfig(Object.fromEntries(urlParams.entries()));
const useWebGPU = (await supportsWebGPU()) && ["webgpu"].includes(config.renderer?.toLowerCase()); const useWebGPU = (await supportsWebGPU()) && ["webgpu"].includes(config.renderer?.toLowerCase());
const solution = import(`./${useWebGPU ? "webgpu" : "regl"}/main.js`); const rendererModule = import(`./${useWebGPU ? "webgpu" : "regl"}/renderer.js`);
const initialize = async (canvas, config) => { const initialize = async (config) => {
const { init, formulate } = await solution; const Renderer = (await rendererModule).default;
const rain = await init(canvas); const renderer = new Renderer();
await formulate(rain, config); await renderer.ready;
renderer.size = [window.innerWidth, window.innerHeight].map(n => n * (window.devicePixelRatio ?? 1) * config.resolution);
window.onresize = () => {
renderer.size = [window.innerWidth, window.innerHeight].map(n => n * (window.devicePixelRatio ?? 1) * config.resolution);
};
window.addEventListener("dblclick", () => {
renderer.fullscreen = !renderer.fullscreen;
});
document.body.appendChild(renderer.canvas);
await renderer.formulate(config);
}; };
if (isRunningSwiftShader() && !config.suppressWarnings) { if (isRunningSwiftShader() && !config.suppressWarnings) {
@@ -41,17 +48,15 @@ document.body.onload = async () => {
<button class="blue pill">Plug me in</button> <button class="blue pill">Plug me in</button>
<a class="red pill" target="_blank" href="https://www.google.com/search?q=chrome+enable+hardware+acceleration">Free me</a> <a class="red pill" target="_blank" href="https://www.google.com/search?q=chrome+enable+hardware+acceleration">Free me</a>
`; `;
canvas.style.display = "none";
document.body.appendChild(notice); document.body.appendChild(notice);
document.querySelector(".blue.pill").addEventListener("click", async () => { document.querySelector(".blue.pill").addEventListener("click", async () => {
config.suppressWarnings = true; config.suppressWarnings = true;
urlParams.set("suppressWarnings", true); urlParams.set("suppressWarnings", true);
history.replaceState({}, "", "?" + unescape(urlParams.toString())); history.replaceState({}, "", "?" + unescape(urlParams.toString()));
await initialize(canvas, config); await initialize(config);
canvas.style.display = "unset";
document.body.removeChild(notice); document.body.removeChild(notice);
}); });
} else { } else {
await initialize(canvas, config); await initialize(config);
} }
}; };

View File

@@ -1,170 +0,0 @@
import { makeFullScreenQuad, makePipeline } from "./utils.js";
import fetchLibraries from "../fetchLibraries.js";
import makeRain from "./rainPass.js";
import makeBloomPass from "./bloomPass.js";
import makePalettePass from "./palettePass.js";
import makeStripePass from "./stripePass.js";
import makeImagePass from "./imagePass.js";
import makeMirrorPass from "./mirrorPass.js";
import { setupCamera, cameraCanvas, cameraAspectRatio } from "../utils/camera.js";
const effects = {
none: null,
plain: makePalettePass,
palette: makePalettePass,
customStripes: makeStripePass,
stripes: makeStripePass,
pride: makeStripePass,
transPride: makeStripePass,
trans: makeStripePass,
image: makeImagePass,
mirror: makeMirrorPass,
};
let createREGL, glMatrix, inclusions;
export const init = async (canvas) => {
const libraries = await fetchLibraries();
createREGL = libraries.createREGL;
glMatrix = libraries.glMatrix;
inclusions = libraries.inclusions;
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);
};
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 extensions = ["OES_texture_half_float", "OES_texture_half_float_linear"];
// These extensions are also needed, but Safari misreports that they are missing
const optionalExtensions = [
"EXT_color_buffer_half_float",
"WEBGL_color_buffer_float",
"OES_standard_derivatives",
];
const regl = createREGL({ canvas, pixelRatio: 1, extensions, optionalExtensions });
const cache = new Map(inclusions);
const rain = { canvas, resize, doubleClick, cache, regl, resolution: 1 };
window.addEventListener("dblclick", doubleClick);
window.addEventListener("resize", resize);
resize();
return rain;
};
export const formulate = async (rain, config) => {
if (rain.destroyed) {
throw new Error("Cannot formulate a destroyed rain instance.");
}
const { resize, canvas, cache, regl } = rain;
rain.resolution = config.resolution;
resize();
const dimensions = { width: 1, height: 1 };
if (config.useCamera) {
await setupCamera();
}
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 pipeline = makePipeline(context, [makeRain, makeBloomPass, effects[effectName]]);
const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary };
const drawToScreen = regl({ uniforms: screenUniforms });
await Promise.all(pipeline.map((step) => step.ready));
pipeline.forEach((step) => step.setSize(canvas.width, canvas.height));
dimensions.width = canvas.width;
dimensions.height = canvas.height;
const targetFrameTimeMilliseconds = 1000 / config.fps;
let last = NaN;
resetREGLTime: {
const reset = regl.frame((o) => {
o.time = 0;
o.tick = 0;
reset.cancel();
});
}
const tick = regl.frame(({ viewportWidth, viewportHeight }) => {
if (config.once) {
tick.cancel();
}
const now = regl.now() * 1000;
if (isNaN(last)) {
last = now;
}
const shouldRender =
config.fps >= 60 || now - last >= targetFrameTimeMilliseconds || config.once == true;
if (shouldRender) {
while (now - targetFrameTimeMilliseconds > last) {
last += targetFrameTimeMilliseconds;
}
}
if (config.useCamera) {
cameraTex(cameraCanvas);
}
if (dimensions.width !== viewportWidth || dimensions.height !== viewportHeight) {
dimensions.width = viewportWidth;
dimensions.height = viewportHeight;
for (const step of pipeline) {
step.setSize(viewportWidth, viewportHeight);
}
}
fullScreenQuad(() => {
for (const step of pipeline) {
step.execute(shouldRender);
}
drawToScreen();
});
});
if (rain.tick != null) {
rain.tick.cancel();
}
rain.tick = tick;
};
export const destroy = (rain) => {
if (rain.destroyed) {
return;
}
const { regl, cache, resize, doubleClick, tick, canvas } = rain;
window.removeEventListener("resize", resize);
window.removeEventListener("dblclick", doubleClick);
cache.clear();
tick.cancel(); // stop RAF
regl.destroy(); // releases all GPU resources & event listeners
rain.destroyed = true;
};
export const type = "regl";

139
js/regl/renderer.js Normal file
View File

@@ -0,0 +1,139 @@
import Renderer from "../renderer.js";
import { makeFullScreenQuad, makePipeline } from "./utils.js";
import makeRain from "./rainPass.js";
import makeBloomPass from "./bloomPass.js";
import makePalettePass from "./palettePass.js";
import makeStripePass from "./stripePass.js";
import makeImagePass from "./imagePass.js";
import makeMirrorPass from "./mirrorPass.js";
import { setupCamera, cameraCanvas, cameraAspectRatio } from "../utils/camera.js";
const effects = {
none: null,
plain: makePalettePass,
palette: makePalettePass,
customStripes: makeStripePass,
stripes: makeStripePass,
pride: makeStripePass,
transPride: makeStripePass,
trans: makeStripePass,
image: makeImagePass,
mirror: makeMirrorPass,
};
export default class REGLRenderer extends Renderer {
#tick;
#regl;
#glMatrix;
constructor() {
super("regl", async () => {
const libraries = await Renderer.libraries;
const extensions = ["OES_texture_half_float", "OES_texture_half_float_linear"];
// These extensions are also needed, but Safari misreports that they are missing
const optionalExtensions = [
"EXT_color_buffer_half_float",
"WEBGL_color_buffer_float",
"OES_standard_derivatives",
];
this.#regl = libraries.createREGL({ canvas: this.canvas, pixelRatio: 1, extensions, optionalExtensions });
this.#glMatrix = libraries.glMatrix;
});
}
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 };
if (config.useCamera) {
await setupCamera();
}
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 pipeline = makePipeline(context, [makeRain, makeBloomPass, effects[effectName]]);
const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary };
const drawToScreen = regl({ uniforms: screenUniforms });
await Promise.all(pipeline.map((step) => step.ready));
pipeline.forEach((step) => step.setSize(canvas.width, canvas.height));
dimensions.width = canvas.width;
dimensions.height = canvas.height;
const targetFrameTimeMilliseconds = 1000 / config.fps;
let last = NaN;
resetREGLTime: {
const reset = regl.frame((o) => {
o.time = 0;
o.tick = 0;
reset.cancel();
});
}
const tick = regl.frame(({ viewportWidth, viewportHeight }) => {
if (config.once) {
tick.cancel();
}
const now = regl.now() * 1000;
if (isNaN(last)) {
last = now;
}
const shouldRender =
config.fps >= 60 || now - last >= targetFrameTimeMilliseconds || config.once == true;
if (shouldRender) {
while (now - targetFrameTimeMilliseconds > last) {
last += targetFrameTimeMilliseconds;
}
}
if (config.useCamera) {
cameraTex(cameraCanvas);
}
if (dimensions.width !== viewportWidth || dimensions.height !== viewportHeight) {
dimensions.width = viewportWidth;
dimensions.height = viewportHeight;
for (const step of pipeline) {
step.setSize(viewportWidth, viewportHeight);
}
}
fullScreenQuad(() => {
for (const step of pipeline) {
step.execute(shouldRender);
}
drawToScreen();
});
});
if (this.#tick != null) {
this.#tick.cancel();
}
this.#tick = tick;
}
destroy() {
if (this.destroyed) {
return;
}
this.#tick.cancel(); // stop RAF
this.#regl.destroy(); // releases all GPU resources & event listeners
super.destroy();
}
}

View File

@@ -60,8 +60,8 @@ const loadImage = (cache, regl, url, mipmap) => {
const data = new Image(); const data = new Image();
data.crossOrigin = "anonymous"; data.crossOrigin = "anonymous";
let imageURL; let imageURL;
if (typeof cache.get(`import::${url}`) === "function") { if (typeof cache.get(`url::${url}`) === "function") {
imageURL = (await cache.get(`import::${url}`)()).default; imageURL = (await cache.get(`url::${url}`)()).default;
} else { } else {
imageURL = url; imageURL = url;
} }
@@ -103,13 +103,11 @@ const loadText = (cache, url) => {
}, },
loaded: (async () => { loaded: (async () => {
if (url != null) { if (url != null) {
let textURL; if (typeof cache.get(`raw::${url}`) === "function") {
if (typeof cache.get(`import::${url}`) === "function") { text = (await cache.get(`raw::${url}`)()).default;
textURL = (await cache.get(`import::${url}`)()).default;
} else { } else {
textURL = url; text = await (await fetch(url)).text();
} }
text = await (await fetch(textURL)).text();
loaded = true; loaded = true;
} }
})(), })(),

92
js/renderer.js Normal file
View File

@@ -0,0 +1,92 @@
import fetchLibraries from "./fetchLibraries.js";
export default class Renderer {
static libraries = fetchLibraries();
#type;
#canvas;
#ready;
#width = 300;
#height = 150;
#fullscreen = false;
#cache = new Map();
#destroyed = false;
constructor(type, ready) {
this.#type = type;
this.#canvas = document.createElement("canvas");
this.#ready = Renderer.libraries.then(libraries => {
this.#cache = new Map(libraries.staticAssets);
}).then(ready);
}
get canvas() {
return this.#canvas;
}
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;
}
[this.#canvas.width, this.#canvas.height] = [this.#width, this.#height] = [width, height];
}
get fullscreen() {
return this.#fullscreen;
}
set fullscreen(value) {
if (!!value === this.#fullscreen) {
return;
}
if (!document.fullscreenEnabled && !document.webkitFullscreenEnabled) {
return;
}
this.#fullscreen = value;
if (document.fullscreenElement != null) {
document.exitFullscreen();
}
if (this.#fullscreen) {
if (this.#canvas.webkitRequestFullscreen != null) {
this.#canvas.webkitRequestFullscreen();
} else {
this.#canvas.requestFullscreen();
}
}
}
async formulate(config) {
await this.ready;
if (this.destroyed) {
throw new Error("Cannot formulate a destroyed rain instance.");
}
}
get destroyed() {
return this.#destroyed;
}
destroy() {
this.#destroyed = true;
this.#cache.clear();
}
}

77
js/staticAssets.js Normal file
View File

@@ -0,0 +1,77 @@
export default [
[
"raw::shaders/glsl/bloomPass.highPass.frag.glsl",
() => import("../shaders/glsl/bloomPass.highPass.frag.glsl?raw"),
],
[
"raw::shaders/glsl/bloomPass.blur.frag.glsl",
() => import("../shaders/glsl/bloomPass.blur.frag.glsl?raw"),
],
[
"raw::shaders/glsl/bloomPass.combine.frag.glsl",
() => import("../shaders/glsl/bloomPass.combine.frag.glsl?raw"),
],
[
"raw::shaders/glsl/imagePass.frag.glsl",
() => import("../shaders/glsl/imagePass.frag.glsl?raw"),
],
[
"raw::shaders/glsl/mirrorPass.frag.glsl",
() => import("../shaders/glsl/mirrorPass.frag.glsl?raw"),
],
[
"raw::shaders/glsl/palettePass.frag.glsl",
() => import("../shaders/glsl/palettePass.frag.glsl?raw"),
],
[
"raw::shaders/glsl/rainPass.intro.frag.glsl",
() => import("../shaders/glsl/rainPass.intro.frag.glsl?raw"),
],
[
"raw::shaders/glsl/rainPass.raindrop.frag.glsl",
() => import("../shaders/glsl/rainPass.raindrop.frag.glsl?raw"),
],
[
"raw::shaders/glsl/rainPass.symbol.frag.glsl",
() => import("../shaders/glsl/rainPass.symbol.frag.glsl?raw"),
],
[
"raw::shaders/glsl/rainPass.effect.frag.glsl",
() => import("../shaders/glsl/rainPass.effect.frag.glsl?raw"),
],
["raw::shaders/glsl/rainPass.vert.glsl", () => import("../shaders/glsl/rainPass.vert.glsl?raw")],
["raw::shaders/glsl/rainPass.frag.glsl", () => import("../shaders/glsl/rainPass.frag.glsl?raw")],
[
"raw::shaders/glsl/stripePass.frag.glsl",
() => import("../shaders/glsl/stripePass.frag.glsl?raw"),
],
["url::assets/coptic_msdf.png", () => import("../assets/coptic_msdf.png")],
["url::assets/gothic_msdf.png", () => import("../assets/gothic_msdf.png")],
["url::assets/matrixcode_msdf.png", () => import("../assets/matrixcode_msdf.png")],
["url::assets/resurrections_msdf.png", () => import("../assets/resurrections_msdf.png")],
["url::assets/megacity_msdf.png", () => import("../assets/megacity_msdf.png")],
[
"url::assets/resurrections_glint_msdf.png",
() => import("../assets/resurrections_glint_msdf.png"),
],
["url::assets/huberfish_a_msdf.png", () => import("../assets/huberfish_a_msdf.png")],
["url::assets/huberfish_d_msdf.png", () => import("../assets/huberfish_d_msdf.png")],
[
"url::assets/gtarg_tenretniolleh_msdf.png",
() => import("../assets/gtarg_tenretniolleh_msdf.png"),
],
["url::assets/gtarg_alientext_msdf.png", () => import("../assets/gtarg_alientext_msdf.png")],
["url::assets/neomatrixology_msdf.png", () => import("../assets/neomatrixology_msdf.png")],
["url::assets/sand.png", () => import("../assets/sand.png")],
["url::assets/pixel_grid.png", () => import("../assets/pixel_grid.png")],
["url::assets/mesh.png", () => import("../assets/mesh.png")],
["url::assets/metal.png", () => import("../assets/metal.png")],
["raw::shaders/wgsl/bloomBlur.wgsl", () => import("../shaders/wgsl/bloomBlur.wgsl?raw")],
["raw::shaders/wgsl/bloomCombine.wgsl", () => import("../shaders/wgsl/bloomCombine.wgsl?raw")],
["raw::shaders/wgsl/endPass.wgsl", () => import("../shaders/wgsl/endPass.wgsl?raw")],
["raw::shaders/wgsl/imagePass.wgsl", () => import("../shaders/wgsl/imagePass.wgsl?raw")],
["raw::shaders/wgsl/mirrorPass.wgsl", () => import("../shaders/wgsl/mirrorPass.wgsl?raw")],
["raw::shaders/wgsl/palettePass.wgsl", () => import("../shaders/wgsl/palettePass.wgsl?raw")],
["raw::shaders/wgsl/rainPass.wgsl", () => import("../shaders/wgsl/rainPass.wgsl?raw")],
["raw::shaders/wgsl/stripePass.wgsl", () => import("../shaders/wgsl/stripePass.wgsl?raw")],
];

View File

@@ -249,7 +249,6 @@ const versions = {
baseContrast: 1.5, baseContrast: 1.5,
highPassThreshold: 0, highPassThreshold: 0,
numColumns: 60, numColumns: 60,
cycleSpeed: 0.03,
bloomStrength: 0.7, bloomStrength: 0.7,
fallSpeed: 0.3, fallSpeed: 0.3,
palette: [ palette: [
@@ -278,7 +277,6 @@ const versions = {
baseContrast: 1.5, baseContrast: 1.5,
highPassThreshold: 0, highPassThreshold: 0,
numColumns: 60, numColumns: 60,
cycleSpeed: 0.03,
bloomStrength: 0.7, bloomStrength: 0.7,
fallSpeed: 0.3, fallSpeed: 0.3,
palette: [ palette: [
@@ -307,7 +305,6 @@ const versions = {
baseContrast: 1.5, baseContrast: 1.5,
highPassThreshold: 0, highPassThreshold: 0,
numColumns: 60, numColumns: 60,
cycleSpeed: 0.03,
bloomStrength: 0.7, bloomStrength: 0.7,
fallSpeed: 0.3, fallSpeed: 0.3,
palette: [ palette: [
@@ -368,7 +365,7 @@ versions["2021"] = versions.resurrections;
const range = (f, min = -Infinity, max = Infinity) => Math.max(min, Math.min(max, f)); const range = (f, min = -Infinity, max = Infinity) => Math.max(min, Math.min(max, f));
const nullNaN = (f) => (isNaN(f) ? null : f); const nullNaN = (f) => (isNaN(f) ? null : f);
const isTrue = (s) => s.toLowerCase().includes("true"); const isTrue = (v) => (typeof v === "string" && v.toLowerCase().includes("true")) || v;
const parseColor = (isHSL) => (s) => ({ const parseColor = (isHSL) => (s) => ({
space: isHSL ? "hsl" : "rgb", space: isHSL ? "hsl" : "rgb",
@@ -491,7 +488,7 @@ paramMapping.dropLength = paramMapping.raindropLength;
paramMapping.angle = paramMapping.slant; paramMapping.angle = paramMapping.slant;
paramMapping.colors = paramMapping.stripeColors; paramMapping.colors = paramMapping.stripeColors;
export default (urlParams) => { export default (urlParams = {}) => {
const validParams = Object.fromEntries( const validParams = Object.fromEntries(
Object.entries(urlParams) Object.entries(urlParams)
.filter(([key]) => key in paramMapping) .filter(([key]) => key in paramMapping)

View File

@@ -1,217 +0,0 @@
import { structs } from "../../lib/gpu-buffer.js";
import { makeUniformBuffer, makePipeline } from "./utils.js";
import fetchLibraries from "../fetchLibraries.js";
import makeRain from "./rainPass.js";
import makeBloomPass from "./bloomPass.js";
import makePalettePass from "./palettePass.js";
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 "../utils/camera.js";
const effects = {
none: null,
plain: makePalettePass,
palette: makePalettePass,
customStripes: makeStripePass,
stripes: makeStripePass,
pride: makeStripePass,
transPride: makeStripePass,
trans: makeStripePass,
image: makeImagePass,
mirror: makeMirrorPass,
};
let glMatrix, inclusions;
export const init = async (canvas) => {
const libraries = await fetchLibraries();
glMatrix = libraries.glMatrix;
inclusions = libraries.inclusions;
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);
};
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(inclusions);
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) => {
if (rain.destroyed) {
throw new Error("Cannot formulate a destroyed rain instance.");
}
const { resize, canvas, cache, canvasContext, adapter, device } = rain;
rain.resolution = config.resolution;
resize();
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 canvasWidth = Math.ceil(canvas.clientWidth * devicePixelRatio * config.resolution);
const canvasHeight = Math.ceil(canvas.clientHeight * devicePixelRatio * config.resolution);
const canvasSize = [canvasWidth, canvasHeight];
if (outputs == null || canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
outputs = pipeline.build(canvasSize);
}
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 (rain.renderLoop != null) {
cancelAnimationFrame(rain.renderLoop);
}
renderLoop(performance.now());
rain.renderLoop = renderLoop;
};
export const destroy = (rain) => {
if (rain.destroyed) {
return;
}
const { device, resize, doubleClick, cache, canvas, renderLoop } = rain;
window.removeEventListener("resize", resize);
window.removeEventListener("dblclick", doubleClick);
cache.clear();
cancelAnimationFrame(renderLoop); // stop RAF
device.destroy(); // This also destroys any objects created with the device
rain.destroyed = true;
};
export const type = "webgpu";

178
js/webgpu/renderer.js Normal file
View File

@@ -0,0 +1,178 @@
import Renderer from "../renderer.js";
import { structs } from "../../lib/gpu-buffer.js";
import { makeUniformBuffer, makePipeline } from "./utils.js";
import makeRain from "./rainPass.js";
import makeBloomPass from "./bloomPass.js";
import makePalettePass from "./palettePass.js";
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 "../utils/camera.js";
const effects = {
none: null,
plain: makePalettePass,
palette: makePalettePass,
customStripes: makeStripePass,
stripes: makeStripePass,
pride: makeStripePass,
transPride: makeStripePass,
trans: makeStripePass,
image: makeImagePass,
mirror: makeMirrorPass,
};
export default class REGLRenderer extends Renderer {
#glMatrix;
#canvasContext;
#adapter;
#device;
#renderLoop;
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;
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);
}
renderLoop(performance.now());
this.#renderLoop = renderLoop;
}
destroy() {
if (this.destroyed) {
return;
}
cancelAnimationFrame(this.#renderLoop); // stop RAF
this.#device.destroy(); // This also destroys any objects created with the device
super.destroy();
}
}

View File

@@ -17,8 +17,8 @@ const loadTexture = async (device, cache, url) => {
}); });
} else { } else {
let imageURL; let imageURL;
if (typeof cache.get(`import::${url}`) === "function") { if (typeof cache.get(`url::${url}`) === "function") {
imageURL = (await cache.get(`import::${url}`)()).default; imageURL = (await cache.get(`url::${url}`)()).default;
} else { } else {
imageURL = url; imageURL = url;
} }
@@ -74,14 +74,12 @@ const loadShader = async (device, cache, url) => {
if (cache.has(key)) { if (cache.has(key)) {
return cache.get(key); return cache.get(key);
} }
let textURL; let code;
if (typeof cache.get(`import::${url}`) === "function") { if (typeof cache.get(`raw::${url}`) === "function") {
textURL = (await cache.get(`import::${url}`)()).default; code = (await cache.get(`raw::${url}`)()).default;
} else { } else {
textURL = url; code = await (await fetch(url)).text();
} }
const response = await fetch(textURL);
const code = await response.text();
return { return {
code, code,
module: device.createShaderModule({ code }), module: device.createShaderModule({ code }),

7767
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "web-based green code rain, made with love", "description": "web-based green code rain, made with love",
"type": "module", "type": "module",
"main": "./dist/digital-rain.cjs",
"module": "./dist/digital-rain.module.js", "module": "./dist/digital-rain.module.js",
"exports": {
".": {
"import": "./dist/digital-rain.module.js",
"require": "./dist/digital-rain.cjs"
}
},
"files": [ "files": [
"/dist", "/dist",
"LICENSE", "LICENSE",
@@ -18,10 +11,13 @@
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "dev": "npm run format && vite --config tools/dev.config.js",
"format": "prettier --write --use-tabs --print-width 100 'js/**/*.js' '*.json' '*.js' './*.js' './*.mjs'", "build": "npm run format && rm -rf ./dist/* && vite build --config tools/build/core.config.js && vite build --config tools/build/full.config.js",
"start": "npm run format ; webpack serve --config ./webpack.config.js", "format": "eslint . && prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,mjs,json}' '*.{js,jsx,mjs,json,html}' 'assets/**/*.{json,css}'"
"build": "npm run format ; rollup -c" },
"prettier": {
"useTabs": true,
"printWidth": 100
}, },
"keywords": [ "keywords": [
"rain", "rain",
@@ -55,27 +51,16 @@
"regl": "^2.1.0" "regl": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.22.9", "@eslint/js": "^9.25.0",
"@babel/preset-env": "^7.22.9", "@types/react": "^19.1.2",
"@babel/preset-react": "^7.22.5", "@types/react-dom": "^19.1.2",
"@rollup/plugin-babel": "^6.0.4", "@vitejs/plugin-react": "^4.4.1",
"@rollup/plugin-commonjs": "^28.0.3", "eslint": "^9.25.0",
"@rollup/plugin-image": "^3.0.3", "eslint-plugin-react-hooks": "^5.2.0",
"@rollup/plugin-node-resolve": "^16.0.1", "eslint-plugin-react-refresh": "^0.4.19",
"@rollup/plugin-terser": "^0.4.4", "globals": "^16.0.0",
"@rollup/plugin-url": "^8.0.2",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^13.0.0",
"html-webpack-plugin": "^5.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"raw-loader": "^4.0.2", "vite": "^6.3.5"
"rollup": "^4.40.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-string": "^3.0.0",
"rollup-plugin-visualizer": "^5.14.0",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -1 +0,0 @@
prettier --write --use-tabs --print-width 160 "index.html" "./js/**/**.js" "./lib/gpu-buffer.js"

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,72 +0,0 @@
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import nodeResolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import babel from "@rollup/plugin-babel";
import url from "@rollup/plugin-url";
import { visualizer } from "rollup-plugin-visualizer"; // <- size report
import terser from "@rollup/plugin-terser";
import { string } from "rollup-plugin-string";
import image from "@rollup/plugin-image";
export default [
{
input: "js/bundle-contents.js",
external: ["react", "react-dom"], // keep them out of your bundle
plugins: [
peerDepsExternal(), // auto-exclude peerDeps
nodeResolve(), // so Rollup can find deps in node_modules
string({ include: ["**/*.glsl"] }),
string({ include: ["**/*.wgsl"] }),
image({ include: ["**/*.png"], limit: 0 }),
babel({
exclude: "node_modules/**", // transpile JSX
babelHelpers: "bundled",
presets: ["@babel/preset-react", "@babel/preset-env"],
}),
commonjs(), // turn CJS deps into ES
terser({
sourceMap: false, // <- suppress .map generation
format: { comments: false },
}),
visualizer({
filename: "dist/stats.html",
gzipSize: true,
brotliSize: true,
includeAssets: true,
}), // bundle-size treemap
],
output: {
inlineDynamicImports: true,
file: "dist/digital-rain.cjs.js",
format: "cjs",
exports: "named",
sourcemap: false,
},
},
{
input: "js/bundle-contents.js",
external: ["react", "react-dom"], // keep them out of your bundle
plugins: [
peerDepsExternal(), // auto-exclude peerDeps
nodeResolve(), // so Rollup can find deps in node_modules
string({ include: ["**/*.glsl"] }),
string({ include: ["**/*.wgsl"] }),
image({ include: ["**/*.png"], limit: 0 }),
babel({
exclude: "node_modules/**", // transpile JSX
babelHelpers: "bundled",
presets: ["@babel/preset-react", "@babel/preset-env"],
}),
commonjs(), // turn CJS deps into ES
],
output: [
{
inlineDynamicImports: true,
file: "dist/digital-rain.module.js",
format: "es",
exports: "named",
sourcemap: true,
},
],
},
];

9
tools/dev.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
// https://vite.dev/config/
// https://github.com/vitejs/vite/blob/main/docs/guide/build.md
export default defineConfig((args) => {
return {
// assetsInclude: ["assets/**.png", "shaders/**.{wgsl,glsl}"],
};
});

48
tools/test/index.html Normal file
View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<details open id="test-standard">
<summary><strong>standard</strong></summary>
<iframe src="../../index.html?version=palimpsest"></iframe>
</details>
<details open id="test-react">
<summary><strong>standard react</strong></summary>
<div id="test-react-container"></div>
<script type="module" src="test-react.jsx?root-id=test-react-container"></script>
</details>
<details id="test-core-bundled">
<summary><strong>core bundled</strong></summary>
<script type="module">
import { REGLRenderer, WebGPURenderer, makeConfig } from "../../dist/digital-rain.core.js";
const useWebGPU = false;
const RendererClass = useWebGPU ? WebGPURenderer : REGLRenderer;
const renderer = new RendererClass();
await renderer.ready;
document.querySelector("#test-core-bundled").appendChild(renderer.canvas);
await renderer.formulate(makeConfig({once: false}));
</script>
</details>
<details id="test-core-react-bundled">
<summary><strong>core react bundled</strong></summary>
<div id="test-core-react-bundled-container"></div>
<script type="module" src="test-react.jsx?bundle=core&root-id=test-core-react-bundled-container"></script>
</details>
<details id="test-full-bundled">
<summary><strong>full bundled</strong></summary>
<script type="module">
import { REGLRenderer, WebGPURenderer, makeConfig } from "../../dist/digital-rain.full.js";
const useWebGPU = false;
const RendererClass = useWebGPU ? WebGPURenderer : REGLRenderer;
const renderer = new RendererClass();
await renderer.ready;
document.querySelector("#test-full-bundled").appendChild(renderer.canvas);
await renderer.formulate(makeConfig({once: false, version: "twilight"}));
</script>
</details>
</body>
</html>

View File

@@ -1,9 +1,23 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { unmountComponentAtNode } from "react-dom"; import { unmountComponentAtNode } from "react-dom";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Matrix } from "./Matrix";
const root = createRoot(document.getElementById("root")); const urlParams = new URLSearchParams(import.meta.url.replaceAll(/.*?\?/g, ""));
const rootID = urlParams.get("root-id") ?? "root";
const root = createRoot(document.getElementById(rootID));
let componentModule;
switch (urlParams.get("bundle")) {
case "core": {
componentModule = (await import("../../dist/digital-rain.core.js"));
break;
}
default: {
componentModule = (await import("../../js/Matrix"));
}
}
const { Matrix } = componentModule;
const versions = [ const versions = [
"classic", "classic",
"3d", "3d",
@@ -16,11 +30,12 @@ const versions = [
"bugs", "bugs",
"morpheus", "morpheus",
]; ];
const effects = ["none", "plain", "palette", "stripes", "pride", "trans", "image", "mirror"]; const effects = ["none", "palette", "stripes", "pride", "trans", "image", "mirror"];
const App = () => { const App = () => {
const [version, setVersion] = useState(versions[0]); const [version, setVersion] = useState(versions[0]);
const [effect, setEffect] = useState("plain"); const [effect, setEffect] = useState("palette");
const [numColumns, setNumColumns] = useState(80); const [numColumns, setNumColumns] = useState(80);
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(null);
@@ -60,8 +75,7 @@ const App = () => {
}; };
return ( return (
<div> <>
<h1>Rain</h1>
<button onClick={onVersionButtonClick}>Version: "{version}"</button> <button onClick={onVersionButtonClick}>Version: "{version}"</button>
<button onClick={onEffectButtonClick}>Effect: "{effect}"</button> <button onClick={onEffectButtonClick}>Effect: "{effect}"</button>
<button onClick={onRendererButtonClick}>Renderer: {rendererType ?? "default (regl)"}</button> <button onClick={onRendererButtonClick}>Renderer: {rendererType ?? "default (regl)"}</button>
@@ -94,25 +108,37 @@ const App = () => {
<input <input
name="num-columns" name="num-columns"
type="range" type="range"
value={numColumns}
min="10" min="10"
max="160" max="160"
step="1" step="1"
onInput={(e) => setNumColumns(parseInt(e.target.value))} onInput={(e) => setNumColumns(parseInt(e.target.value))}
/> />
<label htmlFor="resolution">resolution:</label>
<input
name="resolution"
type="range"
value={resolution}
min="0"
max="1"
step="0.01"
onInput={(e) => setResolution(parseFloat(e.target.value))}
/>
{!destroyed && ( {!destroyed && (
<Matrix <Matrix
style={{ width: "80vw", height: "45vh" }} style={{ width: "40vw", height: "22vh" }}
version={version} version={version}
effect={effect} effect={effect}
numColumns={numColumns} numColumns={numColumns}
resolution={resolution}
renderer={rendererType} renderer={rendererType}
cursorColor={cursorColor} cursorColor={cursorColor}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
density={2.0} density={2.0}
/> />
)} )}
</div> </>
); );
}; };
root.render(<App />); root.render(<App />);

View File

@@ -1,61 +0,0 @@
import webpack from "webpack";
import path from "path";
import HtmlWebpackPlugin from "html-webpack-plugin";
import CopyPlugin from "copy-webpack-plugin";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default {
mode: "development",
entry: path.resolve(__dirname, "./js/index.js"),
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ["babel-loader"],
},
// {
// test: /\.css$/,
// use: ["style-loader", "css-loader"],
// },
{
test: /\.(png|jpe?g|svg|glsl|wgsl)?$/,
type: "asset/resource",
},
],
},
resolve: {
extensions: ["*", ".js", ".jsx"],
},
output: {
path: path.resolve(__dirname, "./dist"),
filename: "[name].bundle.js",
clean: true,
},
devtool: "inline-source-map",
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "public/index.html"),
filename: "index.html",
}),
new CopyPlugin({
patterns: [
{ from: "assets", to: "assets" },
{ from: "shaders", to: "shaders" },
],
}),
new webpack.HotModuleReplacementPlugin(),
],
devServer: {
historyApiFallback: true,
static: path.resolve(__dirname, "./dist"),
compress: true,
hot: true,
open: true,
port: 3000,
},
};