mirror of
https://github.com/Rezmason/matrix.git
synced 2026-04-14 12:29:30 -07:00
Beginning work on an SVG renderer that creates a static vector graphic version of the effect
This commit is contained in:
@@ -23,6 +23,11 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
artboard {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
18
js/main.js
18
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 = `<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
30
js/svg/imagePass.js
Normal 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
63
js/svg/main.js
Normal 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
79
js/svg/palettePass.js
Normal 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
147
js/svg/rainPass.js
Normal 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
59
js/svg/stripePass.js
Normal 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
70
js/svg/utils.js
Normal 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 };
|
||||
Reference in New Issue
Block a user