feat:新增文件内容

This commit is contained in:
Daniel
2026-04-01 17:28:39 +08:00
commit 0bf1299908
33 changed files with 1703 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
ARG BASE_IMAGE=docker.m.daocloud.io/library/node:20-bookworm-slim
FROM ${BASE_IMAGE}
ARG APT_MIRROR=mirrors.aliyun.com
ARG NPM_REGISTRY=https://registry.npmmirror.com
RUN sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json .npmrc ./
RUN npm config set registry ${NPM_REGISTRY} \
&& npm install --registry=${NPM_REGISTRY}
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
BASE_IMAGE: docker.m.daocloud.io/library/node:20-bookworm-slim
APT_MIRROR: mirrors.aliyun.com
NPM_REGISTRY: https://registry.npmmirror.com
container_name: ai-vfx-editor-mvp
environment:
- NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
- CHOKIDAR_USEPOLLING=true
ports:
- "5173:5173"

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI VFX Editor MVP</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "ai-vfx-editor-mvp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.178.0"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.178.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

216
readme.md Normal file
View File

@@ -0,0 +1,216 @@
# AI VFX Editor MVP - Coding Agent 执行说明
## 🎯 目标
构建一个最小可用的 AI 特效编辑器Web版支持
1. Three.js 实时渲染 Shader
2. 参数面板控制 Shader uniforms
3. 多个特效模板切换
4. 简单 AI 输入生成初始参数
5. 导出 Shader 和 Three.js Material
---
## 🏗️ 技术栈
* Frontend: React + Vite
* 渲染: Three.js
* Shader: GLSL
* 状态管理: useState无需复杂方案
* AI接口: mock先写死
---
## 📁 项目结构
src/
├── App.tsx
├── components/
│ ├── Preview.tsx // 渲染窗口
│ ├── ControlPanel.tsx // 参数控制
│ ├── ShaderEditor.tsx // shader展示
│ ├── TemplateSelector.tsx // 模板选择
├── engine/
│ ├── useThree.ts // 初始化Three
│ ├── shaderBuilder.ts // shader拼装
│ ├── uniformManager.ts // 参数管理
├── shaders/
│ ├── base.vert
│ ├── modules/
│ │ ├── noise.glsl
│ │ ├── uv_scroll.glsl
│ │ ├── alpha.glsl
│ ├── templates/
│ │ ├── fire.ts
│ │ ├── ripple.ts
│ │ ├── distortion.ts
---
## 🎨 功能拆解
### 1⃣ Preview核心
* 使用 Three.js 创建 full-screen quad
* 使用 ShaderMaterial
* uniforms 包含:
* u_time
* u_speed
* u_scale
* requestAnimationFrame 更新 u_time
---
### 2⃣ ControlPanel
* 使用 slider 控制:
* speed
* scale
* intensity
* 实时更新 uniforms
---
### 3⃣ ShaderBuilder关键模块
实现函数:
buildShader(template, params)
逻辑:
* 根据 template 注入模块:
* noise
* uv scroll
* alpha
* 输出完整 fragment shader
---
### 4⃣ 模板系统
每个模板定义:
{
name: "fire",
modules: ["noise", "uv_scroll", "alpha"],
defaultParams: {
speed: 1.0,
scale: 3.0
}
}
---
### 5⃣ AI模块Mock
实现函数:
generateEffect(prompt)
返回:
{
template: "fire",
params: {
speed: 1.5,
scale: 4.0
}
}
---
### 6⃣ 导出功能(必须实现)
按钮Export
导出内容:
1. fragment.glsl
2. vertex.glsl
3. material.json
---
## 🧪 测试标准
必须满足:
* 参数修改实时生效(<16ms
* 模板切换无报错
* Shader 可复制运行
* 导出文件可复用
---
## 🚀 初始任务顺序
1. 初始化 Vite + React
2. 完成 Three.js 渲染
3. 加入 ShaderMaterial
4. 加入 uniforms 动画
5. 实现 ControlPanel
6. 实现 模板系统
7. 实现 shaderBuilder
8. 实现 Export
---
## ❗ 注意事项
* 不要引入复杂状态管理
* 不要实现粒子系统
* 不要过度抽象
* 优先保证“可运行”
---
## 🎯 最终目标
得到一个:
👉 可运行的 Web 特效编辑器
👉 能导出 Shader
👉 能通过参数控制效果
这是后续商业化 POC 的基础
---
## 🐳 本地 Docker 快速启动(国内镜像)
项目已默认切换到国内镜像源:
* Node 基础镜像:`docker.m.daocloud.io`
* Debian APT`mirrors.aliyun.com`
* NPM`registry.npmmirror.com`
### 1) 启动
```bash
chmod +x scripts/docker-up.sh scripts/docker-down.sh
./scripts/docker-up.sh
```
启动后访问:`http://localhost:5173`
### 2) 停止
```bash
./scripts/docker-down.sh
```
### 3) 查看日志
```bash
docker compose logs -f web
```

18
scripts/docker-down.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${PROJECT_ROOT}"
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
echo "未找到 docker compose / docker-compose。"
exit 1
fi
echo "停止并清理容器..."
"${COMPOSE_CMD[@]}" down --remove-orphans
echo "已停止。"

22
scripts/docker-up.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${PROJECT_ROOT}"
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
echo "未找到 docker compose / docker-compose请先安装 Docker Desktop。"
exit 1
fi
echo "使用国内镜像源构建并启动容器..."
"${COMPOSE_CMD[@]}" build --pull
"${COMPOSE_CMD[@]}" up -d
echo "启动完成。"
echo "访问地址: http://localhost:5173"
echo "查看日志: ${COMPOSE_CMD[*]} logs -f web"

280
src/App.tsx Normal file
View File

@@ -0,0 +1,280 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Preview from "./components/Preview";
import { generateEffect } from "./engine/aiMock";
import { buildShader } from "./engine/shaderBuilder";
import { createUniforms, updateUniformValues } from "./engine/uniformManager";
import baseVertexShader from "./shaders/base.vert?raw";
import { templateMap, templates } from "./shaders/templates";
import type { EffectParams } from "./shaders/templates/types";
function downloadFile(filename: string, content: string, type: string): void {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
type ShaderPass = {
id: string;
name: string;
fragment: string;
locked?: boolean;
};
const initialPasses: ShaderPass[] = templates.map((t) => ({
id: t.name,
name: t.name,
fragment: buildShader(t, t.defaultParams),
locked: true
}));
export default function App() {
const defaultTemplate = templates[0];
const [templateName, setTemplateName] = useState<string>(templates[0].name);
const [prompt, setPrompt] = useState<string>("hot fire");
const [passes, setPasses] = useState<ShaderPass[]>(initialPasses);
const [activePassId, setActivePassId] = useState<string>(initialPasses[0].id);
const [params, setParams] = useState<EffectParams>(defaultTemplate.defaultParams);
const [uniforms, setUniforms] = useState(() => createUniforms(defaultTemplate.defaultParams));
const [appliedShader, setAppliedShader] = useState<string>(initialPasses[0].fragment);
const [isPlaying, setIsPlaying] = useState<boolean>(true);
const previewWrapRef = useRef<HTMLDivElement | null>(null);
const template = templateMap.get(templateName) ?? templates[0];
const activePass = useMemo(
() => passes.find((p) => p.id === activePassId) ?? passes[0],
[passes, activePassId]
);
useEffect(() => {
updateUniformValues(uniforms, isPlaying ? params : { ...params, speed: 0 });
}, [uniforms, params, isPlaying]);
const onParamChange = (key: keyof EffectParams, value: number) => {
const next = { ...params, [key]: value };
setParams(next);
};
const onTemplateSelect = (name: string) => {
const selected = templateMap.get(name);
if (!selected) {
return;
}
setTemplateName(name);
setActivePassId(selected.name);
setAppliedShader(passes.find((p) => p.id === selected.name)?.fragment ?? appliedShader);
setParams(selected.defaultParams);
setUniforms(createUniforms(selected.defaultParams));
};
const onGenerate = () => {
const result = generateEffect(prompt);
const selected = templateMap.get(result.template);
if (!selected) {
return;
}
setTemplateName(selected.name);
setActivePassId(selected.name);
setAppliedShader(passes.find((p) => p.id === selected.name)?.fragment ?? appliedShader);
setParams(result.params);
setUniforms(createUniforms(result.params));
};
const onActiveShaderChange = (value: string) => {
setPasses((prev) => prev.map((p) => (p.id === activePass.id ? { ...p, fragment: value } : p)));
};
const onApplyShader = () => {
setAppliedShader(activePass.fragment);
};
const onAddShader = () => {
const customCount = passes.filter((p) => p.id.startsWith("custom-")).length + 1;
const id = `custom-${Date.now()}`;
const next: ShaderPass = {
id,
name: `custom_${customCount}`,
fragment: activePass.fragment
};
setPasses((prev) => [...prev, next]);
setActivePassId(id);
setAppliedShader(next.fragment);
};
const onDeleteShader = () => {
if (!activePass || activePass.locked || passes.length <= 1) {
return;
}
const index = passes.findIndex((p) => p.id === activePass.id);
const nextPasses = passes.filter((p) => p.id !== activePass.id);
const fallback = nextPasses[Math.max(0, index - 1)];
setPasses(nextPasses);
setActivePassId(fallback.id);
setAppliedShader(fallback.fragment);
};
const onTogglePlay = () => {
setIsPlaying((prev) => !prev);
};
const onFullscreen = () => {
previewWrapRef.current?.requestFullscreen?.();
};
const onExport = () => {
downloadFile("fragment.glsl", appliedShader, "text/plain;charset=utf-8");
downloadFile("vertex.glsl", baseVertexShader, "text/plain;charset=utf-8");
downloadFile(
"material.json",
JSON.stringify(
{
type: "ShaderMaterial",
template: activePass.name,
uniforms: {
u_time: 0,
u_speed: params.speed,
u_scale: params.scale,
u_intensity: params.intensity
}
},
null,
2
),
"application/json;charset=utf-8"
);
};
return (
<main className="app-shell">
<header className="topbar">
<h1>AI VFX Shader Tool</h1>
<p> Shadertoy + + Shader /</p>
</header>
<section className="content-grid">
<section className="left-column">
<article className="card player" ref={previewWrapRef}>
<Preview vertexShader={baseVertexShader} fragmentShader={appliedShader} uniforms={uniforms} />
<div className="player-bar">
<span className="status-text">
Active: <strong>{activePass.name}</strong>
</span>
<div className="player-actions">
<button type="button" className="secondary" onClick={onTogglePlay}>
{isPlaying ? "Pause" : "Play"}
</button>
<button type="button" onClick={onFullscreen}>
Fullscreen
</button>
</div>
</div>
</article>
<article className="card editor-shell">
<div className="pass-manager">
{passes.map((pass) => (
<button
key={pass.id}
type="button"
className={`pass-tab ${activePass.id === pass.id ? "active" : ""}`}
onClick={() => {
setActivePassId(pass.id);
setAppliedShader(pass.fragment);
if (templateMap.has(pass.id)) {
setTemplateName(pass.id);
}
}}
>
{pass.name}
</button>
))}
<button type="button" className="small secondary" onClick={onAddShader}>
+ Add Shader
</button>
<button
type="button"
className="small secondary"
onClick={onDeleteShader}
disabled={activePass.locked}
title={activePass.locked ? "内置模板不可删除" : "删除当前自定义 Shader"}
>
Delete
</button>
</div>
<label className="field shader-editor">
<span>Fragment Shader (Editable)</span>
<textarea
value={activePass.fragment}
rows={16}
onChange={(e) => onActiveShaderChange(e.target.value)}
/>
</label>
<div className="editor-actions">
<button type="button" onClick={onApplyShader}>
Apply Shader
</button>
<button type="button" className="secondary" onClick={onExport}>
Export
</button>
</div>
</article>
</section>
<aside className="right-column card panel">
<h3>Interaction</h3>
<label className="field">
<span>Template</span>
<select value={templateName} onChange={(e) => onTemplateSelect(e.target.value)}>
{templates.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
</select>
</label>
<label className="field">
<span>AI Prompt (Mock)</span>
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="例如: hot fire / water ripple / glitch distortion"
/>
</label>
<button type="button" className="secondary" onClick={onGenerate}>
Generate
</button>
{(["speed", "scale", "intensity"] as Array<keyof EffectParams>).map((key) => {
const ranges: Record<keyof EffectParams, [number, number, number]> = {
speed: [0, 3, 0.01],
scale: [0.5, 12, 0.1],
intensity: [0, 2, 0.01]
};
const [min, max, step] = ranges[key];
return (
<label key={key} className="slider-row">
<span>{key}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={params[key]}
onChange={(e) => onParamChange(key, Number(e.target.value))}
/>
<strong>{params[key].toFixed(2)}</strong>
</label>
);
})}
</aside>
</section>
</main>
);
}

View File

@@ -0,0 +1,37 @@
import type { EffectParams } from "../shaders/templates/types";
type ControlPanelProps = {
params: EffectParams;
onChange: (key: keyof EffectParams, value: number) => void;
};
const ranges: Record<keyof EffectParams, [number, number, number]> = {
speed: [0, 3, 0.01],
scale: [0.5, 12, 0.1],
intensity: [0, 2, 0.01]
};
export default function ControlPanel({ params, onChange }: ControlPanelProps) {
return (
<section className="panel card">
<h3>Controls</h3>
{(Object.keys(params) as Array<keyof EffectParams>).map((key) => {
const [min, max, step] = ranges[key];
return (
<label key={key} className="slider-row">
<span>{key}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={params[key]}
onChange={(e) => onChange(key, Number(e.target.value))}
/>
<strong>{params[key].toFixed(2)}</strong>
</label>
);
})}
</section>
);
}

View File

@@ -0,0 +1,14 @@
import { useThree } from "../engine/useThree";
import type { UniformState } from "../engine/uniformManager";
type PreviewProps = {
vertexShader: string;
fragmentShader: string;
uniforms: UniformState;
};
export default function Preview({ vertexShader, fragmentShader, uniforms }: PreviewProps) {
const { mountRef } = useThree({ vertexShader, fragmentShader, uniforms });
return <div className="preview-canvas" ref={mountRef} />;
}

View File

@@ -0,0 +1,20 @@
type ShaderEditorProps = {
fragmentShader: string;
vertexShader: string;
};
export default function ShaderEditor({ fragmentShader, vertexShader }: ShaderEditorProps) {
return (
<section className="panel card shader-box">
<h3>Shader Source</h3>
<label className="field">
<span>Fragment</span>
<textarea readOnly value={fragmentShader} rows={14} />
</label>
<label className="field">
<span>Vertex</span>
<textarea readOnly value={vertexShader} rows={7} />
</label>
</section>
);
}

View File

@@ -0,0 +1,47 @@
import type { EffectTemplate } from "../shaders/templates/types";
type TemplateSelectorProps = {
templates: EffectTemplate[];
current: string;
onSelect: (name: string) => void;
prompt: string;
onPromptChange: (value: string) => void;
onGenerate: () => void;
};
export default function TemplateSelector({
templates,
current,
onSelect,
prompt,
onPromptChange,
onGenerate
}: TemplateSelectorProps) {
return (
<section className="panel card">
<h3>Templates</h3>
<label className="field">
<span>Template</span>
<select value={current} onChange={(e) => onSelect(e.target.value)}>
{templates.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
</select>
</label>
<label className="field">
<span>AI Prompt (Mock)</span>
<input
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder="例如hot fire / water ripple / glitch distortion"
/>
</label>
<button type="button" onClick={onGenerate}>
Generate Effect
</button>
</section>
);
}

29
src/engine/aiMock.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { EffectParams } from "../shaders/templates/types";
export type GenerateResult = {
template: "fire" | "ripple" | "distortion";
params: EffectParams;
};
export function generateEffect(prompt: string): GenerateResult {
const p = prompt.toLowerCase();
if (p.includes("water") || p.includes("ripple") || p.includes("涟漪")) {
return {
template: "ripple",
params: { speed: 1.0, scale: 9.0, intensity: 0.9 }
};
}
if (p.includes("glitch") || p.includes("distort") || p.includes("扭曲")) {
return {
template: "distortion",
params: { speed: 1.4, scale: 6.2, intensity: 1.0 }
};
}
return {
template: "fire",
params: { speed: 1.5, scale: 4.0, intensity: 1.0 }
};
}

View File

@@ -0,0 +1,31 @@
import alphaModule from "../shaders/modules/alpha.glsl?raw";
import noiseModule from "../shaders/modules/noise.glsl?raw";
import uvScrollModule from "../shaders/modules/uv_scroll.glsl?raw";
import type { EffectParams, EffectTemplate } from "../shaders/templates/types";
const moduleCodeMap = {
noise: noiseModule,
uv_scroll: uvScrollModule,
alpha: alphaModule
};
export function buildShader(template: EffectTemplate, _params: EffectParams): string {
const modules = template.modules.map((name) => moduleCodeMap[name]).join("\n\n");
return `
precision mediump float;
uniform float u_time;
uniform float u_speed;
uniform float u_scale;
uniform float u_intensity;
varying vec2 v_uv;
${modules}
void main() {
${template.fragmentLogic}
}
`;
}

View File

@@ -0,0 +1,27 @@
import * as THREE from "three";
import type { EffectParams } from "../shaders/templates/types";
export type UniformState = {
u_time: { value: number };
u_speed: { value: number };
u_scale: { value: number };
u_intensity: { value: number };
};
export function createUniforms(params: EffectParams): UniformState {
return {
u_time: { value: 0 },
u_speed: { value: params.speed },
u_scale: { value: params.scale },
u_intensity: { value: params.intensity }
};
}
export function updateUniformValues(
uniforms: UniformState | Record<string, THREE.IUniform>,
params: EffectParams
): void {
uniforms.u_speed.value = params.speed;
uniforms.u_scale.value = params.scale;
uniforms.u_intensity.value = params.intensity;
}

70
src/engine/useThree.ts Normal file
View File

@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import type { UniformState } from "./uniformManager";
type UseThreeProps = {
vertexShader: string;
fragmentShader: string;
uniforms: UniformState;
};
export function useThree({ vertexShader, fragmentShader, uniforms }: UseThreeProps) {
const mountRef = useRef<HTMLDivElement | null>(null);
const materialRef = useRef<THREE.ShaderMaterial | null>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) {
return;
}
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
camera.position.z = 1;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(mount.clientWidth, mount.clientHeight);
mount.innerHTML = "";
mount.appendChild(renderer.domElement);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
uniforms
});
materialRef.current = material;
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
let rafId = 0;
const start = performance.now();
const animate = () => {
uniforms.u_time.value = (performance.now() - start) / 1000;
renderer.render(scene, camera);
rafId = requestAnimationFrame(animate);
};
const onResize = () => {
renderer.setSize(mount.clientWidth, mount.clientHeight);
};
window.addEventListener("resize", onResize);
animate();
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", onResize);
geometry.dispose();
material.dispose();
renderer.dispose();
mount.innerHTML = "";
};
}, [fragmentShader, uniforms, vertexShader]);
return { mountRef, materialRef };
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

