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

506 lines
21 KiB
React

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>
);
}