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:
Make sure component works right
bundled, of course
webpack?
WebGPU formulate is expensive
Mirror pass clicks bug
Minify bundles
Naming "matrix" for the github repo, "digital-rain" and "DigitalRain" for everything else
Minimum react requirement?
Retire fetchLibraries?
Move off of regl
Unify implementations?
Responsive changes
@@ -14,10 +17,10 @@ TODO:
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
Create multiple distributions
Core vs full
core
One embedded MSDF, combined from the two main glyph sets and their configs
fun
full
Other MSDFs and configs
and then one with built-in MSDF generation
(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 name="apple-mobile-web-app-capable" content="yes" />
<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>
html,
body {
height: 100%;
overflow: hidden;
margin: 0;
}
@supports (padding-top: env(safe-area-inset-top)) {
body {
padding: 0;
height: calc(100% + env(safe-area-inset-top));
}
}
body {
background: black;
overflow: hidden;
margin: 0;
font-family: monospace;
font-size: 2em;
text-align: center;

View File

@@ -107,14 +107,32 @@ import makeConfig from "./utils/config";
export const Matrix = memo((props) => {
const { style, className, ...rawConfigProps } = props;
const elProps = { style, className };
const matrix = useRef(null);
const [rCanvas, setCanvas] = useState(null);
const domElement = useRef(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(
Object.entries(rawConfigProps).filter(([_, value]) => value != null),
);
const resizeObserver = new ResizeObserver(entries => {
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 = () => {
return (
@@ -125,62 +143,50 @@ export const Matrix = memo((props) => {
};
const cleanup = () => {
if (rCanvas != null) {
rCanvas.remove();
setCanvas(null);
}
if (rRain != null) {
rRenderer?.destroy(rRain);
setRain(null);
}
if (rRenderer != null) {
setRenderer(null);
}
if (rRenderer == null) return;
rRenderer.canvas.remove();
rRenderer.destroy();
setRenderer(null);
};
useEffect(() => {
const useWebGPU = supportsWebGPU() && ["webgpu"].includes(configProps.renderer?.toLowerCase());
const useWebGPU = supportsWebGPU() && rConfig.renderer === "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 () => {
let renderer;
if (useWebGPU) {
renderer = await import("./webgpu/main.js");
rendererClasses.webgpu ??= (await import("./webgpu/renderer.js")).default;
renderer = new (rendererClasses.webgpu)();
} else {
renderer = await import("./regl/main.js");
rendererClasses.regl ??= (await import("./regl/renderer.js")).default;
renderer = new (rendererClasses.regl)();
}
setRenderer(renderer);
const rain = await renderer.init(canvas);
setRain(rain);
await renderer.ready;
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;
}, [props.renderer]);
}, [rConfig.renderer]);
useEffect(() => {
if (rRain == null || rRain.destroyed) {
return;
}
const refresh = async () => {
await rRenderer.formulate(rRain, makeConfig(configProps));
};
refresh();
}, [props, rRain]);
if (rRenderer?.destroyed ?? true) return;
rRenderer.formulate(rConfig);
}, [rRenderer, rConfig]);
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 () => {
let glMatrix, createREGL, inclusions;
let glMatrix, createREGL, staticAssets;
try {
glMatrix = await import("gl-matrix");
createREGL = (await import("regl")).default;
inclusions = (await import("./inclusions.js")).default;
staticAssets = (await import("./staticAssets.js")).default;
} catch {
const loadJS = (src) =>
new Promise((resolve, reject) => {
@@ -15,8 +15,8 @@ export default async () => {
await Promise.all([loadJS("lib/regl.min.js"), loadJS("lib/gl-matrix.js")]);
glMatrix = globalThis.glMatrix;
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";
const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
document.addEventListener("touchmove", (e) => e.preventDefault(), {
passive: false,
});
@@ -25,12 +23,21 @@ document.body.onload = async () => {
const urlParams = new URLSearchParams(window.location.search);
const config = makeConfig(Object.fromEntries(urlParams.entries()));
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 { init, formulate } = await solution;
const rain = await init(canvas);
await formulate(rain, config);
const initialize = async (config) => {
const Renderer = (await rendererModule).default;
const renderer = new Renderer();
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) {
@@ -41,17 +48,15 @@ document.body.onload = async () => {
<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>
`;
canvas.style.display = "none";
document.body.appendChild(notice);
document.querySelector(".blue.pill").addEventListener("click", async () => {
config.suppressWarnings = true;
urlParams.set("suppressWarnings", true);
history.replaceState({}, "", "?" + unescape(urlParams.toString()));
await initialize(canvas, config);
canvas.style.display = "unset";
await initialize(config);
document.body.removeChild(notice);
});
} 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();
data.crossOrigin = "anonymous";
let imageURL;
if (typeof cache.get(`import::${url}`) === "function") {
imageURL = (await cache.get(`import::${url}`)()).default;
if (typeof cache.get(`url::${url}`) === "function") {
imageURL = (await cache.get(`url::${url}`)()).default;
} else {
imageURL = url;
}
@@ -103,13 +103,11 @@ const loadText = (cache, url) => {
},
loaded: (async () => {
if (url != null) {
let textURL;
if (typeof cache.get(`import::${url}`) === "function") {
textURL = (await cache.get(`import::${url}`)()).default;
if (typeof cache.get(`raw::${url}`) === "function") {
text = (await cache.get(`raw::${url}`)()).default;
} else {
textURL = url;
text = await (await fetch(url)).text();
}
text = await (await fetch(textURL)).text();
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,
highPassThreshold: 0,
numColumns: 60,
cycleSpeed: 0.03,
bloomStrength: 0.7,
fallSpeed: 0.3,
palette: [
@@ -278,7 +277,6 @@ const versions = {
baseContrast: 1.5,
highPassThreshold: 0,
numColumns: 60,
cycleSpeed: 0.03,
bloomStrength: 0.7,
fallSpeed: 0.3,
palette: [
@@ -307,7 +305,6 @@ const versions = {
baseContrast: 1.5,
highPassThreshold: 0,
numColumns: 60,
cycleSpeed: 0.03,
bloomStrength: 0.7,
fallSpeed: 0.3,
palette: [
@@ -368,7 +365,7 @@ versions["2021"] = versions.resurrections;
const range = (f, min = -Infinity, max = Infinity) => Math.max(min, Math.min(max, 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) => ({
space: isHSL ? "hsl" : "rgb",
@@ -491,7 +488,7 @@ paramMapping.dropLength = paramMapping.raindropLength;
paramMapping.angle = paramMapping.slant;
paramMapping.colors = paramMapping.stripeColors;
export default (urlParams) => {
export default (urlParams = {}) => {
const validParams = Object.fromEntries(
Object.entries(urlParams)
.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 {
let imageURL;
if (typeof cache.get(`import::${url}`) === "function") {
imageURL = (await cache.get(`import::${url}`)()).default;
if (typeof cache.get(`url::${url}`) === "function") {
imageURL = (await cache.get(`url::${url}`)()).default;
} else {
imageURL = url;
}
@@ -74,14 +74,12 @@ const loadShader = async (device, cache, url) => {
if (cache.has(key)) {
return cache.get(key);
}
let textURL;
if (typeof cache.get(`import::${url}`) === "function") {
textURL = (await cache.get(`import::${url}`)()).default;
let code;
if (typeof cache.get(`raw::${url}`) === "function") {
code = (await cache.get(`raw::${url}`)()).default;
} else {
textURL = url;
code = await (await fetch(url)).text();
}
const response = await fetch(textURL);
const code = await response.text();
return {
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",
"description": "web-based green code rain, made with love",
"type": "module",
"main": "./dist/digital-rain.cjs",
"module": "./dist/digital-rain.module.js",
"exports": {
".": {
"import": "./dist/digital-rain.module.js",
"require": "./dist/digital-rain.cjs"
}
},
"files": [
"/dist",
"LICENSE",
@@ -18,10 +11,13 @@
"README.md"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"format": "prettier --write --use-tabs --print-width 100 'js/**/*.js' '*.json' '*.js' './*.js' './*.mjs'",
"start": "npm run format ; webpack serve --config ./webpack.config.js",
"build": "npm run format ; rollup -c"
"dev": "npm run format && vite --config tools/dev.config.js",
"build": "npm run format && rm -rf ./dist/* && vite build --config tools/build/core.config.js && vite build --config tools/build/full.config.js",
"format": "eslint . && prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,mjs,json}' '*.{js,jsx,mjs,json,html}' 'assets/**/*.{json,css}'"
},
"prettier": {
"useTabs": true,
"printWidth": 100
},
"keywords": [
"rain",
@@ -55,27 +51,16 @@
"regl": "^2.1.0"
},
"devDependencies": {
"@babel/core": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/preset-react": "^7.22.5",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-image": "^3.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-url": "^8.0.2",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^13.0.0",
"html-webpack-plugin": "^5.5.3",
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"prettier": "^3.5.3",
"raw-loader": "^4.0.2",
"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"
"vite": "^6.3.5"
},
"peerDependencies": {
"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 { unmountComponentAtNode } from "react-dom";
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 = [
"classic",
"3d",
@@ -16,11 +30,12 @@ const versions = [
"bugs",
"morpheus",
];
const effects = ["none", "plain", "palette", "stripes", "pride", "trans", "image", "mirror"];
const effects = ["none", "palette", "stripes", "pride", "trans", "image", "mirror"];
const App = () => {
const [version, setVersion] = useState(versions[0]);
const [effect, setEffect] = useState("plain");
const [effect, setEffect] = useState("palette");
const [numColumns, setNumColumns] = useState(80);
const [resolution, setResolution] = useState(0.75);
const [cursorColor, setCursorColor] = useState(null);
const [backgroundColor, setBackgroundColor] = useState("0,0,0");
const [rendererType, setRendererType] = useState(null);
@@ -60,8 +75,7 @@ const App = () => {
};
return (
<div>
<h1>Rain</h1>
<>
<button onClick={onVersionButtonClick}>Version: "{version}"</button>
<button onClick={onEffectButtonClick}>Effect: "{effect}"</button>
<button onClick={onRendererButtonClick}>Renderer: {rendererType ?? "default (regl)"}</button>
@@ -94,25 +108,37 @@ const App = () => {
<input
name="num-columns"
type="range"
value={numColumns}
min="10"
max="160"
step="1"
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 && (
<Matrix
style={{ width: "80vw", height: "45vh" }}
style={{ width: "40vw", height: "22vh" }}
version={version}
effect={effect}
numColumns={numColumns}
resolution={resolution}
renderer={rendererType}
cursorColor={cursorColor}
backgroundColor={backgroundColor}
density={2.0}
/>
)}
</div>
</>
);
};
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,
},
};