feat:新增文件内容
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user