Deformar en la CPU: mover el array de posiciones
Cómo escribir en el atributo de posición correctamente, por qué hay que guardar la pose de reposo, qué hay que recalcular después y cómo subir solo la parte que cambia.
Mover vértices desde JavaScript es la primera forma de deformar que descubre todo el mundo, y es la que enseñan todos los tutoriales de olas. Funciona, es directa, y tiene cuatro detalles que casi ninguno de esos tutoriales menciona: la pista de uso hay que darla antes del primer render, la deformación hay que aplicarla sobre una copia de la pose original, después hay que recalcular normales y volumen envolvente, y subir el buffer entero cada frame es lo que hace que el ejemplo funcione con un plano de treinta segmentos y se caiga con uno de doscientos.
- Escribir en el atributo de posición con las llamadas correctas y en el orden correcto.
- Explicar por qué hay que conservar una copia de las posiciones de reposo.
- Enumerar lo que queda inconsistente después de mover vértices y cómo repararlo.
- Declarar rangos de actualización parciales cuando solo cambia una zona.
Escribir en el atributo de posición
El atributo es un BufferAttribute y tiene métodos de acceso por componente. Se puede escribir directamente en el array, pero los métodos son más legibles y no tienen coste apreciable:
const pos = geometria.getAttribute( 'position' );
const x = pos.getX( i );
const y = pos.getY( i );
const z = pos.getZ( i );
pos.setY( i, y + 0.5 );
pos.setXYZ( i, x, y, z );
pos.needsUpdate = true; // una sola vez, al final del bucle
needsUpdate va fuera del bucle. Como es un setter que incrementa un contador de versión, llamarlo mil veces no rompe nada pero es un desperdicio, y sobre todo induce a pensar que la subida ocurre ahí. No ocurre: la subida sucede en el siguiente render, y sube todo lo que hayas cambiado desde la anterior.
Lo que sí hay que hacer antes de nada es declarar la intención de uso, y hay que hacerlo antes del primer render:
pos.setUsage( THREE.DynamicDrawUsage );
La documentación de BufferAttribute es explícita: después del primer uso de un buffer, su usage no se puede cambiar. La pista viaja al driver al crear el buffer, y con ella decide dónde alojarlo. Si tu atributo va a reescribirse cada frame y lo dejas en el valor por defecto, el driver lo coloca en la memoria optimizada para lectura y cada actualización cuesta más de lo necesario.
Guardar la pose de reposo
Este es el error que más tiempo se lleva porque el síntoma tarda en aparecer. La forma ingenua de animar una ola es esta:
// MAL: la deformación se acumula sobre la anterior.
for ( let i = 0; i < pos.count; i ++ ) {
pos.setY( i, pos.getY( i ) + Math.sin( t ) * 0.01 );
}
Cada frame lee la posición ya deformada y le suma más. El resultado no es una ola: es una superficie que deriva sin control y que además acumula error de coma flotante. Con una función simétrica el efecto tarda unos segundos en notarse, y para entonces ya no es evidente de dónde viene.
La forma correcta conserva una copia de las posiciones originales y calcula siempre desde ellas:
import * as THREE from 'three';
const geometria = new THREE.PlaneGeometry( 10, 10, 64, 64 );
geometria.rotateX( - Math.PI / 2 ); // el plano nace mirando a +Z
const pos = geometria.getAttribute( 'position' );
pos.setUsage( THREE.DynamicDrawUsage );
// La pose de reposo. Sin esto, la deformación se acumula.
const reposo = new Float32Array( pos.array );
const malla = new THREE.Mesh(
geometria,
new THREE.MeshStandardMaterial( { color: 0x89b4fa, roughness: 0.4 } )
);
scene.add( malla );
const reloj = new THREE.Timer();
reloj.connect( document );
renderer.setAnimationLoop( ( tiempo ) => {
reloj.update( tiempo );
const t = reloj.getElapsed();
for ( let i = 0; i < pos.count; i ++ ) {
const x = reposo[ i * 3 + 0 ];
const z = reposo[ i * 3 + 2 ];
pos.setY( i, Math.sin( x * 0.6 + t ) * Math.cos( z * 0.4 - t * 0.7 ) * 0.6 );
}
pos.needsUpdate = true;
geometria.computeVertexNormals();
geometria.computeBoundingSphere();
renderer.render( scene, camera );
} );
La copia es un Float32Array nuevo construido desde el array del atributo. Cuesta memoria —el doble de la geometría— y es la única forma de que la deformación sea una función pura del tiempo en lugar de un proceso con estado. Esa propiedad, además de arreglar el bug, permite algo muy útil: puedes saltar a cualquier instante t sin recorrer los anteriores, que es lo que hace falta para una barra de reproducción o para un renderizado offline.
Si tu deformación es intrínsecamente acumulativa —una simulación de tela, un fluido— entonces sí hay estado y la copia de reposo no basta: necesitas también la velocidad y todo el resto del estado del sistema. Pero esa es una simulación, no una deformación paramétrica, y las reglas son otras.
Lo que hay que recalcular
Mover vértices deja tres cosas inconsistentes, y las tres fallan de formas distintas.
Las normales. Siguen siendo las de la superficie sin deformar, así que la iluminación describe una geometría que ya no existe. El síntoma es una superficie que se mueve pero se ilumina como si fuera plana. Se arregla con computeVertexNormals(), con las salvedades sobre su coste y su ponderación por área que ya conocemos.
El volumen envolvente. La esfera cacheada describe la pose original. Si la deformación saca vértices fuera de ella, el objeto puede desaparecer al acercarse al borde de la pantalla; si los mete dentro, el culling es menos efectivo pero correcto. Se arregla con computeBoundingSphere(), y si además vas a raycastear, con computeBoundingBox().
El raycasting. Este se arregla solo, y es la única ventaja real de deformar en la CPU: como los datos están en el array, Raycaster intersecta la geometría deformada de verdad. Con desplazamiento en el vertex shader, el rayo golpea la geometría sin deformar y no hay forma barata de evitarlo.
El orden importa: primero se escriben las posiciones, luego needsUpdate, luego los recálculos. computeVertexNormals lee el array, así que tiene que ir después de escribirlo.
Y merece la pena adelantar el coste, que es el tema de la siguiente lección: computeVertexNormals() recorre todos los triángulos haciendo dos restas, un producto vectorial y tres acumulaciones, y luego normaliza todos los vértices. Para una malla de N vértices en una rejilla, eso son unos seis productos vectoriales por vértice. Es aproximadamente tres veces más trabajo que la deformación misma.
Actualizaciones parciales
Cuando solo cambia una parte de la malla —un impacto local, una huella, una zona bajo un objeto— subir el buffer entero es un desperdicio. Los rangos de actualización lo evitan:
// Solo se han movido los vértices del 1000 al 1199.
pos.addUpdateRange( 1000 * 3, 200 * 3 ); // en componentes del array, no en vértices
pos.needsUpdate = true;
Los índices son posiciones del array subyacente. Para un atributo de tres componentes hay que multiplicar por tres, y olvidarlo es el error clásico: actualiza los vértices equivocados y el síntoma es una zona que se deforma desplazada respecto a donde debería.
Tres cosas hay que saber sobre el mecanismo. Three.js ordena y fusiona los rangos antes de subirlos, mutando tu array, y los limpia él mismo al terminar: no tienes que llamar a clearUpdateRanges(). Como se limpian, hay que declararlos de nuevo en cada frame que actualices. Y 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.
El cálculo de cuándo compensa es sencillo. Una subida completa de un plano de doscientos por doscientos son 40 401 vértices por tres componentes por cuatro bytes: 485 kilobytes por frame. Si de verdad solo cambian doscientos vértices, el rango lo baja a 2,4 kilobytes. Pero cada rango es una llamada a la API de gráficos, y con muchos rangos dispersos el coste por llamada domina: a partir de unas pocas decenas, sale más barato subirlo todo.
// Patrón razonable: acumular índices tocados y decidir al final.
const tocados = [];
function deformarZona( indiceInicio, cantidad ) {
// ...escribir posiciones...
tocados.push( { start: indiceInicio * 3, count: cantidad * 3 } );
}
function subir() {
if ( tocados.length > 0 && tocados.length < 32 ) {
for ( const r of tocados ) pos.addUpdateRange( r.start, r.count );
}
// con más de 32 rangos no se declara ninguno: sube el buffer entero
pos.needsUpdate = true;
tocados.length = 0;
}
La discusión sobre si deformar en la CPU o en la GPU se plantea casi siempre como una cuestión de rendimiento, y así planteada la respuesta es trivial: la GPU gana por dos órdenes de magnitud. Pero esa no es la pregunta útil, porque la GPU tiene una limitación estructural que ninguna optimización arregla: el resultado del vertex shader no vuelve. Una vez desplazado un vértice en la GPU, ese dato existe durante unos microsegundos dentro del pipeline, se convierte en fragmentos y desaparece. JavaScript nunca lo ve. Y hay un montón de cosas que necesitan verlo: un rayo que tiene que golpear la superficie deformada, un personaje que tiene que caminar sobre el terreno ondulado, un motor de física que necesita la malla de colisión, un cálculo de volumen, una exportación. Así que el criterio correcto no es de coste sino de destino del dato: si el resultado de la deformación solo se va a mirar, hazla en la GPU; si algo más lo tiene que consultar, hazla en la CPU o duplícala. Y “duplícala” es la respuesta que usan los motores serios y que a la gente le parece un desperdicio: la misma función de desplazamiento escrita dos veces, una en GLSL para lo que se ve y otra en JavaScript para consultarla en los pocos puntos donde hace falta. Parece feo y es lo correcto, porque evaluar la función en cuatro puntos por frame en la CPU cuesta nada, mientras que evaluarla en cuarenta mil cuesta el frame entero. El error es asumir que hay que elegir una sola implementación.
- Monta la ola del ejemplo y comprueba qué pasa si quitas la copia de reposo.
- Quita
computeVertexNormals()y describe cómo se ilumina la superficie. - Quita
computeBoundingSphere()y encuentra el ángulo de cámara donde la malla desaparece. - Mueve
setUsagea después del primer render y comprueba que ya no tiene efecto. - Deforma solo una zona con
addUpdateRangey verifica que se actualiza donde toca.