Beginning work on an SVG renderer that creates a static vector graphic version of the effect

This commit is contained in:
Rezmason
2025-05-09 12:49:00 -07:00
parent 4f76dbc334
commit 31793a5ece
8 changed files with 463 additions and 8 deletions

View File

@@ -23,6 +23,11 @@
text-align: center;
}
artboard {
width: 100vw;
height: 100vh;
}
canvas {
width: 100vw;
height: 100vh;

View File

@@ -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 = `<div class="notice">
<p>Wake up, Neo... you've got hardware acceleration disabled.</p>
@@ -31,17 +33,17 @@ 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";
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);
}
};

30
js/svg/imagePass.js Normal file
View File

@@ -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();
}
}
);
};

63
js/svg/main.js Normal file
View File

@@ -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);
}
};

79
js/svg/palettePass.js Normal file
View File

@@ -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();
}
}
);
};

147
js/svg/rainPass.js Normal file
View File

@@ -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(`<use fill="#${Math.floor(0xFF * brightness).toString(16)}" href="#sym_${symbol}" transform="translate(${pos[0]},${pos[1]})"></use>`);
}
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();
}
);
};

59
js/svg/stripePass.js Normal file
View File

@@ -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();
}
}
);
};

70
js/svg/utils.js Normal file
View File

@@ -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 };