256 lignes de C ++ nu: écriture d'un ray tracer à partir de zéro en quelques heures

Je publie le chapitre suivant de mon cours magistral sur l'infographie ( ici vous pouvez lire l' original en russe, bien que la version anglaise soit plus récente). Cette fois, le sujet de la conversation est de dessiner des scènes en utilisant le lancer de rayons . Comme d'habitude, j'essaie d'éviter les bibliothèques tierces, car cela incite les étudiants à regarder sous le capot.

Il existe déjà de nombreux projets similaires sur Internet, mais presque tous présentent des programmes finis extrêmement difficiles à comprendre. Voici, par exemple, un programme de rendu très célèbre qui tient sur une carte de visite . Un résultat très impressionnant, mais comprendre ce code est très difficile. Mon objectif n'est pas de montrer comment je peux, mais de dire en détail comment le reproduire. De plus, il me semble que spécifiquement cette conférence est utile non seulement autant que du matériel de formation sur l'infographie, mais plutôt comme un manuel de programmation. Je montrerai constamment comment arriver au résultat final, en partant de zéro: comment décomposer un problème complexe en étapes élémentaires résolubles.

Attention: juste regarder mon code, ainsi que lire cet article avec une tasse de thé à la main, n'a pas de sens. Cet article est conçu pour vous permettre de saisir un clavier et d'écrire votre propre moteur. Il sera sûrement meilleur que le mien. Eh bien, ou changez simplement le langage de programmation!

Donc, aujourd'hui, je vais montrer comment dessiner de telles images:



Première étape: enregistrer l'image sur le disque


Je ne veux pas m'embêter avec les gestionnaires de fenêtres, le traitement souris / clavier, etc. Le résultat de notre programme sera une simple image enregistrée sur le disque. Ainsi, la première chose que nous devons être en mesure de faire est d'enregistrer l'image sur le disque. Ici se trouve le code qui vous permet de le faire. Permettez-moi de vous donner son fichier principal:

#include <limits> #include <cmath> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" void render() { const int width = 1024; const int height = 768; std::vector<Vec3f> framebuffer(width*height); for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } std::ofstream ofs; // save the framebuffer to file ofs.open("./out.ppm"); ofs << "P6\n" << width << " " << height << "\n255\n"; for (size_t i = 0; i < height*width; ++i) { for (size_t j = 0; j<3; j++) { ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j]))); } } ofs.close(); } int main() { render(); return 0; } 

Dans la fonction principale, seule la fonction render () est appelée, rien d'autre. Que contient la fonction render ()? Tout d'abord, je définis une image comme un tableau unidimensionnel de valeurs de tampon d'image de type Vec3f, ce sont de simples vecteurs tridimensionnels qui nous donnent la couleur (r, g, b) pour chaque pixel.

La classe vector se trouve dans le fichier geometry.h, je ne la décrirai pas ici: premièrement, tout y est trivial, manipulation simple de vecteurs bidimensionnels et tridimensionnels (addition, soustraction, affectation, multiplication par un scalaire, produit scalaire), et deuxièmement, gbg l'a déjà décrit en détail dans le cadre d'un cours magistral sur l'infographie.

J'enregistre l'image au format ppm ; C'est le moyen le plus simple d'enregistrer des images, mais ce n'est pas toujours le plus pratique pour une visualisation ultérieure. Si vous souhaitez enregistrer dans d'autres formats, je recommande toujours de connecter une bibliothèque tierce, par exemple, stb . C'est une merveilleuse bibliothèque: il suffit d'inclure un fichier d'en-tête stb_image_write.h dans le projet, et cela permettra d'enregistrer même en png, même en jpg.

Au total, l'objectif de cette étape est de s'assurer que nous pouvons a) créer une image en mémoire et y écrire différentes valeurs de couleur b) enregistrer le résultat sur le disque afin qu'il puisse être visualisé dans un programme tiers. Voici le résultat:



Étape deux, la plus difficile: le lancer de rayons directement


Il s'agit de l'étape la plus importante et la plus difficile de toute la chaîne. Je veux définir une sphère dans mon code et l'afficher à l'écran sans se soucier des matériaux ou de l'éclairage. Voici à quoi devrait ressembler notre résultat:



Pour plus de commodité, dans mon référentiel, il y a un commit pour chaque étape; Github facilite l'affichage de vos modifications. Ici, par exemple , ce qui a changé dans le deuxième commit par rapport au premier.

Pour commencer: de quoi avons-nous besoin pour représenter une sphère dans la mémoire de l'ordinateur? Quatre nombres nous suffisent: un vecteur tridimensionnel avec le centre de la sphère et un scalaire décrivant le rayon:

 struct Sphere { Vec3f center; float radius; Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {} bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const { Vec3f L = center - orig; float tca = L*dir; float d2 = L*L - tca*tca; if (d2 > radius*radius) return false; float thc = sqrtf(radius*radius - d2); t0 = tca - thc; float t1 = tca + thc; if (t0 < 0) t0 = t1; if (t0 < 0) return false; return true; } }; 

