***************** Analyse organique ***************** .. role:: csharp(code) :language: c# Introduction ============ Dans cette partie de la documentation, je vais expliquer le cycle de vie d'un modèle 3D en détail. De son chargement à son affichage. .. _systemes-de-coordonnes: .. figure:: ./img/projectionSchema.svg :alt: Expliquation des systèmes de coordonnés. :align: center Expliquation des systèmes de coordonnés. Insipiré par celui sur `Learn OpenTK `_. Lors de l'affichage d'un modèle, il passe par 5 systèmes de coordonnées différents : #. **Local space** : coordonnées relatives à l'objet. Celles où l'objet "débute". #. **World space** : coordonnées dans un monde plus large, relatives à une origine globale avec d'autres objets placés relativement à cette origine. #. **View space** : coordonnées ayant comme origine la position de la caméra. #. **2D space** : espace 2D qui est le résultat de la projection. #. **Screen space** : les points sont déplacés de coordonnées d'un espace mathématique à des pixels sur une image / écran. Tout le code de rendu se trouve dans la classe ``Renderer``. Il est possible ensuite d'appeler la méthode :csharp:`RenderNextFrame()` afin de rendre une image qui sera accessible dans le champ ``Bitmap``. Classes mathématiques ======================= Avant d'afficher une scène ou un modèle, il faut tout d'abord pouvoir stocker un point dans l'espace. Ceci se fait grâce aux structures ``Vector3`` et ``Vector4``. Comme je voulais utiliser des chiffres à virgules dans mes structures, je devais choisir entre des ``double`` ou des ``float``. J'ai décidé de partir sur des ``double`` comme la plupart des ordinateurs modernes sont 64 bits. Le projet ne marche d'ailleurs que sur les machines 64 bits. Vector3 ------- Ma structure vecteur 3 est grandement basé sur `ce guide _`. J'a décidé de suivre l'idée que les vecteurs et matrices sont des valeures immutables. Constantes ^^^^^^^^^^ Afin d'avoir des constantes de type ``Vector3``, j'ai mis des variables ``static readonly`` : .. code:: c# /// /// An origin vector. Is equal to Vector(0, 0, 0). Same as Zero. /// public static readonly Vector3 Origin = new(0, 0, 0); Champs ^^^^^^ Les coordonnées ``x``, ``y`` et ``z`` sont donc stockés dans des champs de type ``double`` et ``readonly``. .. code:: c# /// /// The X component of the vector. /// private readonly double x; /// /// The Y component of the vector. /// private readonly double y; /// /// The Z component of the vector. /// private readonly double z; Propriétés ^^^^^^^^^^^^^^ Comme les coordonnées du vecteur sont readonly, je les rends accessible avec des getteurs uniquement : .. code:: c# /// /// Gets the X component of the vector. /// public double X => x; La magnitude (longueur) des vecteurs se trouve dans la propriété ``Magnitude`` : .. code:: c# /// /// Gets the magnitude (aka. length or absolute value) of the vector. /// public double Magnitude => Math.Sqrt(SqrMagnitude); ``SqrMagnitude`` sert à comparer la longueur de deux vecteurs sans avoir à effectuer une racine carrée. .. code:: c# /// /// Gets the squared magnitude of this vector, can be used for better performance than Magnitude. /// public double SqrMagnitude => SqrComponents().SumComponents(); J'explique les méthodes ``SqrComponents()`` et ``SumComponents()`` s'occupent respectivement de donner le carré et la somme des composants du vecteur. Opérateurs ^^^^^^^^^^^ J'ai ajouté la plupart des opérateurs possibles entre vecteurs dans cette structure. Par exemple pour l'addition : .. code:: c# public static Vector3 operator +(Vector3 v1, Vector3 v2) => new(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z); J'ai également implémenté l'addition et la division par des scalaires : .. code:: c# public static Vector3 operator *(Vector3 v1, double s2) => new(v1.X * s2, v1.Y * s2, v1.Z * s2); public static Vector3 operator /(Vector3 v1, double s2) => new(v1.X / s2, v1.Y / s2, v1.Z / s2); J'ai aussi mis un moyen de comparer deux vecteurs. Pour ce faire j'utilise la propriété ``SqrMagnitude``. .. code:: c# public static bool operator <(Vector3 v1, Vector3 v2) => v1.SqrMagnitude < v2.SqrMagnitude; Cela dit, je n'ai pas implémenté le produit vectoriel ou scalaire avec la surcharge d'opérateur, car cela peut être confu. Pour ces opérations, j'ai fait des méthodes. Méthodes ^^^^^^^^^ .. code:: c# /// /// Determines the dot product of two vectors. /// /// The vector to multiply. /// The vector to multiply by. /// Returns a scalar representing the dot product of the two vectors. public static double Dot(Vector3 v1, Vector3 v2) => (v1.X * v2.X) + (v1.Y * v2.Y) + (v1.Z * v2.Z); /// /// Determine the cross product of two Vectors. /// Determine the vector product. /// Determine the normal vector (Vector3 90° to the plane). /// /// The vector to multiply. /// The vector to multiply by. /// Vector3 representig the cross product of the two vectors. public static Vector3 Cross(Vector3 v1, Vector3 v2) => new((v1.Y * v2.Z) - (v1.Z * v2.Y), (v1.Z * v2.X) - (v1.X * v2.Z), (v1.X * v2.Z) - (v1.Y * v2.X)); Comme pour la plupart des méthodes dans cette structure, j'ai également "wrappé" les méthodes statiques dans des méthodes non-statiques : .. code:: c# /// /// Determines the dot product of two vectors. /// /// The vector to multiply by. /// Returns a scalar representing the dot product of the two vectors. public double Dot(Vector3 other) => Dot(this, other); /// /// Determine the cross product of two Vectors. /// Determine the vector product. /// Determine the normal vector (Vector3 90° to the plane). /// /// The vector to multiply by. /// Vector3 representig the cross product of the two vectors. public Vector3 Cross(Vector3 other) => Cross(this, other); Cette manière de coder est héritée du guide que j'ai suivis. Je ne la réutilise donc pas dans le reste du projet. Pour savoir si un vecteur est unitaire, j'ai ajouté cette méthode : .. code:: c# /// /// Checks if the vector is a unit vector. /// Checks if the vector has be normalized. /// Checks if the vector has a magnitude of 1. /// /// The vector to be checked for normalization. /// Returns true if the vector is a unit vector. public static bool IsUnitVector(Vector3 v1) => v1.Magnitude == 1; Le problème, c'est que la valeure retournée de ``Magnitude`` est un double et a cause de la précision des chiffres à virgules, il ne faut pas directement les comparer à des chiffres. C'est pour ça que j'ai implémenté la même méthode mais avec une marge. .. code:: c# /// /// Checks if the vector is a unit vector within a tolerance. /// Checks if the vector has been normalized within a tolerance. /// Checks if the vector has a magnitude of 1 within a tolerance. /// /// The vector to be checked for normalization. /// The tolerance to use when comparing the magnitude. /// Returns true if the vector is a unit vector. public static bool IsUnitVector(Vector3 v1, double tolerance) => v1.Magnitude.AlmostEqualsWithAbsTolerance(1, tolerance); La méthode ``AlmostEqualsWithAbsTolerance()`` est une méthode extension de doubles qui se trouve dans la classe ``DoubleExtension.cs``. .. code:: c# public static bool AlmostEqualsWithAbsTolerance(this double a, double b, double maxAbsoluteError) { double diff = Math.Abs(a - b); // Shortcut, handles infinities return a.Equals(b) || diff <= maxAbsoluteError; } Pour normaliser le vecteur, on peut appeller la méthode ``Normalize()`` : .. code:: c# /// /// Gets the normalized unit vector with a magnitude of one. /// /// The vector to be normalized. /// Returns the normalized vector. /// /// Thrown when the normalisation of a zero magnitude vector is attempted. /// /// /// Thrown when the normalisation of a NaN magnitude vector is attempted. /// /// /// Exceptions will be thrown if the vector being normalized has a magnitude of 0 or of NaN. /// public static Vector3 Normalize(Vector3 v1) { double magnitude = v1.Magnitude; if (double.IsInfinity(magnitude)) { v1 = NormalizeSpecialCasesOrOrigional(v1); if (v1.IsNaN()) { // If this wasn't a special case, throw an exception throw new NormalizeVectorException(NormalizeInf); } } // Check that we are not trying to normalize a vector of magnitude 0 if (magnitude == 0) { throw new NormalizeVectorException(NormalizeZero); } // Check that we are not trying to normalize a vector of magnitude NaN if (double.IsNaN(magnitude)) { throw new NormalizeVectorException(NormalizeNaN); } return NormalizeOrNaN(v1); } Afin de vérifier si les éléments d'un vecteur sont ``NaN``, cette méthode utilise ``IsNaN()`` : .. code::c# /// /// Checks if any component of a vector is Not A Number (NaN). /// /// The vector checked for NaN components. /// Returns true if the vector has NaN components. public static bool IsNaN(Vector3 v1) => double.IsNaN(v1.X) || double.IsNaN(v1.Y) || double.IsNaN(v1.Z); La méthode ``Normalize`` vérifie si le vecteur peut être normalisé, sinon elle jette une exception ``NormalizeVectorException``. Si la magnitude est infinie, la méthode utilise une autre méthode ``NormalizeSpecialCasesOrOrigional()`` : .. code:: c# /// /// This method is used to normalize special cases of vectors where the components are infinite and/or zero only. /// Other vectors will be returned un-normalized. /// /// The vector to be normalized if it is a special case. /// Normialized special case vectors, NaN or the origional vector. private static Vector3 NormalizeSpecialCasesOrOrigional(Vector3 v1) { if (double.IsInfinity(v1.Magnitude)) { double x = v1.X == 0 ? 0 : v1.X == -0 ? -0 : double.IsPositiveInfinity(v1.X) ? 1 : double.IsNegativeInfinity(v1.X) ? -1 : double.NaN; double y = v1.Y == 0 ? 0 : v1.Y == -0 ? -0 : double.IsPositiveInfinity(v1.Y) ? 1 : double.IsNegativeInfinity(v1.Y) ? -1 : double.NaN; double z = v1.Z == 0 ? 0 : v1.Z == -0 ? -0 : double.IsPositiveInfinity(v1.Z) ? 1 : double.IsNegativeInfinity(v1.Z) ? -1 : double.NaN; return new(x, y, z); } return v1; } Une fois que les checks sont fait, la méthode retourne le résultat d'une autre méthode, ``NormalizeOrNaN()`` : .. code:: c# /// /// Gets the normalized unit vector with a magnitude of one. /// /// The vector to be normalized. /// The normalized vector3 or vector (NaN,NaN,NaN) if the magnitude is 0 or NaN. private static Vector3 NormalizeOrNaN(Vector3 v1) { // Find the inverse of the vectors magnitude double inverse = 1 / v1.Magnitude; // Multiply each component by the inverse of the magnitude return new Vector3(v1.X * inverse, v1.Y * inverse, v1.Z * inverse); } Si on veut pouvoir normaliser sans avoir ``NaN`` en retour, on peut utiliser la méthode ``NormalizeOrDefault()`` : .. code:: c# /// /// Gets the normalized unit vector with a magnitude of one. /// /// The vector to be normalized. /// Returns Vector (0,0,0) if the magnitude is zero, Vector (NaN, NaN, NaN) if magnitude is NaN, or normalized vector. public static Vector3 NormalizeOrDefault(Vector3 v1) { // Special cases v1 = NormalizeSpecialCasesOrOrigional(v1); // Check that we are not trying to normalize with a vector of magnitude 0. If yes, we return v(0, 0, 0). if (v1.Magnitude == 0) { return Origin; } // Check that we are not trying to normalize a vector with a NaN component. If yes, we return v(NaN, NaN, NaN). if (v1.IsNaN()) { return NaN; } return NormalizeOrNaN(v1); } Afin de savoir si une face fait dos à la caméra, la méthode ``IsBackFace()`` est utilisée : .. code:: c# /// /// Checks if a face normal vector represents back face. /// Checks if a face is visible, given the line of sight. /// /// The vector representing the face normal Vector3. /// The unit vector representing the direction of sight from a virtual camera. /// True if the vector (as a normal) represents a back-face. public static bool IsBackFace(Vector3 normal, Vector3 lineOfSight) => normal.Dot(lineOfSight) < 0; Elle fait le produit scalair entre la normale de la face et la ligne de la caméra ou de l'observateur. Si ce produit est inférieur à 0, la face fait dos à la caméra (on dit en anglais qu'elle est une "back face"). Afin de pouvoir afficher les vecteurs sur un ``Bitmap`` Windows Form, il faut pouvoir les obtenir en ``PointF``, j'ai donc créé cette méthode : .. code:: c# /// /// Converts this vector to a . /// Only keeps the X and Y components of this vector. /// /// A point with the X and Y components of this vector. public PointF ToPointF() => new((float)X, (float)Y); J'ai également créé une méthode pour faire un vecteur 4 à partir d'un vecteur 3 : .. code:: c# /// /// Converts this vector to a . /// The component will be equal to 1. /// /// The Vector3 as a . public Vector4 ToVector4() => new(x, y, z, 1); Vector4 ------- Une grande partie du vecteur 4 est similaire au vecteur 3, je ne vais donc pas documenter ces points là. La majeure différence est que le vecteur 4 représente une coordonnée homogène (voir éléments mathématiques). Afin d'obtenir les coordonnées physiques du vecteur, j'ai créé cette méthode : .. code:: c# /// /// Converts the Vector4 to physical coords in a Vector3. /// Divides the X, Y and Z components by W. /// If W = 0, it just gives the X, Y and Z components in a Vector3. /// /// Returns the physical coordinates of this vector. public Vector3 ToPhysicalCoords() => W != 0 ? new(X / W, Y / W, Z / W) : new(X, Y, Z); Quand le vecteur 4 est convertis en ``PointF``, il est également convertis en vecteur 3 en premier. .. code:: c# /// /// Converts this vector to a . /// Firsts gets his physical coordinates, then only keeps the X and Y components. /// /// A containing the physical X and Y coordinates of the vector. public PointF ToPointF() => ToPhysicalCoords().ToPointF(); Matrix4 ------- Comme vus dans les éléments mathématique, ces vecteurs doivent pouvoir être transformés à l'aide de matrices. C'est pourquoi j'ai également créé une structure ``Matrix4``. Champs ^^^^^^^ Les données de la matrice sont stockées dans un tableau à deux dimensions : .. code:: c# private readonly double[,] values; Propriétés ^^^^^^^^^^^^ J'ai ajouté un getter sur le champ ``values`` : .. code:: c# /// /// Gets the values of the matrix. /// public double[,] Values { get { return new double[Size, Size] { { values[0, 0], values[0, 1], values[0, 2], values[0, 3] }, { values[1, 0], values[1, 1], values[1, 2], values[1, 3] }, { values[2, 0], values[2, 1], values[2, 2], values[2, 3] }, { values[3, 0], values[3, 1], values[3, 2], values[3, 3] }, }; } } J'ai également ajouté une propriété par valeur possible dans ce tableau, par exemple : .. code:: c# /// /// Gets the value at 0, 0 on the matrix. /// public double V00 => values[0, 0]; La matrice à aussi un moyen de calculer son déterminant. J'ai tout simplement mis toutes les opérations à la main dans une propriété : .. code:: c# /// /// Gets the determinant of the matrix. /// public double Determinant { get { return (values[0, 0] * values[1, 1] * values[2, 2] * values[3, 3]) - (values[0, 0] * values[1, 1] * values[2, 3] * values[3, 2]) + (values[0, 0] * values[1, 2] * values[2, 3] * values[3, 1]) - (values[0, 0] * values[1, 2] * values[2, 1] * values[3, 3]) + (values[0, 0] * values[1, 3] * values[2, 1] * values[3, 2]) - (values[0, 0] * values[1, 3] * values[2, 2] * values[3, 1]) - (values[0, 1] * values[1, 2] * values[2, 3] * values[3, 0]) + (values[0, 1] * values[1, 2] * values[2, 0] * values[3, 3]) - (values[0, 1] * values[1, 3] * values[2, 0] * values[3, 2]) + (values[0, 1] * values[1, 3] * values[2, 2] * values[3, 0]) - (values[0, 1] * values[1, 0] * values[2, 2] * values[3, 3]) + (values[0, 1] * values[1, 0] * values[2, 3] * values[3, 2]) + (values[0, 2] * values[1, 3] * values[2, 0] * values[3, 1]) - (values[0, 2] * values[1, 3] * values[2, 1] * values[3, 0]) + (values[0, 2] * values[1, 0] * values[2, 1] * values[3, 3]) - (values[0, 2] * values[1, 0] * values[2, 3] * values[3, 1]) + (values[0, 2] * values[1, 1] * values[2, 3] * values[3, 0]) + (values[0, 2] * values[1, 1] * values[2, 0] * values[3, 3]) - (values[0, 3] * values[1, 0] * values[2, 1] * values[3, 2]) + (values[0, 3] * values[1, 0] * values[2, 2] * values[3, 1]) - (values[0, 3] * values[1, 1] * values[2, 2] * values[3, 0]) + (values[0, 3] * values[1, 1] * values[2, 0] * values[3, 2]) - (values[0, 3] * values[1, 2] * values[2, 0] * values[3, 1]) + (values[0, 3] * values[1, 2] * values[2, 1] * values[3, 0]); } } J'ai fait une surcharge d'opérateur pour les opérations scalaires avec les matrices : .. code:: c# /// /// Adds a matrix and a scalar. /// /// Matrix to add to. /// Scalar to add to the matrix. /// The added matrix. public static Matrix4 operator +(Matrix4 mat, double scalar) { double[,] newMat = new double[Size, Size] { { mat[0, 0] * scalar, mat[0, 1] * scalar, mat[0, 2] * scalar, mat[0, 3] * scalar }, { mat[1, 0] * scalar, mat[1, 1] * scalar, mat[1, 2] * scalar, mat[1, 3] * scalar }, { mat[2, 0] * scalar, mat[2, 1] * scalar, mat[2, 2] * scalar, mat[2, 3] * scalar }, { mat[3, 0] * scalar, mat[3, 1] * scalar, mat[3, 2] * scalar, mat[3, 3] * scalar }, }; return new(newMat); } Je n'utilise pas de boucle afin d'avoir de meilleures performances. J'ai utilisé en gros la même méthode pour l'addition entre matrice : .. code:: c# /// /// Adds two matrices. /// /// The matrix on the left. /// The matrix on the right. /// The added matrix. public static Matrix4 operator +(Matrix4 left, Matrix4 right) { double[,] newMat = new double[Size, Size] { { left[0, 0] + right[0, 0], left[0, 1] + right[0, 0], left[0, 2] + right[0, 0], left[0, 3] + right[0, 0] }, { left[1, 0] + right[1, 0], left[1, 1] + right[1, 0], left[1, 2] + right[1, 0], left[1, 3] + right[1, 0] }, { left[2, 0] + right[2, 0], left[2, 1] + right[2, 0], left[2, 2] + right[2, 0], left[2, 3] + right[2, 0] }, { left[3, 0] + right[3, 0], left[3, 1] + right[3, 0], left[3, 2] + right[3, 0], left[3, 3] + right[3, 0] }, }; return new(newMat); } Pour la multiplication entre matrice, je ne boucle églamement pas : .. code:: c# /// /// Multiplies two matrices. /// /// The left matrix. /// The right matrix. /// The dot product of the matrices. public static Matrix4 operator *(Matrix4 left, Matrix4 right) { double[,] newMat = new double[Size, Size]; // First row newMat[0, 0] = (left.values[0, 0] * right.values[0, 0]) + (left.values[0, 1] * right.values[1, 0]) + (left.values[0, 2] * right.values[2, 0]) + (left.values[0, 3] * right.values[3, 0]); newMat[0, 1] = (left.values[0, 0] * right.values[0, 1]) + (left.values[0, 1] * right.values[1, 1]) + (left.values[0, 2] * right.values[2, 1]) + (left.values[0, 3] * right.values[3, 1]); newMat[0, 2] = (left.values[0, 0] * right.values[0, 2]) + (left.values[0, 1] * right.values[1, 2]) + (left.values[0, 2] * right.values[2, 2]) + (left.values[0, 3] * right.values[3, 2]); newMat[0, 3] = (left.values[0, 0] * right.values[0, 3]) + (left.values[0, 1] * right.values[1, 3]) + (left.values[0, 2] * right.values[2, 3]) + (left.values[0, 3] * right.values[3, 3]); // Second row newMat[1, 0] = (left.values[1, 0] * right.values[0, 0]) + (left.values[1, 1] * right.values[1, 0]) + (left.values[1, 2] * right.values[2, 0]) + (left.values[1, 3] * right.values[3, 0]); newMat[1, 1] = (left.values[1, 0] * right.values[0, 1]) + (left.values[1, 1] * right.values[1, 1]) + (left.values[1, 2] * right.values[2, 1]) + (left.values[1, 3] * right.values[3, 1]); newMat[1, 2] = (left.values[1, 0] * right.values[0, 2]) + (left.values[1, 1] * right.values[1, 2]) + (left.values[1, 2] * right.values[2, 2]) + (left.values[1, 3] * right.values[3, 2]); newMat[1, 3] = (left.values[1, 0] * right.values[0, 3]) + (left.values[1, 1] * right.values[1, 3]) + (left.values[1, 2] * right.values[2, 3]) + (left.values[1, 3] * right.values[3, 3]); // Third row newMat[2, 0] = (left.values[2, 0] * right.values[0, 0]) + (left.values[2, 1] * right.values[1, 0]) + (left.values[2, 2] * right.values[2, 0]) + (left.values[2, 3] * right.values[3, 0]); newMat[2, 1] = (left.values[2, 0] * right.values[0, 1]) + (left.values[2, 1] * right.values[1, 1]) + (left.values[2, 2] * right.values[2, 1]) + (left.values[2, 3] * right.values[3, 1]); newMat[2, 2] = (left.values[2, 0] * right.values[0, 2]) + (left.values[2, 1] * right.values[1, 2]) + (left.values[2, 2] * right.values[2, 2]) + (left.values[2, 3] * right.values[3, 2]); newMat[2, 3] = (left.values[2, 0] * right.values[0, 3]) + (left.values[2, 1] * right.values[1, 3]) + (left.values[2, 2] * right.values[2, 3]) + (left.values[2, 3] * right.values[3, 3]); // Fourth row newMat[3, 0] = (left.values[3, 0] * right.values[0, 0]) + (left.values[3, 1] * right.values[1, 0]) + (left.values[3, 2] * right.values[2, 0]) + (left.values[3, 3] * right.values[3, 0]); newMat[3, 1] = (left.values[3, 0] * right.values[0, 1]) + (left.values[3, 1] * right.values[1, 1]) + (left.values[3, 2] * right.values[2, 1]) + (left.values[3, 3] * right.values[3, 1]); newMat[3, 2] = (left.values[3, 0] * right.values[0, 2]) + (left.values[3, 1] * right.values[1, 2]) + (left.values[3, 2] * right.values[2, 2]) + (left.values[3, 3] * right.values[3, 2]); newMat[3, 3] = (left.values[3, 0] * right.values[0, 3]) + (left.values[3, 1] * right.values[1, 3]) + (left.values[3, 2] * right.values[2, 3]) + (left.values[3, 3] * right.values[3, 3]); return new(newMat); } Méthodes ^^^^^^^^^ La plupart des méthodes sont là pour créer des matrices de transformations / projection. Elle sont tiré des livres qui m'ont guidé durant ce projet. Voici un exemple d'une méthode qui créée une matrice de rotation autour de l'axe X. .. code:: c# /// /// Builds a rotation matrix for a rotation around x-axis. /// /// The counter clockwise angle in radian. /// The rotation matrix. public static Matrix4 CreateRotationX(double angle) { return new(new double[,] { { 1, 0, 0, 0 }, { 0, Math.Cos(angle), Math.Sin(angle), 0 }, { 0, -Math.Sin(angle), Math.Cos(angle), 0 }, { 0, 0, 0, 1 }, }); } J'ai également fait une méthode qui prend un vecteur en tant que paramètre et qui combine les trois rotations : .. code:: c# /// /// Builds a combined rotation matrix around the X, Y and Z axes. /// Angles in radians. /// /// Rotation that will be applied in the matrix. /// The rotation matrix. public static Matrix4 CreateRotation(Vector3 rotation) { double x = rotation.X; double y = rotation.Y; double z = rotation.Z; return new Matrix4(new double[,] { { Math.Cos(y) * Math.Cos(z), Math.Cos(y) * Math.Sin(z), -Math.Sin(y), 0, }, { (Math.Sin(x) * Math.Sin(y) * Math.Cos(z)) - (Math.Cos(x) * Math.Sin(z)), (Math.Sin(x) * Math.Sin(y) * Math.Sin(z)) + (Math.Cos(x) * Math.Cos(z)), Math.Sin(x) * Math.Cos(y), 0, }, { (Math.Cos(x) * Math.Sin(y) * Math.Cos(z)) + (Math.Sin(x) * Math.Sin(z)), (Math.Cos(x) * Math.Sin(y) * Math.Sin(z)) - (Math.Sin(x) * Math.Cos(z)), Math.Cos(x) * Math.Cos(y), 0, }, { 0, 0, 0, 1 }, }); } Pour la création de la matrice de rotation autour d'un axe arbitraire, je demande une structure ``Axis`` en paramètre. Cette structure est très simple, elle contient deux poits et l'axe à partir de l'origine (point1 - point0). Pour la matrice perspective, j'ai deux méthode, une qui demande les paramètres d'un frustum de vision et l'autre demande le fov : .. code:: c# /// /// Creates an perspective projection matrix. /// /// Left edge of the view frustum. /// Right edge of the view frustum. /// Bottom edge of the view frustum. /// Top edge of the view frustum. /// Distance to the near clip plane. /// Distance to the far clip plane. /// A perspective projection matrix. /// /// Thrown under the following conditions: /// /// depthNear is negative or zero /// depthFar is negative or zero /// depthNear is larger than depthFar /// /// /// Taken from here : https://github.com/opentk/opentk/blob/082c8d228d0def042b11424ac002776432f44f47/src/OpenTK.Mathematics/Matrix/Matrix4d.cs#L1024. public static Matrix4 CreatePerspectiveOffCenter( double left, double right, double bottom, double top, double depthNear, double depthFar) { if (depthNear <= 0) { throw new ArgumentOutOfRangeException(nameof(depthNear)); } if (depthFar <= 0) { throw new ArgumentOutOfRangeException(nameof(depthFar)); } if (depthNear >= depthFar) { throw new ArgumentOutOfRangeException(nameof(depthNear)); } double x = 2.0f * depthNear / (right - left); double y = 2.0f * depthNear / (top - bottom); double a = (right + left) / (right - left); double b = (top + bottom) / (top - bottom); double c = -(depthFar + depthNear) / (depthFar - depthNear); double d = -(2.0f * depthFar * depthNear) / (depthFar - depthNear); #pragma warning disable SA1117 // Parameters should be on same line or separate lines return new Matrix4( x, 0, 0, 0, 0, y, 0, 0, a, b, c, -1, 0, 0, d, 0); #pragma warning restore SA1117 // Parameters should be on same line or separate lines } /// /// Creates a perspective projection matrix. /// /// Angle of the field of view in the y direction (in radians). /// Aspect ratio of the view (width / height). /// Distance to the near clip plane. /// Distance to the far clip plane. /// A perspective projection matrix. /// /// Thrown under the following conditions: /// /// fovy is zero, less than zero or larger than Math.PI /// aspect is negative or zero /// depthNear is negative or zero /// depthFar is negative or zero /// depthNear is larger than depthFar /// /// public static Matrix4 CreatePerspectiveFieldOfView(double fovy, double aspect, double depthNear, double depthFar) { if (fovy <= 0 || fovy > Math.PI) { throw new ArgumentOutOfRangeException(nameof(fovy)); } if (aspect <= 0) { throw new ArgumentOutOfRangeException(nameof(aspect)); } if (depthNear <= 0) { throw new ArgumentOutOfRangeException(nameof(depthNear)); } if (depthFar <= 0) { throw new ArgumentOutOfRangeException(nameof(depthFar)); } double maxY = depthNear * Math.Tan(0.5d * fovy); double minY = -maxY; double minX = minY * aspect; double maxX = maxY * aspect; return CreatePerspectiveOffCenter(minX, maxX, minY, maxY, depthNear, depthFar); } La structure matrice implémente l'interface ``IEquatable``, j'ai donc implémenté les méthodes demandé par celle-ci mais j'ai également ajouté une version qui peut prendre une tolérance. .. code:: c# /// public bool Equals(Matrix4 other) { for (int i = 0; i < Size; i++) { for (int j = 0; j < Size; j++) { if (values[i, j] != other.values[i, j]) { return false; } } } return true; } /// /// Comparator within a tolerance. /// /// Matrix to compare with. /// Tolerance to apply in the comparison. /// True if two matrices are equal withing a tolerance. public bool Equals(Matrix4 other, double tolerance) { for (int i = 0; i < Size; i++) { for (int j = 0; j < Size; j++) { if (values[i, j].AlmostEqualsWithAbsTolerance(other.values[i, j], tolerance)) { return false; } } } return true; } Face ---- Un modèle n'est pas que constitué de points, mais également de faces pour organiser ces points entre eux. J'ai donc aussi créé une classe ``Face`` qui possède un tableau de sommets qui sont des vecteurs 4. Chaque face possède également une couleur qui peut être changée plus tard afin de simuler l'éclairage. Comme une face peut avoir plus de 3 sommets, j'ai décidé d'utiliser la méthode de newell afin de calculer la normale de la face. (Voir le `wiki de OpenGL `_) .. code:: c# /// /// Gets the normal of the face. /// public Vector3 Normal { get { Vector3 normal = new(0, 0, 0); for (int i = 0; i < vertices.Length; i++) { Vector3 current = vertices[i].ToPhysicalCoords(); Vector3 next = vertices[(i + 1) % vertices.Length].ToPhysicalCoords(); double x = normal.X + ((current.Y - next.Y) * (current.Z + next.Z)); double y = normal.Y + ((current.Z - next.Z) * (current.X + next.X)); double z = normal.Z + ((current.X - next.X) * (current.Y + next.Y)); normal = new(x, y, z); } return normal.NormalizeOrDefault(); } } Le centre de la face est calculable en faisant la moyenne des points de celle-ci : .. code:: c# /// /// Gets the center of the face. /// public Vector3 Center { get { Vector3 center = new(0, 0, 0); for (int i = 0; i < vertices.Length; i++) { center += vertices[i].ToPhysicalCoords(); } center /= vertices.Length; return center; } } Après la projection, il est utile de savoir si une face est dans le sens des aiguilles d'une montre ou pas (pour le backface culling). Pour ce faire, j'ai créé cette propriété : .. code:: c# /// /// Gets a value indicating whether the vertices in this face are clockwise. /// public bool IsClockwise { get { double sum = 0d; for (int i = 0; i < vertices.Length; i++) { Vector3 current = vertices[i].ToPhysicalCoords(); // Modulo so that when we are at the last item, next is the first one Vector3 next = vertices[(i + 1) % vertices.Length].ToPhysicalCoords(); sum += (next.X - current.X) * (next.Y + current.Y); } return sum > 0d; } } L'opérateur * à été surchargé afin de pouvoir multiplier une face à une matrice. .. code:: c# /// /// Multiplies the vertices of the face by the matrix. /// /// The face to multiply. /// The matrix to multiply the face by. /// The multiplied face. public static Face operator *(Face f1, Matrix4 m2) { Vector4[] newVertices = new Vector4[f1.Vertices.Length]; for (int i = 0; i < newVertices.Length; i++) { newVertices[i] = f1.Vertices[i] * m2; } return new(newVertices, f1.Color); } Pour gérer l'éclairage, j'ai fait une méthode qui prends la direction de la lumière et sa couleur en paramètre : .. code:: c# /// /// Updates the color (lighting) of the face. /// /// The direction vector of the light. /// The color the face will be at 100% lighting. public void LightUp(Vector3 lightDirection, Color lightColor) { double dot = Normal.Dot(lightDirection); Color color = Color; if (dot > 0) { int red = (int)(lightColor.R * dot) % 255; int green = (int)(lightColor.G * dot) % 255; int blue = (int)(lightColor.B * dot) % 255; color = Color.FromArgb(red, green, blue); } Color = color; } Le produit scalaire entre la normale de la face et la direction de la lumière permet de définir la clartée de la face. Model ----- Finalement, ces faces sont stockées dans une classe ``Model``. Cette dernière à une surcharge d'opérateur afin qu'elle puisse être multipliée par une matrice. .. code:: c# public static Model operator *(Model model, Matrix4 matrix) { Face[] newFaces = new Face[model.Faces.Length]; Parallel.For(0, newFaces.Length, (i) => { newFaces[i] = model.Faces[i] * matrix; }); return new(newFaces); } La multiplication est parallèlisée pour de meilleures performances. Afin de pouvoir charger un modèle depuis un fichier, j'ai créé la méthode :csharp:`static Model FromPly(string path)` qui permet de charger un modèle au format ``.ply`` qui a été exporté par blender selon le guide indiqué dans l'analyse fonctionnelle. Les fichiers ``.ply`` sont organisés de manière à ce qu'il y ait premièrement une liste de sommets, puis une liste de faces. Je lis donc ces deux éléments puis les stockes dans la classe. Il est également possible de créer un pavé droit via les méthodes :csharp:`static Model CreateRectangularCuboidCentered(double height, double width, double depth)` et :csharp:`static Model CreateRectangularCuboidOffCenter(double height, double width, double depth)`. La première centre le pavé droit sur son centre et la deuxième sur un bord. Graphe de scène ================ Afin de pouvoir organiser mes objets dans la scène, j'ai implémenté un `graphe de scène `__ (`Scene graph `_ en anglais). L'idée d'un graphe de scène est qu'il y a des objets qui sont des parents d'autres objets. Les enfants ont des positions, rotations et homothéties relatives à celles du parent. Ce qui fait que quand le parent bouge ou tourne, l'enfant bouge et tourne par rapport au parent. Cela permet de faire par exemple un coussin qui est un enfant d'un canapé et quand le canapé bouge, le coussin bouge avec le canapé. Le graphe de scène s'occupe de créer la "Matrice modèle" qui sert à mettre le modèle dans le world space comme expliqué dans la :ref:`figure ci-dessus`. Afin d'implémenter ça, j'ai créé 3 classes : ``Entity``, ``EntityCollection`` et ``Scene``. Classe Scene ------------ Cette classe est la plus simple des trois, elle possède une instance de EntityCollection afin d'avoir une liste d'entité. Elle possède également une caméra qui se trouve dans la scène. Finalement, elle a une propriété qui permet d'obtenir toutes les faces de la scène, triée des plus éloignées de la caméra aux plus proches. .. code:: c# /// /// Gets all the absolute faces sorted from the furthest to the camera to the closest. /// public List SortedAbsoluteFaces { get { List faces = entities.AbsoluteFaces; faces.Sort((l, r) => r.Center.Distance(camera.Position).CompareTo(l.Center.Distance(camera.Position))); return faces; } } Le tri se fait grâce à la méthode ``Sort()`` qui est disponible sur les listes. Classe EntityCollection ----------------------- Cette classe est basiquement un "wrapper" sur une liste d'entités afin de gérer le parent de celles-ci. Une collection d'entités possède également un parent afin de rendre la gestion de celui-ci chez les enfants plus simple. Lorsqu'on ajoute une entité à la liste, cette classe s'occupe de vérifier qu'elle ne se trouve pas déjà dans la liste et met à jour son parent. Par exemple : .. code:: c# /// /// Adds an entity to the list. /// Checks if the entity is in the list first and updates it's parent when added. /// /// Entity to add in the list. public void Add(Entity entity) { if (!entities.Contains(entity)) { entities.Add(entity); entity.Parent = parent; } } Lorsque l'entité est retirée de la liste, on met son parent à :csharp:`null`. Afin que la scène puisse obtenir toutes les faces et les trier, j'ai ajouté une propriété :csharp:`List AbsoluteFaces` qui permet d'obtenir toutes les faces contenues dans la collection. Celle-ci itère dans la liste d'entités et ajoute les faces du modèle de cette entité dans une liste de face. Comme la liste d'enfants des entités est également une ``EntityCollection``, j'ajoute ensuite les ``AbsoluteFaces`` de la liste d'enfants dans cette liste de face. Ce qui fait que c'est une propriété récursive. Pour finir, j'ai implémenté l'interface :csharp:`IEnumerable` afin de pouvoir itérer sur les faces de cette classe dans une boucle ``foreach`` sans exposer la liste directement. Classe Entity ------------- Une entité est tout ce qui peut se trouver sur une scène. Chaque entité possède un vecteur 3 qui représente sa position, un vecteur 3 qui représente sa rotation et un double qui représente son homothétie. J'aurais pu utiliser un vecteur 3 pour l'homothétie également, mais je n'ai pas fait de matrice qui prend 3 paramètres, donc je ne l'ai pas fait. Cela dit, c'est possible. Les entités ont une liste d'enfants sous la forme d'une ``EntityCollection`` et également une référence à leur parent qui est une entité. Elles possèdent également un modèle qui est ce qui sera affiché sur le rendu. Une entité est considérée comme une racine si elle n'a pas de parent (:csharp:`Parent == null`) et une feuille si elle n'a pas d'enfants (:csharp:`Children.Count == 0`). Lors de la construction d'une entité, on regarde si on lui passe un parent, et si oui, on ajoute l'entité dans les enfants du parent. .. code::c# /// /// Initializes a new instance of the class. /// /// 3D model of this entity. /// Parent of this entity. public Entity(Model model, Entity parent) { Model = model; children = new(this); Parent = parent; parent?.Children.Add(this); Scale = 1; } Afin de pouvoir placer l'entité par rapport à son parent, il faut obtenir une transformation relative. Celle-ci est composée d'une matrice d'homothétie, de rotation et de translation (dans cet ordre-là). La matrice d'homothétie est constituée de la valeur d'homothétie (scale). La matrice de rotation est la matrice résultante de la multiplication entre une rotation X, Y et Z (d'où le vecteur 3). La matrice de translation utilise la position relative de l'entité. Tout ceci est regroupé dans la propriété ``Matrix4 Transformation``. .. code:: c# /// /// Gets the relative transformation matrix of this entity. /// Contains the scale, rotation and translation of this entity. /// public Matrix4 Transformation { get { Matrix4 transf = Matrix4.CreateScale(Scale); transf *= Matrix4.CreateRotation(Rotation); transf *= Matrix4.CreateTranslation(Position); return transf; } } Pour obtenir la matrice de transformation absolue (transformation vers le world space), il faut multiplier la matrice de transformation relative de l'entité par celle de son parent. Ceci se trouve dans la propriété :csharp:`Matrix4 AbsoluteTransformation`. .. code:: c# /// /// Gets the absolute transformation matrix of this entity. /// Contains the scale, rotation and translation of this entity multiplied by his parent's. /// Gets if is false. /// public Matrix4 AbsoluteTransformation { get { Matrix4 transf = Transformation; if (!IsRoot) { transf *= Parent.AbsoluteTransformation; } return transf; } } Finalement, pour avoir le modèle dans l'espace monde, il faut multiplier le modèle par la matrice de transformation absolue. Ceci se trouve dans la propriété ``Model AbsoluteModel``. Caméra ======= La caméra de ce projet est basée sur celle décrite dans le tutoriel `LearnOpenTK `_. Elle possède une position dans le monde et son orientation est définie par deux doubles ``pitch`` et ``yaw`` pour son orientation. Elle n'a pas de ``roll``, car c'est une caméra style "FPS". La caméra stocke également 3 vecteurs : ``front``, ``up`` et ``right``. Ils servent à savoir les axes de l'espace vue de la caméra. Lorsque l'orientation de la caméra est changée, ces vecteurs sont mis à jour. Le pitch est limité entre -89 et 89 degrés afin d'empêcher le `blocage de cardan `_. Lors de la mise à jours des vecteurs, le premier à être mis à jour est ``front``. On utilise de la trigonométrie grâce au pitch et au yaw pour le trouver. ``up`` est toujours égal à l'axe Y monde. ``right`` quand a lui est calculé en faisant le produit vectoriel de ``front`` et ``up``. Lorsqu'on veut projeter les modèles avec cette caméra, il faut en obtenir la matrice vue et la matrice de projection. Pour ce faire, j'ai créé deux propriétés : :csharp:`Matrix4 ViewMatrix` et :csharp:`Matrix4 ProjectionMatrix`. Ces deux propriétés utilisent respectivement les méthodes :csharp:`LookAt` et :csharp:`CreatePerspectiveFieldOfView` de la classe ``Matrix4``. Mise en place de la scène ========================== Voici un exemple de comment on créer une scène afin de l'afficher dans l'application. .. code:: c# Camera camera = new(new(0, 0, 6), aspect); // Create backhoe scene demoBackhoeScene = new(camera); Model armModel = Model.FromPly(@"./ply/backhoe/arm.ply"); Entity wheels = new(Model.FromPly(@"./ply/backhoe/wheels.ply")); wheels.Scale = 1 / 0.5d; Entity body = new(Model.FromPly(@"./ply/backhoe/body.ply"), wheels); body.Position = new(0, 2.3d, 0); Entity armOne = new(armModel, body); armOne.Position = new(3, 1, 0); armOne.Rotation = new(0, 0, MathHelper.DegToRad(45)); Entity armTwo = new(armModel, armOne); armTwo.Position = new(6, 0, 0); armTwo.Rotation = new(0, 0, MathHelper.DegToRad(-90)); demoBackhoeScene.Entities.Add(wheels); Premièrement, il faut créer une caméra que l'on passe à la scène. Ensuite, il faut créer les modèles que nos entités vont avoir. Avec ces modèles, on peut les passer aux entités tout en leur donnant un parent s'ils en ont un. Il est ensuite possible de leur donner une postion, rotation et scale. Pour finir, on peut passer les entités racines à la scène. Affichage ========= La forme ``FrmMain`` possède deux timers, le premier qui va rafraichir la fenêtre et donc afficher l'image rendue du ``Renderer`` et l'autre va appeler la méthode ``RenderNextFrame()`` sur le renderer. Le Renderer va d'abord créer une matrice de projection qui sera une combinaison de la matrice vue et de la matrice projection du :ref:`schéma`. Celles-ci sont obtenues depuis la caméra. Ensuite, les axes sont dessinés. Ce sont des lignes définies par des points qui sont projetés dans l'espace. Après, on obtient les faces triées en fonction de la distance de la caméra via la scène. Ce procédé s'appelle `algorithme du peintre `_. WTF .. code:: c# /// /// Renders the next frame. /// public void RenderNextFrame() { // Clear the image in black g.Clear(Color.Black); // Update the projection matrix projection = scene.Camera.ViewMatrix * scene.Camera.ProjectionMatrix; // Draw the X, Y and Z axes on the scene DrawAxes(); // Draw the faces foreach (Face face in scene.SortedAbsoluteFaces) { // Don't draw the faces if we cull with the normals of the faces if (CullingMode == CullingMode.Normals) { // The face is not facing the camera, we continue if (face.Normal.IsBackFace(scene.Camera.LineOfSight)) { continue; } } // Light the face if (ShowFill) { face.LightUp(scene.Camera.LineOfSight, LightColor); } // Project the face Face projectedFace = face * projection; // Draw the face DrawFace(projectedFace, face); } // Draw camera informations g.DrawString($"Camera pos : {scene.Camera.Position}", new Font("Cascadia Code", 13), Brushes.White, new PointF(0, 0)); double pitch = Math.Round(MathHelper.RadToDeg(scene.Camera.Pitch)); double yaw = Math.Round(MathHelper.RadToDeg(scene.Camera.Yaw)); g.DrawString( $"Camera angle (pitch, yaw): {pitch}, {yaw}", new Font("Cascadia Code", 13), Brushes.White, new PointF(0, 20)); } Si le backface culling à l'aide des normales est activé, on check si celle-ci font face à la caméra ou pas. On regarde également si l'on veut remplir les faces, si c'est le cas, on choisiat la couleur des faces en fonction de la caméra qui est notre source d'éclairage et d'une couleur d'éclairage. On pourrait choisir un autre endroit comme source de lumière, le choix de la caméra est arbitraire. Enfin, les faces sont multipliées par la matrice de projection, puis dessinées dans l'image.