Himmelsmechanik im Browser: Project Orrery
Vaynerov Project Orrery zeichnet jeden aktiven Satelliten in Echtzeit, in einem Browser-Tab, ohne Plug-in und ohne einen Server, der die Mathematik übernimmt. So funktioniert die Pipeline wirklich — und welche Teile davon sich am Ende als völlig himmelsmechanikfrei herausstellten.
Auf dieser Seite
- Eine Zehn-Megabyte-Datei und ein Zwei-Megabyte-Cache
- Kepler plus die eine Störung, die man nicht weglassen kann
- Propagation in Sichtqualität — und das auch zu sagen
- Zehn Momentaufnahmen pro Sekunde, null Allokationen
- Ein Mesh, sechs Zahlen pro Satellit
- Picking ohne Raycaster
- Eine Küstenlinie in 66.717 Bytes
Die Datei am anderen Ende ist etwa zehn Megabyte groß. Sie enthält rund sechzehntausend Sätze von Bahnelementen — einen für jedes aktive Objekt im öffentlichen Katalog, veröffentlicht von CelesTrak und alle paar Stunden erneuert. Vaynerov Project Orrery nimmt diese Datei, magert sie ab, schickt sie in Ihren Browser und propagiert sie vollständig in einem Web Worker, zehn Momentaufnahmen pro Sekunde, während Sie den Planeten mit dem Daumen drehen.
Es gibt eine einfachere Version dieser Seite. Man bettet den Satellitentracker von irgendwem in ein iframe, legt einen petrolfarbenen Rahmen drum herum und nennt das Ergebnis ein Flaggschiff. Wir haben stattdessen den Propagator geschrieben — Keplers Gleichung, die säkularen J2-Raten, die siderische Rotation der Erde, alles in TypeScript. Diese Entscheidung ist der Grund, warum es die Seite gibt, und sie war im Rückblick auch der am wenigsten schwierige Teil des Baus. Schwer waren ein Cache und ein Speicherbudget.
- ~16k
- Elementsätze am Ursprung (~10 MB)
- ~540 KB
- gzip-komprimiert nach dem Abmagern
- 5
- Newton-Iterationen pro Lösung
- 0.010°
- GEO-Drift über 6 simulierte Stunden
Was ankommt, was ausgeliefert wird, was der Solver kostet — und was die Selbstprüfung von ihm verlangt.
Eine Zehn-Megabyte-Datei und ein Zwei-Megabyte-Cache
Unser Proxy fragt bei CelesTrak die Gruppe active als JSON an, mit sechzig Sekunden Timeout und einem User-Agent, der sagt, wer wir sind — VaynerovOrrery/1.0 (+https://vaynerov.com/orrery) —, weil CelesTrak seine Aufrufer darum bittet, sich zu erkennen zu geben, und es nichts kostet, ein guter Gast zu sein.
Zurück kommt geschwätziges JSON mit langen, menschenlesbaren Schlüsseln, sechzehntausendmal wiederholt. Wir kodieren es als Array von Arrays neu, was die Schlüssel vollständig tilgt, und runden die Felder auf die Genauigkeit, die das Auge tatsächlich nutzen kann: mittlere Bewegung auf acht Nachkommastellen, Exzentrizität auf sieben, Winkel auf vier. Aus zehn Megabyte werden roh etwa anderthalb Megabyte und über die Leitung rund 540 KB.
// One satellite, ten slots. The name survives; everything else is a number.
// [name, norad, epochMs, meanMotion, ecc, inc, raan, argp, ma, groupIdx]Der abgemagerte Datensatz. Die JSON-Schlüssel wegzulassen ist die größte Einsparung der gesamten Pipeline — größer als jede Rundung.
Ein kleiner Teil der Datensätze ist dem Renderer aktiv feindlich gesinnt, der Proxy wirft sie deshalb weg: alles mit einem nicht endlichen Element, alles mit einer mittleren Bewegung unter 0,9 Umläufen pro Tag und alles mit einer Exzentrizität von 0,9 oder darüber. Die Untergrenze bei der mittleren Bewegung entfernt Objekte in Mondentfernung — die Deep-Space-Observatorien, deren Bahnen sie mehrere Bildschirme außerhalb eines 6,4 Einheiten großen Planeten platzieren würden. Die Obergrenze bei der Exzentrizität entfernt nahezu parabolische Datensätze, die der Kepler-Solver aus Gründen nicht mag, auf die wir gleich zurückkommen. Rund elftausend Datensätze überleben bis in einen Browser.
Und dann ein Detail, das man leicht falsch macht und das dabei schweigt: Die Epochen der General Perturbations werden ohne Zeitzone veröffentlicht. Date.parse liest einen zonenlosen Zeitstempel als lokale Zeit, derselbe Katalog auf zwei Maschinen in zwei Zeitzonen propagiert also jeden Satelliten an einen anderen Ort — um Tausende Kilometer daneben, ohne dass irgendetwas wirft. Wir kürzen die Sekundenbruchteile auf Millisekunden und hängen ein wörtliches Z an.
Warum der Cache handgemacht ist
Next.js hat einen völlig brauchbaren Data Cache, und wir benutzen ihn hier nicht, aus drei Gründen, die sich der Reihe nach angesammelt haben. Der erste ist die Größe: Eine Antwort von zehn Megabyte überschreitet das Limit von 2 MB pro Eintrag, next: { revalidate } hat also stillschweigend nie eine letzte gute Kopie behalten. Stillschweigend ist das entscheidende Wort — nichts schlug fehl, der Fetch ging nur jedes Mal zum Ursprung.
Der zweite Grund ist der, der wirklich wehtat. ISR auf Routenebene hat einmal einen 503 vom Ursprung gecacht, und weil die Antwort erfolgreich gecacht wurde, blieb unser Zustand „Konstellation offline“ sechs Stunden lang auf der Seite eingefroren, während es CelesTrak bestens ging. Einen Fehlschlag zu cachen ist schlimmer, als gar nicht zu cachen.
Der dritte ist eine Feinheit des Protokolls: CelesTrak antwortet mit 403, wenn man innerhalb seines Zwei-Stunden-Aktualisierungsfensters noch einmal fragt. Das ist kein Fehler, sondern ein Signal nicht verändert, und jeder Cache, der es als Fehlschlag behandelt, wirft gute Daten zugunsten von nichts hinaus.
Veraltete Elemente schlagen einen leeren Himmel. Ein Satellit, gezeichnet aus sechs Stunden alten Elementen, ist fürs Auge immer noch genau dort, wo der Satellit ist.
Also: zwei Stufen, Prozessspeicher vor einer Datei im temporären Verzeichnis des Betriebssystems, sechs Stunden TTL gegen den rund zweistündigen Takt von CelesTrak, zehn Minuten Mindestabstand zwischen fehlgeschlagenen Versuchen und Deduplizierung laufender Anfragen, damit ein Kaltstart unter Last einmal holt statt einmal pro Request. Liefern wir aus einer veralteten Stufe, sagen wir das in einem Response-Header, x-orrery-elements: stale, und der Browser-Cache bekommt max-age=3600, stale-while-revalidate=21600. Der echte Offline-Fall — keine Kopie im Speicher, keine auf der Platte, Ursprung tot — liefert einen 503 mit no-store, damit nirgendwo irgendetwas den Fehlschlag festnageln kann, wie ISR es einmal tat.
Kepler plus die eine Störung, die man nicht weglassen kann
Jeder Datensatz liefert eine mittlere Bewegung — Umläufe pro Tag — und fünf Winkel. Die Zweikörpermechanik macht aus der ersten in einer Zeile eine große Halbachse, denn mittlere Bewegung und Bahngröße sind dieselbe Tatsache, zweimal aufgeschrieben.
Die Erde ist leider weder Punktmasse noch Kugel. Sie wölbt sich am Äquator um etwa 21 km, und diese Wölbung — kodiert als dimensionsloser Koeffizient J2, 1,08262668 × 10⁻³ — übt auf jede nicht exakt äquatoriale Bahn ein Drehmoment aus. Über einen einzelnen Überflug ist der Effekt unsichtbar. Über eine Woche ist er der Unterschied zwischen einem Satelliten dort, wo Sie ihn gezeichnet haben, und einem auf der anderen Seite des Planeten. J2 ist überhaupt der Grund, warum es sonnensynchrone Bahnen gibt: Missionsplaner wählen eine Inklination, bei der die Knotendrift genau zur Bewegung der Erde um die Sonne passt.
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);Die drei säkularen Raten. Die Rektaszension des aufsteigenden Knotens wandert zurück, das Argument des Perigäums dreht sich, und die mittlere Anomalie bekommt eine kleine Korrektur — alles linear in der Zeit ab der Epoche des Datensatzes.
Schiebt man die drei Winkel zum gewünschten Zeitpunkt vor, hat man eine mittlere Anomalie, die in eine exzentrische umgerechnet werden muss. Das ist Keplers Gleichung, M = E − e sin E, die keine geschlossene Lösung hat und per Newton-Iteration gelöst wird. Wir starten bei E = M und laufen exakt fünf Iterationen — kein Konvergenztest, kein vorzeitiger Ausstieg, keine Schleifenschranke, über die man streiten müsste. Diese Konstante ist keine Faulheit; sie ist der Ertrag des Exzentrizitätsfilters weiter vorn. Mit garantiertem e < 0,9 landen fünf Iterationen ab E = M für jeden Datensatz des Katalogs innerhalb der Float-Genauigkeit, und eine feste Iterationszahl bedeutet, dass jeder Satellit gleich viel kostet — genau das, was eine enge innere Schleife über sechzehntausend Objekte will.
Der Rest sind Drehungen: perifokale Koordinaten über Rz(−Ω) Rx(−i) Rz(−ω) ins erdzentriert-inertiale System, von Hand ausmultipliziert, weil von diesem Produkt nur zwei Spalten den Kontakt mit einer zweidimensionalen Bahnebene überstehen. Dann ein Achsentausch, weil die Luft- und Raumfahrt Z nach oben legt und three.js Y.
Eine Entscheidung fällt aus der Wahl des Bezugssystems heraus und gehört deutlich gesagt: Wir drehen den Planeten, nicht die Konstellation. Die Satelliten bleiben in inertialen Koordinaten, in denen ihre Bahnen einfach sind, und die Erdgruppe wird mit der mittleren Sternzeit von Greenwich gedreht — θ = 280.46061837 + 360.98564736629 × (JD − 2451545.0). Der Lehrbuchausdruck führt danach noch einen T²-Term; wir haben ihn weggelassen, nachdem wir seinen Beitrag über Jahrzehnte simulierter Zeit auf deutlich unter einem Zehntelgrad gemessen hatten. Beim Maßstab eines 6,4 Einheiten großen Planeten ist ein Zehntelgrad kein Pixel.
Propagation in Sichtqualität — und das auch zu sagen
Was wir gebaut haben, ist Zweikörperbewegung plus säkulare J2-Raten. Es kennt keinen atmosphärischen Widerstand, keine lunisolaren Störungen, keinen Strahlungsdruck der Sonne, keine Resonanzterme, keine kurzperiodischen Terme. Für einen Satelliten auf einer 400-km-Bahn läuft allein die Reibung unserem Modell innerhalb von Tagen davon.
Der Kommentar im Propagator sagt das in einem Satz — treu fürs Auge beim Maßstab eines 6,4 Einheiten großen Planeten, nicht auf den Meter — und endet mit der Zeile, die uns gut genug gefiel, um sie in die Oberfläche zu nehmen: niemals damit navigieren. Im HUD steht ein Chip mit „Propagation in Sichtqualität“, und die Infokarte eines gewählten Satelliten meldet, wie alt seine Elemente sind, gemessen an der Simulationszeit statt an der Uhr an der Wand, damit ein Zeitsprung nach vorn die Extrapolation ehrlicherweise schlechter aussehen lässt.
Ein Modell, das weiß, was es nicht ist, nützt mehr als eines, das so tut. Der Chip ist kein Haftungsausschluss, zu dem man uns gezwungen hat; er ist das genaueste Etikett auf der ganzen Seite.
Dieselbe Ehrlichkeit taucht an kleinerer Stelle auf. Wählen Sie einen Satelliten, zeichnen wir seine Bahn als 128 Stützpunkte über eine volle Umlaufzeit, exakt auf dem Haupt-Thread propagiert. Diese Kurve stimmt nur für den Moment, in dem sie berechnet wurde, denn J2 dreht die Bahn darunter leise weiter — sie wird deshalb nach 45 Minuten simulierter Drift neu berechnet, statt schön daliegen zu dürfen.
Nichts davon wäre prüfbar, wenn die Zahlen nur je als Pixel aufträten. Also läuft beim Start eine Selbstprüfung, die es nur in der Entwicklung gibt, und behauptet gegen bekannte Physik: Eine mittlere Bewegung von 15,495 Umläufen pro Tag, wie sie die ISS hat, muss eine große Halbachse von 6796,32 km ergeben, eine Höhe von 416,83 km, eine Umlaufzeit von 92,933 Minuten und eine Geschwindigkeit von 7,660 km/s; ein geostationärer Datensatz bei 1,0027 muss 42.165,23 km ergeben und über sechs Stunden nicht mehr als 0,010° driften. Wenn die Arithmetik je abwandert, erfahren wir es aus der Konsole, bevor es jemand vom Himmel erfährt.
Zehn Momentaufnahmen pro Sekunde, null Allokationen
Elftausend Kepler-Lösungen pro Frame bei 60 fps sind nichts, was ein Haupt-Thread versuchen sollte, und auch nichts, was er versuchen müsste. Der Propagator wohnt in einem Web Worker, der per Vertrag DOM-frei und React-frei ist, und erzeugt Momentaufnahmen mit gedeckelten zehn Hertz. Der Renderer interpoliert.
Eine Momentaufnahme ist ein gepacktes Float32Array, drei Floats pro Satellit, und sie wird transferiert statt kopiert — der Besitz wandert zum Haupt-Thread, und die Referenz des Workers wird null. Die Engine hält die letzten beiden Momentaufnahmen und lerpt nach Simulationszeit zwischen ihnen, weshalb sich ein Physikbudget von 10 Hz als kontinuierliche Bewegung darstellt. Wird ein Puffer ausgemustert, transferiert der Haupt-Thread ihn als Recycling-Nachricht zurück an den Worker, der ihn wieder füllt. Im eingeschwungenen Zustand allokiert das gesamte System überhaupt nichts; es gibt keinen Müll einzusammeln, also auch keine Sammlerpause zu spüren.
Zeit ist hier eine reine Funktion: Simulationszeit ist ein Anker plus verstrichene reale Zeit mal Zeitrafferfaktor, mit den Stufen 1×, 60×, 600× und 3600× und einem Sprung zurück in die Echtzeit. Bei hohem Zeitraffer wird der Worker gebeten, weiter vorauszuschauen — mindestens 1500 Millisekunden Vorlauf, mehr bei großem Faktor —, denn bei 3600× ist uns flüssiger Lauf lieber als exakte Interpolation zwischen zwei Zeitpunkten, die das Auge ohnehin nicht trennen kann. Drei Wächter verhindern, dass die Illusion bricht: Ein Generationszähler verwirft Momentaufnahmen, die vor einem Katalogwechsel entstanden sind, ein Zeitsprung rückwärts über einer zeitrafferskalierten Schwelle macht das Interpolationsintervall ungültig, statt es rückwärts abzuspielen, und die ganze Schleife hält an, wenn das Dokument verborgen ist oder weniger als 2 % des Canvas im Bild stehen.
Ein Mesh, sechs Zahlen pro Satellit
Jeder Satellit am Himmel ist eine Instanz eines einzigen InstancedMesh aus einem Tetraeder — vier Dreiecke, der billigste geschlossene Körper, den es gibt — mit unbeleuchtetem Material und Farbe pro Instanz. Im ganzen Orrery gibt es kein Postprocessing: Ein Bloom-Pass hätte den Himmel auf Screenshots hübscher gemacht und das Frame-Budget jedes schwächeren Geräts aufgefressen, das ihn zeichnen muss.
Das Update pro Frame schreibt direkt in das Array der Instanzmatrizen, und es schreibt sechs von sechzehn Zahlen pro Satellit.
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.Keine Matrix4-Objekte, kein compose()-Aufruf, keine temporären Objekte pro Instanz. Elftausend davon pro Frame sind eine Speicherkopie, keine Berechnung.
Die scheinbare Größe ist entfernungskompensiert, damit ein Satellit am fernen Rand der Erde anklickbar bleibt: s = 0.016 + dist × 0.0021, gedeckelt bei 0,105. Bemannte Stationen rendern 1,7× größer und atmen mit einem langsamen Sinuspuls, der unter prefers-reduced-motion komplett entfällt. Das Filtern nach Gruppe — den acht Töpfen nach dem Prinzip „erster Treffer gewinnt“, die der Proxy vergibt, wobei geostationär über eine mittlere Bewegung zwischen 0,99 und 1,02 beansprucht wird, damit ein über dem Äquator geparkter Wettersatellit auch als solcher markiert ist — baut das Mesh nie neu. Verborgene Instanzen bekommen schlicht die Skalierung null, die die GPU gratis wegwirft.
Picking ohne Raycaster
In ein InstancedMesh aus elftausend Tetraedern hineinzustrahlen, um den einen unter Ihrem Finger zu finden, sind sehr viele Schnitttests für eine Frage, die die Projektionsmatrix längst beantwortet hat. Die Auswahl läuft deshalb andersherum: Eine einzige Schleife projiziert jeden Satelliten in den Bildschirmraum, verwirft über das Vorzeichen von w alles hinter der Kamera, verwirft alles außerhalb der normalisierten Gerätekoordinaten ±1,1 und behält den nächstgelegenen Treffer innerhalb von 14 Pixeln zur Maus oder 20 zur Fingerkuppe. Das ist ein Durchgang Arithmetik, den wir ohnehin gemacht hätten.
Touch bekommt dann den üblichen sorgfältigen Unfug: Ein Zeiger, der mehr als 8 Pixel zurücklegt, ist ein Ziehen und kein Tippen; zweimal Tippen innerhalb von 350 Millisekunden und 24 Pixeln heißt Mitfliegen, was die Kamera starr mit dem Frame-Delta des Satelliten mitführt und den Rest ausschwingen lässt, während die Zoom-Untergrenze von 8 Einheiten auf 0,9 animiert, damit Sie dem gewählten Objekt tatsächlich nahekommen.
Eine Küstenlinie in 66.717 Bytes
Ein Globus braucht Kontinente, und die übliche Antwort ist eine Textur — ein paar hundert Kilobyte JPEG, das auf jeder Zoomstufe falsch aussieht und auf einem Telefon mit niedriger Pixeldichte zu Matsch verläuft. Wir haben stattdessen Linien gezeichnet. Die 1:110m-Küstenlinien von Natural Earth, auf ein Hundertstelgrad quantisiert und lauflängenkodiert, werden zu einem Modul aus 134 Polylinien und 5127 Punkten mit einem Gewicht von 66.717 Bytes — weniger als ein kleines Foto, und bei jedem Zoom scharf.
Eine Feinheit verdient ihren Code. Ein langes Küstensegment, als gerade Sehne zwischen zwei Punkten auf einer Kugel gezeichnet, verläuft durch die Kugel, Grönland taucht also kurz unter. Jedes Segment wird deshalb per sphärischer Interpolation entlang des Großkreises unterteilt, bis kein Schritt mehr rund zwei Grad überschreitet, was jeden Vertex exakt auf die Oberfläche legt. Derselbe Trick beim Gradnetz, beim helleren Äquator und beim gestrichelten geostationären Gürtel bei 42,164 Szeneneinheiten — dem einen Ring auf der Seite, dessen Radius man nachschlagen und prüfen könnte.
Orrery ging im selben Release live wie Blueprint, an dem Tag, an dem das Lab die zwölf Spiele überschritt, und es erbt von diesen Spielen mehr, als es aussieht: die Qualitätsstufen, die das Device Pixel Ratio je Hardwareklasse deckeln, den Fullscreen-Vertrag, die Disziplin, beim Unmount jede Schleife abzureißen. Ein Browserspiel und eine Live-Satellitenkarte haben, wie sich herausstellt, fast identische Fehlerbilder.