Files
shaders_lab/extracted/shader-dag-blends.jsx
2026-04-28 22:12:27 +02:00

1242 lines
51 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { X, AlertCircle, RotateCcw, ChevronDown, ChevronRight, Trash2, GripVertical } from 'lucide-react';
// ═══════════════════════════════════════════════════════════════════
// CATÁLOGO DE NODOS
// Cada nodo declara:
// - kind: 'gen' | 'op' | 'blend'
// - params: hasta 4 floats · slots del vec4<f32> en u.params[idx]
// - controls: descriptores de UI (pueden agrupar varios params)
// - body: snippet WGSL del cuerpo de la función
// ═══════════════════════════════════════════════════════════════════
const MAX_NODES = 16;
const ACCENT = '#5eead4';
const GEN_COLOR = '#5eead4';
const OP_COLOR = '#c4b5fd';
const BLEND_COLOR = '#fbbf24';
const NODES = {
// ─── GENERADORES ───────────────────────────────────────────────
solid: {
kind: 'gen', label: 'solid', desc: 'color constante',
params: [
{ k: 'r', d: 0.35 }, { k: 'g', d: 0.25 }, { k: 'b', d: 0.55 }, { k: '_', d: 0 },
],
controls: [
{ kind: 'color', keys: ['r', 'g', 'b'], label: 'color' },
],
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 direccional',
params: [
{ k: 'angle', d: 0.8 }, { k: 'hue', d: 0.5 }, { k: '_', d: 0 }, { k: '_', d: 0 },
],
controls: [
{ kind: 'slider', key: 'angle', label: 'ángulo', min: 0, max: 6.2832, step: 0.01 },
{ kind: 'slider', key: 'hue', label: 'tono', min: 0, max: 1, 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', d: 1 }, { k: 'scale', d: 2 }, { k: '_', d: 0 }, { k: '_', d: 0 },
],
controls: [
{ kind: 'slider', key: 'speed', label: 'velocidad', min: 0, max: 3, step: 0.01 },
{ kind: 'slider', key: 'scale', label: 'escala', min: 0.5, max: 10, step: 0.1 },
],
body: (i) => `
let p = u.params[${i}];
let col = 0.5 + 0.5 * cos(u.time * p.x + 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', d: 8 }, { k: 'rot', d: 0.25 }, { k: '_', d: 0 }, { k: '_', d: 0 },
],
controls: [
{ kind: 'slider', key: 'scale', label: 'escala', min: 1, max: 30, step: 0.5 },
{ kind: 'slider', key: 'rot', label: 'rotación', min: -2, max: 2, 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: 'cx', d: 0 }, { k: 'cy', d: 0 }, { k: 'radius', d: 0.35 }, { k: 'soft', d: 0.01 },
],
controls: [
{ kind: 'xy', keys: ['cx', 'cy'], label: 'centro', min: -0.8, max: 0.8, step: 0.01 },
{ kind: 'slider', key: 'radius', label: 'radio', min: 0, max: 1, step: 0.01 },
{ kind: 'slider', key: 'soft', label: 'suavidad', min: 0.001, max: 0.1, step: 0.001 },
],
body: (i) => `
let p = u.params[${i}];
let aspect = u.resolution.x / u.resolution.y;
let pos = vec2<f32>((uv.x - 0.5) * aspect - p.x, uv.y - 0.5 - p.y);
let d = length(pos) - p.z;
let fill = smoothstep(p.w, -p.w, d);
return mix(c, vec4<f32>(1.0), fill);`,
},
stripes: {
kind: 'gen', label: 'stripes', desc: 'rayas animadas',
params: [
{ k: 'freq', d: 20 }, { k: 'speed', d: 1 }, { k: 'angle', d: 0.5 }, { k: '_', d: 0 },
],
controls: [
{ kind: 'slider', key: 'freq', label: 'frecuencia', min: 1, max: 80, step: 0.5 },
{ kind: 'slider', key: 'speed', label: 'velocidad', min: -5, max: 5, step: 0.05 },
{ kind: 'slider', key: 'angle', label: 'ángulo', min: 0, max: 3.1416, 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', d: 80 }, { k: 'seed', d: 7 }, { k: 'anim', d: 0 }, { k: '_', d: 0 },
],
controls: [
{ kind: 'slider', key: 'scale', label: 'escala', min: 1, max: 200, step: 1 },
{ kind: 'slider', key: 'seed', label: 'seed', min: 0, max: 100, step: 1 },
{ kind: 'slider', key: 'anim', label: 'animar', min: 0, max: 10, step: 0.1 },
],
body: (i) => `
let p = u.params[${i}];
let q = floor(uv * p.x + p.y + u.time * p.z);
let h = fract(sin(dot(q, vec2<f32>(12.9898, 78.233))) * 43758.5453);
return vec4<f32>(vec3<f32>(h), 1.0);`,
},
// ─── OPERADORES (toman c, devuelven c') ────────────────────────
invert: {
kind: 'op', label: 'invert', desc: '1 rgb',
params: [{ k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [],
body: () => `
return vec4<f32>(1.0 - c.rgb, c.a);`,
},
gamma: {
kind: 'op', label: 'gamma', desc: 'pow(rgb, γ)',
params: [{ k: 'g', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [{ kind: 'slider', key: 'g', label: 'γ', min: 0.1, max: 5, step: 0.01 }],
body: (i) => `
let g = max(0.01, u.params[${i}].x);
return vec4<f32>(pow(max(c.rgb, vec3<f32>(0.0)), vec3<f32>(g)), c.a);`,
},
contrast: {
kind: 'op', label: 'contrast', desc: '(rgb 0.5)·k + 0.5',
params: [{ k: 'k', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [{ kind: 'slider', key: 'k', label: 'k', min: 0, max: 3, 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', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [{ kind: 'slider', key: 's', label: 's', min: 0, max: 2, 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', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [{ kind: 'slider', key: 'h', label: 'h', min: 0, max: 1, 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 × color',
params: [{ k: 'r', d: 1 }, { k: 'g', d: 0.8 }, { k: 'b', d: 0.7 }, { k: '_', d: 0 }],
controls: [{ kind: 'color', keys: ['r', 'g', 'b'], label: 'tinte' }],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(c.rgb * vec3<f32>(p.x, p.y, p.z) * 2.0, c.a);`,
},
posterize: {
kind: 'op', label: 'posterize', desc: 'cuantizar a N niveles',
params: [{ k: 'levels', d: 5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [{ kind: 'slider', key: 'levels', label: 'niveles', min: 2, max: 16, 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: 'cx', d: 0 }, { k: 'cy', d: 0 }, { k: 'strength', d: 1 }, { k: 'radius', d: 0.5 }],
controls: [
{ kind: 'xy', keys: ['cx', 'cy'], label: 'centro', min: -0.5, max: 0.5, step: 0.01 },
{ kind: 'slider', key: 'strength', label: 'fuerza', min: 0, max: 2, step: 0.01 },
{ kind: 'slider', key: 'radius', label: 'radio', min: 0, max: 1.4, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let d = length((uv - vec2<f32>(0.5)) - vec2<f32>(p.x, p.y));
let v = 1.0 - smoothstep(p.w, p.w + 0.3, d) * p.z;
return vec4<f32>(c.rgb * v, c.a);`,
},
ripple: {
kind: 'op', label: 'ripple', desc: 'ondas radiales',
params: [{ k: 'freq', d: 30 }, { k: 'amp', d: 0.2 }, { k: 'speed', d: 2 }, { k: '_', d: 0 }],
controls: [
{ kind: 'slider', key: 'freq', label: 'frecuencia', min: 1, max: 100, step: 1 },
{ kind: 'slider', key: 'amp', label: 'amplitud', min: 0, max: 1, step: 0.01 },
{ kind: 'slider', key: 'speed', label: 'velocidad', min: -5, max: 5, 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: 'onda temporal',
params: [{ k: 'freq', d: 2 }, { k: 'amount', d: 0.3 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'slider', key: 'freq', label: 'frecuencia', min: 0, max: 10, step: 0.05 },
{ kind: 'slider', key: 'amount', label: 'cantidad', min: 0, max: 1, 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);`,
},
// ─── BLENDS (fan-in: toman a + b + amount) ─────────────────────
blend_mix: {
kind: 'blend', label: 'mix', desc: 'interpolación · mix(a, b, t)',
params: [{ k: 't', d: 0.5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 't', label: 't', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
return mix(a, b, u.params[${i}].x);`,
},
blend_multiply: {
kind: 'blend', label: 'multiply', desc: 'a · b',
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
let r = vec4<f32>(a.rgb * b.rgb, a.a);
return mix(a, r, u.params[${i}].x);`,
},
blend_screen: {
kind: 'blend', label: 'screen', desc: '1 (1a)(1b)',
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
let r = vec4<f32>(1.0 - (1.0 - a.rgb) * (1.0 - b.rgb), a.a);
return mix(a, r, u.params[${i}].x);`,
},
blend_add: {
kind: 'blend', label: 'add', desc: 'clamp(a + b)',
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
let r = vec4<f32>(min(a.rgb + b.rgb, vec3<f32>(1.0)), a.a);
return mix(a, r, u.params[${i}].x);`,
},
blend_difference: {
kind: 'blend', label: 'difference', desc: '|a b|',
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
let r = vec4<f32>(abs(a.rgb - b.rgb), a.a);
return mix(a, r, u.params[${i}].x);`,
},
blend_darken: {
kind: 'blend', label: 'darken', desc: 'min(a, b)',
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
let r = vec4<f32>(min(a.rgb, b.rgb), a.a);
return mix(a, r, u.params[${i}].x);`,
},
blend_lighten: {
kind: 'blend', label: 'lighten', desc: 'max(a, b)',
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b' },
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
],
body: (i) => `
let r = vec4<f32>(max(a.rgb, b.rgb), a.a);
return mix(a, r, u.params[${i}].x);`,
},
blend_mask: {
kind: 'blend', label: 'mask', desc: 'usa luma(b) como alpha',
params: [{ k: 'invert', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
controls: [
{ kind: 'source', label: 'source b (máscara)' },
{ kind: 'select', key: 'invert', label: 'invertir', options: ['no', 'sí'] },
],
body: (i) => `
let luma = dot(b.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
let m = select(luma, 1.0 - luma, u.params[${i}].x > 0.5);
return vec4<f32>(a.rgb * m, a.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 })),
blend: Object.entries(NODES).filter(([, v]) => v.kind === 'blend').map(([k, v]) => ({ name: k, ...v })),
};
// ═══════════════════════════════════════════════════════════════════
// Compilador: DAG → WGSL (con soporte para blends / fan-in)
// ═══════════════════════════════════════════════════════════════════
function compileDagToWGSL(pipeline) {
const safe = pipeline.slice(0, MAX_NODES);
const fns = safe.map((step, idx) => {
const def = NODES[step.name];
const sig = def.kind === 'blend'
? '(a: vec4<f32>, b: vec4<f32>, uv: vec2<f32>)'
: '(c: vec4<f32>, uv: vec2<f32>)';
return `fn node_${idx}${sig} -> vec4<f32> {${def.body(idx)}
}`;
}).join('\n\n');
let chain;
if (safe.length === 0) {
chain = ' var c = vec4<f32>(0.04, 0.04, 0.06, 1.0);';
} else {
const lines = safe.map((step, idx) => {
const def = NODES[step.name];
const prev = idx === 0 ? 'vec4<f32>(0.0, 0.0, 0.0, 1.0)' : `out_${idx - 1}`;
if (def.kind === 'blend') {
// resolver source por id (robusto ante reordenamientos)
let srcIdx = -1;
const srcId = step.meta?.sourceId;
if (srcId) {
srcIdx = safe.findIndex(s => s.id === srcId);
}
if (srcIdx < 0 || srcIdx >= idx) srcIdx = Math.max(0, idx - 2); // fallback: el de hace dos
const src = idx === 0 ? prev : `out_${Math.min(srcIdx, idx - 1)}`;
return ` let out_${idx} = node_${idx}(${prev}, ${src}, uv);`;
}
return ` let out_${idx} = node_${idx}(${prev}, uv);`;
});
chain = lines.join('\n');
}
const finalExpr = safe.length === 0 ? 'c' : `out_${safe.length - 1}`;
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;
${chain}
return ${finalExpr};
}
`;
}
// ═══════════════════════════════════════════════════════════════════
// Uniform buffer
// ═══════════════════════════════════════════════════════════════════
const UNIFORM_FLOATS = 4 + MAX_NODES * 4;
const UNIFORM_BYTES = UNIFORM_FLOATS * 4;
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 && p.k !== '_' ? (step.params[p.k] ?? p.d) : (p ? p.d : 0);
}
}
device.queue.writeBuffer(buffer, 0, data);
}
// ═══════════════════════════════════════════════════════════════════
// Hook WebGPU + 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);
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]);
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]);
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];
const obj = {};
for (const p of d.params) if (p.k !== '_') obj[p.k] = p.d;
return obj;
}
// ═══════════════════════════════════════════════════════════════════
// Controles UI
// ═══════════════════════════════════════════════════════════════════
function ParamSlider({ ctrl, value, onChange, color }) {
const display = ctrl.step >= 1 ? Math.round(value) : Number(value).toFixed(ctrl.step < 0.01 ? 3 : 2);
return (
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{ctrl.label}</span>
<input
type="range"
min={ctrl.min} max={ctrl.max} step={ctrl.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
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 XYPad({ ctrl, xValue, yValue, onChange, color }) {
const ref = useRef(null);
const [dragging, setDragging] = useState(false);
const xNorm = (xValue - ctrl.min) / (ctrl.max - ctrl.min);
const yNorm = (yValue - ctrl.min) / (ctrl.max - ctrl.min);
const update = useCallback((clientX, clientY) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const nx = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const ny = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
const xv = ctrl.min + nx * (ctrl.max - ctrl.min);
const yv = ctrl.min + ny * (ctrl.max - ctrl.min);
onChange(xv, yv);
}, [ctrl.min, ctrl.max, onChange]);
useEffect(() => {
if (!dragging) return;
const onMove = (e) => update(e.clientX, e.clientY);
const onUp = () => setDragging(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [dragging, update]);
return (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between">
<span className="font-mono text-[10px] text-neutral-400">{ctrl.label}</span>
<span className="font-mono text-[9px] text-neutral-500 tabular-nums">
{xValue.toFixed(2)} , {yValue.toFixed(2)}
</span>
</div>
<div
ref={ref}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
setDragging(true);
update(e.clientX, e.clientY);
}}
className="relative rounded cursor-crosshair select-none"
style={{
width: '100%',
height: '70px',
background: `${color}0a`,
border: `1px solid ${color}30`,
}}
>
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1, background: `${color}18` }} />
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 1, background: `${color}18` }} />
<div
style={{
position: 'absolute',
left: `${xNorm * 100}%`,
top: `${(1 - yNorm) * 100}%`,
transform: 'translate(-50%, -50%)',
width: 10, height: 10,
borderRadius: '50%',
background: color,
boxShadow: `0 0 10px ${color}aa`,
pointerEvents: 'none',
}}
/>
</div>
</div>
);
}
function ColorPicker({ ctrl, r, g, b, onChange, color }) {
const toHex = () => {
const h = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0');
return `#${h(r)}${h(g)}${h(b)}`;
};
const fromHex = (hex) => {
const rr = parseInt(hex.slice(1, 3), 16) / 255;
const gg = parseInt(hex.slice(3, 5), 16) / 255;
const bb = parseInt(hex.slice(5, 7), 16) / 255;
return [rr, gg, bb];
};
return (
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{ctrl.label}</span>
<label
onPointerDown={(e) => e.stopPropagation()}
className="flex-1 rounded h-6 cursor-pointer overflow-hidden relative"
style={{ border: `1px solid ${color}30` }}
>
<div style={{ position: 'absolute', inset: 0, background: toHex() }} />
<input
type="color"
value={toHex()}
onChange={(e) => {
const [rr, gg, bb] = fromHex(e.target.value);
onChange(rr, gg, bb);
}}
className="opacity-0 w-full h-full cursor-pointer"
/>
</label>
<span className="font-mono text-[9px] text-neutral-500 tabular-nums shrink-0">{toHex()}</span>
</div>
);
}
function SelectControl({ ctrl, value, options, onChange, color }) {
return (
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{ctrl.label}</span>
<select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="flex-1 font-mono text-[10px] rounded px-1.5 py-0.5 outline-none cursor-pointer"
style={{
background: 'rgba(0,0,0,0.4)',
border: `1px solid ${color}40`,
color,
}}
>
{options.map((opt, i) => (
<option key={i} value={i} style={{ background: '#0a0a0a', color: '#d4d4d4' }}>{opt}</option>
))}
</select>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Paleta item
// ═══════════════════════════════════════════════════════════════════
function PaletteItem({ node, color, onClick }) {
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/node-name', node.name);
e.dataTransfer.effectAllowed = 'copy';
}}
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>
);
}
// ═══════════════════════════════════════════════════════════════════
// Nodo del pipeline (con drag handle aislado)
// ═══════════════════════════════════════════════════════════════════
function PipelineNode({ step, index, pipeline, onRemove, onChangeParams, onChangeMeta, onDragStart, onDrop, isDragging }) {
const [armed, setArmed] = useState(false); // drag solo se arma desde el handle
const def = NODES[step.name];
const color = def.kind === 'gen' ? GEN_COLOR : def.kind === 'op' ? OP_COLOR : BLEND_COLOR;
const previousOptions = pipeline.slice(0, index).map((s, i) => `${i}: ${NODES[s.name].label}`);
const currentSourceIdx = (() => {
const srcId = step.meta?.sourceId;
const idx = srcId ? pipeline.findIndex(s => s.id === srcId) : -1;
return idx >= 0 && idx < index ? idx : Math.max(0, index - 2);
})();
const setSource = (optionIdx) => {
const srcStep = pipeline[optionIdx];
if (srcStep) onChangeMeta(index, { sourceId: srcStep.id });
};
const renderControl = (ctrl, i) => {
if (ctrl.kind === 'slider') {
return (
<ParamSlider
key={i} ctrl={ctrl} color={color}
value={step.params[ctrl.key] ?? def.params.find(p => p.k === ctrl.key)?.d ?? 0}
onChange={(v) => onChangeParams(index, { [ctrl.key]: v })}
/>
);
}
if (ctrl.kind === 'xy') {
const [xk, yk] = ctrl.keys;
return (
<XYPad
key={i} ctrl={ctrl} color={color}
xValue={step.params[xk] ?? 0} yValue={step.params[yk] ?? 0}
onChange={(xv, yv) => onChangeParams(index, { [xk]: xv, [yk]: yv })}
/>
);
}
if (ctrl.kind === 'color') {
const [rk, gk, bk] = ctrl.keys;
return (
<ColorPicker
key={i} ctrl={ctrl} color={color}
r={step.params[rk] ?? 0} g={step.params[gk] ?? 0} b={step.params[bk] ?? 0}
onChange={(r, g, b) => onChangeParams(index, { [rk]: r, [gk]: g, [bk]: b })}
/>
);
}
if (ctrl.kind === 'select') {
return (
<SelectControl
key={i} ctrl={ctrl} color={color}
value={step.params[ctrl.key] ?? 0}
options={ctrl.options}
onChange={(v) => onChangeParams(index, { [ctrl.key]: v })}
/>
);
}
if (ctrl.kind === 'source') {
if (previousOptions.length === 0) {
return (
<div key={i} className="font-mono text-[9px] text-rose-400/70 italic">
· requiere al menos un nodo antes ·
</div>
);
}
return (
<SelectControl
key={i}
ctrl={{ label: ctrl.label }}
value={currentSourceIdx}
options={previousOptions}
onChange={setSource}
color={color}
/>
);
}
return null;
};
return (
<div
draggable={armed}
onDragStart={(e) => {
if (!armed) { e.preventDefault(); return; }
e.dataTransfer.setData('text/reorder-index', String(index));
e.dataTransfer.effectAllowed = 'move';
onDragStart?.(index);
}}
onDragEnd={() => setArmed(false)}
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,
}}
>
{/* ── HEADER · única zona que arma el drag ── */}
<div
className="flex items-center gap-2 px-2.5 py-1.5 border-b cursor-grab active:cursor-grabbing select-none"
style={{ borderColor: `${color}20` }}
onPointerDown={() => setArmed(true)}
onPointerUp={() => setArmed(false)}
onPointerLeave={() => setArmed(false)}
>
<GripVertical size={11} className="text-neutral-600 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
onPointerDown={(e) => e.stopPropagation()}
onClick={() => onRemove(index)}
className="text-neutral-500 hover:text-neutral-200 p-0.5"
aria-label="Eliminar"
>
<X size={11} />
</button>
</div>
{/* ── CONTROLES ── */}
{def.controls && def.controls.length > 0 ? (
<div className="px-2.5 py-2 flex flex-col gap-2">
{def.controls.map((c, i) => renderControl(c, i))}
</div>
) : (
<div className="px-2.5 py-1.5 font-mono text-[9px] text-neutral-600 italic">sin parámetros</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Badges y overlays
// ═══════════════════════════════════════════════════════════════════
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. prueba abrir en pestaña nueva si estás en un iframe.
</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(() => {
const n1 = { id: uid(), name: 'plasma', params: defaultParams('plasma'), meta: {} };
const n2 = { id: uid(), name: 'circle', params: defaultParams('circle'), meta: {} };
const n3 = { id: uid(), name: 'blend_multiply', params: defaultParams('blend_multiply'), meta: { sourceId: n1.id } };
const n4 = { id: uid(), name: 'vignette', params: defaultParams('vignette'), meta: {} };
return [n1, n2, n3, n4];
});
const [dragIndex, setDragIndex] = useState(null);
const [wgslOpen, setWgslOpen] = useState(false);
// topologyKey captura nombre + meta relevantes a la generación de código
const topologyKey = useMemo(
() => pipeline.map(s => `${s.name}:${s.meta?.sourceId ?? ''}`).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 => {
const newNode = { id: uid(), name, params: defaultParams(name), meta: {} };
// si es un blend, elegir por defecto el nodo de hace dos como fuente (o el primero si no hay)
if (NODES[name].kind === 'blend' && p.length >= 1) {
const fallbackSrc = p[Math.max(0, p.length - 2)];
if (fallbackSrc) newNode.meta.sourceId = fallbackSrc.id;
}
return [...p, newNode];
});
};
const removeNode = (idx) => setPipeline(p => p.filter((_, i) => i !== idx));
const changeParams = (idx, updates) => {
setPipeline(p => p.map((s, i) => i === idx ? { ...s, params: { ...s.params, ...updates } } : s));
};
const changeMeta = (idx, updates) => {
setPipeline(p => p.map((s, i) => i === idx ? { ...s, meta: { ...(s.meta || {}), ...updates } } : 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];
const newNode = { id: uid(), name: nodeName, params: defaultParams(nodeName), meta: {} };
if (NODES[nodeName].kind === 'blend' && targetIdx >= 1) {
const fallbackSrc = copy[Math.max(0, targetIdx - 1)];
if (fallbackSrc) newNode.meta.sourceId = fallbackSrc.id;
}
copy.splice(targetIdx, 0, newNode);
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 · 004</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">shader dag · fan-in</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 con <em className="italic" style={{ color: BLEND_COLOR }}>blends</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
gen · op · blend · xy pads, colores y selectores · drag desde el handle
</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 cols */}
<main style={{
display: 'grid',
gridTemplateColumns: '180px minmax(300px, 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>
{['gen', 'op', 'blend'].map(kind => {
const meta = {
gen: { color: GEN_COLOR, label: 'generadores', sub: 'producen color · ignoran c' },
op: { color: OP_COLOR, label: 'operadores', sub: 'transforman c' },
blend: { color: BLEND_COLOR, label: 'blends', sub: 'fan-in: a + b' },
}[kind];
return (
<section key={kind} className="mb-5 last:mb-0">
<div className="flex items-center gap-2 mb-1 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: meta.color, boxShadow: `0 0 8px ${meta.color}` }} />
<span className="font-display text-sm italic" style={{ color: meta.color }}>{meta.label}</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1">{meta.sub}</div>
<div className="flex flex-col gap-0.5">
{NODES_BY_KIND[kind].map(n => (
<PaletteItem key={n.name} node={n} color={meta.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">
drag desde <span style={{color: ACCENT}}> header</span> para reordenar · sliders y pads son independientes
</div>
</aside>
{/* PIPELINE */}
<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}
pipeline={pipeline}
onRemove={removeNode}
onChangeParams={changeParams}
onChangeMeta={changeMeta}
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 · <span style={{ color: BLEND_COLOR }}>blend</span> combina dos nodos (fan-in real)
</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">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>
)}
<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">
blends leen dos variables · <span style={{ color: BLEND_COLOR }}>out_&lt;idx-1&gt;</span> y <span style={{ color: BLEND_COLOR }}>out_&lt;source&gt;</span> · el DAG ya no es lineal
</div>
</aside>
</main>
</div>
</div>
);
}