841 lines
27 KiB
JavaScript
841 lines
27 KiB
JavaScript
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();
|