El problema de las escalas grandes: cuando el mundo tiembla
Cuánto puede medir tu mundo antes de que f32 deje de servir, los cuatro síntomas que parecen cuatro bugs distintos, y la resta concreta que los causa todos.
Un mundo de kilómetros con detalle de centímetros no cabe en f32, y la aritmética que lo demuestra son dos líneas. Lo difícil no es la cuenta: es reconocer los síntomas, porque un solo error de precisión se manifiesta como cuatro bugs con aspecto completamente distinto, se reparte entre cuatro tickets, y cada uno recibe un parche que no arregla nada. Cuando la geometría tiembla, las normales se facetan, aparece z-fighting lejos del origen y los objetos pequeños se deforman, no tienes cuatro problemas. Tienes una resta mal hecha.
- Calcular el tamaño máximo de mundo que admite
f32para una resolución dada. - Identificar los cuatro síntomas de pérdida de precisión posicional y atribuirlos a su causa común.
- Localizar la resta responsable dentro de la cadena de transformaciones de un motor.
- Dimensionar el error de un uniform de tiempo y elegir la unidad correcta para enviarlo.
Cuánto mide tu mundo
La cuenta se hace en una dirección: eliges la resolución que necesitas y despejas la distancia máxima. Como el escalón de un f32 en la octava [2^e, 2^(e+1)) vale 2^(e-23), basta buscar la primera octava donde el escalón supera tu tolerancia.
| Resolución deseada | Regla de siete dígitos | Frontera exacta | Escalón justo antes |
|---|---|---|---|
1 mm |
unos 8 km | 16 384 m |
0,98 mm |
1 cm |
unos 80 km | 131 072 m |
7,8 mm |
10 cm |
unos 800 km | 1 048 576 m |
6,25 cm |
1 m |
unos 8000 km | 8 388 608 m |
0,5 m |
Las dos columnas del medio dicen cosas distintas y conviene entender por qué. La regla de siete dígitos es la heurística de servilleta: f32 da algo más de siete dígitos decimales, así que un número como 8000,000 gasta cuatro dígitos en los kilómetros y tres en los milímetros, y ahí se acaba el presupuesto. La frontera exacta es dónde está de verdad el salto, y siempre es una potencia de dos: hasta 16 384 metros el escalón es de 0,98 mm y a partir de ahí pasa de golpe a 1,95 mm. La heurística es conservadora por un factor de dos, que es exactamente el margen que quieres tener.
Un ejemplo concreto para fijarlo. A 1000 km del origen el escalón vale 0,0625 m, seis centímetros y cuarto. Cualquier detalle geométrico más fino que eso —el marco de una ventana, la separación entre dos raíles, el grosor de una chapa— deja de existir: todos sus vértices se redondean al mismo puñado de valores representables y la malla se colapsa sobre sí misma.
La conclusión práctica es un número que deberías tener escrito en algún sitio: el radio máximo de tu mundo en f32, para el detalle más fino que contiene. Si tu escena mide más que eso, no hay ajuste de cámara, ni formato de profundidad, ni antialiasing que lo arregle, porque el problema ocurre antes de la rasterización.
Los cuatro síntomas
Ninguno de los cuatro se parece a los otros, y esa es la razón de que el diagnóstico tarde semanas.
La geometría tiembla al mover la cámara. Es el síntoma delator y el más confuso. Los vértices no se desplazan de forma continua: saltan de un float representable al siguiente, y como el redondeo depende de la posición de la cámara, que cambia cada fotograma, el salto se produce en momentos distintos para cada vértice. Una fachada entera parece hervir. Lo característico es que el temblor desaparece cuando la cámara se detiene, porque entonces el error es constante y un error constante es invisible: el edificio está tres centímetros donde no debería y a nadie le importa. Lo que ves no es el error, es su derivada respecto al movimiento. De ahí que el bug sea imposible de reproducir con una captura de fotograma y que control de calidad lo describa como «parpadeo raro a veces».
Las normales se facetan y la iluminación parpadea. Si las normales se calculan en el shader a partir de posiciones —con derivadas de pantalla, con diferencias finitas sobre un mapa de altura, o normalizando la diferencia entre vértices vecinos— heredan la cancelación de esas posiciones, amplificada. Una normal es una diferencia normalizada: la resta pierde los dígitos y la normalización estira el ruido restante hasta la longitud uno. Un mapa de altura suave se convierte en un mosaico de facetas planas, y el especular, que es la potencia alta de un producto escalar, convierte cualquier ruido angular en centelleo.
Z-fighting lejos del origen con near y far razonables. El buffer de profundidad tiene su propio problema de precisión, gobernado por el plano cercano, pero este no es ese. Aquí la profundidad se calcula correctamente a partir de una posición que ya llegó corrupta: dos superficies separadas un centímetro cuyos vértices se han redondeado al mismo valor producen exactamente la misma z, y ninguna función de comparación puede desempatar valores idénticos. La pista para distinguirlo es geográfica: si subes near y no cambia nada, y si el mismo par de superficies no pelea cuando trasladas la escena cerca del origen, el problema es posicional.
Los objetos pequeños lejos del origen se deforman. Un objeto de un metro colocado a doscientos kilómetros tiene todos sus vértices dentro de un intervalo donde el escalón vale 1,56 cm. Sus cien vértices se reparten entre unos sesenta valores distinguibles, muchos coinciden, los triángulos degeneran en segmentos y el objeto se aplasta. Empeora con la distancia y no depende del ángulo de vista, lo que lo distingue de cualquier problema de rasterización.
La resta culpable
Los cuatro salen del mismo sitio. La cadena de transformaciones de cualquier motor termina calculando la posición de un vértice relativa a la cámara, y esa resta, hecha en f32, es el caso de libro de la cancelación catastrófica que ya viste en cómo se distribuyen los floats: dos operandos enormes cuya diferencia es pequeña.
Lo importante es dónde ocurre exactamente. Si subes al shader la matriz de modelo y la de vista por separado y las multiplicas dentro, la resta la hace la GPU en f32 y ya has perdido. Si compones la matriz de modelo-vista en la CPU, la resta la hace JavaScript en doble precisión y llega intacta.
Aquí está la demostración, con aritmética f32 simulada en la CPU para poder compararla contra la referencia exacta. Cámara en x = 500 000, objeto en x = 500 000,05, un vértice a un centímetro del origen del objeto. La respuesta correcta es 0,06 metros.
const f = Math.fround;
// Multiplicacion matriz por vector con aritmetica f32 en cada paso,
// como la haria la GPU. Matrices en orden de columnas.
function transformar(m, v) {
const o = [0, 0, 0, 0];
for (let r = 0; r < 4; r++) {
let s = 0;
for (let k = 0; k < 4; k++) s = f(s + f(m[k * 4 + r] * v[k]));
o[r] = s;
}
return o;
}
const ojo = 500000, objeto = 500000.05;
const local = [0.01, 0, 0, 1].map(f);
// A) Modelo y vista suben por separado: la resta la hace la GPU en f32.
const modelo = [1,0,0,0, 0,1,0,0, 0,0,1,0, objeto, 0, 0, 1].map(f);
const vista = [1,0,0,0, 0,1,0,0, 0,0,1,0, -ojo, 0, 0, 1].map(f);
transformar(vista, transformar(modelo, local))[0]; // 0.0625
// B) modeloVista relativo: la resta se hace en f64 antes de tocar f32.
const mvRel = [1,0,0,0, 0,1,0,0, 0,0,1,0, objeto - ojo, 0, 0, 1].map(f);
transformar(mvRel, local)[0]; // 0.06000000238418579
// Referencia exacta en doble precision: 0.05999999998835847
// Error de A: 2,500e-3 m Error de B: 2,396e-9 m
Dos milímetros y medio frente a dos nanómetros. Un factor de un millón, y el único cambio es quién hace la resta. Fíjate además en el paso intermedio: f32(500000.05) ya vale 500000.0625, porque el ULP a esa magnitud es 0,03125 y 0,05 no es múltiplo suyo. El error entra en cuanto la posición del objeto toca un Float32Array, antes de que la GPU haya hecho nada.
Eso da la regla de localización. Recorre tu cadena de transformaciones hacia atrás y busca el primer sitio donde una coordenada absoluta grande se almacena en treinta y dos bits. Los sospechosos habituales son el uniform de la matriz de modelo, el atributo de posición de instancia en un vertex buffer, el buffer de posiciones de un sistema de partículas en espacio de mundo, y las posiciones que un compute shader de física guarda entre fotogramas. Cualquiera de ellos convierte un motor correcto en uno que tiembla.
El caso del tiempo
El tiempo es una coordenada como las demás y sufre exactamente lo mismo, con el agravante de que crece sola.
El patrón habitual es tomar performance.now(), que devuelve milisegundos desde el arranque de la página, y subirlo tal cual a un uniform. Al principio funciona. Después de un rato deja de funcionar, y la degradación es progresiva, así que nadie la conecta con nada.
| Tiempo de sesión | Valor en ms | Escalón en f32 |
|---|---|---|
| 30 min | 1 800 000 |
0,125 ms |
| 1 h | 3 600 000 |
0,25 ms |
| 2 h | 7 200 000 |
0,5 ms |
| 4 h | 14 400 000 |
1 ms |
| 10 h | 36 000 000 |
4 ms |
| 24 h | 86 400 000 |
8 ms |
A las cuatro horas ya no puedes representar fracciones de milisegundo. A las diez, el escalón vale cuatro milisegundos, una cuarta parte de un fotograma a sesenta hercios: cualquier animación derivada del tiempo avanza a saltos, se congela durante varios fotogramas y luego pega un tirón. Los ruidos procedurales, que son funciones de alta frecuencia del tiempo, se quedan literalmente clavados. Y en un quiosco o un panel que no se recarga en días, esto no es hipotético.
Las tres salidas, por orden de preferencia. Envía el tiempo en segundos, que divide el valor por mil y te compra diez octavas: a las veinticuatro horas el escalón es de 7,8 ms en milisegundos pero de 7,8 microsegundos en segundos. Aplica un módulo antes de subirlo, que es lo correcto para cualquier cosa periódica: tiempo % 3600 para una animación con periodo de una hora mantiene el valor por debajo de 3600 y el escalón en 0,24 ms para siempre. Y sube el delta en lugar del absoluto cuando lo que necesitas es integrar, dejando la acumulación en la CPU donde hay cincuenta y tres bits.
const t0 = performance.now();
let acumulado = 0; // en doble, en la CPU, sin limite practico
function frame(ahora) {
const dt = (ahora - anterior) / 1000;
anterior = ahora;
acumulado += dt;
uniformes[0] = acumulado % 3600; // ciclo de una hora, escalon 0,24 ms
uniformes[1] = dt; // valores pequenos, precision maxima
device.queue.writeBuffer(bufferUniformes, 0, uniformes);
requestAnimationFrame(frame);
}
El módulo tiene una condición que hay que respetar: el periodo tiene que ser múltiplo exacto del de todo lo que dependa de él, o el salto de vuelta a cero produce un tirón visible una vez por ciclo. Con funciones de seno, usa un múltiplo de 2π; con ruido procedural, un valor que el propio ruido repita.
La razón por la que este fallo tarda tanto en diagnosticarse no es técnica, es organizativa, y entenderla te ahorra el mes que tardan los demás. El error de precisión posicional no produce un artefacto, produce cuatro, y los cuatro tienen aspecto de pertenecer a subsistemas distintos: el temblor parece un problema de la cámara o del bucle de animación, las normales sucias parecen un problema del material o del mapa de normales, el z-fighting parece un problema de configuración de profundidad, y la deformación de objetos lejanos parece un problema del importador de mallas. Cuatro tickets, cuatro personas, cuatro hipótesis razonables y cuatro parches que no arreglan nada. He visto equipos añadir un filtro temporal a la cámara para «suavizar el temblor», lo cual funciona lo justo para que el bug sobreviva otro sprint.
El segundo motivo es que el error es invisible en el depurador. Si inspeccionas las posiciones de los vértices, están bien. Si inspeccionas la matriz de modelo, está bien. Si haces una captura de fotograma con una herramienta de depuración gráfica, todo es coherente, porque el error no está en ninguno de esos valores por separado: está en la diferencia entre dos de ellos, y la diferencia solo existe dentro de la multiplicación matriz-vector, donde ninguna herramienta te deja mirar.
De ahí la prueba diagnóstica que resuelve el asunto en dos minutos y que casi nadie ejecuta: resta la posición de la cámara a todo el mundo y vuelve a renderizar. Traslada la escena entera y la cámara al origen, sin cambiar nada más, y mira. Si los cuatro síntomas se van a la vez, tienes el diagnóstico completo y no necesitas seguir investigando ninguno de ellos. Si alguno persiste, ese sí es un bug independiente y ya sabes cuál. Esa prueba de dos minutos separa un problema de precisión de todo lo demás y es la primera que hay que hacer, no la última.