Mecánica orbital en el navegador: Project Orrery
Vaynerov Project Orrery dibuja en tiempo real todos los satélites activos, en una pestaña del navegador, sin ningún plugin y sin que ningún servidor haga los cálculos. Así funciona la cadena de verdad — y qué partes resultaron no tener nada que ver con la mecánica orbital.
En esta página
El archivo de origen pesa unos diez megabytes. Contiene unos dieciséis mil registros de elementos orbitales — uno por cada objeto activo del catálogo público, publicado por CelesTrak y actualizado cada par de horas. Vaynerov Project Orrery coge ese archivo, lo adelgaza, te lo envía al navegador y lo propaga entero en un Web Worker a diez instantáneas por segundo mientras haces girar el planeta con el pulgar.
Existe una versión más fácil de esta página. Incrustar el rastreador de satélites de otro en un iframe, ponerle un borde turquesa y llamarlo buque insignia. En vez de eso escribimos el propagador — la ecuación de Kepler, las tasas seculares de J2, la rotación sidérea de la Tierra, todo en TypeScript. Esa decisión es la razón de que la página exista y también, en retrospectiva, la parte menos difícil del proyecto. Lo difícil fue una caché y un presupuesto de memoria.
- ~16k
- registros de elementos en origen (~10 MB)
- ~540 KB
- comprimidos tras el adelgazamiento
- 5
- iteraciones de Newton por resolución
- 0.010°
- deriva GEO en 6 horas simuladas
Lo que llega, lo que se envía, lo que cuesta el solucionador y lo que la autocomprobación le exige.
Un archivo de diez megabytes y una caché de dos
Nuestro proxy le pide a CelesTrak el grupo active en JSON, con un tiempo de espera de sesenta segundos y un user agent que dice quiénes somos — VaynerovOrrery/1.0 (+https://vaynerov.com/orrery) — porque CelesTrak pide a quien llame que se identifique y ser buen invitado no cuesta nada.
Lo que vuelve es un JSON verboso con claves largas y legibles repetidas dieciséis mil veces. Lo recodificamos como un array de arrays, que borra las claves por completo, y redondeamos los campos a la precisión que el ojo puede aprovechar de verdad: el movimiento medio a ocho decimales, la excentricidad a siete, los ángulos a cuatro. Diez megabytes se convierten en algo así como megabyte y medio en bruto, y en unos 540 KB por la red.
// One satellite, ten slots. The name survives; everything else is a number.
// [name, norad, epochMs, meanMotion, ecc, inc, raan, argp, ma, groupIdx]El registro adelgazado. Quitar las claves JSON es el mayor ahorro de toda la cadena — mayor que cualquier redondeo.
Una pequeña parte de los registros es activamente hostil al renderizador, así que el proxy los descarta: cualquiera con un elemento no finito, cualquiera con un movimiento medio por debajo de 0,9 revoluciones por día y cualquiera con excentricidad igual o superior a 0,9. El suelo de movimiento medio elimina los objetos a distancia lunar — los observatorios de espacio profundo cuyas órbitas los situarían varias pantallas fuera de un planeta dibujado con 6,4 unidades de altura. El techo de excentricidad elimina los registros casi parabólicos, que al solucionador de Kepler le desagradan por razones a las que volveremos. Unos once mil registros sobreviven y llegan al navegador.
Y luego un detalle fácil de hacer mal y silencioso cuando lo haces: las épocas de perturbaciones generales se publican sin zona horaria. Date.parse lee una marca de tiempo sin zona como hora local, así que el mismo catálogo propagado en dos máquinas de dos zonas distintas coloca cada satélite en un sitio diferente — a miles de kilómetros, y sin que nada lance una excepción. Nosotros truncamos las fracciones de segundo a milisegundos y añadimos una Z literal.
Por qué la caché es artesanal
Next.js tiene una caché de datos perfectamente buena y aquí no la usamos, por tres razones que se fueron acumulando en orden. La primera es el tamaño: una respuesta de origen de diez megabytes supera el límite de 2 MB por entrada, así que next: { revalidate } nunca llegó a retener silenciosamente una última copia buena. Silenciosamente es la palabra clave: nada fallaba, simplemente la descarga iba al origen todas las veces.
La segunda razón es la que de verdad dolió. Un ISR a nivel de ruta cacheó una vez un 503 del origen y, como la respuesta se cacheó correctamente, nuestro estado de «constelación sin conexión» se quedó congelado en la página seis horas mientras CelesTrak estaba perfectamente sano. Cachear un fallo es peor que no cachear nada.
La tercera es una sutileza del protocolo: CelesTrak devuelve un 403 si vuelves a preguntar dentro de su ventana de actualización de dos horas. Eso no es un error, es una señal de no modificado, y cualquier caché que lo trate como fallo desalojará datos buenos a cambio de nada.
Unos elementos rancios ganan a un cielo en blanco. Un satélite dibujado con elementos de hace seis horas sigue estando, a ojo, exactamente donde está el satélite.
Así que: dos niveles, memoria de proceso por delante de un archivo en el directorio temporal del sistema, un TTL de seis horas frente a la cadencia de unas dos horas de CelesTrak, un suelo de diez minutos entre intentos fallidos y deduplicación de peticiones en vuelo, para que un arranque en frío bajo carga descargue una vez y no una vez por petición. Cuando servimos desde un nivel rancio lo decimos en una cabecera de respuesta, x-orrery-elements: stale, y la caché del navegador recibe max-age=3600, stale-while-revalidate=21600. El caso auténtico de desconexión — ni copia en memoria, ni copia en disco, origen caído — devuelve un 503 marcado no-store, para que nada en ninguna parte pueda clavar el fallo como hizo el ISR aquella vez.
Kepler, más la única perturbación que no puedes saltarte
Cada registro te da un movimiento medio — revoluciones por día — y cinco ángulos. La mecánica de dos cuerpos convierte lo primero en un semieje mayor en una línea, porque el movimiento medio y el tamaño de la órbita son el mismo hecho escrito dos veces.
La Tierra, poco amablemente, no es ni una masa puntual ni una esfera. Se abomba en el ecuador unos 21 km, y ese abombamiento — codificado como el coeficiente adimensional J2, 1,08262668 × 10⁻³ — ejerce un par sobre toda órbita que no sea exactamente ecuatorial. En una sola pasada el efecto es invisible. En una semana es la diferencia entre que un satélite esté donde lo dibujaste y que esté al otro lado del planeta. J2 es la razón de que existan las órbitas heliosíncronas: los diseñadores de misión eligen una inclinación que hace que la deriva nodal coincida exactamente con el movimiento de la Tierra alrededor del Sol.
const n0 = meanMotion * TWO_PI / 86400; // rad/s
const a = Math.cbrt(MU_EARTH / (n0 * n0)); // km
const p = a * (1 - ecc * ecc);
const f = n0 * J2 * (RE / p) ** 2;
const raanDot = -1.5 * f * cosI;
const argpDot = 0.75 * f * (5 * cosI * cosI - 1);
const mDotJ2 = 0.75 * f * Math.sqrt(1 - ecc * ecc) * (3 * cosI * cosI - 1);Las tres tasas seculares. La ascensión recta del nodo ascendente retrograda, el argumento del perigeo rota y la anomalía media recibe una pequeña corrección — todo lineal en el tiempo desde la época del registro.
Adelanta los tres ángulos hasta el instante pedido y tendrás una anomalía media que hay que convertir en excéntrica. Eso es la ecuación de Kepler, M = E − e sin E, que no tiene solución en forma cerrada y se resuelve por iteración de Newton. Nosotros arrancamos en E = M y hacemos exactamente cinco iteraciones: sin test de convergencia, sin salida anticipada, sin cota de bucle que discutir. Esa constante no es pereza; es el rédito del filtro de excentricidad aguas arriba. Con e < 0,9 garantizado, cinco iteraciones desde E = M caen dentro de la precisión del float para todos los registros del catálogo, y un número fijo de iteraciones significa que todos los satélites cuestan lo mismo, que es justo lo que quiere un bucle interno apretado sobre dieciséis mil objetos.
Lo demás son rotaciones: coordenadas perifocales a través de Rz(−Ω) Rx(−i) Rz(−ω) hasta el sistema inercial geocéntrico, desarrolladas a mano porque solo dos columnas de ese producto sobreviven al contacto con un plano orbital bidimensional. Y luego un intercambio de ejes, porque la convención aeroespacial es Z arriba y three.js es Y arriba.
De la elección del sistema de referencia se desprende una decisión que merece decirse con claridad: giramos el planeta, no la constelación. Los satélites se quedan en coordenadas inerciales, donde sus órbitas son sencillas, y el grupo de la Tierra rota según el tiempo sidéreo medio de Greenwich — θ = 280,46061837 + 360,98564736629 × (JD − 2451545,0). La expresión de manual arrastra un término en T² después de eso; lo eliminamos, tras medir su contribución muy por debajo de una décima de grado a lo largo de décadas de tiempo simulado. A la escala de un planeta dibujado con 6,4 unidades de altura, una décima de grado no llega a píxel.
Propagación de grado visual, y decirlo
Lo que hemos construido es movimiento de dos cuerpos más tasas seculares de J2. No tiene resistencia atmosférica, ni perturbaciones lunisolares, ni presión de radiación solar, ni términos de resonancia, ni términos de periodo corto. Para un satélite en una órbita de 400 km, solo la resistencia atmosférica adelantará a nuestro modelo en cuestión de días.
El comentario del propagador lo dice en una frase — fiel al ojo a la escala de un planeta dibujado con 6,4 unidades de altura, no al instrumento — y termina con la línea que nos gustó lo suficiente como para ponerla en la interfaz: no navegues nunca con esto. El HUD lleva una etiqueta que dice «propagación de grado visual», y la ficha de un satélite seleccionado informa de qué antigüedad tienen sus elementos, medida contra el tiempo de simulación y no contra el reloj de pared, para que avanzar el tiempo haga que la extrapolación se vea honestamente peor.
Un modelo que sabe lo que no es resulta más útil que un modelo que finge. La etiqueta no es un descargo que nos vimos obligados a escribir: es lo más exacto que hay en toda la página.
La misma honestidad aparece en un sitio más pequeño. Al seleccionar un satélite dibujamos su órbita como 128 muestras a lo largo de un periodo completo, propagadas de forma exacta en el hilo principal. Esa curva solo es correcta para el instante en que se calculó, porque J2 va rotando la órbita por debajo en silencio, así que se recalcula tras 45 minutos de deriva simulada en vez de dejarla mintiendo con elegancia.
Nada de esto sería comprobable si los números solo aparecieran como píxeles. Así que una autocomprobación de solo desarrollo corre al arrancar y hace aserciones contra física conocida: un movimiento medio de clase ISS de 15,495 revoluciones por día tiene que producir un semieje mayor de 6.796,32 km, una altitud de 416,83 km, un periodo de 92,933 minutos y una velocidad de 7,660 km/s; un registro geoestacionario a 1,0027 tiene que producir 42.165,23 km y no derivar más de 0,010° en seis horas. Si la aritmética se desvía alguna vez, nos enteramos por la consola antes de que nadie se entere por el cielo.
Diez instantáneas por segundo, cero reservas de memoria
Once mil resoluciones de Kepler por fotograma a 60 fps no es algo que un hilo principal deba intentar, y tampoco es algo que necesite intentar. El propagador vive en un Web Worker que por contrato no toca ni el DOM ni React, y produce instantáneas a un máximo de diez hercios. El renderizador interpola.
Una instantánea es un único Float32Array empaquetado, tres floats por satélite, y se transfiere en vez de copiarse: la propiedad pasa al hilo principal y la referencia del worker queda a null. El motor guarda las dos últimas instantáneas e interpola entre ellas por tiempo de simulación, que es por lo que un presupuesto de física de 10 Hz se renderiza como movimiento continuo. Cuando un búfer se retira, el hilo principal se lo transfiere de vuelta al worker como mensaje de reciclaje, y el worker lo rellena. En régimen estacionario el sistema entero no reserva absolutamente nada; no hay basura que recoger, así que no hay pausa del recolector que notar.
Aquí el tiempo es una función pura: el tiempo de simulación es un ancla más el tiempo real transcurrido multiplicado por el factor de aceleración, con ajustes de 1×, 60×, 600× y 3600× y un salto de vuelta al directo. Con aceleración alta se le pide al worker que mire más adelante — una anticipación de al menos 1.500 milisegundos, y más cuanto mayor es la aceleración — porque a 3600× preferimos un flujo suave a una interpolación exacta entre dos instantes que el ojo no puede separar de todos modos. Tres guardas impiden que se rompa la ilusión: un contador de generación descarta las instantáneas producidas antes de un cambio de catálogo, un salto hacia atrás en el tiempo mayor que un umbral escalado por la aceleración invalida el intervalo de interpolación en vez de reproducirlo al revés, y todo el bucle se detiene cuando el documento está oculto o el canvas ocupa menos del 2% de la pantalla.
Una malla, seis números por satélite
Cada satélite del cielo es una instancia de un único InstancedMesh construido a partir de un tetraedro — cuatro triángulos, el sólido cerrado más barato que existe — con un material sin iluminación y color por instancia. En Orrery no hay postprocesado en ninguna parte: una pasada de bloom habría dejado el cielo más bonito en las capturas y se habría comido el presupuesto de fotograma de todos los dispositivos de gama baja que tienen que dibujarlo.
La actualización por fotograma escribe directamente en el array de matrices de instancia, y escribe seis números por satélite de dieciséis.
m[i + 0] = m[i + 5] = m[i + 10] = s; // scale, on the diagonal
m[i + 12] = x; // translation
m[i + 13] = y;
m[i + 14] = z;
// rotation is never written; m[i + 15] was set to 1 once, at init.Sin objetos Matrix4, sin llamadas a compose(), sin temporales por instancia. Once mil de estas por fotograma son una copia de memoria, no un cálculo.
El tamaño aparente se compensa con la distancia para que un satélite en el limbo lejano de la Tierra siga siendo pulsable: s = 0.016 + dist × 0.0021, acotado a 0,105. Las estaciones tripuladas se dibujan 1,7× más grandes y respiran con un pulso senoidal lento, que se omite por completo bajo prefers-reduced-motion. Filtrar por grupo — los ocho cubos por primera coincidencia que asigna el proxy, con el geoestacionario reclamado por un movimiento medio entre 0,99 y 1,02, para que un satélite meteorológico aparcado sobre el ecuador quede etiquetado como lo que es — nunca reconstruye la malla. A las instancias ocultas simplemente se les pone escala cero, que la GPU descarta gratis.
Seleccionar sin un raycaster
Lanzar rayos contra un InstancedMesh de once mil tetraedros para encontrar el que está bajo tu dedo son muchísimas pruebas de intersección para responder a una pregunta que la matriz de proyección ya había respondido. Así que la selección funciona al revés: un único bucle proyecta todos los satélites al espacio de pantalla, descarta lo que está detrás de la cámara por el signo de w, descarta lo que queda fuera de las coordenadas de dispositivo normalizadas ±1,1 y se queda con el acierto más cercano dentro de 14 píxeles del ratón o 20 de la yema de un dedo. Es una pasada de aritmética que íbamos a hacer de todos modos.
El táctil recibe luego las tonterías cuidadosas de siempre: un puntero que recorre más de 8 píxeles es un arrastre, no un toque; dos toques dentro de 350 milisegundos y 24 píxeles significan acompañar al satélite, lo que traslada la cámara rígidamente con el delta del propio satélite y amortigua el residuo, mientras el suelo de zoom se anima de 8 unidades hasta 0,9 para que puedas acercarte de verdad a lo que has elegido.
Una línea de costa en 66.717 bytes
Un globo necesita continentes, y la respuesta habitual es una textura: unos cientos de kilobytes de JPEG que se ve mal a cualquier nivel de zoom y se convierte en barro en un móvil de DPR bajo. Nosotros dibujamos líneas. Las costas 1:110m de Natural Earth, cuantizadas a la centésima de grado y empaquetadas por longitud de serie, se convierten en un módulo de 134 polilíneas y 5.127 puntos que pesa 66.717 bytes — menos que una fotografía pequeña, y nítido a cualquier zoom.
Hay una sutileza que se gana su código. Un tramo largo de costa dibujado como una cuerda recta entre dos puntos de una esfera pasa por dentro de la esfera, así que Groenlandia se sumerge un momento. Por eso cada tramo se subdivide a lo largo del círculo máximo mediante interpolación esférica hasta que ningún paso supera los dos grados, lo que deja todos los vértices exactamente sobre la superficie. El mismo truco para la retícula, para el ecuador más brillante y para el cinturón geoestacionario discontinuo a 42,164 unidades de escena — el único anillo de la página cuyo radio podrías buscar y comprobar.
Orrery se publicó en la misma entrega que Blueprint, el día en que el Laboratorio pasó de doce juegos, y hereda de esos juegos más de lo que parece: los niveles de calidad que limitan la densidad de píxeles por clase de hardware, el contrato de pantalla completa, la disciplina de desmontar todos los bucles al desmontar el componente. Resulta que un juego de navegador y un mapa de satélites en vivo tienen modos de fallo casi idénticos.