[
A primeira e a
segunda partes.]
Hoje vamos dar um grande salto. Vamos nos afastar de estruturas exclusivamente esféricas e do plano infinito que traçamos anteriormente e adicionar triângulos - toda a essência da computação gráfica moderna, um elemento que consiste em todos os mundos virtuais. Se você quiser continuar com o que terminamos da última vez, use o
código da parte 2 . O código final para o que faremos hoje está disponível
aqui . Vamos começar!
Triângulos
Um triângulo é apenas uma lista de três
vértices conectados, cada um dos quais armazena sua própria posição e, às vezes, normal. A ordem de deslocamento dos vértices do seu ponto de vista determina o que estamos vendo - a frente ou a face traseira do triângulo. Tradicionalmente, a “frente” é considerada a ordem de rotação no sentido anti-horário.
Primeiro, precisamos ser capazes de determinar se o raio cruza um triângulo e, em caso afirmativo, em que ponto. Um algoritmo muito popular (mas certamente
não o único ) para determinar a interseção de um raio com um triângulo foi proposto em 1997 pelos cavalheiros
Thomas Akenin-Meller e Ben Trembor. Você pode ler mais sobre isso em seu artigo "Interseção rápida e mínima com raios-triângulo de armazenamento"
aqui .
O código do artigo pode ser facilmente transportado para o código de shader HLSL:
static const float EPSILON = 1e-8; bool IntersectTriangle_MT97(Ray ray, float3 vert0, float3 vert1, float3 vert2, inout float t, inout float u, inout float v) {
Para usar esta função, precisamos de um raio e três vértices de um triângulo. O valor de retorno nos diz se o triângulo se cruzou. Em caso de interseção, três valores adicionais são calculados:
t
descreve a distância ao longo da viga até o ponto de interseção e
u
/
v
são duas das três coordenadas baricêntricas que determinam a localização do ponto de interseção no triângulo (a última coordenada pode ser calculada como
w = 1 - u - v
). Se você ainda não está familiarizado com as coordenadas
barricêntricas , leia a excelente explicação no
Scratchapixel .
Sem muito atraso, vamos traçar um triângulo com os vértices indicados no código! Localize a função
Trace
no shader e adicione o seguinte fragmento de código:
Como eu disse,
t
armazena a distância ao longo da viga e podemos usar diretamente esse valor para calcular o ponto de interseção. O normal, que é importante para calcular a reflexão correta, pode ser calculado usando o produto vetorial de quaisquer duas arestas do triângulo. Inicie o modo de jogo e admire seu primeiro triângulo traçado:
Exercício: Tente calcular a posição usando coordenadas barricêntricas em vez de distância. Se você fizer tudo certo, o triângulo brilhante será exatamente como antes.
Malhas triangulares
Superamos o primeiro obstáculo, mas traçar malhas inteiras a partir de triângulos é uma história completamente diferente. Primeiro, precisamos aprender algumas informações básicas sobre malhas. Se você os conhece, pode pular com segurança o próximo parágrafo.
Na computação gráfica, a malha é definida por vários buffers, sendo os mais importantes os buffers de
vértice e
índice .
O buffer de vértice é uma lista de vetores 3D que descrevem a posição de cada vértice no
espaço do objeto (isso significa que esses valores não precisam ser alterados ao mover, girar ou dimensionar um objeto - eles são convertidos do
espaço do objeto para o
espaço do
mundo em tempo real usando a multiplicação de matrizes) .
Um buffer de índice é uma lista de valores inteiros que são
índices que apontam para o buffer de vértice. A cada três índices formam um triângulo. Por exemplo, se o buffer de índice tiver a forma [0, 1, 2, 0, 2, 3], ele terá dois triângulos: o primeiro triângulo consistirá nos primeiro, segundo e terceiro vértices no buffer de vértice e o segundo triângulo consistirá no primeiro, terceiro e quarto picos. Portanto, o buffer de índice também determina a ordem de travessia mencionada acima. Além dos buffers e índices de vértices, pode haver buffers adicionais que adicionam outras informações a cada vértice. Os buffers adicionais mais comuns armazenam
normais ,
coordenadas de textura (chamadas
texcoords ou simplesmente
UV ), além de
cores de vértice .
Usando GameObjects
Primeiro de tudo, precisamos descobrir quais GameObjects devem se tornar parte do processo de rastreamento de raios. Uma solução ingênua seria simplesmente usar
FindObjectOfType<MeshRenderer>()
, mas fazer algo mais flexível e rápido. Vamos adicionar um novo componente
RayTracingObject
:
using UnityEngine; [RequireComponent(typeof(MeshRenderer))] [RequireComponent(typeof(MeshFilter))] public class RayTracingObject : MonoBehaviour { private void OnEnable() { RayTracingMaster.RegisterObject(this); } private void OnDisable() { RayTracingMaster.UnregisterObject(this); } }
Esse componente é adicionado a cada objeto que queremos usar para o traçado de raios e é envolvido em seu registro usando o
RayTracingMaster
. Adicione as seguintes funções ao assistente:
private static bool _meshObjectsNeedRebuilding = false; private static List<RayTracingObject> _rayTracingObjects = new List<RayTracingObject>(); public static void RegisterObject(RayTracingObject obj) { _rayTracingObjects.Add(obj); _meshObjectsNeedRebuilding = true; } public static void UnregisterObject(RayTracingObject obj) { _rayTracingObjects.Remove(obj); _meshObjectsNeedRebuilding = true; }
Tudo está indo bem - agora sabemos quais objetos precisam ser rastreados. Mas a parte complicada continua: vamos coletar todos os dados das malhas do Unity (matriz, buffers de vértices e índices - lembra-se deles?), Gravá-los em nossas próprias estruturas de dados e carregá-los na GPU para que o shader possa usá-los. Vamos começar definindo estruturas de dados e buffers no lado do C #, no assistente:
struct MeshObject { public Matrix4x4 localToWorldMatrix; public int indices_offset; public int indices_count; } private static List<MeshObject> _meshObjects = new List<MeshObject>(); private static List<Vector3> _vertices = new List<Vector3>(); private static List<int> _indices = new List<int>(); private ComputeBuffer _meshObjectBuffer; private ComputeBuffer _vertexBuffer; private ComputeBuffer _indexBuffer;
... e agora vamos fazer o mesmo no shader. Você está acostumado?
struct MeshObject { float4x4 localToWorldMatrix; int indices_offset; int indices_count; }; StructuredBuffer<MeshObject> _MeshObjects; StructuredBuffer<float3> _Vertices; StructuredBuffer<int> _Indices;
As estruturas de dados estão prontas e podemos preenchê-las com dados reais. Coletamos todos os vértices de todas as malhas em uma grande
List<Vector3>
e todos os índices em uma grande
List<int>
. Não há problemas com os vértices, mas os índices precisam ser alterados para que continuem apontando para o vértice correto em nosso buffer grande. Imagine que já adicionamos objetos de 1000 vértices e agora adicionamos um cubo de malha simples. O primeiro triângulo pode consistir em índices [0, 1, 2], mas como já tínhamos 1000 vértices no buffer, precisamos mudar os índices antes de adicionar vértices ao cubo. Ou seja, eles se transformarão em [1000, 1001, 1002]. Aqui está o que parece no código:
private void RebuildMeshObjectBuffers() { if (!_meshObjectsNeedRebuilding) { return; } _meshObjectsNeedRebuilding = false; _currentSample = 0;
Chamamos
RebuildMeshObjectBuffers
na função
OnRenderImage
e não se esqueça de liberar novos buffers no
OnDisable
. Aqui estão duas funções auxiliares que usei no código acima para simplificar um pouco o buffer:
private static void CreateComputeBuffer<T>(ref ComputeBuffer buffer, List<T> data, int stride) where T : struct {
Ótimo, criamos buffers e eles são preenchidos com os dados necessários! Agora só precisamos relatar isso ao shader. Adicione o seguinte código ao
SetShaderParameters
(e, graças às novas funções auxiliares, podemos reduzir o código do buffer da esfera):
SetComputeBuffer("_Spheres", _sphereBuffer); SetComputeBuffer("_MeshObjects", _meshObjectBuffer); SetComputeBuffer("_Vertices", _vertexBuffer); SetComputeBuffer("_Indices", _indexBuffer);
Portanto, o trabalho é chato, mas vamos ver o que acabamos de fazer: coletamos todos os dados internos das malhas (matriz, vértices e índices), os colocamos em uma estrutura conveniente e simples e os enviamos para a GPU, que agora espera quando eles podem ser usados.
Rastreamento de malha
Não vamos fazê-lo esperar. No shader, já temos o código de rastreamento de um triângulo individual, e a malha é, de fato, apenas muitos triângulos. O único aspecto novo aqui é que usamos a matriz para transformar os vértices do espaço do objeto para o espaço do mundo usando a função
mul
(abreviação de multiplicar). A matriz contém a translação, rotação e escala do objeto. Ele tem um tamanho de 4 × 4, portanto, para multiplicação, precisamos de um vetor 4d. Os três primeiros componentes (x, y, z) são obtidos do buffer de vértice. Definimos o quarto componente (w) como 1 porque estamos lidando com um ponto. Se essa fosse a direção, escreveríamos 0 nela para ignorar todas as traduções e a escala na matriz. Isso é confuso para você? Em seguida, leia
este tutorial pelo menos oito vezes. Aqui está o código shader:
void IntersectMeshObject(Ray ray, inout RayHit bestHit, MeshObject meshObject) { uint offset = meshObject.indices_offset; uint count = offset + meshObject.indices_count; for (uint i = offset; i < count; i += 3) { float3 v0 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i]], 1))).xyz; float3 v1 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 1]], 1))).xyz; float3 v2 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 2]], 1))).xyz; float t, u, v; if (IntersectTriangle_MT97(ray, v0, v1, v2, t, u, v)) { if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(cross(v1 - v0, v2 - v0)); bestHit.albedo = 0.0f; bestHit.specular = 0.65f; bestHit.smoothness = 0.99f; bestHit.emission = 0.0f; } } } }
Estamos apenas a um passo de ver tudo em ação. Vamos reestruturar um pouco a função
Trace
e adicionar um rastreamento de objetos de malha:
RayHit Trace(Ray ray) { RayHit bestHit = CreateRayHit(); uint count, stride, i;
Resultados
Isso é tudo! Vamos adicionar algumas malhas simples (as primitivas do Unity são boas), fornecer o componente
RayTracingObject
e observar a mágica. Ainda
não use malhas detalhadas (mais de algumas centenas de triângulos)! Nosso shader não possui otimização e, se você exagerar, pode levar alguns segundos ou até minutos para rastrear pelo menos uma amostra por pixel. Como resultado, o sistema interromperá o driver da GPU, o mecanismo do Unity poderá travar e o computador precisará reiniciar.
Observe que nossas malhas não têm sombreamento suave, mas plano. Como ainda não carregamos as normais dos vértices no buffer, para obter a normal dos vértices de cada triângulo, devemos executar um produto vetorial. Além disso, não podemos interpolar sobre a área do triângulo. Lidaremos com esse problema na próxima parte do tutorial.
Por uma questão de interesse, baixei Stanford Bunny do
arquivo Morgan McGwire e, usando o modificador de
dizimação do pacote
Blender , reduzi o número de vértices para 431. Você pode experimentar parâmetros de iluminação e material codificado na função de sombreamento
IntersectMeshObject
. Aqui está um coelho dielétrico com belas sombras suaves e um pouco de iluminação global difusa no
Grafitti Shelter :
... e aqui está um coelho de metal sob a forte luz direcional de
Cape Hill , lançando brilho de discoteca no piso plano:
... e aqui estão dois coelhinhos escondidos sob a grande pedra Suzanne sob o céu azul
Kiara 9 Dusk (prescrevi material alternativo para o segundo objeto, verificando se a mudança de índice é zero):
O que vem a seguir?
É ótimo ver uma malha real em seu próprio marcador pela primeira vez, certo? Hoje processamos alguns dados, descobrimos a interseção usando o algoritmo Meller-Trambor e coletamos tudo para que pudéssemos usar imediatamente o mecanismo GameObjects do mecanismo Unity. Além disso, vimos uma das vantagens do rastreamento de raios: assim que você adiciona uma nova interseção ao código, todos os belos efeitos (sombras suaves, iluminação global refletida e difusa e assim por diante) começam imediatamente a funcionar.
Renderizar um coelho brilhante levou muito tempo, e eu ainda tive que usar um pouco de filtragem para me livrar do barulho mais óbvio. Para resolver esse problema, uma cena geralmente é escrita em uma estrutura espacial, por exemplo, em uma grade, em uma árvore K-dimensional ou em uma hierarquia de volumes delimitadores, o que aumenta significativamente a velocidade de renderização de grandes cenas.
Mas precisamos avançar em ordem: além disso, eliminaremos o problema das normais, para que nossas malhas (mesmo as de baixo poli) pareçam mais suaves do que agora. Também seria bom atualizar matrizes automaticamente ao mover objetos e se referir diretamente aos materiais do Unity, e não apenas escrevê-los no código. É isso que faremos na próxima parte da série de tutoriais. Obrigado pela leitura e até a parte 4!