Segundo e último post da série do planeta pixel art para Godot 4. No primeiro post saiu o planeta base e as nuvens, e até ali só existia um mundo. Agora o mesmo código passa a desenhar seis, e ganha calotas polares, inclinação do eixo e anéis.
Os quatro tipos rochosos compartilham quase tudo: o relevo (a altura h do ruído) continua igual, e o que muda é a tradução de altura em cor, mais uma ou outra regra por tipo. Gasoso e lua fogem disso e ganham o próprio cálculo.

Seis tipos de planeta
Primeiro os tipos: quatro planetas rochosos que só trocam a tabela de cores, e depois os dois que fogem da regra, o gigante gasoso e a lua.
1. Quatro planetas rochosos, só trocando as cores
Os quatro tipos rochosos (terrestre, deserto, gelo e lava) usam exatamente o mesmo ruído de altura. Só muda a função ground, que recebe a altura (já menos o sea_level) e devolve uma cor:
vec3 ground(int type, float t, out float glow) {
glow = 0.0;
if (type == 1) { // deserto: sem água, de cânions ferrugem a dunas claras
if (t < -0.12) return vec3(0.45, 0.20, 0.14);
if (t < 0.0) return vec3(0.65, 0.34, 0.18);
if (t < 0.10) return vec3(0.82, 0.55, 0.28);
if (t < 0.22) return vec3(0.90, 0.72, 0.42);
return vec3(0.95, 0.85, 0.62);
}
// ... gelo, lava e terrestre seguem o mesmo molde
}
No deserto, onde o terrestre tem água o planeta ganha tons de ferrugem. No gelo, azul de gelo e branco. Cada tipo tem cinco ou seis degraus de cor, e só isso já muda o mundo inteiro.
Abaixo estão a altura em cinza e os quatro tipos com a mesma semente, então os continentes caem nos mesmos lugares. Mexa em mar e repare que o slider não se comporta igual nos quatro:
Dois problemas apareceram aqui, e os dois têm a mesma causa:
- Com
sea_level = 0.5, a lava cobria metade do planeta e parecia tinta. Baixei os limiares da lava para-0.18e-0.08, e sobrou rocha escura entre os rios. - O gelo saía todo azul chapado. Somei
0.10à altura só desse tipo (ot += 0.10no código) para aparecer mais geada.
A lição: cada tipo tem os próprios limiares. O sea_level sozinho não serve de botão universal, porque “metade do planeta” é água num mundo e lava no outro.
2. Lava que não escurece na sombra
A lava não pode escurecer com a sombra: rio de lava é fonte de luz. A função ground devolve também um glow (0 ou 1), e o sombreado em degraus só vale onde glow = 0:
vec3 shaded = col * mix(0.48, 1.08, band);
shaded = mix(shaded, shaded * vec3(0.7, 0.8, 1.2), 1.0 - band);
col = mix(shaded, col, glow * (1.0 - band * 0.5));
Na lava, a cor original se mantém, só um pouco mais fraca no escuro (1.0 - band * 0.5). À esquerda o brilho está desligado (a lava escurece com a sombra como qualquer rocha) e, à direita, ligado:
3. Clima e atmosfera por tipo
Dois valores mudam por tipo antes de qualquer desenho: a cor da atmosfera e quanta nuvem o planeta tem.
vec3 atm = vec3(0.45, 0.70, 1.0);
float cover = cloud_cover;
if (planet_type == 1) { atm = vec3(0.95, 0.65, 0.40); cover *= 0.35; } // deserto: seco
else if (planet_type == 2) { atm = vec3(0.70, 0.90, 1.00); cover *= 0.5; } // gelo
else if (planet_type == 3) { atm = vec3(1.00, 0.40, 0.20); cover = 0.0; } // lava: sem nuvem
else if (planet_type == 4) { atm = vec3(0.90, 0.75, 0.55); cover = 0.0; } // gasoso
else if (planet_type == 5) { cover = 0.0; } // lua: sem ar
O deserto tem 35% da cobertura do terrestre, o gelo 50%, e lava e gasoso não têm nuvem nenhuma. Cada atmosfera tem a própria cor (azul, laranja, azul gelo, vermelho, creme). A lua nem desenha atmosfera: atm_k = 0, então o limbo fica seco e cortado.
4. Gigante gasoso: faixas por latitude
O gasoso não tem altura nem continentes, só faixas. A coordenada vertical do ponto, p.y, vezes 7, dá as faixas. Para as bordas não ficarem retas, soma-se um ruído esticado na vertical, que entorta cada faixa e parece turbulência. A faixa de número k = floor(v) escolhe uma entre quatro cores, por um hash:
vec3 gas(vec3 p) {
float v = p.y * 7.0 + (fbm(p * vec3(1.2, 3.0, 1.2) * scale + seed) - 0.5) * 2.2;
float k = floor(v);
float r = hash(vec3(k, seed, 1.7));
if (r < 0.25) return vec3(0.58, 0.40, 0.30);
if (r < 0.55) return vec3(0.72, 0.52, 0.36);
if (r < 0.80) return vec3(0.85, 0.70, 0.50);
return vec3(0.93, 0.84, 0.66);
}
Como p já é o ponto rodado, o desenho gira junto com o planeta. Com torcer em 0 as faixas são retas; aumente para ver a turbulência:
5. Lua: crateras com ruído celular
A lua é diferente: a cratera não é desenhada, ela sai de uma distância. A técnica é o ruído celular (Worley). O espaço é dividido numa grade, e cada célula tem um ponto aleatório (o centro de uma cratera) e um raio aleatório. Para cada pixel, o shader olha as 27 células vizinhas (3x3x3) e guarda a menor distância ao centro dividida pelo raio da cratera:
float crater(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
float best = 9.0;
for (int x = -1; x <= 1; x++)
for (int y = -1; y <= 1; y++)
for (int z = -1; z <= 1; z++) {
vec3 g = vec3(float(x), float(y), float(z));
vec3 o = vec3(hash(i + g), hash(i + g + 7.1), hash(i + g + 13.7));
float rad = 0.30 + 0.30 * hash(i + g + 3.3);
best = min(best, length(g + o - f) / rad);
}
return best;
}
Abaixo de 1, o pixel está dentro da cratera. A cor vem de dois cortes: dentro de 0.8 raios escurece (o fundo da tigela), e entre 0.8 e 1.0 clareia (a borda levantada pega luz):
float k = crater(p * scale * 1.5 + seed);
if (k < 0.8) col *= 0.60; // fundo da tigela
else if (k < 1.0) col *= 1.28; // borda levantada
À esquerda, a distância k em cinza (escuro = perto do centro de uma cratera). À direita, a lua pronta, com a base em três tons de cinza (mares escuros e planalto claro). Não existe círculo desenhado em lugar nenhum, o círculo é a própria curva de distância constante.
A primeira lua saiu com crateras do tamanho de um pixel e parecia poeira: o raio era 0.22 a 0.42 da célula e a grade, densa. Subi o raio para 0.30 a 0.60 e baixei a frequência (scale * 1.5). Em pixel art, a cratera precisa ter uns 5 pixels no mínimo para ser lida. Também clareei os cinzas, porque o tom frio da sombra deixava tudo azul-escuro.
Experimente os seis
Troque o tipo e mexa no resto. Cada planeta tem os mesmos controles, mas cada tipo responde diferente.
Polos, inclinação e anéis
Faltam três detalhes que fazem o planeta parecer um planeta de verdade: as calotas de gelo nos polos, a inclinação do eixo e os anéis.

