wandres.dev
BUFFERGEOMETRY · Atributos y la memoria de la GPU

BufferAttribute y los arrays tipados

Qué subclases tipadas existen de verdad en r184, qué significa normalizado y cuánta memoria ahorra, cómo declarar rangos de actualización, y el truco para soltar la copia de CPU.

⏱ 19 min

BufferAttribute es una capa fina sobre un array tipado, y su valor está justo en lo que añade: el tamaño de elemento, la bandera de normalización, la pista de uso y el contador de versión. Elegir bien el tipo del array es una de las pocas decisiones de la geometría que se traduce directamente en megabytes de memoria de vídeo, y normalized es la palanca que casi nadie usa porque parece un detalle de bajo nivel y en realidad es el modo de partir por dos el peso de una malla sin que se note.

🎯 Al terminar esta lección sabrás
  • Elegir la subclase tipada adecuada para cada atributo y justificar la decisión en bytes.
  • Explicar qué hace exactamente normalized en el hardware.
  • Declarar rangos de actualización parciales y entender quién los limpia.
  • Decidir cuándo entrelazar atributos y cuándo soltar la copia de JavaScript.

El constructor y los tipos

new THREE.BufferAttribute( array, itemSize, normalized = false )

El primer argumento tiene que ser un array tipado. Pasarle un array normal de JavaScript lanza una excepción explícita, no un aviso: array should be a Typed Array. Es una de las pocas comprobaciones duras de la librería y está ahí porque el error sería silencioso y desconcertante en cualquier otro sitio.

itemSize es cuántos componentes forman cada elemento, y de ahí sale count como la longitud del array dividida por ese número. Un atributo de tres componentes sobre un array de trescientos flotantes tiene cien elementos.

Las subclases tipadas evitan tener que construir el array a mano. Estas son todas las que exporta r184, sin ninguna más:

Clase Array subyacente Bytes por componente
Int8BufferAttribute Int8Array 1
Uint8BufferAttribute Uint8Array 1
Uint8ClampedBufferAttribute Uint8ClampedArray 1
Int16BufferAttribute Int16Array 2
Uint16BufferAttribute Uint16Array 2
Float16BufferAttribute Uint16Array 2
Int32BufferAttribute Int32Array 4
Uint32BufferAttribute Uint32Array 4
Float32BufferAttribute Float32Array 4

No existe Float64BufferAttribute, y no es un olvido: no hay hardware gráfico de consumo que lea atributos de doble precisión, así que un array de sesenta y cuatro bits habría que convertirlo antes de subirlo y la clase no aportaría nada.

Todas aceptan también un array normal de números y lo convierten:

const uv = new THREE.Float32BufferAttribute( [ 0, 0, 1, 0, 1, 1 ], 2 );

Float16BufferAttribute merece una advertencia. Guarda el dato en un Uint16Array y hace la conversión a media precisión a mano en getX, setX y compañía. Pero no sobrescribe getComponent ni setComponent, así que esos dos devuelven y escriben el entero de dieciséis bits sin convertir. Si mezclas ambas APIs sobre el mismo atributo, obtienes basura, y el fallo no salta en ningún sitio.

Normalizado: precisión a cambio de memoria

normalized no hace nada en JavaScript: es una instrucción para el hardware. Cuando está activo y el array es de enteros, la GPU divide cada valor por el máximo de su tipo antes de entregárselo al shader. Un Uint8 normalizado se convierte en un flotante del cero al uno; un Int16 normalizado, en un flotante de menos uno a uno.

De ahí sale la palanca. Una normal es siempre un vector unitario: sus tres componentes viven en el intervalo de menos uno a uno. Guardarla en tres flotantes de treinta y dos bits es gastar veinticuatro cifras significativas para describir algo que necesita, con suerte, tres. Un Int16 normalizado da unas treinta y dos mil quinientas subdivisiones en cada eje, muy por encima de lo que cualquier iluminación puede distinguir.

