wandres.dev
EL PRIMER TRIÁNGULO · Pipeline completo mínimo

El triángulo completo: un archivo que puedes copiar y ejecutar

Un HTML autocontenido con detección de soporte, búfer de vértices, uniformes, redimensionado y bucle de animación, explicado bloque a bloque.

⏱ 20 min

Todo lo anterior por separado no sirve de nada hasta que se ve funcionando. Esto es un archivo HTML completo: sin dependencias, sin herramientas de compilación, sin servidor más allá de uno estático. Guárdalo como index.html, sírvelo por HTTPS o desde localhost, ábrelo, y hay un triángulo girando. A partir de ahí, cada cosa que modifiques te enseña algo.

🎯 Al terminar esta lección sabrás
  • Ejecutar una aplicación completa de WebGPU desde cero.
  • Identificar en el código real cada concepto de los niveles anteriores.
  • Diagnosticar los cuatro fallos más probables al ejecutarlo.
  • Modificar el ejemplo para verificar que entiendes cada pieza.

El archivo completo

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Triangulo WebGPU</title>
<style>
  html, body { margin: 0; height: 100%; background: #11111b; color: #cdd6f4;
               font: 15px/1.5 system-ui, sans-serif; }
  #lienzo { display: block; width: 100%; height: 100%; }
  #aviso { position: absolute; inset: 0; display: grid; place-items: center;
           padding: 2rem; text-align: center; }
  #aviso[hidden] { display: none; }
</style>
</head>
<body>
<canvas id="lienzo"></canvas>
<div id="aviso" hidden></div>

<script type="module">
const canvas = document.querySelector('#lienzo');
const aviso  = document.querySelector('#aviso');

function fallar(mensaje) {
  aviso.textContent = mensaje;
  aviso.hidden = false;
  canvas.style.display = 'none';
}

// ---------------------------------------------------------------- WGSL
const WGSL = `
struct Uniformes {
  angulo: f32,
  aspecto: f32,
  relleno: vec2f,
}

@group(0) @binding(0) var<uniform> u: Uniformes;

struct EntradaVertice {
  @location(0) pos: vec2f,
  @location(1) color: vec3f,
}

struct SalidaVertice {
  @builtin(position) clip: vec4f,
  @location(0) color: vec3f,
}

@vertex
fn vs(entrada: EntradaVertice) -> SalidaVertice {
  let c = cos(u.angulo);
  let s = sin(u.angulo);
  let girado = vec2f(
    entrada.pos.x * c - entrada.pos.y * s,
    entrada.pos.x * s + entrada.pos.y * c
  );
  var salida: SalidaVertice;
  salida.clip  = vec4f(girado.x / u.aspecto, girado.y, 0.0, 1.0);
  salida.color = entrada.color;
  return salida;
}

@fragment
fn fs(entrada: SalidaVertice) -> @location(0) vec4f {
  return vec4f(entrada.color, 1.0);
}
`;

