Vol. 07 · Dispatch2026-06-23

Construímos um sistema solar Kepleriano em 700 linhas de WebGL

Como um solver Newton–Raphson genérico e o React Three Fiber nos deram 20+ luas, 9 planetas e um "veja o céu no dia em que você nasceu" de graça — mais o refactor que cortou nossa camada 3D de 6.470 → 1.754 linhas.

by MetaWorldOS Engineering
WebGLThree.jsReactTypeScriptAstronomia

TL;DR — Um sistema solar Kepleriano puro, com controle de tempo, roda em aproximadamente 700 linhas de TypeScript em cima do React Three Fiber. O truque é resolver a equação de Kepler do jeito certo (11 linhas de Newton–Raphson) e compartilhar um único solver entre 20+ luas, 9 planetas e 2 cometas. Vamos passar pela matemática, pelo refactor que cortou nossa camada 3D em 73% e pelas pegadinhas (a armadilha do eclipse, precisão de float na GPU, injeção de tempo).

Demo ao vivo: /universe · variante personalizada de aniversário: /universe/birthday · solver interativo: /tools/kepler


Quando começamos a construir o Universe, tínhamos duas restrições em tensão:

  1. Tinha que ser astronomicamente real. Saturno na longitude J2000 correta para qualquer data. A Lua na fase certa. O cometa Halley passando no século certo.
  2. Tinha que caber em poucas milhares de linhas. O Universe é uma feature dentro de um produto maior — não um clone autônomo do Stellarium.

O caminho barato é cos(angle * t) por planeta. Funciona por uns dez segundos — até alguém perguntar “onde Marte estava no dia em que eu nasci?” e a simulação estar errada por meses.

O caminho certo é Kepler. E acontece que Kepler é curto.

Esta é a turnê de engenharia. O que construímos, o que cortamos, e o refactor que levou nossa camada orbital + 3D de ~6.500 linhas para ~1.750.


Por que círculos não funcionam

Aqui está a armadilha, presente em todo tutorial “sistema solar 3D para iniciantes” da internet:

// Parece OK. Erra por meses dentro de um ano.
const angle = (time / period) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;

Dois motivos pelos quais está errado:

  • Órbitas são elipses. A Terra oscila entre 147,1 e 152,1 milhões de km do Sol. A excentricidade de Plutão (0,25) significa que seu periélio é mais próximo do Sol do que a órbita de Netuno.
  • A velocidade orbital não é constante. A segunda lei de Kepler — áreas iguais em tempos iguais — diz que os corpos se movem mais rápido no periélio e mais lento no afélio. Um modelo de ω constante tira a média disso e erra cada vez mais a posição instantânea à medida que a excentricidade cresce.

Para o Halley (e ≈ 0,97), o modelo circular é uma piada. Para a Lua (e ≈ 0,055), ainda assim já é suficiente para divergir visivelmente depois de algumas órbitas.

A correção é a equação de Kepler:

M = E − e·sin(E)

M é a anomalia média (linear no tempo). E é a anomalia excêntrica (o ângulo geométrico que queremos, quase). e é a excentricidade. Não há forma fechada para E dado M. Você itera.


O solver, em 11 linhas

function solveKepler(meanAnomaly: number, e: number, maxIterations = 8): number {
    let E = meanAnomaly;
    for (let i = 0; i < maxIterations; i++) {
        const f = E - e * Math.sin(E) - meanAnomaly;
        const fPrime = 1 - e * Math.cos(E);
        const delta = f / fPrime;
        E -= delta;
        if (Math.abs(delta) < 1e-10) break;
    }
    return E;
}

Newton–Raphson em f(E) = E − e·sin(E) − M. Converge quadraticamente. Iterações medidas em corpos reais:

Corpo Excentricidade Iterações até convergir
Maioria das luas < 0,05 1
Terra 0,0167 1
Mercúrio 0,206 2
Plutão 0,249 2–3
Nereida 0,749 4–5
Halley 0,967 6–7

8 é o orçamento para o pior caso, e o early-out em delta < 1e-10 significa que só pagamos o custo real por corpo por frame. Nenhum corpo do nosso sistema chegou a queimar as 8 completas.

Aviso. Livros-texto frequentemente sugerem E₀ = M + e·sin(M) como estimativa inicial para acelerar a convergência em órbitas de alta excentricidade. Testamos. Economiza uma iteração no Halley, zero em tudo mais. Não vale a linha extra.


