501 lines
15 KiB
JavaScript
501 lines
15 KiB
JavaScript
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);
|