La seule chose non triviale dans ce code est une fonction qui vous permet de vérifier si un rayon donné (originaire d'orig dans la direction de dir) croise notre sphère. Une description détaillée de l'algorithme pour vérifier l'intersection du faisceau et de la sphère peut être lue ici , je recommande fortement de le faire et de vérifier mon code.

Comment fonctionne le lancer de rayons? Très simple. Au premier stade, nous avons simplement recouvert l'image d'un dégradé:

  for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } 

Maintenant, pour chaque pixel, nous allons former un rayon venant du centre des coordonnées et traversant notre pixel, et vérifier si ce rayon intersecte notre sphère.



S'il n'y a pas d'intersection avec la sphère, alors nous mettrons color1, sinon color2:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { float sphere_dist = std::numeric_limits<float>::max(); if (!sphere.ray_intersect(orig, dir, sphere_dist)) { return Vec3f(0.2, 0.7, 0.8); // background color } return Vec3f(0.4, 0.4, 0.3); } void render(const Sphere &sphere) {  [...] for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height; float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.); Vec3f dir = Vec3f(x, y, -1).normalize(); framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere); } }  [...] } 

À ce stade, je recommande de prendre un crayon et de vérifier sur papier tous les calculs, à la fois l'intersection d'un rayon avec une sphère et le balayage d'une image avec des rayons. Au cas où, notre caméra est déterminée par les éléments suivants:

  • largeur d'image
  • hauteur de l'image
  • angle de vue, fov
  • emplacement de la caméra, Vec3f (0,0,0)
  • direction du regard, le long de l'axe z, dans le sens de moins l'infini

Troisième étape: ajouter plus de sphères


Le plus difficile est derrière nous, maintenant notre chemin est sans nuage. Si nous pouvons dessiner une sphère. alors évidemment ajouter un peu plus de travail n'est pas difficile. Ici, vous pouvez voir les changements dans le code, et voici le résultat:



Quatrième étape: l'éclairage


Tout le monde est bon dans notre photo, mais ce n'est tout simplement pas assez d'éclairage. Dans le reste de l'article, nous n'en parlerons que. Ajoutez des sources lumineuses ponctuelles:

 struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; }; 

Considérer un éclairage réel est une tâche très, très difficile, donc, comme tout le monde, nous tromperons l'œil en dessinant des résultats complètement non physiques, mais très probablement, plausibles. Première remarque: pourquoi fait-il froid en hiver et chaud en été? Parce que le chauffage de la surface de la terre dépend de l'angle d'incidence de la lumière solaire. Plus le soleil est haut au-dessus de l'horizon, plus la surface est lumineuse. Et vice versa, plus l'horizon est bas, plus faible. Eh bien, après le coucher du soleil à l'horizon, les photons ne nous atteignent pas du tout. En ce qui concerne nos sphères: voici notre faisceau émis par la caméra (pas de relation avec les photons, attention!) Intersecté avec la sphère. Comment comprendre comment le point d'intersection est illuminé? Vous pouvez simplement regarder l'angle entre le vecteur normal à ce point et le vecteur décrivant la direction de la lumière. Plus l'angle est petit, meilleure est la surface éclairée. Pour le rendre encore plus pratique, vous pouvez simplement prendre le produit scalaire entre le vecteur normal et le vecteur d'éclairage. Je rappelle que le produit scalaire entre deux vecteurs a et b est égal au produit des normes des vecteurs par le cosinus de l'angle entre les vecteurs: a * b = | a | | b | cos (alpha (a, b)). Si nous prenons des vecteurs de longueur unitaire, alors le produit scalaire le plus simple nous donnera l'intensité de l'éclairage de surface.

Ainsi, dans la fonction cast_ray, au lieu d'une couleur constante, nous retournerons la couleur en tenant compte des sources lumineuses:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { [...] float diffuse_light_intensity = 0; for (size_t i=0; i<lights.size(); i++) { Vec3f light_dir = (lights[i].position - point).normalize(); diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N); } return material.diffuse_color * diffuse_light_intensity; } 

Voir les changements ici , mais le résultat du programme:



Cinquième étape: surfaces brillantes


Une astuce avec un produit scalaire entre un vecteur normal et un vecteur lumineux se rapproche assez bien de l'illumination des surfaces mates, dans la littérature on l'appelle illumination diffuse. Que faire si on veut du lisse et du brillant? Je veux avoir cette photo:



Voyez combien peu de changements devaient être faits. En bref, les réflexions sur les surfaces brillantes sont plus lumineuses, plus l'angle entre la direction de vue et la direction de la lumière réfléchie est petit. Eh bien, les coins, bien sûr, nous compterons à travers les produits scalaires, exactement comme avant.

Cette gymnastique avec éclairage des surfaces mates et brillantes est connue sous le nom de modèle Phong . Le wiki a une description assez détaillée de ce modèle d'éclairage; il se lit bien lorsqu'il est comparé en parallèle avec mon code. Voici une image clé à comprendre:


Étape six: Ombres


Pourquoi avons-nous de la lumière, mais pas d'ombres? Mess! Je veux cette photo:



Seules six lignes de code nous permettent d'y parvenir: lors du dessin de chaque point, nous nous assurons simplement que la source lumineuse n'intersecte pas les objets de notre scène, et si c'est le cas, alors la source lumineuse actuelle saute. Il n'y a qu'une petite subtilité: je déplace un peu le point dans le sens de la normale:

 Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; 

Pourquoi? Oui, c'est juste que notre point se trouve sur la surface de l'objet, et (à l'exception du problème des erreurs numériques) tout rayon de ce point traversera notre scène.

Étape sept: Réflexions


C'est incroyable, mais pour ajouter des reflets à notre scène, il suffit d'ajouter trois lignes de code:

  Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1); 

Voyez par vous-même: à l'intersection avec l'objet, nous comptons simplement le rayon réfléchi (la fonction du calcul des bosses est utile!) Et appelons récursivement la fonction cast_ray dans la direction du rayon réfléchi. Assurez-vous de jouer avec la profondeur de récursivité , je la mets à quatre, je recommence à zéro, qu'est-ce qui va changer dans l'image? Voici mon résultat avec une réflexion de travail et une profondeur de quatre:



Étape huit: Réfraction


En apprenant à compter les réflexions, les réfractions sont comptées exactement de la même manière . Une fonction qui vous permet de calculer la direction du rayon réfracté ( selon la loi de Snell ), et trois lignes de code dans notre fonction récursive cast_ray. Voici le résultat, dans lequel la boule la plus proche est devenue «verre», elle se réfracte et se reflète légèrement:



Étape neuf: ajouter plus d'objets


Pourquoi sommes-nous tous sans lait, mais sans lait. Jusqu'à ce moment, nous n'avons rendu que des sphères, car il s'agit de l'un des objets mathématiques non triviaux les plus simples. Et ajoutons un morceau de l'avion. Un classique du genre est un échiquier. Pour cela, une dizaine de lignes dans une fonction qui considère l'intersection du faisceau avec la scène nous suffisent largement.

Eh bien, voici le résultat:



Comme je l'ai promis, exactement 256 lignes de code, comptez pour vous !

Étape dix: devoirs


Nous avons parcouru un long chemin: nous avons appris à ajouter des objets à la scène, à considérer un éclairage assez compliqué. Permettez-moi de laisser deux tâches comme devoirs. Absolument tout le travail préparatoire a déjà été fait dans la branche homework_assignment . Chaque travail nécessitera un maximum de dix lignes de code.

Tâche un: Carte de l'environnement


Pour le moment, si le faisceau ne traverse pas la scène, nous le réglons simplement sur une couleur constante. Et pourquoi, en fait, permanent? Prenons une photo sphérique (fichier envmap.jpg ) et utilisons-la comme arrière-plan! Pour vous faciliter la vie, j'ai lié notre projet à la bibliothèque stb pour la commodité de travailler avec des fichiers jpeg. Cela devrait être un rendu comme celui-ci:



La deuxième tâche: charlatan!


Nous pouvons rendre des sphères et des plans (voir échiquier). Ajoutons donc un dessin de modèles triangulés! J'ai écrit du code pour lire la grille de triangles et y ai ajouté une fonction d'intersection rayon-triangle. Maintenant, ajouter un canard à notre scène devrait être complètement trivial!



Conclusion


Ma tâche principale est de montrer des projets intéressants (et faciles!) À programmer, j'espère vraiment pouvoir le faire. C'est très important, car je suis convaincu qu'un programmeur doit écrire beaucoup et avec goût. Je ne sais pas pour vous, mais personnellement la comptabilité et un sapeur, avec une complexité de code assez comparable, ne m'attirent pas du tout.

Deux cent cinquante lignes de lancer de rayons peuvent en fait être écrites en quelques heures. Cinq cents lignes de tramage logiciel peuvent être maîtrisées en quelques jours. La prochaine fois, nous trierons le rakecasting et, en même temps, je montrerai les jeux les plus simples que mes élèves de première année écrivent dans le cadre de l'enseignement de la programmation C ++. Restez à l'écoute!

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


All Articles