11
src/shaders/base.vert Normal file
View File

@@ -0,0 +1,11 @@
precision mediump float;
attribute vec3 position;
attribute vec2 uv;
varying vec2 v_uv;
void main() {
v_uv = uv;
gl_Position = vec4(position, 1.0);
}

View File

@@ -0,0 +1,3 @@
float applyAlpha(float value, float intensity) {
return clamp(value * intensity, 0.0, 1.0);
}

View File

@@ -0,0 +1,18 @@
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 78.233);
return fract(p.x * p.y);
}
float noise2d(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}

View File

@@ -0,0 +1,4 @@
vec2 scrollUV(vec2 uv, float time, float speed) {
uv.y += time * speed * 0.12;
return uv;
}

View File

@@ -0,0 +1,22 @@
import type { EffectTemplate } from "./types";
const distortionTemplate: EffectTemplate = {
name: "distortion",
modules: ["noise", "alpha"],
defaultParams: {
speed: 1.2,
scale: 5.0,
intensity: 1.0
},
fragmentLogic: `
vec2 uv = v_uv;
float offset = noise2d(uv * u_scale + u_time * u_speed) - 0.5;
uv += vec2(offset * 0.18, offset * 0.1);
float shade = noise2d(uv * (u_scale * 0.7) + 11.3);
vec3 color = mix(vec3(0.05, 0.05, 0.08), vec3(0.8, 0.95, 1.0), shade);
float alpha = applyAlpha(0.65 + shade * 0.35, u_intensity);
gl_FragColor = vec4(color, alpha);
`
};
export default distortionTemplate;