1. Calotas polares
Os polos de um planeta são regiões em que a latitude é alta. A latitude do ponto da superfície é o valor absoluto de p.y: perto de 0 no equador, perto de 1 nos polos. A calota é onde essa latitude passa de uma linha:
float polar = (p.y > 0.0 ? p.y : -p.y * 0.85) + (fbm(p * 4.0 + seed) - 0.5) * 0.3;
if (polar > 1.0 - ice_caps * 0.5) col = vec3(0.92, 0.96, 1.0);
Uma comparação seca daria uma borda reta, como uma linha de régua. Por isso se soma um ruído em p.y antes de comparar (o fbm(...) - 0.5 vezes 0.3): dois números “mágicos” e a borda fica irregular. ice_caps controla o tamanho. Isso vale para terrestre, deserto e gelo (o deserto ganha uma calota creme em vez de branca).
2. Inclinação: sem ela a calota não aparece
O planeta gira em torno do eixo vertical, e quem olha vê o equador de frente. Nessa posição os polos ficam exatamente na borda do disco, vistos de lado, e a calota vira uma faixa fina que mal se enxerga. A solução é inclinar o eixo em direção a quem olha, como o eixo da Terra num globo de mesa.
A inclinação é uma rotação em torno do eixo X, aplicada ao ponto depois do giro (primeiro gira em torno do eixo vertical, depois inclina o resultado):
vec3 tilted(vec3 p) {
float c = cos(tilt);
float s = sin(tilt);
return vec3(p.x, p.y * c + p.z * s, -p.y * s + p.z * c);
}
vec3 p = tilted(vec3(n.x * cos(a) + n.z * sin(a), n.y, -n.x * sin(a) + n.z * cos(a)));
O mesmo tilted() entra nas nuvens, para elas acompanharem o eixo. À esquerda, tilt = 0: os polos ficam na borda e a calota praticamente não aparece. À direita, o planeta inclinado: a calota vira uma tampa branca no topo. Mexa em tilt e caps:
3. Por que o sinal do ângulo da inclinação é invertido
Na primeira tentativa a inclinação saiu para o lado errado: apareceu o polo sul em vez do norte. O motivo: o que o shader calcula é o ponto no planeta a partir do ponto na tela, ou seja, o caminho de volta. É a rotação inversa, então todo ângulo sai com o sinal trocado. Inverter o sinal do seno resolveu.
Compare dois valores opostos de tilt. Num deles vemos o polo norte, no outro o sul (que é um pouco menor, como a última seção dos polos explica):
4. Anéis: uma elipse
Anel de planeta é um círculo visto de lado, e círculo visto de lado é uma elipse. O shader não tem câmera nem 3D para os anéis, só uma coordenada de tela:
- Gira a coordenada da tela por
ring_angle(a inclinação do anel na imagem). - Divide o eixo vertical por
ring_open. Nesse espaço o anel é um círculo normal, err = length(...)é a distância ao centro. - O anel é a faixa em que
0.68 < rr < 0.98.
vec2 lq = vec2(qs.x * cos(ring_angle) + qs.y * sin(ring_angle), -qs.x * sin(ring_angle) + qs.y * cos(ring_angle));
float rr = length(vec2(lq.x, lq.y / ring_open));
if (rr > 0.68 && rr < 0.98) {
float r = hash(vec3(floor((rr - 0.68) * 30.0), seed, 5.0));
if (r > 0.22) {
ring = vec4(mix(vec3(0.55, 0.42, 0.32), vec3(0.93, 0.84, 0.66), r), 1.0);
}
}
Dividir por um número pequeno achata o círculo numa elipse, o que simula ver o anel de lado. As listras e os vãos são a mesma ideia das faixas do gigante gasoso, só que na distância do centro em vez da latitude: floor((rr - 0.68) * 30) numera as faixas, um hash de cada número dá a cor, e abaixo de um limiar a faixa vira um vão transparente.
Frente e trás
Os anéis passam na frente do planeta numa metade e atrás na outra, e isso sai de uma comparação de sinal: a metade de baixo na tela (lq.y > 0) passa na frente, e a de cima fica atrás. Atrás do disco ela some, porque o planeta é desenhado por cima. Na frente, é desenhada por cima do planeta. Não existe 3D de verdade.
No segundo quadro abaixo, a frente é azul e o fundo é vermelho. O terceiro é o resultado completo. Arraste abertura (1 é visto de cima, perto de 0 é de lado) e ângulo:
A sombra do planeta no anel
O anel também recebe a sombra do planeta, do lado oposto à luz, perto da linha que passa pelo centro na direção da luz. É uma aproximação em 2D, e só vale para a metade de trás. Na primeira tentativa a metade da frente também escurecia, e o anel ficava sujo.
Anéis pedem mais pixels
Com anéis ligados o planeta encolhe para ocupar uns 55% da tela, e o pixels passa a valer para a tela inteira. Resultado: a 64 pixels o planeta tem só uns 35 de largura e perde detalhe, e os anéis ficam finos. Os demos acima usam 96. Para valer a pena com anéis, o ideal é subir pixels para 96 ou 128.
5. Calota do polo sul virando bloco chapado
Como o eixo é inclinado para a frente, o polo sul fica logo atrás da borda de baixo, e só uma faixa da calota aparecia ali. No visualizador da página do itch, em alguns ângulos, essa faixa virava um bloco chapado azul-acinzentado (no deserto, parecia uma mancha). A correção foi fazer a calota do sul menor que a do norte: a latitude do sul vale 0.85x na comparação (o -p.y * 0.85 do código da primeira seção dos polos).
A primeira tentativa usou 1.25, e errei o sentido: multiplicar a latitude por mais de 1 aumenta a calota, porque a condição é latitude > limiar. Para encolher, é por menos de 1.
No deserto abaixo, à esquerda o polo sul vale 1.0 (igual ao norte) e à direita o fator é o do slider (0.85 por padrão). Veja o polo de baixo:
Achei a mancha girando o planeta no visualizador, não nas imagens dos GIFs. Vale testar nos dois: o GIF mostra um ângulo só, o visualizador deixa girar em todos.
Tudo junto
O planeta completo, com todos os controles. Troque o tipo, ligue os anéis, incline o eixo:
Fim
O shader completo, com os seis tipos, nuvens com sombra, calotas, inclinação e anéis, está no itch.io: Pixel Planet. É grátis e CC0 (domínio público), então dá para usar em jogo comercial e modificar à vontade. A página tem um visualizador que roda o shader de verdade no navegador, com sliders, edição do código ao vivo e download da imagem. O shader é um arquivo só (.gdshader), para pôr num ColorRect do Godot 4.