Agora subindo muito mais o nível, OpenGL e Vulkan são duas APIs que fazem a mesma coisa, mas tem filosófias diferentes. O papel do OpenGL é renderizar, boa parte das implementações são feitas pelo driver, isso significa que você não precisa tocar em conceitos baixo nivel de programação com a GPU (como a requisição de memória e compatibilidade de formatos de imagens da GPU).
Também reconstruido dinâmicamente o pipeline, possibilitando um uso muito mais flexivel (no contexto de APIs e GPUs) da GPU para renderizar, gerenciando os uniforms buffers (dados de qualquer tipo para rapido acesso nas shaders sem precisar reconstruir as shaders), compatibilidade de formatos, configuração do swapchain, e sincronização quase toda feita internamente pelo driver. Por conta do OpenGL tratar internamente a sincronização, não é possivel programar em uma mesma instância do OpenGL em diferentes threads, já que o OpenGL é feito para uma única thread.
Vulkan, diferentemente, trata tudo com command buffer e faz você configurar tudo, desde a swapchain, até a construção do pipeline, sem sincronização interna e apenas externa (dependente da sincronização na aplicação). Por esse motivo, Vulkan é tão low-level quanto DirectX 11 e OpenGL, não podemos dizer do mesmo sobre DirectX 12, embora Vulkan seja ainda mais baixo-nivel que DirectX 12. Mesmo com toda essa dificuldade, a comunidade de Vulkan é muito mais ativa e aos poucos constrói uma melhoria relevante ao uso das efêtivo da GPU.
OpenGL 4 oferece as compute shaders, estágios adicionais que ficam fora da renderização (mas que são executadas na mesma unidade do fragment shader), feitas para computar qualquer tipo de dado, funcionando como um OpenCL ou CUDA nativo, permitindo GPGPU. Vulkan por outro lado, permite muito mais a fundo o uso da API como GPGPU, pois seu runtime e eco-sistema depende da implementação da aplicação.
Outra coisa que devemos entender sobre OpenGL é que ele funciona como uma maquina de estados, e para qualquer comunicação com a GPU, é preciso invocar uma função do OpenGL especificando a sua mudança. Por exemplo, se você quiser alterar as vértices de um modelo 3D (enviar novamente novas vértices), é preciso chamar funções do OpenGL e dizer para o driver apenas trabalhar com determinado estado. Assim você vai poder alterar as vértices, com base nesse estado.
No Vulkan (no DirectX sempre foi dessa maneira) é diferente, ao invés de ser uma maquina de estados, você tem estados como objetos, o nome desse conceito é objet-state based, então você pode guardar informações em formas de objetos e alterar na aplicação de forma flexivel. Desta maneira é possivel trabalhar com diversoso objetos de estados sem sobrecarregar o driver com funções desnececssárias, apenas chamando diretamente a função necessária para tal ação.
No Vulkan, não existe mais JIT, as shaders não são compiladas em tempo real. Você precisa pré-compilar usando SPIR-V e carregar os binários.
Buffer é uma palavra genérica usada para definir qualquer dado enviado a GPU (comun entre as APIs). As imagens, vértices, indices, ou literalmente qualquer array é considerado um buffer, no OpenGL, para trabalhar com qualquer dado é preciso criar um ID que leva a esse buffer, inicialmente, para criar um buffer, é preciso gerar o ID, allocar um espaço necessário para o uso desse buffer e enviar dados usando fill (RAM para VRAM).
Esses mesmos buffers gerados, vão ser usados para qualquer coisa na GPU, no OpenGL, é muito mais dinâmico, enquanto no Vulkan é muito mais complexo e de baixo-nivel.
// Para gerar um buffer dentro da GPU,
// precisamos definir um id.
glGenBuffers(1, &this->buffer_resources_id);
glGenBuffers(1, &this->buffer_indices_id);
glGenVertexArrays(1, &this->list_buffer_id);
float resources[8] {
0.0f, 0.0f,
1.0f, 0.0f,
0.0f, 1.0f,
1.0f, 1.0f
};
uint8_t indices[6] {
0, 1, 3,
3, 2, 0
};
// Emm especifico, esse ID referencia uma lista de outros
// buffers. Essa lista de buffers vai ser ligada no programa
// de shader.
glBindVertexArray(this->list_buffer_id);
// Esse ID vai ser encarregado de referenciar,
// toda vez que for preciso mexer com esse buffer,
// é preciso ligar o buffer no código para usar ele.
glBindBuffer(GL_ARRAY_BUFFER, this->buffer_resources_id);
// Nas APIs modernas, para enviar dados para esse buffer,
// é por uma série de etapas. No OpenGL, não é necessario essas
// etapas, mas vamos fazer aqui.
// Primeiro alocamos um espaço dentro da VRAM da GPU.
glBufferData(GL_ARRAY_BUFFER, sizeof(resources), nullptr, GL_STATIC_DRAW);
// Agora damos "fill", preenchendo o espaço vazio com os dados
// que queremos enviar.
// Stride é um conceito de memória, muito usado nas APIs.
// initial offset stride index é a posição da lista que vai
// começar a preencher.
// size index byte é a quantidade em diante que vai ser preenchida.
uint64_t initial_offset_index_byte = 0;
uint64_t size_index_byte = sizeof(resources);
glBufferSubData(GL_ARRAY_BUFFER, initial_offset_index_byte, size_index_byte, resources);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, (void*) 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, this->buffer_indices_id);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glBindVertexArray(0);
Para renderizar um objeto na tela, não basta apenas ter buffers de vértices, mas o que fazer com esses buffers, é preciso criar um programa carregando todas as shaders necessárias para cada estágio - como dito no cap. passado sobre o graphics pipeline -, uma coisa importante, é saber gerenciar esses buffers, pois todo buffer eventualmente pesa na memória da GPU.
Uma shader é um pequeno software encarregado de receber uma informação e gerar uma nova informação (input and output). O fetch de shader, é uma abstração que usa das threads da GPU para computar e executar diversas shaders ao mesmo tempo de uma única vez.
Para entender como ocorre o runtime e fetch de shaders na GPU, vamos imaginar que queremos somar 2 vetores. Note que isso não diz sobre a complexidade do funcionamento de um microprocessador stream (como explicado no primeiro cap), esse é só um exemplo para explicar o fetch de shaders dentro da abstração do driver na execução de shaders usando as threads da GPU.
Para comparar com a CPU, na CPU levaria 3 instruções.
a = (2, 3)
b = (4, 3)
c = a + b = (2 + 4, 3 + 3) = (6, 6) // levou 3 "instruções"
Na GPU, o fetch de "instruções" seria feito em stream de uma única vez:
c = (2, 3) + (4, 3) = (6, 6) // em uma unica "instrução"
O fetch de shader funciona dessa maneira, mas isso é feito ao mesmo tempo que várias outras shaders são executadas de uma única vez, em um unico fetch, paralelamente a outros threads de shaders. Por conta disso, não conseguimos guardar atômicamente valores entre shaders do mesmo tipo, pois enquanto um bloco de dados são executados, outros blocos são executados paralelamente, sem saber os dados daquele fetch de shaders no bloco especifico.
Apenas entre estágios, mas isso fica limitado apenas ao estágio anterior e não a execução da mesma shader.
Cada grupo de threads executa uma shader por thread de uma única vez.
As linguagens de sombreamento contém primitivos matemáticos e podem fazer operações matemáticas de algebra linear, como multiplicação, adição, subtração e divisão de vetores. Operações de vetores e matrizes ocorrem majoritariamente no processo de geometria da renderização. O espaço normalizado é uma medida que a GPU utiliza para conseguir rasterizar as formas 3D em fragmento de pixels. Todo esse trabalho de projeção é por meio de transformação. Em computação gráfica e algebra linear, a transformação ocorre quando construimos matrizes (por meio de multiplicação ou adição), essas matrizes são perspectiva, look at e modelo.
Um código em GLSL exemplificando isso.
#version 450 // versão da linguagem de shader
layout (binding = 0) in vec2 aPos; // vértices atribuidas pela aplicação na CPU (seu game).
uniform mat4 uMatrixOrtho; // uniform de dados, que podem ser alterados rapidamente com um custo SUPER pequeno, usada aqui nesse estágio.
// entry point do código
void main() {
// gl_Position define a posição da vértice em questão.
gl_Position = uMatrixOrtho * vec4(aPos.x, aPos.y, 0.0f, 1.0f);
// para multiplicar mat4x4 só é possivel com vec4, no final desse valor é definido 1.0f, pois isso se trata do depth buffer.
}
Agora como uma shader se comporta no fragment.
#version 450 // versão da linguagem de shader
layout (location = 0) out vec4 vFragColor; // cor do final do fragmento
uniform vec4 uColor; // uniform para definir a cor antes de um draw call.
// entry point do código
void main() {
vFragColor = uColor; // atribuimos a cor do fragmento
}
Por exemplo, perspectiva ortográfica é uma matriz 4x4, usada para transformar objetos 3D em objetos planos de duas dimensão. Essa matriz transforma todas as vértices de um desenho em 3D, em 2D, essa matriz não necessáriamente precisa de uma câmera, pois sua câmera é fixa, para cada objeto na tela, se quisermos rotacionar um quadrado, podemos montar uma matriz de rotação e multiplicar pela projeção ortográfica (perspectiva, câmera).
Draw call é uma operação nas APIs, encarregada de dar um sinal para utilizar as informações atuais ligadas já definidas pela aplicação, essas informações podem ser uma quantidade de buffers, metadados, shaders, texturas, uniforms etc.
Assim que enviado, existe uma necessidade de sincronização, entretanto, no OpenGL isso não é necessário, por ser de alto nivel, semaphores/semáforos (conceito de sincronização entre GPU e CPU) não são obrigatórios. Após o sinal, a GPU deve dar inicio a renderização, iniciando o pipeline gráfico definidos pelo contexto.
Como são feito a iluminação de objetos na tela e interação da luz?
Essa é uma questão super íncrivel, ao mesmo tempo que temos tecnologias como path tracing em tempo real acelerado por GPU, os jogos aindam utilizam métodos matemáticos como equações de renderização por observação impirica da luz.
Phong reflection model é um modelo matemático desenvolvida por Bui Toung Phong na univerdade de Utah. De forma resumida, a sua importância é devida a sua compreensão da interação da luz com uma surperfice, usando operações de vetores em linguagens de shaders e calculando aspectos como albedo (distruibuição da luz) e specular (parte brilhante) em uma face polignal.
A medida que novas implementações e modificações foram surgindo, como no caso do Blinn-Phong que acrescenta uma leve modificação na equação de renderização original (Phong), diversas pessoas foram contribuindo para o surgimento eminente de uma umbrela de conceitos aproximados da física. PBR é uma umbrela de conceitos que busca aproximar a interação da luz mais fíeis a física. Por isso é chamado de Physical-Based-Rendering.
Desses conceitos, surgiram o BRDF, BSSDF, BSDF e conceitos como surface-scattering (BSSDF).
Bidirectional Reflection Distribution Function (BRDF) é uma equação MUITO utilizada nos jogos atuais, e pela sua natureza consegue trazer aspectos que o blin-phong não tinha, como conservação de energia.
Bidirectional Subsurface-Scattering Distribution Function (BSSDF) é uma equação muito usada em filmes, a Dinsey tem uma modificação do BSDF e BRDF que é muito usado nas animações.
Ray-tracing em tempo real é possivel pelas técnologias e pela especialização dos núcleos para determinadas shaders. Em GPUs mais recentes, podemos criar shaders para trabalhar com a inserção da luz e simular traços de raios usando a GPU para computar esses calculos.
É importante citar, que ray-tracing é um conceito já muito velho, desde o ano 90s existia alguns programas pela internet, aonde você poderia brincar com o ray-tracing offline, a medida que o poder computacional da GPU foi evoluindo, a possibilidade de ray-tracing ser implementando em tempo real, foi acontecendo, novos conceitos como path-tracing, também surgiram.
Ray-tracing ainda é um processamento muito pesado, mas existe um grande salto computacional atualmente, pois ray-tracing é de fato algo muito incrível, e não temos como desconsiderar esse avanço.