Skip to content

Vaynerov Technologies

Não nos limitamos a desenvolver — conjuramos cada linha de código e cada píxel.

Todos os artigosMergulhos técnicos

Mecânica orbital no navegador: o Project Orrery

O Vaynerov Project Orrery desenha todos os satélites ativos em tempo real, num separador do navegador, sem plug-in e sem servidor nenhum a fazer as contas. Eis como o pipeline funciona de facto — e que partes dele acabaram por não ter nada que ver com mecânica orbital.

Vaynerov TechnologiesO estúdio
Publicado Atualizado 13 min de leitura
Nesta página
  1. Um ficheiro de dez megabytes e uma cache de dois
  2. Kepler, mais a única perturbação que não se pode ignorar
  3. Propagação de grau visual, e dizê-lo
  4. Dez instantâneos por segundo, zero alocações
  5. Uma malha, seis números por satélite
  6. Selecionar sem raycaster
  7. Um litoral em 66 717 bytes

O ficheiro de origem tem cerca de dez megabytes. Contém aproximadamente dezasseis mil registos de elementos orbitais — um por cada objeto ativo do catálogo público, publicado pela CelesTrak e atualizado de duas em duas horas. O Vaynerov Project Orrery pega nesse ficheiro, emagrece-o, envia-o para o seu navegador e propaga-o todo num Web Worker a dez instantâneos por segundo enquanto roda o planeta com o polegar.

Há uma versão mais fácil desta página. Incorporar num iframe o rastreador de satélites de outra pessoa, pôr-lhe uma moldura azul-petróleo à volta e chamar-lhe carro-chefe. Nós escrevemos o propagador — a equação de Kepler, as taxas seculares de J2, a rotação sideral da Terra, tudo em TypeScript. Essa decisão é a razão pela qual a página existe e é também, em retrospetiva, a parte menos difícil do trabalho. As partes difíceis foram uma cache e um orçamento de memória.

~16k
registos de elementos na origem (~10 MB)
~540 KB
comprimidos depois de emagrecidos
5
iterações de Newton por resolução
0.010°
deriva GEO em 6 horas simuladas

O que chega, o que segue para o cliente, o que custa o solucionador e o que a autoverificação lhe exige.

Quatro etapas, um orçamento de alocação. Tudo o que está à esquerda do worker acontece de seis em seis horas; tudo o que está à direita acontece dez vezes por segundo.

Um ficheiro de dez megabytes e uma cache de dois

