GPU Ray Tracing en Unity - Parte 3

[ La primera y segunda parte.]


Hoy daremos un gran salto. Nos alejaremos de las estructuras exclusivamente esféricas y del plano infinito que trazamos anteriormente, y agregaremos triángulos: toda la esencia de los gráficos modernos por computadora, un elemento en el que consisten todos los mundos virtuales. Si desea continuar con lo que terminamos la última vez, use el código de la parte 2 . El código final de lo que haremos hoy está disponible aquí . ¡Empecemos!

Triángulos


Un triángulo es solo una lista de tres vértices conectados, cada uno de los cuales mantiene su posición, y algunas veces es normal. El orden de recorrido de los vértices desde su punto de vista determina lo que estamos viendo: la cara frontal o posterior del triángulo. Tradicionalmente, el "frente" se considera el orden de derivación en sentido antihorario.

Primero, debemos ser capaces de determinar si el rayo se cruza con un triángulo, y si es así, en qué punto. En 1997, los señores Thomas Akenin-Meller y Ben Trembor propusieron un algoritmo muy popular (pero ciertamente no el único ) para determinar la intersección de un rayo con un triángulo. Puede leer más al respecto en su artículo "Intersección de triángulo-rayo de almacenamiento mínimo y rápido" aquí .

El código del artículo se puede portar fácilmente al código de sombreador 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 función, necesitamos un rayo y tres vértices de un triángulo. El valor de retorno nos dice si el triángulo se cruzó. En caso de intersección, se calculan tres valores adicionales: t describe la distancia a lo largo de la viga hasta el punto de intersección, y u / v son dos de las tres coordenadas barcéntricas que determinan la ubicación del punto de intersección en el triángulo (la última coordenada se puede calcular como w = 1 - u - v ). Si aún no está familiarizado con las coordenadas barcéntricas , lea su excelente explicación en Scratchapixel .

Sin demasiado retraso, tracemos un triángulo con los vértices indicados en el código. Busque la función Trace en el sombreador y agregue el siguiente 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 dije, t almacena la distancia a lo largo de la viga, y podemos usar directamente este valor para calcular el punto de intersección. Lo normal, que es importante para calcular la reflexión correcta, se puede calcular utilizando el producto vectorial de cualquiera de los dos bordes del triángulo. Inicie el modo de juego y admire su primer triángulo trazado:


Ejercicio: intente calcular la posición utilizando coordenadas barcéntricas en lugar de la distancia. Si haces todo bien, el triángulo brillante se verá exactamente como antes.

Mallas triangulares


Superamos el primer obstáculo, pero rastrear mallas enteras desde triángulos es una historia completamente diferente. Primero necesitamos aprender información básica sobre mallas. Si los conoce, puede saltarse el siguiente párrafo de forma segura.

En gráficos de computadora, la malla está definida por varios búferes, los más importantes son los búferes de vértices e índices . El búfer de vértices es una lista de vectores 3D que describe la posición de cada vértice en el espacio del objeto (esto significa que dichos valores no necesitan cambiarse al mover, rotar o escalar un objeto; se convierten del espacio del objeto al espacio mundial sobre la marcha utilizando la multiplicación de matrices) . Un búfer de índice es una lista de valores enteros que son índices que apuntan al búfer de vértices. Cada tres índices forman un triángulo. Por ejemplo, si el búfer de índice tiene la forma [0, 1, 2, 0, 2, 3], entonces tiene dos triángulos: el primer triángulo consiste en el primer, segundo y tercer vértices en el búfer de vértices, y el segundo triángulo consiste en el primero, tercero y cuarto picos. Por lo tanto, el búfer de índice también determina el orden transversal mencionado anteriormente. Además de los búferes e índices de vértices, puede haber búferes adicionales que agreguen otra información a cada vértice. Los buffers adicionales más comunes almacenan normales , coordenadas de textura (llamadas texcoords o simplemente UV ), así como colores de vértice .

Usando GameObjects


En primer lugar, necesitamos descubrir qué GameObjects deberían formar parte del proceso de trazado de rayos. Una solución ingenua sería simplemente usar FindObjectOfType<MeshRenderer>() , pero hacer algo más flexible y rápido. RayTracingObject un nuevo 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); } } 

Este componente se agrega a cada objeto que queremos usar para el trazado de rayos y se involucra en su registro utilizando RayTracingMaster . Agregue las siguientes funciones al asistente:

 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; } 

Todo va bien, ahora sabemos qué objetos deben rastrearse. Pero luego viene la parte difícil: vamos a recopilar todos los datos de las mallas de Unity (matriz, búferes de vértices e índices, ¿los recuerda?), Los escribimos en nuestras propias estructuras de datos y los cargamos en la GPU para que el sombreador pueda usarlos. Comencemos definiendo estructuras de datos y memorias intermedias en el lado de C #, en el asistente:

 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; 

... y ahora hagamos lo mismo en el sombreador. ¿Estás acostumbrado?

 struct MeshObject { float4x4 localToWorldMatrix; int indices_offset; int indices_count; }; StructuredBuffer<MeshObject> _MeshObjects; StructuredBuffer<float3> _Vertices; StructuredBuffer<int> _Indices; 