Da anomalia excêntrica para uma posição 3D

Uma vez que você tem E, o resto é geometria de ensino médio. Anomalia verdadeira:

const trueAnomaly = 2 * Math.atan2(
    Math.sqrt(1 + e) * Math.sin(E / 2),
    Math.sqrt(1 - e) * Math.cos(E / 2)
);

Distância do foco (o Sol, para os planetas):

const r = (a * (1 - e * e)) / (1 + e * Math.cos(trueAnomaly));

Posição no plano orbital:

return new THREE.Vector3(
    r * Math.cos(trueAnomaly),
    0,                              // plano da eclíptica
    r * Math.sin(trueAnomaly)
);

Todas as órbitas vão no plano XZ (a eclíptica). Inclinações reais existem — Plutão tem 17°, Halley 162° (retrógrado!) — mas para o nosso caso de uso, a silhueta do sistema solar é dominada pelo que está na eclíptica. Pular a rotação 3×3 por corpo por frame é um ganho real de performance.

Inclinações axiais e a inclinação lunar são adicionadas em camadas no renderer (a matemática orbital em si permanece planar).


O refactor: um solver, vinte luas

Aqui está a parte que você pode copiar literalmente. Nosso layout original tinha ~20 arquivos assim:

io.ts                # 70 linhas, Kepler copy-pasted com constantes de Io
europa.ts            # 70 linhas, Kepler copy-pasted com constantes de Europa
titan.ts             # 70 linhas, Kepler copy-pasted com constantes de Titã
... mais 17 ...

~1.400 linhas de código orbital só para o conjunto de luas. Quase tudo duplicado, e tudo precisaria-ser-corrigido-21-vezes se achássemos um bug no solver.

O refactor: um módulo genérico.

// moonOrbit.ts — o solver vive aqui, exatamente uma vez.
export interface MoonOrbitParams {
    periodHours: number;
    semiMajorAxisKm: number;
    eccentricity: number;
    sceneRadius: number;     // escala visual — ver próxima seção
}

export function getMoonPosition(
    date: Date,
    params: MoonOrbitParams
): THREE.Vector3 {
    // solveKepler + geometria — 25 linhas no total
}

Cada lua colapsa para um arquivo fino de dados:

// rheaOrbit.ts — o arquivo inteiro, 18 linhas no total
import { getMoonPosition, MoonOrbitParams } from "./moonOrbit";

const RHEA_PARAMS: MoonOrbitParams = {
    periodHours: 108.4,
    semiMajorAxisKm: 527_040,
    eccentricity: 0.001,
    sceneRadius: 0.9,
};

export const getRheaPosition = (date: Date) =>
    getMoonPosition(date, RHEA_PARAMS);

Os arquivos por lua foram de ~70 linhas para ~18 — uma queda de 75% em todo o conjunto. Fizemos o mesmo exercício na camada de carregamento de mesh/textura (um Moon3D genérico + dados por lua), e na soma de toda a camada 3D + orbital:

6.470 → 1.754 linhas. Redução de 73% sem remover uma única feature.

Por que manter um arquivo por lua em vez de uma tabela grande? Porque os dados são por lua, os comentários são por lua (“a libração de Io é dirigida por Júpiter, não por sua própria órbita”) e o grafo de imports fica limpo — um componente que só renderiza Reia importa só os parâmetros de Reia.


Unidades de cena vs realidade

Usamos unidades de cena internamente (~1,0 para “tamanho interessante”) e convertemos na fronteira dos dados. O semieixo maior real de Reia é 527.040 km. Seu sceneRadius é 0.9. A conversão vive dentro do solver:

const sceneScale = sceneRadius / semiMajorAxisKm;
const rScene = r * sceneScale;

Dois motivos pelos quais você quer isso:

1. Controle editorial sobre a escala visual. Em escala 1:1, as luas viram pixels grudados em Saturno, e Saturno vira um pixel ao lado da própria órbita. Designers escolhem sceneRadius; astrônomos escolhem eccentricity e periodHours. Os dois lados ganham — a órbita tem forma correta e tempo correto, só foi redimensionada para a câmera.

2. Precisão de float na GPU. O Three.js usa floats de 32 bits na GPU. Trabalhar em unidades de cena perto de 1,0 te mantém na faixa precisa. Jogar quilômetros crus dentro do Three.js (1.5e8 para a distância Terra-Sol) e você cai no penhasco de precisão — corpos começam a tremer em certos ângulos de câmera, aparece z-fighting na geometria, o near plane da câmera deixa de estar onde você acha que está.

