From 31793a5ece66ed3fa4ad19eba35e580f29f9d43e Mon Sep 17 00:00:00 2001 From: Rezmason Date: Fri, 9 May 2025 12:49:00 -0700 Subject: [PATCH] Beginning work on an SVG renderer that creates a static vector graphic version of the effect --- index.html | 5 ++ js/main.js | 18 +++--- js/svg/imagePass.js | 30 +++++++++ js/svg/main.js | 63 ++++++++++++++++++ js/svg/palettePass.js | 79 +++++++++++++++++++++++ js/svg/rainPass.js | 147 ++++++++++++++++++++++++++++++++++++++++++ js/svg/stripePass.js | 59 +++++++++++++++++ js/svg/utils.js | 70 ++++++++++++++++++++ 8 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 js/svg/imagePass.js create mode 100644 js/svg/main.js create mode 100644 js/svg/palettePass.js create mode 100644 js/svg/rainPass.js create mode 100644 js/svg/stripePass.js create mode 100644 js/svg/utils.js diff --git a/index.html b/index.html index 6a365b6..f0532da 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,11 @@ text-align: center; } + artboard { + width: 100vw; + height: 100vh; + } + canvas { width: 100vw; height: 100vh; diff --git a/js/main.js b/js/main.js index 3f6809b..1b36af3 100644 --- a/js/main.js +++ b/js/main.js @@ -1,7 +1,5 @@ import makeConfig from "./config.js"; -const canvas = document.createElement("canvas"); -document.body.appendChild(canvas); document.addEventListener("touchmove", (e) => e.preventDefault(), { passive: false, }); @@ -21,9 +19,13 @@ 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 useSVG = ["svg"].includes(config.renderer?.toLowerCase()); + const solution = import(`./${useSVG ? "svg" : useWebGPU ? "webgpu" : "regl"}/main.js`); - if (isRunningSwiftShader() && !config.suppressWarnings) { + const element = document.createElement(useSVG ? "artboard" : "canvas"); + document.body.appendChild(element); + + if (!useSVG && isRunningSwiftShader() && !config.suppressWarnings) { const notice = document.createElement("notice"); notice.innerHTML = `

Wake up, Neo... you've got hardware acceleration disabled.

@@ -31,17 +33,17 @@ document.body.onload = async () => { Free me `; - canvas.style.display = "none"; + element.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 solution).default(canvas, config); - canvas.style.display = "unset"; + (await solution).default(element, config); + element.style.display = "unset"; document.body.removeChild(notice); }); } else { - (await solution).default(canvas, config); + (await solution).default(element, config); } }; diff --git a/js/svg/imagePass.js b/js/svg/imagePass.js new file mode 100644 index 0000000..a5a26f0 --- /dev/null +++ b/js/svg/imagePass.js @@ -0,0 +1,30 @@ +import { loadImage, loadText, makePassSVG, makePass } from "./utils.js"; + +// Multiplies the rendered rain and bloom by a loaded in image + +const defaultBGURL = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Flammarion_Colored.jpg/917px-Flammarion_Colored.jpg"; + +export default ({ config }, inputs) => { + const output = makePassSVG(); + const bgURL = "bgURL" in config ? config.bgURL : defaultBGURL; + const background = loadImage(bgURL); + + const render = () => { + + }; + + return makePass( + { + primary: output, + }, + Promise.all([background.loaded]), + (w, h) => { + // output.resize(w, h); + }, + (shouldRender) => { + if (shouldRender) { + render(); + } + } + ); +}; diff --git a/js/svg/main.js b/js/svg/main.js new file mode 100644 index 0000000..f049703 --- /dev/null +++ b/js/svg/main.js @@ -0,0 +1,63 @@ +import { makePipeline } from "./utils.js"; + +import makeRain from "./rainPass.js"; +import makePalettePass from "./palettePass.js"; +import makeStripePass from "./stripePass.js"; +import makeImagePass from "./imagePass.js"; + +const effects = { + none: null, + plain: makePalettePass, + palette: makePalettePass, + customStripes: makeStripePass, + stripes: makeStripePass, + pride: makeStripePass, + transPride: makeStripePass, + trans: makeStripePass, + image: makeImagePass, +}; + +const dimensions = { width: 1, height: 1 }; + +const loadJS = (src) => + new Promise((resolve, reject) => { + const tag = document.createElement("script"); + tag.onload = resolve; + tag.onerror = reject; + tag.src = src; + document.body.appendChild(tag); + }); + +export default async (artboard, config) => { + await Promise.all([loadJS("lib/gl-matrix.js")]); + + const rect = artboard.getBoundingClientRect(); + [dimensions.width, dimensions.height] = [rect.width, rect.height]; + + if (document.fullscreenEnabled || document.webkitFullscreenEnabled) { + window.ondblclick = () => { + if (document.fullscreenElement == null) { + if (artboard.webkitRequestFullscreen != null) { + artboard.webkitRequestFullscreen(); + } else { + artboard.requestFullscreen(); + } + } else { + document.exitFullscreen(); + } + }; + } + + const effectName = config.effect in effects ? config.effect : "palette"; + const context = { artboard, config }; + const pipeline = makePipeline(context, [makeRain, effects[effectName]]); + await Promise.all(pipeline.map((step) => step.ready)); + + for (const step of pipeline) { + step.setSize(dimensions.width, dimensions.height); + } + + for (const step of pipeline) { + step.execute(true); + } +}; diff --git a/js/svg/palettePass.js b/js/svg/palettePass.js new file mode 100644 index 0000000..5403402 --- /dev/null +++ b/js/svg/palettePass.js @@ -0,0 +1,79 @@ +import colorToRGB from "../colorToRGB.js"; +import { loadText, makeLinearGradient, makePassSVG, makePass } from "./utils.js"; + +// Maps the brightness of the rendered rain and bloom to colors +// in a 1D gradient palette texture generated from the passed-in color sequence + +// This shader introduces noise into the renders, to avoid banding + +const makePalette = (entries) => { + const PALETTE_SIZE = 2048; + const paletteColors = Array(PALETTE_SIZE); + + // Convert HSL gradient into sorted RGB gradient, capping the ends + const sortedEntries = entries + .slice() + .sort((e1, e2) => e1.at - e2.at) + .map((entry) => ({ + rgb: colorToRGB(entry.color), + arrayIndex: Math.floor(Math.max(Math.min(1, entry.at), 0) * (PALETTE_SIZE - 1)), + })); + sortedEntries.unshift({ rgb: sortedEntries[0].rgb, arrayIndex: 0 }); + sortedEntries.push({ + rgb: sortedEntries[sortedEntries.length - 1].rgb, + arrayIndex: PALETTE_SIZE - 1, + }); + + // Interpolate between the sorted RGB entries to generate + // the palette texture data + sortedEntries.forEach((entry, index) => { + paletteColors[entry.arrayIndex] = entry.rgb.slice(); + if (index + 1 < sortedEntries.length) { + const nextEntry = sortedEntries[index + 1]; + const diff = nextEntry.arrayIndex - entry.arrayIndex; + for (let i = 0; i < diff; i++) { + const ratio = i / diff; + paletteColors[entry.arrayIndex + i] = [ + entry.rgb[0] * (1 - ratio) + nextEntry.rgb[0] * ratio, + entry.rgb[1] * (1 - ratio) + nextEntry.rgb[1] * ratio, + entry.rgb[2] * (1 - ratio) + nextEntry.rgb[2] * ratio, + ]; + } + } + }); + + return makeLinearGradient( + paletteColors.map((rgb) => [...rgb, 1]) + ); +}; + +// The rendered texture's values are mapped to colors in a palette texture. +// A little noise is introduced, to hide the banding that appears +// in subtle gradients. The noise is also time-driven, so its grain +// won't persist across subsequent frames. This is a safe trick +// in screen space. + +export default ({ config }, inputs) => { + const output = makePassSVG(); + const paletteTex = makePalette(config.palette); + const { backgroundColor, cursorColor, glintColor, cursorIntensity, glintIntensity, ditherMagnitude } = config; + + const render = () => { + + }; + + return makePass( + { + primary: output, + }, + null, + (w, h) => { + // output.resize(w, h); + }, + (shouldRender) => { + if (shouldRender) { + render(); + } + } + ); +}; diff --git a/js/svg/rainPass.js b/js/svg/rainPass.js new file mode 100644 index 0000000..07ebc5b --- /dev/null +++ b/js/svg/rainPass.js @@ -0,0 +1,147 @@ +import { loadImage, loadText, makePassSVG, makePass } from "./utils.js"; + +const extractEntries = (src, keys) => Object.fromEntries(Array.from(Object.entries(src)).filter(([key]) => keys.includes(key))); + +const rippleTypes = { + box: 0, + circle: 1, +}; + +export default ({ artboard, config }) => { + const { mat2, mat4, vec2, vec3, vec4 } = glMatrix; + + // The volumetric mode multiplies the number of columns + // to reach the desired density, and then overlaps them + const volumetric = config.volumetric; + const density = volumetric && config.effect !== "none" ? config.density : 1; + const [numRows, numColumns] = [config.numColumns, Math.floor(config.numColumns * density)]; + + // Various effect-related values + const rippleType = config.rippleTypeName in rippleTypes ? rippleTypes[config.rippleTypeName] : -1; + const slantVec = [Math.cos(config.slant), Math.sin(config.slant)]; + const slantScale = 1 / (Math.abs(Math.sin(2 * config.slant)) * (Math.sqrt(2) - 1) + 1); + const showDebugView = config.effect === "none"; + + const glyphTransform = mat2.fromScaling(mat2.create(), vec2.fromValues(config.glyphFlip ? -1 : 1, 1)); + mat2.rotate(glyphTransform, glyphTransform, (config.glyphRotation * Math.PI) / 180); + + const glyphPositions = Array(numRows) + .fill() + .map((_, y) => + Array(numColumns) + .fill() + .map((_, x) => vec2.fromValues(x, y)) + ).flat(); + + const glyphs = Array(numRows * numColumns).fill(null); + + // We render the code into an FBO using MSDFs: https://github.com/Chlumsky/msdfgen + const glyphMSDF = loadImage(config.glyphMSDFURL); + const glintMSDF = loadImage(config.glintMSDFURL); + const baseTexture = loadImage(config.baseTextureURL, true); + const glintTexture = loadImage(config.glintTextureURL, true); + const output = makePassSVG(); + + const raindrop = () => { + + const SQRT_2 = Math.sqrt(2); + const SQRT_5 = Math.sqrt(5); + + const randomAB = vec2.fromValues(12.9898, 78.233); + const randomFloat = (uv) => { + const dt = vec2.dot(uv, randomAB); + return (Math.sin(dt % Math.PI) * 43758.5453) % 1; + } + + const wobble = (x) => { + return x + 0.3 * Math.sin(SQRT_2 * x) + 0.2 * Math.sin(SQRT_5 * x); + } + + const columnPos = vec2.create(); + const getRainBrightness = (pos) => { + columnPos[0] = pos[0]; + const columnTime = randomFloat(columnPos) * 1000; + let rainTime = (pos[1] * 0.01 + columnTime) / config.raindropLength; + if (!config.loops) { + rainTime = wobble(rainTime); + } + return 1.0 - (rainTime % 1); + } + + const gridSize = vec2.fromValues(numColumns, numRows); + const posBelow = vec2.create(); + for (let i = 0; i < glyphPositions.length; i++) { + const pos = glyphPositions[i]; + vec2.set(posBelow, pos[0], pos[1] - 1); + const brightness = getRainBrightness(pos); + const brightnessBelow = getRainBrightness(posBelow); + const isCursor = brightness > brightnessBelow; + const symbol = Math.floor(config.glyphSequenceLength * Math.random()); + glyphs[i] = { + pos, brightness, isCursor, symbol + }; + } + }; + + const glyphElements = []; + + const render = () => { + // TODO: rain pass vert, rain pass frag + + for (const {pos, brightness, isCursor, symbol} of glyphs) { + if (brightness < 0) { + continue; + } + glyphElements.push(``); + } + console.log(glyphElements.join("\n")); + }; + + // Camera and transform math for the volumetric mode + const screenSize = [1, 1]; + const transform = mat4.create(); + if (volumetric && config.isometric) { + mat4.rotateX(transform, transform, (Math.PI * 1) / 8); + mat4.rotateY(transform, transform, (Math.PI * 1) / 4); + mat4.translate(transform, transform, vec3.fromValues(0, 0, -1)); + mat4.scale(transform, transform, vec3.fromValues(1, 1, 2)); + } else { + mat4.translate(transform, transform, vec3.fromValues(0, 0, -1)); + } + const camera = mat4.create(); + + return makePass( + { + primary: output, + }, + Promise.all([ + glyphMSDF.loaded, + glintMSDF.loaded, + baseTexture.loaded, + glintTexture.loaded, + // rainPassRaindrop.loaded, + // rainPassSymbol.loaded, + // rainPassVert.loaded, + // rainPassFrag.loaded, + ]), + (w, h) => { + // output.resize(w, h); + const aspectRatio = w / h; + + if (volumetric && config.isometric) { + if (aspectRatio > 1) { + mat4.ortho(camera, -1.5 * aspectRatio, 1.5 * aspectRatio, -1.5, 1.5, -1000, 1000); + } else { + mat4.ortho(camera, -1.5, 1.5, -1.5 / aspectRatio, 1.5 / aspectRatio, -1000, 1000); + } + } else { + mat4.perspective(camera, (Math.PI / 180) * 90, aspectRatio, 0.0001, 1000); + } + [screenSize[0], screenSize[1]] = aspectRatio > 1 ? [1, aspectRatio] : [1 / aspectRatio, 1]; + }, + (shouldRender) => { + raindrop(); + render(); + } + ); +}; diff --git a/js/svg/stripePass.js b/js/svg/stripePass.js new file mode 100644 index 0000000..5f0d7db --- /dev/null +++ b/js/svg/stripePass.js @@ -0,0 +1,59 @@ +import colorToRGB from "../colorToRGB.js"; +import { loadText, makeLinearGradient, makePassSVG, makePass } from "./utils.js"; + +// Multiplies the rendered rain and bloom by a 1D gradient texture +// generated from the passed-in color sequence + +// This shader introduces noise into the renders, to avoid banding + +const transPrideStripeColors = [ + { space: "rgb", values: [0.36, 0.81, 0.98] }, + { space: "rgb", values: [0.96, 0.66, 0.72] }, + { space: "rgb", values: [1.0, 1.0, 1.0] }, + { space: "rgb", values: [0.96, 0.66, 0.72] }, + { space: "rgb", values: [0.36, 0.81, 0.98] }, +] + .map((color) => Array(3).fill(color)) + .flat(); + +const prideStripeColors = [ + { space: "rgb", values: [0.89, 0.01, 0.01] }, + { space: "rgb", values: [1.0, 0.55, 0.0] }, + { space: "rgb", values: [1.0, 0.93, 0.0] }, + { space: "rgb", values: [0.0, 0.5, 0.15] }, + { space: "rgb", values: [0.0, 0.3, 1.0] }, + { space: "rgb", values: [0.46, 0.03, 0.53] }, +] + .map((color) => Array(2).fill(color)) + .flat(); + +export default ({ config }, inputs) => { + const output = makePassSVG(); + + const { backgroundColor, cursorColor, glintColor, cursorIntensity, glintIntensity, ditherMagnitude } = config; + + // Expand and convert stripe colors into 1D texture data + const stripeColors = "stripeColors" in config ? config.stripeColors : config.effect === "pride" ? prideStripeColors : transPrideStripeColors; + const stripeTex = makeLinearGradient( + stripeColors.map((color) => [...colorToRGB(color), 1]) + ); + + const render = () => { + + }; + + return makePass( + { + primary: output, + }, + null, + (w, h) => { + // output.resize(w, h); + }, + (shouldRender) => { + if (shouldRender) { + render(); + } + } + ); +}; diff --git a/js/svg/utils.js b/js/svg/utils.js new file mode 100644 index 0000000..836b408 --- /dev/null +++ b/js/svg/utils.js @@ -0,0 +1,70 @@ +const makePassSVG = () => document.createElementNS("http://www.w3.org/2000/svg", "svg"); + +const loadImage = (url) => { + const image = new Image(); + let loaded = false; + return { + image: () => { + if (!loaded && url != null) { + console.warn(`image still loading: ${url}`); + } + return image; + }, + width: () => { + if (!loaded && url != null) { + console.warn(`image still loading: ${url}`); + } + return loaded ? image.width : 1; + }, + height: () => { + if (!loaded && url != null) { + console.warn(`image still loading: ${url}`); + } + return loaded ? image.height : 1; + }, + loaded: (async () => { + if (url != null) { + image.crossOrigin = "anonymous"; + image.src = url; + await image.decode(); + loaded = true; + } + })(), + }; +}; + +const loadText = (url) => { + let text = ""; + let loaded = false; + return { + text: () => { + if (!loaded) { + console.warn(`text still loading: ${url}`); + } + return text; + }, + loaded: (async () => { + if (url != null) { + text = await (await fetch(url)).text(); + loaded = true; + } + })(), + }; +}; + +const makeLinearGradient = (rgbas) => { + const data = rgbas.map((rgba) => rgba.map((f) => Math.floor(f * 0xff))).flat(); + return data; +}; + +const makePass = (outputs, ready, setSize, execute) => ({ + outputs: outputs ?? {}, + ready: ready ?? Promise.resolve(), + setSize: setSize ?? (() => {}), + execute: execute ?? (() => {}), +}); + +const makePipeline = (context, steps) => + steps.filter((f) => f != null).reduce((pipeline, f, i) => [...pipeline, f(context, i == 0 ? null : pipeline[i - 1].outputs)], []); + +export { loadText, loadImage, makeLinearGradient, makePass, makePassSVG, makePipeline };