Las estructuras de datos están listas y podemos llenarlas con datos reales. Recopilamos todos los vértices de todas las mallas en una gran List<Vector3> , y todos los índices en una gran List<int> . No hay problemas con los vértices, pero los índices deben cambiarse para que continúen apuntando al vértice correcto en nuestro búfer grande. Imagine que ya hemos agregado objetos de 1000 vértices, y ahora agregamos un cubo de malla simple. El primer triángulo puede consistir en índices [0, 1, 2], pero como ya teníamos 1000 vértices en el búfer, necesitamos cambiar los índices antes de agregar vértices al cubo. Es decir, se convertirán en [1000, 1001, 1002]. Así es como se ve en el 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); } 

Llame a RebuildMeshObjectBuffers en la función OnRenderImage y no olvide liberar nuevos buffers en OnDisable . Aquí hay dos funciones auxiliares que utilicé en el código anterior para simplificar un poco el manejo del búfer:

 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); } } 

¡Genial, creamos buffers y están llenos de los datos necesarios! Ahora solo tenemos que informar esto al sombreador. Agregue el siguiente código a SetShaderParameters (y gracias a las nuevas funciones auxiliares, podemos reducir el código del búfer de esfera):

 SetComputeBuffer("_Spheres", _sphereBuffer); SetComputeBuffer("_MeshObjects", _meshObjectBuffer); SetComputeBuffer("_Vertices", _vertexBuffer); SetComputeBuffer("_Indices", _indexBuffer); 

Por lo tanto, el trabajo es aburrido, pero veamos lo que acabamos de hacer: recolectamos todos los datos internos de las mallas (matriz, vértices e índices), los colocamos en una estructura conveniente y simple, y luego los enviamos a la GPU, que ahora esperamos cuando Pueden ser utilizados.

Trazado de malla


No lo hagamos esperar. En el sombreador, ya tenemos el código de traza de un triángulo individual, y la malla es, de hecho, solo muchos triángulos. El único aspecto nuevo aquí es que usamos una matriz para transformar vértices desde el espacio del objeto al espacio mundial usando la función mul incorporada (abreviatura de multiplicar). La matriz contiene la traslación, rotación y escala del objeto. Tiene un tamaño de 4 × 4, por lo que para la multiplicación necesitamos un vector 4d. Los primeros tres componentes (x, y, z) se toman del búfer de vértices. Establecemos el cuarto componente (w) en 1 porque estamos tratando con un punto. Si esta fuera la dirección, entonces escribiríamos 0 para ignorar todas las traducciones y la escala en la matriz. ¿Es esto confuso para ti? Luego lea este tutorial al menos ocho veces. Aquí está el código del sombreador:

 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 a solo un paso de verlo todo en acción. Reestructuramos un poco la función Trace y agreguemos un rastro de objetos de malla:

 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


Eso es todo! RayTracingObject algunas mallas simples (las primitivas de Unity están bien), dele el componente RayTracingObject y observe la magia. ¡No use mallas detalladas todavía (más de unos pocos cientos de triángulos)! Nuestro sombreador carece de optimización, y si se excede, puede tomar segundos o incluso minutos rastrear al menos una muestra por píxel. Como resultado, el sistema detendrá el controlador de la GPU, el motor de Unity puede fallar y la computadora deberá reiniciarse.


Tenga en cuenta que nuestras mallas no tienen un sombreado suave sino plano. Como todavía no hemos cargado las normales de los vértices en el búfer, para obtener la normal de los vértices de cada triángulo, debemos realizar un producto vectorial. Además, no podemos interpolar sobre el área del triángulo. Abordaremos este problema en la siguiente parte del tutorial.

En aras del interés, descargué el Stanford Bunny del archivo Morgan McGwire y, utilizando el modificador de diezmado del paquete Blender , reduje el número de vértices a 431. Puede experimentar con parámetros de iluminación y material codificado en la función de sombreado IntersectMeshObject . Aquí hay un conejo dieléctrico con hermosas sombras suaves y un poco de iluminación global difusa en Grafitti Shelter :


... y aquí hay un conejo de metal bajo la fuerte luz direccional de Cape Hill , arrojando un resplandor de disco en el plano del piso:


... y aquí hay dos conejitos escondidos debajo de la gran piedra Suzanne bajo el cielo azul Kiara 9 Dusk (prescribí material alternativo para el segundo objeto, verificando si el cambio de índice es cero):


Que sigue


Es genial ver una malla real en tu propio marcador por primera vez, ¿verdad? Hoy procesamos algunos datos, descubrimos la intersección utilizando el algoritmo Meller-Trambor y recopilamos todo para que pudiéramos usar de inmediato el motor GameObjects del motor Unity. Además, vimos una de las ventajas del trazado de rayos: tan pronto como agrega una nueva intersección al código, todos los hermosos efectos (sombras suaves, iluminación global reflejada y difusa, etc.) comienzan a funcionar de inmediato.

Renderizar un conejo brillante tomó mucho tiempo, y todavía tuve que usar un poco de filtrado para deshacerme del ruido más obvio. Para resolver este problema, una escena generalmente se escribe en una estructura espacial, por ejemplo, en una cuadrícula, un árbol K-dimensional o una jerarquía de volúmenes delimitadores, lo que aumenta significativamente la velocidad de representación de escenas grandes.

Pero tenemos que movernos en orden: además eliminaremos el problema con las normales para que nuestras mallas (incluso las de baja poli) se vean más suaves que ahora. También sería bueno actualizar automáticamente las matrices al mover objetos y referirse directamente a los materiales de Unity, y no solo escribirlos en el código. Esto es lo que haremos en la próxima parte de la serie de tutoriales. ¡Gracias por leer y nos vemos en la parte 4!

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


All Articles