// ------------------------------------------------------------ arranque
async function main() {
  if (!navigator.gpu) {
    return fallar('Este navegador no expone WebGPU. Recuerda que hace falta ' +
                  'un contexto seguro: HTTPS o localhost.');
  }

  const adapter = await navigator.gpu.requestAdapter({
    powerPreference: 'high-performance',
  });
  if (!adapter) {
    return fallar('No hay ningun adaptador de GPU disponible.');
  }

  const device = await adapter.requestDevice({ label: 'principal' });
  device.addEventListener('uncapturederror', (e) => {
    console.error('[webgpu]', e.error.message);
  });
  device.lost.then((info) => {
    if (info.reason !== 'destroyed') {
      fallar('Se ha perdido el dispositivo: ' + info.message);
    }
  });

  const context = canvas.getContext('webgpu');
  const FORMATO = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format: FORMATO, alphaMode: 'opaque' });

  // ------------------------------------------------------------- shader
  const modulo = device.createShaderModule({ label: 'triangulo', code: WGSL });
  const info = await modulo.getCompilationInfo();
  for (const m of info.messages) {
    console[m.type === 'error' ? 'error' : 'warn'](
      `WGSL ${m.type} en ${m.lineNum}:${m.linePos} - ${m.message}`
    );
  }
  if (info.messages.some((m) => m.type === 'error')) {
    return fallar('El shader no compila. Mira la consola.');
  }

  // ------------------------------------------------------------ vertices
  // 3 vertices, 5 floats cada uno: x, y, r, g, b. Stride de 20 bytes.
  const vertices = new Float32Array([
     0.00,  0.60,   1.00, 0.35, 0.55,
    -0.55, -0.40,   0.35, 0.75, 1.00,
     0.55, -0.40,   0.60, 1.00, 0.55,
  ]);

  const bufVertices = device.createBuffer({
    label: 'vertices',
    size: vertices.byteLength,          // 60 bytes, multiplo de 4
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true,
  });
  new Float32Array(bufVertices.getMappedRange()).set(vertices);
  bufVertices.unmap();

  // ------------------------------------------------------------ uniformes
  // El struct de WGSL ocupa 16 bytes: f32 + f32 + vec2f de relleno.
  const datosUniformes = new Float32Array(4);
  const bufUniformes = device.createBuffer({
    label: 'uniformes',
    size: datosUniformes.byteLength,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  // ------------------------------------------------------------- pipeline
  const pipeline = await device.createRenderPipelineAsync({
    label: 'triangulo',
    layout: 'auto',
    vertex: {
      module: modulo,
      entryPoint: 'vs',
      buffers: [{
        arrayStride: 20,
        attributes: [
          { shaderLocation: 0, offset: 0, format: 'float32x2' },
          { shaderLocation: 1, offset: 8, format: 'float32x3' },
        ],
      }],
    },
    fragment: {
      module: modulo,
      entryPoint: 'fs',
      targets: [{ format: FORMATO }],
    },
    primitive: { topology: 'triangle-list', cullMode: 'none' },
  });

  const grupo = device.createBindGroup({
    label: 'uniformes',
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: bufUniformes } }],
  });

  // ------------------------------------------------------------- tamano
  const MAX_DPR = 2;
  function ajustarTamano() {
    const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
    const maxDim = device.limits.maxTextureDimension2D;
    const r = canvas.getBoundingClientRect();
    const w = Math.max(1, Math.min(Math.round(r.width  * dpr), maxDim));
    const h = Math.max(1, Math.min(Math.round(r.height * dpr), maxDim));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }
  new ResizeObserver(ajustarTamano).observe(canvas);
  ajustarTamano();

  // -------------------------------------------------------------- bucle
  function frame(tMs) {
    ajustarTamano();

    datosUniformes[0] = tMs * 0.001;                    // angulo
    datosUniformes[1] = canvas.width / canvas.height;   // aspecto
    device.queue.writeBuffer(bufUniformes, 0, datosUniformes);

    const encoder = device.createCommandEncoder({ label: 'frame' });
    const pase = encoder.beginRenderPass({
      label: 'principal',
      colorAttachments: [{
        view: context.getCurrentTexture().createView(),
        clearValue: { r: 0.067, g: 0.067, b: 0.106, a: 1 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });

    pase.setPipeline(pipeline);
    pase.setBindGroup(0, grupo);
    pase.setVertexBuffer(0, bufVertices);
    pase.draw(3);
    pase.end();

    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

main().catch((e) => {
  console.error(e);
  fallar('Error al iniciar WebGPU: ' + e.message);
});
</script>
</body>
</html>

Qué hace cada bloque

El WGSL declara un struct Uniformes de 16 bytes exactos: dos f32 seguidos de un vec2f de relleno. Ese relleno no es decorativo: en el espacio de dirección uniform, la disposición se redondea a múltiplos de 16, y escribirlo explícitamente hace que la correspondencia con el Float32Array(4) de JavaScript sea evidente en lugar de accidental.

El arranque cubre los tres modos de fallo del nivel 3 con mensajes distintos, instala el manejador de errores no capturados antes de crear nada, y engancha device.lost.

El módulo se compila y se consultan sus mensajes. Si hay un error de sintaxis, aparece con línea y columna en la consola en lugar de manifestarse como un pipeline inválido.

El búfer de vértices usa mappedAtCreation porque los datos se conocen y no cambian. Su usage es solo VERTEX: no necesita COPY_DST porque nunca se escribe con writeBuffer.

El búfer de uniformes, al revés: UNIFORM | COPY_DST porque se escribe cada fotograma.

El pipeline usa la variante asíncrona, que espera a que la compilación real termine. La disposición del búfer de vértices declara paso de 20 bytes y dos atributos, con el color a partir del byte 8, que es donde acaban los dos f32 de la posición.

El ajuste de tamaño compara antes de asignar, acota con Math.max(1, ...) contra la dimensión cero y con maxTextureDimension2D por arriba, y limita la densidad de píxeles a 2.

El bucle hace exactamente cuatro cosas: actualizar uniformes, grabar comandos, enviar y volver a pedir un fotograma. No crea ningún objeto de GPU. Esa ausencia es la prueba de que la arquitectura es correcta.

Los cuatro fallos más probables

Pantalla en blanco con «Este navegador no expone WebGPU» y sabes que sí lo tiene. Estás abriendo el archivo con file:// o sirviéndolo por HTTP plano. WebGPU exige contexto seguro. Sirve la carpeta con cualquier servidor estático en localhost.

Fondo del canvas correcto pero sin triángulo. Casi siempre es el arrayStride o los offset de los atributos. Si el color aparece negro, el atributo de color está leyendo del sitio equivocado. Comprueba que el paso son 20 bytes y el desplazamiento del color 8.

El triángulo aparece deformado y estirado. Falta el ajuste de tamaño del canvas o la corrección de aspecto en el shader. Prueba a quitar la división por u.aspecto para ver la diferencia.

Errores de validación en cada fotograma. Casi siempre es el formato: el canvas configurado con uno y el targets[0].format del pipeline con otro. La constante FORMATO se usa en los dos sitios precisamente para que eso no pueda pasar.

El siguiente paso no es añadir cosas: es quitarlas y ver qué se rompe

La tentación después de ver el triángulo es añadir: una textura, una cámara, un modelo. Es el orden que hace que la gente acabe con un archivo de mil líneas que funciona y que no entiende.

El ejercicio que de verdad consolida es el contrario, y consiste en romper el ejemplo de forma dirigida y predecir el síntoma antes de ejecutar. Cada una de estas mutaciones tarda diez segundos y enseña una cosa concreta:

Cambia arrayStride de 20 a 16 y predice qué se ve. Cambia el offset del color de 8 a 12. Quita COPY_DST del búfer de uniformes. Quita bufVertices.unmap(). Cambia loadOp de 'clear' a 'load'. Cambia draw(3) por draw(3, 5) sin tocar el shader, y luego usa @builtin(instance_index) para separarlas. Pon el entryPoint del fragment shader mal escrito. Quita el pase.end(). Cambia el format del target a la otra opción de las dos posibles.

La disciplina que hay que imponerse es escribir la predicción antes de recargar: qué se ve, qué dice la consola, si es un error de validación o un fallo silencioso. Acertar significa que el modelo mental es correcto. Fallar es infinitamente más valioso, porque acabas de localizar exactamente dónde estaba mal.

Ese método —mutar y predecir— es la razón por la que este archivo tiene que ser pequeño. Un ejemplo de mil líneas no se puede mutar: hay demasiadas cosas que podrían explicar cualquier síntoma. Un ejemplo de ciento cincuenta líneas donde cada una tiene un porqué es un laboratorio, y vale más que veinte tutoriales leídos.

⚔️ Reto práctico

Modifica el ejemplo para dibujar cinco triángulos girando a velocidades distintas, sin añadir ningún búfer de vértices y sin llamar a draw más de una vez.

Pistas: draw(3, 5) lanza cinco instancias. Añade @builtin(instance_index) inst: u32 como parámetro del vertex shader y úsalo para desplazar cada instancia en X y para escalar el ángulo. Comprueba que sigue habiendo exactamente un submit por fotograma y que el bucle no crea ningún objeto nuevo.

El modelo de grabación que el bucle usa sin explicar merece una vuelta más: por qué WebGPU graba en vez de ejecutar.