wandres.dev
UNIFORM BUFFERS · Datos constantes y alineación

Dynamic offsets: un buffer para miles de objetos

Cómo declarar un binding con offset dinámico, por qué minUniformBufferOffsetAlignment vale 256, el cálculo del paso entre entradas y el coste de memoria que impone.

⏱ 18 min

Dos mil objetos necesitan dos mil matrices de modelo. Crear dos mil buffers es absurdo; crear dos mil bind groups sobre un buffer grande funciona pero desperdicia objetos. El dynamic offset resuelve el caso con un buffer, un bind group y un número que se mueve en tiempo de dibujado. Es el mecanismo con el que WebGPU responde a la pregunta de cómo dar a cada draw sus propios datos sin multiplicar recursos.

🎯 Al terminar esta lección sabrás
  • Declarar un binding con hasDynamicOffset y atarlo con el offset correcto.
  • Calcular el paso entre entradas a partir de minUniformBufferOffsetAlignment.
  • Explicar por qué el límite vale 256 bytes y qué desperdicio implica.
  • Comparar el patrón con las alternativas y saber cuándo cada una gana.

El mecanismo

Se activa en el layout, con un campo booleano:

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

El bind group se crea una sola vez, con el size del elemento, no del buffer entero. El offset del GPUBufferBinding queda en 0 y se convierte en un offset base al que se suma el dinámico:

const grupoObjeto = device.createBindGroup({
  layout: layoutObjeto,
  entries: [{ binding: 0, resource: { buffer: bufferObjetos, offset: 0, size: 128 } }],
});

Y en el bucle, el tercer argumento de setBindGroup lleva un offset por cada entrada del grupo que declare hasDynamicOffset, en el orden creciente de binding:

for (let i = 0; i < objetos.length; i++) {
  pass.setBindGroup(2, grupoObjeto, [i * paso]);
  pass.drawIndexed(objetos[i].cuenta);
}

El shader no se entera de nada. Ve un uniform normal, y cada draw lee el tramo que la API le puso delante:

struct Objeto {
  modelo : mat4x4<f32>,
  normal : mat4x4<f32>,
};
@group(2) @binding(0) var<uniform> objeto : Objeto;

El 256 que decide el consumo de memoria

minUniformBufferOffsetAlignment vale 256 bytes en el mínimo garantizado. Cada offset dinámico tiene que ser múltiplo de ese valor, así que el paso entre entradas consecutivas se redondea hacia arriba:

const ALINEACION = device.limits.minUniformBufferOffsetAlignment;
const TAM = 128;                                     // dos mat4x4
const paso = Math.ceil(TAM / ALINEACION) * ALINEACION;

Con 128 bytes de datos y una alineación de 256, el paso es 256: la mitad del buffer es relleno. Dos mil objetos ocupan 512 KiB para 256 KiB de datos útiles.

El límite es de tipo alignment, lo que en la terminología de la especificación significa que el valor garantizado es el peor caso y un dispositivo concreto puede exigir menos. Muchas GPUs de escritorio reportan 32 o 64. Leer device.limits y usar el valor real, en vez de hardcodear 256, elimina el desperdicio en el hardware que lo permite:

// Con alineación real de 64: paso 128, sin relleno. Mismo código.
const paso = Math.ceil(TAM / device.limits.minUniformBufferOffsetAlignment)
           * device.limits.minUniformBufferOffsetAlignment;

El equivalente para buffers de storage es minStorageBufferOffsetAlignment, con el mismo valor garantizado de 256 y la misma semántica.

Hay un segundo límite que hay que vigilar: maxDynamicUniformBuffersPerPipelineLayout vale 8, y maxDynamicStorageBuffersPerPipelineLayout vale 4. Se cuentan sumando todas las entradas con hasDynamicOffset de todos los grupos del pipeline layout. Con un solo binding dinámico para los datos por objeto vas sobrado; con uno por cada nivel de la jerarquía te acercas rápido.

⚠️
El offset dinámico se valida en cada setBindGroup

