feat:优化数据
This commit is contained in:
657
display.js
657
display.js
@@ -13,11 +13,85 @@ 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;
|
||||
/** 串行补全缺失缩略图(每步独立 WebGL,用完即释放),避免多卡片同时建上下文 */
|
||||
const thumbBackfillQueue = [];
|
||||
const thumbBackfillQueuedIds = new Set();
|
||||
const thumbBackfillFailUntil = new Map();
|
||||
let thumbBackfillRunner = false;
|
||||
const THUMB_BACKOFF_MS = 120000;
|
||||
|
||||
let gridHoverRuntime = null;
|
||||
let sharedHoverCanvas = null;
|
||||
|
||||
function scheduleThumbBackfill(shaders) {
|
||||
if (typeof window.captureShaderThumbnail !== "function") return;
|
||||
const now = Date.now();
|
||||
for (const s of shaders) {
|
||||
if (s.thumbnailUrl) continue;
|
||||
const code = s.code && String(s.code).trim();
|
||||
if (!code) continue;
|
||||
const retryAt = thumbBackfillFailUntil.get(s.id);
|
||||
if (retryAt != null && now < retryAt) continue;
|
||||
if (thumbBackfillQueuedIds.has(s.id)) continue;
|
||||
thumbBackfillQueuedIds.add(s.id);
|
||||
thumbBackfillQueue.push({ id: s.id, code, name: s.name || "shader" });
|
||||
}
|
||||
void runThumbBackfillLoop();
|
||||
}
|
||||
|
||||
async function runThumbBackfillLoop() {
|
||||
if (thumbBackfillRunner) return;
|
||||
thumbBackfillRunner = true;
|
||||
try {
|
||||
while (thumbBackfillQueue.length) {
|
||||
if (detailRuntime || gridHoverRuntime) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
continue;
|
||||
}
|
||||
const job = thumbBackfillQueue.shift();
|
||||
try {
|
||||
const dataUrl = await window.captureShaderThumbnail(job.code, job.name);
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(job.id)}/thumbnail`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pngBase64: dataUrl }),
|
||||
});
|
||||
if (res.ok) {
|
||||
thumbBackfillFailUntil.delete(job.id);
|
||||
const url = `${API_BASE}/${encodeURIComponent(job.id)}/thumbnail?t=${Date.now()}`;
|
||||
replaceCardStageWithThumb(job.id, url);
|
||||
} else {
|
||||
thumbBackfillFailUntil.set(job.id, Date.now() + THUMB_BACKOFF_MS);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("缩略图自动生成失败", job.id, e);
|
||||
thumbBackfillFailUntil.set(job.id, Date.now() + THUMB_BACKOFF_MS);
|
||||
} finally {
|
||||
thumbBackfillQueuedIds.delete(job.id);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
} finally {
|
||||
thumbBackfillRunner = false;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCardStageWithThumb(id, url) {
|
||||
if (gridHoverRuntime && gridHoverRuntime.state.id === id) tearDownGridHover();
|
||||
const card = gridEl.querySelector(`[data-shader-id="${CSS.escape(id)}"]`);
|
||||
if (!card) return;
|
||||
const stage = card.querySelector(".card-stage");
|
||||
if (!stage) return;
|
||||
stage.innerHTML = `<img class="card-thumb" src="${url}" alt="" loading="lazy" decoding="async" />`;
|
||||
}
|
||||
|
||||
/** 全屏详情:限制像素量以便稳定 50+ FPS(过高 DPR × 边长会显著拖慢片元着色器) */
|
||||
const DETAIL_MAX_PIXEL_RATIO = 1.35;
|
||||
const DETAIL_MAX_BUFFER_LONG_SIDE = 1680;
|
||||
|
||||
/** 卡片 hover 实时预览:单例 WebGL,与缩略图同量级分辨率上限 */
|
||||
const HOVER_MAX_PIXEL_RATIO = 1.25;
|
||||
const HOVER_MAX_BUFFER_LONG_SIDE = 560;
|
||||
|
||||
const GL_CONTEXT_OPTS = {
|
||||
alpha: false,
|
||||
@@ -28,12 +102,6 @@ const GL_CONTEXT_OPTS = {
|
||||
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;
|
||||
@@ -254,19 +322,6 @@ function bindChannelTextures(gl, channelUniformLocs, channelTextures) {
|
||||
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));
|
||||
@@ -280,120 +335,21 @@ function computeDetailBufferSize(cssW, cssH) {
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
function countGridWebGLContexts() {
|
||||
let n = 0;
|
||||
for (const p of previews) {
|
||||
if (p.gl) n += 1;
|
||||
function computeHoverBufferSize(cssW, cssH) {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, HOVER_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 > HOVER_MAX_BUFFER_LONG_SIDE) {
|
||||
const s = HOVER_MAX_BUFFER_LONG_SIDE / long;
|
||||
w = Math.max(1, Math.floor(w * s));
|
||||
h = Math.max(1, Math.floor(h * s));
|
||||
}
|
||||
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"),
|
||||
iDate: gl.getUniformLocation(program, "iDate"),
|
||||
};
|
||||
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;
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
const previews = [];
|
||||
/** Driven by scheduleRender(); only runs while detail / hover / pending static thumbs need a frame. */
|
||||
/** Driven by scheduleRender(); detail + grid hover. */
|
||||
let renderRafId = 0;
|
||||
|
||||
function scheduleRender() {
|
||||
@@ -404,33 +360,10 @@ function scheduleRender() {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (gridHoverRuntime) return true;
|
||||
return !paused;
|
||||
}
|
||||
|
||||
let paused = false;
|
||||
@@ -447,150 +380,188 @@ let detailRuntime = null;
|
||||
let searchKeyword = "";
|
||||
let lastSignature = "";
|
||||
|
||||
function getSharedHoverCanvas() {
|
||||
if (!sharedHoverCanvas) {
|
||||
sharedHoverCanvas = document.createElement("canvas");
|
||||
sharedHoverCanvas.className = "card-hover-canvas";
|
||||
}
|
||||
return sharedHoverCanvas;
|
||||
}
|
||||
|
||||
function tearDownGridHover() {
|
||||
if (!gridHoverRuntime) {
|
||||
if (sharedHoverCanvas && sharedHoverCanvas.parentNode) {
|
||||
const stage = sharedHoverCanvas.parentNode;
|
||||
stage.querySelectorAll(":scope > *").forEach((el) => {
|
||||
el.style.visibility = "";
|
||||
});
|
||||
sharedHoverCanvas.remove();
|
||||
sharedHoverCanvas.style.display = "none";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { gl, program, vao, vbo, channelTextures, _hoverResizeObserver } = gridHoverRuntime;
|
||||
if (_hoverResizeObserver) _hoverResizeObserver.disconnect();
|
||||
try {
|
||||
if (program) gl.deleteProgram(program);
|
||||
if (vao) gl.deleteVertexArray(vao);
|
||||
if (vbo) gl.deleteBuffer(vbo);
|
||||
if (channelTextures) channelTextures.forEach((t) => gl.deleteTexture(t));
|
||||
} catch (_) {
|
||||
/* lost */
|
||||
}
|
||||
gridHoverRuntime = null;
|
||||
if (sharedHoverCanvas && sharedHoverCanvas.parentNode) {
|
||||
const stage = sharedHoverCanvas.parentNode;
|
||||
stage.querySelectorAll(":scope > *").forEach((el) => {
|
||||
el.style.visibility = "";
|
||||
});
|
||||
sharedHoverCanvas.remove();
|
||||
}
|
||||
if (sharedHoverCanvas) sharedHoverCanvas.style.display = "none";
|
||||
}
|
||||
|
||||
function startGridHover(previewState, card) {
|
||||
if (detailViewEl.classList.contains("active")) return;
|
||||
tearDownGridHover();
|
||||
const canvas = getSharedHoverCanvas();
|
||||
const stage = card.querySelector(".card-stage");
|
||||
if (!stage) return;
|
||||
|
||||
const gl = canvas.getContext("webgl2", GL_CONTEXT_OPTS);
|
||||
if (!gl) return;
|
||||
|
||||
let program;
|
||||
let codeForRender = previewState.code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_e1) {
|
||||
try {
|
||||
codeForRender = toPortableGlsl(previewState.code);
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_e2) {
|
||||
codeForRender = fallbackShader(previewState.name);
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
iDate: gl.getUniformLocation(program, "iDate"),
|
||||
};
|
||||
|
||||
stage.appendChild(canvas);
|
||||
canvas.style.display = "block";
|
||||
stage.querySelectorAll(":scope > *").forEach((el) => {
|
||||
if (el !== canvas) el.style.visibility = "hidden";
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!gridHoverRuntime) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
gridHoverRuntime._layoutW = r.width;
|
||||
gridHoverRuntime._layoutH = r.height;
|
||||
});
|
||||
ro.observe(canvas);
|
||||
const r0 = canvas.getBoundingClientRect();
|
||||
gridHoverRuntime = {
|
||||
gl,
|
||||
program,
|
||||
vao,
|
||||
vbo,
|
||||
channelTextures,
|
||||
channelUniforms,
|
||||
uniforms,
|
||||
canvas,
|
||||
state: previewState,
|
||||
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
|
||||
localTime: 0.05,
|
||||
localFrame: 1,
|
||||
lastTick: performance.now(),
|
||||
_layoutW: r0.width,
|
||||
_layoutH: r0.height,
|
||||
_hoverResizeObserver: ro,
|
||||
};
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function createPreviewCard(shader) {
|
||||
const { id, name, author = "unknown", views = 0, likes = 0, code } = shader;
|
||||
const { id, name, author = "unknown", views = 0, likes = 0, code, thumbnailUrl } = shader;
|
||||
const card = document.createElement("article");
|
||||
card.className = "card";
|
||||
card.dataset.search = `${name} ${author}`.toLowerCase();
|
||||
|
||||
const visualInner = thumbnailUrl
|
||||
? `<img class="card-thumb" src="${thumbnailUrl}" alt="" loading="lazy" decoding="async" />`
|
||||
: `<div class="card-thumb card-thumb--placeholder">暂无缩略图<br /><small>正在尝试自动生成…</small></div>`;
|
||||
|
||||
card.dataset.shaderId = id;
|
||||
card.innerHTML = `
|
||||
<div class="card-head">
|
||||
<strong>${name}</strong>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button type="button" class="pause-local-btn">暂停</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas></canvas>
|
||||
<div class="card-stage">${visualInner}</div>
|
||||
<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 state = {
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
codeForRender: code,
|
||||
compileFailed: false,
|
||||
card,
|
||||
canvas,
|
||||
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("click", () => openDetail(state));
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
state.isPointerOver = true;
|
||||
state.lastTick = performance.now();
|
||||
scheduleRender();
|
||||
if (detailViewEl.classList.contains("active")) return;
|
||||
startGridHover(state, card);
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
state.isPointerOver = false;
|
||||
state.localTime = 0;
|
||||
state.localFrame = 0;
|
||||
state._staticDirty = true;
|
||||
state._staticRendered = false;
|
||||
scheduleRender();
|
||||
if (gridHoverRuntime && gridHoverRuntime.state.id === state.id) tearDownGridHover();
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
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;
|
||||
card.addEventListener("mousemove", (event) => {
|
||||
if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
|
||||
const c = gridHoverRuntime.canvas;
|
||||
const rect = c.getBoundingClientRect();
|
||||
const bw = c.width || 1;
|
||||
const bh = c.height || 1;
|
||||
gridHoverRuntime.mouse.x = ((event.clientX - rect.left) / rect.width) * bw;
|
||||
gridHoverRuntime.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh;
|
||||
});
|
||||
canvas.addEventListener("mousedown", () => {
|
||||
state.mouse.down = true;
|
||||
state.mouse.downX = state.mouse.x;
|
||||
state.mouse.downY = state.mouse.y;
|
||||
card.addEventListener("mousedown", () => {
|
||||
if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
|
||||
gridHoverRuntime.mouse.down = true;
|
||||
gridHoverRuntime.mouse.downX = gridHoverRuntime.mouse.x;
|
||||
gridHoverRuntime.mouse.downY = gridHoverRuntime.mouse.y;
|
||||
});
|
||||
window.addEventListener("mouseup", () => {
|
||||
state.mouse.down = false;
|
||||
});
|
||||
|
||||
pauseLocalBtn.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
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() {
|
||||
tearDownGridHover();
|
||||
previews.forEach((preview) => {
|
||||
tearDownPreviewWebGL(preview);
|
||||
if (preview._resizeObserver) preview._resizeObserver.disconnect();
|
||||
if (preview._io) preview._io.disconnect();
|
||||
preview.card.remove();
|
||||
});
|
||||
previews.length = 0;
|
||||
@@ -610,19 +581,30 @@ function closeDetail() {
|
||||
}
|
||||
|
||||
function openDetail(previewState) {
|
||||
tearDownGridHover();
|
||||
closeDetail();
|
||||
const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS);
|
||||
if (!gl) return;
|
||||
let program;
|
||||
const detailCode = previewState.codeForRender || previewState.code;
|
||||
let detailCode = previewState.code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (err) {
|
||||
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
|
||||
return;
|
||||
} catch (_e1) {
|
||||
try {
|
||||
detailCode = toPortableGlsl(previewState.code);
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (_e2) {
|
||||
detailCode = fallbackShader(previewState.name);
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (err) {
|
||||
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { vao, vbo } = createQuad(gl);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 256);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 128);
|
||||
const channelUniforms = [
|
||||
gl.getUniformLocation(program, "iChannel0"),
|
||||
gl.getUniformLocation(program, "iChannel1"),
|
||||
@@ -679,7 +661,12 @@ async function loadShaders() {
|
||||
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 }))
|
||||
shaders.map((s) => ({
|
||||
id: s.id,
|
||||
updatedAt: s.updatedAt || s.createdAt || "",
|
||||
thumbnailAt: s.thumbnailAt || "",
|
||||
name: s.name,
|
||||
}))
|
||||
);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
@@ -688,6 +675,7 @@ async function loadShaders() {
|
||||
applySearch();
|
||||
syncStats();
|
||||
}
|
||||
scheduleThumbBackfill(shaders);
|
||||
} catch (err) {
|
||||
statsEl.textContent = `读取后端失败:${String(err.message || err)}`;
|
||||
}
|
||||
@@ -716,83 +704,56 @@ function renderAll(ts) {
|
||||
fpsCounter += 1;
|
||||
}
|
||||
|
||||
const detailOpen = !!detailRuntime;
|
||||
|
||||
for (const preview of previews) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (isAnim) {
|
||||
gl.uniform1f(uniforms.iTime, preview.localTime);
|
||||
gl.uniform1f(uniforms.iTimeDelta, preview.isPlaying && !paused ? localDt : 0);
|
||||
gl.uniform1i(uniforms.iFrame, preview.localFrame);
|
||||
if (gridHoverRuntime) {
|
||||
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms, canvas } =
|
||||
gridHoverRuntime;
|
||||
if (gl.isContextLost()) {
|
||||
tearDownGridHover();
|
||||
} else {
|
||||
gl.uniform1f(uniforms.iTime, 0);
|
||||
gl.uniform1f(uniforms.iTimeDelta, 0);
|
||||
gl.uniform1i(uniforms.iFrame, 0);
|
||||
}
|
||||
setIDateUniform(gl, uniforms.iDate);
|
||||
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);
|
||||
preview._glTouchSeq = ++gridGlTouchCounter;
|
||||
if (!isAnim) {
|
||||
preview._staticRendered = true;
|
||||
preview._staticDirty = false;
|
||||
const now = performance.now();
|
||||
const localDt = Math.min((now - gridHoverRuntime.lastTick) / 1000, 0.05);
|
||||
gridHoverRuntime.lastTick = now;
|
||||
if (!paused) {
|
||||
gridHoverRuntime.localTime += localDt;
|
||||
gridHoverRuntime.localFrame += 1;
|
||||
}
|
||||
const lw =
|
||||
gridHoverRuntime._layoutW > 0 ? gridHoverRuntime._layoutW : canvas.clientWidth || 1;
|
||||
const lh =
|
||||
gridHoverRuntime._layoutH > 0 ? gridHoverRuntime._layoutH : canvas.clientHeight || 1;
|
||||
const { w, h } = computeHoverBufferSize(lw, lh);
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
}
|
||||
gl.useProgram(program);
|
||||
bindChannelTextures(gl, channelUniforms, channelTextures);
|
||||
gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1);
|
||||
gl.uniform1f(uniforms.iTime, gridHoverRuntime.localTime);
|
||||
gl.uniform1f(uniforms.iTimeDelta, paused ? 0 : localDt);
|
||||
gl.uniform1i(uniforms.iFrame, gridHoverRuntime.localFrame);
|
||||
setIDateUniform(gl, uniforms.iDate);
|
||||
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, channelTextures, channelUniforms } = detailRuntime;
|
||||
if (gl.isContextLost()) {
|
||||
closeDetail();
|
||||
scheduleRender();
|
||||
return;
|
||||
}
|
||||
const lw =
|
||||
detailRuntime._layoutW > 0 ? detailRuntime._layoutW : detailCanvas.clientWidth || 1;
|
||||
const lh =
|
||||
@@ -866,8 +827,8 @@ detailCanvas.addEventListener("mousedown", () => {
|
||||
detailRuntime.mouse.downY = detailRuntime.mouse.y;
|
||||
});
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!detailRuntime) return;
|
||||
detailRuntime.mouse.down = false;
|
||||
if (detailRuntime) detailRuntime.mouse.down = false;
|
||||
if (gridHoverRuntime) gridHoverRuntime.mouse.down = false;
|
||||
});
|
||||
searchInput.addEventListener("input", (event) => {
|
||||
searchKeyword = event.target.value || "";
|
||||
|
||||
Reference in New Issue
Block a user