// Normales en enteros de 16 bits normalizados: la mitad de memoria, sin diferencia visible.
const n = geometria.attributes.normal;
const comprimidas = new Int16Array( n.count * 3 );
for ( let i = 0; i < n.count * 3; i ++ ) {
  comprimidas[ i ] = Math.round( THREE.MathUtils.clamp( n.array[ i ], - 1, 1 ) * 32767 );
}
geometria.setAttribute( 'normal', new THREE.Int16BufferAttribute( comprimidas, 3, true ) );

El tercer argumento, true, es la parte que importa: sin él, el shader recibiría números del orden de treinta mil y la iluminación explotaría.

La cuenta completa para una malla típica, por vértice:

Atributo Habitual Bytes Comprimido Bytes
position Float32 x3 12 Float32 x3 12
normal Float32 x3 12 Int16 normalizado x3 6
uv Float32 x2 8 Uint16 normalizado x2 4
color Float32 x3 12 Uint8 normalizado x3 3
Total 44 25

Un cuarenta y tres por ciento menos. En una malla de doscientos mil vértices eso son 8,8 MB frente a 5,0 MB, y en un dispositivo móvil con memoria de vídeo compartida esa diferencia se nota en el tiempo de carga y en la probabilidad de que el navegador descarte el contexto.

La posición se queda en flotante y hay una razón: los valores no están acotados a un intervalo conocido y el error absoluto de un entero normalizado sería proporcional al tamaño del modelo. Se puede comprimir también, escalando por la caja envolvente y compensando con la matriz del objeto, y eso es exactamente lo que hace la compresión de mallas de los formatos modernos. Para las UVs, el mismo razonamiento vale solo si están dentro del intervalo de cero a uno; si repites la textura con coordenadas mayores que uno, Uint16 normalizado no sirve y hay que usar Float16.

Actualizar: usage y rangos

usage es una pista para el driver sobre el patrón de escritura esperado. Los tres valores son constantes de Three.js:

atributo.setUsage( THREE.StaticDrawUsage );   // por defecto: se sube una vez
atributo.setUsage( THREE.DynamicDrawUsage );  // se reescribe muchas veces
atributo.setUsage( THREE.StreamDrawUsage );   // se reescribe y se usa una vez

La documentación de BufferAttribute es explícita en un punto: después del primer uso de un buffer, su usage no se puede cambiar. La pista se transmite al crear el buffer y a partir de ahí el driver ya ha decidido dónde alojarlo. Así que si vas a animar un atributo, márcalo antes del primer render.

Para actualizaciones parciales, r184 usa un array de rangos. El campo updateRange en singular, con su offset y su count, ya no existe: fue sustituido por updateRanges, un array de objetos con start y count, y dos métodos:

// Solo se han movido los vértices del 100 al 149.
atributo.addUpdateRange( 100 * 3, 50 * 3 );   // en componentes del array, no en vértices
atributo.needsUpdate = true;

Los índices son posiciones del array subyacente, no de elementos: para un atributo de tres componentes hay que multiplicar por tres. Es el error más frecuente y produce actualizaciones que afectan a los vértices equivocados.

Quién limpia los rangos es una pregunta que la documentación no responde bien, así que aquí va la respuesta verificada en el código: lo hace Three.js. Al subir, WebGLAttributes ordena los rangos, fusiona los adyacentes —mutando tu array— y al terminar llama a clearUpdateRanges() él mismo. Tú no tienes que hacerlo. Lo que sí tienes que hacer es declarar los rangos de nuevo en cada frame que quieras actualizar, porque después de subir no queda ninguno. Y hay una trampa: si añades rangos pero no marcas needsUpdate, la subida no ocurre, los rangos no se limpian, y en el siguiente frame se acumulan con los nuevos.

Merece la pena calibrar cuándo compensa. Una subida completa de un atributo de posición de cien mil vértices son 1,2 MB por frame. Si de verdad solo cambian cincuenta vértices, declarar el rango baja eso a 600 bytes. Pero cada rango es una llamada a bufferSubData, y con muchos rangos dispersos el coste por llamada domina: a partir de unas pocas decenas de rangos suele salir más barato subirlo entero.

Entrelazar y soltar la copia de CPU

Por defecto, cada atributo vive en su propio buffer y la GPU lee tres regiones de memoria distintas por vértice. Entrelazar consiste en meterlos todos en un único buffer, alternados, de modo que todos los datos de un vértice queden contiguos.

