wandres.dev
CÁMARAS Y CINEMÁTICA · Movimiento con intención

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.

⏱ 17 min

El temblor de cámara es el efecto más fácil de implementar mal y el más fácil de reconocer cuando está mal. Un Math.random() sumado a la posición cada frame produce ruido blanco: vibración de alta frecuencia que se percibe como un fallo de renderizado, no como un impacto. Un temblor creíble tiene continuidad, tiene frecuencia controlada, y decae con una curva que no es lineal. Las tres cosas salen de la misma decisión: usar ruido coherente en lugar de números aleatorios.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el ruido blanco no sirve y qué aporta el ruido coherente.
  • Construir un shake con octavas independientes por eje y por grado de libertad.
  • Controlar la intensidad con una única variable elevada al cuadrado.
  • Aplicar el shake en un nodo separado para no destruir la lógica de la cámara.

Aleatorio no es lo mismo que ruido

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

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.

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.

💡
La rotación es la que se ve

Si tienes que elegir un solo grado de libertad, elige la rotación. Un desplazamiento de la cámara es casi invisible cuando la escena está lejos, porque la traslación produce paralaje pequeña; una rotación de tres grados desplaza la imagen entera por igual y es enormemente perceptible. En la práctica, casi todo el shake de cine es rotacional, con una pizca de traslación para los impactos muy cercanos.

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.

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

El shake no comunica el impacto, comunica la escala del impacto

Hay un error de diseño que se repite y que ninguna cantidad de ajuste técnico arregla: usar shake para señalar que ha pasado algo. Cuando cada acción del jugador —cada disparo, cada salto, cada golpe— viene acompañada de temblor, el efecto deja de significar nada en dos minutos y se convierte en ruido que cansa. El shake no es un acuse de recibo; es un indicador de magnitud, y su valor depende por completo de que sea escaso. Eso tiene tres consecuencias muy concretas en el código. La primera: define desde el principio una escala explícita de cuánto trauma añade cada evento y no la improvises. Algo así como 0,15 para el impacto menor que exista en tu escena, 0,4 para uno significativo, 0,9 reservado para el evento más grande de toda la experiencia. Si no la escribes, el número acaba siendo el que quedó bien la última vez que lo tocaste, y en un mes todo tiembla igual. La segunda: escala el trauma por la distancia, con caída al menos cuadrática. Una explosión a diez metros y otra a cien no deberían mover la cámara igual, y si lo hacen, la escena pierde toda sensación de espacio. La tercera, y la que más gente ignora: hay usuarios a los que esto les provoca mareo real. El movimiento de cámara no solicitado es uno de los principales desencadenantes de cinetosis en aplicaciones interactivas, y en realidad virtual pasa de molesto a incapacitante. Un multiplicador global de intensidad expuesto en opciones, que llegue hasta cero, no es un extra de accesibilidad: es lo mínimo. Y si tu experiencia corre en un visor, la respuesta correcta no es reducir el shake sino no mover nunca la cámara del usuario: en XR el temblor se comunica moviendo el mundo, atenuando la imagen o con retroalimentación háptica, jamás rotando el punto de vista. La cabeza del usuario es suya.

⚔️ Un temblor que no cansa
  1. Implementa el shake con Math.random() y con SimplexNoise y compáralos con la misma intensidad.
  2. Quita el cuadrado del trauma y describe cómo cambia la percepción del final del temblor.
  3. Monta la jerarquía de soporte y sacudida, y comprueba que desactivar el shake no altera nada más.
  4. Añade escalado por distancia con caída cuadrática y prueba con explosiones a 5, 20 y 80 unidades.
  5. Expón un multiplicador global que llegue a cero y verifica que la escena sigue siendo jugable sin ningún temblor.