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, }; @group(0) @binding(0) var u: Uniforms; @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { // triángulo fullscreen (truco de tres vértices) let p = array, 3>( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0) ); return vec4(p[i], 0.0, 1.0); } `; const PRESETS = { plasma: SHADER_HEADER + ` @fragment fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { let uv = pos.xy / u.resolution; let c = 0.5 + 0.5 * cos(u.time + uv.xyx + vec3(0.0, 2.0, 4.0)); return vec4(c, 1.0); } `, circle: SHADER_HEADER + ` @fragment fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { // 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(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(0.05, 0.06, 0.08); let col = mix(bg, vec3(0.94, 0.55, 0.72), fill) + vec3(ring * 0.9); return vec4(col, 1.0); } `, checker: SHADER_HEADER + ` @fragment fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { // tablero rotando let uv = pos.xy / u.resolution - 0.5; let aspect = u.resolution.x / u.resolution.y; let p0 = vec2(uv.x * aspect, uv.y); let rot = u.time * 0.25; let c = cos(rot); let s = sin(rot); let p = vec2(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(0.93, 0.91, 0.86), vec3(0.10, 0.09, 0.13), chk); return vec4(col, 1.0); } `, waves: SHADER_HEADER + ` @fragment fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { // 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(v, pow(v, 2.0) * 0.5 + 0.3, 1.0 - v * 0.7); return vec4(col, 1.0); } `, sdfBlob: SHADER_HEADER + ` fn sdCircle(p: vec2, 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) -> @location(0) vec4 { // 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(uv.x * aspect, uv.y); let t = u.time; let c1 = sdCircle(p - vec2(cos(t) * 0.4, sin(t * 1.3) * 0.3), 0.25); let c2 = sdCircle(p - vec2(cos(t * 0.7 + 2.0) * 0.35, sin(t * 0.9) * 0.25), 0.22); let c3 = sdCircle(p - vec2(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(0.04, 0.02, 0.06); let core = vec3(0.95, 0.4, 0.6); let col = bg + core * (fill * 0.9 + glow * 0.3); return vec4(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 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 (
{/* HEADER */}
lab · 002 / shaders · webgpu /

laboratorio de píxels

editor WGSL en vivo · cada tecla recompila el fragment shader · el uniform block expone time y resolution

t = {timeT.toFixed(2)}s {fps} fps
{/* MAIN · 2 columnas: canvas | editor */}
{/* IZQUIERDA · CANVAS */}
canvas fragment shader · fullscreen triangle
{status !== 'ready' && }
consejo · el vertex shader genera un triángulo que cubre toda la pantalla. todo el trabajo interesante pasa en el fragment.
{/* DERECHA · EDITOR */}
{/* presets */}
presets
{PRESET_ORDER.map(p => { const active = p.key === activePreset; return ( ); })}
{/* editor */}
fuente · wgsl {shaderError ? ( error de compilación ) : ( ✓ compilado )}