wandres.dev
BUFFERS · Memoria en la GPU

createBuffer: reservar memoria en la GPU

El descriptor de búfer, las reglas de tamaño y alineación, qué propiedades se pueden consultar, por qué un búfer no se puede redimensionar y cuándo hay que llamar a destroy.

⏱ 16 min

Un GPUBuffer es un bloque contiguo de memoria de la GPU sin ningún tipo asociado. Su descriptor tiene tres campos y aun así concentra decisiones que condicionan todo lo que se puede hacer con él después: el tamaño es inmutable, los usos son inmutables, y la combinación de usos que declares determina si el búfer puede o no participar en cada operación de la API.

🎯 Al terminar esta lección sabrás
  • Crear búferes con el descriptor correcto y las alineaciones que exige la especificación.
  • Explicar por qué tamaño y usos son inmutables tras la creación.
  • Consultar las propiedades de un búfer existente.
  • Decidir cuándo hay que llamar a destroy() y qué ocurre si no lo haces.

El descriptor

Tres campos más la etiqueta:

const buffer = device.createBuffer({
  label: 'vertices del terreno',
  size: 4096,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  mappedAtCreation: false,
});

size es el tamaño en bytes y es obligatorio. Está acotado por device.limits.maxBufferSize, cuyo valor por defecto es 268435456, o sea 256 MiB. Es inmutable: no hay forma de redimensionar un búfer.

usage es una máscara de bits con los usos permitidos y es obligatoria. También inmutable. Tiene su propia lección porque las combinaciones válidas no son obvias.

mappedAtCreation pide que el búfer nazca ya mapeado en memoria accesible desde JavaScript, para rellenarlo antes de su primer uso. Por defecto es false.

Un búfer no tiene tipo. Los mismos bytes pueden ser vértices en un pipeline, índices en otro y datos de almacenamiento en un compute shader, siempre que hayas declarado esos usos. La interpretación la ponen el shader y el descriptor del pipeline, no el búfer.

Tamaño y alineación

Hay una regla estricta y varias derivadas que en la práctica funcionan como si fueran estrictas.

La regla estricta: si mappedAtCreation es true, size tiene que ser múltiplo de 4, y si no lo es se lanza un RangeError. Ojo, esto sí es una excepción de JavaScript, no un error de validación silencioso.

Las reglas derivadas vienen de las operaciones que vas a hacer con el búfer, y en conjunto hacen que un tamaño no múltiplo de 4 sea inútil:

  • El rango que se mapea con mapAsync tiene que ser múltiplo de 4 y su desplazamiento múltiplo de 8.
  • queue.writeBuffer exige que el desplazamiento de destino y el tamaño escrito sean múltiplos de 4.
  • copyBufferToBuffer exige que los dos desplazamientos y el tamaño sean múltiplos de 4.

De modo que la práctica universal es redondear siempre el tamaño hacia arriba, a 4 como mínimo y a 16 para los búferes uniformes, porque las reglas de disposición de WGSL alinean los struct de uniformes a 16 bytes:

const alinear = (n, a) => Math.ceil(n / a) * a;

