wandres.dev
BIND GROUPS II · Frecuencia de cambio y organización

Los cuatro grupos de un renderer que escala

El reparto concreto en cuatro bind groups —aplicación, frame, material y objeto— con sus layouts, su WGSL y el bucle de dibujado que los recorre en el orden correcto.

⏱ 21 min

El principio de agrupar por frecuencia no vale nada hasta que lo conviertes en cuatro layouts concretos y un bucle que los recorre. Este es ese reparto: el que usan, con variaciones menores de nombre, casi todos los motores construidos sobre APIs explícitas. Lo importante no es copiarlo sino entender por qué cada recurso está donde está, para saber moverlo cuando tu escena no se parezca a esta.

🎯 Al terminar esta lección sabrás
  • Escribir los cuatro bind group layouts de un renderer completo con sus entradas exactas.
  • Traducir ese reparto al WGSL con los @group y @binding correspondientes.
  • Escribir el bucle de dibujado anidado que ata cada grupo en su nivel.
  • Justificar la posición de cada recurso a partir de su frecuencia de cambio.

Los cuatro layouts

Grupo 0, la aplicación y el frame. Todo lo que se ata una vez por render pass. En un renderer con un solo pass principal, aquí conviven las constantes de aplicación y los datos de cámara sin coste añadido, porque el grupo se ata igualmente una vez.

const layout0 = device.createBindGroupLayout({
  label: 'frame',
  entries: [
    // Cámara: vista, proyección, viewProj, posición, tiempo.
    { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: { type: 'uniform', minBindingSize: 208 } },
    // Luces: array de tamaño variable, por eso storage y no uniform.
    { binding: 1, visibility: GPUShaderStage.FRAGMENT,
      buffer: { type: 'read-only-storage' } },
    // Mapa de sombras ya renderizado y su sampler de comparación.
    { binding: 2, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'depth', viewDimension: '2d' } },
    { binding: 3, visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'comparison' } },
    // LUT de tonemapping: constante durante toda la aplicación.
    { binding: 4, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'float', viewDimension: '3d' } },
    { binding: 5, visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'filtering' } },
  ],
});

Grupo 1, el material. Los parámetros y las texturas que definen cómo se ve una superficie. Se ata una vez por material, así que el bucle tiene que estar ordenado por material para que el número coincida con el número de materiales y no con el de objetos.

const layout1 = device.createBindGroupLayout({
  label: 'material',
  entries: [
    { binding: 0, visibility: GPUShaderStage.FRAGMENT,
      buffer: { type: 'uniform', minBindingSize: 32 } },
    { binding: 1, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'float', viewDimension: '2d' } },  // albedo
    { binding: 2, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'float', viewDimension: '2d' } },  // normal
    { binding: 3, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'float', viewDimension: '2d' } },  // metálico-rugosidad
    { binding: 4, visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'filtering' } },
  ],
});

Grupo 2, el objeto. Aquí es donde la mayoría de los renderers pierden la partida, porque el instinto es meter una matriz por objeto en un uniform buffer y crear dos mil bind groups. La versión que escala usa un dynamic offset: un solo bind group, un solo buffer, y el offset se mueve en tiempo de dibujado.

const layout2 = device.createBindGroupLayout({
  label: 'objeto',
  entries: [
    { binding: 0, visibility: GPUShaderStage.VERTEX,
      buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: 128 } },
  ],
});

Grupo 3, la técnica. El cuarto grupo se reserva para recursos que dependen del algoritmo y no del contenido: el buffer de clusters del clustered lighting, la textura de profundidad del frame anterior para el motion blur, el atlas de las cascadas de sombra. No todos los pipelines lo usan, y ahí es donde el hueco null en el pipeline layout gana su sitio.

const layout3 = device.createBindGroupLayout({
  label: 'tecnica',
  entries: [
    { binding: 0, visibility: GPUShaderStage.FRAGMENT,
      buffer: { type: 'read-only-storage' } },   // índices de luces por cluster
    { binding: 1, visibility: GPUShaderStage.FRAGMENT,
      buffer: { type: 'read-only-storage' } },   // rangos por cluster
  ],
});

El WGSL que le corresponde

La estructura del shader refleja el reparto uno a uno, y eso es una ventaja de legibilidad que no se aprecia hasta que mantienes veinte shaders:

struct Camara {
  vista      : mat4x4<f32>,
  proyeccion : mat4x4<f32>,
  viewProj   : mat4x4<f32>,
  posicion   : vec3<f32>,
  tiempo     : f32,
};

struct Luz {
  posicion  : vec3<f32>,
  intensidad: f32,
  color     : vec3<f32>,
  radio     : f32,
};

struct Material {
  colorBase : vec4<f32>,
  rugosidad : f32,
  metalico  : f32,
  normalMul : f32,
  _relleno  : f32,
};

struct Objeto {
  modelo : mat4x4<f32>,
  normal : mat4x4<f32>,
};

