La mécanique orbitale dans le navigateur : Project Orrery
Vaynerov Project Orrery dessine chaque satellite actif en temps réel, dans un onglet de navigateur, sans plugin et sans serveur pour faire les calculs. Voici comment la chaîne fonctionne réellement — et quelles parties, au bout du compte, n'avaient rien à voir avec la mécanique orbitale.
Sur cette page
- Un fichier de dix mégaoctets et un cache de deux mégaoctets
- Kepler, plus la seule perturbation qu'on ne peut pas sauter
- Une propagation de qualité visuelle, et le dire
- Dix instantanés par seconde, zéro allocation
- Un maillage, six nombres par satellite
- Pointer sans lancer de rayon
- Un littoral en 66 717 octets
Le fichier amont pèse une dizaine de mégaoctets. Il contient environ seize mille fiches d'éléments orbitaux — une pour chaque objet actif du catalogue public, publiées par CelesTrak et rafraîchies toutes les deux heures ou presque. Vaynerov Project Orrery prend ce fichier, l'allège, l'envoie à votre navigateur, et le propage en entier dans un Web Worker à dix instantanés par seconde pendant que vous faites tourner la planète du pouce.
Il existe une version plus facile de cette page. Intégrer le traqueur de satellites de quelqu'un d'autre dans une iframe, l'entourer d'un liseré sarcelle, appeler cela un vaisseau amiral. Nous avons écrit le propagateur à la place — l'équation de Kepler, les taux séculaires de J2, la rotation sidérale de la Terre, le tout en TypeScript. Cette décision est la raison d'être de la page, et c'est aussi, rétrospectivement, la partie la moins difficile du chantier. Les parties dures ont été un cache et un budget mémoire.
- ~16k
- fiches d'éléments en amont (~10 Mo)
- ~540 KB
- en gzip après allègement
- 5
- itérations de Newton par résolution
- 0.010°
- dérive GEO sur 6 heures simulées
Ce qui arrive, ce qui part, ce que coûte le solveur, et ce que l'autocontrôle exige de lui.
Un fichier de dix mégaoctets et un cache de deux mégaoctets
Notre proxy demande à CelesTrak le groupe active en JSON, avec un délai d'attente de soixante secondes et un user agent qui dit qui nous sommes — VaynerovOrrery/1.0 (+https://vaynerov.com/orrery) — parce que CelesTrak demande à ses appelants de s'identifier et qu'il ne coûte rien d'être un invité correct.
Ce qui revient est un JSON verbeux, aux longues clés lisibles par un humain, répétées seize mille fois. Nous le réencodons en tableau de tableaux, ce qui efface entièrement les clés, et nous arrondissons les champs à la précision que l'œil peut réellement exploiter : le moyen mouvement à huit décimales, l'excentricité à sept, les angles à quatre. Dix mégaoctets deviennent environ un mégaoctet et demi en brut, et quelque 540 ko sur le réseau.
// One satellite, ten slots. The name survives; everything else is a number.
// [name, norad, epochMs, meanMotion, ecc, inc, raan, argp, ma, groupIdx]La fiche allégée. Supprimer les clés JSON est la plus grosse économie de toute la chaîne — plus grosse que n'importe quel arrondi.
Une petite part des fiches est franchement hostile au moteur de rendu, alors le proxy les écarte : tout ce qui porte un élément non fini, tout ce dont le moyen mouvement descend sous 0,9 révolution par jour, et tout ce dont l'excentricité atteint ou dépasse 0,9. Le plancher de moyen mouvement retire les objets à distance lunaire — ces observatoires de l'espace lointain dont les orbites les placeraient à plusieurs écrans d'une planète dessinée sur 6,4 unités de haut. Le plafond d'excentricité retire les fiches quasi paraboliques, que le solveur de Kepler n'aime pas pour des raisons sur lesquelles nous reviendrons. Environ onze mille fiches survivent jusqu'à un navigateur.
Puis un détail facile à rater, et silencieux quand on le rate : les époques de perturbations générales sont publiées sans fuseau horaire. Date.parse lit un horodatage sans fuseau comme une heure locale, si bien que le même catalogue propagé sur deux machines dans deux fuseaux place chaque satellite à un endroit différent — de plusieurs milliers de kilomètres, sans que rien ne lève d'exception. Nous tronquons les fractions de seconde à la milliseconde et ajoutons un Z littéral.
Pourquoi le cache est fait maison
Next.js a un cache de données parfaitement convenable et nous ne l'utilisons pas ici, pour trois raisons qui se sont accumulées dans l'ordre. La première est la taille : une réponse amont de dix mégaoctets dépasse la limite de 2 Mo par entrée, si bien que next: { revalidate } n'a jamais conservé la moindre copie valide. Silencieusement, et c'est le mot qui compte : rien n'échouait, la requête partait simplement en amont à chaque fois.
La deuxième raison est celle qui a vraiment fait mal. L'ISR au niveau de la route a un jour mis en cache un 503 venu de l'amont, et comme la réponse était correctement mise en cache, notre état « constellation hors ligne » est resté figé six heures sur la page alors que CelesTrak se portait très bien. Mettre un échec en cache est pire que de ne rien mettre en cache du tout.
La troisième est une subtilité de protocole : CelesTrak renvoie 403 si vous redemandez à l'intérieur de sa fenêtre de mise à jour de deux heures. Ce n'est pas une erreur, c'est un signal non modifié, et tout cache qui le traite comme un échec évincera de bonnes données au profit de rien.
Des éléments périmés valent mieux qu'un ciel vide. Un satellite dessiné à partir d'éléments vieux de six heures est encore, pour l'œil, exactement là où le satellite se trouve.
Donc : deux niveaux, la mémoire du processus devant un fichier dans le répertoire temporaire du système, un TTL de six heures face à la cadence d'environ deux heures de CelesTrak, un plancher de dix minutes entre deux tentatives échouées, et une déduplication des requêtes en vol pour qu'un démarrage à froid sous charge n'aille chercher qu'une fois plutôt qu'une fois par requête. Quand nous servons depuis un niveau périmé, nous le disons dans un en-tête de réponse, x-orrery-elements: stale, et le cache navigateur reçoit max-age=3600, stale-while-revalidate=21600. Le vrai cas hors ligne — pas de copie en mémoire, pas de copie sur disque, amont en panne — renvoie un 503 marqué no-store, pour que rien nulle part ne puisse épingler l'échec comme l'ISR l'a fait un jour.
Kepler, plus la seule perturbation qu'on ne peut pas sauter
Chaque fiche vous donne un moyen mouvement — des révolutions par jour — et cinq angles. La mécanique à deux corps transforme le premier en demi-grand axe en une ligne, parce que moyen mouvement et taille d'orbite sont le même fait écrit deux fois.
La Terre, fâcheusement, n'est ni un point matériel ni une sphère. Elle se renfle à l'équateur d'environ 21 km, et ce renflement — encodé dans le coefficient sans dimension J2, 1,08262668 × 10⁻³ — exerce un couple sur toute orbite qui n'est pas exactement équatoriale. Sur un seul passage, l'effet est invisible. Sur une semaine, c'est la différence entre un satellite là où vous l'avez dessiné et un satellite de l'autre côté de la planète. J2 est la raison même de l'existence des orbites héliosynchrones : les concepteurs de mission choisissent une inclinaison qui fait coïncider précisément la dérive nodale avec le mouvement de la Terre autour du Soleil.
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);Les trois taux séculaires. L'ascension droite du nœud ascendant régresse, l'argument du périgée tourne, et l'anomalie moyenne reçoit une petite correction — le tout linéaire en temps depuis l'époque de la fiche.
Avancez les trois angles jusqu'à l'instant demandé, et vous obtenez une anomalie moyenne qu'il faut convertir en anomalie excentrique. C'est l'équation de Kepler, M = E − e sin E, qui n'a pas de solution en forme close et se résout par itération de Newton. Nous partons de E = M et exécutons exactement cinq itérations — pas de test de convergence, pas de sortie anticipée, pas de borne de boucle à débattre. Cette constante n'est pas de la paresse : c'est le dividende du filtre d'excentricité appliqué en amont. Avec e < 0,9 garanti, cinq itérations depuis E = M tombent dans la précision des flottants pour chaque fiche du catalogue, et un nombre fixe d'itérations fait que chaque satellite coûte la même chose — exactement ce que veut une boucle interne serrée sur seize mille objets.
Le reste, ce sont des rotations : des coordonnées périfocales via Rz(−Ω) Rx(−i) Rz(−ω) vers le repère inertiel géocentrique, développées à la main parce que seules deux colonnes de ce produit survivent au contact d'un plan orbital bidimensionnel. Puis une permutation d'axes, parce que la convention aérospatiale est Z vers le haut et que three.js est Y vers le haut.
Une décision découle du choix de repère et mérite d'être énoncée franchement : nous faisons tourner la planète, pas la constellation. Les satellites restent en coordonnées inertielles, où leurs orbites sont simples, et le groupe Terre est mis en rotation par le temps sidéral moyen de Greenwich — θ = 280,46061837 + 360,98564736629 × (JD − 2451545,0). L'expression du manuel porte ensuite un terme en T² ; nous l'avons abandonné après avoir mesuré sa contribution bien en dessous du dixième de degré sur des décennies de temps simulé. À l'échelle d'une planète dessinée sur 6,4 unités de haut, un dixième de degré ne fait pas un pixel.
Une propagation de qualité visuelle, et le dire
Ce que nous avons construit, c'est du mouvement à deux corps plus les taux séculaires de J2. Sans traînée atmosphérique, sans perturbations luni-solaires, sans pression de radiation solaire, sans termes de résonance, sans termes à courte période. Pour un satellite sur une orbite à 400 km, la seule traînée aura dépassé notre modèle en quelques jours.
Le commentaire du propagateur le dit en une phrase — fidèle à l'œil à l'échelle d'une planète dessinée sur 6,4 unités de haut, pas au mètre — et se termine par la ligne qui nous a assez plu pour finir dans l'interface : ne naviguez jamais avec. Le HUD porte une pastille « propagation de qualité visuelle », et la fiche d'un satellite sélectionné indique l'âge de ses éléments, mesuré contre le temps de simulation plutôt que contre l'horloge murale, de sorte qu'accélérer le temps rend l'extrapolation honnêtement plus mauvaise à l'œil.
Un modèle qui sait ce qu'il n'est pas est plus utile qu'un modèle qui fait semblant. La pastille n'est pas un avertissement qu'on nous aurait imposé : c'est l'étiquette la plus exacte de la page.
La même honnêteté se retrouve dans un endroit plus modeste. Quand vous sélectionnez un satellite, nous traçons son orbite en 128 échantillons sur une période complète, propagés exactement sur le thread principal. Cette courbe n'est correcte que pour l'instant où elle a été calculée, parce que J2 fait discrètement tourner l'orbite sous elle — elle est donc recalculée après 45 minutes de dérive simulée plutôt que laissée à mentir joliment.
Rien de tout cela ne serait vérifiable si les nombres n'apparaissaient jamais qu'en pixels. Un autocontrôle réservé au développement s'exécute donc au démarrage et se confronte à la physique connue : un moyen mouvement de classe ISS de 15,495 révolutions par jour doit produire un demi-grand axe de 6 796,32 km, une altitude de 416,83 km, une période de 92,933 minutes et une vitesse de 7,660 km/s ; une fiche géostationnaire à 1,0027 doit produire 42 165,23 km et ne pas dériver de plus de 0,010° en six heures. Si l'arithmétique se met à divaguer, nous l'apprenons de la console avant que quiconque l'apprenne du ciel.
Dix instantanés par seconde, zéro allocation
Onze mille résolutions de Kepler par image à 60 ips n'est pas une chose qu'un thread principal devrait tenter, et ce n'est pas une chose qu'il a besoin de tenter. Le propagateur vit dans un Web Worker sans DOM et sans React par contrat, et il produit des instantanés à dix hertz au plus. Le rendu interpole.
Un instantané est un unique Float32Array compact, trois flottants par satellite, et il est transféré plutôt que copié — la propriété passe au thread principal et la référence du worker devient nulle. Le moteur conserve les deux derniers instantanés et interpole linéairement entre eux selon le temps de simulation, ce qui explique qu'un budget physique de 10 Hz se rende en mouvement continu. Quand un tampon est libéré, le thread principal le retransfère au worker par un message de recyclage, et le worker le remplit à nouveau. En régime établi, tout le système n'alloue absolument rien ; il n'y a pas de déchets à ramasser, donc pas de pause du ramasse-miettes à ressentir.
Le temps est ici une fonction pure : le temps de simulation vaut une ancre plus le temps réel écoulé multiplié par le facteur d'accélération, avec des réglages à 1×, 60×, 600× et 3600× et un retour immédiat au direct. À forte accélération, on demande au worker de regarder plus loin devant — une avance d'au moins 1 500 millisecondes, et davantage quand l'accélération est grande — parce qu'à 3600× nous préférons un flux fluide à une interpolation exacte entre deux instants que l'œil ne sait de toute façon pas séparer. Trois garde-fous empêchent l'illusion de se briser : un compteur de génération écarte les instantanés produits avant un changement de catalogue, un saut en arrière dans le temps supérieur à un seuil mis à l'échelle de l'accélération invalide l'encadrement d'interpolation au lieu de le jouer à l'envers, et toute la boucle s'arrête dès que le document est masqué ou que le canvas occupe moins de 2 % de l'écran.
Un maillage, six nombres par satellite
Chaque satellite du ciel est une instance d'un unique InstancedMesh bâti sur un tétraèdre — quatre triangles, le solide fermé le moins cher qui soit — avec un matériau non éclairé et une couleur par instance. Il n'y a aucun post-traitement nulle part dans Orrery : une passe de bloom aurait rendu le ciel plus joli en capture d'écran et pris tout le budget d'image de chaque appareil de bas de gamme qui doit le dessiner.
La mise à jour par image écrit directement dans le tableau des matrices d'instances, et elle y écrit six nombres par satellite sur seize.
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.Aucun objet Matrix4, aucun appel à compose(), aucun temporaire par instance. Onze mille de ces écritures par image, c'est une copie mémoire, pas un calcul.
La taille apparente est compensée en distance pour qu'un satellite au limbe lointain de la Terre reste cliquable : s = 0.016 + dist × 0.0021, borné à 0,105. Les stations habitées sont rendues 1,7× plus grandes et respirent au rythme d'une lente sinusoïde, entièrement supprimée sous prefers-reduced-motion. Le filtrage par groupe — les huit paniers premier-arrivé assignés par le proxy, le géostationnaire étant revendiqué par un moyen mouvement compris entre 0,99 et 1,02 pour qu'un satellite météo garé au-dessus de l'équateur soit étiqueté pour ce qu'il est — ne reconstruit jamais le maillage. Les instances masquées reçoivent simplement une échelle nulle, que le GPU écarte gratuitement.
Pointer sans lancer de rayon
Lancer un rayon dans un InstancedMesh de onze mille tétraèdres pour trouver celui qui se trouve sous votre doigt, cela fait beaucoup de tests d'intersection pour répondre à une question à laquelle la matrice de projection a déjà répondu. La sélection procède donc dans l'autre sens : une seule boucle projette chaque satellite en espace écran, élimine tout ce qui est derrière la caméra par le signe de w, écarte tout ce qui sort des coordonnées normalisées à ±1,1, et garde l'impact le plus proche à moins de 14 pixels de la souris ou 20 du bout du doigt. C'est une passe d'arithmétique que nous allions faire de toute façon.
Le tactile a ensuite droit aux habituelles subtilités : un pointeur qui parcourt plus de 8 pixels est un glissement, pas un tapotement ; deux tapotements en moins de 350 millisecondes et dans un rayon de 24 pixels veulent dire « accompagne-le », ce qui translate la caméra rigidement avec le delta de repère propre au satellite et amortit le résidu, tandis que le plancher de zoom s'anime de 8 unités jusqu'à 0,9 pour que vous puissiez vraiment vous approcher de ce que vous avez choisi.
Un littoral en 66 717 octets
Un globe a besoin de continents, et la réponse habituelle est une texture — quelques centaines de kilo-octets de JPEG qui a l'air faux à tous les niveaux de zoom et se change en bouillie sur un téléphone à faible densité. Nous avons dessiné des lignes à la place. Les littoraux Natural Earth au 1:110 m, quantifiés au centième de degré et compressés par plages, deviennent un module de 134 polylignes et 5 127 points pesant 66 717 octets — moins qu'une petite photographie, et net à tous les zooms.
Une subtilité mérite son code. Un long segment de littoral tracé en corde droite entre deux points d'une sphère passe à travers la sphère, et le Groenland se retrouve brièvement immergé. Chaque segment est donc subdivisé le long du grand cercle par interpolation sphérique jusqu'à ce qu'aucun pas ne dépasse deux degrés environ, ce qui place chaque sommet exactement sur la surface. Même astuce pour le graticule, l'équateur plus lumineux, et la ceinture géostationnaire en pointillés à 42,164 unités de scène — le seul anneau de la page dont on peut aller vérifier le rayon.
Orrery est sorti dans la même livraison que Blueprint, le jour où le Labo a franchi les douze jeux, et il hérite de ces jeux bien plus qu'il n'y paraît : les paliers de qualité qui plafonnent la densité de pixels par classe de matériel, le contrat plein écran, la discipline consistant à démonter chaque boucle au démontage du composant. Un jeu de navigateur et une carte de satellites en direct ont, il se trouve, des modes de défaillance presque identiques.