Modélisation Procédurale

assets/goal.jpg
Objectif de scène 3D obtenue en fin de séance

Code

Considérons désormais le code présent dans le répertoire scenes_inf443/03b_modeling/
assets/base_terrain.jpg
Organisation du code
Fichiers:

Modélisation du terrain

Principe

La surface affichée correspond à un "terrain" définie sous la forme d'un champ de hauteur \(z=f(x,y)\).
La fonction calculant cette hauteur pour un \((x,y)\) donné est la fonction evaluate_terrain_height(x,y) dans le fichier terrain.cpp.
Pour afficher ce terrain, il est nécessaire de génerer un maillage. Cela est réalisé par la fonction create_terrain_mesh qui prend en paramètre la longueur du terrain suivant x/y.
Les sommets de ce maillages sont tels que les coordonnées \((x,y)\) échantillonnent une grille régulière le long des direction \(x\) et \(y\). Et la hauteur \(z\) est obtenue par l'appel à la fonction evaluate_terrain_height.
// Fill terrain geometry
for(int ku=0; ku<N; ++ku)
{
    for(int kv=0; kv<N; ++kv)
    {
        // Compute local parametric coordinates (u,v) \in [0,1]
        float u = ku/(N-1.0f);
        float v = kv/(N-1.0f);

        // Compute the real coordinates (x,y) of the terrain 
        float x = (u - 0.5f) * terrain_length;
        float y = (v - 0.5f) * terrain_length;

        // Compute the surface height function at the given sampled coordinate
        float z = evaluate_terrain_height(x,y);

        // Store vertex coordinates
        terrain.position[kv+N*ku] = {x,y,z};
    }
}
Notez que ce type de création de maillage à partir de l'échantionnage sur une grille régulière est une approche classique qui permet de modéliser de manière plus générale des surfaces paramétriques \(f(u,v)\).
La suite de la fonction permet de compléter la connectivité triangulaire sur chaque élément de la grille du terrain.
Enfin, la demande de création de ce maillage est réalisé dans la fonction initialize() de scene_structure. Pour l'affichage, un objet de type mesh_drawable est initialisé à partir du maillage, et l'appel à l'affichage est réalisé dans la fonction display() (draw(terrain, environment);).

Fonction de hauteur

A l'heure actuelle, la hauteur du terrain est définie comme une gaussienne centrée sur l'origine,
\(z=h_0 \, exp\left(-\left(\frac{\left\|p-p_0\right\|}{\sigma_0}\right)^2\right)\), avec \(p=(u,v)\), et \(p_0=(u_0,v_0)\).
float evaluate_terrain_height(float x, float y)
{
    vec2 p_0 = { 0, 0 };
    float h_0 = 2.0f;
    float sigma_0 = 3.0f;

    float d = norm(vec2(x, y) - p_0) / sigma_0;

    float z = h_0 * std::exp(-d * d);
    
    return z;
}
> Modifiez la fonction de hauteur de manière à générer une surface plus générique de type
\(\displaystyle z= \sum_i h_i \, exp\left(-\left(\frac{\|p-p_i\|}{\sigma_i}\right)^2\right)\).
La fonction pourra contenir les paramètres \((p_i,h_i,\sigma_i)\) comme des vecteurs/tableaux de taille fixe.
On pourra utiliser les paramètres suivants:
    • \(p_i=\{(-10,-10),(5,5),(-3,4),(6,4)\}\)
    • \(h_i=\{3,-1.5,1,2\}\)
    • \(\sigma_i=\{10,3,4,4\}\)
assets/terrain_wireframe.jpg assets/terrain.jpg
Aide:
vec2 p_i[4] = { {-10,-10}, {5,5}, {-3,4}, {6,4} };
float h_i[4] = {3.0f, -1.5f, 1.0f, 2.0f};

Une autre possibilitée serait:
std::array<vec2,4> p_i = { vec2{-10,-10}, vec2{5,5}, vec2{-3,4}, vec2{6,4} };
std::array<float,4> h_i = {3.0f, -1.5f, 1.0f, 2.0f};
à partir des structures de la STL (librairie standard), qui possède l'avantage de donner accès à p_i.size(), et permet une copie sécurisée du contenu entre une variable et une autre contrairement au cas "type[N]" qui force à manipuler un pointeur.

Modélisation d'arbres sur le terrain

Nous souhaitons modéliser une série d'arbres/sapins présents sur le terrain. Pour cela, nous allons modéliser un arbre individuel à l'aide de primitives géométriques, puis réaliser son affichage en de multiples endroits du terrain.
assets/tree.jpg

Modélisation du tronc

Le tronc est modélisé sous la forme d'un cylindre, plus précisément sont approximation par un maillage triangulaire.

Une fonction create_cylinder_mesh dans le fichier tree.cpp est prévue pour générer ce cylindre, votre objectif est de la compléter.

Le rayon et la hauteur du cylindre sont passés en paramètre. On pourra supposer qu'il s'agit d'un cylindre dont l'axe central est orienté suivant la coordonnée \(z\), et centré de manière à passer par l'origine.

