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 sampler2D iChannel0; uniform sampler2D iChannel1; uniform sampler2D iChannel2; uniform sampler2D iChannel3; ${userCode} void main() { mainImage(outColor, gl_FragCoord.xy); }`; } 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, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const info = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(info || "unknown compile error"); } return shader; } const vs = compile(gl.VERTEX_SHADER, vertexShaderSource); const fs = compile(gl.FRAGMENT_SHADER, 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)) { const info = gl.getProgramInfoLog(program); gl.deleteProgram(program); throw new Error(info || "unknown link error"); } 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"), }; 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 = `