Cel Shading na Godot 4: Visual Toon com Shader Próprio

Cel shading na Godot 4: monte um spatial shader com função light() que quantiza a luz em bandas, adiciona rim light e fecha com contorno toon.
Se você já tentou dar cara de anime ou cartoon a um jogo 3D, provavelmente esbarrou no mesmo problema: a iluminação padrão da engine deixa tudo com aquele degradê suave e realista, e o resultado não bate com a arte estilizada que você imaginou. A solução é o cel shading, e fazer cel shading na Godot 4 com um shader próprio é mais simples do que parece. Neste post você vai escrever um spatial shader que quantiza a luz em degraus, entender a função light(), controlar o número de bandas, somar um rim light e fechar o visual com um contorno. Público iniciante e intermediário, código real e comentado.
O que muda entre realista e toon
Vale entender o que está acontecendo por baixo antes de sair copiando código. No sombreamento realista, a quantidade de luz que uma superfície recebe depende do ângulo entre a normal da superfície e a direção da luz. Esse cálculo, chamado difusa Lambertiana, produz um valor contínuo: uma face virada de frente para a luz recebe quase 100 por cento, e conforme ela gira para longe, o valor cai suavemente até zero. Esse gradiente contínuo é o que dá o aspecto fotográfico.
O cel shading não muda esse cálculo. Ele muda o que a gente faz com o resultado. Em vez de usar o valor contínuo direto, a gente pega esse número entre 0 e 1 e força ele a cair em poucos degraus fixos. Um pedaço da superfície fica na banda "iluminado", outro pedaço na banda "sombra", e a transição entre elas é uma linha dura em vez de um degradê. É essa quebra em blocos chapados que o olho lê como desenho animado.
Se essa é sua primeira vez mexendo com código de shader, dá uma passada antes no guia de shaders na Godot para iniciantes, que cobre o básico de shader_type, uniforms e a estrutura das funções. Aqui eu assumo que você já sabe criar um recurso .gdshader e aplicar num material.
Cel shading na Godot 4 do zero
Na Godot 4, um material 3D usa shader_type spatial. Se você nunca escreveu um shader desse tipo, vale começar pelos primeiros passos com shader 3D spatial na Godot antes de partir pro toon. O truque para o toon é que a gente não vai deixar a engine calcular a iluminação sozinha. A Godot permite escrever uma função light(), que roda uma vez para cada luz que atinge o objeto e te dá controle total sobre como aquela luz vira cor na tela. É dentro dela que a mágica do banding acontece.
Antes de escrever a light(), definimos a cor base do objeto na função fragment(), que roda por pixel. Comece com este esqueleto.
shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_toon, specular_toon;
// Cor base e textura do material
uniform vec4 albedo_color : source_color = vec4(0.85, 0.35, 0.35, 1.0);
uniform sampler2D albedo_texture : source_color, hint_default_white;
void fragment() {
ALBEDO = albedo_color.rgb * texture(albedo_texture, UV).rgb;
}
O hint_default_white faz o shader funcionar mesmo sem uma textura atribuída: ele assume branco e a cor final vira só o albedo_color. Isso deixa o material testável na hora, sem precisar arrastar imagem nenhuma.
Quantizando a luz em bandas
Agora a parte principal. A função light() te entrega algumas variáveis prontas. As que interessam aqui são NORMAL (a normal da superfície), LIGHT (a direção até a luz), LIGHT_COLOR (a cor da luz, já com a energia embutida), ATTENUATION (quanto a luz chega naquele ponto, considerando sombras e distância) e ALBEDO (a cor que você definiu no fragment). O que a gente escreve em DIFFUSE_LIGHT é o que aparece na tela.
// Numero de degraus de luz (2 = cartoon duro, 4+ = mais suave)
uniform int bands : hint_range(1, 8) = 3;
void light() {
// 1. Difusa Lambert: o quanto a superficie encara a luz
float n_dot_l = dot(NORMAL, LIGHT);
float diffuse = clamp(n_dot_l, 0.0, 1.0) * ATTENUATION;
// 2. Quantiza em degraus (o coracao do cel shading)
float steps = float(bands);
float toon = floor(diffuse * steps) / steps;
// 3. Escreve a luz final
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * toon;
}
A linha que faz tudo acontecer é a do floor. Suponha que diffuse valha 0.62 e bands seja 3. A conta fica floor(0.62 * 3) / 3, ou seja floor(1.86) / 3, que dá 1 / 3, aproximadamente 0.33. Qualquer valor de difusa entre 0.33 e 0.66 vira o mesmo 0.33. É isso que agrupa a superfície em blocos: faixas inteiras de ângulos diferentes recebem exatamente a mesma cor, e a transição entre elas vira uma linha dura.
Troque o valor de bands no inspetor e observe. Com 2, você tem só claro e escuro, o visual mais cartoon possível. Com 4 ou 5, o degrau fica mais fino e o resultado se aproxima do sombreamento de anime, com uma ou duas sombras intermediárias.
Suavizando a borda dos degraus
Se você olhar de perto, a linha entre uma banda e outra fica serrilhada, com aquele efeito de escada nos pixels. Isso acontece porque o floor é um corte seco. Dá para pedir à GPU que suavize só aquela beirada usando fwidth, que mede o quanto o valor muda de um pixel para o vizinho. Com ele, a gente aplica um smoothstep estreito exatamente na transição.
void light() {
float n_dot_l = dot(NORMAL, LIGHT);
float diffuse = clamp(n_dot_l, 0.0, 1.0) * ATTENUATION;
float steps = float(bands);
float scaled = diffuse * steps;
// largura de um pixel na transicao, para antialiasing
float aa = fwidth(scaled) * 0.5;
float lower = floor(scaled);
float toon = (lower + smoothstep(0.5 - aa, 0.5 + aa, fract(scaled))) / steps;
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * toon;
}
Aqui a gente separa a parte inteira (floor) da parte fracionária (fract) e usa o smoothstep só nos poucos pixels bem em cima da fronteira do degrau. O resultado mantém o visual chapado de bandas, mas com a linha de sombra limpa em vez de serrilhada. Se preferir o corte cru e nítido, fique com a versão anterior do floor. As duas são cel shading válido, é decisão de estilo.
Adicionando o rim light
O rim light, ou luz de borda, é aquele brilho fino que contorna a silhueta do objeto do lado oposto à câmera. Ele ajuda a destacar o personagem do fundo e reforça a leitura de anime. A conta é baseada no ângulo entre a normal e a direção da visão (VIEW): quanto mais a superfície aponta para longe da câmera, mais forte o brilho.
uniform vec4 rim_color : source_color = vec4(1.0);
uniform float rim_amount : hint_range(0.0, 1.0) = 0.6;
uniform float rim_width : hint_range(0.0, 1.0) = 0.7;
void light() {
float n_dot_l = dot(NORMAL, LIGHT);
float diffuse = clamp(n_dot_l, 0.0, 1.0) * ATTENUATION;
float steps = float(bands);
float toon = floor(diffuse * steps) / steps;
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * toon;
// Rim: mais forte nas bordas viradas pra fora da camera
float rim = 1.0 - clamp(dot(NORMAL, VIEW), 0.0, 1.0);
rim = smoothstep(rim_width - 0.05, rim_width + 0.05, rim);
// so aparece no lado que recebe luz, e tambem em degrau
rim *= step(0.001, diffuse) * rim_amount;
DIFFUSE_LIGHT += rim_color.rgb * rim * LIGHT_COLOR;
}
O 1.0 - dot(NORMAL, VIEW) vale quase zero quando a superfície encara a câmera de frente e chega perto de um nas bordas. O smoothstep com o rim_width transforma isso numa faixa nítida, de novo no espírito toon. Multiplicar por step(0.001, diffuse) garante que o rim só apareça no lado iluminado do objeto, senão a silhueta inteira acenderia, inclusive na sombra, e o efeito perderia a graça. Ajuste rim_width para engrossar ou afinar a linha e rim_amount para a intensidade.
Se quiser ir além, vale conhecer também como as luzes de cena funcionam por dentro para posicionar melhor a fonte principal e a de recorte. O guia de iluminação 3D na Godot explica os tipos de luz e como energia e atenuação entram nessa mesma função light().
Controle de artista: a textura de rampa
O floor divide a luz em degraus iguais, mas nem sempre você quer degraus iguais. Um visual de anime costuma ter uma sombra principal larga e uma faixa fina de meio-tom logo na transição, tamanhos diferentes que o floor sozinho não entrega. A solução que muitos estúdios usam é trocar o cálculo por uma textura de rampa: uma imagem de uma linha só, do escuro à esquerda ao claro à direita, onde você mesmo pinta onde cada banda começa e termina.
A ideia é usar o valor da difusa como coordenada horizontal (U) pra ler a cor nessa textura. Cada pixel da rampa define quanta luz aquela faixa recebe.
uniform sampler2D ramp_texture : source_color, hint_default_white;
void light() {
float n_dot_l = dot(NORMAL, LIGHT);
float diffuse = clamp(n_dot_l, 0.0, 1.0) * ATTENUATION;
// Le a cor da rampa na posicao da difusa (U), linha do meio (V)
vec3 ramp = texture(ramp_texture, vec2(diffuse, 0.5)).rgb;
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * ramp;
}
Pra isso funcionar como banda dura, a textura da rampa precisa ser importada com filtro Nearest e com repetição desligada (clamp), senão a GPU interpola entre os pixels e você perde o corte seco. Com Nearest, cada pixel da rampa vira uma banda de cor chapada: uma rampa de três pixels dá três bandas, mas agora você pode fazer o pixel do meio ocupar o dobro do espaço dos outros só desenhando a imagem diferente.
A vantagem prática é separar a decisão de arte do código. O programador entrega o shader uma vez, e o artista itera o visual trocando a imagem da rampa, sem tocar em floor, sem mexer no número de bandas e sem recompilar nada. É o mesmo princípio de deixar o ajuste fino na mão de quem cuida do visual, e casa bem na hora de aplicar esse toon num personagem importado do Blender para a Godot, em que a malha chega pronta e só o material muda.
Fechando com o contorno (outline)
O shader que a gente montou cuida do preenchimento: as cores chapadas em bandas e o rim. O que ainda falta para o visual bater de vez com desenho animado é a linha preta ao redor da silhueta. Esse contorno é um efeito separado e não vem da função light().
A técnica mais usada em 3D é o casco invertido, ou inverted hull. Você duplica a malha, empurra os vértices dela um pouco para fora ao longo das normais, pinta de preto e renderiza só as faces de trás com cull_front. O resultado é uma casca preta que aparece exatamente na borda do modelo, sem cobrir a parte da frente. Como esse contorno tem suas próprias particularidades e vários ajustes, cobri o passo a passo dele com código completo no post sobre shader de contorno na Godot. Combine os dois materiais e o efeito toon fica completo: bandas por dentro, linha por fora.
Uma alternativa mais barata, quando você não quer duplicar a malha, é usar o efeito de pós-processamento com detecção de borda por profundidade e normal. Ele desenha o contorno lendo a tela inteira, o que pega também linhas internas do modelo, não só a silhueta. É mais elaborado e fica para outra hora, mas saiba que existe.
Ligando o toon a um material de verdade
Para testar rápido, crie um MeshInstance3D com uma malha qualquer (uma cápsula ou uma esfera já servem), atribua um ShaderMaterial, cole o shader completo e adicione uma DirectionalLight3D na cena. Gire a luz e você vê as bandas deslizando pela superfície em blocos duros. Se quiser trocar propriedades do shader por código, lembre de usar GDScript tipado e set_shader_parameter.
extends MeshInstance3D
@export var bandas: int = 3
@export var cor_rim: Color = Color.WHITE
func _ready() -> void:
var material: ShaderMaterial = get_active_material(0) as ShaderMaterial
if material == null:
return
material.set_shader_parameter("bands", bandas)
material.set_shader_parameter("rim_color", cor_rim)
Repare no as ShaderMaterial e na checagem de null: se o material do índice 0 não for um ShaderMaterial, o cast devolve null e a gente sai da função sem erro. Tipar as variáveis não é firula, é o que deixa o editor te avisar de nome de parâmetro errado antes de rodar.
Resumo prático
O cel shading não reinventa a iluminação, ele reinterpreta o resultado dela. Você calcula a difusa Lambert normalmente, quantiza esse valor em poucos degraus com floor dentro da função light(), e o gradiente contínuo vira blocos chapados de cor. A partir daí, o número de bandas define o quão duro ou suave o visual fica, o rim light destaca a silhueta, o antialiasing com fwidth limpa a borda dos degraus e o contorno por casco invertido dá o acabamento de desenho.
Comece com a versão do floor, ajuste bands e as cores da luz até achar o tom que combina com sua arte, e só depois some o rim e o outline. Cada peça é independente, então dá para ir empilhando efeito por efeito e ver o personagem ganhar cara de anime a cada passo.
Se escrever esse código de shader ainda intimida, saiba que dá para montar boa parte desse tipo de efeito ligando nós no editor de Visual Shader da Godot, vendo o resultado enquanto conecta as peças, e só depois migrar para o código quando quiser controle total.
Perguntas frequentes
O que diferencia cel shading do sombreamento realista?
No sombreamento padrão (PBR) a luz varia de forma contínua sobre a superfície, criando um gradiente suave do claro ao escuro. No cel shading a gente pega esse mesmo cálculo de luz e quantiza o resultado em poucos degraus fixos, então em vez de um degradê você vê blocos chapados de cor, igual arte de desenho animado ou anime.
Para que serve a função light() no shader da Godot 4?
A função light() substitui o modelo de iluminação padrão da Godot e roda uma vez por luz que atinge o objeto. É nela que você tem acesso a NORMAL, LIGHT, LIGHT_COLOR e ATTENUATION e decide como escrever em DIFFUSE_LIGHT e SPECULAR_LIGHT. É exatamente o lugar certo para quantizar a luz em bandas e montar o efeito toon.
Como controlar quantas bandas de luz o cel shading tem?
Você multiplica o valor da difusa (entre 0 e 1) pelo número de bandas, aplica floor para arredondar para baixo e divide pelo mesmo número. Com 2 bandas o resultado é bem duro, estilo cartoon; com 4 ou 5 fica mais suave, mais perto do anime. Basta expor esse número como um uniform int e ajustar no inspetor.
Preciso de um contorno junto com o cel shading?
Não é obrigatório, mas o contorno preto (outline) é o que faz o objeto parecer desenhado de verdade. O shader de banda cuida do preenchimento das cores; o contorno cuida da silhueta. Os dois são efeitos separados que se somam, e você aplica a outline por cima, geralmente com a técnica de casco invertido em 3D.
Cel shading funciona com as luzes normais da cena na Godot?
Sim. Como o efeito é escrito dentro da função light(), ele reage a DirectionalLight3D, OmniLight3D e SpotLight3D normalmente, respeitando cor, energia e atenuação de cada uma. Você não precisa de uma iluminação especial, só de um material com o seu shader toon aplicado.


