Este é o primeiro de dois posts sobre como fazer um planeta pixel art para Godot 4. O planeta sai de um quadrado vazio, sem textura nenhuma: cada pixel descobre a própria cor por conta. Tudo começou com o Pixel Planet Generator, da Deep-Fold: é bonito, grátis, tem licença MIT e foi o que me deu vontade de tentar a ideia como shader. Se você gosta de planetas em pixel art, vale baixar o deles também.
Aqui sai o planeta completo, com terra e mar: esfera, ruído 3D, cor e luz, depois as nuvens com sombra, a atmosfera e como fazer o GIF recomeçar sem tranco. No segundo post entram seis tipos de planeta, polos, inclinação e anéis.
O resultado final, com visualizador no navegador e download grátis (CC0), está no itch.io.

O planeta base
Primeiro o chão, sem nuvens: esfera, relevo, cor por altura e luz em degraus.

O shader roda uma vez para cada pixel do quadrado e só precisa responder uma coisa: de que cor é este pixel? Todo o planeta sai dessa resposta.
1. Pixelizar a coordenada
O Godot entrega a coordenada UV de 0 a 1, contínua. Para parecer pixel art, a gente arredonda UV para uma grade de pixels por pixels e usa o centro de cada quadradinho:
vec2 uv = (floor(UV * float(pixels)) + 0.5) / float(pixels);
Todo pixel “grande” recebe o mesmo uv em toda a sua área, então o resto do cálculo dá uma cor só por quadradinho. O planeta tem a resolução que a gente escolher (64 por padrão), independente do tamanho do nó na tela.
Abaixo, a coordenada vira cor (vermelho é u, verde é v). Com pixels baixo dá para ver a grade; com pixels alto, o degradê parece contínuo.
2. Do disco à esfera
Com q = uv * 2.0 - 1.0, as coordenadas vão de -1 a 1 e o centro vira (0, 0). Um pixel está dentro do disco se a distância dele ao centro é no máximo 1, ou seja, se x² + y² <= 1. É o dot(q, q):
vec2 q = uv * 2.0 - 1.0;
float d = dot(q, q);
if (d > 1.0) {
COLOR = vec4(0.0); // fora do disco: transparente
}
Para fingir que o disco é uma bola, falta a profundidade. Na superfície da esfera de raio 1, x² + y² + z² = 1, então o z do ponto que fica de frente para a câmera é:
vec3 p = vec3(q.x, -q.y, sqrt(1.0 - d));
Pronto: cada pixel do disco virou um ponto 3D (x, y, z) na superfície de uma esfera. O -q.y é só porque o y da tela cresce para baixo.
À esquerda, o que o if recorta (o disco). À direita, o z de cada pixel como cinza: claro no centro, onde a superfície está de frente para a câmera, e escuro na borda, onde ela vira de lado.
3. Girar o ponto, não a imagem
Para o planeta girar, a gente gira esse ponto 3D em volta do eixo vertical, com seno e cosseno de TIME * spin:
float a = TIME * spin * 6.2831853;
p = vec3(p.x * cos(a) + p.z * sin(a), p.y, -p.x * sin(a) + p.z * cos(a));
Isso é diferente de deslizar uma textura pelo disco. Como o ponto gira em 3D antes de procurar a cor, o continente “enrola” nas bordas como numa esfera de verdade: fica comprimido perto do limbo e some atrás.
Comparação com um xadrez: à esquerda, a textura desliza pelo disco (parece um adesivo passando). À direita, o ponto 3D gira e o xadrez acompanha a curvatura.
4. Continentes: ruído em 3D
O terreno é uma altura h para cada ponto da superfície. A altura vem de ruído de valor (value noise): sorteia-se um número em cada canto de uma grade e mistura-se de forma suave entre eles. Somando quatro camadas, cada uma com o dobro da frequência e metade da força (o fbm), sai um relevo com continentes grandes e detalhes pequenos.
float fbm(vec3 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * noise(p);
p = p * 2.03 + 11.7;
a *= 0.5;
}
return v;
}
float h = fbm(p * scale + seed * 7.31);
A escolha importante é o ruído ser 3D, amostrado pelo ponto p da superfície. Um ruído 2D em latitude e longitude teria costura onde o mapa se fecha e esticaria tudo nos polos. Em 3D, o ponto da esfera só tem vizinhos em volta, não existe emenda nem polo especial. E o seed só desloca o ponto de amostragem: outro número, outro mundo.
O hash que sorteia os cantos não usa sin, só fract e multiplicações, porque sin com número grande dá resultado diferente de placa para placa.
Para ver a diferença, a esfera abaixo está virada com um polo para a câmera. À esquerda, ruído 2D em longitude e latitude: o desenho se repuxa em torno do polo e aparece uma linha de emenda onde o mapa fecha. À direita, ruído 3D no ponto da superfície: sem emenda e sem polo especial.
A altura sozinha, em cinza, e a mesma altura cortada em duas cores pelo sea_level (esse foi o primeiro resultado do shader, ainda sem cor por altura). Mexa em mar e escala:
5. Cor por altura, com poucas cores
A altura vira cor por uma rampa de seis faixas, comparando t = h - sea_level:
if (t < -0.12) col = vec3(0.08, 0.16, 0.42); // água funda
else if (t < 0.0) col = vec3(0.12, 0.32, 0.62); // água rasa
else if (t < 0.03) col = vec3(0.82, 0.74, 0.46); // areia
else if (t < 0.14) col = vec3(0.36, 0.62, 0.26); // grama
else if (t < 0.24) col = vec3(0.20, 0.42, 0.22); // floresta
else col = vec3(0.52, 0.50, 0.46); // rocha
Seis cores escolhidas à mão é o que faz parecer pixel art. Um gradiente contínuo entre azul e verde estragaria o efeito, porque pixel art é feita de áreas chapadas com borda dura. O sea_level é a fração do planeta que fica debaixo d’água: subir o número alaga continentes.
6. Luz em degraus
A luz usa a normal da esfera, que é o próprio ponto da superfície sem a rotação. Assim a luz fica parada na tela e o planeta gira por baixo dela, como o Sol de verdade. O dot(normal, luz) dá um número de 0 (sombra) a 1 (pleno sol), e em vez de usar esse número direto a gente corta em poucos degraus:
vec3 n = vec3(q.x, -q.y, sqrt(1.0 - d));
float lit = clamp(dot(n, normalize(light_dir)), 0.0, 1.0);
float step_f = float(shade_steps);
float band = floor(lit * step_f + 0.35) / step_f;
col *= mix(0.48, 1.08, band);
col = mix(col, col * vec3(0.7, 0.8, 1.2), 1.0 - band);
Com shade_steps = 3 o planeta ganha três faixas de luz com bordas duras, que é o sombreado típico de pixel art. Só a luz, sem cor, e com o número de degraus no slider:
A última linha deixa a sombra fria (puxa para o azul) e a luz quente. Escurecer só multiplicando por um número cinza deixa a imagem suja; esfriar a sombra mantém a cor viva.
Um erro desta etapa: a primeira versão da sombra era mix(0.30, 1.1, band). O lado escuro ficou lamacento e a água funda virou quase preto, sem dar para ver o relevo. Subir o piso para 0.48 resolveu. O lado da sombra precisa continuar legível.
Experimente o planeta base
Tudo junto: o mesmo cálculo dos exemplos acima, em JavaScript, rodando na CPU (o código está no arquivo playground.js deste post). Mexa em mar para alagar os continentes, em escala para mudar o tamanho deles e em degraus para ver a luz passar de bandas duras a quase contínua.
Nuvens, sombra e atmosfera
Até aqui o planeta é só chão e luz: parece uma bola de bilhar com continentes. Falta uma camada por cima.
1. Uma camada de nuvens
Nuvem é só outra camada de ruído. A gente roda um segundo fbm, com semente e escala diferentes da do chão, e onde o valor passa de um limiar pinta de branco. Para dar sensação de camada, a nuvem gira numa velocidade diferente da do chão.
float c = fbm(pc * scale * 1.6 + seed * 3.7 + 40.0);
if (c > 0.78 - cloud_cover * 0.4) {
vec3 cloud = vec3(0.92, 0.95, 1.0) * mix(0.55, 1.0, band);
col = mix(col, cloud, 0.92);
}
O branco também passa pelas bandas de luz (band), então a nuvem do lado escuro fica acinzentada, e não branca-neon. Já funciona, mas as nuvens saem em blocos grandes e duros, que parecem manchas:

2. Escolher o limiar: por que nenhuma nuvem aparecia
O limiar inicial era 1.0 - cobertura * 0.6, e nenhuma nuvem aparecia. O motivo: somar quatro camadas de ruído não dá um valor uniforme entre 0 e 1. As camadas se compensam, e o resultado fica concentrado perto de 0.5, quase nunca passando de 0.73. O histograma é um sino.
O gráfico abaixo conta o valor do fbm em 20 mil pontos (cinza) e pinta de vermelho o que passa do limiar, ou seja, o que viraria nuvem. Arraste o limiar: acima de uns 0.75, quase não sobra barra vermelha.
A conta final ficou 0.78 - cobertura * 0.4, e cobertura em 0.5 dá um limiar perto de 0.58. A lição: limiar de ruído somado se escolhe olhando a distribuição, não no chute. Com cobertura = 0.6 o planeta ficou quase todo branco, por isso o padrão é 0.5.
3. Faixas em vez de manchas
Para as nuvens parecerem clima, e não mancha, a gente estica o ruído na horizontal. Basta multiplicar o eixo vertical do ponto antes de amostrar o ruído:
float clouds(vec3 n, float angle) {
vec3 pc = vec3(n.x * cos(angle) + n.z * sin(angle), n.y, -n.x * sin(angle) + n.z * cos(angle));
return fbm(pc * vec3(1.0, 1.7, 1.0) * scale * 1.5 + seed * 3.7 + 40.0);
}
Com y * 1.7, o ruído varia mais rápido na vertical, então as manchas se alongam ao longo das linhas de latitude, e a gente lê isso como faixas de nuvem. A primeira tentativa foi 2.4 e deu linhas finas demais. Arraste faixas e veja:
A borda das nuvens ganhou uma segunda cor: nos 0.07 logo acima do limiar, em vez de branco, um tom azulado frio. Com duas cores a nuvem ganha volume sem precisar de uma paleta maior.
4. Sombra das nuvens no chão
A sombra da nuvem no chão não precisa de nada novo. Ela é a mesma nuvem amostrada um pouco em direção à luz: se naquele ponto deslocado tem nuvem e no pixel em si não tem, o chão está embaixo de uma sombra, e escurece.
float c = clouds(nv, a * 2.0);
float sh = clouds(normalize(nv - normalize(light_dir) * 0.07), a * 2.0);
if (c > thr) {
// nuvem
} else if (sh > thr) {
col *= 0.72; // chão debaixo de uma nuvem
}
Uma função, duas chamadas. A sombra é discreta (0.72 de brilho), então o terceiro quadro mostra só a conta: nuvem em branco, chão sob sombra em preto e o resto em cinza.
5. Atmosfera
A atmosfera são duas coisas pequenas, as duas só no lado iluminado:
- Uma faixa azul clara dentro da borda do disco. A profundidade
n.zperto da borda vale quase 0, então1.0 - n.zperto de 1 marca o limbo. - Um halo de 1 pixel fora do disco.
float edge = 1.0 - n.z;
if (edge > 0.82 && band > 0.0) col = mix(col, atmosphere.rgb, 0.55);
// fora do disco, no lado da luz
float halo = 1.0 + 2.0 / float(pixels);
float side = clamp(dot(normalize(vec3(q.x, -q.y, 0.0)), normalize(light_dir)) + 0.3, 0.0, 1.0);
COLOR = d < halo * halo && side > 0.4 ? vec4(atmosphere.rgb, 0.55) : vec4(0.0);
6. Por que o GIF dava um tranco quando recomeçava
Os GIFs deste post são loops de 120 quadros a 15 quadros por segundo. A cena de exemplo gira o planeta a spin = 0.125, uma volta a cada 8 segundos, então o último quadro é o primeiro de novo.
Com as nuvens isso quebrou. Elas giravam a 1.4x a velocidade do chão. Depois de 8 segundos o chão deu uma volta e as nuvens deram 1.4, então o último quadro tinha nuvem num lugar diferente do primeiro: um pulo no fim do loop.
A regra é simples: a razão entre as velocidades tem que ser um número inteiro. Com nuvem a 2x, quando o chão dá uma volta as nuvens dão duas, e tudo volta para o mesmo lugar. É por isso que no shader a chamada é clouds(nv, a * 2.0).
Arraste razão abaixo. À esquerda está o primeiro quadro do GIF e, no meio, o último. À direita, a diferença entre os dois (preto = igual, claro = diferente). Só fica totalmente preta em 1, 2 ou 3.
No próximo post
O planeta já tem chão, luz, nuvens, sombra e atmosfera, mas é um mundo só: terra e mar. No próximo post o mesmo shader passa a desenhar seis planetas diferentes, e entram as calotas, a inclinação e os anéis.