Recentemente eu estava conversando com o Eduardo Chaves e ele me mostrou o site http://shader-playground.timjones.io/ .
Aqui é possível escrever um shader (vertex, fragment, etc...) e você pode escolher o compilador, linguagem e o estágio do shader que você está escrevendo.
O melhor analisador é o Radeon GPU Analyser, porque ele compila o código e você pode ver as instruções que ele gera, a quantidade de registradores que ele usa, etc…
Esse é o nosso ponto de partida.
Motivação
A principal motivação é entender melhor o código de shader que estamos escrevendo.
E espremer nosso código para otimizar até o último bit :-).
A Matriz Inversa Transposta
Problema 1: Calcular a matriz inversa transposta para que a normal mantenha sua ortogonalidade para cálculos de iluminação.
Eu sempre pensei que usar estruturas maiores ou agrupar os dados em tipos de arrays (vec2, vec3, vec4) nos shaders seria melhor. Mas olhe o exemplo que temos a seguir.
Eu implementei o cálculo da matriz inversa transposta usando o método de cofatores/determinante.
Eu escrevi 4 algoritmos em shader:
mat3 inverse_transpose_0(mat3 m) { mat3 result_a = mat3(m[2].z * m[1].y, m[1].z * m[2].x, m[2].y * m[1].x, m[0].z * m[2].y, m[2].z * m[0].x, m[0].y * m[2].x, m[1].z * m[0].y, m[0].z * m[1].x, m[1].y * m[0].x); mat3 result_b = mat3(m[1].z * m[2].y, m[2].z * m[1].x, m[1].y * m[2].x, m[2].z * m[0].y, m[0].z * m[2].x, m[2].y * m[0].x, m[0].z * m[1].y, m[1].z * m[0].x, m[0].y * m[1].x); mat3 result = result_a - result_b; return result / dot( m[0], result[0] );// result / determinant }
mat3 inverse_transpose_1(mat3 m) { float a0 = m[0][0], a1 = m[0][1], a2 = m[0][2]; float b0 = m[1][0], b1 = m[1][1], b2 = m[1][2]; float c0 = m[2][0], c1 = m[2][1], c2 = m[2][2]; mat3 result_a = mat3( c2 * b1, b2 * c0, c1 * b0, a2 * c1, c2 * a0, a1 * c0, b2 * a1, a2 * b0, b1 * a0); mat3 result_b = mat3( b2 * c1, c2 * b0, b1 * c0 , c2 * a1, a2 * c0, c1 * a0, a2 * b1, b2 * a0, a1 * b0); mat3 result = result_a - result_b; return result / dot( m[0], result[0] );// result / determinant }
mat3 inverse_transpose_2(mat3 m) { float a0 = m[0][0], a1 = m[0][1], a2 = m[0][2]; float b0 = m[1][0], b1 = m[1][1], b2 = m[1][2]; float c0 = m[2][0], c1 = m[2][1], c2 = m[2][2]; vec3 _a0 = vec3( c2 * b1, b2 * c0, c1 * b0 ); vec3 _a1 = vec3( b2 * c1, c2 * b0, b1 * c0 ); vec3 _a = _a0 - _a1; vec3 _b0 = vec3( a2 * c1, c2 * a0, a1 * c0 ); vec3 _b1 = vec3( c2 * a1, a2 * c0, c1 * a0 ); vec3 _b = _b0 - _b1; vec3 _c0 = vec3( b2 * a1, a2 * b0, b1 * a0 ); vec3 _c1 = vec3( a2 * b1, b2 * a0, a1 * b0 ); vec3 _c = _c0 - _c1; return mat3(_a,_b,_c) / dot( m[0], _a );// result / determinant }
mat3 inverse_transpose_3(mat3 m) { float a00 = m[0][0], a01 = m[0][1], a02 = m[0][2]; float a10 = m[1][0], a11 = m[1][1], a12 = m[1][2]; float a20 = m[2][0], a21 = m[2][1], a22 = m[2][2]; float b01 = a22 * a11 - a12 * a21; float b11 = a12 * a20 - a22 * a10; float b21 = a21 * a10 - a11 * a20; return mat3( b01, b11, b21, (a02*a21 - a22*a01), (a22*a00 - a02*a20), (a01*a20 - a21*a00), (a12*a01 - a02*a11), (a02*a10 - a12*a00), (a11*a00 - a01*a10) ) / (a00 * b01 + a01 * b11 + a02 * b21); // result / determinant }
Qual algoritmo tem um desempenho melhor depois da compilação?
Inocentemente eu pensava que a função inverse_transpose_0 era a melhor das implementações. Porque ela usa somente um bloco de dados em matriz, e realiza uma subtração de 9(3x3) valores em uma linha de código.
Mas eu estava errado, porque esse foi um dos piores códigos gerados depois da compilação.
Dê uma olhada nas estatísticas:
Name | Instructions | Registers |
---|---|---|
inverse_transpose_0 | 74 | 15 |
inverse_transpose_1 | 74 | 15 |
inverse_transpose_2 | 68 | 15 |
inverse_transpose_3 | 68 | 13 |
transpose(inverse(mat3())) | 73 | 14 |
mat3(transpose(inverse())) | 108 | 18 |
Eu descobri que as operações de matrizes geram operações em outras estruturas como vec2, vec3 e vec4 de acordo com o conjunto de instruções da placa gráfica.
Então a implementação 0 engana os nossos olhos com poucas estruturas de dados maiores.
A implementação 1 gera instruções semelhantes no compilador Radeon, mas em outros compiladores ela gera menos instruções comparada com a implementação 0. Ambas usam uma subtração com o tipo mat3 para calcular a inversa. É melhor criar floats separados ao invés de usar diretamente a indexação da matriz no código.
A implementação 2 é um pouco mais esperta. Ela usa floats separados, e usa primitivas vec3. Ela gera menos instruções. Mas ainda não é a melhor implementação.
Finalmente nós temos a implementação 3. Nós podemos esquecer tudo sobre agrupar dados que tínhamos pensado no início e armazenar os valores todos em floats. O compilador agrupa automaticamente todos os valores para gente e adiciona algumas instruções de multiplicar e adicionar (mad). Essa implementação gera o melhor código com a utilização de menos registradores.
Cálculo dos Cofatores
O cálculo dos cofatores me chamou a atenção, porque essa situação é bastante comum de acontecer em qualquer equação.
Por exemplo:
Na implementação 3, o código original era:
float b11 = - a22 * a10 + a12 * a20;
O novo é:
float b11 = a12 * a20 - a22 * a10;
O código original gera 1 instrução a mais que o novo.
A Transformação da Normal
Problema 2: calcular o vetor normal depois de uma transformação para manter os cálculos de iluminação OK no fragment shader.
Eu pensava: Quando eu transformar uma normal por uma matriz 4x4, eu posso converter o vértice, multiplicar pela matriz e fazer um swizzle para pegar o resultado final.
Tem vários shaders que eu já escrevi que usa essa forma:
vec3 N = normalize( uLocalToWorld_it * vec4( aNormal, 0.0) ).xyz;
E esse é o novo código que eu tenho:
vec3 N = normalize( mat3(uLocalToWorld_it) * aNormal );
O código original gera 2 instruções a mais que o código novo.
Considerações Finais
Essas considerações não são regras. São conclusões que cheguei analisando o resultado da compilação enquanto eu desenvolvia os algoritmos.
Espero que possa ajudá-lo a pensar sobre o seu código.
Vamos lá:
- Quando você usa estruturas indexadas (matriz) em várias partes do código. É melhor criar estruturas primitivas mais simples como associações temporárias e usá-las nos locais que o algoritmo precisa.
- Quando você faz os cálculos sobre valores que já estão agrupados em vec2, vec3 ou vec4. Tente deixar o código o mais simples possível e não tem problema usar muitos floats para isso. A implementação 3 da inversa da transposta é a melhor porque ela gera menos instruções e usa menos registradores.
- Tente não usar sinais negativos em elementos isolados na equação. Sempre que você ver uma forma: ‘-a+b’, tente usar a forma: ‘b-a’ no lugar. Isso gerará menos instruções.
- Quando precisar de transformar um vec3 que é um vetor (não um ponto). Converter um mat4 para mat3 e fazer a multiplicação é melhor que converter o vec3 para vec4, fazer a multiplicação e usar o swizzle no resultado.
Obrigado por ler esse post.
Um grande abraço,
Alessandro Ribeiro