View File

@@ -0,0 +1,21 @@
import type { EffectTemplate } from "./types";
const fireTemplate: EffectTemplate = {
name: "fire",
modules: ["noise", "uv_scroll", "alpha"],
defaultParams: {
speed: 1.0,
scale: 3.0,
intensity: 1.0
},
fragmentLogic: `
vec2 uv = scrollUV(v_uv, u_time, u_speed);
float n = noise2d(uv * u_scale + vec2(0.0, u_time * 0.6));
float flame = smoothstep(0.2, 1.0, n + (1.0 - uv.y) * 0.8);
vec3 color = mix(vec3(0.85, 0.1, 0.02), vec3(1.0, 0.75, 0.08), flame);
float alpha = applyAlpha(flame, u_intensity);
gl_FragColor = vec4(color, alpha);
`
};
export default fireTemplate;

View File

@@ -0,0 +1,7 @@
import distortion from "./distortion";
import fire from "./fire";
import ripple from "./ripple";
export const templates = [fire, ripple, distortion];
export const templateMap = new Map(templates.map((t) => [t.name, t]));

View File

@@ -0,0 +1,21 @@
import type { EffectTemplate } from "./types";
const rippleTemplate: EffectTemplate = {
name: "ripple",
modules: ["uv_scroll", "alpha"],
defaultParams: {
speed: 0.8,
scale: 8.0,
intensity: 0.85
},
fragmentLogic: `
vec2 uv = v_uv - 0.5;
float dist = length(uv);
float wave = sin((dist * u_scale - u_time * u_speed * 3.0) * 6.2831) * 0.5 + 0.5;
vec3 color = mix(vec3(0.05, 0.2, 0.5), vec3(0.4, 0.85, 1.0), wave);
float alpha = applyAlpha(1.0 - dist * 1.6, u_intensity);
gl_FragColor = vec4(color, alpha);
`
};
export default rippleTemplate;

