diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..1486704
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+npm-debug.log
+.git
+.gitignore
+.cursor
+*.local
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..22e4ef2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM docker.m.daocloud.io/library/node:20-alpine
+
+WORKDIR /app
+
+ARG NPM_REGISTRY=https://registry.npmmirror.com
+ENV npm_config_registry=${NPM_REGISTRY}
+ENV NODE_ENV=production
+
+COPY package*.json ./
+RUN npm config set registry ${NPM_REGISTRY} && npm ci --omit=dev
+
+COPY . .
+
+EXPOSE 5180
+
+CMD ["npm", "run", "start"]
diff --git a/README.md b/README.md
index a747329..457e623 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,33 @@
# VFXdemo
-一个用于快速复刻 ShaderToy 效果的本地工具页(WebGL2 多预览面板)。
+一个用于快速复刻 ShaderToy 效果的本地工具页(WebGL2 多预览面板 + 后端内容管理)。
## 启动
```bash
-python3 -m http.server 5173
+npm install
+npm run dev
```
-打开 `http://localhost:5173`。
+打开 `http://localhost:5180`。
+
+## Docker 一键启动(国内镜像)
+
+```bash
+./start-docker-cn.sh
+```
+
+说明:
+- Node 基础镜像走 `docker.m.daocloud.io`
+- npm 安装源走 `https://registry.npmmirror.com`
+- 服务地址:`http://localhost:5180`
## 页面能力
-- 左侧工具区:粘贴 ShaderToy GLSL(包含 `mainImage`)并新增预览卡片
-- 右侧面板:多个 VFX 同屏实时渲染,适合“散布式特效预览”
-- 内置多个默认特效卡片,可直接当样例改造
-- 全局暂停/继续、全局重置时间
+- 预览墙:多个 VFX 同屏实时渲染,支持点进详情
+- 每个小窗独立播放/暂停
+- 后端管理:点击“管理内容”,可直接粘贴 GLSL 并保存到后端
+- 保存后实时刷新前端展示,支持删除
## 兼容的 uniform
@@ -25,8 +37,10 @@ python3 -m http.server 5173
- `iFrame`
- `iMouse`
-## 复刻工作流建议
+## 后端接口
-1. 在 ShaderToy 复制完整 GLSL(至少包含 `mainImage`)
-2. 粘贴到左侧输入框并点击“新增预览”
-3. 对比目标效果后持续微调参数
+- `GET /api/shaders` 获取列表
+- `POST /api/shaders` 新增 shader(`name`, `author`, `code`)
+- `DELETE /api/shaders/:id` 删除 shader
+
+数据默认持久化在 `data/shaders.json`。
diff --git a/app.js b/app.js
index 8d492f0..1436ab9 100644
--- a/app.js
+++ b/app.js
@@ -18,6 +18,57 @@ void main() {
}`;
const presetShaders = [
+ {
+ name: "Alien Core (Ported)",
+ code: `vec3 paletteAC(float d) {
+ return mix(vec3(0.2, 0.7, 0.9), vec3(1.0, 0.0, 1.0), d);
+}
+
+vec2 rotateAC(vec2 p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ return mat2(c, -s, s, c) * p;
+}
+
+float mapAC(vec3 p) {
+ for (int i = 0; i < 8; i++) {
+ float t = iTime * 0.2;
+ p.xz = rotateAC(p.xz, t);
+ p.xy = rotateAC(p.xy, t * 1.89);
+ p.xz = abs(p.xz) - 0.5;
+ }
+ return dot(sign(p), p) / 5.0;
+}
+
+vec4 raymarchAC(vec3 ro, vec3 rd) {
+ float t = 0.0;
+ vec3 col = vec3(0.0);
+ float d = 0.0;
+ for (int i = 0; i < 64; i++) {
+ vec3 p = ro + rd * t;
+ d = mapAC(p) * 0.5;
+ if (d < 0.02 || d > 100.0) break;
+ col += paletteAC(length(p) * 0.1) / (400.0 * d);
+ t += d;
+ }
+ float alpha = 1.0 / (max(d, 0.0001) * 100.0);
+ return vec4(col, alpha);
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.x;
+ vec3 ro = vec3(0.0, 0.0, -50.0);
+ ro.xz = rotateAC(ro.xz, iTime);
+
+ vec3 cf = normalize(-ro);
+ vec3 cs = normalize(cross(cf, vec3(0.0, 1.0, 0.0)));
+ vec3 cu = normalize(cross(cf, cs));
+ vec3 uuv = ro + cf * 3.0 + uv.x * cs + uv.y * cu;
+ vec3 rd = normalize(uuv - ro);
+
+ fragColor = raymarchAC(ro, rd);
+}`,
+ },
{
name: "Neon Flow",
code: `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
diff --git a/data/shaders.json b/data/shaders.json
new file mode 100644
index 0000000..69231c1
--- /dev/null
+++ b/data/shaders.json
@@ -0,0 +1,11 @@
+[
+ {
+ "id": "alien-core-ported",
+ "name": "Alien Core (Ported)",
+ "author": "glkt",
+ "views": 9754,
+ "likes": 256,
+ "createdAt": "2026-04-01T00:00:00.000Z",
+ "code": "vec3 paletteAC(float d) {\n return mix(vec3(0.2, 0.7, 0.9), vec3(1.0, 0.0, 1.0), d);\n}\n\nvec2 rotateAC(vec2 p, float a) {\n float c = cos(a);\n float s = sin(a);\n return mat2(c, -s, s, c) * p;\n}\n\nfloat mapAC(vec3 p) {\n for (int i = 0; i < 8; i++) {\n float t = iTime * 0.2;\n p.xz = rotateAC(p.xz, t);\n p.xy = rotateAC(p.xy, t * 1.89);\n p.xz = abs(p.xz) - 0.5;\n }\n return dot(sign(p), p) / 5.0;\n}\n\nvec4 raymarchAC(vec3 ro, vec3 rd) {\n float t = 0.0;\n vec3 col = vec3(0.0);\n float d = 0.0;\n for (int i = 0; i < 64; i++) {\n vec3 p = ro + rd * t;\n d = mapAC(p) * 0.5;\n if (d < 0.02 || d > 100.0) break;\n col += paletteAC(length(p) * 0.1) / (400.0 * d);\n t += d;\n }\n float alpha = 1.0 / (max(d, 0.0001) * 100.0);\n return vec4(col, alpha);\n}\n\nvoid mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.x;\n vec3 ro = vec3(0.0, 0.0, -50.0);\n ro.xz = rotateAC(ro.xz, iTime);\n\n vec3 cf = normalize(-ro);\n vec3 cs = normalize(cross(cf, vec3(0.0, 1.0, 0.0)));\n vec3 cu = normalize(cross(cf, cs));\n vec3 uuv = ro + cf * 3.0 + uv.x * cs + uv.y * cu;\n vec3 rd = normalize(uuv - ro);\n\n fragColor = raymarchAC(ro, rd);\n}"
+ }
+]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..642f586
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ vfxdemo:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ NPM_REGISTRY: https://registry.npmmirror.com
+ image: vfxdemo:cn
+ container_name: vfxdemo
+ ports:
+ - "5180:5180"
+ environment:
+ - PORT=5180
+ - NODE_ENV=production
+ volumes:
+ - ./data:/app/data
+ restart: unless-stopped
diff --git a/frontend.js b/frontend.js
new file mode 100644
index 0000000..f9cb649
--- /dev/null
+++ b/frontend.js
@@ -0,0 +1,500 @@
+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);
diff --git a/index.html b/index.html
index 586c69d..1860175 100644
--- a/index.html
+++ b/index.html
@@ -206,13 +206,81 @@
display: block;
background: #000;
}
+
+ .modal {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: none;
+ place-items: center;
+ background: rgba(4, 6, 10, 0.7);
+ backdrop-filter: blur(4px);
+ }
+
+ .modal.active {
+ display: grid;
+ }
+
+ .modal-card {
+ width: min(900px, 92vw);
+ max-height: 90vh;
+ overflow: auto;
+ border: 1px solid rgba(123, 178, 255, 0.5);
+ border-radius: 14px;
+ background: #0c0f1a;
+ padding: 14px;
+ }
+
+ .modal-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ .modal-grid input,
+ .modal-grid textarea {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.04);
+ color: #fff;
+ border-radius: 8px;
+ padding: 8px;
+ }
+
+ .modal-grid textarea {
+ min-height: 220px;
+ resize: vertical;
+ grid-column: 1 / -1;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace;
+ }
+
+ .table {
+ margin-top: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ overflow: hidden;
+ }
+
+ .row-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 10px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ }
+
+ .row-item:first-child {
+ border-top: 0;
+ }
@@ -243,6 +311,27 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+