429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import * as THREE from "three";
|
|
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 EffectItem = {
|
|
id: string;
|
|
name: string;
|
|
templateName: string;
|
|
params: EffectParams;
|
|
shaderDraft: string;
|
|
shaderApplied: string;
|
|
};
|
|
|
|
function makeEffect(templateName: string, serial: number): EffectItem {
|
|
const template = templateMap.get(templateName) ?? templates[0];
|
|
const shader = buildShader(template, template.defaultParams);
|
|
return {
|
|
id: `effect-${Date.now()}-${Math.floor(Math.random() * 10000)}`,
|
|
name: `effect_${serial}`,
|
|
templateName: template.name,
|
|
params: { ...template.defaultParams },
|
|
shaderDraft: shader,
|
|
shaderApplied: shader
|
|
};
|
|
}
|
|
|
|
export default function App() {
|
|
const channelIndices = [0, 1, 2, 3] as const;
|
|
const channelKeys = ["iChannel0", "iChannel1", "iChannel2", "iChannel3"] as const;
|
|
const [effects, setEffects] = useState<EffectItem[]>([makeEffect(templates[0].name, 1)]);
|
|
const [activeEffectId, setActiveEffectId] = useState<string>("");
|
|
const [prompt, setPrompt] = useState<string>("hot fire");
|
|
const [isPlaying, setIsPlaying] = useState<boolean>(true);
|
|
const previewWrapRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const activeEffect = useMemo(() => {
|
|
const direct = effects.find((effect) => effect.id === activeEffectId);
|
|
return direct ?? effects[0];
|
|
}, [effects, activeEffectId]);
|
|
|
|
const [uniforms, setUniforms] = useState(() => createUniforms(effects[0].params));
|
|
const [channelTextures, setChannelTextures] = useState<Array<THREE.Texture | null>>([
|
|
null,
|
|
null,
|
|
null,
|
|
null
|
|
]);
|
|
const [channelFileNames, setChannelFileNames] = useState<string[]>(["None", "None", "None", "None"]);
|
|
const channelTexturesRef = useRef<Array<THREE.Texture | null>>([null, null, null, null]);
|
|
|
|
useEffect(() => {
|
|
if (!activeEffectId && effects[0]) {
|
|
setActiveEffectId(effects[0].id);
|
|
}
|
|
}, [activeEffectId, effects]);
|
|
|
|
useEffect(() => {
|
|
if (activeEffect) {
|
|
setUniforms(createUniforms(activeEffect.params));
|
|
}
|
|
}, [activeEffect?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!activeEffect) {
|
|
return;
|
|
}
|
|
updateUniformValues(uniforms, isPlaying ? activeEffect.params : { ...activeEffect.params, speed: 0 });
|
|
}, [uniforms, activeEffect, isPlaying]);
|
|
|
|
useEffect(() => {
|
|
channelTexturesRef.current = channelTextures;
|
|
channelIndices.forEach((idx) => {
|
|
const channelKey = channelKeys[idx];
|
|
const texture = channelTextures[idx];
|
|
uniforms[channelKey].value = texture;
|
|
if (texture && texture.image) {
|
|
const image = texture.image as { width?: number; height?: number };
|
|
const width = image.width ?? 1;
|
|
const height = image.height ?? 1;
|
|
uniforms.iChannelResolution.value[idx].set(width, height, 1);
|
|
} else {
|
|
uniforms.iChannelResolution.value[idx].set(1, 1, 1);
|
|
}
|
|
});
|
|
}, [channelTextures, uniforms]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
channelTexturesRef.current.forEach((texture) => texture?.dispose());
|
|
};
|
|
}, []);
|
|
|
|
const updateActiveEffect = (updater: (current: EffectItem) => EffectItem) => {
|
|
setEffects((prev) => prev.map((effect) => (effect.id === activeEffect.id ? updater(effect) : effect)));
|
|
};
|
|
|
|
const onCreateEffect = () => {
|
|
const next = makeEffect(templates[0].name, effects.length + 1);
|
|
setEffects((prev) => [...prev, next]);
|
|
setActiveEffectId(next.id);
|
|
setUniforms(createUniforms(next.params));
|
|
};
|
|
|
|
const onDeleteEffect = () => {
|
|
if (!activeEffect || effects.length <= 1) {
|
|
return;
|
|
}
|
|
const currentIndex = effects.findIndex((effect) => effect.id === activeEffect.id);
|
|
const nextEffects = effects.filter((effect) => effect.id !== activeEffect.id);
|
|
const fallback = nextEffects[Math.max(0, currentIndex - 1)] ?? nextEffects[0];
|
|
setEffects(nextEffects);
|
|
setActiveEffectId(fallback.id);
|
|
setUniforms(createUniforms(fallback.params));
|
|
};
|
|
|
|
const onSelectTemplate = (templateName: string) => {
|
|
const selected = templateMap.get(templateName);
|
|
if (!selected) {
|
|
return;
|
|
}
|
|
const nextShader = buildShader(selected, selected.defaultParams);
|
|
updateActiveEffect((current) => ({
|
|
...current,
|
|
templateName: selected.name,
|
|
params: { ...selected.defaultParams },
|
|
shaderDraft: nextShader,
|
|
shaderApplied: nextShader
|
|
}));
|
|
setUniforms(createUniforms(selected.defaultParams));
|
|
};
|
|
|
|
const onGenerate = () => {
|
|
const result = generateEffect(prompt);
|
|
const selected = templateMap.get(result.template);
|
|
if (!selected) {
|
|
return;
|
|
}
|
|
const nextShader = buildShader(selected, result.params);
|
|
updateActiveEffect((current) => ({
|
|
...current,
|
|
templateName: selected.name,
|
|
params: { ...result.params },
|
|
shaderDraft: nextShader,
|
|
shaderApplied: nextShader
|
|
}));
|
|
setUniforms(createUniforms(result.params));
|
|
};
|
|
|
|
const onParamChange = (key: keyof EffectParams, value: number) => {
|
|
updateActiveEffect((current) => ({
|
|
...current,
|
|
params: { ...current.params, [key]: value }
|
|
}));
|
|
};
|
|
|
|
const onApplyShader = () => {
|
|
updateActiveEffect((current) => ({ ...current, shaderApplied: current.shaderDraft }));
|
|
};
|
|
|
|
const onExport = () => {
|
|
downloadFile("fragment.glsl", activeEffect.shaderApplied, "text/plain;charset=utf-8");
|
|
downloadFile("vertex.glsl", baseVertexShader, "text/plain;charset=utf-8");
|
|
downloadFile(
|
|
"material.json",
|
|
JSON.stringify(
|
|
{
|
|
type: "ShaderMaterial",
|
|
effect: activeEffect.name,
|
|
template: activeEffect.templateName,
|
|
uniforms: {
|
|
u_time: 0,
|
|
u_speed: activeEffect.params.speed,
|
|
u_scale: activeEffect.params.scale,
|
|
u_intensity: activeEffect.params.intensity
|
|
}
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
"application/json;charset=utf-8"
|
|
);
|
|
};
|
|
|
|
const onFullscreen = () => {
|
|
previewWrapRef.current?.requestFullscreen?.();
|
|
};
|
|
|
|
const onUploadChannelTexture = (channelIndex: number, file: File) => {
|
|
const objectUrl = URL.createObjectURL(file);
|
|
const loader = new THREE.TextureLoader();
|
|
loader.load(
|
|
objectUrl,
|
|
(texture) => {
|
|
texture.needsUpdate = true;
|
|
setChannelTextures((prev) => {
|
|
const next = [...prev];
|
|
next[channelIndex]?.dispose();
|
|
next[channelIndex] = texture;
|
|
return next;
|
|
});
|
|
setChannelFileNames((prev) => {
|
|
const next = [...prev];
|
|
next[channelIndex] = file.name;
|
|
return next;
|
|
});
|
|
URL.revokeObjectURL(objectUrl);
|
|
},
|
|
undefined,
|
|
() => {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
);
|
|
};
|
|
|
|
const onClearChannelTexture = (channelIndex: number) => {
|
|
setChannelTextures((prev) => {
|
|
const next = [...prev];
|
|
next[channelIndex]?.dispose();
|
|
next[channelIndex] = null;
|
|
return next;
|
|
});
|
|
setChannelFileNames((prev) => {
|
|
const next = [...prev];
|
|
next[channelIndex] = "None";
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const onSampleRateChange = (value: number) => {
|
|
uniforms.iSampleRate.value = Number.isFinite(value) && value > 0 ? value : 44100;
|
|
};
|
|
|
|
return (
|
|
<main className="app-shell">
|
|
<header className="topbar">
|
|
<h1>AI VFX Shader Tool</h1>
|
|
</header>
|
|
|
|
<section className="content-grid">
|
|
<section className="left-column">
|
|
<article className="card player" ref={previewWrapRef}>
|
|
<Preview
|
|
vertexShader={baseVertexShader}
|
|
fragmentShader={activeEffect.shaderApplied}
|
|
uniforms={uniforms}
|
|
/>
|
|
<div className="player-bar">
|
|
<span className="status-text">
|
|
Active Effect: <strong>{activeEffect.name}</strong>
|
|
</span>
|
|
<div className="player-actions">
|
|
<button type="button" className="secondary" onClick={() => setIsPlaying((prev) => !prev)}>
|
|
{isPlaying ? "Pause" : "Play"}
|
|
</button>
|
|
<button type="button" onClick={onFullscreen}>
|
|
Fullscreen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="card editor-shell">
|
|
<div className="effect-manager">
|
|
{effects.map((effect) => (
|
|
<button
|
|
key={effect.id}
|
|
type="button"
|
|
className={`effect-tab ${activeEffect.id === effect.id ? "active" : ""}`}
|
|
onClick={() => {
|
|
setActiveEffectId(effect.id);
|
|
setUniforms(createUniforms(effect.params));
|
|
}}
|
|
>
|
|
{effect.name}
|
|
</button>
|
|
))}
|
|
<div className="effect-actions">
|
|
<button type="button" className="small secondary" onClick={onCreateEffect}>
|
|
+ New Effect
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="small secondary"
|
|
onClick={onDeleteEffect}
|
|
disabled={effects.length <= 1}
|
|
>
|
|
Delete Effect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="field shader-editor">
|
|
<span>Fragment Shader (Editable)</span>
|
|
<textarea
|
|
value={activeEffect.shaderDraft}
|
|
rows={16}
|
|
onChange={(e) =>
|
|
updateActiveEffect((current) => ({ ...current, shaderDraft: 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>Effect Settings</h3>
|
|
|
|
<label className="field">
|
|
<span>Effect Name</span>
|
|
<input
|
|
value={activeEffect.name}
|
|
onChange={(e) => updateActiveEffect((current) => ({ ...current, name: e.target.value }))}
|
|
placeholder="输入特效名称"
|
|
/>
|
|
</label>
|
|
|
|
<label className="field">
|
|
<span>Template</span>
|
|
<select value={activeEffect.templateName} onChange={(e) => onSelectTemplate(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={activeEffect.params[key]}
|
|
onChange={(e) => onParamChange(key, Number(e.target.value))}
|
|
/>
|
|
<strong>{activeEffect.params[key].toFixed(2)}</strong>
|
|
</label>
|
|
);
|
|
})}
|
|
|
|
<label className="field">
|
|
<span>iSampleRate</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={uniforms.iSampleRate.value}
|
|
onChange={(e) => onSampleRateChange(Number(e.target.value))}
|
|
/>
|
|
</label>
|
|
|
|
<div className="channel-panel">
|
|
<h4>Shadertoy Channels</h4>
|
|
{channelIndices.map((channelIndex) => (
|
|
<div key={channelIndex} className="channel-row">
|
|
<span>{`iChannel${channelIndex}`}</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
onUploadChannelTexture(channelIndex, file);
|
|
}
|
|
}}
|
|
/>
|
|
<small>{channelFileNames[channelIndex]}</small>
|
|
<button
|
|
type="button"
|
|
className="small secondary"
|
|
onClick={() => onClearChannelTexture(channelIndex)}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|