[
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) {
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:
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;
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 {
¡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;
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!