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>
|
||||
);
|
||||
}
|
||||
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" />
|
||||
Reference in New Issue
Block a user