const uniformes = device.createBuffer({
  label: 'uniformes de camara',
  size: alinear(datos.byteLength, 16),
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

Ese redondeo es la línea que separa el código que funciona del que produce errores de validación aparentemente aleatorios cuando alguien añade un campo a un struct.

⚠️
La memoria se reserva de verdad, aunque no la llenes

createBuffer reserva la memoria inmediatamente, no de forma perezosa. Un búfer de 100 MB ocupa 100 MB desde la línea en que se crea, y si no hay sitio la implementación emite un GPUOutOfMemoryError por el canal de errores y devuelve un búfer inválido. No lanza excepción. Si reservas cantidades grandes, envuélvelo en un ámbito de error para enterarte.

Qué se puede consultar

Un GPUBuffer expone cuatro propiedades de solo lectura y ninguna de ellas es su contenido.

buffer.size y buffer.usage devuelven lo que pediste. Son útiles para escribir funciones genéricas que validen antes de operar.

buffer.mapState es el estado de mapeo, con tres valores: 'unmapped', 'pending' y 'mapped'. Sirve para comprobar si es seguro llamar a mapAsync o getMappedRange.

buffer.label es la etiqueta.

Lo que no hay es forma de leer el contenido desde JavaScript sin el ciclo completo de copia y mapeo. Un GPUBuffer no es un ArrayBuffer: sus bytes viven en memoria de la GPU, y traerlos de vuelta es la operación más cara de la API.

function puedeSerVertice(b) {
  return (b.usage & GPUBufferUsage.VERTEX) !== 0;
}

function estaLibre(b) {
  return b.mapState === 'unmapped';
}

Por qué nada se puede cambiar

La inmutabilidad de size y usage no es una limitación arbitraria: cae directamente del modelo de validación adelantada.

Los usos determinan dónde y cómo la implementación reserva la memoria. Un búfer con MAP_READ necesita memoria visible desde la CPU; uno con VERTEX puede ir en memoria exclusiva de la GPU con una disposición optimizada para el ensamblador de vértices; uno con UNIFORM puede ir a una región con alineación especial. Cambiar el uso después significaría mover el búfer, invalidando cualquier bind group que lo referencie.

El tamaño determina la reserva misma. Redimensionar sería reservar otro búfer y copiar, y la especificación prefiere que eso lo escribas tú, de forma visible, a esconderlo.

La consecuencia práctica es que crecer un búfer es un patrón que hay que implementar a mano, y merece la pena hacerlo con crecimiento geométrico para no recrear en cada añadido:

function asegurarCapacidad(device, actual, bytesNecesarios, usage, label) {
  if (actual && actual.size >= bytesNecesarios) return actual;
  const nuevo = device.createBuffer({
    label,
    size: Math.max(bytesNecesarios, (actual?.size ?? 256) * 2),
    usage,
  });
  actual?.destroy();
  return nuevo;
}

Ojo con una consecuencia de ese patrón: al recrear el búfer hay que recrear todos los bind groups que lo referenciaban. Un GPUBindGroup guarda la referencia al búfer concreto, no un puntero indirecto.

destroy y la memoria de vídeo

buffer.destroy() libera la memoria inmediatamente y marca el búfer como destruido. Cualquier uso posterior produce un error de validación. Si el búfer estaba mapeado, se desmapea.

La pregunta es cuándo llamarlo, y la respuesta correcta no es «siempre» ni «nunca».

Llámalo para búferes grandes que dejas de usar: objetivos intermedios que se recrean al redimensionar, datos de un nivel que se descarga, búferes de staging de un solo uso, cualquier cosa por encima de unos pocos megabytes.

No hace falta para búferes pequeños que viven toda la sesión. El recolector de basura acabará liberándolos y el coste de gestionarlo a mano no compensa.

La razón por la que hay que llamarlo para los grandes es que el recolector de basura de JavaScript no sabe cuánta memoria de vídeo hay detrás de una referencia. Ve un objeto pequeño y no tiene ninguna urgencia por recogerlo, aunque ese objeto mantenga cien megabytes ocupados al otro lado del bus. Es la misma clase de problema que con ImageBitmap o con los objetos de URL.createObjectURL, y la solución es la misma: liberación explícita.

Suballocar en un búfer grande es lo que hacen los motores serios, y en WebGPU es casi obligatorio

El patrón ingenuo es un búfer por objeto: un GPUBuffer para los vértices de esta malla, otro para los índices, otro para sus uniformes. Funciona con diez objetos y se derrumba con mil, por tres motivos que se acumulan.

Cada búfer tiene un coste fijo de gestión en la implementación y en el controlador, y una reserva mínima que suele ser bastante mayor que unos pocos cientos de bytes. Mil búferes de 200 bytes consumen mucho más que 200 KB.

Cada búfer es una referencia distinta en un bind group, y como maxStorageBuffersPerShaderStage está en 8 por defecto y maxUniformBuffersPerShaderStage en 12, el número de búferes distintos que un shader puede ver a la vez es pequeño. Con un búfer por objeto necesitas un bind group por objeto, y eso son mil cambios de bind group por fotograma.

Cambiar de búfer entre dibujos cuesta más que cambiar de desplazamiento. Es la clave de todo esto.

La alternativa que usan todos los motores es un búfer grande con suballocación: un solo GPUBuffer de, digamos, 64 MB, un asignador propio en JavaScript que reparte rangos, y cada objeto guarda su desplazamiento y su tamaño en lugar de su búfer. Los dibujos usan setVertexBuffer(0, grande, offset, size) sobre el mismo búfer, y los uniformes usan desplazamientos dinámicos: un bind group creado una vez con hasDynamicOffset: true, y setBindGroup(0, grupo, [offset]) por objeto. Un solo bind group para mil objetos.

El detalle que hay que respetar es la alineación: minUniformBufferOffsetAlignment vale 256 por defecto, así que cada bloque de uniformes ocupa un múltiplo de 256 bytes aunque contenga 80. Se desperdicia memoria y se gana todo lo demás. Escribir ese asignador son cuarenta líneas y es probablemente la inversión con mejor retorno de un motor propio en WebGPU.

El siguiente paso es entender qué combinaciones de uso son legales y por qué: los flags de usage.