Skip to content

Procesamiento de imágenes en el browser con WebGPU

Published:

En el artículo anterior vimos qué es WebGPU y cuándo tiene sentido usarlo. Ahora toca adentrarse en el código. El procesamiento de imágenes es el caso de uso más accesible para empezar con WebGPU: los datos son conocidos (píxeles), la operación es uniforme, y el beneficio de rendimiento es medible y visible.

Vamos a implementar un filtro de desenfoque gaussiano (Gaussian blur) primero con Canvas 2D y luego con WebGPU, y comparamos los resultados.

El problema con Canvas 2D

Canvas 2D tiene filter: blur(), que es cómodo pero no configurable. Si necesitamos algo más elaborado, como un blur con radio variable, una convolución personalizada o un filtro en tiempo real sobre una secuencia de frames, terminamos procesando píxeles manualmente en la CPU.

// Blur manual en Canvas 2D — esto corre en la CPU
function applyBlurCPU(imageData, radius) {
  const { data, width, height } = imageData;
  const output = new Uint8ClampedArray(data.length);

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let r = 0,
        g = 0,
        b = 0,
        count = 0;

      for (let ky = -radius; ky <= radius; ky++) {
        for (let kx = -radius; kx <= radius; kx++) {
          const nx = Math.max(0, Math.min(width - 1, x + kx));
          const ny = Math.max(0, Math.min(height - 1, y + ky));
          const i = (ny * width + nx) * 4;
          r += data[i];
          g += data[i + 1];
          b += data[i + 2];
          count++;
        }
      }

      const i = (y * width + x) * 4;
      output[i] = r / count;
      output[i + 1] = g / count;
      output[i + 2] = b / count;
      output[i + 3] = data[i + 3];
    }
  }

  return new ImageData(output, width, height);
}

Para una imagen de 1920×1080 con radio 5, esto son ~230 millones de operaciones. En la CPU, eso tarda entre 200ms y 500ms. Inutilizable en tiempo real.

Mismo filtro con WebGPU

Con WebGPU, el mismo blur se ejecuta en la GPU, con miles de píxeles procesados en paralelo.

Setup inicial

async function initWebGPU() {
  if (!navigator.gpu) throw new Error("WebGPU no disponible");

  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error("No se encontró adaptador GPU");

  const device = await adapter.requestDevice();
  return device;
}

El shader WGSL

El shader es el programa que corre en la GPU. Cada invocación procesa un píxel:

@group(0) @binding(0) var inputTex: texture_2d<f32>;
@group(0) @binding(1) var outputTex: texture_storage_2d<rgba8unorm, write>;

@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
  let size   = vec2<i32>(textureDimensions(inputTex));
  let coords = vec2<i32>(i32(id.x), i32(id.y));

  if (coords.x >= size.x || coords.y >= size.y) { return; }

  let radius: i32 = 5;
  var color = vec4<f32>(0.0);
  var count: f32 = 0.0;

  for (var ky: i32 = -radius; ky <= radius; ky++) {
    for (var kx: i32 = -radius; kx <= radius; kx++) {
      let sample = clamp(coords + vec2<i32>(kx, ky), vec2<i32>(0), size - 1);
      color += textureLoad(inputTex, sample, 0);
      count += 1.0;
    }
  }

  textureStore(outputTex, coords, color / count);
}

Pipeline y ejecución

async function applyBlurGPU(device, imageBitmap) {
  const { width, height } = imageBitmap;

  // Textura de entrada
  const inputTexture = device.createTexture({
    size: [width, height],
    format: "rgba8unorm",
    usage:
      GPUTextureUsage.TEXTURE_BINDING |
      GPUTextureUsage.COPY_DST |
      GPUTextureUsage.RENDER_ATTACHMENT,
  });

  // Copiamos la imagen a la textura GPU
  device.queue.copyExternalImageToTexture(
    { source: imageBitmap },
    { texture: inputTexture },
    [width, height]
  );

  // Textura de salida
  const outputTexture = device.createTexture({
    size: [width, height],
    format: "rgba8unorm",
    usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.COPY_SRC,
  });

  // Shader module
  const shaderModule = device.createShaderModule({ code: BLUR_SHADER_WGSL });

  // Pipeline
  const pipeline = device.createComputePipeline({
    layout: "auto",
    compute: { module: shaderModule, entryPoint: "main" },
  });

  // Bind group
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: inputTexture.createView() },
      { binding: 1, resource: outputTexture.createView() },
    ],
  });

  // Encodar y enviar comandos
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(Math.ceil(width / 16), Math.ceil(height / 16));
  pass.end();

  device.queue.submit([encoder.finish()]);
  await device.queue.onSubmittedWorkDone();

  return outputTexture;
}

Benchmarks

Aplicando blur con radio 5 a imágenes de distintas resoluciones:

WebGPU Image Benchmark

Mediciones reales en MacBook Air M4, GPU Apple Metal-3. Blur gaussiano radio 5, mediana de 5 iteraciones. Los tiempos varían según hardware · ejecuta el benchmark en tu dispositivo (código fuente).

La diferencia se amplía con la resolución porque el número de píxeles crece cuadráticamente y la GPU escala mucho mejor que la CPU.

Casos de uso reales

Con este mismo patrón podemos implementar:

La clave es siempre la misma: si la operación se puede expresar como “aplica esto a cada píxel de forma independiente”, la GPU va a ganar.

Consideraciones

Latencia de transferencia

Copiar datos entre CPU y GPU tiene coste. Para imágenes procesadas una sola vez, ese coste puede ser mayor que el beneficio. WebGPU tiene sentido cuando el procesamiento es recurrente (tiempo real, múltiples frames).

Formato de textura

WebGPU trabaja con formatos específicos (rgba8unorm, rgba16float…). Hay que asegurarse de usar el formato adecuado según la precisión que necesitemos.

Fallback

Siempre implementar la versión Canvas 2D como fallback para Firefox y navegadores sin WebGPU.

La serie

  1. WebGPU: el nuevo motor de rendimiento del navegador
  2. Procesamiento de imágenes en el browser con WebGPU — estás aquí
  3. ML en el browser con WebGPU: TensorFlow.js con el backend WebGPU, inferencia en tiempo real.
  4. Edición de vídeo en el browser con WebGPU: efectos en tiempo real frame a frame.

Conclusión

El mismo filtro que la CPU tarda 280ms en aplicar, la GPU lo resuelve en 8ms. Para cualquier aplicación web que procese imágenes de forma intensiva (editores, herramientas de diseño, filtros en tiempo real), WebGPU es la opción correcta.

En el próximo artículo subimos la complejidad: ML en el browser con TensorFlow.js usando WebGPU como backend.


Next Post
WebGPU: el nuevo motor de rendimiento del navegador