La validación comprueba que el número de offsets coincide con el número de entradas dinámicas, que cada uno respeta su alineación, y que offset del binding + offset dinámico + minBindingSize no se sale del buffer. Ese último detalle es la razón por la que conviene poner minBindingSize: sin él, la comprobación se pospone al draw, y el error aparece con menos contexto. El coste de la validación no es despreciable, y es lo que hace que la variante con Uint32Array preasignado que vimos en el nivel 18 merezca la pena.

Escribir el buffer entero de una vez

El patrón se completa con una escritura única por frame en lugar de una por objeto. Se construye un ArrayBuffer del tamaño total y se vuelca de golpe:

const total = paso * objetos.length;
const cpu = new ArrayBuffer(total);

function actualizar(objetos) {
  for (let i = 0; i < objetos.length; i++) {
    // Una vista por objeto sobre su tramo. Sin copias intermedias.
    const v = new Float32Array(cpu, i * paso, 32);
    v.set(objetos[i].matrizModelo, 0);
    v.set(objetos[i].matrizNormal, 16);
  }
  device.queue.writeBuffer(bufferObjetos, 0, cpu, 0, total);
}

new Float32Array(buffer, byteOffset, length) crea una vista sin copiar, pero el byteOffset tiene que ser múltiplo de 4 —lo es, porque el paso es múltiplo de 256— y crear dos mil vistas por frame genera dos mil objetos para el recolector. Si eso aparece en el perfil, la alternativa es una sola vista sobre todo el buffer y aritmética de índices:

const v = new Float32Array(cpu);           // una vez, fuera del bucle
const pasoF = paso / 4;                    // paso en floats

for (let i = 0; i < objetos.length; i++) {
  v.set(objetos[i].matrizModelo, i * pasoF);
  v.set(objetos[i].matrizNormal, i * pasoF + 16);
}

Y si solo se mueven unos pocos objetos por frame, escribir solo esos tramos con writeBuffer(buffer, i * paso, cpu, i * paso, TAM) evita subir medio megabyte para cambiar tres matrices.

Las alternativas y cuándo ganan

El dynamic offset no es la única forma de dar a cada draw sus datos, y tiene un competidor claro.

Un bind group por objeto sobre el mismo buffer. No necesita hasDynamicOffset ni consume presupuesto de bindings dinámicos, y la llamada a setBindGroup es la variante barata de dos argumentos. A cambio, crea y valida un objeto por entrada. Gana cuando el número de entradas es pequeño y estable —decenas, no miles— y cuando los objetos tienen conjuntos de recursos distintos, no solo offsets distintos.

Un storage buffer indexado desde el shader. El buffer entero se ata una vez en un grupo de baja frecuencia, y el shader elige su entrada con @builtin(instance_index):

@group(0) @binding(2) var<storage, read> objetos : array<Objeto>;

@vertex
fn vs(@builtin(instance_index) inst : u32, entrada : Vertice) -> Salida {
  let m = objetos[inst].modelo;
  // ...
}

Esto elimina todas las llamadas por objeto: un solo drawIndexed con instanceCount igual al número de objetos dibuja todo. Además el stride del array en el espacio storage es el natural, 128 bytes, sin el relleno del 256. La contrapartida es que todos los objetos del draw tienen que compartir malla y material, lo cual conduce directamente al instancing y, más adelante, al renderizado dirigido por GPU.

El dynamic offset es un peldaño, no un destino

Vale la pena decirlo sin rodeos: el patrón de dynamic offset es la solución correcta para el renderer de complejidad media, y es el peldaño intermedio en una escalera de tres. Empiezas con un bind group por objeto porque es lo obvio. Pasas a dynamic offsets cuando descubres que crear dos mil bind groups por frame es absurdo, y ganas un orden de magnitud. Y acabas eliminando el grupo de objeto por completo cuando entiendes que el shader puede indexar un storage buffer con instance_index y que entonces el bucle de dibujado se colapsa en un solo draw. Lo interesante es que cada peldaño te obliga a resolver un problema distinto: el primero, la gestión de recursos; el segundo, la alineación y el layout de memoria; el tercero, la ordenación de la escena para que los objetos agrupables lo estén. Saltarse el segundo es tentador y casi siempre malo, porque el tercero exige dominar el layout de un array de structs en la GPU, que es exactamente lo que el segundo te obliga a aprender.