OpenGL – Geometry shaders

Até agora em nossa série sobre OpenGL, usamos vertex e fragment shader para manipular os vértices em pixels na tela. Desde o OpenGL 3.2 existe um terceiro tipo de shader que se posiciona entre o vertex e o fragment shader, conhecido como geometry shader. Esse shader possui a habilidade única de criar uma nova forma geométrica usando a saída do vertex shader como entrada.

Como negligenciamos o gato dos exemplos anteriores por um tempo, ele fugiu para uma nova casa. Isso nos dá uma boa oportunidade para começar do zero. No final desse artigo, teremos o seguinte exemplo:
End result
Isso não parece muito interessante… até você considerar que o resultado acima foi produzido com uma única chamada à função de desenho:

glDrawArrays(GL_POINTS, 0, 4);

Note que tudo o que o geometry shader pode fazer pode ser conseguido de outras formas, mas sua habilidade de gerar formas geométricas a partir de uma pequena entrada de dados permite reduzir o uso da banda CPU -> GPU.

Configuração

Vamos começar escrevendo um código simples que apenas desenha 4 pontos vermelhos na tela.

// Vertex shader
const char* vertexShaderSrc = GLSL(
    in vec2 pos;
    void main() {
        gl_Position = vec4(pos, 0.0, 1.0);
    }
);
// Fragment shader
const char* fragmentShaderSrc = GLSL(
    out vec4 outColor;
    void main() {
        outColor = vec4(1.0, 0.0, 0.0, 1.0);
    }
);

Começamos pela declaração de dois shaders bem simples na parte superior do arquivo. O vertex shader simplesmente passa adiante o atributo position de cada ponto e o fragment shader sempre retorna vermelho. Nada de especial aqui.

Aqui fizemos uso de uma macro GLSL muito conveniente. Ela tem a seguinte definição:

#define GLSL(src) "#version 150 core\n" #src

Isto é muito mais conveniente do que usar a sintaxe de múltiplas strings de antes. Fique ciente de que nova linhas são ignoradas, sendo essa a razão pela qual a diretiva de pré-processador #version está separada.

Vamos também adicionar uma função auxiliar e compilar um shader:

GLuint createShader(GLenum type, const GLchar* src) {
    GLuint shader = glCreateShader(type);
    glShaderSource(shader, 1, &src, nullptr);
    glCompileShader(shader);
    return shader;
}

Na função main, crie um janela e um contexto OpenGL com a biblioteca de sua escolha e inicialize o GLEW. Os shaders devem em seguida ser compilados e ativados:

GLuint vertexShader = createShader(GL_VERTEX_SHADER, vertexShaderSrc);
GLuint fragmentShader = createShader(GL_FRAGMENT_SHADER, fragmentShaderSrc);
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glUseProgram(shaderProgram);

Depois disso, crie um buffer para guardar as coordenadas dos pontos:

GLuint vbo;
glGenBuffers(1, &vbo);
float points[] = {
    -0.45f,  0.45f,
     0.45f,  0.45f,
     0.45f, -0.45f,
    -0.45f, -0.45f,
};
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(points), points, GL_STATIC_DRAW);

Nós temos 4 pontos aqui, cada um com coordenadas de dispositivo x e y. Lembre que essas coordenadas de dispositivo variam de -1 a 1 da esquerda para direita e de baixo para cima, assim cada canto terá um ponto.
Em seguida crie um VAO e configure a especificação do formato dos vértices:

// Create VAO
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
// Specify layout of point data
GLint posAttrib = glGetAttribLocation(shaderProgram, "pos");
glEnableVertexAttribArray(posAttrib);
glVertexAttribPointer(posAttrib, 2, GL_FLOAT, GL_FALSE, 0, 0);

Finalmente, o loop de renderização:

glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glDrawArrays(GL_POINTS, 0, 4);

Com esse código, você deve visualizar 4 pontos vermelhos na tela, sob um fundo preto, como mostrado abaixo:

Se encontrar problemas para fazer isso funcionar, dê uma olhada co código de referência.

Geometry shader básico

Para entender como um geometry shader funciona, vamos dar uma olhada em exemplo:

layout(points) in;
layout(line_strip, max_vertices = 2) out;
void main() {
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    EndPrimitive();
}

