Files
VFXdemo/frontend.js
Daniel afbcd99224 fix
2026-04-01 20:06:17 +08:00

501 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<div class="card-head">
<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>
<div class="card-meta">
<span>by ${author}</span>
<span>views ${views} | likes ${likes}</span>
</div>
<div class="card-head error"></div>
`;
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 = `<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;
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);