O nosso proxy pede à CelesTrak o grupo active em JSON, com um tempo limite de sessenta segundos e um user agent que diz quem somos — VaynerovOrrery/1.0 (+https://vaynerov.com/orrery) — porque a CelesTrak pede a quem a consulta que se identifique e não custa nada ser bom hóspede.

O que volta é JSON verboso, com chaves longas e legíveis repetidas dezasseis mil vezes. Recodificamo-lo como um vetor de vetores, o que apaga as chaves por completo, e arredondamos os campos à precisão que o olho consegue efetivamente usar: movimento médio a oito casas decimais, excentricidade a sete, ângulos a quatro. Dez megabytes passam a cerca de um megabyte e meio em bruto, e a cerca de 540 KB na rede.

// One satellite, ten slots. The name survives; everything else is a number.
// [name, norad, epochMs, meanMotion, ecc, inc, raan, argp, ma, groupIdx]

O registo emagrecido. Deitar fora as chaves do JSON é a maior poupança isolada do pipeline — maior do que qualquer arredondamento.

Uma pequena parte dos registos é ativamente hostil ao renderizador, por isso o proxy descarta-os: tudo o que tenha um elemento não finito, tudo com movimento médio abaixo de 0,9 revoluções por dia e tudo com excentricidade igual ou superior a 0,9. O piso do movimento médio remove os objetos à distância da Lua — os observatórios do espaço profundo, cujas órbitas os colocariam vários ecrãs para lá de um planeta desenhado com 6,4 unidades de altura. O teto da excentricidade remove os registos quase parabólicos, de que o solucionador de Kepler não gosta por razões a que voltaremos. Cerca de onze mil registos sobrevivem até chegar a um navegador.

Depois há um pormenor fácil de errar e silencioso quando se erra: as épocas das perturbações gerais são publicadas sem fuso horário. O Date.parse lê um carimbo temporal sem fuso como hora local, por isso o mesmo catálogo propagado em duas máquinas em dois fusos coloca cada satélite num sítio diferente — a milhares de quilómetros, sem nada a lançar erro. Nós truncamos as frações de segundo a milissegundos e acrescentamos um Z literal.

Porque é que a cache é feita à mão

O Next.js tem uma cache de dados perfeitamente boa e aqui não a usamos, por três razões que se foram acumulando por esta ordem. A primeira é o tamanho: uma resposta de origem de dez megabytes excede o limite de 2 MB por entrada, por isso o next: { revalidate } nunca chegou a guardar silenciosamente uma última cópia boa. Silenciosamente é a palavra-chave — nada falhava, a leitura ia simplesmente à origem de todas as vezes.

A segunda razão é a que doeu a sério. A ISR ao nível da rota guardou uma vez em cache um 503 da origem e, como a resposta foi guardada com sucesso, o nosso estado de «constelação offline» ficou congelado na página durante seis horas enquanto a CelesTrak estava perfeitamente saudável. Guardar em cache uma falha é pior do que não guardar nada.

A terceira é uma subtileza do protocolo: a CelesTrak devolve 403 se lhe voltar a pedir dentro da sua janela de atualização de duas horas. Isso não é um erro, é um sinal de não modificado, e qualquer cache que o trate como falha vai despejar dados bons em troca de nada.

Elementos desatualizados são melhores do que um céu vazio. Um satélite desenhado a partir de elementos com seis horas está, para o olho, exatamente onde o satélite está.

Portanto: dois níveis, memória do processo à frente de um ficheiro no diretório temporário do sistema, um TTL de seis horas contra a cadência de cerca de duas horas da CelesTrak, um intervalo mínimo de dez minutos entre tentativas falhadas, e desduplicação de pedidos em curso, para que um arranque a frio sob carga vá à origem uma vez e não uma vez por pedido. Quando servimos de um nível desatualizado dizemo-lo num cabeçalho de resposta, x-orrery-elements: stale, e a cache do navegador recebe max-age=3600, stale-while-revalidate=21600. O caso genuinamente offline — sem cópia em memória, sem cópia em disco, origem em baixo — devolve um 503 marcado no-store, para que nada em lado nenhum possa fixar a falha como a ISR fez uma vez.

Kepler, mais a única perturbação que não se pode ignorar

Cada registo dá-lhe um movimento médio — revoluções por dia — e cinco ângulos. A mecânica de dois corpos transforma o primeiro num semieixo maior numa linha, porque movimento médio e tamanho da órbita são o mesmo facto escrito de duas maneiras.

A Terra, pouco solidária, não é um ponto material nem uma esfera. Tem uma saliência no equador de cerca de 21 km, e essa saliência — codificada no coeficiente adimensional J2, 1,08262668 × 10⁻³ — exerce torque sobre todas as órbitas que não sejam exatamente equatoriais. Numa única passagem o efeito é invisível. Ao fim de uma semana é a diferença entre um satélite estar onde o desenhou e estar do outro lado do planeta. O J2 é a razão pela qual as órbitas heliossíncronas existem: quem projeta missões escolhe uma inclinação que faz a deriva nodal coincidir exatamente com o movimento da Terra à volta do 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);

As três taxas seculares. A ascensão reta do nodo ascendente regride, o argumento do perigeu roda e a anomalia média ganha uma pequena correção — tudo linear no tempo desde a época do registo.

Avance os três ângulos até ao instante pedido e fica com uma anomalia média que é preciso converter numa anomalia excêntrica. Isso é a equação de Kepler, M = E − e sin E, que não tem solução em forma fechada e se resolve por iteração de Newton. Começamos em E = M e corremos exatamente cinco iterações — sem teste de convergência, sem saída antecipada, sem limite de ciclo para discutir. Essa constante não é preguiça; é o retorno do filtro de excentricidade lá atrás. Com e < 0,9 garantido, cinco iterações a partir de E = M aterram dentro da precisão do float para todos os registos do catálogo, e um número fixo de iterações significa que todos os satélites custam o mesmo, que é o que um ciclo interno apertado sobre dezasseis mil objetos quer.

O resto são rotações: das coordenadas perifocais, por Rz(−Ω) Rx(−i) Rz(−ω), para o referencial inercial geocêntrico, expandidas à mão porque só duas colunas desse produto sobrevivem ao contacto com um plano orbital bidimensional. Depois uma troca de eixos, porque a convenção aeroespacial é Z para cima e a do three.js é Y para cima.

