Pular pro conteúdo
Francisco Cidade
12 minutos de leitura

Planeta pixel art com shader, parte 2: seis mundos, polos e anéis

Sumário

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.

Passeio pelos seis tipos de planeta: terrestre, deserto, gelo, lava, gigante gasoso com anéis e lua

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:

altura h
terrestre
deserto
gelo
lava

Dois problemas apareceram aqui, e os dois têm a mesma causa:

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:

sem brilho
com brilho

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:

gigante gasoso

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.

distância à cratera mais próxima
lua

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.

Gigante gasoso com anéis inclinados, girando

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:

sem inclinação
com inclinação

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):

tilt = 0.6
tilt = -0.6

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:

  1. Gira a coordenada da tela por ring_angle (a inclinação do anel na imagem).
  2. Divide o eixo vertical por ring_open. Nesse espaço o anel é um círculo normal, e rr = length(...) é a distância ao centro.
  3. 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:

só os anéis
frente (azul) e trás (vermelho)
completo

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:

sul = 1.0
sul com o fator do slider

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.