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