Files
fn_registry/cpp/apps/shaders_lab/extracted/shader-dag-lab.jsx
T
2026-05-11 16:30:43 +02:00

879 lines
36 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { X, AlertCircle, RotateCcw, ChevronDown, ChevronRight, Trash2, GripVertical } from 'lucide-react';
// ═══════════════════════════════════════════════════════════════════
// CATÁLOGO DE NODOS — cada uno emite un snippet WGSL
// Convención: cada nodo compila a `fn node_<idx>(c, uv) -> vec4<f32>`
// Parámetros: hasta 4 floats empaquetados en u.params[idx] (vec4<f32>)
// ═══════════════════════════════════════════════════════════════════
const MAX_NODES = 16;
const ACCENT = '#5eead4';
const GEN_COLOR = '#5eead4';
const OP_COLOR = '#c4b5fd';
const NODES = {
// ── GENERADORES (ignoran c, producen nuevo color) ──
solid: {
kind: 'gen', label: 'solid', desc: 'color constante',
params: [
{ k: 'r', label: 'r', min: 0, max: 1, d: 0.35, step: 0.01 },
{ k: 'g', label: 'g', min: 0, max: 1, d: 0.25, step: 0.01 },
{ k: 'b', label: 'b', min: 0, max: 1, d: 0.55, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(p.x, p.y, p.z, 1.0);`,
},
gradient: {
kind: 'gen', label: 'gradient', desc: 'gradiente en ángulo',
params: [
{ k: 'angle', label: 'ángulo', min: 0, max: 6.2832, d: 0.8, step: 0.01 },
{ k: 'hue', label: 'tono', min: 0, max: 1, d: 0.5, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let dir = vec2<f32>(cos(p.x), sin(p.x));
let t = dot(uv - 0.5, dir) + 0.5;
let col = 0.5 + 0.5 * cos(6.28318 * (p.y + vec3<f32>(0.0, 0.33, 0.67) + t));
return vec4<f32>(col, 1.0);`,
},
plasma: {
kind: 'gen', label: 'plasma', desc: 'onda trigonométrica',
params: [
{ k: 'speed', label: 'velocidad', min: 0, max: 3, d: 1, step: 0.01 },
{ k: 'scale', label: 'escala', min: 0.5, max: 10, d: 2, step: 0.1 },
],
body: (i) => `
let p = u.params[${i}];
let t = u.time * p.x;
let col = 0.5 + 0.5 * cos(t + uv.xyx * p.y + vec3<f32>(0.0, 2.0, 4.0));
return vec4<f32>(col, 1.0);`,
},
checker: {
kind: 'gen', label: 'checker', desc: 'tablero rotando',
params: [
{ k: 'scale', label: 'escala', min: 1, max: 30, d: 8, step: 0.5 },
{ k: 'rot', label: 'rotación', min: -2, max: 2, d: 0.25, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let aspect = u.resolution.x / u.resolution.y;
let q0 = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);
let a = u.time * p.y;
let rm = mat2x2<f32>(cos(a), -sin(a), sin(a), cos(a));
let q = rm * q0 * p.x;
let chk = (floor(q.x) + floor(q.y)) - 2.0 * floor((floor(q.x) + floor(q.y)) * 0.5);
return vec4<f32>(vec3<f32>(chk), 1.0);`,
},
circle: {
kind: 'gen', label: 'circle', desc: 'sdf de círculo',
params: [
{ k: 'radius', label: 'radio', min: 0, max: 1, d: 0.4, step: 0.01 },
{ k: 'soft', label: 'suavidad', min: 0.001, max: 0.1, d: 0.008, step: 0.001 },
{ k: 'pulse', label: 'pulso', min: 0, max: 1, d: 0.1, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let aspect = u.resolution.x / u.resolution.y;
let pos = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);
let r = p.x + p.z * 0.15 * sin(u.time * 2.0);
let d = length(pos) - r;
let fill = smoothstep(p.y, -p.y, d);
return mix(c, vec4<f32>(1.0), fill);`,
},
stripes: {
kind: 'gen', label: 'stripes', desc: 'rayas animadas',
params: [
{ k: 'freq', label: 'frecuencia', min: 1, max: 80, d: 20, step: 0.5 },
{ k: 'speed', label: 'velocidad', min: -5, max: 5, d: 1, step: 0.05 },
{ k: 'angle', label: 'ángulo', min: 0, max: 3.1416, d: 0.5, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let dir = vec2<f32>(cos(p.z), sin(p.z));
let x = dot(uv, dir);
let v = 0.5 + 0.5 * sin(x * p.x + u.time * p.y);
return vec4<f32>(vec3<f32>(v), 1.0);`,
},
noise: {
kind: 'gen', label: 'noise', desc: 'hash pseudo-aleatorio',
params: [
{ k: 'scale', label: 'escala', min: 1, max: 200, d: 80, step: 1 },
{ k: 'seed', label: 'seed', min: 0, max: 100, d: 7, step: 1 },
],
body: (i) => `
let p = u.params[${i}];
let q = floor(uv * p.x + p.y);
let h = fract(sin(dot(q, vec2<f32>(12.9898, 78.233))) * 43758.5453);
return vec4<f32>(vec3<f32>(h), 1.0);`,
},
// ── OPERADORES (transforman c) ──
invert: {
kind: 'op', label: 'invert', desc: '1 rgb',
params: [],
body: () => `
return vec4<f32>(1.0 - c.rgb, c.a);`,
},
gamma: {
kind: 'op', label: 'gamma', desc: 'pow(rgb, γ)',
params: [
{ k: 'g', label: 'γ', min: 0.1, max: 5, d: 1, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let g = max(0.01, p.x);
return vec4<f32>(pow(max(c.rgb, vec3<f32>(0.0)), vec3<f32>(g)), c.a);`,
},
brightness: {
kind: 'op', label: 'brightness', desc: 'rgb + v',
params: [
{ k: 'v', label: 'valor', min: -1, max: 1, d: 0, step: 0.01 },
],
body: (i) => `
return vec4<f32>(c.rgb + vec3<f32>(u.params[${i}].x), c.a);`,
},
contrast: {
kind: 'op', label: 'contrast', desc: '(rgb 0.5)·k + 0.5',
params: [
{ k: 'k', label: 'k', min: 0, max: 3, d: 1, step: 0.01 },
],
body: (i) => `
return vec4<f32>((c.rgb - vec3<f32>(0.5)) * u.params[${i}].x + vec3<f32>(0.5), c.a);`,
},
saturate: {
kind: 'op', label: 'saturate', desc: 'lerp(luma, rgb, s)',
params: [
{ k: 's', label: 's', min: 0, max: 2, d: 1, step: 0.01 },
],
body: (i) => `
let luma = dot(c.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
return vec4<f32>(mix(vec3<f32>(luma), c.rgb, u.params[${i}].x), c.a);`,
},
hueShift: {
kind: 'op', label: 'hue shift', desc: 'rotar matiz',
params: [
{ k: 'h', label: 'h', min: 0, max: 1, d: 0, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let k = vec3<f32>(0.57735);
let ca = cos(p.x * 6.2832);
let sa = sin(p.x * 6.2832);
let rot = c.rgb * ca + cross(k, c.rgb) * sa + k * dot(k, c.rgb) * (1.0 - ca);
return vec4<f32>(rot, c.a);`,
},
tint: {
kind: 'op', label: 'tint', desc: 'rgb × tinte',
params: [
{ k: 'r', label: 'r', min: 0, max: 2, d: 1, step: 0.01 },
{ k: 'g', label: 'g', min: 0, max: 2, d: 1, step: 0.01 },
{ k: 'b', label: 'b', min: 0, max: 2, d: 1, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(c.rgb * vec3<f32>(p.x, p.y, p.z), c.a);`,
},
posterize: {
kind: 'op', label: 'posterize', desc: 'cuantizar a N niveles',
params: [
{ k: 'levels', label: 'niveles', min: 2, max: 16, d: 5, step: 1 },
],
body: (i) => `
let n = max(2.0, u.params[${i}].x);
return vec4<f32>(floor(c.rgb * n) / n, c.a);`,
},
vignette: {
kind: 'op', label: 'vignette', desc: 'oscurecer bordes',
params: [
{ k: 'strength', label: 'fuerza', min: 0, max: 2, d: 1, step: 0.01 },
{ k: 'radius', label: 'radio', min: 0, max: 1.4, d: 0.5, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let d = length(uv - vec2<f32>(0.5));
let v = 1.0 - smoothstep(p.y, p.y + 0.3, d) * p.x;
return vec4<f32>(c.rgb * v, c.a);`,
},
ripple: {
kind: 'op', label: 'ripple', desc: 'modular brillo con ondas',
params: [
{ k: 'freq', label: 'frecuencia', min: 1, max: 100, d: 30, step: 1 },
{ k: 'amp', label: 'amplitud', min: 0, max: 1, d: 0.2, step: 0.01 },
{ k: 'speed', label: 'velocidad', min: -5, max: 5, d: 2, step: 0.05 },
],
body: (i) => `
let p = u.params[${i}];
let d = length(uv - vec2<f32>(0.5));
let w = sin(d * p.x - u.time * p.z) * p.y;
return vec4<f32>(c.rgb * (1.0 + w), c.a);`,
},
pulse: {
kind: 'op', label: 'pulse', desc: 'multiplicar por onda',
params: [
{ k: 'freq', label: 'frecuencia', min: 0, max: 10, d: 2, step: 0.05 },
{ k: 'amount', label: 'cantidad', min: 0, max: 1, d: 0.3, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(c.rgb * (1.0 + p.y * sin(u.time * p.x)), c.a);`,
},
};
const NODES_BY_KIND = {
gen: Object.entries(NODES).filter(([, v]) => v.kind === 'gen').map(([k, v]) => ({ name: k, ...v })),
op: Object.entries(NODES).filter(([, v]) => v.kind === 'op' ).map(([k, v]) => ({ name: k, ...v })),
};
// ═══════════════════════════════════════════════════════════════════
// Compilador: DAG → WGSL
// ═══════════════════════════════════════════════════════════════════
function compileDagToWGSL(pipeline) {
const safePipeline = pipeline.slice(0, MAX_NODES);
const fns = safePipeline.map((step, idx) => {
const def = NODES[step.name];
return `fn node_${idx}(c: vec4<f32>, uv: vec2<f32>) -> vec4<f32> {${def.body(idx)}
}`;
}).join('\n\n');
const chain = safePipeline.length === 0
? ' // pipeline vacío · fondo por defecto\n c = vec4<f32>(0.04, 0.04, 0.06, 1.0);'
: safePipeline.map((_, idx) => ` c = node_${idx}(c, uv);`).join('\n');
return `struct Uniforms {
time: f32,
_pad: f32,
resolution: vec2<f32>,
params: array<vec4<f32>, ${MAX_NODES}>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(p[i], 0.0, 1.0);
}
${fns}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
var c = vec4<f32>(0.0, 0.0, 0.0, 1.0);
${chain}
return c;
}
`;
}
// ═══════════════════════════════════════════════════════════════════
// Uniform buffer: escribe los valores actuales de params + time/res
// ═══════════════════════════════════════════════════════════════════
const UNIFORM_FLOATS = 4 + MAX_NODES * 4; // header (4) + params (MAX_NODES × 4)
const UNIFORM_BYTES = UNIFORM_FLOATS * 4; // 272 bytes
function writeUniforms(device, buffer, time, width, height, pipeline) {
const data = new Float32Array(UNIFORM_FLOATS);
data[0] = time;
data[1] = 0;
data[2] = width;
data[3] = height;
for (let i = 0; i < Math.min(pipeline.length, MAX_NODES); i++) {
const step = pipeline[i];
const def = NODES[step.name];
const offset = 4 + i * 4;
for (let j = 0; j < 4; j++) {
const p = def.params[j];
data[offset + j] = p ? (step.params[p.k] ?? p.d) : 0;
}
}
device.queue.writeBuffer(buffer, 0, data);
}
// ═══════════════════════════════════════════════════════════════════
// Hook WebGPU + compilación de DAG
// ═══════════════════════════════════════════════════════════════════
function useWebGPUDag(canvasRef, pipeline, topologyKey) {
const gpu = useRef({
device: null, context: null, format: null,
pipeline: null, bindGroup: null, uniformBuffer: null,
startTime: 0,
});
const pipelineRef = useRef(pipeline);
useEffect(() => { pipelineRef.current = pipeline; }, [pipeline]);
const [status, setStatus] = useState('init');
const [shaderError, setShaderError] = useState(null);
const [fps, setFps] = useState(0);
// ── Init GPU ──
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!navigator.gpu) { setStatus('unsupported'); return; }
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { setStatus('unsupported'); return; }
const device = await adapter.requestDevice();
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = 400; canvas.height = 400;
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
const uniformBuffer = device.createBuffer({
size: UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
gpu.current = {
...gpu.current, device, context, format, uniformBuffer,
startTime: performance.now(),
};
device.lost.then(() => setStatus('error'));
setStatus('ready');
} catch (e) {
console.error(e);
setShaderError(String(e.message || e));
setStatus('error');
}
})();
return () => { cancelled = true; };
}, [canvasRef]);
// ── Recompilar shader (solo en cambios de topología) ──
const compileShader = useCallback(async (wgsl) => {
const { device, format, uniformBuffer } = gpu.current;
if (!device) return;
device.pushErrorScope('validation');
const module = device.createShaderModule({ code: wgsl });
const info = await module.getCompilationInfo();
const errors = info.messages.filter(m => m.type === 'error');
if (errors.length > 0) {
setShaderError(errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n'));
await device.popErrorScope();
return;
}
try {
const p = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive:{ topology: 'triangle-list' },
});
const bg = device.createBindGroup({
layout: p.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
});
gpu.current.pipeline = p;
gpu.current.bindGroup = bg;
setShaderError(null);
} catch (e) {
setShaderError(String(e.message || e));
}
const err = await device.popErrorScope();
if (err) setShaderError(err.message);
}, []);
useEffect(() => {
if (status !== 'ready') return;
const wgsl = compileDagToWGSL(pipelineRef.current);
compileShader(wgsl);
}, [topologyKey, status, compileShader]);
// ── Render loop (lee params actuales cada frame via ref) ──
useEffect(() => {
if (status !== 'ready') return;
let running = true;
let frames = 0;
let lastFpsSample = performance.now();
const loop = () => {
if (!running) return;
const { device, context, pipeline: gpuPipe, bindGroup, uniformBuffer, startTime } = gpu.current;
const canvas = canvasRef.current;
if (device && context && gpuPipe && bindGroup && canvas) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
}
const t = (performance.now() - startTime) / 1000;
writeUniforms(device, uniformBuffer, t, canvas.width, canvas.height, pipelineRef.current);
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear', storeOp: 'store',
}],
});
pass.setPipeline(gpuPipe);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
frames++;
const now = performance.now();
if (now - lastFpsSample >= 500) {
setFps(Math.round((frames * 1000) / (now - lastFpsSample)));
frames = 0; lastFpsSample = now;
}
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
return () => { running = false; };
}, [status, canvasRef]);
const resetTime = useCallback(() => {
gpu.current.startTime = performance.now();
}, []);
return { status, shaderError, fps, resetTime };
}
// ═══════════════════════════════════════════════════════════════════
// Utilidades
// ═══════════════════════════════════════════════════════════════════
let _uid = 0;
const uid = () => `n${++_uid}_${Date.now().toString(36)}`;
function defaultParams(name) {
const d = NODES[name];
return Object.fromEntries((d.params || []).map(p => [p.k, p.d]));
}
// ═══════════════════════════════════════════════════════════════════
// Sub-componentes
// ═══════════════════════════════════════════════════════════════════
function PaletteItem({ node, color, onDragStart, onClick }) {
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/node-name', node.name);
e.dataTransfer.effectAllowed = 'copy';
onDragStart?.();
}}
onClick={onClick}
className="group flex items-center gap-2 px-2 py-1 rounded cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-neutral-800/60"
style={{ borderLeft: `2px solid ${color}` }}
title={node.desc}
>
<span className="font-mono text-[11px] font-medium flex-1" style={{ color }}>{node.label}</span>
<span className="font-mono text-[8px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity uppercase tracking-wider">drag</span>
</div>
);
}
function ParamSlider({ param, value, onChange, color }) {
const display = param.step >= 1 ? Math.round(value) : Number(value).toFixed(param.step < 0.1 ? 3 : 2);
return (
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{param.label}</span>
<input
type="range"
min={param.min} max={param.max} step={param.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1"
style={{ accentColor: color, minWidth: '60px' }}
/>
<span className="font-mono text-[10px] text-neutral-500 w-10 text-right tabular-nums shrink-0">{display}</span>
</div>
);
}
function PipelineNode({ step, index, onRemove, onParamChange, onDragStart, onDrop, isDragging }) {
const def = NODES[step.name];
const color = def.kind === 'gen' ? GEN_COLOR : OP_COLOR;
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/reorder-index', String(index));
e.dataTransfer.effectAllowed = 'move';
onDragStart?.(index);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop?.(e, index)}
className="rounded-lg transition-all"
style={{
background: `linear-gradient(180deg, ${color}14 0%, ${color}06 100%)`,
border: `1px solid ${color}30`,
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-2 px-2.5 py-1.5 border-b" style={{ borderColor: `${color}20` }}>
<GripVertical size={11} className="text-neutral-600 cursor-grab shrink-0" />
<span className="font-mono text-[9px] uppercase tracking-wider text-neutral-500 shrink-0">
{def.kind} · {index}
</span>
<span className="font-mono text-xs font-semibold flex-1 truncate" style={{ color }}>{def.label}</span>
<button
onClick={() => onRemove(index)}
className="text-neutral-500 hover:text-neutral-200 p-0.5"
aria-label="Eliminar"
>
<X size={11} />
</button>
</div>
{def.params.length > 0 && (
<div className="px-2.5 py-2 flex flex-col gap-1.5">
{def.params.map(p => (
<ParamSlider
key={p.k}
param={p}
value={step.params[p.k] ?? p.d}
onChange={(v) => onParamChange(index, p.k, v)}
color={color}
/>
))}
</div>
)}
{def.params.length === 0 && (
<div className="px-2.5 py-1.5 font-mono text-[9px] text-neutral-600 italic">sin parámetros</div>
)}
</div>
);
}
function StatusBadge({ status }) {
const map = {
init: { color: '#fbbf24', label: 'inicializando' },
ready: { color: ACCENT, label: 'activo' },
unsupported: { color: '#f43f5e', label: 'sin webgpu' },
error: { color: '#f43f5e', label: 'error' },
};
const s = map[status] || map.init;
return (
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: s.color, boxShadow: `0 0 8px ${s.color}` }} />
<span className="font-mono text-[10px]" style={{ color: s.color }}>{s.label}</span>
</span>
);
}
function StatusOverlay({ status, error }) {
if (status === 'ready') return null;
return (
<div className="absolute inset-0 flex items-center justify-center p-6 text-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
{status === 'init' && <div className="font-mono text-[11px] text-neutral-400">inicializando adaptador GPU</div>}
{status === 'unsupported' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{ color: '#f43f5e' }}>WebGPU no disponible</div>
<div className="font-mono text-[10px] text-neutral-400 leading-relaxed">
chrome/edge 113+, safari 18+, o firefox nightly con flag. si estás en un navegador compatible, prueba abrir el artifact en pestaña nueva.
</div>
</div>
)}
{status === 'error' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{ color: '#f43f5e' }}>error</div>
<div className="font-mono text-[10px] text-neutral-400 whitespace-pre-wrap">{error}</div>
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// APP
// ═══════════════════════════════════════════════════════════════════
export default function App() {
const canvasRef = useRef(null);
const [pipeline, setPipeline] = useState(() => [
{ id: uid(), name: 'plasma', params: defaultParams('plasma') },
{ id: uid(), name: 'vignette', params: defaultParams('vignette') },
]);
const [dragIndex, setDragIndex] = useState(null);
const [wgslOpen, setWgslOpen] = useState(false);
// La clave de topología cambia SOLO cuando cambia la estructura (no params)
const topologyKey = useMemo(() => pipeline.map(s => s.name).join('|'), [pipeline]);
const { status, shaderError, fps, resetTime } = useWebGPUDag(canvasRef, pipeline, topologyKey);
const generatedWGSL = useMemo(() => compileDagToWGSL(pipeline), [topologyKey]);
const addNode = (name) => {
if (pipeline.length >= MAX_NODES) return;
setPipeline(p => [...p, { id: uid(), name, params: defaultParams(name) }]);
};
const removeNode = (idx) => setPipeline(p => p.filter((_, i) => i !== idx));
const changeParam = (idx, key, value) => {
setPipeline(p => p.map((s, i) => i === idx ? { ...s, params: { ...s.params, [key]: value } } : s));
};
const handleDropOnZone = (e) => {
e.preventDefault();
const nodeName = e.dataTransfer.getData('text/node-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (nodeName && NODES[nodeName]) {
addNode(nodeName);
} else if (reorderIdx !== '') {
const from = Number(reorderIdx);
setPipeline(p => {
const copy = [...p];
const [m] = copy.splice(from, 1);
copy.push(m);
return copy;
});
}
setDragIndex(null);
};
const handleDropOnNode = (e, targetIdx) => {
e.preventDefault();
e.stopPropagation();
const nodeName = e.dataTransfer.getData('text/node-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (nodeName && NODES[nodeName]) {
setPipeline(p => {
const copy = [...p];
copy.splice(targetIdx, 0, { id: uid(), name: nodeName, params: defaultParams(nodeName) });
return copy;
});
} else if (reorderIdx !== '') {
const from = Number(reorderIdx);
if (from === targetIdx) return;
setPipeline(p => {
const copy = [...p];
const [m] = copy.splice(from, 1);
const adj = from < targetIdx ? targetIdx - 1 : targetIdx;
copy.splice(adj, 0, m);
return copy;
});
}
setDragIndex(null);
};
return (
<div className="w-full min-h-screen text-neutral-200" style={{
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.grid-bg {
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 24px 24px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
input[type="range"] { height: 4px; }
`}</style>
<div className="grid-bg min-h-screen">
{/* HEADER */}
<header className="px-6 pt-6 pb-5 border-b border-white/5">
<div className="flex items-end justify-between flex-wrap gap-4">
<div>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 003</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">shader dag · webgpu</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<StatusBadge status={status} />
</div>
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
composición de <em className="italic" style={{ color: ACCENT }}>fragmentos</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
cada nodo emite un snippet WGSL · el DAG se concatena en un único fragment shader · los sliders actualizan uniforms sin recompilar
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] text-neutral-600">{fps} fps</span>
<button
onClick={resetTime}
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
>
<RotateCcw size={11} /> reset t
</button>
<button
onClick={() => setPipeline([])}
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
>
<Trash2 size={11} /> vaciar
</button>
</div>
</div>
</header>
{/* MAIN · 3 columnas: paleta | pipeline+sliders | canvas+wgsl */}
<main style={{
display: 'grid',
gridTemplateColumns: '180px minmax(280px, 1fr) minmax(320px, 420px)',
minHeight: 'calc(100vh - 110px)',
overflowX: 'auto',
}}>
{/* ── PALETA ── */}
<aside className="border-r border-white/5 p-4 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 110px)' }}>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-4">primitivas</div>
<section className="mb-5">
<div className="flex items-center gap-2 mb-2 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: GEN_COLOR, boxShadow: `0 0 8px ${GEN_COLOR}` }} />
<span className="font-display text-sm italic" style={{ color: GEN_COLOR }}>generadores</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1">producen color · ignoran c</div>
<div className="flex flex-col gap-0.5">
{NODES_BY_KIND.gen.map(n => (
<PaletteItem key={n.name} node={n} color={GEN_COLOR} onClick={() => addNode(n.name)} />
))}
</div>
</section>
<section>
<div className="flex items-center gap-2 mb-2 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: OP_COLOR, boxShadow: `0 0 8px ${OP_COLOR}` }} />
<span className="font-display text-sm italic" style={{ color: OP_COLOR }}>operadores</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1">transforman c · punto a punto</div>
<div className="flex flex-col gap-0.5">
{NODES_BY_KIND.op.map(n => (
<PaletteItem key={n.name} node={n} color={OP_COLOR} onClick={() => addNode(n.name)} />
))}
</div>
</section>
<div className="mt-6 pt-4 border-t border-white/5 font-mono text-[9px] text-neutral-600 leading-relaxed">
tip · click o drag para añadir. reordena arrastrando nodos del pipeline.
</div>
</aside>
{/* ── PIPELINE (vertical, con sliders integrados) ── */}
<section className="p-4 overflow-y-auto border-r border-white/5" style={{ maxHeight: 'calc(100vh - 110px)' }}>
<div className="flex items-baseline gap-3 mb-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">pipeline</span>
<span className="font-mono text-[10px] text-neutral-600">
{pipeline.length}/{MAX_NODES} nodos
</span>
</div>
<div
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={handleDropOnZone}
className="rounded-xl border border-dashed border-white/10 p-3 transition-colors hover:border-white/20"
style={{ background: 'rgba(255,255,255,0.015)', minHeight: '300px' }}
>
{pipeline.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="font-display text-lg italic text-neutral-500 mb-1">pipeline vacío</div>
<div className="font-mono text-[10px] text-neutral-600">arrastra una primitiva de la izquierda</div>
</div>
) : (
<div className="flex flex-col gap-2">
{pipeline.map((step, i) => (
<PipelineNode
key={step.id}
step={step}
index={i}
onRemove={removeNode}
onParamChange={changeParam}
onDragStart={setDragIndex}
onDrop={handleDropOnNode}
isDragging={dragIndex === i}
/>
))}
</div>
)}
</div>
<div className="mt-4 font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
<span style={{ color: GEN_COLOR }}>gen</span> produce · <span style={{ color: OP_COLOR }}>op</span> transforma · el flujo es c node₀ node₁ nodeₙ
</div>
</section>
{/* ── CANVAS + WGSL ── */}
<aside className="p-4 overflow-y-auto flex flex-col gap-3" style={{ maxHeight: 'calc(100vh - 110px)' }}>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">output</span>
<span className="font-mono text-[9px] text-neutral-600">fragment · fullscreen triangle</span>
</div>
<div
className="rounded-xl border border-white/10 overflow-hidden relative"
style={{
aspectRatio: '1/1',
background: '#000',
boxShadow: `0 0 0 1px ${ACCENT}10, 0 20px 60px -30px ${ACCENT}30`,
}}
>
<canvas ref={canvasRef} className="block" style={{ width: '100%', height: '100%' }} />
{status !== 'ready' && <StatusOverlay status={status} error={shaderError} />}
</div>
{shaderError && status === 'ready' && (
<div className="rounded-lg p-3" style={{ background: '#f43f5e0a', border: '1px solid #f43f5e30' }}>
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-rose-400 mb-1.5 flex items-center gap-1.5">
<AlertCircle size={11} /> compilación fallida
</div>
<pre className="font-mono text-[10px] text-rose-300 whitespace-pre-wrap leading-relaxed">{shaderError}</pre>
<div className="font-mono text-[9px] text-neutral-500 mt-2">último pipeline válido sigue activo</div>
</div>
)}
{/* WGSL viewer */}
<div className="rounded-lg border border-white/5" style={{ background: 'rgba(255,255,255,0.015)' }}>
<button
onClick={() => setWgslOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500 hover:text-neutral-300"
>
{wgslOpen ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
wgsl generado
<span className="ml-auto text-neutral-600 normal-case tracking-normal">{generatedWGSL.split('\n').length} líneas</span>
</button>
{wgslOpen && (
<pre className="font-mono text-[10px] text-neutral-400 px-3 pb-3 overflow-auto leading-relaxed" style={{ maxHeight: '260px' }}>
{generatedWGSL}
</pre>
)}
</div>
<div className="font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
los sliders escriben a <span style={{ color: ACCENT }}>u.params[idx]</span> sin recompilar · solo cambios de topología regeneran WGSL
</div>
</aside>
</main>
</div>
</div>
);
}