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

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" />