wandres.dev
PRECISIÓN Y ESTABILIDAD · Floats, NaN y el z-fighting

Cómo se distribuyen los floats: el escalón crece con la magnitud

La anatomía de IEEE 754 binary32, por qué el espaciado entre floats se duplica en cada octava, y la tabla que convierte esa aritmética en milímetros de tu escena.

⏱ 21 min

Casi todos los bugs de precisión que vas a encontrar en una escena grande vienen de una sola creencia falsa: que los números en coma flotante están repartidos de manera uniforme, como si fueran una regla graduada. No lo están. El escalón entre dos floats consecutivos vale una diezmillonésima cerca del uno y vale un metro entero a diez mil kilómetros del origen, y esa progresión es exacta, calculable y completamente determinista. Una vez que sabes leer el escalón, los bugs dejan de parecer aleatorios.

🎯 Al terminar esta lección sabrás
  • Descomponer un f32 en sus tres campos y calcular su escalón desde los bits.
  • Predecir el error absoluto de una coordenada a partir de su magnitud, sin experimentar.
  • Reconocer la cancelación catastrófica en una resta y cuantificar los bits de información perdidos.
  • Inspeccionar el patrón de bits de un float desde JavaScript y desde WGSL.

Los tres campos y el bit que no está

Un f32 es IEEE 754 binary32: treinta y dos bits repartidos en un bit de signo, ocho bits de exponente y veintitrés bits de mantisa. El valor de un número normal se reconstruye así:

// signo: 1 bit. exponente: 8 bits, sesgado por 127. mantisa: 23 bits.
function reconstruir(signo, exponente, mantisa) {
  return (-1) ** signo * (1 + mantisa / 2 ** 23) * 2 ** (exponente - 127);
}
reconstruir(0, 130, 0x200000);   // 10

Fíjate en el 1 + que encabeza el significando. Ese uno no está almacenado en ninguna parte: se sabe que está porque todo número normal, escrito en binario y normalizado, empieza por uno. Es el bit implícito, y es la razón por la que veintitrés bits de mantisa dan veinticuatro bits de significando. Ese bit gratis es la mejor decisión de diseño del formato y la fuente de la única constante que importa: 2^-24 ≈ 5,96e-8, el épsilon de máquina en redondeo al más cercano.

El exponente se guarda sesgado por 127, así que el rango de exponentes normales va de -126 a +127. Los dos valores extremos del campo están reservados: exponente = 0 marca los subnormales y el cero, y exponente = 255 marca los infinitos y los NaN. El resultado es que el mayor finito representable es 3,4028235e38 y el menor normal positivo es 1,1754944e-38.

De aquí sale la propiedad que gobierna todo lo demás. Dentro de una octava —el intervalo entre una potencia de dos y la siguiente— los veinticuatro bits de significando reparten el intervalo en 2^23 trozos iguales, así que los floats sí están uniformemente espaciados, pero solo dentro de cada octava. Al cruzar a la octava siguiente el exponente sube en uno, el intervalo se duplica, el número de trozos no cambia, y el escalón se duplica también.

El escalón se llama ULP, unidad en el último lugar. Para un x positivo en la octava [2^e, 2^(e+1)), el ULP vale exactamente 2^(e-23).

Octava Exponente ULP como potencia ULP decimal
1 a 2 2^0 2^-23 1,1920929e-7
2 a 4 2^1 2^-22 2,3841858e-7
1024 a 2048 2^10 2^-13 1,2207031e-4
65536 a 131072 2^16 2^-7 7,8125e-3
1048576 a 2097152 2^20 2^-3 0,125
8388608 a 16777216 2^23 2^0 1

La última fila merece que te detengas: a partir de 2^23 = 8 388 608, el escalón vale uno. Un f32 no puede representar 8388609,5, ni ningún valor fraccionario. Y a partir de 2^24 = 16 777 216 el escalón vale dos, así que ni siquiera puede representar todos los enteros: 16777217 no existe en f32, se redondea a 16777216.

El error relativo es constante, el absoluto no

Divide el ULP por el propio número y verás que el cociente es siempre el mismo, entre 2^-24 y 2^-23 según dónde caiga el número dentro de su octava. Eso es el diseño funcionando: el error relativo de un f32 es constante en todo el rango, unos 6e-8, equivalente a algo más de siete dígitos decimales significativos —log10(2^24) = 7,22, de donde sale la regla habitual de «seis dígitos garantizados, siete casi siempre»—.

Pero tú no colocas objetos en tu escena con errores relativos: los colocas en metros. Y en metros lo que ves es el error absoluto, que crece linealmente con la magnitud. Esta es la única frase de la lección que hay que memorizar: el error relativo es constante, el error absoluto crece con la magnitud.

