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>
);
}