Files
VFXdemo/thumb-renderer.js
2026-04-02 11:15:21 +08:00

353 lines
12 KiB
JavaScript

(function () {
const THUMB_W = 512;
const THUMB_H = 320;
const THUMB_TIME_CANDIDATES = [
0, 0.1, 0.22, 0.45, 0.78, 1.15, 1.7, 2.25, 2.85, 3.45, 4.2, 5.1, 6.28, 7.5, 9, 11, 13,
];
const GL_CONTEXT_OPTS = {
alpha: false,
antialias: false,
depth: false,
stencil: false,
powerPreference: "high-performance",
desynchronized: true,
};
const vertexShaderSource = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;
function toPortableGlsl(code) {
if (!code) return "";
let s = String(code);
const start = s.indexOf("void mainImage");
if (start >= 0) {
s = s.slice(0, s.length);
}
s = s
.replace(/float2/g, "vec2")
.replace(/float3/g, "vec3")
.replace(/float4/g, "vec4")
.replace(/int2/g, "ivec2")
.replace(/int3/g, "ivec3")
.replace(/int4/g, "ivec4")
.replace(/precise::tanh/g, "tanh")
.replace(/ANGLE_userUniforms\._uiResolution/g, "iResolution")
.replace(/ANGLE_userUniforms\._uiTime/g, "iTime")
.replace(/ANGLE_userUniforms\._uiMouse/g, "iMouse")
.replace(/ANGLE_texture\([^)]*\)/g, "vec4(0.0)")
.replace(/ANGLE_texelFetch\([^)]*\)/g, "vec4(0.0)")
.replace(/ANGLE_mod\(/g, "mod(")
.replace(/\[\[[^\]]+\]\]/g, "")
.replace(/template\s*[^\n]*\n/g, "")
.replace(/ANGLE_out\(([^)]+)\)/g, "$1")
.replace(/ANGLE_swizzle_ref\(([^)]+)\)/g, "$1")
.replace(/ANGLE_elem_ref\(([^)]+)\)/g, "$1")
.replace(/(\d+\.\d+|\d+)f\b/g, "$1");
const mi = s.indexOf("void mainImage");
if (mi >= 0) {
const head = s.slice(0, mi);
const helpers = head
.split("\n\n")
.filter((blk) => /(void|float|vec[234]|mat[234])\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(blk))
.join("\n\n");
const mainPart = s.slice(mi);
return `${helpers}\n\n${mainPart}`;
}
return s;
}
function fallbackShader(name) {
const h = (name || "shader")
.split("")
.reduce((a, c) => (a * 33 + c.charCodeAt(0)) >>> 0, 5381);
const k1 = ((h % 97) / 97).toFixed(3);
const k2 = (((h >> 5) % 89) / 89).toFixed(3);
return `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float t = iTime;
float v = sin(uv.x * (8.0 + ${k1} * 8.0) + t * (1.4 + ${k2} * 1.2))
+ cos(uv.y * (10.0 + ${k2} * 6.0) - t * (1.1 + ${k1}));
vec3 col = 0.5 + 0.5 * cos(vec3(0.2, 1.2, 2.2) + v + t);
fragColor = vec4(col, 1.0);
}`;
}
function buildFragmentShader(userCode) {
return `#version 300 es
precision highp float;
out vec4 outColor;
uniform vec3 iResolution;
uniform float iTime;
uniform float iTimeDelta;
uniform int iFrame;
uniform vec4 iMouse;
uniform vec4 iDate;
uniform sampler2D iChannel0;
uniform sampler2D iChannel1;
uniform sampler2D iChannel2;
uniform sampler2D iChannel3;
${userCode}
void main() { mainImage(outColor, gl_FragCoord.xy); }`;
}
function setIDateUniform(gl, loc) {
if (loc == null) return;
const d = new Date();
gl.uniform4f(
loc,
d.getFullYear(),
d.getMonth() + 1,
d.getDate(),
d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() * 0.001
);
}
function createQuad(gl) {
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
const vao = gl.createVertexArray();
const vbo = gl.createBuffer();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
return { vao, vbo };
}
function compileProgram(gl, fragmentShaderSource) {
function compile(type, label, source) {
if (gl.isContextLost?.()) {
throw new Error(`${label}: WebGL context lost`);
}
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
let info = (gl.getShaderInfoLog(shader) || "").trim();
if (!info) {
const err = gl.getError();
info =
err && err !== gl.NO_ERROR
? `WebGL getError 0x${err.toString(16)} (driver returned no compile log)`
: "No compile log (shader too complex or driver limits).";
}
gl.deleteShader(shader);
throw new Error(`${label} shader:\n${info}`);
}
return shader;
}
const vs = compile(gl.VERTEX_SHADER, "Vertex", vertexShaderSource);
const fs = compile(gl.FRAGMENT_SHADER, "Fragment", fragmentShaderSource);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
let info = (gl.getProgramInfoLog(program) || "").trim();
if (!info) {
const err = gl.getError();
info =
err && err !== gl.NO_ERROR
? `WebGL getError 0x${err.toString(16)} (no link log)`
: "No link log.";
}
gl.deleteProgram(program);
throw new Error(`Program link:\n${info}`);
}
return program;
}
function createTexture(gl, width, height, data) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.bindTexture(gl.TEXTURE_2D, null);
return tex;
}
function createDefaultChannelTextures(gl, noiseSize) {
const w0 = 512;
const h0 = 2;
const d0 = new Uint8Array(w0 * h0 * 4);
for (let x = 0; x < w0; x++) {
const t = x / (w0 - 1);
const fft = Math.floor(255 * Math.pow(1.0 - t, 1.8));
const wave = Math.floor(128 + 127 * Math.sin(t * Math.PI * 10.0));
const i0 = (x + 0 * w0) * 4;
const i1 = (x + 1 * w0) * 4;
d0[i0] = fft;
d0[i0 + 1] = fft;
d0[i0 + 2] = fft;
d0[i0 + 3] = 255;
d0[i1] = wave;
d0[i1 + 1] = wave;
d0[i1 + 2] = wave;
d0[i1 + 3] = 255;
}
const size = noiseSize;
const makeNoise = (scaleA, scaleB) => {
const d = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const n = Math.floor(
127 +
127 *
Math.sin((x * scaleA + y * scaleB) * 0.11) *
Math.cos((x * scaleB - y * scaleA) * 0.07)
);
d[i] = n;
d[i + 1] = (n * 37) & 255;
d[i + 2] = (n * 73) & 255;
d[i + 3] = 255;
}
}
return d;
};
return [
createTexture(gl, w0, h0, d0),
createTexture(gl, size, size, makeNoise(1.0, 0.7)),
createTexture(gl, size, size, makeNoise(0.8, 1.3)),
createTexture(gl, size, size, makeNoise(1.7, 0.4)),
];
}
function bindChannelTextures(gl, channelUniformLocs, channelTextures) {
for (let i = 0; i < 4; i++) {
if (channelUniformLocs[i] == null) continue;
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, channelTextures[i]);
gl.uniform1i(channelUniformLocs[i], i);
}
gl.activeTexture(gl.TEXTURE0);
}
function sampleThumbStats(data, width, height) {
const row = width * 4;
let sumL = 0;
let sumL2 = 0;
let n = 0;
for (let y = 0; y < height; y += 5) {
for (let x = 0; x < width; x += 6) {
const i = y * row + x * 4;
if (i + 2 >= data.length) continue;
const r = data[i] / 255;
const g = data[i + 1] / 255;
const b = data[i + 2] / 255;
const L = 0.299 * r + 0.587 * g + 0.114 * b;
sumL += L;
sumL2 += L * L;
n++;
}
}
if (n < 4) return { mean: 0, varL: 0 };
const mean = sumL / n;
const varL = Math.max(0, sumL2 / n - mean * mean);
return { mean, varL };
}
function isAcceptableThumb(mean, varL) {
if (mean < 0.017 || mean > 0.99) return false;
if (varL < 0.00065) return false;
return true;
}
function thumbScore(mean, varL) {
return varL * 85 + (1 - Math.abs(mean - 0.4) * 1.05);
}
window.captureShaderThumbnail = function captureShaderThumbnail(code, name) {
const canvas = document.createElement("canvas");
canvas.width = THUMB_W;
canvas.height = THUMB_H;
const gl = canvas.getContext("webgl2", GL_CONTEXT_OPTS);
if (!gl) {
return Promise.reject(new Error("当前浏览器不支持 WebGL2"));
}
let program;
let codeForRender = code;
try {
program = compileProgram(gl, buildFragmentShader(codeForRender));
} catch (_e1) {
try {
codeForRender = toPortableGlsl(code);
program = compileProgram(gl, buildFragmentShader(codeForRender));
} catch (_e2) {
codeForRender = fallbackShader(name);
program = compileProgram(gl, buildFragmentShader(codeForRender));
}
}
const { vao } = createQuad(gl);
const channelTextures = createDefaultChannelTextures(gl, 128);
const channelUniforms = [
gl.getUniformLocation(program, "iChannel0"),
gl.getUniformLocation(program, "iChannel1"),
gl.getUniformLocation(program, "iChannel2"),
gl.getUniformLocation(program, "iChannel3"),
];
const uniforms = {
iResolution: gl.getUniformLocation(program, "iResolution"),
iTime: gl.getUniformLocation(program, "iTime"),
iTimeDelta: gl.getUniformLocation(program, "iTimeDelta"),
iFrame: gl.getUniformLocation(program, "iFrame"),
iMouse: gl.getUniformLocation(program, "iMouse"),
iDate: gl.getUniformLocation(program, "iDate"),
};
gl.viewport(0, 0, THUMB_W, THUMB_H);
gl.useProgram(program);
bindChannelTextures(gl, channelUniforms, channelTextures);
gl.uniform3f(uniforms.iResolution, THUMB_W, THUMB_H, 1);
gl.uniform1f(uniforms.iTimeDelta, 0);
setIDateUniform(gl, uniforms.iDate);
gl.uniform4f(uniforms.iMouse, 0, 0, 0, 0);
gl.bindVertexArray(vao);
gl.pixelStorei(gl.PACK_ALIGNMENT, 1);
const pixels = new Uint8Array(THUMB_W * THUMB_H * 4);
let bestStrict = { t: 0.5, score: -1e9 };
let bestLoose = { t: 0.5, varL: -1 };
for (let ti = 0; ti < THUMB_TIME_CANDIDATES.length; ti++) {
const t = THUMB_TIME_CANDIDATES[ti];
gl.uniform1f(uniforms.iTime, t);
gl.uniform1i(uniforms.iFrame, Math.max(0, Math.floor(t * 60)));
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.readPixels(0, 0, THUMB_W, THUMB_H, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const { mean, varL } = sampleThumbStats(pixels, THUMB_W, THUMB_H);
if (varL > bestLoose.varL) bestLoose = { t, varL };
if (isAcceptableThumb(mean, varL)) {
const sc = thumbScore(mean, varL);
if (sc > bestStrict.score) bestStrict = { t, score: sc };
}
}
const pickT = bestStrict.score > -1e8 ? bestStrict.t : bestLoose.t;
gl.uniform1f(uniforms.iTime, pickT);
gl.uniform1i(uniforms.iFrame, Math.max(0, Math.floor(pickT * 60)));
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
const dataUrl = canvas.toDataURL("image/png");
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) ext.loseContext();
return Promise.resolve(dataUrl);
};
})();