Traducido a unidades de mundo, con la convención de que una unidad es un metro:

Distancia al origen ULP en metros En unidades legibles
1 m 1,1921e-7 0,12 micras
10 m 9,5367e-7 0,95 micras
100 m 7,6294e-6 7,6 micras
1 km 6,1035e-5 0,061 mm
10 km 9,7656e-4 0,98 mm
100 km 7,8125e-3 7,8 mm
1000 km 6,25e-2 6,25 cm
10 000 km 1,0 1 metro

A un kilómetro del origen no puedes distinguir dos puntos separados por seis centésimas de milímetro, lo cual sobra para cualquier cosa. A cien kilómetros el límite es de casi ocho milímetros, que ya se nota en una malla con detalle fino. A diez mil kilómetros —el orden de magnitud de un planeta en metros— la resolución de tu mundo entero es de un metro, y todo lo que quede por debajo de eso simplemente no existe.

Cancelación catastrófica

Hay una operación que convierte ese error absoluto tolerable en un desastre visible, y es la resta de dos números grandes y parecidos.

Toma a = 1234567,0 y b = 1234566,9. En doble precisión su diferencia es 0,1. Pásalos a f32. El primero es un entero por debajo de 2^24, así que se representa exacto. El segundo cae en la octava [2^20, 2^21), donde el ULP vale 0,125, así que se redondea al múltiplo de 0,125 más cercano: 1234566,875. La resta de esos dos valores representables es exacta y vale 0,125.

const a = Math.fround(1234567.0);   // 1234567       entero, exacto en f32
const b = Math.fround(1234566.9);   // 1234566.875   redondeado al multiplo de 0,125

a - b;                              // 0.125, cuando lo esperado era 0.1
Math.abs((a - b - 0.1) / 0.1);      // 0.25 -> un 25 % de error

Un veinticinco por ciento de error a partir de dos operandos con siete dígitos correctos cada uno. Y lo insidioso no es la magnitud del error, sino su forma: el resultado 0,125 tiene veinticuatro bits de mantisa, pero solo uno lleva información. Los otros veintitrés son ceros que la normalización ha arrastrado desde la nada. La operación no ha destruido bits: ha destruido la información y ha dejado los bits, de modo que el resultado parece preciso. Ningún tipo de dato te va a avisar.

La regla general es fácil de aplicar: si restas dos cantidades cuya diferencia es varios órdenes de magnitud menor que ellas mismas, cuenta cuántos órdenes y descuenta ese número de dígitos significativos del resultado. Aquí los operandos valían 1,2e6 y la diferencia 1e-1: siete órdenes de magnitud de distancia, siete dígitos que se evaporan, y f32 solo tenía siete.

Guarda este ejemplo, porque la resta posicionMundo - posicionCamara que hace todo motor 3D es exactamente esta operación, y es el motivo de que las escenas grandes tiemblen.

Los valores especiales y cómo mirarles los bits

Los dos exponentes reservados dan cinco categorías que conviene reconocer por su patrón de bits.

Valor Bits Comentario
+0.0 0x00000000 Exponente y mantisa a cero
-0.0 0x80000000 Solo cambia el signo. -0.0 == 0.0 es cierto
1.0 0x3F800000 Exponente 127, mantisa cero
menor subnormal 0x00000001 1,4013e-45
mayor subnormal 0x007FFFFF 1,1754942e-38
menor normal 0x00800000 1,1754944e-38
mayor finito 0x7F7FFFFF 3,4028235e38
+inf 0x7F800000 Exponente lleno, mantisa cero
-inf 0xFF800000 Ídem con signo
NaN exponente lleno, mantisa distinta de cero 0x7FC00000 es el canónico

Los subnormales rellenan el hueco entre cero y el menor normal renunciando al bit implícito: su significando empieza por cero y su exponente se congela en 2^-126. Ganan rango cerca del cero a cambio de perder precisión progresivamente. En una CPU son una anécdota; en una GPU no cuentes con ellos, porque el hardware gráfico habitualmente los redondea a cero y eso está permitido. Si tu algoritmo depende de que un subnormal sobreviva, tu algoritmo ya está roto.

El cero con signo existe porque 1/(+0.0) da +inf y 1/(-0.0) da -inf, y esa distinción salva algunas funciones en los límites. La trampa práctica es que -0.0 == 0.0 devuelve verdadero pero sus bits son distintos, así que dos rutas que difieren solo en el signo del cero pasan cualquier comparación y fallan cualquier prueba de igualdad bit a bit.

Para mirar los bits desde JavaScript se usan dos vistas tipadas sobre el mismo ArrayBuffer. Escribes por una y lees por la otra, sin copia ni conversión: es un reinterpretado puro.

