O pipeline programável permite substituir etapas do pipeline gráfico (uma série de passos feitas por uma API gráfica para renderizar objetos gráficos) por um código personalizado. Pode-se alterar tanto o código de processamento de vértices (vertex shader) quanto de pixels (pixel shader).
Este código é executado na GPU e possibilita, por exemplo, alterar o modelo de iluminação de uma malha tridimensional. Assim, é possível obter materiais distintos e fazer com que uma roupa tenha uma aparência diferente da pele do personagem, algo que não era possível no pipeline fixo.
Uma grande vantagem da utilização de shaders é que sua execução é feita diretamente em GPU. Além das placas gráficas terem diversas operações comuns à computação gráfica (como soma de vetores e matrizes) implementadas diretamente em hardware, as GPUs mais recentes possuem diversos processadores paralelos que permitem processar vários vértices ou fragmentos ao mesmo tempo, tornando o processo ainda mais rápido.
Em um programa tridimensional, geralmente se usa uma combinação de um vertex shader e um pixel shader. Primeiramente os vértices da malha dos objetos 3D são processados pelo vertex shader e cada pixel que compõe a superfície destes objetos é processado pelo pixel shader. Neste tutorial eu vou focar somente no pixel shader, portanto o processamento será feito sobre imagens bidimensionais. Este tipo de shader é útil tanto em jogos 2D quanto para aplicar efeitos de pós-processamento em jogos 3D, onde primeiro o jogo é renderizado para uma textura e depois o processamento é feito sobre esta textura para só então o resultado final ser mostrado na tela.
No passado, os shaders eram programados em assembly, mas depois surgiram linguagens parecidas com o C, como o HLSL (usado no DirectX e XNA, GLSL (usado no OpenGL) e o CG. Os exemplos mostrados aqui foram desenvolvidos em HLSL, mas a sintaxe destas linguagens é bem parecida e traduzir de uma para outra não é muito complicado.
Utilizar um pixel shader no XNA é um processo bem simples. Na verdade, quando usamos a classe SpriteBatch já estamos usando um shader (o XNA não aceita o pipeline fixo por motivo de compatibilidade com o Xbox 360), mas esta classe nos permite abstrair seu uso. Aqui nós continuaremos usando o SpriteBatch, aplicando o shader sobre ele.
Para começar, podemos criar um novo shader no Visual Studio. Isto é efeito com um clique direito no sub-projeto Content e selecionando Add -> New Item -> Effect File. O modelo fornecido pelo XNA oferece um pixel e um vertex shader prontos. Caso alguém tenha a curiosidade de aplicá-lo a um objeto, ele irá projetar o modelo e alterar sua cor para vermelho.
Veremos dois exemplos aqui. O primeiro deles irá inverter as cores da textura e o outro irá convertê-la para escala de cinza. Em ambos os casos não usaremos o vertex shader. Para não confundir, vamos apagar o código padrão do XNA e começar nosso shader do zero.
O primeiro passo é criar uma estrutura de entrada para o pixel shader. Esta estrutura é passada automaticamente pelo vertex shader e os valores devem possuir uma semântica para que o programa saiba interpretá-los corretamente. A semântica pode ser uma coordenada de textura, cor, posição, etc. Aqui vamos precisar somente da cor e da coordenada de textura. A cor é aquele valor passado no SpriteBatch (geralmente branco) e a coordenada de textura é a coordenada a imagem referente a cada pixel desenhado na tela.
struct PixelShaderInput { float4 Color : COLOR; float2 TextureCoord : TEXCOORD0; };
Também é necessário declarar uma textura e um sampler (amostrador) de textura usado pelo SpriteBatch para acessar a imagem. A textura é declarada como extern porque ela vai ser declarada de fato no SpriteBatch. O TextureSampler permite acessar os texels da textura em questão.
uniform extern texture InputTexture; sampler TextureSampler = sampler_state { Texture = < InputTexture > ; };
Feito isso, criamos nossa função de pixel. Basta escrever uma função que receba a estrutura criada acima como parâmetro. Esta função deve retornar um float4, que é um vetor de quatro posições usado para representar uma cor RGBA. Isto porque, na verdade, depois desta etapa o pixel nada mais é que uma cor. Repare a semântica da função (COLOR0) indicando que o retorno é uma cor.
float4 PixelShaderFunction(PixelShaderInput input) : COLOR0 { // TODO: add your pixel shader code here. }
Agora podemos fazer um cálculo que será executado pelo pixel shader. No primeiro exemplo, valor simplesmente inverter a cor do pixel. Para isso, primeiro acessamos o pixel usando a função text2D (usando o amostrador da textura e a coordenada de textura, sendo que a coordenada é gerada automaticamente pelo pixel shader) e depois multiplicamos pela cor passada pelo SpriteBatch. Enfim, fazemos 1 – a cor para obter seu valor invertido (internamente será gerado um float4 com o resultado de 1 – r, 1 – g, 1 – b e 1 – a) e retornamos o resultado (que é a cor final do pixel).
float4 PixelShaderFunction(PixelShaderInput input) : COLOR0 { // TODO: add your pixel shader code here. input.Color = tex2D(TextureSampler, input.TextureCoord) * input.Color; input.Color = 1 - input.Color; return input.Color; }
Em um arquivo de efeito como este é possível ter diversas funções diferentes, da mesma forma que um programa em C normal. E assim como um programa em C é preciso indicar qual o ponto de partida do programa (o equivalente à função main). Um shader também pode ser dividido em diversas técnicas e passos, mas não vamos entrar em detalhes sobre isto agora. A linha abaixo indica que nossa função será usada como o pixel shader para o passo um da técnica um.
technique Technique1 { pass Pass1 { // TODO: set renderstates here. PixelShader = compile ps_1_1 PixelShaderFunction(); } }
Perceba que deve-se especificar a versão do pixel shader usado. Versões mais altas possuem mais recursos, tanto em variedade de funções quanto em limites do que se pode fazer, mas só rodarão em placas que suportem o mínimo exigido.
Pronto, com este shader bastante simples já é possível inverter a cor de uma textura. Agora precisamos aplicar o shader no XNA. Não vou explicar aqui com carregar e desenhar as imagens, qualquer dúvida vocês podem ver os artigos anteriores ou perguntar nos comentários. Carregar um efeito é praticamente igual a carregar uma textura, primeiro uma variável do tipo Effect é declarada e depois o efeito é carregado no método LoadContent.
Effect efeitoInverso; protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); textura = Content.Load < Texture2D > ("homer"); efeitoInverso = Content.Load < Effect > ("EfeitoInverso"); }
Na hora de desenhar, também não há complicação. Primeiro é preciso iniciar o efeito e depois iniciar o passo desejado (caso haja mais de um passo). Feito isso as texturas são desenhadas normalmente usando SpriteBatch.Draw e deve-se finalizar o processo fechando o passo e o efeito.
spriteBatch.Begin(SpriteBlendMode.None, SpriteSortMode.Immediate, SaveStateMode.None); efeitoInverso.Begin(); efeitoInverso.CurrentTechnique.Passes[0].Begin(); spriteBatch.Draw(textura, new Vector2(50, 50), Color.White); efeitoInverso.CurrentTechnique.Passes[0].End(); efeitoInverso.End(); spriteBatch.End();
O shader da escala de cinza vai mudar muito pouco. Na hora de calcular a cor do pixel, ao invés de fazer 1 – a cor, vamos multiplicar cada componente por um peso (relacionado à sensibilidade do olho humano aos canais RGB). As componentes de um vetor podem ser acessados tanto pelo formato .xyzw quanto .rgba, como eu usei abaixo.
input.Color = input.Color.r * 0.3 + input.Color.g * 0.59 + input.Color.b * 0.11;
Segue uma imagem do resultado final:
E é isto. Neste tutorial aprendemos a criar um pixel shader bem simples e aplicá-lo a uma textura no XNA. A idéia aqui foi fazer apenas uma introdução ao tema, visto que muito mais coisas podem ser feitas utilizando este recurso. Existem outras formas de se implementar estes exemplos, eu fiz da forma que me sinto mais confortável.
O projeto com os dois shaders e o código-fonte pode ser baixado aqui.
Um bom próximo passo para quem se interessou pelo assunto é pesquisar sobre como passar parâmetros da aplicação para o shader.