Tipos de entrada

Enquanto um vertex shader processo vértices e um fragment shader processa fragmentos, um geometry shaser processa primitivas. A primeira linha descreve que tipo de primitiva nosso shader deve processar.

layout(points) in;

Os tipos disponíveis são listados abaixo, com seus tipos de comandos de desenho equivalentes:

  • points – GL_POINTS (1 vértice)
  • lines – GL_LINES, GL_LINE_STRIP, GL_LINE_LIST (2 vértices)
  • lines_adjacency – GL_LINES_ADJACENCY, GL_LINE_STRIP_ADJACENCY (4 vértices)
  • triangles – GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN (3 vértices)
  • triangles_adjacency – GL_TRIANGLES_ADJACENCY, GL_TRIANGLE_STRIP_ADJACENCY (6 vértices)

Como nós desenhamos GL_POINTS, o tipo points é apropriado.

Tipos de saída

A próxima linha descreve a saída do shader. O que é interessante sobre o geometry shader é que eles podem retornar um tipo inteiramente diferente de forma geométrica e o número de primitivas pode até variar!

layout(line_strip, max_vertices = 2) out;

A segunda linha especifica o tipo de saída e a quantidade máxima de vértices que podem ser retornados. Isso é a quantidade máxima para a invocação do shader, não para uma única primitiva (line_strip, nesse caso).
Os seguintes tipos de saída estão disponíveis:

  • points
  • line_strip
  • triangle_strip

Esses tipos parecem bastantes restritivos, mas se pensar um pouco sobre isso, esses tipos são suficientes para desenhar todos os tipos de primitivas. Por exemplo, um triangle_strip com apenas 3 vértices é equivalente à um triângulo normal.

Entrada de vértices

A variável gl_Position, da forma como foi configurada no vertex shader, pode ser acessada usando o array gl_in do geometry shader. Ele é um array cuja estrutura se parece com isso:

in gl_PerVertex
{
    vec4 gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
} gl_in[];

Note que os atributos do vértice como poscolor não estão incluídos; iremos ver como acessa-los mais tarde.

Saída de vértices

O programa do geometry shader pode chamar duas funções especiais ara gerar primitivas, EmitVertexEndPrimitive. A cada vez que o programa chama EmitVertex, um vértice é adicionada à primitiva atual. Quando todos os vértices forem adicionados, o programa chama EndPrimitive para gerar a primitiva.

void main() {
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    EndPrimitive();
}

Antes de chamar EmitVertex, os atributos do vértices devem ser associados à variáveis como gl_Position, como é feito no vertex shader. Veremos como configurar atributos como color para o fragment shader mais tarde.
Agora que você sabe o significado de cada linha, pode explicar o que esse geometry shader faz?

Cria uma única linha horizontal para cada coordenada passada.

Criando um geometry shader

Não há muito a ser explicado, geometry shaders são criados e ativados exatamente da mesma forma que os outros tipos de shaders. Vamos adicionar um geometry shader ao nosso exemplo com 4 pontos que ainda não faz nada.

const char* geometryShaderSrc = GLSL(
    layout(points) in;
    layout(points, max_vertices = 1) out;
    void main() {
        gl_Position = gl_in[0].gl_Position;
        EmitVertex();
        EndPrimitive();
    }
);

Esse geometry shader deve ser bem direto. Para cada ponto que é informado, ele gera um ponto equivalente. Esse é o mínimo de código necessário para exibir os pontos na tela.
Com a função auxiliar, criar um geometry shader é fácil:

GLuint geometryShader = createShader(GL_GEOMETRY_SHADER, geometryShaderSrc);

Não há nada especial sobre anexar o programa de shader:

glAttachShader(shaderProgram, geometryShader);

Quando você executa o programa agora, ainda deve ser mostrado os pontos. Você pode verificar que o geometry shader está sendo usado agora removendo o código da função main. Você verá que nenhum ponto é desenhado porque nenhum está sendo gerado.
Agora, tente substituir o código do geometry shader pelo código de geração de uma linha da seção anterior:

layout(points) in;
layout(line_strip, max_vertices = 2) out;
void main() {
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    EndPrimitive();
}

