Files
VFXdemo/display.js
2026-04-02 10:53:36 +08:00

841 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const pauseBtn = document.getElementById("pause-btn");
const resetBtn = document.getElementById("reset-btn");
const statsEl = document.getElementById("stats");
const globalFpsEl = document.getElementById("global-fps");
const shaderCountEl = document.getElementById("shader-count");
const searchInput = document.getElementById("search-input");
const gridEl = document.getElementById("preview-grid");
const detailViewEl = document.getElementById("detail-view");
const detailTitleEl = document.getElementById("detail-title");
const detailFpsEl = document.getElementById("detail-fps");
const detailCanvas = document.getElementById("detail-canvas");
const backBtn = document.getElementById("back-btn");
const API_BASE = "/api/shaders";
/** 串行补全缺失缩略图(每步独立 WebGL用完即释放避免多卡片同时建上下文 */
const thumbBackfillQueue = [];
const thumbBackfillQueuedIds = new Set();
const thumbBackfillFailUntil = new Map();
let thumbBackfillRunner = false;
const THUMB_BACKOFF_MS = 120000;
let gridHoverRuntime = null;
let sharedHoverCanvas = null;
function scheduleThumbBackfill(shaders) {
if (typeof window.captureShaderThumbnail !== "function") return;
const now = Date.now();
for (const s of shaders) {
if (s.thumbnailUrl) continue;
const code = s.code && String(s.code).trim();
if (!code) continue;
const retryAt = thumbBackfillFailUntil.get(s.id);
if (retryAt != null && now < retryAt) continue;
if (thumbBackfillQueuedIds.has(s.id)) continue;
thumbBackfillQueuedIds.add(s.id);
thumbBackfillQueue.push({ id: s.id, code, name: s.name || "shader" });
}
void runThumbBackfillLoop();
}
async function runThumbBackfillLoop() {
if (thumbBackfillRunner) return;
thumbBackfillRunner = true;
try {
while (thumbBackfillQueue.length) {
if (detailRuntime || gridHoverRuntime) {
await new Promise((r) => setTimeout(r, 250));
continue;
}
const job = thumbBackfillQueue.shift();
try {
const dataUrl = await window.captureShaderThumbnail(job.code, job.name);
const res = await fetch(`${API_BASE}/${encodeURIComponent(job.id)}/thumbnail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pngBase64: dataUrl }),
});
if (res.ok) {
thumbBackfillFailUntil.delete(job.id);
const url = `${API_BASE}/${encodeURIComponent(job.id)}/thumbnail?t=${Date.now()}`;
replaceCardStageWithThumb(job.id, url);
} else {
thumbBackfillFailUntil.set(job.id, Date.now() + THUMB_BACKOFF_MS);
}
} catch (e) {
console.warn("缩略图自动生成失败", job.id, e);
thumbBackfillFailUntil.set(job.id, Date.now() + THUMB_BACKOFF_MS);
} finally {
thumbBackfillQueuedIds.delete(job.id);
}
await new Promise((r) => setTimeout(r, 60));
}
} finally {
thumbBackfillRunner = false;
}
}
function replaceCardStageWithThumb(id, url) {
if (gridHoverRuntime && gridHoverRuntime.state.id === id) tearDownGridHover();
const card = gridEl.querySelector(`[data-shader-id="${CSS.escape(id)}"]`);
if (!card) return;
const stage = card.querySelector(".card-stage");
if (!stage) return;
stage.innerHTML = `<img class="card-thumb" src="${url}" alt="" loading="lazy" decoding="async" />`;
}
/** 全屏详情:限制像素量以便稳定 50+ FPS过高 DPR × 边长会显著拖慢片元着色器) */
const DETAIL_MAX_PIXEL_RATIO = 1.35;
const DETAIL_MAX_BUFFER_LONG_SIDE = 1680;
/** 卡片 hover 实时预览:单例 WebGL与缩略图同量级分辨率上限 */
const HOVER_MAX_PIXEL_RATIO = 1.25;
const HOVER_MAX_BUFFER_LONG_SIDE = 560;
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");
// Keep only helper functions + mainImage block, drop struct/template leftovers.
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 (often: shader too complex, loop/unroll limits, or driver bug). Try a simpler shader.";
}
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 (vertex/fragment interface mismatch or resource limits).";
}
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 = 256) {
// iChannel0: audio-like lookup texture (matches many shadertoy audio assumptions)
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));
// row0: fft, row1: waveform
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;
}
// iChannel1/2/3: tiling noise/grain-like textures (smaller in grid previews).
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 computeDetailBufferSize(cssW, cssH) {
const dpr = Math.min(window.devicePixelRatio || 1, DETAIL_MAX_PIXEL_RATIO);
let w = Math.max(1, Math.floor(cssW * dpr));
let h = Math.max(1, Math.floor(cssH * dpr));
const long = Math.max(w, h);
if (long > DETAIL_MAX_BUFFER_LONG_SIDE) {
const s = DETAIL_MAX_BUFFER_LONG_SIDE / long;
w = Math.max(1, Math.floor(w * s));
h = Math.max(1, Math.floor(h * s));
}
return { w, h };
}
function computeHoverBufferSize(cssW, cssH) {
const dpr = Math.min(window.devicePixelRatio || 1, HOVER_MAX_PIXEL_RATIO);
let w = Math.max(1, Math.floor(cssW * dpr));
let h = Math.max(1, Math.floor(cssH * dpr));
const long = Math.max(w, h);
if (long > HOVER_MAX_BUFFER_LONG_SIDE) {
const s = HOVER_MAX_BUFFER_LONG_SIDE / long;
w = Math.max(1, Math.floor(w * s));
h = Math.max(1, Math.floor(h * s));
}
return { w, h };
}
const previews = [];
/** Driven by scheduleRender(); detail + grid hover. */
let renderRafId = 0;
function scheduleRender() {
if (renderRafId) return;
renderRafId = requestAnimationFrame((ts) => {
renderRafId = 0;
renderAll(ts);
});
}
function shouldScheduleNextFrame() {
if (detailRuntime) return true;
if (gridHoverRuntime) return true;
return !paused;
}
let paused = false;
let elapsed = 0;
let frame = 0;
let lastTs = performance.now();
let fpsCounter = 0;
let fps = 0;
let lastSec = 0;
let detailFpsCounter = 0;
let detailFps = 0;
let detailLastSec = 0;
let detailRuntime = null;
let searchKeyword = "";
let lastSignature = "";
function getSharedHoverCanvas() {
if (!sharedHoverCanvas) {
sharedHoverCanvas = document.createElement("canvas");
sharedHoverCanvas.className = "card-hover-canvas";
}
return sharedHoverCanvas;
}
function tearDownGridHover() {
if (!gridHoverRuntime) {
if (sharedHoverCanvas && sharedHoverCanvas.parentNode) {
const stage = sharedHoverCanvas.parentNode;
stage.querySelectorAll(":scope > *").forEach((el) => {
el.style.visibility = "";
});
sharedHoverCanvas.remove();
sharedHoverCanvas.style.display = "none";
}
return;
}
const { gl, program, vao, vbo, channelTextures, _hoverResizeObserver } = gridHoverRuntime;
if (_hoverResizeObserver) _hoverResizeObserver.disconnect();
try {
if (program) gl.deleteProgram(program);
if (vao) gl.deleteVertexArray(vao);
if (vbo) gl.deleteBuffer(vbo);
if (channelTextures) channelTextures.forEach((t) => gl.deleteTexture(t));
} catch (_) {
/* lost */
}
gridHoverRuntime = null;
if (sharedHoverCanvas && sharedHoverCanvas.parentNode) {
const stage = sharedHoverCanvas.parentNode;
stage.querySelectorAll(":scope > *").forEach((el) => {
el.style.visibility = "";
});
sharedHoverCanvas.remove();
}
if (sharedHoverCanvas) sharedHoverCanvas.style.display = "none";
}
function startGridHover(previewState, card) {
if (detailViewEl.classList.contains("active")) return;
tearDownGridHover();
const canvas = getSharedHoverCanvas();
const stage = card.querySelector(".card-stage");
if (!stage) return;
const gl = canvas.getContext("webgl2", GL_CONTEXT_OPTS);
if (!gl) return;
let program;
let codeForRender = previewState.code;
try {
program = compileProgram(gl, buildFragmentShader(codeForRender));
} catch (_e1) {
try {
codeForRender = toPortableGlsl(previewState.code);
program = compileProgram(gl, buildFragmentShader(codeForRender));
} catch (_e2) {
codeForRender = fallbackShader(previewState.name);
try {
program = compileProgram(gl, buildFragmentShader(codeForRender));
} catch {
return;
}
}
}
const { vao, vbo } = 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"),
};
stage.appendChild(canvas);
canvas.style.display = "block";
stage.querySelectorAll(":scope > *").forEach((el) => {
if (el !== canvas) el.style.visibility = "hidden";
});
const ro = new ResizeObserver(() => {
if (!gridHoverRuntime) return;
const r = canvas.getBoundingClientRect();
gridHoverRuntime._layoutW = r.width;
gridHoverRuntime._layoutH = r.height;
});
ro.observe(canvas);
const r0 = canvas.getBoundingClientRect();
gridHoverRuntime = {
gl,
program,
vao,
vbo,
channelTextures,
channelUniforms,
uniforms,
canvas,
state: previewState,
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
localTime: 0.05,
localFrame: 1,
lastTick: performance.now(),
_layoutW: r0.width,
_layoutH: r0.height,
_hoverResizeObserver: ro,
};
scheduleRender();
}
function createPreviewCard(shader) {
const { id, name, author = "unknown", views = 0, likes = 0, code, thumbnailUrl } = shader;
const card = document.createElement("article");
card.className = "card";
card.dataset.search = `${name} ${author}`.toLowerCase();
const visualInner = thumbnailUrl
? `<img class="card-thumb" src="${thumbnailUrl}" alt="" loading="lazy" decoding="async" />`
: `<div class="card-thumb card-thumb--placeholder">暂无缩略图<br /><small>正在尝试自动生成…</small></div>`;
card.dataset.shaderId = id;
card.innerHTML = `
<div class="card-head">
<strong>${name}</strong>
</div>
<div class="card-stage">${visualInner}</div>
<div class="card-meta">
<span>by ${author}</span>
<span>views ${views} | likes ${likes}</span>
</div>
`;
const state = {
id,
name,
code,
card,
};
card.addEventListener("click", () => openDetail(state));
card.addEventListener("mouseenter", () => {
if (detailViewEl.classList.contains("active")) return;
startGridHover(state, card);
});
card.addEventListener("mouseleave", () => {
if (gridHoverRuntime && gridHoverRuntime.state.id === state.id) tearDownGridHover();
});
card.addEventListener("mousemove", (event) => {
if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
const c = gridHoverRuntime.canvas;
const rect = c.getBoundingClientRect();
const bw = c.width || 1;
const bh = c.height || 1;
gridHoverRuntime.mouse.x = ((event.clientX - rect.left) / rect.width) * bw;
gridHoverRuntime.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh;
});
card.addEventListener("mousedown", () => {
if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
gridHoverRuntime.mouse.down = true;
gridHoverRuntime.mouse.downX = gridHoverRuntime.mouse.x;
gridHoverRuntime.mouse.downY = gridHoverRuntime.mouse.y;
});
gridEl.appendChild(card);
previews.push(state);
}
function clearPreviews() {
tearDownGridHover();
previews.forEach((preview) => {
preview.card.remove();
});
previews.length = 0;
}
function closeDetail() {
detailViewEl.classList.remove("active");
detailViewEl.setAttribute("aria-hidden", "true");
if (!detailRuntime) return;
const { gl, program, vao, vbo, channelTextures = [], _detailResizeObserver } = detailRuntime;
if (_detailResizeObserver) _detailResizeObserver.disconnect();
gl.deleteProgram(program);
gl.deleteVertexArray(vao);
gl.deleteBuffer(vbo);
channelTextures.forEach((tex) => gl.deleteTexture(tex));
detailRuntime = null;
}
function openDetail(previewState) {
tearDownGridHover();
closeDetail();
const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS);
if (!gl) return;
let program;
let detailCode = previewState.code;
try {
program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (_e1) {
try {
detailCode = toPortableGlsl(previewState.code);
program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (_e2) {
detailCode = fallbackShader(previewState.name);
try {
program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (err) {
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
return;
}
}
}
const { vao, vbo } = 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"),
];
detailRuntime = {
gl,
program,
vao,
vbo,
channelTextures,
channelUniforms,
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"),
},
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
_layoutW: 0,
_layoutH: 0,
};
const dro = new ResizeObserver((entries) => {
for (const e of entries) {
if (e.target !== detailCanvas) continue;
const cr = e.contentRect;
detailRuntime._layoutW = cr.width;
detailRuntime._layoutH = cr.height;
}
});
dro.observe(detailCanvas);
detailRuntime._detailResizeObserver = dro;
detailTitleEl.textContent = previewState.name;
detailViewEl.classList.add("active");
detailViewEl.setAttribute("aria-hidden", "false");
scheduleRender();
}
function applySearch() {
const keyword = searchKeyword.trim().toLowerCase();
for (const preview of previews) {
preview.card.style.display = !keyword || preview.card.dataset.search.includes(keyword) ? "" : "none";
}
scheduleRender();
}
async function loadShaders() {
try {
const res = await fetch(`${API_BASE}?t=${Date.now()}`, { cache: "no-store" });
if (!res.ok) throw new Error("加载失败");
const shaders = await res.json();
const signature = JSON.stringify(
shaders.map((s) => ({
id: s.id,
updatedAt: s.updatedAt || s.createdAt || "",
thumbnailAt: s.thumbnailAt || "",
name: s.name,
}))
);
if (signature !== lastSignature) {
lastSignature = signature;
clearPreviews();
shaders.forEach(createPreviewCard);
applySearch();
syncStats();
}
scheduleThumbBackfill(shaders);
} catch (err) {
statsEl.textContent = `读取后端失败:${String(err.message || err)}`;
}
}
function syncStats() {
statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${
paused ? "已暂停" : "运行中"
}`;
shaderCountEl.textContent = `${previews.length} shaders`;
}
function renderAll(ts) {
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (!paused) {
elapsed += dt;
frame += 1;
}
if (Math.floor(elapsed) !== lastSec) {
lastSec = Math.floor(elapsed);
fps = fpsCounter;
fpsCounter = 0;
globalFpsEl.textContent = `FPS: ${fps}`;
} else {
fpsCounter += 1;
}
if (gridHoverRuntime) {
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms, canvas } =
gridHoverRuntime;
if (gl.isContextLost()) {
tearDownGridHover();
} else {
const now = performance.now();
const localDt = Math.min((now - gridHoverRuntime.lastTick) / 1000, 0.05);
gridHoverRuntime.lastTick = now;
if (!paused) {
gridHoverRuntime.localTime += localDt;
gridHoverRuntime.localFrame += 1;
}
const lw =
gridHoverRuntime._layoutW > 0 ? gridHoverRuntime._layoutW : canvas.clientWidth || 1;
const lh =
gridHoverRuntime._layoutH > 0 ? gridHoverRuntime._layoutH : canvas.clientHeight || 1;
const { w, h } = computeHoverBufferSize(lw, lh);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
gl.useProgram(program);
bindChannelTextures(gl, channelUniforms, channelTextures);
gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1);
gl.uniform1f(uniforms.iTime, gridHoverRuntime.localTime);
gl.uniform1f(uniforms.iTimeDelta, paused ? 0 : localDt);
gl.uniform1i(uniforms.iFrame, gridHoverRuntime.localFrame);
setIDateUniform(gl, uniforms.iDate);
gl.uniform4f(
uniforms.iMouse,
mouse.x,
mouse.y,
mouse.down ? mouse.downX : -mouse.downX,
mouse.down ? mouse.downY : -mouse.downY
);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
}
}
if (detailRuntime) {
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms } = detailRuntime;
if (gl.isContextLost()) {
closeDetail();
scheduleRender();
return;
}
const lw =
detailRuntime._layoutW > 0 ? detailRuntime._layoutW : detailCanvas.clientWidth || 1;
const lh =
detailRuntime._layoutH > 0 ? detailRuntime._layoutH : detailCanvas.clientHeight || 1;
const { w, h } = computeDetailBufferSize(lw, lh);
if (detailCanvas.width !== w || detailCanvas.height !== h) {
detailCanvas.width = w;
detailCanvas.height = h;
gl.viewport(0, 0, w, h);
}
gl.useProgram(program);
bindChannelTextures(gl, channelUniforms, channelTextures);
gl.uniform3f(uniforms.iResolution, detailCanvas.width, detailCanvas.height, 1);
gl.uniform1f(uniforms.iTime, elapsed);
gl.uniform1f(uniforms.iTimeDelta, dt);
gl.uniform1i(uniforms.iFrame, frame);
setIDateUniform(gl, uniforms.iDate);
gl.uniform4f(
uniforms.iMouse,
mouse.x,
mouse.y,
mouse.down ? mouse.downX : -mouse.downX,
mouse.down ? mouse.downY : -mouse.downY
);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
if (Math.floor(elapsed) !== detailLastSec) {
detailLastSec = Math.floor(elapsed);
detailFps = detailFpsCounter;
detailFpsCounter = 0;
detailFpsEl.textContent = `FPS: ${detailFps}`;
} else {
detailFpsCounter += 1;
}
}
if (shouldScheduleNextFrame()) scheduleRender();
}
pauseBtn.addEventListener("click", () => {
paused = !paused;
pauseBtn.textContent = paused ? "全局继续" : "全局暂停";
syncStats();
scheduleRender();
});
resetBtn.addEventListener("click", () => {
elapsed = 0;
frame = 0;
fpsCounter = 0;
lastSec = 0;
detailLastSec = 0;
detailFpsCounter = 0;
syncStats();
scheduleRender();
});
backBtn.addEventListener("click", () => {
closeDetail();
scheduleRender();
});
detailCanvas.addEventListener("mousemove", (event) => {
if (!detailRuntime) return;
const rect = detailCanvas.getBoundingClientRect();
detailRuntime.mouse.x = ((event.clientX - rect.left) / rect.width) * detailCanvas.width;
detailRuntime.mouse.y = ((rect.bottom - event.clientY) / rect.height) * detailCanvas.height;
});
detailCanvas.addEventListener("mousedown", () => {
if (!detailRuntime) return;
detailRuntime.mouse.down = true;
detailRuntime.mouse.downX = detailRuntime.mouse.x;
detailRuntime.mouse.downY = detailRuntime.mouse.y;
});
window.addEventListener("mouseup", () => {
if (detailRuntime) detailRuntime.mouse.down = false;
if (gridHoverRuntime) gridHoverRuntime.mouse.down = false;
});
searchInput.addEventListener("input", (event) => {
searchKeyword = event.target.value || "";
applySearch();
});
loadShaders();
setInterval(loadShaders, 3000);
scheduleRender();