const buffer = new ArrayBuffer(4);
const f32 = new Float32Array(buffer);
const u32 = new Uint32Array(buffer);

function bits(x) {
  f32[0] = x;
  return '0x' + u32[0].toString(16).toUpperCase().padStart(8, '0');
}

// El escalon real: escribe x, incrementa su patron de bits en uno,
// y mide cuanto ha subido el valor. Solo valido para x positivo y finito.
function escalon(x) {
  f32[0] = Math.fround(x);
  const antes = f32[0];
  u32[0] += 1;
  return f32[0] - antes;
}

for (const m of [1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7]) {
  console.log(String(m).padStart(9), bits(m), escalon(m).toExponential(4));
}
// 1  0x3F800000  1.1921e-7
// 10  0x41200000  9.5367e-7
// 100  0x42C80000  7.6294e-6
// 1000  0x447A0000  6.1035e-5
// 10000  0x461C4000  9.7656e-4
// 100000  0x47C35000  7.8125e-3
// 1000000  0x49742400  6.2500e-2
// 10000000  0x4B189680  1.0000e+0

Math.fround es la pieza que falta cuando trabajas en JavaScript: devuelve el f32 más cercano a un número, calculado en doble precisión. Es la manera de simular en la CPU exactamente lo que verá el shader antes de subir nada a la GPU.

En WGSL el equivalente es bitcast, que reinterpreta los bits sin convertir el valor. La función es la misma en las dos direcciones.

fn exponenteDe(x: f32) -> u32 {
  return (bitcast<u32>(x) >> 23u) & 0xffu;
}

// El siguiente float representable hacia arriba, para x positivo y finito.
fn siguiente(x: f32) -> f32 {
  return bitcast<f32>(bitcast<u32>(x) + 1u);
}

fn escalonDe(x: f32) -> f32 {
  return siguiente(x) - x;
}

Esta pareja bitcast<u32> y bitcast<f32> va a reaparecer en cuanto haya que detectar un NaN de forma que ningún optimizador pueda eliminar la comprobación, porque una operación entera no tiene semántica de coma flotante que el compilador pueda explotar. Lo verás en depurar un NaN que se propaga.

Un float es un logaritmo con corrección lineal, y una coordenada no es una magnitud

Merece la pena entender qué optimiza el formato, porque explica por qué encaja tan mal con las coordenadas. El campo de exponente es, literalmente, la parte entera del logaritmo en base dos del número; la mantisa es una interpolación lineal dentro de esa octava. Un f32 es una tabla logarítmica con corrección de primer orden, y por eso reparte su precisión de forma multiplicativa: da el mismo error relativo a un electrón que a una galaxia. Para magnitudes físicas —masas, energías, intensidades luminosas, radios, tiempos de vida— ese reparto es exactamente el correcto, y es por lo que la coma flotante ganó a la coma fija en los años setenta y no ha vuelto a perder.

El problema es que una coordenada no es una magnitud. Una coordenada es una diferencia disfrazada: la distancia desde un punto que alguien eligió arbitrariamente. Cuando escribes que un vértice está en x = 500000, no estás afirmando nada sobre ese vértice, estás afirmando algo sobre la relación entre el vértice y un origen que no existe físicamente y que probablemente pusiste donde el exportador del modelo lo dejó. El formato, sin saberlo, está gastando toda su precisión buena en la vecindad de ese punto arbitrario, y dándote un metro de resolución donde de verdad estás mirando.

De ahí sale la respuesta entera al problema de las escalas grandes, y sale antes de escribir una línea de código: si el formato concentra la precisión alrededor del cero, pon el cero donde está la cámara. No es un truco ni una optimización; es alinear la representación con lo que realmente estás midiendo. Todo lo demás —el origen flotante, la doble precisión emulada, los enteros de 64 bits en la CPU— son variantes de esa misma frase. Y el corolario incómodo: cualquier sistema que guarde posiciones absolutas en f32 está tomando una decisión sobre precisión sin saber que la está tomando, porque el error no aparece hasta que alguien mueve el mundo lejos del origen, normalmente seis meses después y en el portátil de un cliente.

⚔️ Mide tu propio escalón
  1. Escribe la función escalon del ejemplo y comprueba que a 2^23 devuelve exactamente 1 y a 2^24 devuelve exactamente 2.
  2. Verifica que Math.fround(16777217) === 16777216 y explica por qué.
  3. Toma las coordenadas reales de tu escena más grande, calcula el ULP en su punto más lejano, y compáralo con el detalle geométrico más fino que contiene.
  4. Reproduce la cancelación de 1234567,0 menos 1234566,9 con Math.fround y confirma el 0,125.
  5. Busca en tu código todas las restas de dos posiciones de mundo. Esa lista es tu inventario de riesgo.