import * as THREE from 'three';

// Por vértice: 3 de posición, 3 de normal, 2 de uv = 8 flotantes.
const entrelazado = new THREE.InterleavedBuffer( datos, 8 );

geometria.setAttribute( 'position', new THREE.InterleavedBufferAttribute( entrelazado, 3, 0 ) );
geometria.setAttribute( 'normal',   new THREE.InterleavedBufferAttribute( entrelazado, 3, 3 ) );
geometria.setAttribute( 'uv',       new THREE.InterleavedBufferAttribute( entrelazado, 2, 6 ) );

La ventaja es localidad de caché: cuando la GPU lee la posición de un vértice, la línea de caché que trae ya contiene su normal y sus UVs. En mallas grandes con vértices accedidos en orden aleatorio —que es lo que ocurre con un índice bien optimizado— la diferencia es medible. La desventaja es rigidez: actualizar un solo atributo obliga a subir el bloque entrelazado entero, así que para geometría dinámica es peor. La regla es sencilla: entrelaza lo estático, separa lo que se anima.

Y el último truco, que es de los que separan una escena que carga bien de una que se queda a medias en un móvil. Después de subir un atributo a la GPU, la copia de JavaScript sigue ahí ocupando montón. Para una escena de varios millones de vértices eso puede ser un centenar de megabytes que no vuelves a usar. onUpload permite soltarla:

geometria.getAttribute( 'position' ).onUpload( function () { this.array = null; } );
geometria.getAttribute( 'normal' ).onUpload( function () { this.array = null; } );

Tiene que ser una función normal, no una flecha, porque el this es el propio atributo. Y lo que rompe hay que saberlo antes de aplicarlo: sin el array ya no puedes raycastear contra esa malla, ni recalcular sus volúmenes envolventes, ni deformarla, ni serializarla. Es una optimización de solo ida, y el sitio correcto para aplicarla es la geometría decorativa de fondo, nunca la que el usuario puede seleccionar.

El cuello de botella de una malla grande casi nunca es el número de triángulos: es el ancho de banda

Hay una intuición equivocada muy extendida que dice que una malla es cara porque tiene muchos triángulos. En la GPU moderna, procesar un triángulo más es casi gratis: las unidades de cómputo están sobradas y el rasterizador va sobrado. Lo que no va sobrado es la memoria. Una GPU de gama media mueve del orden de doscientos gigabytes por segundo, y a sesenta fotogramas por segundo eso son unos tres gigabytes por frame para absolutamente todo: geometría, texturas, buffers intermedios, salida final. Una malla de dos millones de vértices con cuarenta y cuatro bytes por vértice son ochenta y ocho megabytes que hay que leer entera cada vez que se dibuja, y si además la dibujas para la sombra, dos veces. Comprimir esos cuarenta y cuatro bytes a veinticinco no reduce el número de triángulos ni una unidad, pero reduce el tiempo de dibujado casi en la misma proporción, porque el cuello estaba en leer, no en calcular. Esa es la razón de que las técnicas de compresión de vértices sean el primer recurso de cualquier motor serio y de que los formatos modernos las traigan de serie: KHR_mesh_quantization en glTF hace exactamente lo que describe esta lección, enteros normalizados con la escala compensada en la matriz del nodo. Y explica también un fenómeno que desconcierta al perfilar: dos escenas con el mismo número de triángulos pueden diferir tres veces en rendimiento, y toda la diferencia está en cuántos bytes pesa cada vértice y en cuántas veces se lee. Cuando midas, no cuentes triángulos: cuenta bytes por frame.

⚔️ Pesa tu geometría
  1. Escribe una función que sume los bytes de todos los atributos de una geometría y compárala con estimateBytesUsed de BufferGeometryUtils.
  2. Comprime las normales a Int16 normalizado y compara visualmente el resultado con las originales.
  3. Comprime las UVs a Uint16 normalizado en un modelo con textura repetida y observa qué se rompe.
  4. Anima cincuenta vértices de una malla grande con addUpdateRange y mide la diferencia frente a subirla entera.
  5. Aplica onUpload para soltar el array y comprueba exactamente qué operaciones dejan de funcionar.