fix:bug
This commit is contained in:
32
README.md
Normal file
32
README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# VFXdemo
|
||||
|
||||
一个用于快速复刻 ShaderToy 效果的本地工具页(WebGL2 多预览面板)。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
python3 -m http.server 5173
|
||||
```
|
||||
|
||||
打开 `http://localhost:5173`。
|
||||
|
||||
## 页面能力
|
||||
|
||||
- 左侧工具区:粘贴 ShaderToy GLSL(包含 `mainImage`)并新增预览卡片
|
||||
- 右侧面板:多个 VFX 同屏实时渲染,适合“散布式特效预览”
|
||||
- 内置多个默认特效卡片,可直接当样例改造
|
||||
- 全局暂停/继续、全局重置时间
|
||||
|
||||
## 兼容的 uniform
|
||||
|
||||
- `iResolution`
|
||||
- `iTime`
|
||||
- `iTimeDelta`
|
||||
- `iFrame`
|
||||
- `iMouse`
|
||||
|
||||
## 复刻工作流建议
|
||||
|
||||
1. 在 ShaderToy 复制完整 GLSL(至少包含 `mainImage`)
|
||||
2. 粘贴到左侧输入框并点击“新增预览”
|
||||
3. 对比目标效果后持续微调参数
|
||||
477
app.js
Normal file
477
app.js
Normal file
@@ -0,0 +1,477 @@
|
||||
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 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 vertexShaderSource = `#version 300 es
|
||||
precision highp float;
|
||||
layout(location = 0) in vec2 aPosition;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const presetShaders = [
|
||||
{
|
||||
name: "Neon Flow",
|
||||
code: `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
|
||||
float t = iTime * 0.7;
|
||||
float n = sin(uv.x * 7.0 + t) + cos(uv.y * 11.0 - t * 1.2);
|
||||
float glow = 0.015 / (abs(uv.y + 0.2 * sin(uv.x * 5.0 + t)) + 0.005);
|
||||
vec3 col = vec3(0.02, 0.05, 0.12) + vec3(0.1, 0.5, 0.9) * (0.5 + 0.5 * n);
|
||||
col += vec3(0.9, 0.2, 1.0) * glow;
|
||||
fragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Plasma Warp",
|
||||
code: `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 uv = fragCoord / iResolution.xy;
|
||||
float t = iTime * 0.9;
|
||||
float p = sin((uv.x + t) * 10.0) + sin((uv.y - t) * 12.0);
|
||||
p += sin((uv.x + uv.y + t * 0.7) * 18.0);
|
||||
vec3 col = 0.5 + 0.5 * cos(vec3(0.2, 1.3, 2.4) + p + t);
|
||||
fragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Tunnel Pulse",
|
||||
code: `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
|
||||
float t = iTime;
|
||||
float a = atan(uv.y, uv.x);
|
||||
float r = length(uv);
|
||||
float rings = sin(18.0 * r - t * 6.0);
|
||||
float rays = sin(a * 12.0 + t * 3.0);
|
||||
vec3 col = vec3(0.05, 0.03, 0.08);
|
||||
col += vec3(0.2, 0.7, 1.0) * smoothstep(0.98, 1.0, rings);
|
||||
col += vec3(1.0, 0.3, 0.6) * smoothstep(0.95, 1.0, rays) * (1.0 - r);
|
||||
fragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Grid Energy",
|
||||
code: `float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
|
||||
vec2 p = uv * 5.0;
|
||||
vec2 gv = fract(p) - 0.5;
|
||||
vec2 id = floor(p);
|
||||
float rnd = hash21(id);
|
||||
float line = min(abs(gv.x), abs(gv.y));
|
||||
float spark = 0.01 / (line + 0.004);
|
||||
spark *= 0.5 + 0.5 * sin(iTime * 4.0 + rnd * 20.0);
|
||||
vec3 col = vec3(0.03, 0.05, 0.08) + spark * vec3(0.3, 1.0, 0.9);
|
||||
fragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Smoke Bands",
|
||||
code: `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
|
||||
float t = iTime * 0.35;
|
||||
float w = sin(uv.x * 8.0 + t) + sin(uv.x * 14.0 - t * 1.4);
|
||||
float m = smoothstep(0.2, -0.3, abs(uv.y + 0.2 * w));
|
||||
vec3 col = mix(vec3(0.02, 0.02, 0.03), vec3(0.4, 0.5, 0.9), m);
|
||||
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 = [];
|
||||
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;
|
||||
|
||||
function createPreviewCard(name, code) {
|
||||
const views = Math.floor(3000 + Math.random() * 22000);
|
||||
const likes = Math.floor(40 + Math.random() * 700);
|
||||
const author = ["glkt", "aciekick", "iq", "shane", "bers", "felixfaire"][
|
||||
Math.floor(Math.random() * 6)
|
||||
];
|
||||
const card = document.createElement("article");
|
||||
card.className = "card";
|
||||
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;
|
||||
}
|
||||
|
||||
const fragment = buildFragmentShader(code);
|
||||
let program;
|
||||
try {
|
||||
program = compileProgram(gl, fragment);
|
||||
} 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 = {
|
||||
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,
|
||||
};
|
||||
|
||||
function resizeCanvas() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
resizeCanvas();
|
||||
|
||||
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", () => {
|
||||
state.removed = true;
|
||||
card.remove();
|
||||
const idx = previews.indexOf(state);
|
||||
if (idx >= 0) previews.splice(idx, 1);
|
||||
syncStats();
|
||||
});
|
||||
|
||||
pauseLocalBtn.addEventListener("click", () => {
|
||||
state.isPlaying = !state.isPlaying;
|
||||
state.lastTick = performance.now();
|
||||
pauseLocalBtn.textContent = state.isPlaying ? "暂停" : "继续";
|
||||
});
|
||||
|
||||
canvas.addEventListener("click", () => {
|
||||
openDetail(state);
|
||||
});
|
||||
|
||||
gridEl.appendChild(card);
|
||||
previews.push(state);
|
||||
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) {
|
||||
statsEl.textContent = "无法打开详情:当前浏览器不支持 WebGL2。";
|
||||
return;
|
||||
}
|
||||
|
||||
let program;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(previewState.code));
|
||||
} catch (err) {
|
||||
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const { vao, vbo } = 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"),
|
||||
};
|
||||
|
||||
detailRuntime = {
|
||||
gl,
|
||||
program,
|
||||
vao,
|
||||
vbo,
|
||||
uniforms,
|
||||
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) {
|
||||
if (preview.removed) continue;
|
||||
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 ? "已暂停" : "运行中"
|
||||
}`;
|
||||
if (shaderCountEl) {
|
||||
shaderCountEl.textContent = `${previews.length} shaders`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
for (const preset of presetShaders) {
|
||||
createPreviewCard(preset.name, preset.code);
|
||||
}
|
||||
syncStats();
|
||||
requestAnimationFrame(renderAll);
|
||||
248
index.html
Normal file
248
index.html
Normal file
@@ -0,0 +1,248 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VFX 快速预览工具台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: radial-gradient(circle at 20% 0%, #13182a 0%, #090a0f 35%, #07080c 100%);
|
||||
color: #e8e8eb;
|
||||
font-family: "Space Grotesk", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(130, 148, 255, 0.25);
|
||||
background: rgba(8, 10, 17, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: "Archivo", sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
color: #f7f8ff;
|
||||
text-shadow: 0 0 18px rgba(92, 140, 255, 0.45);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 260px;
|
||||
max-width: 42vw;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #d6d9e8;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
color: #c3c5cd;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar strong {
|
||||
font-family: "Archivo", sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid rgba(104, 129, 255, 0.35);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #0f1220 0%, #0b0d15 100%);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(123, 178, 255, 0.7);
|
||||
box-shadow: 0 14px 32px rgba(18, 46, 150, 0.25);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #d6daea;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.card canvas {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 10px;
|
||||
font-size: 12px;
|
||||
color: #9ea6c8;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff8b8b;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
grid-template-rows: 52px 1fr;
|
||||
background: #05060a;
|
||||
}
|
||||
|
||||
.detail-view.active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.detail-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(14, 16, 23, 0.95);
|
||||
}
|
||||
|
||||
.detail-stage {
|
||||
width: 100%;
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
.detail-stage canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="brand">Shadervault</div>
|
||||
<div class="header-right">
|
||||
<input class="search" placeholder="Search shaders..." />
|
||||
</div>
|
||||
</header>
|
||||
<div id="app">
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<strong>Featured Shader Playlist</strong>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span id="shader-count">0 shaders</span>
|
||||
<button id="pause-btn">全局暂停</button>
|
||||
<button id="reset-btn">全局重置</button>
|
||||
<span id="global-fps">FPS: --</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="stats">准备就绪</div>
|
||||
<section id="preview-grid" class="preview-grid"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<section id="detail-view" class="detail-view" aria-hidden="true">
|
||||
<div class="detail-topbar">
|
||||
<div>
|
||||
<strong id="detail-title">特效详情</strong>
|
||||
<span id="detail-fps" style="margin-left: 8px; color: #c8c9cf; font-size: 12px">FPS: --</span>
|
||||
</div>
|
||||
<button id="back-btn">返回列表</button>
|
||||
</div>
|
||||
<div class="detail-stage">
|
||||
<canvas id="detail-canvas"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user