fix:修复bug
This commit is contained in:
@@ -11,6 +11,9 @@ npm run dev
|
||||
|
||||
打开 `http://localhost:5180`。
|
||||
|
||||
- 展示页:`http://localhost:5180/index.html`
|
||||
- 管理页:`http://localhost:5180/admin.html`
|
||||
|
||||
## Docker 一键启动(国内镜像)
|
||||
|
||||
```bash
|
||||
@@ -20,13 +23,14 @@ npm run dev
|
||||
说明:
|
||||
- Node 基础镜像走 `docker.m.daocloud.io`
|
||||
- npm 安装源走 `https://registry.npmmirror.com`
|
||||
- 服务地址:`http://localhost:5180`
|
||||
- 默认使用 `5180`,若被占用会自动递增到可用端口(如 `5181`)
|
||||
- 启动完成后终端会打印最终访问地址
|
||||
|
||||
## 页面能力
|
||||
|
||||
- 预览墙:多个 VFX 同屏实时渲染,支持点进详情
|
||||
- 每个小窗独立播放/暂停
|
||||
- 后端管理:点击“管理内容”,可直接粘贴 GLSL 并保存到后端
|
||||
- 管理页独立:在 `admin.html` 粘贴 GLSL 并保存到后端
|
||||
- 保存后实时刷新前端展示,支持删除
|
||||
|
||||
## 兼容的 uniform
|
||||
|
||||
83
admin.html
Normal file
83
admin.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shader 管理后台</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0b0f18;
|
||||
color: #e8ecff;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(126, 165, 255, 0.35);
|
||||
background: #111726;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
input, textarea, button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
textarea {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 240px;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.list { margin-top: 12px; }
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 10px 4px;
|
||||
}
|
||||
.item:first-child { border-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<h2 style="margin:0;">Shader 管理后台</h2>
|
||||
<a href="./index.html" style="color:#a9c5ff;">返回展示页</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="grid">
|
||||
<input id="name-input" placeholder="名称" />
|
||||
<input id="author-input" placeholder="作者(可选)" />
|
||||
<textarea id="code-input" placeholder="粘贴 GLSL,必须包含 mainImage"></textarea>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:10px;">
|
||||
<button id="save-btn">保存</button>
|
||||
<button id="example-btn">填入示例</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card list" id="list"></div>
|
||||
</div>
|
||||
<script src="./admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
96
admin.js
Normal file
96
admin.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const API_BASE = "/api/shaders";
|
||||
const nameInput = document.getElementById("name-input");
|
||||
const authorInput = document.getElementById("author-input");
|
||||
const codeInput = document.getElementById("code-input");
|
||||
const saveBtn = document.getElementById("save-btn");
|
||||
const exampleBtn = document.getElementById("example-btn");
|
||||
const listEl = document.getElementById("list");
|
||||
|
||||
const EXAMPLE = `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 * 9.0 + t * 2.0) + cos(uv.y * 8.0 - t * 1.4);
|
||||
vec3 col = 0.5 + 0.5 * cos(vec3(0.2, 1.2, 2.2) + v + t);
|
||||
fragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
|
||||
function validateClientCode(code) {
|
||||
if (!code.includes("mainImage")) return "代码需包含 mainImage。";
|
||||
return "";
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
const res = await fetch(API_BASE);
|
||||
if (!res.ok) throw new Error("加载失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
listEl.innerHTML = "";
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = "<div>暂无数据</div>";
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "item";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<strong>${item.name}</strong>
|
||||
<div style="font-size:12px;color:#9fb2df;">${item.author || "unknown"} · ${item.id}</div>
|
||||
</div>
|
||||
<button>删除</button>
|
||||
`;
|
||||
row.querySelector("button").addEventListener("click", async () => {
|
||||
await fetch(`${API_BASE}/${encodeURIComponent(item.id)}`, { method: "DELETE" });
|
||||
await reload();
|
||||
});
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const items = await fetchList();
|
||||
renderList(items);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const name = nameInput.value.trim();
|
||||
const author = authorInput.value.trim();
|
||||
const code = codeInput.value.trim();
|
||||
if (!name) {
|
||||
alert("名称必填");
|
||||
return;
|
||||
}
|
||||
const err = validateClientCode(code);
|
||||
if (err) {
|
||||
alert(err);
|
||||
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(() => ({}));
|
||||
alert(payload.error || "保存失败");
|
||||
return;
|
||||
}
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (payload.sourceFormat === "angle-metal-auto-converted") {
|
||||
alert("已自动解构并转换为 GLSL,展示页可直接渲染。");
|
||||
}
|
||||
nameInput.value = "";
|
||||
authorInput.value = "";
|
||||
codeInput.value = "";
|
||||
await reload();
|
||||
});
|
||||
|
||||
exampleBtn.addEventListener("click", () => {
|
||||
nameInput.value = "New Shader";
|
||||
authorInput.value = "you";
|
||||
codeInput.value = EXAMPLE;
|
||||
});
|
||||
|
||||
reload();
|
||||
File diff suppressed because one or more lines are too long
@@ -10,15 +10,6 @@ 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";
|
||||
|
||||
@@ -27,13 +18,63 @@ 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) {
|
||||
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 * 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);
|
||||
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
|
||||
@@ -73,7 +114,6 @@ function compileProgram(gl, fragmentShaderSource) {
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
const vs = compile(gl.VERTEX_SHADER, vertexShaderSource);
|
||||
const fs = compile(gl.FRAGMENT_SHADER, fragmentShaderSource);
|
||||
const program = gl.createProgram();
|
||||
@@ -91,7 +131,6 @@ function compileProgram(gl, fragmentShaderSource) {
|
||||
}
|
||||
|
||||
const previews = [];
|
||||
const sourceShaders = [];
|
||||
let paused = false;
|
||||
let elapsed = 0;
|
||||
let frame = 0;
|
||||
@@ -104,6 +143,7 @@ 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;
|
||||
@@ -115,7 +155,6 @@ function createPreviewCard(shader) {
|
||||
<strong>${name}</strong>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button type="button" class="pause-local-btn">暂停</button>
|
||||
<button type="button" class="remove-btn">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas></canvas>
|
||||
@@ -129,7 +168,6 @@ function createPreviewCard(shader) {
|
||||
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";
|
||||
@@ -137,11 +175,24 @@ function createPreviewCard(shader) {
|
||||
}
|
||||
|
||||
let program;
|
||||
let codeForRender = code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(code));
|
||||
} catch (err) {
|
||||
errorEl.textContent = `编译失败:\n${String(err.message || err)}`;
|
||||
return;
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_err1) {
|
||||
try {
|
||||
codeForRender = toPortableGlsl(code);
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
errorEl.textContent = "已自动兼容转换后渲染";
|
||||
} catch (_err2) {
|
||||
codeForRender = fallbackShader(name);
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
errorEl.textContent = "原始代码不兼容,已使用兼容预览模式";
|
||||
} catch (err3) {
|
||||
errorEl.textContent = `编译失败:\n${String(err3.message || err3)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { vao } = createQuad(gl);
|
||||
@@ -157,6 +208,7 @@ function createPreviewCard(shader) {
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
codeForRender,
|
||||
card,
|
||||
canvas,
|
||||
gl,
|
||||
@@ -168,7 +220,6 @@ function createPreviewCard(shader) {
|
||||
localTime: 0,
|
||||
localFrame: 0,
|
||||
lastTick: performance.now(),
|
||||
removed: false,
|
||||
};
|
||||
|
||||
canvas.addEventListener("mousemove", (event) => {
|
||||
@@ -185,10 +236,6 @@ function createPreviewCard(shader) {
|
||||
state.mouse.down = false;
|
||||
});
|
||||
|
||||
removeBtn.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
deleteShader(id);
|
||||
});
|
||||
pauseLocalBtn.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
state.isPlaying = !state.isPlaying;
|
||||
@@ -206,13 +253,6 @@ function clearPreviews() {
|
||||
previews.length = 0;
|
||||
}
|
||||
|
||||
function renderCards(shaders) {
|
||||
clearPreviews();
|
||||
shaders.forEach(createPreviewCard);
|
||||
applySearch();
|
||||
syncStats();
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailViewEl.classList.remove("active");
|
||||
detailViewEl.setAttribute("aria-hidden", "true");
|
||||
@@ -228,15 +268,14 @@ function openDetail(previewState) {
|
||||
closeDetail();
|
||||
const gl = detailCanvas.getContext("webgl2", { antialias: false, alpha: false });
|
||||
if (!gl) return;
|
||||
|
||||
let program;
|
||||
const detailCode = previewState.codeForRender || previewState.code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(previewState.code));
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (err) {
|
||||
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const { vao, vbo } = createQuad(gl);
|
||||
detailRuntime = {
|
||||
gl,
|
||||
@@ -252,12 +291,45 @@ function openDetail(previewState) {
|
||||
},
|
||||
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 applySearch() {
|
||||
const keyword = searchKeyword.trim().toLowerCase();
|
||||
for (const preview of previews) {
|
||||
preview.card.style.display = !keyword || preview.card.dataset.search.includes(keyword) ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -265,7 +337,6 @@ function renderAll(ts) {
|
||||
elapsed += dt;
|
||||
frame += 1;
|
||||
}
|
||||
|
||||
if (Math.floor(elapsed) !== lastSec) {
|
||||
lastSec = Math.floor(elapsed);
|
||||
fps = fpsCounter;
|
||||
@@ -284,7 +355,6 @@ function renderAll(ts) {
|
||||
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));
|
||||
@@ -294,7 +364,6 @@ function renderAll(ts) {
|
||||
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);
|
||||
@@ -337,7 +406,6 @@ function renderAll(ts) {
|
||||
gl.bindVertexArray(vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
if (Math.floor(elapsed) !== detailLastSec) {
|
||||
detailLastSec = Math.floor(elapsed);
|
||||
detailFps = detailFpsCounter;
|
||||
@@ -351,98 +419,11 @@ function renderAll(ts) {
|
||||
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 = `<div class="row-item"><span>暂无 shader,点击“保存到后端”添加。</span></div>`;
|
||||
return;
|
||||
}
|
||||
sourceShaders.forEach((shader) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-item";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<strong>${shader.name}</strong>
|
||||
<div style="font-size:12px;color:#9ea6c8;">${shader.author || "unknown"} · ${shader.id}</div>
|
||||
</div>
|
||||
<button type="button">删除</button>
|
||||
`;
|
||||
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;
|
||||
@@ -452,7 +433,6 @@ resetBtn.addEventListener("click", () => {
|
||||
detailFpsCounter = 0;
|
||||
syncStats();
|
||||
});
|
||||
|
||||
backBtn.addEventListener("click", closeDetail);
|
||||
detailCanvas.addEventListener("mousemove", (event) => {
|
||||
if (!detailRuntime) return;
|
||||
@@ -470,31 +450,11 @@ 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();
|
||||
setInterval(loadShaders, 3000);
|
||||
requestAnimationFrame(renderAll);
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
image: vfxdemo:cn
|
||||
container_name: vfxdemo
|
||||
ports:
|
||||
- "5180:5180"
|
||||
- "${HOST_PORT:-5180}:5180"
|
||||
environment:
|
||||
- PORT=5180
|
||||
- NODE_ENV=production
|
||||
|
||||
24
index.html
24
index.html
@@ -280,7 +280,7 @@
|
||||
<div class="brand">Shadervault</div>
|
||||
<div class="header-right">
|
||||
<input id="search-input" class="search" placeholder="Search shaders..." />
|
||||
<button id="manage-btn">管理内容</button>
|
||||
<a href="./admin.html" style="text-decoration:none;"><button>管理后台</button></a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="app">
|
||||
@@ -312,26 +312,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="manage-modal" class="modal" aria-hidden="true">
|
||||
<div class="modal-card">
|
||||
<div class="topbar">
|
||||
<strong>Shader 内容管理</strong>
|
||||
<div style="display:flex; gap:8px">
|
||||
<button id="close-manage-btn">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-grid">
|
||||
<input id="shader-name-input" placeholder="名称" />
|
||||
<input id="shader-author-input" placeholder="作者(可选)" />
|
||||
<textarea id="shader-code-input" placeholder="粘贴 GLSL,必须包含 mainImage"></textarea>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:10px;">
|
||||
<button id="save-shader-btn">保存到后端</button>
|
||||
<button id="fill-example-btn">填入示例</button>
|
||||
</div>
|
||||
<div class="table" id="shader-admin-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
<script src="./frontend.js"></script>
|
||||
<script src="./display.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
93
server.js
93
server.js
@@ -30,6 +30,90 @@ async function writeDb(list) {
|
||||
await fs.writeFile(DB_PATH, JSON.stringify(list, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function extractFunctionSection(code) {
|
||||
const endMarker = code.indexOf("void ANGLE__0_main");
|
||||
const end = endMarker >= 0 ? endMarker : code.length;
|
||||
const firstFn = code.search(
|
||||
/(metal::float[234](x2)?|float|void)\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*\{/m
|
||||
);
|
||||
if (firstFn < 0 || firstFn >= end) return "";
|
||||
return code.slice(firstFn, end);
|
||||
}
|
||||
|
||||
function convertAngleMetalToGlsl(code) {
|
||||
let section = extractFunctionSection(code);
|
||||
if (!section || !section.includes("_umainImage")) return "";
|
||||
|
||||
section = section
|
||||
.replace(
|
||||
/void _umainImage\s*\(\s*constant ANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,\s*thread metal::float4\s*&\s*([A-Za-z0-9_]+)\s*,\s*metal::float2\s*([A-Za-z0-9_]+)\s*\)/g,
|
||||
"void _umainImage(out vec4 $1, vec2 $2)"
|
||||
)
|
||||
.replace(
|
||||
/float _umap\s*\(\s*constant ANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,\s*metal::float3\s*([A-Za-z0-9_]+)\s*\)/g,
|
||||
"float _umap(vec3 $1)"
|
||||
)
|
||||
.replace(
|
||||
/metal::float4 _urm\s*\(\s*constant ANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,\s*metal::float3\s*([A-Za-z0-9_]+)\s*,\s*metal::float3\s*([A-Za-z0-9_]+)\s*\)/g,
|
||||
"vec4 _urm(vec3 $1, vec3 $2)"
|
||||
)
|
||||
.replace(/_umap\s*\(\s*ANGLE_userUniforms\s*,/g, "_umap(")
|
||||
.replace(/_urm\s*\(\s*ANGLE_userUniforms\s*,/g, "_urm(")
|
||||
.replace(/ANGLE_userUniforms\._uiResolution/g, "iResolution")
|
||||
.replace(/ANGLE_userUniforms\._uiTime/g, "iTime")
|
||||
.replace(/metal::fast::normalize/g, "normalize")
|
||||
.replace(/metal::/g, "")
|
||||
.replace(/\bthread\b/g, "")
|
||||
.replace(/\bconstant\b/g, "")
|
||||
.replace(/\buint32_t\b/g, "uint")
|
||||
.replace(/;\s*;/g, ";");
|
||||
|
||||
const cleaned = [
|
||||
"void ANGLE_loopForwardProgress() {}",
|
||||
section,
|
||||
"void mainImage(out vec4 fragColor, in vec2 fragCoord) { _umainImage(fragColor, fragCoord); }",
|
||||
].join("\n");
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function normalizeIncomingCode(code) {
|
||||
if (!code || typeof code !== "string") return { error: "code 不能为空", normalized: "" };
|
||||
if (code.includes("metal::") || code.includes("[[function_constant")) {
|
||||
const converted = convertAngleMetalToGlsl(code);
|
||||
if (!converted) {
|
||||
return { error: "自动解构失败:请检查粘贴内容是否完整。", normalized: "" };
|
||||
}
|
||||
return { error: "", normalized: converted };
|
||||
}
|
||||
if (!code.includes("mainImage")) {
|
||||
return { error: "code 必须包含 mainImage", normalized: "" };
|
||||
}
|
||||
return { error: "", normalized: code };
|
||||
}
|
||||
|
||||
async function autoNormalizeStoredShaders() {
|
||||
const shaders = await readDb();
|
||||
let changed = false;
|
||||
const next = shaders.map((item) => {
|
||||
const code = String(item.code || "");
|
||||
if (code.includes("metal::") || code.includes("[[function_constant")) {
|
||||
const { error, normalized } = normalizeIncomingCode(code);
|
||||
if (!error && normalized) {
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
code: normalized,
|
||||
updatedAt: new Date().toISOString(),
|
||||
sourceFormat: "angle-metal-auto-converted",
|
||||
};
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (changed) await writeDb(next);
|
||||
}
|
||||
|
||||
app.get("/api/shaders", async (_req, res) => {
|
||||
try {
|
||||
const shaders = await readDb();
|
||||
@@ -44,8 +128,9 @@ app.post("/api/shaders", async (req, res) => {
|
||||
if (!name || typeof name !== "string") {
|
||||
return res.status(400).json({ error: "name 必填" });
|
||||
}
|
||||
if (!code || typeof code !== "string" || !code.includes("mainImage")) {
|
||||
return res.status(400).json({ error: "code 必须包含 mainImage" });
|
||||
const { error: parseError, normalized } = normalizeIncomingCode(code);
|
||||
if (parseError) {
|
||||
return res.status(400).json({ error: parseError });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -54,10 +139,11 @@ app.post("/api/shaders", async (req, res) => {
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
author: String(author || "unknown").trim() || "unknown",
|
||||
code,
|
||||
code: normalized,
|
||||
views: Math.floor(3000 + Math.random() * 22000),
|
||||
likes: Math.floor(40 + Math.random() * 700),
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceFormat: code.includes("metal::") ? "angle-metal-auto-converted" : "glsl",
|
||||
};
|
||||
shaders.unshift(item);
|
||||
await writeDb(shaders);
|
||||
@@ -82,6 +168,7 @@ app.delete("/api/shaders/:id", async (req, res) => {
|
||||
});
|
||||
|
||||
ensureDb().then(() => {
|
||||
autoNormalizeStoredShaders().catch(() => {});
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running: http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
@@ -26,14 +26,26 @@ fi
|
||||
export DOCKER_BUILDKIT=1
|
||||
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
find_free_port() {
|
||||
local port="$1"
|
||||
while lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; do
|
||||
port=$((port + 1))
|
||||
done
|
||||
echo "${port}"
|
||||
}
|
||||
|
||||
HOST_PORT="$(find_free_port 5180)"
|
||||
export HOST_PORT
|
||||
|
||||
echo "==> 使用国内镜像源构建并启动 VFXdemo"
|
||||
echo " - Node 基础镜像: docker.m.daocloud.io"
|
||||
echo " - npm registry: registry.npmmirror.com"
|
||||
echo " - Host 端口: ${HOST_PORT}"
|
||||
|
||||
"${COMPOSE_CMD[@]}" build --pull
|
||||
"${COMPOSE_CMD[@]}" up -d
|
||||
|
||||
echo
|
||||
echo "启动完成: http://localhost:5180"
|
||||
echo "启动完成: http://localhost:${HOST_PORT}"
|
||||
echo "查看日志: ${COMPOSE_CMD[*]} logs -f"
|
||||
echo "停止服务: ${COMPOSE_CMD[*]} down"
|
||||
|
||||
Reference in New Issue
Block a user