commit ae3e6f717bc3eaa8b7c3739aea8e707dc70c7c0d Author: Daniel Date: Wed Apr 1 18:22:45 2026 +0800 fix:bug diff --git a/README.md b/README.md new file mode 100644 index 0000000..a747329 --- /dev/null +++ b/README.md @@ -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. 对比目标效果后持续微调参数 diff --git a/app.js b/app.js new file mode 100644 index 0000000..8d492f0 --- /dev/null +++ b/app.js @@ -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 = ` +
+ ${name} +
+ + +
+
+ +
+ by ${author} + views ${views} | likes ${likes} +
+
+ `; + + 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); diff --git a/index.html b/index.html new file mode 100644 index 0000000..586c69d --- /dev/null +++ b/index.html @@ -0,0 +1,248 @@ + + + + + + VFX 快速预览工具台 + + + + + + + +
+
+
+ Featured Shader Playlist +
+ 0 shaders + + + FPS: -- +
+
+
准备就绪
+
+
+
+ + + + +