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_(c, uv) -> vec4` // Parámetros: hasta 4 floats empaquetados en u.params[idx] (vec4) // ═══════════════════════════════════════════════════════════════════ 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(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(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(0.0, 0.33, 0.67) + t)); return vec4(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(0.0, 2.0, 4.0)); return vec4(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((uv.x - 0.5) * aspect, uv.y - 0.5); let a = u.time * p.y; let rm = mat2x2(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(vec3(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((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(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(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(vec3(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(12.9898, 78.233))) * 43758.5453); return vec4(vec3(h), 1.0);`, }, // ── OPERADORES (transforman c) ── invert: { kind: 'op', label: 'invert', desc: '1 − rgb', params: [], body: () => ` return vec4(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(pow(max(c.rgb, vec3(0.0)), vec3(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(c.rgb + vec3(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((c.rgb - vec3(0.5)) * u.params[${i}].x + vec3(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(0.2126, 0.7152, 0.0722)); return vec4(mix(vec3(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(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(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(c.rgb * vec3(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(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(0.5)); let v = 1.0 - smoothstep(p.y, p.y + 0.3, d) * p.x; return vec4(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(0.5)); let w = sin(d * p.x - u.time * p.z) * p.y; return vec4(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(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, uv: vec2) -> vec4 {${def.body(idx)} }`; }).join('\n\n'); const chain = safePipeline.length === 0 ? ' // pipeline vacío · fondo por defecto\n c = vec4(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, params: array, ${MAX_NODES}>, }; @group(0) @binding(0) var u: Uniforms; @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { let p = array, 3>( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0) ); return vec4(p[i], 0.0, 1.0); } ${fns} @fragment fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { let uv = pos.xy / u.resolution; var c = vec4(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 (
{ 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} > {node.label} drag
); } function ParamSlider({ param, value, onChange, color }) { const display = param.step >= 1 ? Math.round(value) : Number(value).toFixed(param.step < 0.1 ? 3 : 2); return (
{param.label} onChange(Number(e.target.value))} className="flex-1" style={{ accentColor: color, minWidth: '60px' }} /> {display}
); } function PipelineNode({ step, index, onRemove, onParamChange, onDragStart, onDrop, isDragging }) { const def = NODES[step.name]; const color = def.kind === 'gen' ? GEN_COLOR : OP_COLOR; return (
{ 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, }} >
{def.kind} · {index} {def.label}
{def.params.length > 0 && (
{def.params.map(p => ( onParamChange(index, p.k, v)} color={color} /> ))}
)} {def.params.length === 0 && (
sin parámetros
)}
); } 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 ( {s.label} ); } function StatusOverlay({ status, error }) { if (status === 'ready') return null; return (
{status === 'init' &&
inicializando adaptador GPU…
} {status === 'unsupported' && (
WebGPU no disponible
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.
)} {status === 'error' && (
error
{error}
)}
); } // ═══════════════════════════════════════════════════════════════════ // 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 (
{/* HEADER */}
lab · 003 / shader dag · webgpu /

composición de fragmentos

cada nodo emite un snippet WGSL · el DAG se concatena en un único fragment shader · los sliders actualizan uniforms sin recompilar

{fps} fps
{/* MAIN · 3 columnas: paleta | pipeline+sliders | canvas+wgsl */}
{/* ── PALETA ── */} {/* ── PIPELINE (vertical, con sliders integrados) ── */}
pipeline {pipeline.length}/{MAX_NODES} nodos
{ 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 ? (
pipeline vacío
arrastra una primitiva de la izquierda
) : (
{pipeline.map((step, i) => ( ))}
)}
gen produce · op transforma · el flujo es c ← node₀ ← node₁ ← … ← nodeₙ
{/* ── CANVAS + WGSL ── */}
); }