Uma decisão decorre da escolha de referencial e vale a pena dizê-la com todas as letras: rodamos o planeta, não a constelação. Os satélites ficam em coordenadas inerciais, onde as suas órbitas são simples, e o grupo da Terra é rodado pelo Tempo Sideral Médio de Greenwich — θ = 280,46061837 + 360,98564736629 × (JD − 2451545,0). A expressão dos manuais leva a seguir um termo em T²; deitámo-lo fora, depois de medirmos o seu contributo bastante abaixo de um décimo de grau ao longo de décadas de tempo simulado. À escala de um planeta desenhado com 6,4 unidades de altura, um décimo de grau não é um píxel.

Propagação de grau visual, e dizê-lo

O que construímos é movimento de dois corpos mais as taxas seculares de J2. Não tem arrasto atmosférico, não tem perturbações lunissolares, não tem pressão de radiação solar, não tem termos de ressonância, não tem termos de curto período. Para um satélite numa órbita de 400 km, só o arrasto ultrapassa o nosso modelo em poucos dias.

O comentário no propagador di-lo numa frase — fiel ao olho à escala de um planeta desenhado com 6,4 unidades de altura, não ao instrumento — e termina com a linha de que gostámos o suficiente para pôr na interface: nunca navegue com isto. O HUD traz um selo a dizer «propagação de grau visual», e o cartão de informação de um satélite selecionado indica a idade dos seus elementos, medida contra o tempo de simulação e não contra o relógio de parede, para que avançar no tempo faça a extrapolação parecer honestamente pior.

Um modelo que sabe o que não é vale mais do que um modelo que finge. O selo não é um aviso legal que fomos obrigados a escrever; é o rótulo mais exato da página.

A mesma honestidade aparece num sítio mais pequeno. Quando seleciona um satélite, desenhamos a sua órbita com 128 amostras ao longo de um período completo, propagadas exatamente na thread principal. Essa curva só está correta para o instante em que foi calculada, porque o J2 está discretamente a rodar a órbita por baixo dela — por isso é recalculada ao fim de 45 minutos de deriva simulada, em vez de ficar ali a mentir com beleza.

Nada disto seria verificável se os números só aparecessem como píxeis. Por isso uma autoverificação só de desenvolvimento corre no arranque e afirma contra física conhecida: um movimento médio de classe ISS de 15,495 revoluções por dia tem de produzir um semieixo maior de 6796,32 km, uma altitude de 416,83 km, um período de 92,933 minutos e uma velocidade de 7,660 km/s; um registo geostacionário a 1,0027 tem de produzir 42 165,23 km e não derivar mais de 0,010° em seis horas. Se a aritmética alguma vez se desviar, sabemos pela consola antes de alguém saber pelo céu.

Dez instantâneos por segundo, zero alocações

Onze mil resoluções de Kepler por fotograma a 60 fps não é coisa que uma thread principal deva tentar, e também não é coisa que precise de tentar. O propagador vive num Web Worker que é, por contrato, livre de DOM e de React, e produz instantâneos a um máximo de dez hertz. É o renderizador que interpola.

Um instantâneo é um único Float32Array empacotado, três floats por satélite, e é transferido em vez de copiado — a posse passa para a thread principal e a referência do worker fica a nulo. O motor guarda os dois últimos instantâneos e interpola linearmente entre eles pelo tempo de simulação, que é a razão pela qual um orçamento de física de 10 Hz se vê como movimento contínuo. Quando um buffer é reformado, a thread principal transfere-o de volta para o worker numa mensagem de reciclagem, e o worker volta a enchê-lo. Em regime estacionário, o sistema inteiro não aloca absolutamente nada; não há lixo para recolher, por isso não há pausa de recolha para sentir.

O tempo aqui é uma função pura: o tempo de simulação é uma âncora mais o tempo real decorrido multiplicado pelo fator de aceleração, com definições de 1×, 60×, 600× e 3600× e um regresso instantâneo ao presente. Com aceleração alta pede-se ao worker que olhe mais para a frente — pelo menos 1500 milissegundos de avanço, e mais quando a aceleração é grande — porque a 3600× preferimos fluidez a uma interpolação exata entre dois instantes que o olho não separa de qualquer forma. Três guardas impedem que a ilusão se parta: um contador de geração descarta instantâneos produzidos antes de uma troca de catálogo, um salto para trás no tempo maior do que um limiar escalado pela aceleração invalida o intervalo de interpolação em vez de o reproduzir ao contrário, e o ciclo inteiro para quando o documento está oculto ou a tela tem menos de 2% visível.

