This commit is contained in:
Daniel
2026-04-02 10:00:57 +08:00
parent dbf5c942a6
commit 198d8534ad
5 changed files with 921 additions and 134 deletions

View File

@@ -13,6 +13,27 @@ const backBtn = document.getElementById("back-btn");
const API_BASE = "/api/shaders";
/** Grid previews: limit fill-rate so many cards can stay near 60fps (detail view uses full quality). */
const PREVIEW_MAX_PIXEL_RATIO = 1.25;
const PREVIEW_MAX_BUFFER_LONG_SIDE = 520;
const DETAIL_MAX_PIXEL_RATIO = 2;
const DETAIL_MAX_BUFFER_LONG_SIDE = 2560;
const GL_CONTEXT_OPTS = {
alpha: false,
antialias: false,
depth: false,
stencil: false,
powerPreference: "high-performance",
desynchronized: true,
};
/** Browsers cap concurrent WebGL contexts (~8); keep grid below so detail view + pool stay safe. */
const MAX_GRID_WEBGL_CONTEXTS = 7;
const WEBGL_TEARDOWN_DEBOUNCE_MS = 450;
let gridGlTouchCounter = 0;
const vertexShaderSource = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPosition;
@@ -85,6 +106,10 @@ uniform float iTime;
uniform float iTimeDelta;
uniform int iFrame;
uniform vec4 iMouse;
uniform sampler2D iChannel0;
uniform sampler2D iChannel1;
uniform sampler2D iChannel2;
uniform sampler2D iChannel3;
${userCode}
void main() { mainImage(outColor, gl_FragCoord.xy); }`;
}
@@ -130,7 +155,253 @@ function compileProgram(gl, fragmentShaderSource) {
return program;
}
function createTexture(gl, width, height, data) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.bindTexture(gl.TEXTURE_2D, null);
return tex;
}
function createDefaultChannelTextures(gl, noiseSize = 256) {
// iChannel0: audio-like lookup texture (matches many shadertoy audio assumptions)
const w0 = 512;
const h0 = 2;
const d0 = new Uint8Array(w0 * h0 * 4);
for (let x = 0; x < w0; x++) {
const t = x / (w0 - 1);
const fft = Math.floor(255 * Math.pow(1.0 - t, 1.8));
const wave = Math.floor(128 + 127 * Math.sin(t * Math.PI * 10.0));
// row0: fft, row1: waveform
const i0 = (x + 0 * w0) * 4;
const i1 = (x + 1 * w0) * 4;
d0[i0] = fft; d0[i0 + 1] = fft; d0[i0 + 2] = fft; d0[i0 + 3] = 255;
d0[i1] = wave; d0[i1 + 1] = wave; d0[i1 + 2] = wave; d0[i1 + 3] = 255;
}
// iChannel1/2/3: tiling noise/grain-like textures (smaller in grid previews).
const size = noiseSize;
const makeNoise = (scaleA, scaleB) => {
const d = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const n = Math.floor(
127 +
127 *
Math.sin((x * scaleA + y * scaleB) * 0.11) *
Math.cos((x * scaleB - y * scaleA) * 0.07)
);
d[i] = n;
d[i + 1] = (n * 37) & 255;
d[i + 2] = (n * 73) & 255;
d[i + 3] = 255;
}
}
return d;
};
return [
createTexture(gl, w0, h0, d0),
createTexture(gl, size, size, makeNoise(1.0, 0.7)),
createTexture(gl, size, size, makeNoise(0.8, 1.3)),
createTexture(gl, size, size, makeNoise(1.7, 0.4)),
];
}
function bindChannelTextures(gl, channelUniformLocs, channelTextures) {
for (let i = 0; i < 4; i++) {
if (channelUniformLocs[i] == null) continue;
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, channelTextures[i]);
gl.uniform1i(channelUniformLocs[i], i);
}
gl.activeTexture(gl.TEXTURE0);
}
function computePreviewBufferSize(cssW, cssH) {
const dpr = Math.min(window.devicePixelRatio || 1, PREVIEW_MAX_PIXEL_RATIO);
let w = Math.max(1, Math.floor(cssW * dpr));
let h = Math.max(1, Math.floor(cssH * dpr));
const long = Math.max(w, h);
if (long > PREVIEW_MAX_BUFFER_LONG_SIDE) {
const s = PREVIEW_MAX_BUFFER_LONG_SIDE / long;
w = Math.max(1, Math.floor(w * s));
h = Math.max(1, Math.floor(h * s));
}
return { w, h };
}
function computeDetailBufferSize(cssW, cssH) {
const dpr = Math.min(window.devicePixelRatio || 1, DETAIL_MAX_PIXEL_RATIO);
let w = Math.max(1, Math.floor(cssW * dpr));
let h = Math.max(1, Math.floor(cssH * dpr));
const long = Math.max(w, h);
if (long > DETAIL_MAX_BUFFER_LONG_SIDE) {
const s = DETAIL_MAX_BUFFER_LONG_SIDE / long;
w = Math.max(1, Math.floor(w * s));
h = Math.max(1, Math.floor(h * s));
}
return { w, h };
}
function countGridWebGLContexts() {
let n = 0;
for (const p of previews) {
if (p.gl) n += 1;
}
return n;
}
function tearDownPreviewWebGL(state) {
if (state._tearDownTimer) {
clearTimeout(state._tearDownTimer);
state._tearDownTimer = null;
}
state._staticRendered = false;
state._staticDirty = true;
if (!state.gl) return;
const gl = state.gl;
try {
if (state.program) gl.deleteProgram(state.program);
if (state.vao) gl.deleteVertexArray(state.vao);
if (state.vbo) gl.deleteBuffer(state.vbo);
if (state.channelTextures) state.channelTextures.forEach((t) => gl.deleteTexture(t));
} catch (_) {
/* context may already be lost */
}
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) ext.loseContext();
state.gl = null;
state.program = null;
state.vao = null;
state.vbo = null;
state.channelTextures = null;
state.channelUniforms = null;
state.uniforms = null;
}
/** Only evict GPU for cards that are not visible / filtered out — avoids thrash when many cards are on-screen. */
function evictOffscreenGridWebGLContext() {
const off = previews.filter((p) => p.gl && (!p.visible || p.card.style.display === "none"));
if (!off.length) return false;
off.sort((a, b) => (a._glTouchSeq || 0) - (b._glTouchSeq || 0));
tearDownPreviewWebGL(off[0]);
return true;
}
function ensurePreviewWebGL(state) {
if (state.gl || state.compileFailed) return;
const errorEl = state.card.querySelector(".error");
while (countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) {
if (!evictOffscreenGridWebGLContext()) break;
}
if (countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) {
return;
}
const gl = state.canvas.getContext("webgl2", GL_CONTEXT_OPTS);
if (!gl) {
errorEl.textContent = "当前浏览器不支持 WebGL2";
state.compileFailed = true;
return;
}
let program;
let codeForRender = state.code;
try {
program = compileProgram(gl, buildFragmentShader(codeForRender));
errorEl.textContent = "";
} catch (_err1) {
try {
codeForRender = toPortableGlsl(state.code);
program = compileProgram(gl, buildFragmentShader(codeForRender));
errorEl.textContent = "";
} catch (_err2) {
codeForRender = fallbackShader(state.name);
try {
program = compileProgram(gl, buildFragmentShader(codeForRender));
errorEl.textContent = "";
} catch (err3) {
errorEl.textContent = `编译失败:\n${String(err3.message || err3)}`;
state.compileFailed = true;
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) ext.loseContext();
return;
}
}
}
state.codeForRender = codeForRender;
const { vao, vbo } = createQuad(gl);
const channelTextures = createDefaultChannelTextures(gl, 128);
const channelUniforms = [
gl.getUniformLocation(program, "iChannel0"),
gl.getUniformLocation(program, "iChannel1"),
gl.getUniformLocation(program, "iChannel2"),
gl.getUniformLocation(program, "iChannel3"),
];
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"),
};
state.gl = gl;
state.program = program;
state.vao = vao;
state.vbo = vbo;
state.channelTextures = channelTextures;
state.channelUniforms = channelUniforms;
state.uniforms = uniforms;
state._glTouchSeq = ++gridGlTouchCounter;
state._staticDirty = true;
}
const previews = [];
/** Driven by scheduleRender(); only runs while detail / hover / pending static thumbs need a frame. */
let renderRafId = 0;
function scheduleRender() {
if (renderRafId) return;
renderRafId = requestAnimationFrame((ts) => {
renderRafId = 0;
renderAll(ts);
});
}
function shouldKeepAnimatingGrid() {
for (const p of previews) {
if (p.card.style.display === "none") continue;
if (!p.visible) continue;
if (p.isPointerOver) return true;
}
return false;
}
function needsStaticThumbnailPass() {
for (const p of previews) {
if (p.card.style.display === "none") continue;
if (!p.visible) continue;
if (p.isPointerOver) continue;
if (p.compileFailed) continue;
if (p._staticRendered && !p._staticDirty) continue;
if (!p.gl && countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) continue;
return true;
}
return false;
}
function shouldScheduleNextFrame() {
if (detailRuntime) return true;
if (shouldKeepAnimatingGrid()) return true;
if (needsStaticThumbnailPass()) return true;
return false;
}
let paused = false;
let elapsed = 0;
let frame = 0;
@@ -168,64 +439,57 @@ function createPreviewCard(shader) {
const canvas = card.querySelector("canvas");
const errorEl = card.querySelector(".error");
const pauseLocalBtn = card.querySelector(".pause-local-btn");
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
if (!gl) {
errorEl.textContent = "当前浏览器不支持 WebGL2";
return;
}
let program;
let codeForRender = code;
try {
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);
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,
codeForRender,
codeForRender: code,
compileFailed: false,
card,
canvas,
gl,
program,
vao,
uniforms,
gl: null,
program: null,
vao: null,
vbo: null,
channelTextures: null,
channelUniforms: null,
uniforms: null,
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
isPlaying: true,
localTime: 0,
localFrame: 0,
lastTick: performance.now(),
visible: false,
_layoutW: 0,
_layoutH: 0,
_glTouchSeq: 0,
_tearDownTimer: null,
isPointerOver: false,
_staticRendered: false,
_staticDirty: true,
};
card.addEventListener("mouseenter", () => {
state.isPointerOver = true;
state.lastTick = performance.now();
scheduleRender();
});
card.addEventListener("mouseleave", () => {
state.isPointerOver = false;
state.localTime = 0;
state.localFrame = 0;
state._staticDirty = true;
state._staticRendered = false;
scheduleRender();
});
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;
const bw = canvas.width || Math.max(1, state._layoutW || rect.width);
const bh = canvas.height || Math.max(1, state._layoutH || rect.height);
state.mouse.x = ((event.clientX - rect.left) / rect.width) * bw;
state.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh;
});
canvas.addEventListener("mousedown", () => {
state.mouse.down = true;
@@ -241,15 +505,63 @@ function createPreviewCard(shader) {
state.isPlaying = !state.isPlaying;
state.lastTick = performance.now();
pauseLocalBtn.textContent = state.isPlaying ? "暂停" : "继续";
scheduleRender();
});
canvas.addEventListener("click", () => openDetail(state));
gridEl.appendChild(card);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target !== canvas) continue;
const cr = entry.contentRect;
state._layoutW = cr.width;
state._layoutH = cr.height;
state._staticDirty = true;
scheduleRender();
}
});
ro.observe(canvas);
state._resizeObserver = ro;
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.target !== canvas) continue;
state.visible = e.isIntersecting;
if (e.isIntersecting) {
if (state._tearDownTimer) {
clearTimeout(state._tearDownTimer);
state._tearDownTimer = null;
}
state._staticDirty = true;
state._staticRendered = false;
ensurePreviewWebGL(state);
scheduleRender();
} else {
if (state._tearDownTimer) clearTimeout(state._tearDownTimer);
state._tearDownTimer = setTimeout(() => {
state._tearDownTimer = null;
if (!state.visible) tearDownPreviewWebGL(state);
}, WEBGL_TEARDOWN_DEBOUNCE_MS);
}
}
},
{ root: null, rootMargin: "120px", threshold: 0.01 }
);
io.observe(canvas);
state._io = io;
previews.push(state);
}
function clearPreviews() {
previews.forEach((preview) => preview.card.remove());
previews.forEach((preview) => {
tearDownPreviewWebGL(preview);
if (preview._resizeObserver) preview._resizeObserver.disconnect();
if (preview._io) preview._io.disconnect();
preview.card.remove();
});
previews.length = 0;
}
@@ -257,16 +569,18 @@ function closeDetail() {
detailViewEl.classList.remove("active");
detailViewEl.setAttribute("aria-hidden", "true");
if (!detailRuntime) return;
const { gl, program, vao, vbo } = detailRuntime;
const { gl, program, vao, vbo, channelTextures = [], _detailResizeObserver } = detailRuntime;
if (_detailResizeObserver) _detailResizeObserver.disconnect();
gl.deleteProgram(program);
gl.deleteVertexArray(vao);
gl.deleteBuffer(vbo);
channelTextures.forEach((tex) => gl.deleteTexture(tex));
detailRuntime = null;
}
function openDetail(previewState) {
closeDetail();
const gl = detailCanvas.getContext("webgl2", { antialias: false, alpha: false });
const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS);
if (!gl) return;
let program;
const detailCode = previewState.codeForRender || previewState.code;
@@ -277,11 +591,20 @@ function openDetail(previewState) {
return;
}
const { vao, vbo } = createQuad(gl);
const channelTextures = createDefaultChannelTextures(gl, 256);
const channelUniforms = [
gl.getUniformLocation(program, "iChannel0"),
gl.getUniformLocation(program, "iChannel1"),
gl.getUniformLocation(program, "iChannel2"),
gl.getUniformLocation(program, "iChannel3"),
];
detailRuntime = {
gl,
program,
vao,
vbo,
channelTextures,
channelUniforms,
uniforms: {
iResolution: gl.getUniformLocation(program, "iResolution"),
iTime: gl.getUniformLocation(program, "iTime"),
@@ -290,10 +613,24 @@ function openDetail(previewState) {
iMouse: gl.getUniformLocation(program, "iMouse"),
},
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
_layoutW: 0,
_layoutH: 0,
};
const dro = new ResizeObserver((entries) => {
for (const e of entries) {
if (e.target !== detailCanvas) continue;
const cr = e.contentRect;
detailRuntime._layoutW = cr.width;
detailRuntime._layoutH = cr.height;
}
});
dro.observe(detailCanvas);
detailRuntime._detailResizeObserver = dro;
detailTitleEl.textContent = previewState.name;
detailViewEl.classList.add("active");
detailViewEl.setAttribute("aria-hidden", "false");
scheduleRender();
}
function applySearch() {
@@ -301,6 +638,7 @@ function applySearch() {
for (const preview of previews) {
preview.card.style.display = !keyword || preview.card.dataset.search.includes(keyword) ? "" : "none";
}
scheduleRender();
}
async function loadShaders() {
@@ -346,29 +684,63 @@ function renderAll(ts) {
fpsCounter += 1;
}
const detailOpen = !!detailRuntime;
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;
let localDt = 0;
if (!detailOpen) {
const now = performance.now();
localDt = Math.min((now - preview.lastTick) / 1000, 0.05);
preview.lastTick = now;
if (preview.isPointerOver && 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 (
!detailOpen &&
preview.card.style.display !== "none" &&
preview.visible &&
!preview.gl &&
!preview.compileFailed
) {
ensurePreviewWebGL(preview);
}
if (detailOpen) continue;
if (preview.card.style.display === "none") continue;
if (preview.visible === false) continue;
if (!preview.gl) continue;
const isAnim = preview.isPointerOver;
if (!isAnim && preview._staticRendered && !preview._staticDirty) continue;
const { gl, canvas, program, vao, uniforms, mouse, channelTextures, channelUniforms } = preview;
if (!preview._layoutW || !preview._layoutH) {
const r = canvas.getBoundingClientRect();
preview._layoutW = r.width;
preview._layoutH = r.height;
}
const { w, h } = computePreviewBufferSize(preview._layoutW, preview._layoutH);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
if (!isAnim) preview._staticDirty = true;
}
gl.useProgram(program);
bindChannelTextures(gl, channelUniforms, channelTextures);
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);
if (isAnim) {
gl.uniform1f(uniforms.iTime, preview.localTime);
gl.uniform1f(uniforms.iTimeDelta, preview.isPlaying && !paused ? localDt : 0);
gl.uniform1i(uniforms.iFrame, preview.localFrame);
} else {
gl.uniform1f(uniforms.iTime, 0);
gl.uniform1f(uniforms.iTimeDelta, 0);
gl.uniform1i(uniforms.iFrame, 0);
}
gl.uniform4f(
uniforms.iMouse,
mouse.x,
@@ -379,19 +751,27 @@ function renderAll(ts) {
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
preview._glTouchSeq = ++gridGlTouchCounter;
if (!isAnim) {
preview._staticRendered = true;
preview._staticDirty = false;
}
}
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));
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms } = detailRuntime;
const lw =
detailRuntime._layoutW > 0 ? detailRuntime._layoutW : detailCanvas.clientWidth || 1;
const lh =
detailRuntime._layoutH > 0 ? detailRuntime._layoutH : detailCanvas.clientHeight || 1;
const { w, h } = computeDetailBufferSize(lw, lh);
if (detailCanvas.width !== w || detailCanvas.height !== h) {
detailCanvas.width = w;
detailCanvas.height = h;
gl.viewport(0, 0, w, h);
}
gl.useProgram(program);
bindChannelTextures(gl, channelUniforms, channelTextures);
gl.uniform3f(uniforms.iResolution, detailCanvas.width, detailCanvas.height, 1);
gl.uniform1f(uniforms.iTime, elapsed);
gl.uniform1f(uniforms.iTimeDelta, dt);
@@ -416,13 +796,14 @@ function renderAll(ts) {
}
}
requestAnimationFrame(renderAll);
if (shouldScheduleNextFrame()) scheduleRender();
}
pauseBtn.addEventListener("click", () => {
paused = !paused;
pauseBtn.textContent = paused ? "全局继续" : "全局暂停";
syncStats();
scheduleRender();
});
resetBtn.addEventListener("click", () => {
elapsed = 0;
@@ -432,8 +813,12 @@ resetBtn.addEventListener("click", () => {
detailLastSec = 0;
detailFpsCounter = 0;
syncStats();
scheduleRender();
});
backBtn.addEventListener("click", () => {
closeDetail();
scheduleRender();
});
backBtn.addEventListener("click", closeDetail);
detailCanvas.addEventListener("mousemove", (event) => {
if (!detailRuntime) return;
const rect = detailCanvas.getBoundingClientRect();
@@ -457,4 +842,4 @@ searchInput.addEventListener("input", (event) => {
loadShaders();
setInterval(loadShaders, 3000);
requestAnimationFrame(renderAll);
scheduleRender();