assets/cylinder.png

Rem. Pensez au préalable à la manière dont vous allez ordonner les sommets et les triangles. Le cylindre peut rester une surface ouverte à ces extrémitées. Pensez à vérifier votre résultat en affichant votre cylindre avant de passer à la suite (l'affichage en mode wireframe peut également aider au debug si nécessaire).

Rem. 2 L'ordre dans lesquels les sommets sont énumérés pour représenter un triangle est utilisé pour donner une orientation à la normale des triangles. Il faut donc énumérez ceux-ci de manière cohérente pour obtenir une illumination homogène de votre surface. Si des oscillations sombres et clairs appraraissent, l'orientation de vos triangle peut être en cause.

assets/normales_incorrectes.jpg
Gauche: Orientation non homogène des triangles du cylindre aboutit des artefacts visuels sur l'illumination.
Droite: Illumination attendue.
Explication: L'illumination de phong dépend de l'orientation des normales aux sommets des triangles - ces normales étant interpolées sur chaque fragment des triangles dans le fragment shader pour calculer la composante diffuse et spéculaire. Dans le cas de ce code, la normale de chaque sommet est calculée automatiquement en réalisant la moyenne des normales des triangles dont il dépend. Si des triangles voisins possèdent des orientations opposées, leur normales ne sont plus moyennées correctement et peuvent être proche de s'annuler sur les sommets correspondants - ce qui aboutit à des orientations changeantes et éloigné d'une normale attendue. Cela aboutit typiquement visuellement à un front sombre séparant les sommets orientés d'un coté et ceux de l'autres.
  • - m.position est un buffer/tableau contenant des vec3.
  • - Il est possible d'ajouter des valeurs au fur et à mesure dans un buffer. La taille de celui-ci augmente alors à chaque ajout
m.push_back(vec3{x,y,z});
  • - Il est également possible de pré-alouer la taille d'un buffer pour y écrire des valeurs à des indices spécifiques par la suite.
m.position.resize(N);
    • Notez que cette taille peut être modifiée au cours de l'exécution du programme par d'autres appels à .resize().
  • - L'écriture à l'indice k (0 < k < N) du buffer se réalise alors avec la syntaxe
m.position[k]=vec3{x,y,z};
    • Rem. N'utilisez pas la syntaxe buffer[-1] spécifique à Python. En C++, cela tente d'accéder à la case mémoire -1, c-a-d l'élément mémoire précédent le début du buffer. Ce qui peut aboutir à une erreur mémoire (Segmentation Fault), ou à une lecture de valeurs arbitraires. Le dernier élément peut être obtenu avec l'appel à buffer[buffer.size()-1].
  • - Il est également possible de parcourir l'ensemble des valeurs de ce tableau avec les syntaxes suivantes:
// Parcours classique avec des indices:
for(int k=0; k<m.position.size(); ++k) {
    vec3 p = m.position[k];
    // ou vec3& p = m.position[k] pour récupérer une référence plutôt qu'une copie
    // ou encore m.position[k] = ...
}

// Parcours avec "range-based for loop" (copie)
for(vec3 p : m.position) {
    // p recoit la copie des éléments de m.position
    // ex. std::cout<< p <<std::endl;
}

// Parcours avec "range-based for loop" (référence)
for(vec3& p : m.position) {
    // p = ... 
    // p étant une référence, il peut modifier les éléments de m.position
}
(Après l'avoir réalisé vous même, ou si vous éprouvez des difficultés, un code solution possible de création du cylindre est proposé ici)

Modélisation du feuillage

Le feuillage est constitué de 3 cones successifs (toujours sous forme de maillage).

Complétez la fonction create_cone_mesh de manière à créer un maillage de forme conique. Les paramètres d'entrées de la fonction sont respectivement: le rayon à la base du cone, la hauteur de celui-ci, un offset à appliquer entre \(z=0\) et la base du cone.

Rem. Le bas du cone représentant la base du feuillage, pensez à construire cette fois une surface fermée. Pensez également à vérifier votre résultat en affichant votre cylindre avant de passer à la suite.

assets/cone.png

(Après l'avoir réalisé vous même, ou si vous éprouvez des difficultés, un code solution possible de création du cone est proposé ici)

Arbre complet

Une fois les fonctions de tronc et de feuillage réalisées, il est possible d'assembler des éléments ensembles pour former l'apparence d'un arbre.

Créez la fonction suivante dans le fichier tree.cpp

mesh create_tree()
{
    float h = 0.7f; // trunk height
    float r = 0.1f; // trunk radius

    // Create a brown trunk
    mesh trunk = create_cylinder_mesh(r, h);
    trunk.color.fill({0.4f, 0.3f, 0.3f});


    // Create a green foliage from 3 cones
    mesh foliage = create_cone_mesh(4*r, 6*r, 0.0f);      // base-cone
    foliage.push_back(create_cone_mesh(4*r, 6*r, 2*r));   // middle-cone
    foliage.push_back(create_cone_mesh(4*r, 6*r, 4*r));   // top-cone
    foliage.translate({0,0,h});       // place foliage at the top of the trunk
    foliage.color.fill({0.4f, 0.6f, 0.3f});
       
    // The tree is composed of the trunk and the foliage
    mesh tree = trunk;
    tree.push_back(foliage);

    return tree;
}

et ajoutez la signature de cette fonction dans le fichier tree.hpp (cgp::mesh create_tree();)


Si la signature de la fonction n'est pas présente dans le fichier tree.hpp, il ne sera pas possible d'appeler cette fonction depuis scene.cpp (scene.cpp inclue le fichier tree.hpp).

Notez que le tronc correspond au cylindre, et le feuillage est formé par trois cones placés l'un au dessus de l'autre puis concaténé dans un seul et même maillage par l'utilisation de la fonction push_back sur des structures mesh.

assets/foliage.png

Les sommets du tronc et des cones sont affectés à des couleurs différentes. et finalement les maillages sont concaténés dans un seul et même objet de type "mesh".


Rem. L'utilisation de push_back sur des maillages permet de concaténer les buffers de données associées (coordonnées des sommets, couleurs des sommets, normales des sommets, indices formants les triangles). Cela permet de ne manipuler qu'une seule entitée "mesh" à la place de plusieurs. Par contre, cela signifie également qu'il n'est plus possible de manipuler ces entités individuellement par la suite (ex. si le tronc et feuillages possèdent une texture différente, il sera nécessaire de les gérer séparéments).

Peuplement du terrain

Positionnement d'un arbre unique

Essayez dans un premier temps de placer cet arbre à différents endroits du terrain. Pour cela, il est possible d'utiliser le paramètre uniforme de translation de la structure mesh_drawable ([mesh_drawble].model.translation = ...) afin d'afficher l'arbre à une position donnée (principe similaire au cas de la translation du cube dans la scène d'introduction).
Notez que pour connaitre la hauteur d'un point sur le terrain de coordonnées \((x,y)\), vous pouvez explicitement appeler la fonction evaluate_terrain_height(x,y).

Positionnement de plusieurs arbres.

Pour positionner plusieurs arbres, l'idée est de pré-stocker un ensemble de \(N_a\) positions sur le terrain. On note \(p_i\) la ième position pré-stockée (\(i\in[0,N_a-1]\)). Au moment de l'affichage, on applique alors l'algorithme suivant

For all positions pi
    tree.model.translation = pi
    draw(tree, environment);
Mise en pratique:
Créez une fonction ayant la signature suivante dans le fichier terrain.cpp (et ajoutez cette signature dans le fichier terrain.hpp)
std::vector<cgp::vec3> generate_positions_on_terrain(int N, float terrain_length);
L'objectif de cette fonction est de générer un vecteur contenant \(N\) positions placées aléatoirement sur le terrain.
  • Rappel:
  • Pour ajouter une valeur dans un std::vector, on peut utiliser la syntaxe: nomVariable.push_back(valeur)
  • Il est également possible de pré-allouer le vecteur, puis d'écrire à l'indice souhaité
    • nomVariable.resize(maximalSize)
    • nomVariable[k] = valeur;
  • Attention: si vous accédez à un indice d'un vector au dela de la dimension allouée, il s'agit d'une erreur d'accès mémoire qui peut terminer brutalement l'execution (Segmentation Fault).

Une fois la fonction réalisée, appelez celle-ci dans votre scene et stockez les valeurs dans une variable globale de type std::vector<cgp::vec3> tree_position;.
  • tree_position est une variable déclarée dans l'en-tête comme une variable de la classe. Il ne faut donc pas re-déclarer son type lors de son initialisation:
    • [OK]: tree_position = generate_positions_on_terrain(150, terrain_length);
    • [KO]: std::vector<cgp::vec3> tree_position = generate_positions_on_terrain(150, terrain_length);
  • Le second cas vient re-générer une variable locale à la fonction d'initialization. C'est cette variable qui sera affecté et non pas la variable de la classe qui restera alors vide.
assets/terrain_with_tree.jpg
Remarques

Extensions

Extension 1 Faites en sorte d'éviter que deux arbres puissent être trop proches l'un de l'autre - et notamment qu'ils ne puissent s'intersecter. Pour cela, vous pourrez adapter l'algorithme créant les positions stockées des arbres. Rem. Compte tenu du faible nombre d'arbres et du calcul réalisé une unique fois à l'initialisation, un algorithme en complexité quadratique ne pose pas de problème.

Extension 2 (optionnel) En suivant une démarche similaire, il est possible d'ajouter d'autres objets formés de primitives simples. Exemple avec l'ajout de champignons.

  • - Lors de l'affichage d'un très grand nombre d'objets, le programme va devenir lent du fait de l'appel explicite à draw pour chaque objet individuel.
  • - Une méthode efficace pour l'affichage d'un grand nombre d'objets similaire consiste à réaliser de l'instancing.
  • - L'utilisation de l'instancing via la bibliothèque CGP est documenté ici: [Use instancing], [Exemples de codes].