Uma malha, seis números por satélite

Cada satélite no céu é uma instância de uma única InstancedMesh construída a partir de um tetraedro — quatro triângulos, o sólido fechado mais barato que existe — com um material sem iluminação e cor por instância. Não há pós-processamento nenhum no Orrery: uma passagem de bloom teria deixado o céu mais bonito nas capturas de ecrã e teria comido o orçamento de fotograma de todos os dispositivos de gama baixa que o têm de desenhar.

A atualização por fotograma escreve diretamente no vetor de matrizes das instâncias, e escreve seis números por satélite em dezasseis.

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.

Sem objetos Matrix4, sem chamada a compose(), sem temporários por instância. Onze mil destes por fotograma são uma cópia de memória, não um cálculo.

O tamanho aparente é compensado pela distância, para que um satélite no limbo distante da Terra continue clicável: s = 0.016 + dist × 0.0021, limitado a 0,105. As estações tripuladas são desenhadas 1,7× maiores e respiram com uma pulsação sinusoidal lenta, que é totalmente saltada sob prefers-reduced-motion. Filtrar por grupo — os oito baldes de primeira correspondência que o proxy atribui, com o geostacionário reclamado por movimento médio entre 0,99 e 1,02, para que um satélite meteorológico parado sobre o equador seja etiquetado pelo que é — nunca reconstrói a malha. As instâncias escondidas ficam simplesmente com escala zero, que a GPU descarta de graça.

Selecionar sem raycaster

Lançar raios contra uma InstancedMesh de onze mil tetraedros para descobrir qual está debaixo do seu dedo são muitos testes de interseção para responder a uma pergunta a que a matriz de projeção já respondeu. Por isso a seleção corre ao contrário: um único ciclo projeta todos os satélites para o espaço do ecrã, elimina o que está atrás da câmara pelo sinal de w, descarta o que fica fora das coordenadas normalizadas de dispositivo ±1,1 e guarda o acerto mais próximo dentro de 14 píxeis do rato ou 20 da ponta do dedo. É uma passagem de aritmética que já íamos fazer de qualquer maneira.

O toque leva depois o costumeiro disparate cuidadoso: um ponteiro que percorre mais de 8 píxeis é um arrasto, não um toque; dois toques dentro de 350 milissegundos e 24 píxeis significam acompanhar, o que translada a câmara rigidamente com o próprio delta do referencial do satélite e suaviza o resíduo, enquanto o limite de aproximação anima de 8 unidades até 0,9, para que se possa mesmo chegar perto da coisa que escolheu.

Um litoral em 66 717 bytes

Um globo precisa de continentes, e a resposta habitual é uma textura — algumas centenas de kilobytes de JPEG que fica errada em todos os níveis de aproximação e se desfaz em lama num telemóvel com DPR baixo. Nós desenhámos linhas. As linhas de costa 1:110m da Natural Earth, quantizadas ao centésimo de grau e empacotadas por comprimento de sequência, tornam-se um módulo de 134 polilinhas e 5127 pontos a pesar 66 717 bytes — menos do que uma fotografia pequena, e nítido a qualquer aproximação.

Há uma subtileza que justifica o seu código. Um segmento longo de litoral desenhado como corda reta entre dois pontos de uma esfera passa dentro da esfera, e por isso a Gronelândia submerge por instantes. Cada segmento é então subdividido ao longo do círculo máximo por interpolação esférica até nenhum passo exceder cerca de dois graus, o que coloca todos os vértices exatamente à superfície. O mesmo truque para a quadrícula, para o equador mais luminoso e para o cinturão geostacionário tracejado a 42,164 unidades de cena — o único anel da página cujo raio se pode ir confirmar.

Uma unidade de cena são 1000 km. A camada baixa abraça o planeta; o cinturão geostacionário fica a 42,164 unidades — a razão pela qual a câmara por omissão começa suficientemente longe para conter os dois.

O Orrery saiu na mesma versão que o Blueprint, no dia em que o Lab passou os doze jogos, e herda mais desses jogos do que parece: os níveis de qualidade que limitam a densidade de píxeis por classe de hardware, o contrato de ecrã inteiro, a disciplina de desmontar todos os ciclos ao sair. Um jogo de navegador e um mapa de satélites em direto acabam por ter modos de falha quase idênticos.