Traçage des rayons GPU dans Unity - Partie 3

[ Les première et deuxième parties.]


Aujourd'hui, nous allons faire un grand saut. Nous nous éloignerons des structures exclusivement sphériques et du plan infini que nous avons tracé plus tôt, et ajouterons des triangles - l'essence même de l'infographie moderne, un élément qui consiste en tous les mondes virtuels. Si vous souhaitez continuer avec ce que nous avons terminé la dernière fois, utilisez le code de la partie 2 . Le code final de ce que nous ferons aujourd'hui est disponible ici . Commençons!

Triangles


Un triangle n'est qu'une liste de trois sommets connectés, chacun d'entre eux stockant sa propre position, et parfois normal. L'ordre de traversée des sommets de votre point de vue détermine ce que nous regardons - le côté avant ou arrière du triangle. Traditionnellement, le «front» est considéré comme l'ordre de contournement dans le sens antihoraire.

Tout d'abord, nous devons être en mesure de déterminer si le rayon intersecte un triangle, et si oui, à quel point. Un algorithme très populaire (mais certainement pas le seul ) pour déterminer l'intersection d'un rayon avec un triangle a été proposé en 1997 par les messieurs Thomas Akenin-Meller et Ben Trembor. Vous pouvez en savoir plus à ce sujet dans leur article «Intersection Ray-Triangle de stockage rapide et minimal» ici .

Le code de l'article peut être facilement porté vers le code 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; } 

Pour utiliser cette fonction, nous avons besoin d'un rayon et de trois sommets d'un triangle. La valeur de retour nous indique si le triangle s'est intersecté. En cas d'intersection, trois valeurs supplémentaires sont calculées: t décrit la distance le long du faisceau jusqu'au point d'intersection, et u / v sont deux des trois coordonnées barycentriques qui déterminent l'emplacement du point d'intersection sur le triangle (la dernière coordonnée peut être calculée comme w = 1 - u - v ). Si vous n'êtes pas encore familier avec les coordonnées barycentriques, alors lisez leur excellente explication sur Scratchapixel .

Sans trop tarder, traçons un triangle avec les sommets indiqués dans le code! Recherchez la fonction Trace dans le shader et ajoutez-y le fragment de code suivant:

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

Comme je l'ai dit, t stocke la distance le long du faisceau, et nous pouvons directement utiliser cette valeur pour calculer le point d'intersection. La normale, qui est importante pour calculer la réflexion correcte, peut être calculée en utilisant le produit vectoriel de deux bords quelconques du triangle. Lancez le mode de jeu et admirez votre premier triangle tracé:


Exercice: Essayez de calculer la position en utilisant les coordonnées barycentriques plutôt que la distance. Si vous faites tout correctement, le triangle brillant ressemblera exactement à ce qu'il était auparavant.

Mailles triangulaires


Nous avons surmonté le premier obstacle, mais tracer des mailles entières à partir de triangles est une histoire complètement différente. Nous devons d'abord apprendre quelques informations de base sur les maillages. Si vous les connaissez, vous pouvez sauter le paragraphe suivant en toute sécurité.

En infographie, le maillage est défini par plusieurs tampons, les plus importants étant les tampons de sommet et d' index . Le tampon de sommet est une liste de vecteurs 3D décrivant la position de chaque sommet dans l'espace objet (cela signifie que ces valeurs n'ont pas besoin d'être modifiées lors du déplacement, de la rotation ou de la mise à l'échelle d'un objet - elles sont converties de l'espace objet en espace mondial à la volée en utilisant la multiplication matricielle) . Un tampon d'index est une liste de valeurs entières qui sont des index qui pointent vers le tampon de vertex. Tous les trois index forment un triangle. Par exemple, si le tampon d'index a la forme [0, 1, 2, 0, 2, 3], alors il a deux triangles: le premier triangle se compose des premier, deuxième et troisième sommets dans le tampon de sommet, et le deuxième triangle se compose du premier, troisième et quatrièmes pics. Par conséquent, le tampon d'index détermine également l'ordre de parcours susmentionné. En plus des tampons et index des sommets, il peut y avoir des tampons supplémentaires qui ajoutent d'autres informations à chaque sommet. Les tampons supplémentaires les plus courants stockent les normales , les coordonnées de texture (appelées texcoords ou simplement UV ), ainsi que les couleurs des sommets .

Utilisation de GameObjects


Tout d'abord, nous devons savoir quels GameObjects devraient faire partie du processus de lancer de rayons. Une solution naïve serait d'utiliser simplement FindObjectOfType<MeshRenderer>() , mais de faire quelque chose de plus flexible et plus rapide. Ajoutons un nouveau composant 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); } } 

Ce composant est ajouté à chaque objet que nous voulons utiliser pour le RayTracingMaster de rayons et est engagé dans leur enregistrement à l'aide de RayTracingMaster . Ajoutez les fonctions suivantes à l'assistant:

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

Tout se passe bien - nous savons maintenant quels objets doivent être tracés. Mais la partie délicate continue: nous allons collecter toutes les données des maillages Unity (matrice, tampons de sommets et index - vous vous en souvenez?), Les écrire dans nos propres structures de données et les charger dans le GPU afin que le shader puisse les utiliser. Commençons par définir les structures de données et les tampons côté C #, dans l'assistant:

 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; 

... et maintenant faisons de même dans le shader. Êtes-vous habitué?

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