Você pode passar uma semana debugando isso. Ou pode converter na fronteira e seguir adiante.


Tempo como dependência injetada

Um módulo de órbita funcionando não basta. Os usuários querem arrastar o tempo, pausar no aniversário deles, dar play e ver os planetas se moverem. Temos um TimeManager global:

class TimeManager {
    private current = new Date();
    private paused = false;
    private speed = 1.0;

    setDate(d: Date) { this.current = new Date(d); }
    pause() { this.paused = true; }
    resume() { this.paused = false; }
    setSpeed(s: number) { this.speed = s; }

    tick(deltaMs: number) {
        if (this.paused) return;
        this.current = new Date(this.current.getTime() + deltaMs * this.speed);
    }

    now() { return this.current; }
}

O frame de render de cada corpo puxa timeManager.now() e passa para getMoonPosition / getPlanetPosition. O pipeline inteiro é funcionalmente puro de Date → Vector3, então congelar em uma data específica é literalmente timeManager.pause() depois de setDate(...).

É isso que move nossa feature de Céu de Aniversário: digite seu aniversário, o time manager salta para aquele instante, o renderer redesenha cada corpo a partir da equação de Kepler, e você vê o sistema solar exatamente como ele estava no dia em que você nasceu. Sem code path especial — o mesmo render loop, o mesmo now().

Teste no seu aniversário →


Direção de câmera no espaço 3D

A matemática orbital é uma coisa. Enquadrar tudo cinematograficamente é outra. O Céu de Aniversário tem dois modos de câmera:

  • Heliocêntrica — plano oblíquo alto atrás da Terra. A Terra como disco em primeiro plano. O Sol brilhando no fundo. Os planetas internos caindo naturalmente no quadro.
  • POV da Terra — logo acima da superfície noturna da Terra, olhando para fora, em direção ao espaço profundo.

Cada uma é uma punhado de operações vetoriais:

// Composição heliocêntrica.
side.copy(up).cross(sunToEarth).normalize();
outPos
    .copy(earthPos)
    .addScaledVector(sunToEarth, 5)   // 5 unidades atrás da Terra (eixo anti-Sol)
    .addScaledVector(up, 6)           // 6 unidades acima da eclíptica
    .addScaledVector(side, 2);        // pequeno lateral — adiciona profundidade
outLook.set(0, 0, 0);                 // olhar para o Sol

A armadilha do eclipse

É tentador colocar a câmera diretamente atrás da Terra, na linha anti-Sol. Não faça.

O disco aparente da Terra a 5 unidades de distância (~5° de diâmetro angular, dado o raio de render da Terra de 0,6–0,8 em unidades de cena) é maior que o disco aparente do Sol a 19 unidades (~3–4°). Câmera diretamente atrás da Terra = Terra eclipsando o Sol por completo, nenhuma fonte de luz visível, frame morto e escuro.

O offset lateral de 2 unidades e a elevação vertical de 6 unidades te dão ~25° de separação angular entre o centro da Terra e o centro do Sol vistos da câmera. Terra como disco em primeiro plano, Sol brilhando pela borda da Terra. Composição icônica, geometria intencional.

Passamos duas sessões de debug acertando isso. A matemática:

Raio angular da Terra a 8 unidades = atan(0,8 / 8) ≈ 5,7°
Raio angular do Sol a 14,5 unidades = atan(1,0 / 14,5) ≈ 4,0°
Separação angular necessária > 5,7 + 4,0 = 9,7° para evitar sobreposição.

Miramos em ~25° para deixar margem de respiro.


Coisas que deliberadamente NÃO fizemos

A parte honesta. Cortar isso aqui foi o que nos manteve em 700 linhas:

  • Perturbações. Planetas reais não seguem órbitas Keplerianas puras — Júpiter puxa Marte, o momento quadrupolar do Sol importa para Mercúrio. Ignoramos tudo. Para uma janela de 100 anos centrada em J2000, o erro visual é desprezível. Para previsão de órbita de asteroides, você precisaria de integração numérica com forças N-corpos.
  • Correção de tempo de luz. Quando você “vê” Saturno da Terra, está vendo onde ele estava ~80 minutos atrás. Não corrigimos. Não faz diferença numa visualização; faria diferença em ferramentas astrométricas.
  • Precessão dos equinócios. O eixo da Terra oscila com um período de 26.000 anos. Mantemos a inclinação J2000 fixa. Coloque a data em 12.000 d.C. e nossa Polaris já não está onde o polo deveria estar. Estamos OK com isso.
  • Inclinação orbital. Toda órbita vai na eclíptica. Plutão real tem 17°. Não renderizamos a inclinação.

