Analyse organique¶
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.
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 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 <Based on this tutorial : https://www.codeproject.com/articles/17425/a-vector-type-for-c.>_. 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 :
/// <summary>
/// An origin vector. Is equal to Vector(0, 0, 0). Same as Zero.
/// </summary>
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.
/// <summary>
/// The X component of the vector.
/// </summary>
private readonly double x;
/// <summary>
/// The Y component of the vector.
/// </summary>
private readonly double y;
/// <summary>
/// The Z component of the vector.
/// </summary>
private readonly double z;
Propriétés¶
Comme les coordonnées du vecteur sont readonly, je les rends accessible avec des getteurs uniquement :
/// <summary>
/// Gets the X component of the vector.
/// </summary>
public double X => x;
La magnitude (longueur) des vecteurs se trouve dans la propriété Magnitude :
/// <summary>
/// Gets the magnitude (aka. length or absolute value) of the vector.
/// </summary>
public double Magnitude => Math.Sqrt(SqrMagnitude);
SqrMagnitude sert à comparer la longueur de deux vecteurs sans avoir à effectuer une racine carrée.
/// <summary>
/// Gets the squared magnitude of this vector, can be used for better performance than Magnitude.
/// </summary>
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 :
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 :
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.
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¶
/// <summary>
/// Determines the dot product of two vectors.
/// </summary>
/// <param name="v1">The vector to multiply.</param>
/// <param name="v2">The vector to multiply by.</param>
/// <returns>Returns a scalar representing the dot product of the two vectors.</returns>
public static double Dot(Vector3 v1, Vector3 v2) => (v1.X * v2.X) + (v1.Y * v2.Y) + (v1.Z * v2.Z);
/// <summary>
/// Determine the cross product of two Vectors.
/// Determine the vector product.
/// Determine the normal vector (Vector3 90° to the plane).
/// </summary>
/// <param name="v1">The vector to multiply.</param>
/// <param name="v2">The vector to multiply by.</param>
/// <returns>Vector3 representig the cross product of the two vectors.</returns>
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 :
/// <summary>
/// Determines the dot product of two vectors.
/// </summary>
/// <param name="other">The vector to multiply by.</param>
/// <returns>Returns a scalar representing the dot product of the two vectors.</returns>
public double Dot(Vector3 other) => Dot(this, other);
/// <summary>
/// Determine the cross product of two Vectors.
/// Determine the vector product.
/// Determine the normal vector (Vector3 90° to the plane).
/// </summary>
/// <param name="other">The vector to multiply by.</param>
/// <returns>Vector3 representig the cross product of the two vectors.</returns>
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 :
/// <summary>
/// Checks if the vector is a unit vector.
/// Checks if the vector has be normalized.
/// Checks if the vector has a magnitude of 1.
/// </summary>
/// <param name="v1">The vector to be checked for normalization.</param>
/// <returns>Returns true if the vector is a unit vector.</returns>
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.
/// <summary>
/// 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.
/// </summary>
/// <param name="v1">The vector to be checked for normalization.</param>
/// <param name="tolerance">The tolerance to use when comparing the magnitude.</param>
/// <returns>Returns true if the vector is a unit vector.</returns>
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.
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() :
/// <summary>
/// Gets the normalized unit vector with a magnitude of one.
/// </summary>
/// <param name="v1">The vector to be normalized.</param>
/// <returns>Returns the normalized vector.</returns>
/// <exception cref="NormalizeVectorException">
/// Thrown when the normalisation of a zero magnitude vector is attempted.
/// </exception>
/// <exception cref="NormalizeVectorException">
/// Thrown when the normalisation of a NaN magnitude vector is attempted.
/// </exception>
/// <remarks>
/// Exceptions will be thrown if the vector being normalized has a magnitude of 0 or of NaN.
/// </remarks>
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() :
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() :
/// <summary>
/// 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.
/// </summary>
/// <param name="v1">The vector to be normalized if it is a special case.</param>
/// <returns>Normialized special case vectors, NaN or the origional vector.</returns>
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() :
/// <summary>
/// Gets the normalized unit vector with a magnitude of one.
/// </summary>
/// <param name="v1">The vector to be normalized.</param>
/// <returns>The normalized vector3 or vector (NaN,NaN,NaN) if the magnitude is 0 or NaN.</returns>
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() :
/// <summary>
/// Gets the normalized unit vector with a magnitude of one.
/// </summary>
/// <param name="v1">The vector to be normalized.</param>
/// <returns>Returns Vector (0,0,0) if the magnitude is zero, Vector (NaN, NaN, NaN) if magnitude is NaN, or normalized vector.</returns>
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 :
/// <summary>
/// Checks if a face normal vector represents back face.
/// Checks if a face is visible, given the line of sight.
/// </summary>
/// <param name="normal">The vector representing the face normal Vector3.</param>
/// <param name="lineOfSight">The unit vector representing the direction of sight from a virtual camera.</param>
/// <returns>True if the vector (as a normal) represents a back-face.</returns>
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 :
/// <summary>
/// Converts this vector to a <see cref="PointF"/>.
/// Only keeps the X and Y components of this vector.
/// </summary>
/// <returns>A point with the X and Y components of this vector.</returns>
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 :
/// <summary>
/// Converts this vector to a <see cref="Vector4"/>.
/// The <see cref="Vector4.W"/> component will be equal to 1.
/// </summary>
/// <returns>The Vector3 as a <see cref="Vector4"/>.</returns>
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 :
/// <summary>
/// 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.
/// </summary>
/// <returns>Returns the physical coordinates of this vector.</returns>
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.
/// <summary>
/// Converts this vector to a <see cref="PointF"/>.
/// Firsts gets his physical coordinates, then only keeps the X and Y components.
/// </summary>
/// <returns>A <see cref="PointF"/> containing the physical X and Y coordinates of the vector.</returns>
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 :
private readonly double[,] values;
Propriétés¶
J’ai ajouté un getter sur le champ values :
/// <summary>
/// Gets the values of the matrix.
/// </summary>
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 :
/// <summary>
/// Gets the value at 0, 0 on the matrix.
/// </summary>
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é :
/// <summary>
/// Gets the determinant of the matrix.
/// </summary>
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 :
/// <summary>
/// Adds a matrix and a scalar.
/// </summary>
/// <param name="mat">Matrix to add to.</param>
/// <param name="scalar">Scalar to add to the matrix.</param>
/// <returns>The added matrix.</returns>
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 :
/// <summary>
/// Adds two matrices.
/// </summary>
/// <param name="left">The matrix on the left.</param>
/// <param name="right">The matrix on the right.</param>
/// <returns>The added matrix.</returns>
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 :
/// <summary>
/// Multiplies two matrices.
/// </summary>
/// <param name="left">The left matrix.</param>
/// <param name="right">The right matrix.</param>
/// <returns>The dot product of the matrices.</returns>
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.
/// <summary>
/// Builds a rotation matrix for a rotation around x-axis.
/// </summary>
/// <param name="angle">The counter clockwise angle in radian.</param>
/// <returns>The rotation matrix.</returns>
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 :
/// <summary>
/// Builds a combined rotation matrix around the X, Y and Z axes.
/// Angles in radians.
/// </summary>
/// <param name="rotation">Rotation that will be applied in the matrix.</param>
/// <returns>The rotation matrix.</returns>
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 :
/// <summary>
/// Creates an perspective projection matrix.
/// </summary>
/// <param name="left">Left edge of the view frustum.</param>
/// <param name="right">Right edge of the view frustum.</param>
/// <param name="bottom">Bottom edge of the view frustum.</param>
/// <param name="top">Top edge of the view frustum.</param>
/// <param name="depthNear">Distance to the near clip plane.</param>
/// <param name="depthFar">Distance to the far clip plane.</param>
/// <returns>A perspective projection matrix.</returns>
/// <exception cref="System.ArgumentOutOfRangeException">
/// Thrown under the following conditions:
/// <list type="bullet">
/// <item>depthNear is negative or zero</item>
/// <item>depthFar is negative or zero</item>
/// <item>depthNear is larger than depthFar</item>
/// </list>
/// </exception>
/// <remarks>Taken from here : https://github.com/opentk/opentk/blob/082c8d228d0def042b11424ac002776432f44f47/src/OpenTK.Mathematics/Matrix/Matrix4d.cs#L1024. </remarks>
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
}
/// <summary>
/// Creates a perspective projection matrix.
/// </summary>
/// <param name="fovy">Angle of the field of view in the y direction (in radians).</param>
/// <param name="aspect">Aspect ratio of the view (width / height).</param>
/// <param name="depthNear">Distance to the near clip plane.</param>
/// <param name="depthFar">Distance to the far clip plane.</param>
/// <returns>A perspective projection matrix.</returns>
/// <exception cref="System.ArgumentOutOfRangeException">
/// Thrown under the following conditions:
/// <list type="bullet">
/// <item>fovy is zero, less than zero or larger than Math.PI</item>
/// <item>aspect is negative or zero</item>
/// <item>depthNear is negative or zero</item>
/// <item>depthFar is negative or zero</item>
/// <item>depthNear is larger than depthFar</item>
/// </list>
/// </exception>
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<Matrix4>, 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.
/// <inheritdoc/>
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;
}
/// <summary>
/// Comparator within a tolerance.
/// </summary>
/// <param name="other">Matrix to compare with.</param>
/// <param name="tolerance">Tolerance to apply in the comparison.</param>
/// <returns>True if two matrices are equal withing a tolerance.</returns>
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)
/// <summary>
/// Gets the normal of the face.
/// </summary>
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 :
/// <summary>
/// Gets the center of the face.
/// </summary>
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é :
/// <summary>
/// Gets a value indicating whether the vertices in this face are clockwise.
/// </summary>
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.
/// <summary>
/// Multiplies the vertices of the face by the matrix.
/// </summary>
/// <param name="f1">The face to multiply.</param>
/// <param name="m2">The matrix to multiply the face by.</param>
/// <returns>The multiplied face.</returns>
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 :
/// <summary>
/// Updates the color (lighting) of the face.
/// </summary>
/// <param name="lightDirection">The direction vector of the light.</param>
/// <param name="lightColor">The color the face will be at 100% lighting.</param>
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.
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 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 static Model CreateRectangularCuboidCentered(double height, double width, double depth) et 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 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.
/// <summary>
/// Gets all the absolute faces sorted from the furthest to the camera to the closest.
/// </summary>
public List<Face> SortedAbsoluteFaces
{
get
{
List<Face> 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 :
/// <summary>
/// Adds an entity to the list.
/// Checks if the entity is in the list first and updates it's parent when added.
/// </summary>
/// <param name="entity">Entity to add in the list.</param>
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 à null.
Afin que la scène puisse obtenir toutes les faces et les trier, j’ai ajouté une propriété List<Face> 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 IEnumerable<Entity> 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 (Parent == null) et une feuille si elle n’a pas d’enfants (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.
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.
/// <summary>
/// Gets the relative transformation matrix of this entity.
/// Contains the scale, rotation and translation of this entity.
/// </summary>
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é Matrix4 AbsoluteTransformation.
/// <summary>
/// Gets the absolute transformation matrix of this entity.
/// Contains the scale, rotation and translation of this entity multiplied by his parent's.
/// Gets <see cref="Transformation"/> if <see cref="IsRoot"/> is false.
/// </summary>
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 : Matrix4 ViewMatrix et Matrix4 ProjectionMatrix.
Ces deux propriétés utilisent respectivement les méthodes LookAt et 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.
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 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
/// <summary>
/// Renders the next frame.
/// </summary>
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.