Les structures de données sont prêtes et nous pouvons les remplir avec de vraies données. Nous collectons tous les sommets de toutes les mailles dans une grande List<Vector3> , et tous les index dans une grande List<int> . Il n'y a pas de problème avec les sommets, mais les indices doivent être modifiés afin qu'ils continuent de pointer vers le sommet correct dans notre grand tampon. Imaginez que nous avons déjà ajouté des objets à partir de 1000 sommets, et maintenant nous ajoutons un simple cube maillé. Le premier triangle peut être composé d'indices [0, 1, 2], mais comme nous avions déjà 1000 sommets dans le tampon, nous devons déplacer les indices avant d'ajouter des sommets au cube. Autrement dit, ils se transformeront en [1000, 1001, 1002]. Voici à quoi cela ressemble dans le code:

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

Nous appelons RebuildMeshObjectBuffers dans la fonction OnRenderImage , et n'oubliez pas de libérer de nouveaux tampons dans OnDisable . Voici deux fonctions d'assistance que j'ai utilisées dans le code ci-dessus pour simplifier un peu la gestion du tampon:

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

Génial, nous avons créé des tampons et ils sont remplis avec les données nécessaires! Maintenant, nous devons simplement signaler cela au shader. Ajoutez le code suivant à SetShaderParameters (et grâce aux nouvelles fonctions d'assistance, nous pouvons réduire le code du tampon de sphère):

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

Donc, le travail est ennuyeux, mais voyons ce que nous venons de faire: nous avons collecté toutes les données internes des mailles (matrice, sommets et index), les avons placées dans une structure pratique et simple, puis les avons envoyées au GPU, qui attend maintenant avec impatience le moment où ils peuvent être utilisés.

Traçage de maillage


Ne le faisons pas attendre. Dans le shader, nous avons déjà le code de trace d'un triangle individuel, et le maillage est, en fait, juste un grand nombre de triangles. Le seul nouvel aspect ici est que nous utilisons la matrice pour transformer les sommets de l'espace objet en espace monde en utilisant la fonction mul intégrée (abréviation de multiplier). La matrice contient la translation, la rotation et l'échelle de l'objet. Il a une taille de 4 × 4, donc pour la multiplication, nous avons besoin d'un vecteur 4d. Les trois premiers composants (x, y, z) sont extraits du tampon de vertex. Nous mettons la quatrième composante (w) à 1 car nous avons affaire à un point. Si c'était la direction, alors nous écririons 0 pour ignorer toutes les traductions et l'échelle dans la matrice. Est-ce déroutant pour vous? Lisez ensuite ce tutoriel au moins huit fois. Voici le code du 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; } } } } 

Nous sommes à un pas de tout voir en action. Restructurons un peu la fonction Trace et ajoutons une trace d'objets maillés:

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

Résultats


C'est tout! Ajoutons quelques maillages simples (les primitives Unity vont bien), donnons-leur le composant RayTracingObject et observons la magie. N'utilisez pas encore de maillages détaillés (plus de quelques centaines de triangles)! Notre shader manque d'optimisation, et si vous en faites trop, cela peut prendre des secondes, voire des minutes, pour tracer au moins un échantillon par pixel. Par conséquent, le système arrêtera le pilote GPU, le moteur Unity peut se bloquer et l'ordinateur devra redémarrer.


Notez que nos mailles n'ont pas d'ombrage lisse mais plat. Comme nous n'avons pas encore chargé les normales des sommets dans le tampon, pour obtenir la normale des sommets de chaque triangle, nous devons effectuer un produit vectoriel. De plus, nous ne pouvons pas interpoler sur l'aire du triangle. Nous traiterons ce problème dans la prochaine partie du tutoriel.

Pour des raisons d'intérêt, j'ai téléchargé le Stanford Bunny à partir des archives de Morgan McGwire et en utilisant le modificateur de décimation du package Blender , j'ai réduit le nombre de sommets à 431. Vous pouvez expérimenter avec des paramètres d'éclairage et du matériel codé en dur dans la fonction de shader IntersectMeshObject . Voici un lapin diélectrique avec de belles ombres douces et un peu d'éclairage global diffus au Grafitti Shelter :


... et voici un lapin en métal sous la forte lumière directionnelle de Cape Hill , jetant des reflets disco sur le sol:


... et voici deux petits lapins cachés sous la grosse pierre Suzanne sous le ciel bleu Kiara 9 Dusk (j'ai prescrit un matériau alternatif pour le deuxième objet, vérifiant si le décalage d'index est égal à zéro):


Et ensuite?


C'est génial de voir un vrai maillage dans votre propre traceur pour la première fois, non? Aujourd'hui, nous avons traité certaines données, découvert l'intersection à l'aide de l'algorithme Meller-Trambor et collecté tout pour que nous puissions immédiatement utiliser le moteur GameObjects du moteur Unity. De plus, nous avons vu l'un des avantages du lancer de rayons: dès que vous ajoutez une nouvelle intersection au code, tous les beaux effets (ombres douces, éclairage global réfléchi et diffusé, etc.) commencent immédiatement à fonctionner.

Rendre un lapin brillant a pris beaucoup de temps, et j'ai encore dû utiliser un peu de filtrage pour me débarrasser du bruit le plus évident. Pour résoudre ce problème, la scène est généralement écrite dans une structure spatiale, par exemple, dans une grille, un arbre dimensionnel K ou une hiérarchie de volumes englobants, ce qui augmente considérablement la vitesse de rendu de grandes scènes.

Mais nous devons bouger dans l'ordre: nous éliminerons davantage le problème des normales pour que nos mailles (même celles à faible poly) soient plus lisses qu'aujourd'hui. Il serait également intéressant de mettre à jour automatiquement les matrices lors du déplacement d'objets et de se référer directement aux matériaux Unity, et pas seulement de les écrire dans le code. C'est ce que nous ferons dans la prochaine partie de la série de didacticiels. Merci d'avoir lu et à la partie 4!

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


All Articles