A regra: a simulação tem que parecer certa nos dias em que os usuários se importam — aniversário deles, hoje, amanhã — e degradar com elegância em direção a datas absurdas.


Quanto pesa tudo isso

Soma final da camada matemática + tempo + câmera:

Módulo Linhas
moonOrbit.ts (solver genérico) 82
planetOrbit.ts (genérico, planetas + cometas) 176
earthOrbit.ts (inclinação axial, conversão ECI) 302
20 wrappers de luas × ~18 linhas ~360
Órbitas de cometas (Halley, Encke) ~120
TimeManager + controles do scrubber ~140
Total ~1.180

Cerca de 700 destas são o código orbital central. O resto é gerenciamento de tempo e os casos especiais dos cometas.

A camada de renderização 3D (meshes de esfera, texturas, shaders de atmosfera, meshes de lua, ISS via SGP4 etc.) fica em cima: outras ~1.800 linhas construídas sobre o React Three Fiber. R3F é a escolha certa aqui — permite descrever a cena de forma declarativa e reusar o diffing do React para o ciclo de vida dos objetos, e ainda assim te dá useFrame para a matemática imperativa por tick.


Lições para o seu eu do passado

  1. Resolva a equação de Kepler direito desde o dia um. 11 linhas. A diferença entre uma demo e uma ferramenta. Não embarque na versão com órbita circular “só para começar” — vai gastar o mesmo tempo e ainda vai precisar jogar fora.
  2. Escolha uma escala de unidades de cena, converta na fronteira. Não passe quilômetros reais dentro do Three.js. O penhasco de precisão de float é o pior tipo de bug — intermitente, dependente do ângulo da câmera, e você vai culpar sua matemática primeiro.
  3. Faça do tempo uma dependência globalmente injetada. Cada função de posição recebe um Date. Todo sistema lê de um único TimeManager. Pause / scrub / replay viram de graça.
  4. Compartilhe o solver, parametrize os corpos. A redução de linhas é boa; a redução de correções de bug é enorme. Uma correção de correção em solveKepler se propaga para as 20 luas automaticamente.
  5. Escala visual não é um bug. É a diferença entre “cientificamente preciso mas invisível” e “real onde importa, lindo em todo lugar”. Escolha escalas de render à mão. Mantenha períodos e excentricidades honestos.
  6. Enquadramento de câmera é geometria, não vibe. Calcule o tamanho angular dos seus sujeitos a partir da câmera, calcule a separação necessária, então posicione a câmera. “Parece certo” no olho te manda em espirais de debug.

Experimente

A coisa ao vivo vive em /universe — arraste o tempo, ligue e desligue camadas, foque em qualquer corpo. A variante personalizada (/universe/birthday) salta a simulação para um instante que importa para você e adiciona o contexto cultural (signo solar, fase da lua, os 24 termos solares, e o sistema chinês tradicional de 时辰).

Se você já construiu algo parecido e tem perguntas — sobre o solver, a injeção de tempo, o diretor de câmera, ou o refactor — adoramos cavar. Entre em contato pelo Contact.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

WebGL

Real-time meteor showers in WebGL — from pixel grids to silk streaks

Why LineSegments don't work for meteors, why naive Points look like a grid of squares, and how a 64×64 canvas-generated radial-gradient sprite + a dormant-spawn lifecycle gives you a beautiful 18-meteor shower in 175 lines and one draw call.

Read dispatch
Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch
Three.js

How Infinitown fakes an infinite city with 81 chunks and mod 9

The infinite-scrolling town in our Infinitown gamecenter port isn't procedurally generated — it's a Möbius carpet. A 9×9 pool of pre-built chunks maps onto a 9×9 grid of fixed container slots through modulo arithmetic, and camera drags rebind slots to different pool entries instead of spawning new geometry. This post walks the four moving parts (pool, containers, mapping, drag event) and explains why the whole system holds together with zero allocations at runtime.

Read dispatch

Receba um email quando publicarmos um novo artigo — análises técnicas, ~uma vez por mês.