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 = ``; } /** 全屏详情:限制像素量以便稳定 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 ? `` : `
暂无缩略图
正在尝试自动生成…
`; card.dataset.shaderId = id; card.innerHTML = `
${name}
${visualInner}
by ${author} views ${views} | likes ${likes}
`; 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();