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"; /** Grid previews: limit fill-rate so many cards can stay near 60fps (detail view uses full quality). */ const PREVIEW_MAX_PIXEL_RATIO = 1.25; const PREVIEW_MAX_BUFFER_LONG_SIDE = 520; const DETAIL_MAX_PIXEL_RATIO = 2; const DETAIL_MAX_BUFFER_LONG_SIDE = 2560; const GL_CONTEXT_OPTS = { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "high-performance", desynchronized: true, }; /** Browsers cap concurrent WebGL contexts (~8); keep grid below so detail view + pool stay safe. */ const MAX_GRID_WEBGL_CONTEXTS = 7; const WEBGL_TEARDOWN_DEBOUNCE_MS = 450; let gridGlTouchCounter = 0; 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 computePreviewBufferSize(cssW, cssH) { const dpr = Math.min(window.devicePixelRatio || 1, PREVIEW_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 > PREVIEW_MAX_BUFFER_LONG_SIDE) { const s = PREVIEW_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 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 countGridWebGLContexts() { let n = 0; for (const p of previews) { if (p.gl) n += 1; } return n; } function tearDownPreviewWebGL(state) { if (state._tearDownTimer) { clearTimeout(state._tearDownTimer); state._tearDownTimer = null; } state._staticRendered = false; state._staticDirty = true; if (!state.gl) return; const gl = state.gl; try { if (state.program) gl.deleteProgram(state.program); if (state.vao) gl.deleteVertexArray(state.vao); if (state.vbo) gl.deleteBuffer(state.vbo); if (state.channelTextures) state.channelTextures.forEach((t) => gl.deleteTexture(t)); } catch (_) { /* context may already be lost */ } const ext = gl.getExtension("WEBGL_lose_context"); if (ext) ext.loseContext(); state.gl = null; state.program = null; state.vao = null; state.vbo = null; state.channelTextures = null; state.channelUniforms = null; state.uniforms = null; } /** Only evict GPU for cards that are not visible / filtered out — avoids thrash when many cards are on-screen. */ function evictOffscreenGridWebGLContext() { const off = previews.filter((p) => p.gl && (!p.visible || p.card.style.display === "none")); if (!off.length) return false; off.sort((a, b) => (a._glTouchSeq || 0) - (b._glTouchSeq || 0)); tearDownPreviewWebGL(off[0]); return true; } function ensurePreviewWebGL(state) { if (state.gl || state.compileFailed) return; const errorEl = state.card.querySelector(".error"); while (countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) { if (!evictOffscreenGridWebGLContext()) break; } if (countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) { return; } const gl = state.canvas.getContext("webgl2", GL_CONTEXT_OPTS); if (!gl) { errorEl.textContent = "当前浏览器不支持 WebGL2"; state.compileFailed = true; return; } let program; let codeForRender = state.code; try { program = compileProgram(gl, buildFragmentShader(codeForRender)); errorEl.textContent = ""; } catch (_err1) { try { codeForRender = toPortableGlsl(state.code); program = compileProgram(gl, buildFragmentShader(codeForRender)); errorEl.textContent = ""; } catch (_err2) { codeForRender = fallbackShader(state.name); try { program = compileProgram(gl, buildFragmentShader(codeForRender)); errorEl.textContent = ""; } catch (err3) { errorEl.textContent = `编译失败:\n${String(err3.message || err3)}`; state.compileFailed = true; const ext = gl.getExtension("WEBGL_lose_context"); if (ext) ext.loseContext(); return; } } } state.codeForRender = codeForRender; 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"), }; state.gl = gl; state.program = program; state.vao = vao; state.vbo = vbo; state.channelTextures = channelTextures; state.channelUniforms = channelUniforms; state.uniforms = uniforms; state._glTouchSeq = ++gridGlTouchCounter; state._staticDirty = true; } const previews = []; /** Driven by scheduleRender(); only runs while detail / hover / pending static thumbs need a frame. */ let renderRafId = 0; function scheduleRender() { if (renderRafId) return; renderRafId = requestAnimationFrame((ts) => { renderRafId = 0; renderAll(ts); }); } function shouldKeepAnimatingGrid() { for (const p of previews) { if (p.card.style.display === "none") continue; if (!p.visible) continue; if (p.isPointerOver) return true; } return false; } function needsStaticThumbnailPass() { for (const p of previews) { if (p.card.style.display === "none") continue; if (!p.visible) continue; if (p.isPointerOver) continue; if (p.compileFailed) continue; if (p._staticRendered && !p._staticDirty) continue; if (!p.gl && countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) continue; return true; } return false; } function shouldScheduleNextFrame() { if (detailRuntime) return true; if (shouldKeepAnimatingGrid()) return true; if (needsStaticThumbnailPass()) return true; return false; } 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 createPreviewCard(shader) { const { id, name, author = "unknown", views = 0, likes = 0, code } = shader; const card = document.createElement("article"); card.className = "card"; card.dataset.search = `${name} ${author}`.toLowerCase(); card.innerHTML = `
${name}
by ${author} views ${views} | likes ${likes}
`; const canvas = card.querySelector("canvas"); const errorEl = card.querySelector(".error"); const pauseLocalBtn = card.querySelector(".pause-local-btn"); const state = { id, name, code, codeForRender: code, compileFailed: false, card, canvas, gl: null, program: null, vao: null, vbo: null, channelTextures: null, channelUniforms: null, uniforms: null, mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false }, isPlaying: true, localTime: 0, localFrame: 0, lastTick: performance.now(), visible: false, _layoutW: 0, _layoutH: 0, _glTouchSeq: 0, _tearDownTimer: null, isPointerOver: false, _staticRendered: false, _staticDirty: true, }; card.addEventListener("mouseenter", () => { state.isPointerOver = true; state.lastTick = performance.now(); scheduleRender(); }); card.addEventListener("mouseleave", () => { state.isPointerOver = false; state.localTime = 0; state.localFrame = 0; state._staticDirty = true; state._staticRendered = false; scheduleRender(); }); canvas.addEventListener("mousemove", (event) => { const rect = canvas.getBoundingClientRect(); const bw = canvas.width || Math.max(1, state._layoutW || rect.width); const bh = canvas.height || Math.max(1, state._layoutH || rect.height); state.mouse.x = ((event.clientX - rect.left) / rect.width) * bw; state.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh; }); canvas.addEventListener("mousedown", () => { state.mouse.down = true; state.mouse.downX = state.mouse.x; state.mouse.downY = state.mouse.y; }); window.addEventListener("mouseup", () => { state.mouse.down = false; }); pauseLocalBtn.addEventListener("click", (event) => { event.stopPropagation(); state.isPlaying = !state.isPlaying; state.lastTick = performance.now(); pauseLocalBtn.textContent = state.isPlaying ? "暂停" : "继续"; scheduleRender(); }); canvas.addEventListener("click", () => openDetail(state)); gridEl.appendChild(card); const ro = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.target !== canvas) continue; const cr = entry.contentRect; state._layoutW = cr.width; state._layoutH = cr.height; state._staticDirty = true; scheduleRender(); } }); ro.observe(canvas); state._resizeObserver = ro; const io = new IntersectionObserver( (entries) => { for (const e of entries) { if (e.target !== canvas) continue; state.visible = e.isIntersecting; if (e.isIntersecting) { if (state._tearDownTimer) { clearTimeout(state._tearDownTimer); state._tearDownTimer = null; } state._staticDirty = true; state._staticRendered = false; ensurePreviewWebGL(state); scheduleRender(); } else { if (state._tearDownTimer) clearTimeout(state._tearDownTimer); state._tearDownTimer = setTimeout(() => { state._tearDownTimer = null; if (!state.visible) tearDownPreviewWebGL(state); }, WEBGL_TEARDOWN_DEBOUNCE_MS); } } }, { root: null, rootMargin: "120px", threshold: 0.01 } ); io.observe(canvas); state._io = io; previews.push(state); } function clearPreviews() { previews.forEach((preview) => { tearDownPreviewWebGL(preview); if (preview._resizeObserver) preview._resizeObserver.disconnect(); if (preview._io) preview._io.disconnect(); 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) { closeDetail(); const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS); if (!gl) return; let program; const detailCode = previewState.codeForRender || previewState.code; try { program = compileProgram(gl, buildFragmentShader(detailCode)); } catch (err) { statsEl.textContent = `详情编译失败: ${String(err.message || err)}`; return; } const { vao, vbo } = createQuad(gl); const channelTextures = createDefaultChannelTextures(gl, 256); 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 || "", name: s.name })) ); if (signature !== lastSignature) { lastSignature = signature; clearPreviews(); shaders.forEach(createPreviewCard); applySearch(); syncStats(); } } 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; } const detailOpen = !!detailRuntime; for (const preview of previews) { let localDt = 0; if (!detailOpen) { const now = performance.now(); localDt = Math.min((now - preview.lastTick) / 1000, 0.05); preview.lastTick = now; if (preview.isPointerOver && preview.isPlaying && !paused) { preview.localTime += localDt; preview.localFrame += 1; } } if ( !detailOpen && preview.card.style.display !== "none" && preview.visible && !preview.gl && !preview.compileFailed ) { ensurePreviewWebGL(preview); } if (detailOpen) continue; if (preview.card.style.display === "none") continue; if (preview.visible === false) continue; if (!preview.gl) continue; const isAnim = preview.isPointerOver; if (!isAnim && preview._staticRendered && !preview._staticDirty) continue; const { gl, canvas, program, vao, uniforms, mouse, channelTextures, channelUniforms } = preview; if (!preview._layoutW || !preview._layoutH) { const r = canvas.getBoundingClientRect(); preview._layoutW = r.width; preview._layoutH = r.height; } const { w, h } = computePreviewBufferSize(preview._layoutW, preview._layoutH); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); if (!isAnim) preview._staticDirty = true; } gl.useProgram(program); bindChannelTextures(gl, channelUniforms, channelTextures); gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1); if (isAnim) { gl.uniform1f(uniforms.iTime, preview.localTime); gl.uniform1f(uniforms.iTimeDelta, preview.isPlaying && !paused ? localDt : 0); gl.uniform1i(uniforms.iFrame, preview.localFrame); } else { gl.uniform1f(uniforms.iTime, 0); gl.uniform1f(uniforms.iTimeDelta, 0); gl.uniform1i(uniforms.iFrame, 0); } 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); preview._glTouchSeq = ++gridGlTouchCounter; if (!isAnim) { preview._staticRendered = true; preview._staticDirty = false; } } if (detailRuntime) { const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms } = detailRuntime; 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) return; detailRuntime.mouse.down = false; }); searchInput.addEventListener("input", (event) => { searchKeyword = event.target.value || ""; applySearch(); }); loadShaders(); setInterval(loadShaders, 3000); scheduleRender();