Rastreamento de GPU Ray no Unity - Parte 3

[ 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) { // find vectors for two edges sharing vert0 float3 edge1 = vert1 - vert0; float3 edge2 = vert2 - vert0; // begin calculating determinant - also used to calculate U parameter float3 pvec = cross(ray.direction, edge2); // if determinant is near zero, ray lies in plane of triangle float det = dot(edge1, pvec); // use backface culling if (det < EPSILON) return false; float inv_det = 1.0f / det; // calculate distance from vert0 to ray origin float3 tvec = ray.origin - vert0; // calculate U parameter and test bounds u = dot(tvec, pvec) * inv_det; if (u < 0.0 || u > 1.0f) return false; // prepare to test V parameter float3 qvec = cross(tvec, edge1); // calculate V parameter and test bounds v = dot(ray.direction, qvec) * inv_det; if (v < 0.0 || u + v > 1.0f) return false; // calculate t, ray intersects triangle t = dot(edge2, qvec) * inv_det; return true; } 

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:

 // Trace single triangle float3 v0 = float3(-150, 0, -150); float3 v1 = float3(150, 0, -150); float3 v2 = float3(0, 150 * sqrt(2), -150); 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.00f; bestHit.specular = 0.65f * float3(1, 0.4f, 0.2f); bestHit.smoothness = 0.9f; bestHit.emission = 0.0f; } } 

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; // Clear all lists _meshObjects.Clear(); _vertices.Clear(); _indices.Clear(); // Loop over all objects and gather their data foreach (RayTracingObject obj in _rayTracingObjects) { Mesh mesh = obj.GetComponent<MeshFilter>().sharedMesh; // Add vertex data int firstVertex = _vertices.Count; _vertices.AddRange(mesh.vertices); // Add index data - if the vertex buffer wasn't empty before, the // indices need to be offset int firstIndex = _indices.Count; var indices = mesh.GetIndices(0); _indices.AddRange(indices.Select(index => index + firstVertex)); // Add the object itself _meshObjects.Add(new MeshObject() { localToWorldMatrix = obj.transform.localToWorldMatrix, indices_offset = firstIndex, indices_count = indices.Length }); } CreateComputeBuffer(ref _meshObjectBuffer, _meshObjects, 72); CreateComputeBuffer(ref _vertexBuffer, _vertices, 12); CreateComputeBuffer(ref _indexBuffer, _indices, 4); } 

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 { // Do we already have a compute buffer? if (buffer != null) { // If no data or buffer doesn't match the given criteria, release it if (data.Count == 0 || buffer.count != data.Count || buffer.stride != stride) { buffer.Release(); buffer = null; } } if (data.Count != 0) { // If the buffer has been released or wasn't there to // begin with, create it if (buffer == null) { buffer = new ComputeBuffer(data.Count, stride); } // Set data on the buffer buffer.SetData(data); } } private void SetComputeBuffer(string name, ComputeBuffer buffer) { if (buffer != null) { RayTracingShader.SetBuffer(0, name, buffer); } } 

Ó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; // Trace ground plane IntersectGroundPlane(ray, bestHit); // Trace spheres _Spheres.GetDimensions(count, stride); for (i = 0; i < count; i++) { IntersectSphere(ray, bestHit, _Spheres[i]); } // Trace mesh objects _MeshObjects.GetDimensions(count, stride); for (i = 0; i < count; i++) { IntersectMeshObject(ray, bestHit, _MeshObjects[i]); } return bestHit; } 

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!

Source: https://habr.com/ru/post/pt450308/


All Articles