4. Programando em CG + OpenGL
Uma vez que as GPUs se tornaram programáveis, tornou-se necessário criar uma linguagem de alto nível para que fosse possível a rápida e eficiente criação de programas para as mesmas. Foi com este intuito que os desenvolvedores da NVidia criaram o Cg, ou seja, C for Graphics. Por ser baseado em C, programadores que já possuem algum conhecimento desta linguagem são capazes de rapidamente aprender e implementar programas em Cg.
A linguagem Cg permite a criação de dois tipos de programas: vertex programs - para modificação das propriedades dos vértices - e fragment programs - para modificação das propriedades dos fragmentos. Vale à pena lembrar que, assim que as GPUs deixaram de implementar em hardware as especificações das bibliotecas OpenGL e Direct X, estas passaram a conter seus próprios vertex programs e fragment programs a serem rodados nas GPUs. Sendo assim, ao implementar e executar um shader, você estará substituindo o shader default pelo seu.
O Cg possui algumas diferenças importantes em relação à linguagem C. A primeira grande diferença está nos tipos de variáveis que podemos criar, pois, uma vez que a arquitetura das GPUs é voltada para a otimização de processamento de matrizes e vetores, o Cg possui uma vasta gama de tipos de matrizes e vetores que permitem a utilização de operações matemáticas otimizadas para estes tipos. Outra diferença é o fato de não ser possível incluir bibliotecas externas ao Cg a um programa. A única biblioteca disponível, e que contém uma vasta gama de funções matemáticas, é a Standard Cg Library que é inclusa por padrão, sem a necessidade de declaração. Outro ponto diferente é o conceito de semântica, que nada mais é que dados sobre os objetos 3D que são passados automaticamente pela GPU para o seu programa. Veremos mais sobre os diferentes tipos de variáveis, Standard Cg Library e semântica nos exemplos de programas a seguir.
4.1. Vertex Programs
Um vertex program é executado uma vez para cada vértice contido na cena e tem como objetivo alterar suas propriedades, como posição, normal, cor, dentre outras. Vamos analisar um exemplo simples apresentado na Figura 12.
Figura 12: Exemplo 1: vertex program
Neste exemplo, podemos verificar a declaração da função Test1v_green, que retorna uma estrutura do tipo Test1v_Output, declarada logo acima, e que recebe como parâmetro uma variável do tipo float2 position :POSITION, ou seja, ela recebe como parâmetro apenas as duas primeiras coordenadas referentes à posição de um vértice.
As declarações de variáveis do tipo float2, float3 e float4 definem vetores com precisão de ponto flutuante de 2, 3 e 4 posições, respectivamente. Já a declaração :POSITION e :COLOR0 estabelecem a semântica da variável: posição, no primeiro caso, e cor, no segundo. Tais declarações associam uma propriedade do vértice ou fragmento a uma variável, sendo que o valor associado varia de vértice para vértice e de fragmento para fragmento. Se uma variável pertence ao conjunto de argumentos da função de entrada (neste caso a função Test1v_green) e está associada à uma semântica, então, seu valor será atualizado pela própria GPU antes do início da execução da função. Caso a variável associada a uma semântica seja retornada ou pertença a uma estrutura de retorno, o seu valor, no momento do retorno, substituirá o valor da semântica na memória da GPU.
Sendo assim, o que faz o programa acima?
Este programa projeta ortogonalmente todos os vértices no plano z=0 e os colori de verde (cor = RGB).
No entanto, para que estas transformações sejam executadas, devemos lembrar que um shader não faz absolutamente nada sozinho, é necessário utilizarmos uma biblioteca gráfica para gerar os objetos 3D, carregar o shader na GPU e executá-lo. Como exemplo, criaremos um programa simples, utilizando OpenGL, que desenha um triângulo vermelho:
Figura 13: Implementação de um triângulo vermelho em OpenGL
Para modificarmos as propriedades dos vértices do triângulo, utilizando o vertex program da Figura 12, precisamos, primeiramente, declarar um contexto Cg, um profile e um programa Cg. Um contexto Cg é semelhante a um contexto OpenGL. É ele quem gerencia a máquina de estados por trás das chamadas de métodos Cg. Um profile é um conjunto de operações matemáticas que deverão estar implementadas numa GPU para que esta possa rodar seu shader. Portanto, é uma boa prática de programação especificar sempre o profile que contém menor número de funções que não são utilizadas, permitindo que seu programa rode num maior número de GPUs. A declaração de um programa constitui em especificar algumas propriedades do seu programa Cg, como nome do arquivo e da função de entrada. No nosso exemplo, declaramos estas três variáveis como variáveis globais, para facilitar o acesso no decorrer do programa, e as inicializamos logo após a inicialização do contexto OpenGL (Figura 14).
Figura 14: Inicialização de um contexto Cg, de um Profile e de um Vertex Program em OpenGL.
Uma vez declarado o programa, para utilizá-lo na modificação de um objeto 3D, é necessário carregar o profile (cgGLEnableProfile) e "ligar" o programa na GPU (cgGLBindProgram) antes do início do processo de construção do objeto e descarregar o profile ao final do processo (cgGLDisableProfile) (Figura 15). Note que não é preciso "desligar" o programa ao final do processo, pois isto é feito automaticamente.
Figura 16 - Passando argumentos a um programa Cg.
Os argumentos passados por um programa externo à GPU são declarados como uniformes (uniform), justamente devido ao fato de serem os mesmos para todos os vértices ou fragmentos. No programa da Figura 16, a matriz de projeção é passada por meio da variável uniform float4x4 modelViewProj, que é uma matriz 4 por 4 com precisão de ponto flutuante. Para aplicar a transformação representada pela matriz a um vértice, utilizamos a função mul presente na Standard Cg Library, que retorna o resultado da multiplicação dos seus parâmetros - no nosso caso, uma matriz de transformação e um vetor de posição.
Para passarmos os valores da matriz de projeção ao programa Cg, devemos declarar uma variável do tipo CGparameter, inicializá-la e atualizá-la de acordo com os objetivos do nosso programa. No nosso exemplo, declaramos a variável globalmente e a inicializamos logo após o término da inicialização do vertex program (Figura 17).
Figura 15 - À esquerda: carregamento e descarregamento do programa na GPU. À direita: resultado da execução do programa.
Agora, se executarmos o programa descrito, obteremos o triângulo da Figura 13 pintado de verde, como mostra a Figura 15.
4.2. Passando Argumentos
O vertex program apresentado na seção 4.1 é bastante simples. Vamos agora elaborá-lo um pouco mais passando uma matriz de transformação do objeto como parâmetro.
Um argumento passado a um programa Cg é o mesmo para todos os vértices declarados entre um cgGLBindProgram e outro, portanto, a nossa matriz de transformação será aplicada a todos os vértices do triângulo, de acordo com o programa da Figura 16.
Figura 17 - Declaração e inicialização de parâmetros de programas Cg em OpenGL.
Repare que, na função main, o método cgGetNamedParameter recebe como argumentos um objeto do tipo CgProgram (nosso vertex program), um string contendo o nome da variável no programa Cg e retorna um objeto do tipo CgParameter. Para atualizar a cada frame o valor desta variável, criamos uma matriz de transformação dentro do OpenGL e a passamos para o programa Cg utilizando o método cgGLSetStateMatrixParameter, conforme exibido na Figura 18.
Figura 19 - Processo de rasterização de um triângulo.
4.3. Fragment Programs
Nas GPUs com arquitetura do tipo pipeline, para as quais foi feito o Cg, após a execução do vertex program, ocorre o processo de criação e preenchimento dos triângulos (rasterização), no qual são criados os fragmentos. Um fragment program é responsável por alterar as propriedades destes fragmentos, que não existiam no momento da execução do vertex program. Um fragmento pode ser entendido como sendo cada um dos pixels criados após a projeção dos objetos no plano da imagem. É importante ressaltar que um fragmento possui propriedades diferentes de um pixel, como, por exemplo, posição 3D, coordenadas de textura, normal, dentre outras, e um fragmento pode acabar não tendo nenhuma relação com o seu "pixel", pois pode ser eliminado em algum teste de profundidade (z-buffer). Na Figura 19 está representado um exemplo de rasterização de um triângulo após sua projeção no plano da imagem, sendo que os lados do triângulo estão fragmentados em pixels.
Figura 18 - À esquerda: atualizando parâmetros de programas Cg no OpenGL. À direita: resultado das modificações.
Uma vez feitas estas alterações, ao executarmos o programa, obteremos um triângulo verde rotacionado de 90 graus no sentido anti-horário (Figura 18).
Apesar de possuir muitas propriedades cujos nomes são os mesmos que os dos vértices, muitas destas propriedades dos fragmentos não têm qualquer relação com as dos vértices. Por exemplo, a posição de um vértice é passada para a GPU pelo programa OpenGL e pode ser alterada pelo vertex program, enquanto que a posição de um fragmento é calculada durante o processo de rasterização e não pode ser alterada pelo fragment program.
Diferentemente de um vertex program, um fragment program só pode retornar componentes da semântica COLOR, ou seja, vetores de 1 a 4 posições. Tendo isto em mente, podemos apresentar nosso último exemplo de shader: um fragment program que aplica uma textura ao nosso triângulo transformado pelo vertex program da Figura 16. Tal programa é apresentado na Figura 20.
Figura 20 - Exemplo de fragment program.
Note que não precisamos mais da declaração da estrutura de retorno e, ao invés dela, associamos o valor retornado à semântica COLOR adicionando a declaração :COLOR logo após o término da declaração dos parâmetros de entrada da função.
Uma vez que neste nosso exemplo utilizaremos tanto um vertex program quanto um fragment program, é necessário que todos os dados requeridos aos cálculos das propriedades utilizados pelo fragment program sejam retornados pelo vertex program. Quando sobrescrevemos apenas o fragment program, não há a necessidade de se preocupar com isto. Neste caso, então, como o exemplo de fragment program utiliza as coordenadas de textura dos fragmentos, é necessário que o vertex program contenha em sua estrutura de retorno os dados sobre as coordenadas de textura dos vértices. Para tal, devemos modificar vertex program conforme mostra a Figura 21.
Figura 21 - Modificações necessárias ao vertex program.
Após realizadas estas pequenas modificações, precisamos criar e inicializar um profile, um programa e uma parâmetro Cg para o fragment program, dentro do código OpenGL, de forma muito semelhante à realizada durante a criação e inicialização do vertex program, conforme apresentado nas Figura 22 e Figura 23.
Figura 22 - Declaração e inicialização de um profile, um programa e um parâmetro para o fragment program.
Assim como no vertex program, inicializamos o objeto do tipo CGprogram com o retorno da função cgCreateProgramFromFile que recebe, dente outras informações, o nome do arquivo no qual está descrito o código Cg e o nome da função de entrada. Criamos um objeto CGparam para conter o identificador da textura e o inicializamos com o retorno do método cgGetNamedParameter, que recebe como parâmetros o objeto CGprogram referente ao fragment program e o nome da variável dentro deste programa.
Após a inicialização do objeto CGparam que conterá o identificador da textura, associamos tal identificador ao parâmetro utilizando o método cgGLSetTextureParameter. Tal identificador é o número retornado pelo método glGenTextures, responsável pelo gerenciamento dos identificadores de texturas dentro do contexto OpenGL.
Figura 23 - À esquerda: ativação do profile, do programa e da textura para o fragment program. À direita: resultado da utilização do vertex program e do fragment program descritos neste tutorial.
Uma vez inicializados corretamente, assim como o vertex program, é necessário habilitarmos o profile, o programa e a textura associados ao fragment program antes do processamento dos objetos 3D e desabilitá-los ao final do processo.
O resultado da execução do programa descrito pode ser visto na Figura 23, na qual cada fragmento do triângulo rotacionado teve sua cor alterada de acordo com a sua coordenada de textura e a cor do pixel da imagem do coelhinho.