Mesmo sem fazer nenhuma alteração em nossa chamada para a função de senho, a GPU está de repente desenhado linhas ao invés de pontos.

Tente experimentar um pouco agora. Por exemplo, tente retornar retângulos pelo uso de triangle_strip.

Geometry shaders e atributos de vértices

Vamos adicionar algumas variações nas linhas que são desenhadas permitindo que elas tenhas um cor única. Ao adicionar a variável de entrada de cor no vertex shader, podemos especificar uma cor por vértice e dessa forma por linha gerada.

in vec2 pos;
in vec3 color;
out vec3 vColor; // Output to geometry (or fragment) shader
void main() {
    gl_Position = vec4(pos, 0.0, 1.0);
    vColor = color;
}

Atualize a especificação do vértice no código do programa:

GLint posAttrib = glGetAttribLocation(shaderProgram, "pos");
glEnableVertexAttribArray(posAttrib);
glVertexAttribPointer(posAttrib, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), 0);
GLint colAttrib = glGetAttribLocation(shaderProgram, "color");
glEnableVertexAttribArray(colAttrib);
glVertexAttribPointer(colAttrib, 3, GL_FLOAT, GL_FALSE,
                      5 * sizeof(float), (void*) (2 * sizeof(float)));

E atualize os dados dos pontos para incluir uma cor RGB por ponto:

float points[] = {
    -0.45f,  0.45f, 1.0f, 0.0f, 0.0f, // Red point
     0.45f,  0.45f, 0.0f, 1.0f, 0.0f, // Green point
     0.45f, -0.45f, 0.0f, 0.0f, 1.0f, // Blue point
    -0.45f, -0.45f, 1.0f, 1.0f, 0.0f, // Yellow point
};

Como o vertex shader não é mais seguido pelo fragment shader, mas pelo geometry shader, temos que incluir a variável vColor como entrada.

layout(points) in;
layout(line_strip, max_vertices = 2) out;
in vec3 vColor[]; // Output from vertex shader for each vertex
out vec3 fColor; // Output to fragment shader
void main() {
    ...

Você pode ver como isso é bastante similar a como a entrada de dados é tratada no fragment shader. A única diferença é que as entrada precisam ser arrays agora, pois o geometry shader pode receber primitivas com múltiplos vértices como entrada, cada um com seus próprios atributos.
Como a cor precisa ser passada adiante para o fragment shader, adicionamos ela à saída do geometry shader. Nós podemos agora associar valores à ela, como fizemos antes com gl_Postion.

void main() {
    fColor = vColor[0]; // Point has only one vertex
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.1, 0.0, 0.0);
    EmitVertex();
    gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.1, 0.0, 0.0);
    EmitVertex();
    EndPrimitive();
}

Sempre que EmitVertex é chamada agora, um vértice é emitido com o valor atual de fColor como atributo de cor. Podemos acessar esse atributo no fragment shader agora:

in vec3 fColor;
out vec4 outColor;
void main() {
    outColor = vec4(fColor, 1.0);
}

Assim, quando você especifica um atributo para um vértice, ele é primeiro passado para o vertex shader. O vertex shader pode então escolher se passa ele para o geometry shader. E então o geometry shader pode escolher se passa diante este valor para o fragment shader.

Porém, esse exemplo não é muito interessante. Poderiamos facilmente replicar esse comportamento pela criação de um buffer com uma única linha e criando algumas chamadas da função de desenho com cores e posições diferentes configurada com variáveis uniform.

Geometria gerada dinâmicamente

O poder real do geometry shader reside na habilidade de gerar uma variada quantidade de primitivas, então vamos ver um exemplo que use apropriadamente essa habilidade.
Vamos dizer que você está criando um jogo onde o mundo consiste de círculos. Você poderia desenhar um único model de um circulo e desenha-lo repetidamente, mas essa abordagem não é a ideal.  Se estiver muito perto, esses “círculos” irá parecer com polígonos feios e se estiver muito distante, sua placa gráfica está desperdiçando performance na renderização de coisa complexas que você não está vendo.
Podemos fazer melhor com geometry shaders! Podemos escrever um shader que gere o círculo na resolução apropriada baseado nas condições durante a execução. Vamos primeiro modificar o geometry shader para  desenhar um polígono de 10 lados para cada ponto. Se você se lembra da trigonometria, isso deve ser muito fácil:

layout(points) in;
layout(line_strip, max_vertices = 11) out;
in vec3 vColor[];
out vec3 fColor;
const float PI = 3.1415926;
void main() {
    fColor = vColor[0];
    for (int i = 0; i <= 10; i++) {
        // Angle between each side in radians
        float ang = PI * 2.0 / 10.0 * i;
        // Offset from center of point (0.3 to accomodate for aspect ratio)
        vec4 offset = vec4(cos(ang) * 0.3, -sin(ang) * 0.4, 0.0, 0.0);
        gl_Position = gl_in[0].gl_Position + offset;
        EmitVertex();
    }
    EndPrimitive();
}

O primeiro ponto é repetido até fechar o loop da linha, por isso 11 vértices são desenhados. O resultado é o esperado:

Agora é trivial adicionar os atributos de vértices para controlar a quantidade de lados. Adicione os novo atributos aos dados e à especificação:

float points[] = {
//  Coordinates  Color             Sides
    -0.45f,  0.45f, 1.0f, 0.0f, 0.0f,  4.0f,
     0.45f,  0.45f, 0.0f, 1.0f, 0.0f,  8.0f,
     0.45f, -0.45f, 0.0f, 0.0f, 1.0f, 16.0f,
    -0.45f, -0.45f, 1.0f, 1.0f, 0.0f, 32.0f
};
...
// Specify layout of point data
GLint posAttrib = glGetAttribLocation(shaderProgram, "pos");
glEnableVertexAttribArray(posAttrib);
glVertexAttribPointer(posAttrib, 2, GL_FLOAT, GL_FALSE,
                      6 * sizeof(float), 0);
GLint colAttrib = glGetAttribLocation(shaderProgram, "color");
glEnableVertexAttribArray(colAttrib);
glVertexAttribPointer(colAttrib, 3, GL_FLOAT, GL_FALSE,
                      6 * sizeof(float), (void*) (2 * sizeof(float)));
GLint sidesAttrib = glGetAttribLocation(shaderProgram, "sides");
glEnableVertexAttribArray(sidesAttrib);
glVertexAttribPointer(sidesAttrib, 1, GL_FLOAT, GL_FALSE,
                      6 * sizeof(float), (void*) (5 * sizeof(float)));

Altere o vertex shader para passar o valor ao geometry shader:

in vec2 pos;
in vec3 color;
in float sides;
out vec3 vColor;
out float vSides;
void main() {
    gl_Position = vec4(pos, 0.0, 1.0);
    vColor = color;
    vSides = sides;
}

E use a variável no geometry shader ao invés do número mágico de lados, 10.0. è necessário também configurar um valor apropriado para max_vertices para nossa entrada, senão os círculos com mais vértices serão cortados.

layout(line_strip, max_vertices = 64) out;
...
in float vSides[];
...
// Safe, floats can represent small integers exactly
for (int i = 0; i <= vSides[0]; i++) {
        // Angle between each side in radians
        float ang = PI * 2.0 / vSides[0] * i;
        ...

Agora podemos criar círculos com qualquer quantidade de lados desejada simplesmente adicionando mais pontos!
End result
Sem o geometry shader, teriamos que reconstruir todo o buffer de vértices sempre que esses círculos tivesses que ser alterados, agora podemos simplesmente alterar o valor do atributo do vértice. Em um jogo, esse atributo poderia ser alterado baseado na distância do jogador como descrito acima. Você pode encontrar o código completo aqui.

Conclusão

Geomtry shader podem não ter tantos uso no mundo real quanto coisas como framebuffer e texturas, mas eles podem definitivamente ajudar na criação de conteúdo na GPU como mostrado aqui.
Se você precisa repetir um único mesh várias vezes, como um cubo em um jogo de voxel, você poderia criar um geometry shader que gera cubos a partir de pontos de um jeito similar. Porém, nesse casos, onde cada mesh gerado é exatamente o mesmo, existem métodos mais eficientes como instancing.
Por fim, em relação à portabilidade, as últimas versões do WebGL e OpenGL ES ainda não suportam geometry shaders, então tenha isso em mente se estiver considerando desenvolver um aplicativo móvel ou web.
Fonte: open.gl/geometry