Um shader que não compila pelo menos diz onde falha. Um shader que compila e desenha a coisa errada não diz nada. Ele não tem printf nem breakpoints no navegador, e roda como milhões de cópias ao mesmo tempo. Depurá-lo é um ofício próprio: ler bem o log do compilador, transformar em cor os valores de que você desconfia, cortar o problema ao meio até ele não ter onde se esconder, e saber quais operações não têm resultado definido.
Ao final você vai conseguir
- Ler um log de compilação e achar a linha a que ele se refere, mesmo quando foi acrescentado código antes do seu.
- Transformar qualquer valor numa cor que mostre de relance se ele está certo.
- Achar um bug cortando o shader ao meio, e ler o valor de um pixel como número.
- Reconhecer as operações cujo resultado é indefinido, e protegê-las.
- Escolher uma ferramenta que execute um shader passo a passo na sua plataforma.
O glGetShaderInfoLog devolve as mensagens do compilador como texto. O formato não é padronizado, mas a maioria dos drivers começa cada mensagem com a string de código e a linha. Estas são mensagens reais do compilador do Chrome e do Edge (ANGLE):
1ERROR: 0:6: 'col' : undeclared identifier
2ERROR: 0:6: 'constructor' : not enough data provided for construction
3 │ │
4 │ └─ line 6
5 └─── source string 0 (glShaderSource can take several strings)Corrija a primeira mensagem primeiro. As seguintes muitas vezes são consequência dela: acima, vec4(col, 1.0) só falha porque col não existe. E um erro de sintaxe é informado onde o parser o percebeu, que muitas vezes é a linha depois do erro. Um ponto e vírgula faltando no fim da linha 6 aparece como "'o' : syntax error" na linha 7, na primeira palavra que não pode continuar a instrução.
| Código | Mensagem (ANGLE) | O que significa |
|---|---|---|
| float x = 1; | cannot convert from 'const int' to 'highp float' | o GLSL ES nunca converte int em float sozinho (o GLSL de desktop converte desde a 1.20). Escreva 1.0. |
| float x = 0.5 * i; | wrong operand types - no operation '*' exists that takes a left-hand operand of type 'const float' and a right operand of type 'highp int' | a mesma regra para operadores: escreva 0.5 * float(i). |
| vec3 c = vec4(1.0); | dimension mismatch | um vec4 não cabe num vec3: pegue .rgb ou .xyz. |
| um ; faltando | 'o' : syntax error | informado na linha seguinte, na primeira palavra que não pode vir depois. |
| vec4(col, 1.0) | undeclared identifier | um erro de digitação, uma variável declarada dentro de um bloco que já terminou, ou uma que o estágio não tem. |
Quando os números de linha não batem
Muitos programas põem código na frente do shader antes de compilá-lo: uma linha #version, uma lista de #defines, uma biblioteca de funções compartilhada. Cada linha acrescentada desloca os números do log, e "linha 47" deixa de ser a linha 47 do seu arquivo. A diretiva #line resolve isso: #line n diz que a próxima linha é a linha n. Ponha #line 1 logo antes do seu próprio código, e o log volta a contar a partir da sua primeira linha. (O playground desta trilha chega ao mesmo resultado pelo caminho inverso: ele conta as linhas do seu prelúdio e as subtrai de cada número do log.)
1std::string src = "#version 330 core\n"
2 + defines // "#define SHADOWS 1\n#define LIGHTS 4\n"
3 + commonLib // shared functions, many lines
4 + "#line 1\n" // the log now counts from the next line
5 + readFile("lit.frag");Compile antes de rodar
Cada driver tem o seu próprio compilador de GLSL, e alguns aceitam código que a especificação proíbe. Um shader pode então funcionar no seu computador e não compilar no de outra pessoa. O compilador de referência da Khronos é rigoroso e roda sem GPU, então você pode conferir todo shader como parte do build: glslangValidator lit.frag mostra os erros (o estágio vem da extensão do arquivo: .vert, .frag, .comp), e para Vulkan o glslc compila para SPIR-V (veja Versões e dialetos).
Não existe printf na GPU, mas existe uma tela com milhões de pixels. A técnica universal é mostrar como cor o valor de que você desconfia. Escreva FragColor = vec4(vec3(x), 1.0) para um escalar, ou vec4(v * 0.5 + 0.5, 1.0) para uma direção em [−1, 1]. Depois confira se ela tem a cara que você espera: preto onde deveria ser 0, branco onde deveria ser 1, uma rampa suave onde deveria ser contínua. Valores fora da faixa são cortados em preto e branco, então use fract(x) para ver valores grandes como faixas.
1FragColor = vec4(vec3(d), 1.0); // a scalar
2FragColor = vec4(normal * 0.5 + 0.5, 1.0); // a direction
3FragColor = vec4(fract(worldPos), 1.0); // large coordinates as repeating bands
4FragColor = vec4(d < 0.0 ? vec3(1, 0, 0) : vec3(0, 0, 1), 1.0); // a sign
5FragColor = vec4(any(isnan(c)) ? vec3(1, 0, 1) : vec3(0), 1.0); // NaN huntingPor que * 0,5 + 0,5 para direções: os componentes de uma normal unitária vão de −1 a 1, mas um canal de cor só mostra 0..1, então tudo que fosse negativo ficaria preto. Dividir pela metade e deslocar leva −1 → 0, 0 → 0,5 e 1 → 1. Uma normal apontando para cima, (0, 1, 0), aparece como (0,5; 1; 0,5), verde-claro; uma apontando para a câmera ao longo de +Z aparece como (0,5; 0,5; 1), o lilás de todo normal map. Com fract(worldPos), uma posição de 3,25 aparece como 0,25: cada unidade inteira vira uma faixa, então dá para contar unidades na tela.
Dois hábitos deixam essas visões muito mais úteis. Mostre a visão de depuração de um lado da tela e a imagem de verdade do outro, divididas no mouse, para ver a que parte da imagem pertence cada valor. E, para uma cor final, não olhe para ela: teste-a. Uma checagem de faixa pinta de azul todo pixel abaixo de 0, de vermelho acima de 1 e de magenta o NaN, e de cinza tudo o que está bem, de modo que um problema de um pixel não consegue se esconder numa imagem cheia de detalhes.
O uView escolhe o que o lado esquerdo mostra; o lado direito é sempre a imagem de verdade. Arraste pelo canvas para mover a divisão. 1: a normal como cor. 2: as coordenadas de textura da bola como faixas que se repetem. 3: N·L com o sinal, negativo em vermelho. 4: uma checagem de faixa na cor final: azul abaixo de 0, vermelho acima de 1, magenta para NaN, cinza onde está tudo bem.
Quando você não faz ideia de onde está o bug, corte ao meio o espaço em que ele pode estar, de novo e de novo. Cada passo custa uma edição e uma olhada, então mesmo um shader longo leva só alguns passos:
| Passo | Se continua errado | Se fica certo |
|---|---|---|
| Escreva uma cor constante na primeira linha do main e dê return. | o problema não é o shader: geometria, blending, teste de profundidade, ou o programa errado em uso (veja Depurando OpenGL) | o bug está no shader; siga em frente |
| Troque um uniform ou uma textura suspeitos pelo valor que deveriam ter. | o bug está na matemática do próprio shader | o bug está do lado da CPU: location errada, glUniform1i para um float, um uniform que o compilador removeu |
| Mostre um valor intermediário na metade do main e dê return. | o bug está acima dessa linha: suba o return | o bug está abaixo dela: desça o return |
Às vezes uma cor não é precisa o bastante: você precisa saber se um valor é 0,5 ou 0,503. Aí escolha um pixel e leia o valor dele como número. Renderize num alvo float de 32 bits, escreva o valor que você quer só para aquele pixel, e leia de volta com glReadPixels. É um printf de um pixel, e é assim que a figura da próxima seção obtém os seus números.
1// Early return: everything below this line is not run
2FragColor = vec4(vec3(ndl), 1.0); return;
3
4// Pixel probe: write the exact numbers for pixel (200, 100) only.
5// Render into a GL_RGBA32F texture, then on the CPU:
6// glReadPixels(200, 100, 1, 1, GL_RGBA, GL_FLOAT, out);
7if (ivec2(gl_FragCoord.xy) == ivec2(200, 100)) {
8 FragColor = vec4(ndl, spec, length(N), 1.0);
9 return;
10}A especificação lista, função por função, entradas para as quais o resultado é indefinido: sqrt de um número negativo, pow de uma base negativa, acos de um valor acima de 1, e outras. Indefinido não quer dizer erro. A GPU devolve alguma coisa, muitas vezes NaN (not a number), às vezes um número comum, e GPUs diferentes devolvem coisas diferentes. A figura roda cada caso como um shader de verdade na sua GPU e lê o resultado de volta:
| expressão | a especificação diz | RTX 4060 |
|---|---|---|
| sqrt(-1.0) | indefinido para x < 0 | NaN |
| log(0.0) | indefinido para x ≤ 0 | −Inf |
| 1.0 / 0.0 | +Inf no IEEE 754, que o GLSL ES não exige | +Inf |
| x / x, com x = 0.0 | 0 / 0 é NaN no IEEE 754 | 1 |
| pow(-2.0, 2.0) | indefinido para x < 0 | 4 |
| pow(-2.0, 2.2) | indefinido para x < 0 | NaN |
| pow(0.0, -1.0) | indefinido para x = 0 e y ≤ 0 | +Inf |
| normalize(vec3(0.0)).x | divide por um comprimento 0 | NaN |
| acos(1.000001) | indefinido para |x| > 1 | NaN |
| atan(0.0, 0.0) | indefinido quando os dois são 0 | 0 |
| NaN * 0.0 | NaN no IEEE 754 | 0 |
| min(NaN, 1.0) | não especificado | 1 |
| max(NaN, 0.0) | não especificado | NaN |
| clamp(NaN, 0.0, 1.0) | não especificado | 1 |
| isnan(NaN) ? 1.0 : 0.0 | 1, ou sempre 0 se a GPU não tem NaN | 1 |
| -7 % 3 | indefinido se um operando é negativo | -1 |
| mod(-7.0, 3.0) | x − y · floor(x / y) = 2 | 2 |
| smoothstep(1.0, 0.0, 0.25) | indefinido se edge0 ≥ edge1 | 0.84375 |
| float x; (nunca escrita) | valor indefinido | 0 |
| f() que esquece o return | valor indefinido | 0 |
Cada linha é um shader de um pixel de verdade. Rosa é NaN, âmbar é infinito. Veja x / x: 0 / 0 deveria ser NaN, mas o compilador simplificou x / x para 1 antes de a GPU ver um zero. NaN * 0.0 virou 0 do mesmo jeito, enquanto min e clamp transformaram o NaN num número sem avisar e max o manteve. Nada disso é garantido: outra GPU, outro driver ou outro navegador podem mostrar outra coluna, e é exatamente por isso que shaders não podem depender disso.
O NaN é o pior deles, porque se espalha. Qualquer conta com um NaN dá NaN, então um valor ruim num termo de iluminação transforma a cor inteira em NaN. Quando a cor é escrita num alvo comum de 8 bits, o NaN em geral vira 0: pixels pretos, ou uma forma preta de bordas nítidas, onde está a entrada ruim. O infinito se espalha de um jeito mais brando: vira NaN assim que você calcula ∞ − ∞, 0 · ∞ ou ∞ / ∞.
O termo ambiente 0,12 deveria manter a bola inteira levemente iluminada, mas uma lua crescente preta pura passa pelo lado escuro dela. Ligue o uFind: ele pinta de magenta todo pixel com NaN e escurece o resto. Que linha produz NaN só ali? (A resposta está no texto abaixo do playground.)
A resposta: pow(dot(N, H), uShine). O vetor médio H aponta entre a luz e o observador. No lado da bola voltado para longe dele, dot(N, H) é negativo, e pow de uma base negativa é indefinido. As GPUs calculam pow(x, y) como exp2(y · log2(x)), e log2 de um número negativo é NaN. O NaN então passa pela soma e pela linha de gama até a cor, e os 0,12 de luz ambiente se perdem junto. A correção é pow(max(dot(N, H), 0.0), uShine). Todo pixel que apareceu em magenta era um pixel em que um número negativo chegou ao pow.
| Expressão | Indefinido quando | Versão protegida |
|---|---|---|
| sqrt(x) | x < 0 | sqrt(max(x, 0.0)) |
| pow(x, y) | x < 0, ou x = 0 e y ≤ 0 | pow(max(x, 0.0), y) |
| log(x), log2(x) | x ≤ 0 | log(max(x, 1e-30)) |
| acos(x), asin(x) | |x| > 1 | acos(clamp(x, -1.0, 1.0)) |
| normalize(v) | v tem comprimento 0 | confira dot(v, v) > 1e-12 antes, ou escolha uma direção de reserva |
| a / b | b = 0 | a / max(b, 1e-6) quando b ≥ 0 |
| atan(y, x) | x = y = 0 | teste o centro antes de chamá-la |
| smoothstep(e0, e1, x) | e0 ≥ e1 | 1.0 − smoothstep(e1, e0, x) para uma borda descendente |
| clamp(x, lo, hi) | lo > hi | garanta que os limites estejam em ordem |
| a % b (int) | a ou b negativo | mod() em floats, ou ints sem sinal |
Cores e sondas funcionam em qualquer lugar. Em plataformas nativas, ferramentas conseguem mais: capturar um frame, escolher um pixel, e executar o shader daquele pixel linha por linha com o valor de cada variável. O suporte depende da API:
| Ferramenta | O que faz pelos shaders | Onde |
|---|---|---|
| RenderDoc | captura de frame, histórico do pixel e depuração de shader linha por linha; no OpenGL ela captura o frame mas não executa shaders passo a passo | Vulkan, D3D11, D3D12, OpenGL |
| debugPrintfEXT | printf de dentro de um shader (GL_EXT_debug_printf); a camada de validação imprime a saída, e o RenderDoc a mostra por draw | Vulkan |
| Nsight Graphics | o depurador de frames e profiler da NVIDIA; o depurador de shaders dele funciona com Vulkan e D3D12 | Vulkan, D3D12, OpenGL |
| Depurador de Metal do Xcode | executa passo a passo shaders de Metal, que é no que o WebGL se transforma num Mac | macOS, iOS |
| Spector.js | uma extensão do navegador que captura toda chamada WebGL de um frame com o estado e o código dos shaders; sem execução passo a passo | WebGL |
| glslangValidator, glslc | compilam offline com o compilador de referência, para pegar erros antes de o driver vê-los | qualquer uma |
1#version 450
2#extension GL_EXT_debug_printf : enable
3...
4// Printed once per invocation that runs it, so limit it to one pixel
5if (ivec2(gl_FragCoord.xy) == ivec2(200, 100))
6 debugPrintfEXT("N = %v3f, ndl = %f", N, ndl);| Sintoma | Causa | Correção |
|---|---|---|
| dezenas de erros depois de uma edição pequena | um erro confunde o parser em tudo o que vem depois | corrija só a primeira mensagem, depois compile de novo |
| o log aponta para uma linha que parece certa | foi acrescentado código antes do shader, ou o erro está no fim da linha anterior | #line 1 antes do seu código; olhe uma linha acima |
| pixels pretos com bordas nítidas | NaN de um sqrt, pow, acos ou normalize sem proteção | visão de NaN para achá-los, depois proteja a entrada |
| funciona no navegador, pisca no build nativo | uma variável não inicializada, zerada pelo ANGLE mas não pelo driver | inicialize tudo |
| funciona numa GPU, não compila em outra | um driver tolerante aceitou código fora da especificação | confira com o glslangValidator no build |
| uma cor de depuração parece certa, mas o resultado continua errado | o valor foi cortado em 0..1 na tela, escondendo que era 3 ou −2 | use uma checagem de faixa, fract, ou a sonda de pixel |
Ideias-chave
- Corrija o primeiro erro de compilação primeiro; um erro de sintaxe é informado onde o parser o percebeu, muitas vezes uma linha depois.
- #line 1 antes do seu código faz os números de linha do log voltarem a ser os seus.
- Pinte como cor o valor de que você desconfia, dividido contra a imagem real, e faça checagem de faixa nas cores finais.
- Corte o shader ao meio com returns antecipados e constantes; leia números exatos com uma sonda de um pixel.
- sqrt, pow, log, acos, normalize, divisão e % têm entradas com resultado indefinido; o NaN se espalha e em geral aparece como preto.
- O navegador zera variáveis esquecidas; drivers nativos não. RenderDoc, debugPrintfEXT e ferramentas dos fabricantes executam shaders passo a passo.