feat(kotlin-compose): finalize design system + apps + sync sub-repo gitlinks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:30:43 +02:00
parent 42c14fae59
commit a396ee781a
18 changed files with 5285 additions and 57 deletions
@@ -0,0 +1,254 @@
# Shader DAG Lab — Arquitectura y hoja de ruta
Este documento resume el diseño acumulado tras iterar en artifacts desde
"composición funcional sobre listas" hasta "DAG de shaders WebGPU con fan-in".
Sirve como contexto para retomar el proyecto en Claude Code sin perder las
decisiones de diseño.
## El problema que resuelve
Un entorno para componer fragment shaders WGSL visualmente: el usuario arrastra
"nodos" (primitivas shader) desde una paleta a un pipeline, configura
parámetros con sliders / XY pads / color pickers, y el sistema compila el DAG
resultante a un único fragment shader ejecutado en WebGPU.
Las dos vistas — grafo y código — son proyecciones del mismo modelo interno.
El usuario casual arrastra cajas; el usuario avanzado lee el WGSL generado.
## Arquitectura en una frase
```
Pipeline state (árbol JSON)
↓ compileDagToWGSL()
WGSL source
↓ device.createShaderModule()
GPU pipeline
↓ render loop, escribe uniforms cada frame
Canvas
```
Separación crítica: la **topología** del DAG (qué nodos, en qué orden, con qué
aristas) dispara recompilación del shader. Los **valores de parámetros** NO
disparan recompilación — solo se escriben al uniform buffer cada frame. Esto
hace que mover un slider sea instantáneo mientras que añadir/quitar nodos
paga el coste de compilar un nuevo pipeline.
## Modelo de datos
Un `step` del pipeline es:
```ts
type Step = {
id: string; // UUID estable (sobrevive reorders)
name: string; // clave en el catálogo NODES
params: { // valores de parámetros editables
[key: string]: number
};
meta?: { // metadatos que afectan compilación
sourceId?: string; // para blends: id del otro nodo fuente
};
};
```
El `topologyKey` que dispara recompilación se computa como:
```
pipeline.map(s => `${s.name}:${s.meta?.sourceId ?? ''}`).join('|')
```
## Catálogo de nodos
Cada entrada en `NODES` declara:
```ts
{
kind: 'gen' | 'op' | 'blend' | 'warp' | 'sdf' | 'filter' | 'modulator',
label: string,
desc: string,
params: [{ k: string, d: number }], // hasta 4 slots del vec4<f32>
controls: Control[], // descriptores de UI
body: (idx: number) => string, // emite el cuerpo WGSL
}
```
### Tipos de control UI actualmente soportados
- `slider`: rango numérico con thumb
- `xy`: pad 2D que controla dos params contiguos
- `color`: picker RGB que controla tres params contiguos
- `select`: dropdown con opciones discretas (valor = índice)
- `source`: selector del nodo fuente para blends (escribe a `meta.sourceId`)
## Compilación del DAG a WGSL
`compileDagToWGSL(pipeline)` emite un shader con esta estructura:
```wgsl
struct Uniforms {
time: f32,
_pad: f32,
resolution: vec2<f32>,
params: array<vec4<f32>, 16>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex fn vs(...) { /* fullscreen triangle */ }
// Una función por nodo, nombrada node_<idx>
fn node_0(c: vec4<f32>, uv: vec2<f32>) -> vec4<f32> { ... }
fn node_1(a: vec4<f32>, b: vec4<f32>, uv: vec2<f32>) -> vec4<f32> { ... } // blend
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
let out_0 = node_0(vec4<f32>(0.0), uv);
let out_1 = node_1(out_0, out_0, uv); // blend con source=out_0
return out_1;
}
```
Los outputs intermedios `out_<i>` se preservan como variables locales para
que los blends puedan referenciar nodos anteriores arbitrarios. Esto es lo
que hace que el DAG sea más que un pipeline lineal.
## Estado actual de tipos de nodo
**Implementados (lab 004):**
- `gen`: solid, gradient, plasma, checker, circle, stripes, noise (hash)
- `op`: invert, gamma, contrast, saturate, hueShift, tint, posterize, vignette, ripple, pulse
- `blend`: mix, multiply, screen, add, difference, darken, lighten, mask
**Pendientes de implementar (ver hoja de ruta abajo):**
- `warp`: distorsiones de UV (twirl, polar, kaleidoscope, pixelate, chromatic)
- `sdf`: campos de distancia con compositing (smooth_union, subtract, intersect)
- `modulator`: LFOs que producen escalares animados para alimentar parámetros
- Ruidos procedurales reales: perlin, simplex, worley, fbm
- Filtros de luminancia: threshold, levels, duotone, channel_swap
- Inputs externos: mouse como uniform adicional
## Hoja de ruta post-artifact
### Lab 005 — Warps + Ruidos + Filtros de luma + Mouse
Cambios que requiere:
- **Refactor de compilación**: generadores pasan a ser `fn sample_gen_<i>(uv) -> vec4<f32>`
para que los warps puedan modificar uv antes del muestreo.
- La cadena main mantiene dos estados: `uv` (mutable por warps) y `c` (color).
- Nuevo kind `warp` con snippets que transforman `uv`.
- Nuevo uniform `mouse: vec2<f32>` (coords 0..1) actualizado desde pointer events.
- Perlin/FBM como snippets WGSL copiados de implementaciones conocidas
(hash-based gradient noise).
### Lab 006 — Multi-pass (convoluciones + feedback)
Cambio arquitectónico mayor: cada nodo puede escribir a una textura offscreen,
el siguiente samplea esa textura. Requiere:
- Render targets intermedios (pool de texturas)
- Múltiples bind groups
- Double buffer para feedback temporal (frame N+1 lee frame N)
- Detección de qué nodos necesitan aislar su pass y cuáles pueden fusionarse
Desbloquea: blur gaussiano, sobel, edge detection, bloom, reaction-diffusion,
trails, motion blur.
### Lab 007 — SDFs tipados
Introduce heterogeneidad de tipos en las aristas del DAG:
- Aristas de tipo `field` (`f32`) vs `color` (`vec4<f32>`)
- Validación de tipos en compilación: un operador de color no acepta un field
- Nodo terminal `render_sdf` que convierte field → color con shading opciones
(planar, gradient, stroke, inflate/outline)
- Operadores SDF: `smooth_union`, `subtract`, `intersect`, `round`, `onion`
Este es el salto conceptual a "DAG tipado" que formaliza lo que el lab 001
insinuaba (el fold cambiaba de tipo la arista).
### Lab 008 — Bidireccional código ↔ grafo
Hasta ahora solo va grafo → código. El inverso requiere:
- Parser acotado del WGSL que nosotros mismos emitimos (no WGSL general)
- Marcadores en comentarios `// @meta node=<name> id=<id>` para robustez
- Detección de diff estructural para mantener posiciones / parámetros al editar
- Editor de código integrado (CodeMirror) sincronizado con el pipeline
### Lab 009 — Nodos custom definidos por usuario
Modal donde el usuario escribe body WGSL + declara params, y se registra en
la paleta como si viniera del catálogo. Cierra la asimetría código→grafo sin
necesitar parser completo. Persiste en localStorage o export/import JSON.
## Decisiones de diseño que vale la pena recordar
1. **Los IDs de nodo son UUIDs, no índices posicionales**. Las referencias
de `sourceId` sobreviven a reorderings. Los índices se re-derivan en
compilación.
2. **El patrón "armed drag"** para el drag handle: el nodo es `draggable=false`
por defecto y solo se arma a `true` cuando ocurre pointerdown sobre el
header con el handle. Esto evita que los sliders internos activen drag
accidentalmente.
3. **Uniform packing**: todos los parámetros de un nodo van en `u.params[idx]`
(un `vec4<f32>`). Si un nodo necesita más de 4 floats, habría que
reasignar slots o usar dos slots. No hay nodos hasta ahora que lo pidan.
4. **MAX_NODES = 16** es arbitrario, limitado solo por el tamaño del array
de params en el uniform buffer. Subir es trivial: cambia la constante.
5. **Las arbitrary values de Tailwind no funcionan** en algunos entornos
sin JIT. Grid templates, min-h con calc, etc. se escriben con
`style={{...}}` inline. En el proyecto de Claude Code esto no debería
ser problema si usas Tailwind 3+ con su compilador.
## Stack sugerido para Claude Code
- **Vite + React + TypeScript**: setup estándar, HMR inmediato
- **Tailwind 3+** con JIT: los arbitrary values funcionarán esta vez
- **Zustand o Jotai** para el pipeline state (se va a hacer más complejo)
- **Biome o ESLint + Prettier** para formato consistente
- Opcional pero recomendado en cuanto crezca:
- **Vitest** para tests unitarios del compilador (`compileDagToWGSL`)
- **React Testing Library** si tests de UI
- **reactflow** si en lab 008 quieres visualizar el DAG como grafo editable
## Organización de archivos sugerida
```
src/
nodes/
index.ts # export del catálogo completo
generators.ts # gen kind
operators.ts # op kind
blends.ts # blend kind
warps.ts # (lab 005)
sdfs.ts # (lab 007)
types.ts # tipos compartidos NodeDef, Control, etc.
compiler/
compileDagToWGSL.ts # la función principal
uniforms.ts # packing y escritura del buffer
validate.ts # validación de tipos (lab 007+)
webgpu/
useWebGPU.ts # el hook
renderer.ts # setup del device, context, pipeline
ui/
PipelineNode.tsx
controls/
Slider.tsx
XYPad.tsx
ColorPicker.tsx
Select.tsx
SourceSelector.tsx
Palette.tsx
Canvas.tsx
WGSLView.tsx
store/
pipeline.ts # Zustand store
App.tsx
main.tsx
```
## Primeros pasos en Claude Code
1. `npm create vite@latest shader-dag -- --template react-ts`
2. Copiar `shader-dag-blends.jsx` como base monolítica, renombrar a `.tsx`
3. Arreglar los tipos TypeScript (muchas funciones del artifact no están tipadas)
4. Romper el monolito según la estructura de arriba
5. Implementar lab 005: refactor de compilación para habilitar warps
Nota: el artifact de base (`shader-dag-blends.jsx`) funciona pero tiene el
tipado implícito de JSX. Convertir a TS te va a revelar varios tipos que
merece la pena modelar explícitamente (especialmente `Control`, que ahora es
un union discriminado informal).
@@ -0,0 +1,598 @@
import { useState, useMemo, useRef } from 'react';
import { X, Trash2, GripVertical } from 'lucide-react';
// ─────────────────────────────────────────────────────────────────
// Categorías morfológicas — cada una con su identidad visual
// ─────────────────────────────────────────────────────────────────
const CATEGORIES = {
gen: {
label: 'Generadores',
subtitle: 'Anamorfismos',
signature: 'seed → [a]',
color: '#7dd3fc',
},
map: {
label: 'Transformaciones',
subtitle: 'Functor · map',
signature: '(a→b) → [a]→[b]',
color: '#c4b5fd',
},
filter: {
label: 'Filtros',
subtitle: 'Refinamientos',
signature: '[a] → [a]',
color: '#fcd34d',
},
scan: {
label: 'Scans',
subtitle: 'Folds acumulativos',
signature: '[a] → [a]',
color: '#86efac',
},
fold: {
label: 'Plegados',
subtitle: 'Catamorfismos',
signature: '[a] → b',
color: '#fda4af',
},
};
// PRNG determinista para que `random` sea reproducible
function mulberry32(seed) {
let a = seed;
return function() {
a = (a + 0x6D2B79F5) | 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ─────────────────────────────────────────────────────────────────
// Catálogo de funciones disponibles
// ─────────────────────────────────────────────────────────────────
const FUNCTIONS = {
// — GENERADORES (anamorfismos: producen estructura desde un seed) —
range: { cat: 'gen', symbol: 'range', sig: 'n → [1..n]', params: [{k:'n',d:20,min:1,max:120}], run: (_,p) => Array.from({length:p.n}, (_,i)=>i+1) },
linspace: { cat: 'gen', symbol: 'linspace', sig: 'n → [0..2π]', params: [{k:'n',d:60,min:2,max:200}], run: (_,p) => Array.from({length:p.n}, (_,i)=>(i/(p.n-1))*2*Math.PI) },
sine: { cat: 'gen', symbol: 'sine', sig: 'n → sin(4π·t)', params: [{k:'n',d:80,min:4,max:200}], run: (_,p) => Array.from({length:p.n}, (_,i)=>Math.sin((i/(p.n-1))*4*Math.PI)) },
noise: { cat: 'gen', symbol: 'noise', sig: 'seed → U(-1,1)', params: [{k:'n',d:50,min:2,max:200},{k:'seed',d:7,min:1,max:9999}], run: (_,p) => { const r=mulberry32(p.seed); return Array.from({length:p.n}, ()=>r()*2-1); } },
fib: { cat: 'gen', symbol: 'fib', sig: 'n → Fibₙ', params: [{k:'n',d:12,min:2,max:25}], run: (_,p) => { const r=[1,1]; while(r.length<p.n) r.push(r[r.length-1]+r[r.length-2]); return r.slice(0,p.n); } },
// MAPS (functor f: ab aplicado punto-a-punto)
double: { cat: 'map', symbol: '·2', sig: 'x 2x', run: (a) => a.map(x=>x*2) },
square: { cat: 'map', symbol: 'x²', sig: 'x ↦ x²', run: (a) => a.map(x=>x*x) },
negate: { cat: 'map', symbol: '-x', sig: 'x ↦ -x', run: (a) => a.map(x=>-x) },
abs: { cat: 'map', symbol: '|x|', sig: 'x ↦ |x|', run: (a) => a.map(x=>Math.abs(x)) },
sqrt: { cat: 'map', symbol: '√', sig: 'x ↦ √|x|', run: (a) => a.map(x=>Math.sqrt(Math.abs(x))) },
sin: { cat: 'map', symbol: 'sin', sig: 'x ↦ sin x', run: (a) => a.map(x=>Math.sin(x)) },
log: { cat: 'map', symbol: 'ln', sig: 'x ↦ ln(1+|x|)', run: (a) => a.map(x=>Math.log(1+Math.abs(x))) },
// — FILTROS (refinamiento) —
positive: { cat: 'filter', symbol: '>0', sig: 'x > 0', run: (a) => a.filter(x=>x>0) },
even: { cat: 'filter', symbol: 'par', sig: 'x even', run: (a) => a.filter(x=>Math.round(x)%2===0) },
gt: { cat: 'filter', symbol: '>t', sig: 'x > t', params: [{k:'t',d:0,min:-50,max:50,step:0.5}], run: (a,p) => a.filter(x=>x>p.t) },
// — SCANS (folds acumulativos: dejan rastro) —
cumsum: { cat: 'scan', symbol: 'Σ*', sig: 'Σ prefix', run: (a) => { let s=0; return a.map(x=>s+=x); } },
cummax: { cat: 'scan', symbol: 'max*', sig: 'max prefix', run: (a) => { let m=-Infinity; return a.map(x=>{m=Math.max(m,x); return m;}); } },
diff: { cat: 'scan', symbol: 'Δ', sig: 'xₙ - xₙ₋₁', run: (a) => a.map((x,i)=>i===0?0:x-a[i-1]) },
mavg: { cat: 'scan', symbol: 'μ_k', sig: 'media móvil k', params: [{k:'k',d:3,min:1,max:15}], run: (a,p) => a.map((_,i)=>{ const s=Math.max(0,i-p.k+1); const sl=a.slice(s,i+1); return sl.reduce((t,v)=>t+v,0)/sl.length; }) },
// — FOLDS (catamorfismos: colapsan a escalar · terminales) —
sum: { cat: 'fold', symbol: 'Σ', sig: '[a] → Σa', run: (a) => a.reduce((s,x)=>s+x, 0) },
product: { cat: 'fold', symbol: '∏', sig: '[a] → ∏a', run: (a) => a.reduce((s,x)=>s*x, 1) },
max: { cat: 'fold', symbol: 'max', sig: '[a] → max', run: (a) => a.length ? Math.max(...a) : 0 },
min: { cat: 'fold', symbol: 'min', sig: '[a] → min', run: (a) => a.length ? Math.min(...a) : 0 },
mean: { cat: 'fold', symbol: 'μ', sig: '[a] → μ', run: (a) => a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0 },
count: { cat: 'fold', symbol: '#', sig: '[a] → ', run: (a) => a.length },
};
const BY_CATEGORY = Object.entries(CATEGORIES).map(([catKey, catMeta]) => ({
...catMeta,
key: catKey,
items: Object.entries(FUNCTIONS).filter(([, f]) => f.cat === catKey).map(([name, f]) => ({ name, ...f })),
}));
// ─────────────────────────────────────────────────────────────────
// Ejecutor: aplica el pipeline y conserva salidas intermedias
// ─────────────────────────────────────────────────────────────────
function executePipeline(pipeline) {
const steps = [];
let current = Array.from({length: 10}, (_, i) => i + 1); // semilla si no hay generador
let terminated = false;
for (const item of pipeline) {
const def = FUNCTIONS[item.name];
if (!def) continue;
if (terminated) { steps.push({ ...item, def, unreachable: true }); continue; }
try {
const value = def.run(current, item.params || {});
steps.push({ ...item, def, value });
if (def.cat === 'fold') terminated = true;
else current = value;
} catch (e) {
steps.push({ ...item, def, error: e.message });
terminated = true;
}
}
return { steps, terminated };
}
// ─────────────────────────────────────────────────────────────────
// Helpers de id y parámetros
// ─────────────────────────────────────────────────────────────────
let _uid = 0;
const uid = () => `n${++_uid}_${Date.now().toString(36)}`;
function defaultParams(name) {
const def = FUNCTIONS[name];
if (!def.params) return {};
return Object.fromEntries(def.params.map(p => [p.k, p.d]));
}
// ─────────────────────────────────────────────────────────────────
// Sparkline compacto (SVG manual)
// ─────────────────────────────────────────────────────────────────
function Sparkline({ data, color = '#888', w = 180, h = 34 }) {
if (!Array.isArray(data) || data.length === 0) {
return <div style={{width:w, height:h}} className="flex items-center justify-center text-[10px] text-neutral-600"></div>;
}
const min = Math.min(...data);
const max = Math.max(...data);
const span = max - min || 1;
const n = data.length;
const xStep = n > 1 ? (w - 6) / (n - 1) : 0;
const pts = data.map((v, i) => [3 + i * xStep, h - 3 - ((v - min) / span) * (h - 6)]);
const path = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' ');
const zeroY = h - 3 - ((0 - min) / span) * (h - 6);
const showZero = min < 0 && max > 0;
return (
<svg width={w} height={h} className="block">
{showZero && <line x1={0} x2={w} y1={zeroY} y2={zeroY} stroke={color} strokeOpacity={0.15} strokeDasharray="2 3" />}
<path d={path} fill="none" stroke={color} strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round" />
{n <= 40 && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={1.4} fill={color} fillOpacity={0.85} />
))}
</svg>
);
}
// ─────────────────────────────────────────────────────────────────
// Ficha en la paleta (draggable)
// ─────────────────────────────────────────────────────────────────
function PaletteItem({ fnName, fn, color, onDragStart, onClick }) {
return (
<div
draggable
onDragStart={(e) => { e.dataTransfer.setData('text/fn-name', fnName); e.dataTransfer.effectAllowed = 'copy'; onDragStart?.(); }}
onClick={onClick}
className="group flex items-center gap-2 px-2.5 py-1.5 rounded-md cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-neutral-800/60"
style={{ borderLeft: `2px solid ${color}` }}
title={`${fnName} :: ${fn.sig}`}
>
<span className="font-mono text-[11px] font-semibold tracking-tight" style={{ color }}>{fn.symbol}</span>
<span className="font-mono text-[11px] text-neutral-400 flex-1 truncate">{fnName}</span>
<span className="font-mono text-[9px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity">drag</span>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// Nodo del pipeline
// ─────────────────────────────────────────────────────────────────
function PipelineNode({ step, index, isLast, onRemove, onParamChange, onDragStart, onDragOver, onDrop, isDragging }) {
const { def, value, unreachable, error } = step;
const cat = CATEGORIES[def.cat];
const color = cat.color;
const isScalar = def.cat === 'fold';
return (
<div
draggable
onDragStart={(e) => { e.dataTransfer.setData('text/reorder-index', String(index)); e.dataTransfer.effectAllowed = 'move'; onDragStart?.(index); }}
onDragOver={(e) => { e.preventDefault(); onDragOver?.(index); }}
onDrop={(e) => onDrop?.(e, index)}
className="relative group"
style={{ opacity: isDragging ? 0.4 : 1 }}
>
<div
className="flex flex-col rounded-lg backdrop-blur-sm transition-all"
style={{
background: `linear-gradient(180deg, ${color}14 0%, ${color}06 100%)`,
border: `1px solid ${color}40`,
boxShadow: `0 0 0 1px ${color}10, 0 8px 24px -12px ${color}30`,
minWidth: 200,
}}
>
{/* cabecera */}
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b" style={{ borderColor: `${color}25` }}>
<GripVertical size={12} className="text-neutral-600 cursor-grab" />
<span className="font-mono text-[11px] font-bold" style={{ color }}>{def.symbol}</span>
<span className="font-mono text-[10px] text-neutral-500 flex-1">{step.name}</span>
<button
onClick={() => onRemove(index)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-neutral-500 hover:text-neutral-200 p-0.5 rounded"
aria-label="Eliminar"
>
<X size={11} />
</button>
</div>
{/* params */}
{def.params && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b flex-wrap" style={{ borderColor: `${color}18` }}>
{def.params.map(p => (
<label key={p.k} className="flex items-center gap-1 font-mono text-[10px] text-neutral-500">
<span>{p.k}=</span>
<input
type="number"
value={step.params?.[p.k] ?? p.d}
min={p.min} max={p.max} step={p.step ?? 1}
onChange={(e) => onParamChange(index, p.k, Number(e.target.value))}
className="w-12 bg-neutral-900/60 border border-neutral-700/50 rounded px-1 py-0.5 text-neutral-200 font-mono text-[10px] focus:outline-none focus:border-neutral-500"
/>
</label>
))}
</div>
)}
{/* preview */}
<div className="px-2.5 py-1.5">
{unreachable ? (
<div className="font-mono text-[10px] text-neutral-600 italic">inalcanzable · fold anterior terminó el pipeline</div>
) : error ? (
<div className="font-mono text-[10px] text-rose-400">error: {error}</div>
) : isScalar ? (
<div className="flex items-baseline gap-2">
<span className="font-mono text-[10px] text-neutral-500">resultado</span>
<span className="font-display text-2xl font-light" style={{ color }}>{formatScalar(value)}</span>
</div>
) : (
<div>
<Sparkline data={value} color={color} w={180} h={32} />
<div className="font-mono text-[9px] text-neutral-600 mt-1">
n={value?.length ?? 0} · range [{formatScalar(Math.min(...(value?.length?value:[0])))}, {formatScalar(Math.max(...(value?.length?value:[0])))}]
</div>
</div>
)}
</div>
</div>
{/* flecha de composición */}
{!isLast && (
<div className="absolute top-1/2 -right-5 -translate-y-1/2 font-mono text-neutral-600 text-sm pointer-events-none"></div>
)}
</div>
);
}
function formatScalar(v) {
if (v === undefined || v === null || !isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
const abs = Math.abs(v);
if (abs >= 1000 || (abs > 0 && abs < 0.01)) return v.toExponential(2);
return v.toFixed(3);
}
// ─────────────────────────────────────────────────────────────────
// Visualización grande del output final
// ─────────────────────────────────────────────────────────────────
function FinalView({ lastStep }) {
if (!lastStep) {
return (
<div className="flex-1 flex items-center justify-center text-neutral-600 font-mono text-xs">
arrastra una función para empezar
</div>
);
}
const { def, value, error, unreachable } = lastStep;
if (error || unreachable) return null;
const color = CATEGORIES[def.cat].color;
if (def.cat === 'fold') {
return (
<div className="flex flex-col items-center justify-center py-6 w-full">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500 mb-2">resultado · escalar</span>
<span className="font-display text-5xl font-light break-all text-center" style={{ color }}>{formatScalar(value)}</span>
</div>
);
}
// plot grande
if (!Array.isArray(value) || value.length === 0) {
return <div className="font-mono text-xs text-neutral-600 p-4"> (lista vacía)</div>;
}
const W = 720, H = 180, pad = 20;
const min = Math.min(...value), max = Math.max(...value);
const span = max - min || 1;
const n = value.length;
const xStep = n > 1 ? (W - 2 * pad) / (n - 1) : 0;
const pts = value.map((v, i) => [pad + i * xStep, H - pad - ((v - min) / span) * (H - 2 * pad)]);
const path = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' ');
const areaPath = `${path} L${pts[pts.length-1][0]},${H-pad} L${pts[0][0]},${H-pad} Z`;
const zeroY = H - pad - ((0 - min) / span) * (H - 2 * pad);
const showZero = min < 0 && max > 0;
return (
<div className="flex flex-col">
<div className="flex items-baseline justify-between mb-2">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500">resultado · señal</span>
<span className="font-mono text-[10px] text-neutral-500">n={n} · [{formatScalar(min)}, {formatScalar(max)}]</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" preserveAspectRatio="none">
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{/* grid */}
{[0.25, 0.5, 0.75].map(f => (
<line key={f} x1={pad} x2={W-pad} y1={pad + f*(H-2*pad)} y2={pad + f*(H-2*pad)} stroke="#fff" strokeOpacity={0.04} />
))}
{showZero && <line x1={pad} x2={W-pad} y1={zeroY} y2={zeroY} stroke={color} strokeOpacity={0.25} strokeDasharray="3 4" />}
<path d={areaPath} fill="url(#areaGrad)" />
<path d={path} fill="none" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
{n <= 80 && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={2} fill={color} />
))}
{/* eje */}
<line x1={pad} x2={W-pad} y1={H-pad} y2={H-pad} stroke="#fff" strokeOpacity={0.1} />
<line x1={pad} x2={pad} y1={pad} y2={H-pad} stroke="#fff" strokeOpacity={0.1} />
</svg>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// APP principal
// ─────────────────────────────────────────────────────────────────
export default function App() {
const [pipeline, setPipeline] = useState([
{ id: uid(), name: 'range', params: defaultParams('range') },
{ id: uid(), name: 'square', params: defaultParams('square') },
{ id: uid(), name: 'cumsum', params: defaultParams('cumsum') },
]);
const [dragIndex, setDragIndex] = useState(null);
const [hoverIndex, setHoverIndex] = useState(null);
const dropZoneRef = useRef(null);
const { steps } = useMemo(() => executePipeline(pipeline), [pipeline]);
const lastStep = steps[steps.length - 1];
// expresión simbólica: fold_sum ∘ map_square ∘ gen_range(20)
const expression = useMemo(() => {
if (pipeline.length === 0) return '∅';
const parts = pipeline.map(s => {
const def = FUNCTIONS[s.name];
const paramStr = def.params ? '(' + def.params.map(p => `${p.k}=${s.params?.[p.k] ?? p.d}`).join(',') + ')' : '';
return `${s.name}${paramStr}`;
}).reverse();
return parts.join(' ∘ ');
}, [pipeline]);
const addFunction = (name) => {
setPipeline(p => [...p, { id: uid(), name, params: defaultParams(name) }]);
};
const removeFunction = (index) => {
setPipeline(p => p.filter((_, i) => i !== index));
};
const changeParam = (index, key, value) => {
setPipeline(p => p.map((s, i) => i === index ? { ...s, params: { ...s.params, [key]: value } } : s));
};
const handleDropOnZone = (e) => {
e.preventDefault();
const fnName = e.dataTransfer.getData('text/fn-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (fnName && FUNCTIONS[fnName]) {
addFunction(fnName);
} else if (reorderIdx !== '') {
// soltar al final si vino de un nodo
const from = Number(reorderIdx);
setPipeline(p => {
const copy = [...p];
const [moved] = copy.splice(from, 1);
copy.push(moved);
return copy;
});
}
setDragIndex(null);
setHoverIndex(null);
};
const handleDropOnNode = (e, targetIdx) => {
e.preventDefault();
e.stopPropagation();
const fnName = e.dataTransfer.getData('text/fn-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (fnName && FUNCTIONS[fnName]) {
setPipeline(p => {
const copy = [...p];
copy.splice(targetIdx, 0, { id: uid(), name: fnName, params: defaultParams(fnName) });
return copy;
});
} else if (reorderIdx !== '') {
const from = Number(reorderIdx);
if (from === targetIdx) return;
setPipeline(p => {
const copy = [...p];
const [moved] = copy.splice(from, 1);
const adjusted = from < targetIdx ? targetIdx - 1 : targetIdx;
copy.splice(adjusted, 0, moved);
return copy;
});
}
setDragIndex(null);
setHoverIndex(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;
}
/* scrollbar */
::-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); }
`}</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 · 001</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">composición funcional</span>
</div>
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
laboratorio de <em className="italic" style={{color:'#c4b5fd'}}>morfismos</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
arrastra funciones al pipeline y componlas de izquierda a derecha. cada tipo corresponde a un esquema de recursión distinto.
</p>
</div>
<button
onClick={() => setPipeline([])}
className="flex items-center gap-2 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>
</header>
{/* MAIN GRID · 2 columnas: paleta | (expresión + pipeline + visualizador apilados) */}
<main
style={{
display: 'grid',
gridTemplateColumns: '200px minmax(280px, 1fr)',
minHeight: 'calc(100vh - 140px)',
overflowX: 'auto',
}}
>
{/* ─── IZQUIERDA · PALETA ─── */}
<aside className="border-r border-white/5 p-5 overflow-y-auto" style={{maxHeight: 'calc(100vh - 140px)'}}>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-4">primitivas</div>
<div className="flex flex-col gap-5">
{BY_CATEGORY.map(cat => (
<section key={cat.key}>
<div className="flex items-center gap-2 mb-2 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{background: cat.color, boxShadow: `0 0 8px ${cat.color}`}} />
<span className="font-display text-sm italic" style={{color: cat.color}}>{cat.label}</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1 leading-snug">
{cat.subtitle} · <span className="text-neutral-500">{cat.signature}</span>
</div>
<div className="flex flex-col gap-0.5">
{cat.items.map(item => (
<PaletteItem
key={item.name}
fnName={item.name}
fn={item}
color={cat.color}
onClick={() => addFunction(item.name)}
/>
))}
</div>
</section>
))}
</div>
<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 al pipeline. reordena arrastrando nodos existentes.
</div>
</aside>
{/* ─── DERECHA · EXPRESIÓN + PIPELINE + VISUALIZADOR ─── */}
<section className="p-4 md:p-6 overflow-y-auto flex flex-col gap-5" style={{maxHeight: 'calc(100vh - 140px)'}}>
{/* Expresión simbólica */}
<div>
<div className="flex items-baseline gap-3 mb-2">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">expresión</span>
<span className="font-mono text-[9px] text-neutral-600">se aplica de derecha a izquierda</span>
</div>
<div className="rounded-lg bg-white/[0.03] border border-white/10 p-3">
<code className="font-mono text-xs text-neutral-200 break-all leading-relaxed">
{expression}
</code>
</div>
</div>
{/* Pipeline */}
<div>
<div className="flex items-baseline gap-3 mb-2">
<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} {pipeline.length === 1 ? 'nodo' : 'nodos'}</span>
</div>
<div
ref={dropZoneRef}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={handleDropOnZone}
className="rounded-xl border border-dashed border-white/10 p-5 transition-colors hover:border-white/20"
style={{background: 'rgba(255,255,255,0.015)', minHeight: '180px'}}
>
{pipeline.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center py-12 text-center">
<div className="font-display text-xl italic text-neutral-500 mb-2">zona de composición</div>
<div className="font-mono text-[10px] text-neutral-600 max-w-xs">arrastra una primitiva desde la izquierda. los nodos se encadenan de izquierda a derecha.</div>
</div>
) : (
<div className="flex flex-wrap gap-x-8 gap-y-4 items-start">
{steps.map((step, i) => (
<PipelineNode
key={step.id}
step={step}
index={i}
isLast={i === steps.length - 1}
onRemove={removeFunction}
onParamChange={changeParam}
onDragStart={setDragIndex}
onDragOver={setHoverIndex}
onDrop={handleDropOnNode}
isDragging={dragIndex === i}
/>
))}
</div>
)}
</div>
</div>
{/* Visualizador */}
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-2">visualizador</div>
<div className="rounded-lg border border-white/5 bg-white/[0.015] p-4" style={{minHeight: '220px'}}>
<div className="h-full flex items-center justify-center">
<FinalView lastStep={lastStep} />
</div>
</div>
</div>
{/* Nota didáctica */}
<div className="font-mono text-[10px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
<span style={{color:'#7dd3fc'}}>anamorfismo</span> genera estructura · <span style={{color:'#c4b5fd'}}>functor map</span> transforma punto a punto ·{' '}
<span style={{color:'#fcd34d'}}>filtro</span> refina · <span style={{color:'#86efac'}}>scan</span> acumula dejando rastro ·{' '}
<span style={{color:'#fda4af'}}>catamorfismo</span> colapsa (es terminal)
</div>
</section>
</main>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,878 @@
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>
);
}
@@ -0,0 +1,505 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Play, RotateCcw, AlertCircle } from 'lucide-react';
// ─────────────────────────────────────────────────────────────────
// Presets WGSL · cada uno es un shader completo autosuficiente
// ─────────────────────────────────────────────────────────────────
const SHADER_HEADER = `struct Uniforms {
time: f32,
resolution: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
// triángulo fullscreen (truco de tres vértices)
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);
}
`;
const PRESETS = {
plasma: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
let c = 0.5 + 0.5 * cos(u.time + uv.xyx + vec3<f32>(0.0, 2.0, 4.0));
return vec4<f32>(c, 1.0);
}
`,
circle: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// SDF de un círculo con anillo y fondo sutil
let uv = (pos.xy / u.resolution) * 2.0 - 1.0;
let aspect = u.resolution.x / u.resolution.y;
let p = vec2<f32>(uv.x * aspect, uv.y);
let r = 0.4 + 0.05 * sin(u.time * 2.0);
let d = length(p) - r;
let fill = smoothstep(0.0, -0.01, d);
let ring = smoothstep(0.015, 0.0, abs(d));
let bg = vec3<f32>(0.05, 0.06, 0.08);
let col = mix(bg, vec3<f32>(0.94, 0.55, 0.72), fill) + vec3<f32>(ring * 0.9);
return vec4<f32>(col, 1.0);
}
`,
checker: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// tablero rotando
let uv = pos.xy / u.resolution - 0.5;
let aspect = u.resolution.x / u.resolution.y;
let p0 = vec2<f32>(uv.x * aspect, uv.y);
let rot = u.time * 0.25;
let c = cos(rot);
let s = sin(rot);
let p = vec2<f32>(p0.x * c - p0.y * s, p0.x * s + p0.y * c) * 10.0;
let chk = (floor(p.x) + floor(p.y)) - 2.0 * floor((floor(p.x) + floor(p.y)) * 0.5);
let col = mix(vec3<f32>(0.93, 0.91, 0.86), vec3<f32>(0.10, 0.09, 0.13), chk);
return vec4<f32>(col, 1.0);
}
`,
waves: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// interferencia de dos ondas senoidales
let uv = pos.xy / u.resolution;
let a = sin(uv.x * 20.0 + u.time * 2.0);
let b = sin(uv.y * 15.0 - u.time * 1.3);
let v = 0.5 + 0.5 * a * b;
let col = vec3<f32>(v, pow(v, 2.0) * 0.5 + 0.3, 1.0 - v * 0.7);
return vec4<f32>(col, 1.0);
}
`,
sdfBlob: SHADER_HEADER + `
fn sdCircle(p: vec2<f32>, r: f32) -> f32 { return length(p) - r; }
fn smoothMin(a: f32, b: f32, k: f32) -> f32 {
let h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * h * k * (1.0 / 6.0);
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// metaball · fusión de tres SDFs
let uv = (pos.xy / u.resolution) * 2.0 - 1.0;
let aspect = u.resolution.x / u.resolution.y;
let p = vec2<f32>(uv.x * aspect, uv.y);
let t = u.time;
let c1 = sdCircle(p - vec2<f32>(cos(t) * 0.4, sin(t * 1.3) * 0.3), 0.25);
let c2 = sdCircle(p - vec2<f32>(cos(t * 0.7 + 2.0) * 0.35, sin(t * 0.9) * 0.25), 0.22);
let c3 = sdCircle(p - vec2<f32>(sin(t * 1.1) * 0.3, cos(t * 0.6) * 0.35), 0.2);
let d = smoothMin(smoothMin(c1, c2, 0.35), c3, 0.35);
let fill = smoothstep(0.02, -0.02, d);
let glow = exp(-8.0 * max(d, 0.0));
let bg = vec3<f32>(0.04, 0.02, 0.06);
let core = vec3<f32>(0.95, 0.4, 0.6);
let col = bg + core * (fill * 0.9 + glow * 0.3);
return vec4<f32>(col, 1.0);
}
`,
};
const PRESET_ORDER = [
{ key: 'plasma', label: 'plasma', hint: 'cos-gradient clásico' },
{ key: 'circle', label: 'círculo sdf', hint: 'signed distance field' },
{ key: 'checker', label: 'tablero', hint: 'patrón rotando' },
{ key: 'waves', label: 'ondas', hint: 'interferencia senoidal' },
{ key: 'sdfBlob', label: 'metaball', hint: 'fusión suave de SDFs' },
];
// ─────────────────────────────────────────────────────────────────
// Hook: gestión de todo el ciclo WebGPU
// ─────────────────────────────────────────────────────────────────
function useWebGPU(canvasRef, code) {
const gpu = useRef({
device: null,
context: null,
format: null,
pipeline: null,
bindGroup: null,
uniformBuffer: null,
startTime: 0,
raf: 0,
});
const [status, setStatus] = useState('init'); // init · ready · unsupported · error
const [shaderError, setShaderError] = useState(null);
const [fps, setFps] = useState(0);
const [timeT, setTimeT] = useState(0);
// ── Inicialización ──
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: 16, // f32 time + pad + vec2<f32> resolution
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]);
// ── Compilación del shader (debounced) ──
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) {
const msg = errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n');
setShaderError(msg);
await device.popErrorScope();
return;
}
try {
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive:{ topology: 'triangle-list' },
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
});
gpu.current.pipeline = pipeline;
gpu.current.bindGroup = bindGroup;
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 id = setTimeout(() => compileShader(code), 180);
return () => clearTimeout(id);
}, [code, status, compileShader]);
// ── Render loop ──
useEffect(() => {
if (status !== 'ready') return;
let running = true;
let frames = 0;
let lastFpsSample = performance.now();
const loop = () => {
if (!running) return;
const { device, context, pipeline, bindGroup, uniformBuffer, startTime } = gpu.current;
const canvas = canvasRef.current;
if (device && context && pipeline && bindGroup && canvas) {
// redimensionado
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;
const data = new Float32Array([t, 0, canvas.width, canvas.height]);
device.queue.writeBuffer(uniformBuffer, 0, data);
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(pipeline);
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)));
setTimeT(t);
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, timeT, resetTime };
}
// ─────────────────────────────────────────────────────────────────
// APP
// ─────────────────────────────────────────────────────────────────
export default function App() {
const canvasRef = useRef(null);
const [code, setCode] = useState(PRESETS.plasma);
const [activePreset, setActivePreset] = useState('plasma');
const { status, shaderError, fps, timeT, resetTime } = useWebGPU(canvasRef, code);
const loadPreset = (key) => {
setActivePreset(key);
setCode(PRESETS[key]);
};
const ACCENT = '#5eead4'; // teal-300
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); }
textarea.wgsl {
tab-size: 2;
-moz-tab-size: 2;
}
`}</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 · 002</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">shaders · 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">
laboratorio de <em className="italic" style={{color: ACCENT}}>píxels</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
editor WGSL en vivo · cada tecla recompila el fragment shader · el uniform block expone <span style={{color: ACCENT}}>time</span> y <span style={{color: ACCENT}}>resolution</span>
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] text-neutral-600">t = {timeT.toFixed(2)}s</span>
<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>
</div>
</div>
</header>
{/* MAIN · 2 columnas: canvas | editor */}
<main style={{
display: 'grid',
gridTemplateColumns: 'minmax(280px, 1fr) minmax(340px, 1fr)',
minHeight: 'calc(100vh - 110px)',
overflowX: 'auto',
}}>
{/* IZQUIERDA · CANVAS */}
<section className="p-4 md:p-6 border-r border-white/5 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">canvas</span>
<span className="font-mono text-[9px] text-neutral-600">fragment shader · fullscreen triangle</span>
</div>
<div
className="flex-1 rounded-xl border border-white/10 overflow-hidden relative"
style={{
background: '#000',
boxShadow: `0 0 0 1px ${ACCENT}10, 0 20px 60px -30px ${ACCENT}30`,
minHeight: '320px',
}}
>
<canvas
ref={canvasRef}
className="block"
style={{width: '100%', height: '100%'}}
/>
{status !== 'ready' && <StatusOverlay status={status} error={shaderError} />}
</div>
<div className="font-mono text-[9px] text-neutral-600">
consejo · el vertex shader genera un triángulo que cubre toda la pantalla. todo el trabajo interesante pasa en el <span style={{color: ACCENT}}>fragment</span>.
</div>
</section>
{/* DERECHA · EDITOR */}
<section className="p-4 md:p-6 flex flex-col gap-3" style={{maxHeight: 'calc(100vh - 110px)'}}>
{/* presets */}
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-2">presets</div>
<div className="flex flex-wrap gap-1.5">
{PRESET_ORDER.map(p => {
const active = p.key === activePreset;
return (
<button
key={p.key}
onClick={() => loadPreset(p.key)}
className="font-mono text-[11px] px-2.5 py-1 rounded transition-all"
style={{
background: active ? `${ACCENT}18` : 'rgba(255,255,255,0.02)',
border: `1px solid ${active ? ACCENT + '60' : 'rgba(255,255,255,0.08)'}`,
color: active ? ACCENT : '#a3a3a3',
}}
title={p.hint}
>
{p.label}
</button>
);
})}
</div>
</div>
{/* editor */}
<div className="flex flex-col flex-1 gap-2 min-h-0">
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">fuente · wgsl</span>
{shaderError ? (
<span className="font-mono text-[10px] text-rose-400 flex items-center gap-1">
<AlertCircle size={10} /> error de compilación
</span>
) : (
<span className="font-mono text-[10px]" style={{color: ACCENT}}> compilado</span>
)}
</div>
<textarea
className="wgsl flex-1 w-full rounded-lg p-3 font-mono text-[12px] leading-relaxed resize-none outline-none"
value={code}
onChange={(e) => setCode(e.target.value)}
spellCheck={false}
style={{
background: 'rgba(255,255,255,0.02)',
border: `1px solid ${shaderError ? '#f43f5e40' : 'rgba(255,255,255,0.08)'}`,
color: '#d4d4d4',
minHeight: '300px',
}}
/>
</div>
{/* errores */}
{shaderError && (
<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-[11px] text-rose-300 whitespace-pre-wrap leading-relaxed">{shaderError}</pre>
</div>
)}
<div className="font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
uniforms · <span style={{color: ACCENT}}>u.time</span> (f32, segundos desde inicio) · <span style={{color: ACCENT}}>u.resolution</span> (vec2&lt;f32&gt;, px). el último pipeline válido se mantiene hasta que la próxima compilación tenga éxito.
</div>
</section>
</main>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// Sub-componentes
// ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
const map = {
init: { color: '#fbbf24', label: 'inicializando' },
ready: { color: '#5eead4', 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">
este navegador no expone <code>navigator.gpu</code>. prueba con chrome/edge recientes, o safari 18+, o activa el flag en firefox nightly.
</div>
</div>
)}
{status === 'error' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{color: '#f43f5e'}}>error de inicialización</div>
<div className="font-mono text-[10px] text-neutral-400 whitespace-pre-wrap">{error}</div>
</div>
)}
</div>
);
}