@group(0) @binding(0) var<uniform>            camara   : Camara;
@group(0) @binding(1) var<storage, read>      luces    : array<Luz>;
@group(0) @binding(2) var                     sombras  : texture_depth_2d;
@group(0) @binding(3) var                     cmpSombra: sampler_comparison;
@group(0) @binding(4) var                     lut      : texture_3d<f32>;
@group(0) @binding(5) var                     lutSmp   : sampler;

@group(1) @binding(0) var<uniform>            material : Material;
@group(1) @binding(1) var                     albedo   : texture_2d<f32>;
@group(1) @binding(2) var                     normalTx : texture_2d<f32>;
@group(1) @binding(3) var                     mrTx     : texture_2d<f32>;
@group(1) @binding(4) var                     smp      : sampler;

@group(2) @binding(0) var<uniform>            objeto   : Objeto;

@group(3) @binding(0) var<storage, read>      indicesLuz : array<u32>;
@group(3) @binding(1) var<storage, read>      rangosLuz  : array<vec2<u32>>;

Observa que el dynamic offset del grupo 2 no aparece en el WGSL. El shader ve un uniform normal; el offset lo aplica la API al atar. Es una de las razones por las que layout: 'auto' no puede generarlo.

El bucle que los recorre

Con los cuatro grupos repartidos así, el bucle de dibujado tiene una forma que se explica sola:

function dibujarOpacos(pass, escena) {
  pass.setBindGroup(0, grupoFrame);      // 1 vez
  pass.setBindGroup(3, grupoTecnica);    // 1 vez

  for (const material of escena.materialesOrdenados) {
    pass.setPipeline(material.pipeline);
    pass.setBindGroup(1, material.grupo);          // 1 vez por material

    for (const objeto of material.objetos) {
      pass.setBindGroup(2, grupoObjeto, [objeto.offset]); // 1 vez por objeto
      pass.setVertexBuffer(0, objeto.malla.vertices);
      pass.setIndexBuffer(objeto.malla.indices, 'uint32');
      pass.drawIndexed(objeto.malla.cuenta);
    }
  }
}

Dos llamadas fuera de todo, una por material, una por objeto. Para 2000 objetos y 50 materiales: 2052 llamadas a setBindGroup y exactamente 53 bind groups en toda la aplicación —uno de frame, uno de técnica, cincuenta de material, uno de objeto—. Compáralo con las 6000 llamadas y 6000 grupos del reparto por tipo.

Fíjate también en setPipeline dentro del bucle de materiales. Si dos materiales comparten pipeline —mismo shader, distinta textura—, la llamada es redundante y conviene evitarla comparando con el último pipeline atado. Ordenar primero por pipeline y después por material dentro de cada pipeline es la ordenación completa; lo desarrollo en la lección de ordenación.

💡
El orden de las llamadas dentro del pase no importa, pero el de los bucles sí

setBindGroup(0, ...) antes o después de setPipeline da igual: el estado se resuelve en el draw. Lo que sí importa es que las llamadas de baja frecuencia estén fuera de los bucles de alta frecuencia, y eso lo decide la estructura del código, no el orden dentro de una iteración.

Cuándo este reparto no es el tuyo

El reparto de cuatro grupos asume un renderer directo con ordenación por material. Hay tres escenarios donde cambia.

En un renderer diferido, el pass de geometría escribe el G-buffer y el pass de iluminación lo lee. En el segundo pass no hay materiales ni objetos: hay una pantalla completa y un conjunto de luces. El reparto se colapsa a dos grupos y el grupo 1 pasa a contener los attachments del G-buffer.

En un renderer con instancing agresivo, los datos por objeto dejan de estar en un uniform con dynamic offset y pasan a un storage buffer indexado por @builtin(instance_index). El grupo 2 desaparece: los datos por objeto suben al grupo 0, porque el buffer entero se ata una vez y el shader elige la entrada. Esa es la transición hacia el renderizado dirigido por GPU, y es la razón por la que un renderer moderno acaba usando menos grupos, no más.

En un renderer con muchos passes por frame —sombras con cuatro cascadas, reflejos, post-proceso encadenado—, el grupo 0 deja de ser “por frame” y pasa a ser “por pass”, que es una frecuencia mayor de lo que sugiere el nombre. Sigue estando bien colocado, pero conviene renombrarlo para no engañarse.

El grupo 2 es el que más se equivoca y el que más se paga

De los cuatro, el grupo de objeto es el único que se ata miles de veces por frame, y por eso es el único donde la elección de mecanismo cambia el perfil de rendimiento. Hay tres formas de resolverlo y la diferencia entre ellas es de un orden de magnitud. La peor: un bind group por objeto, 2000 objetos creados y validados. La intermedia: un bind group con dynamic offset, que es la que he escrito arriba, y que reduce la creación a uno pero mantiene las 2000 llamadas. La mejor: ningún grupo de objeto, un storage buffer con todos los datos atado en el grupo 0, y el shader indexando con @builtin(instance_index); las llamadas por objeto bajan a cero y el draw puede convertirse en un drawIndexed con miles de instancias. La progresión de las tres es, literalmente, la historia de la arquitectura de renderers de la última década, y puedes recorrerla entera sin cambiar de API.