feat:新增文件内容
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
15
docker-compose.yml
Normal 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
12
index.html
Normal 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
24
package.json
Normal 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
216
readme.md
Normal 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
18
scripts/docker-down.sh
Executable 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
22
scripts/docker-up.sh
Executable 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
280
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/ControlPanel.tsx
Normal file
37
src/components/ControlPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/components/Preview.tsx
Normal file
14
src/components/Preview.tsx
Normal 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} />;
|
||||
}
|
||||
20
src/components/ShaderEditor.tsx
Normal file
20
src/components/ShaderEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/TemplateSelector.tsx
Normal file
47
src/components/TemplateSelector.tsx
Normal 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
29
src/engine/aiMock.ts
Normal 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 }
|
||||
};
|
||||
}
|
||||
31
src/engine/shaderBuilder.ts
Normal file
31
src/engine/shaderBuilder.ts
Normal 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}
|
||||
}
|
||||
`;
|
||||
}
|
||||
27
src/engine/uniformManager.ts
Normal file
27
src/engine/uniformManager.ts
Normal 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
70
src/engine/useThree.ts
Normal 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
10
src/main.tsx
Normal 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
11
src/shaders/base.vert
Normal 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);
|
||||
}
|
||||
3
src/shaders/modules/alpha.glsl
Normal file
3
src/shaders/modules/alpha.glsl
Normal file
@@ -0,0 +1,3 @@
|
||||
float applyAlpha(float value, float intensity) {
|
||||
return clamp(value * intensity, 0.0, 1.0);
|
||||
}
|
||||
18
src/shaders/modules/noise.glsl
Normal file
18
src/shaders/modules/noise.glsl
Normal 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;
|
||||
}
|
||||
4
src/shaders/modules/uv_scroll.glsl
Normal file
4
src/shaders/modules/uv_scroll.glsl
Normal file
@@ -0,0 +1,4 @@
|
||||
vec2 scrollUV(vec2 uv, float time, float speed) {
|
||||
uv.y += time * speed * 0.12;
|
||||
return uv;
|
||||
}
|
||||
22
src/shaders/templates/distortion.ts
Normal file
22
src/shaders/templates/distortion.ts
Normal 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;
|
||||
21
src/shaders/templates/fire.ts
Normal file
21
src/shaders/templates/fire.ts
Normal 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;
|
||||
7
src/shaders/templates/index.ts
Normal file
7
src/shaders/templates/index.ts
Normal 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]));
|
||||
21
src/shaders/templates/ripple.ts
Normal file
21
src/shaders/templates/ripple.ts
Normal 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;
|
||||
12
src/shaders/templates/types.ts
Normal file
12
src/shaders/templates/types.ts
Normal 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
208
src/styles.css
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
448
test.html
Normal file
448
test.html
Normal 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
18
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
});
|
||||
Reference in New Issue
Block a user