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 manageBtn = document.getElementById("manage-btn"); const manageModal = document.getElementById("manage-modal"); const closeManageBtn = document.getElementById("close-manage-btn"); const saveShaderBtn = document.getElementById("save-shader-btn"); const fillExampleBtn = document.getElementById("fill-example-btn"); const shaderNameInput = document.getElementById("shader-name-input"); const shaderAuthorInput = document.getElementById("shader-author-input"); const shaderCodeInput = document.getElementById("shader-code-input"); const shaderAdminList = document.getElementById("shader-admin-list"); const API_BASE = "/api/shaders"; const vertexShaderSource = `#version 300 es precision highp float; layout(location = 0) in vec2 aPosition; void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`; const EXAMPLE_SHADER = `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 * 10.0 + t * 2.1) + cos(uv.y * 9.0 - t * 1.4); vec3 col = 0.5 + 0.5 * cos(vec3(0.1, 1.1, 2.3) + 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; ${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; } const previews = []; const sourceShaders = []; 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 = ""; 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 removeBtn = card.querySelector(".remove-btn"); const gl = canvas.getContext("webgl2", { antialias: false, alpha: false }); if (!gl) { errorEl.textContent = "当前浏览器不支持 WebGL2"; return; } let program; try { program = compileProgram(gl, buildFragmentShader(code)); } catch (err) { errorEl.textContent = `编译失败:\n${String(err.message || err)}`; return; } const { vao } = createQuad(gl); 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"), }; const state = { id, name, code, card, canvas, gl, program, vao, uniforms, mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false }, isPlaying: true, localTime: 0, localFrame: 0, lastTick: performance.now(), removed: false, }; canvas.addEventListener("mousemove", (event) => { const rect = canvas.getBoundingClientRect(); state.mouse.x = ((event.clientX - rect.left) / rect.width) * canvas.width; state.mouse.y = ((rect.bottom - event.clientY) / rect.height) * canvas.height; }); 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; }); removeBtn.addEventListener("click", (event) => { event.stopPropagation(); deleteShader(id); }); pauseLocalBtn.addEventListener("click", (event) => { event.stopPropagation(); state.isPlaying = !state.isPlaying; state.lastTick = performance.now(); pauseLocalBtn.textContent = state.isPlaying ? "暂停" : "继续"; }); canvas.addEventListener("click", () => openDetail(state)); gridEl.appendChild(card); previews.push(state); } function clearPreviews() { previews.forEach((preview) => preview.card.remove()); previews.length = 0; } function renderCards(shaders) { clearPreviews(); shaders.forEach(createPreviewCard); applySearch(); syncStats(); } function closeDetail() { detailViewEl.classList.remove("active"); detailViewEl.setAttribute("aria-hidden", "true"); if (!detailRuntime) return; const { gl, program, vao, vbo } = detailRuntime; gl.deleteProgram(program); gl.deleteVertexArray(vao); gl.deleteBuffer(vbo); detailRuntime = null; } function openDetail(previewState) { closeDetail(); const gl = detailCanvas.getContext("webgl2", { antialias: false, alpha: false }); if (!gl) return; let program; try { program = compileProgram(gl, buildFragmentShader(previewState.code)); } catch (err) { statsEl.textContent = `详情编译失败: ${String(err.message || err)}`; return; } const { vao, vbo } = createQuad(gl); detailRuntime = { gl, program, vao, vbo, 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"), }, mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false }, }; detailTitleEl.textContent = previewState.name; detailViewEl.classList.add("active"); detailViewEl.setAttribute("aria-hidden", "false"); } 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; } for (const preview of previews) { const { gl, canvas, program, vao, uniforms, mouse } = preview; const now = performance.now(); const localDt = Math.min((now - preview.lastTick) / 1000, 0.05); preview.lastTick = now; if (preview.isPlaying && !paused) { preview.localTime += localDt; preview.localFrame += 1; } const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = Math.max(1, Math.floor(rect.width * dpr)); const h = Math.max(1, Math.floor(rect.height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); } gl.useProgram(program); gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1); gl.uniform1f(uniforms.iTime, preview.localTime); gl.uniform1f(uniforms.iTimeDelta, preview.isPlaying && !paused ? localDt : 0); gl.uniform1i(uniforms.iFrame, preview.localFrame); 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 } = detailRuntime; const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = Math.max(1, Math.floor(detailCanvas.clientWidth * dpr)); const h = Math.max(1, Math.floor(detailCanvas.clientHeight * dpr)); if (detailCanvas.width !== w || detailCanvas.height !== h) { detailCanvas.width = w; detailCanvas.height = h; gl.viewport(0, 0, w, h); } gl.useProgram(program); gl.uniform3f(uniforms.iResolution, detailCanvas.width, detailCanvas.height, 1); gl.uniform1f(uniforms.iTime, elapsed); gl.uniform1f(uniforms.iTimeDelta, dt); gl.uniform1i(uniforms.iFrame, frame); 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; } } requestAnimationFrame(renderAll); } function syncStats() { statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${ paused ? "已暂停" : "运行中" }`; shaderCountEl.textContent = `${previews.length} shaders`; } function applySearch() { const keyword = searchKeyword.trim().toLowerCase(); for (const preview of previews) { preview.card.style.display = !keyword || preview.card.dataset.search.includes(keyword) ? "" : "none"; } } function renderAdminList() { shaderAdminList.innerHTML = ""; if (sourceShaders.length === 0) { shaderAdminList.innerHTML = `
暂无 shader,点击“保存到后端”添加。
`; return; } sourceShaders.forEach((shader) => { const row = document.createElement("div"); row.className = "row-item"; row.innerHTML = `
${shader.name}
${shader.author || "unknown"} · ${shader.id}
`; row.querySelector("button").addEventListener("click", () => deleteShader(shader.id)); shaderAdminList.appendChild(row); }); } async function fetchShaders() { const res = await fetch(API_BASE); if (!res.ok) throw new Error("加载失败"); return res.json(); } async function loadShaders() { try { const list = await fetchShaders(); sourceShaders.splice(0, sourceShaders.length, ...list); renderCards(sourceShaders); renderAdminList(); } catch (err) { statsEl.textContent = `读取后端失败:${String(err.message || err)}`; } } async function saveShader() { const name = shaderNameInput.value.trim(); const author = shaderAuthorInput.value.trim(); const code = shaderCodeInput.value.trim(); if (!name || !code.includes("mainImage")) { statsEl.textContent = "保存失败:名称必填,代码需包含 mainImage。"; return; } const res = await fetch(API_BASE, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, author, code }), }); if (!res.ok) { const payload = await res.json().catch(() => ({})); throw new Error(payload.error || "保存失败"); } shaderNameInput.value = ""; shaderAuthorInput.value = ""; shaderCodeInput.value = ""; await loadShaders(); } async function deleteShader(id) { const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!res.ok) { statsEl.textContent = "删除失败。"; return; } await loadShaders(); } pauseBtn.addEventListener("click", () => { paused = !paused; pauseBtn.textContent = paused ? "全局继续" : "全局暂停"; syncStats(); }); resetBtn.addEventListener("click", () => { elapsed = 0; frame = 0; fpsCounter = 0; lastSec = 0; detailLastSec = 0; detailFpsCounter = 0; syncStats(); }); backBtn.addEventListener("click", closeDetail); 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(); }); manageBtn.addEventListener("click", () => { manageModal.classList.add("active"); manageModal.setAttribute("aria-hidden", "false"); }); closeManageBtn.addEventListener("click", () => { manageModal.classList.remove("active"); manageModal.setAttribute("aria-hidden", "true"); }); fillExampleBtn.addEventListener("click", () => { shaderNameInput.value = "New Shader"; shaderAuthorInput.value = "you"; shaderCodeInput.value = EXAMPLE_SHADER; }); saveShaderBtn.addEventListener("click", async () => { try { await saveShader(); } catch (err) { statsEl.textContent = `保存失败:${String(err.message || err)}`; } }); loadShaders(); requestAnimationFrame(renderAll);