Saltar al contenido

Shake procedural con ruido

Por qué el temblor de cámara no se hace con números aleatorios, cómo se construye con ruido coherente, dónde aplicarlo para no contaminar la cámara, y cómo se controla con una sola variable de trauma.

Aleatorio no es lo mismo que ruido

JS
// MAL: ruido blanco. Vibra, no tiembla.camera.position.x += ( Math.random() - 0.5 ) * intensidad;

Dos problemas. El primero es que no hay ninguna relación entre el valor de un frame y el del siguiente: la cámara salta a un punto arbitrario en cada fotograma, lo que produce el contenido espectral de una interferencia. El segundo es que la frecuencia queda determinada por la frecuencia de refresco: a 144 Hz el temblor es más rápido que a 60, otra vez el mismo problema de independencia del framerate.

El ruido coherente resuelve los dos. Es una función continua del tiempo: valores próximos en el tiempo dan valores próximos en la salida, y la velocidad a la que cambia la controlas con la escala del argumento.

Three.js incluye una implementación en sus addons:

JS
import { SimplexNoise } from 'three/addons/math/SimplexNoise.js';const ruido = new SimplexNoise();// Ruido 2D: primer eje el tiempo, segundo eje una semilla por canal.const valor = ruido.noise( tiempo * frecuencia, semilla );

SimplexNoise expone noise( x, y ) para dos dimensiones, noise3d( x, y, z ) y noise4d( x, y, z, w ). Para un shake, la versión 2D basta: el primer argumento es el tiempo escalado por la frecuencia, y el segundo es una constante distinta por cada grado de libertad, que garantiza que los ejes se muevan de forma independiente.

Ese detalle de la semilla es importante. Si usas el mismo argumento para X e Y, los dos ejes reciben el mismo valor y la cámara se mueve en diagonal perfecta, que es inmediatamente reconocible como artificial. Separando las semillas por un valor grande —cien, mil— cada eje recorre una región distinta del campo de ruido y el movimiento se ve orgánico.

La variable de trauma

El control se reduce a un único número entre cero y uno, que la literatura llama trauma. Los eventos lo suben; el tiempo lo baja.

JS
class Shake {	constructor() {		this.ruido = new SimplexNoise();		this.trauma = 0;		this.decaimiento = 1.4;    // unidades de trauma por segundo		this.frecuencia = 14;      // oscilaciones por segundo, aproximadamente		this.tiempo = 0;		this.maxPos = 0.35;        // unidades de mundo		this.maxAng = 0.06;        // radianes		this.posicion = new THREE.Vector3();		this.rotacion = new THREE.Euler();	}	golpe( cantidad ) {		this.trauma = Math.min( 1, this.trauma + cantidad );	}	actualizar( dt ) {		this.tiempo += dt;		this.trauma = Math.max( 0, this.trauma - this.decaimiento * dt );		// La clave: el trauma se eleva al cuadrado antes de usarse.		const intensidad = this.trauma * this.trauma;		const t = this.tiempo * this.frecuencia;		this.posicion.set(			this.ruido.noise( t, 0 ) * this.maxPos * intensidad,			this.ruido.noise( t, 100 ) * this.maxPos * intensidad,			this.ruido.noise( t, 200 ) * this.maxPos * intensidad * 0.4		);		this.rotacion.set(			this.ruido.noise( t, 300 ) * this.maxAng * intensidad,			this.ruido.noise( t, 400 ) * this.maxAng * intensidad,			this.ruido.noise( t, 500 ) * this.maxAng * intensidad		);	}}

El cuadrado del trauma es la decisión que más cambia la sensación y la que menos se explica. Si usas el trauma directamente, el decaimiento es lineal y el temblor se apaga con una uniformidad que se percibe como mecánica: la cámara tiembla, tiembla algo menos, algo menos, y para. Con el cuadrado, la caída es rápida al principio y suave al final: el golpe se siente contundente y la resolución es limpia, porque los últimos instantes de temblor son casi imperceptibles en lugar de ir menguando de forma visible. Es la misma razón por la que las curvas de audio se manejan en decibelios y no en amplitud lineal.

La acumulación con Math.min( 1, ... ) permite que varios impactos seguidos sumen sin desbordar. Un solo disparo añade 0,25; una explosión cercana añade 0,7; tres explosiones seguidas saturan a uno y no revientan la escena.

La frecuencia de 14 es un punto de partida razonable para un impacto. Valores más bajos —de cuatro a seis— dan un balanceo de terremoto; valores más altos —de veinte para arriba— dan una vibración de motor. Que el eje Z de la posición vaya al 40 % es intencionado: el movimiento hacia adelante y atrás en el sentido de la mirada se nota mucho más que el lateral, así que conviene atenuarlo.

Dónde aplicarlo

Este es el punto donde la mayoría de las implementaciones se estropean. Si sumas el shake directamente a camera.position y camera.rotation, el frame siguiente tu lógica de seguimiento lee esa posición contaminada, la suaviza, y el temblor se realimenta y se acumula. Además pierdes la posición “limpia” y ya no puedes hacer un lookAt correcto.

La solución es estructural: la cámara vive dentro de un nodo, y el shake se aplica al nodo, no a la cámara.

JS
// Estructura del grafo://   soporte (lo mueve la logica de seguimiento)//     -> sacudida (solo el shake)//          -> camera (transformacion local a cero)const soporte = new THREE.Object3D();const sacudida = new THREE.Object3D();scene.add( soporte );soporte.add( sacudida );sacudida.add( camera );camera.position.set( 0, 0, 0 );camera.rotation.set( 0, 0, 0 );function actualizar( dt ) {	// 1. La logica de camara mueve el soporte, sin saber nada del shake.	dampVector( soporte.position, posDeseada, 4, dt );	soporte.lookAt( miraActual );	// 2. El shake solo toca su propio nodo.	shake.actualizar( dt );	sacudida.position.copy( shake.posicion );	sacudida.rotation.copy( shake.rotacion );}

Las dos responsabilidades quedan separadas por completo. El soporte no se entera de que hay temblor; el nodo de sacudida no sabe dónde está la cámara. Puedes desactivar el shake poniendo su transformación a cero y nada más cambia. Y si necesitas la posición limpia de la cámara —para audio espacial, para culling, para un raycast— la tienes en soporte.

El mismo patrón sirve para cualquier otro efecto aditivo: balanceo al caminar, retroceso de disparo, respiración. Cada uno su nodo, todos en cadena, y la lógica principal moviendo solo la raíz.

Una advertencia sobre el orden de composición: como los nodos se multiplican en cadena, una rotación en sacudida gira todo lo que cuelga de él alrededor de su propio origen. Si quieres que el temblor rote alrededor del punto donde está la cámara, la cámara tiene que estar en el origen local del nodo de sacudida, que es lo que hace el camera.position.set( 0, 0, 0 ) del ejemplo.