Saltar al contenido

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.

El constructor y los tipos

JS
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:

JS
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.

JS
// 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:

JS
atributo.setUsage( THREE.StaticDrawUsage );   // por defecto: se sube una vezatributo.setUsage( THREE.DynamicDrawUsage );  // se reescribe muchas vecesatributo.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:

JS
// Solo se han movido los vértices del 100 al 149.atributo.addUpdateRange( 100 * 3, 50 * 3 );   // en componentes del array, no en vérticesatributo.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.

JS
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:

JS
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.