View File

@@ -0,0 +1,12 @@
export type EffectParams = {
speed: number;
scale: number;
intensity: number;
};
export type EffectTemplate = {
name: string;
modules: Array<"noise" | "uv_scroll" | "alpha">;
defaultParams: EffectParams;
fragmentLogic: string;
};

208
src/styles.css Normal file
View File

@@ -0,0 +1,208 @@
:root {
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.45;
color: #eef3ff;
background: #0d1119;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at 20% 0%, #23385c 0%, #0d1119 45%, #070a11 100%);
}
.app-shell {
width: min(95vw, 1440px);
margin: 0 auto;
min-height: 100vh;
padding: 18px 0 22px;
display: grid;
gap: 14px;
}
.topbar {
padding: 0 4px;
}
.topbar h1 {
margin: 0;
font-size: 26px;
letter-spacing: 0.2px;
}
.topbar p {
margin: 4px 0 0;
color: #9db1d4;
}
.content-grid {
display: grid;
grid-template-columns: 1.7fr 1fr;
gap: 14px;
align-items: start;
}
.left-column {
display: grid;
gap: 12px;
}
.card {
background: rgba(12, 20, 35, 0.82);
border: 1px solid rgba(116, 156, 212, 0.25);
border-radius: 12px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24);
}
.player {
overflow: hidden;
}
.preview-canvas {
width: 100%;
aspect-ratio: 16 / 9;
min-height: 260px;
}
.player-bar {
border-top: 1px solid rgba(124, 166, 225, 0.2);
padding: 10px 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.status-text {
color: #9fb4d8;
font-size: 12px;
}
.player-actions {
display: flex;
gap: 8px;
}
.editor-shell {
padding: 10px;
display: grid;
gap: 10px;
}
.pass-manager {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.pass-tab,
button {
border: none;
border-radius: 8px;
color: #eaf2ff;
background: linear-gradient(90deg, #2f67b3, #2f8dc1);
padding: 8px 12px;
cursor: pointer;
font: inherit;
white-space: nowrap;
}
.pass-tab {
background: #2d3e5d;
border: 1px solid rgba(132, 168, 221, 0.24);
}
.pass-tab.active {
background: #f39a34;
color: #141a27;
font-weight: 700;
border-color: transparent;
}
button:hover {
filter: brightness(1.08);
}
button.secondary {
background: #1a273d;
border: 1px solid rgba(132, 168, 221, 0.24);
}
button.small {
padding: 7px 10px;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.field {
display: grid;
gap: 6px;
}
.field span {
color: #a6bddf;
font-size: 12px;
}
.shader-editor textarea {
width: 100%;
min-height: 320px;
resize: vertical;
}
.editor-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.right-column {
padding: 14px;
display: grid;
gap: 10px;
}
.right-column h3 {
margin: 0;
}
input,
select,
textarea {
border: 1px solid rgba(126, 165, 221, 0.32);
border-radius: 8px;
color: #ecf2fd;
background: rgba(16, 29, 50, 0.9);
padding: 8px;
font: inherit;
}
.slider-row {
display: grid;
grid-template-columns: 64px 1fr 56px;
align-items: center;
gap: 10px;
}
.slider-row strong {
text-align: right;
color: #bad2f4;
font-size: 12px;
}
@media (max-width: 1100px) {
.content-grid {
grid-template-columns: 1fr;
}
.preview-canvas {
min-height: 220px;
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

448
test.html Normal file
View File

@@ -0,0 +1,448 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shader Preview Builder</title>
<style>
:root {
color-scheme: dark;
--bg: #0c1018;
--card: #141b27;
--line: #253147;
--text: #e8edf7;
--muted: #96a6c4;
--accent: #4fa3ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px/1.4 "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: radial-gradient(circle at 20% 10%, #1a2740, var(--bg) 55%);
}
.app {
max-width: 1240px;
margin: 0 auto;
padding: 16px;
display: grid;
gap: 12px;
}
.title {
margin: 0;
font-size: 20px;
}
.desc {
margin: 4px 0 0;
color: var(--muted);
}
.layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
}
.card {
background: color-mix(in oklab, var(--card) 88%, black 12%);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.preview-wrap {
position: relative;
}
#glCanvas {
width: 100%;
aspect-ratio: 16 / 9;
display: block;
background: #000;
}
.preview-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-top: 1px solid var(--line);
}
.preview-meta {
color: var(--muted);
font-size: 12px;
}
.panel {
padding: 12px;
display: grid;
gap: 10px;
align-content: start;
}
.row {
display: grid;
gap: 6px;
}
.row > label {
color: var(--muted);
font-size: 12px;
}
input,
select,
textarea,
button {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: #0f1624;
color: var(--text);
padding: 8px;
font: inherit;
}
textarea {
min-height: 200px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
button {
cursor: pointer;
background: linear-gradient(90deg, #2d69c7, #2ea4db);
border: none;
font-weight: 600;
}
button.secondary {
background: #1d2739;
border: 1px solid var(--line);
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="app">
<header>
<h1 class="title">Shader 预览与交互结构</h1>
<p class="desc">仅保留预览和交互,方便快速构建。</p>
</header>
<section class="layout">
<article class="card preview-wrap">
<canvas id="glCanvas" width="1280" height="720"></canvas>
<div class="preview-bar">
<span class="preview-meta" id="fpsText">FPS: --</span>
<div class="two" style="width: 220px">
<button class="secondary" id="toggleBtn" type="button">暂停</button>
<button id="fullscreenBtn" type="button">全屏</button>
</div>
</div>
</article>
<aside class="card panel">
<div class="row">
<label for="template">模板</label>
<select id="template">
<option value="fire">Fire</option>
<option value="ripple">Ripple</option>
<option value="distort">Distort</option>
</select>
</div>
<div class="row">
<label for="speed">速度 <span id="speedVal">1.00</span></label>
<input id="speed" type="range" min="0" max="3" step="0.01" value="1" />
</div>
<div class="row">
<label for="scale">缩放 <span id="scaleVal">4.00</span></label>
<input id="scale" type="range" min="0.5" max="12" step="0.1" value="4" />
</div>
<div class="row">
<label for="intensity">强度 <span id="intensityVal">1.00</span></label>
<input id="intensity" type="range" min="0" max="2" step="0.01" value="1" />
</div>
<div class="row">
<label for="fragCode">Fragment Shader可编辑</label>
<textarea id="fragCode"></textarea>
</div>
<div class="two">
<button type="button" id="applyBtn">应用 Shader</button>
<button type="button" class="secondary" id="resetBtn">重置模板</button>
</div>
</aside>
</section>
</main>
<script>
const canvas = document.getElementById("glCanvas");
const gl = canvas.getContext("webgl");
const speed = document.getElementById("speed");
const scale = document.getElementById("scale");
const intensity = document.getElementById("intensity");
const speedVal = document.getElementById("speedVal");
const scaleVal = document.getElementById("scaleVal");
const intensityVal = document.getElementById("intensityVal");
const fragCode = document.getElementById("fragCode");
const template = document.getElementById("template");
const fpsText = document.getElementById("fpsText");
const toggleBtn = document.getElementById("toggleBtn");
if (!gl) {
alert("当前浏览器不支持 WebGL");
}
const vertexSource = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`;
const templates = {
fire: `
precision mediump float;
varying vec2 v_uv;
uniform float u_time;
uniform float u_speed;
uniform float u_scale;
uniform float u_intensity;
float hash21(vec2 p){ p = fract(p*vec2(123.34,456.21)); p += dot(p,p+78.23); return fract(p.x*p.y); }
float noise2d(vec2 p){
vec2 i=floor(p), f=fract(p);
float a=hash21(i), b=hash21(i+vec2(1.,0.)), c=hash21(i+vec2(0.,1.)), d=hash21(i+vec2(1.,1.));
vec2 u=f*f*(3.-2.*f);
return mix(a,b,u.x)+(c-a)*u.y*(1.-u.x)+(d-b)*u.x*u.y;
}
void main(){
vec2 uv=v_uv;
uv.y += u_time*u_speed*0.12;
float n=noise2d(uv*u_scale+vec2(0.,u_time*0.6));
float f=smoothstep(0.2,1.0,n+(1.0-uv.y)*0.8);
vec3 col=mix(vec3(0.85,0.1,0.02),vec3(1.0,0.75,0.08),f);
gl_FragColor=vec4(col, clamp(f*u_intensity,0.0,1.0));
}`,
ripple: `
precision mediump float;
varying vec2 v_uv;
uniform float u_time;
uniform float u_speed;
uniform float u_scale;
uniform float u_intensity;
void main(){
vec2 uv=v_uv-0.5;
float d=length(uv);
float w=sin((d*u_scale-u_time*u_speed*3.0)*6.2831)*0.5+0.5;
vec3 col=mix(vec3(0.05,0.2,0.5),vec3(0.4,0.85,1.0),w);
float a=clamp((1.0-d*1.6)*u_intensity,0.0,1.0);
gl_FragColor=vec4(col,a);
}`,
distort: `
precision mediump float;
varying vec2 v_uv;
uniform float u_time;
uniform float u_speed;
uniform float u_scale;
uniform float u_intensity;
float hash21(vec2 p){ p = fract(p*vec2(123.34,456.21)); p += dot(p,p+78.23); return fract(p.x*p.y); }
float noise2d(vec2 p){
vec2 i=floor(p), f=fract(p);
float a=hash21(i), b=hash21(i+vec2(1.,0.)), c=hash21(i+vec2(0.,1.)), d=hash21(i+vec2(1.,1.));
vec2 u=f*f*(3.-2.*f);
return mix(a,b,u.x)+(c-a)*u.y*(1.-u.x)+(d-b)*u.x*u.y;
}
void main(){
vec2 uv=v_uv;
float off=noise2d(uv*u_scale+u_time*u_speed)-0.5;
uv += vec2(off*0.18,off*0.1);
float shade=noise2d(uv*(u_scale*0.7)+11.3);
vec3 col=mix(vec3(0.05,0.05,0.08),vec3(0.8,0.95,1.0),shade);
gl_FragColor=vec4(col, clamp((0.65+shade*0.35)*u_intensity,0.0,1.0));
}`
};
function createShader(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);
}
return shader;
}
function createProgram(vsSource, fsSource) {
const vs = createShader(gl.VERTEX_SHADER, vsSource);
const fs = createShader(gl.FRAGMENT_SHADER, fsSource);
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);
}
return program;
}
let program;
let loc = {};
function buildProgram(fragmentSource) {
const next = createProgram(vertexSource, fragmentSource);
if (program) gl.deleteProgram(program);
program = next;
loc = {
a_pos: gl.getAttribLocation(program, "a_pos"),
u_time: gl.getUniformLocation(program, "u_time"),
u_speed: gl.getUniformLocation(program, "u_speed"),
u_scale: gl.getUniformLocation(program, "u_scale"),
u_intensity: gl.getUniformLocation(program, "u_intensity")
};
}
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
function loadTemplate(name) {
fragCode.value = templates[name];
}
function updateLabels() {
speedVal.textContent = Number(speed.value).toFixed(2);
scaleVal.textContent = Number(scale.value).toFixed(2);
intensityVal.textContent = Number(intensity.value).toFixed(2);
}
template.addEventListener("change", () => {
loadTemplate(template.value);
applyShader();
});
[speed, scale, intensity].forEach((el) => el.addEventListener("input", updateLabels));
function applyShader() {
try {
buildProgram(fragCode.value);
} catch (err) {
alert("Shader 编译失败:\n" + err.message);
}
}
document.getElementById("applyBtn").addEventListener("click", applyShader);
document.getElementById("resetBtn").addEventListener("click", () => {
speed.value = "1";
scale.value = "4";
intensity.value = "1";
template.value = "fire";
loadTemplate("fire");
updateLabels();
applyShader();
});
document.getElementById("fullscreenBtn").addEventListener("click", () => {
if (canvas.requestFullscreen) canvas.requestFullscreen();
});
let playing = true;
toggleBtn.addEventListener("click", () => {
playing = !playing;
toggleBtn.textContent = playing ? "暂停" : "继续";
});
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
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);
}
}
window.addEventListener("resize", resize);
let start = performance.now();
let prev = start;
let frame = 0;
let acc = 0;
function draw(now) {
resize();
const dt = now - prev;
prev = now;
frame += 1;
acc += dt;
if (acc >= 500) {
fpsText.textContent = `FPS: ${(1000 * frame / acc).toFixed(1)}`;
frame = 0;
acc = 0;
}
if (playing) {
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.enableVertexAttribArray(loc.a_pos);
gl.vertexAttribPointer(loc.a_pos, 2, gl.FLOAT, false, 0, 0);
gl.uniform1f(loc.u_time, (now - start) / 1000);
gl.uniform1f(loc.u_speed, Number(speed.value));
gl.uniform1f(loc.u_scale, Number(scale.value));
gl.uniform1f(loc.u_intensity, Number(intensity.value));
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
requestAnimationFrame(draw);
}
loadTemplate("fire");
updateLabels();
applyShader();
requestAnimationFrame(draw);
</script>
</body>
</html>

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"baseUrl": "."
},
"include": ["src"]
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});