La caméra et les matrices de projection
Une caméra 3D est une suite de transformations mathématiques qui convertissent les coordonnées du monde en coordonnées d'écran. Notre classe Camera encapsule ces matrices, gère un dirty flag (booléan annonçant une géométrie souillée), et expose des modes d'usage — FPS, orbite, follow, transition animée, shake.
Notre objet caméra écrit dans un buffer nommés ici cameraUniforms qui sera envoyé aux shaders (dans le vertex buffer(1)) nécessitant trois dimensions minimum. Elle n'affiche rien à l'écran, ce sont des perspectives.
L’objet caméra n’est pas magique, ni fourni par l’API Metal. C’est l’ensemble de coordonnées de sommet du point de vue de la caméra, comme l'origine de la scène : la matrice de vue transforme toutes les coordonnées du monde en coordonnées de vue.
Tu vas en fait créer un système de coordonnées avec 3 axes unitaires perpendiculaires avec la position de la caméra comme origine.
Pour définir une caméra :
Le meilleur moyen d'y parvenir est d'utiliser la méthode appelée look-at.
Une position (ou eye) dans l'espace mondial.
L’axe z de la matrice de vue doit être négatif parce que par convention (API Metal) la caméra pointe vers l'axe z positif. Droit devant toi.
simd::float3 position = { 0.0f, 0.0f, -3.0f }; // ← eye
Une direction vers laquelle elle regarde.
Soustraire le v⃗ de la cible (target, qui est le centre des axes pour commencer) du v⃗ position donne un v⃗ qui est la différence de ces deux vecteurs, le v⃗ direction :
simd::float3 target = { 0.0f, 0.0f, 0.0f }; // ← cible — centre fenêtre
simd::float3 direction = simd::normalize(target - position); // ← target - eye = forward (0, 0, +1)
0 - (-3) = 3 → normalisé → 1
Un vecteur pointant vers la droite.
L’axe x positif. Pour l’obtenir : on invente un vecteur pointant vers le haut (up) et on effectue un produit croisé (produit vectoriel) sur ce vecteur et le v⃗ de direction
simd::float3 up = { 0.0, 1.0, 0.0 }; // ← haut
simd::float3 right = simd::normalize(simd::cross(up, direction)); // ← droite
Puisque le résultat d'un produit croisé est un vecteur perpendiculaire aux deux vecteurs, nous aurons un vecteur qui pointe dans la direction de l'axe x positif :
\( \vec{up} \times \vec{direction} = \begin{pmatrix} 0.0 \\ 1.0 \\ 0.0 \end{pmatrix} \times \begin{pmatrix} 0.0 \\ 0.0 \\ 1 \end{pmatrix} = \begin{pmatrix} 1 \\ 0.0 \\ 0.0 \end{pmatrix} \)
Un vecteur pointant vers le haut.
Pour obtenir le bon \( \vec{up} \) on effectue un produit croisé du v⃗ de direction et du v⃗ de droite.
simd::float3 up = simd::normalize(simd::cross(direction, right)); // ← haut final
LookAt Matrix.
Nous savons depuis le chapitre sur les vecteurs que trois vecteurs orthogonaux \( \mathbf{right}, \mathbf{up}, \mathbf{forward} \) (avant) suffisent à décrire une orientation complète dans \( \mathbb{R}^3 \) (ensemble des 3 nombres réels), on peut encoder toute la pose de la caméra dans une seule matrice \(4 \times 4\) appelée world matrix de la caméra — c'est exactement ce que retourne notre fonction sViewMatrix (s dans le nom, pour static) :
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* + + */
/* File: Camera.cpp +++ +++ */
/* + + */
/* By: Laboitederemdal + + */
/* + + */
/* Created: 18/09/2025 18:56:40 + + + + + + */
/* Updated: 18/05/2026 */
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "Camera.hpp"
// params: position caméra, position + _direction (pas la target !), haut
static simd::float4x4 sViewMatrix(simd::float3 inEye, simd::float3 inTo, simd::float3 inUp)
{
simd::float3 z = simd::normalize(inTo - inEye); // axe forward (avant)
simd::float3 x = simd::normalize(simd::cross(inUp, z)); // axe right (droite)
simd::float3 y = simd::cross(z, x); // axe up (reortho)
// translation projetée sur chaque axe
simd::float3 t = (simd::float3){-simd::dot(x, inEye),-simd::dot(y, inEye),-simd::dot(z, inEye) };
// Matrice column-major : colonnes x, y, z, translation
return (simd::float4x4(simd::float4 { x.x, y.x, z.x, 0 },
simd::float4 { x.y, y.y, z.y, 0 },
simd::float4 { x.z, y.z, z.z, 0 },
simd::float4 { t.x, t.y, t.z, 1 }));
}
Produit vectoriel, notation mathématique :
\( LookAt = \begin{bmatrix} right_x && right_y && right_z && 0 \\ up_x && up_y && up_z && 0 \\ direction_x && direction_y && direction_z && 0 \\ 0 && 0 && 0 && 1 \end{bmatrix} \times \begin{bmatrix} 1 && 0 && 0 && -position_x \\ 0 && 1 && 0 && -position_y \\ 0 && 0 && 1 && -position_z \\ 0 && 0 && 0 && 1 \end{bmatrix} \)
\( LookAt = \begin{bmatrix} right_x && right_y && right_z && -right \cdot position \\ up_x && up_y && up_z && -up \cdot position \\ direction_x && direction_y && direction_z && -direction \cdot position \\ 0 && 0 && 0 && 1 \end{bmatrix} \)
Une matrice dont les colonnes sont \( \mathbf{right}, \mathbf{up}, \mathbf{forward} \) et la \( \mathbf{position} \), qui exprime le passage du repère caméra vers le repère monde.
La viewMatrix — le regard de la caméra
Or le shader ne veut pas ce passage-là — il veut l'inverse : transformer chaque vertex du monde vers l'espace caméra. On obtient la view matrix en inversant cette world matrix :
\[ \vec{v} = Matrix_{\text{camera}}^{-1} \]
simd::float4x4 viewMatrix = sViewMatrix(position, position + direction, up);
Et puisque \( M_{\text{camera}} \) est une matrice rigide (rotation + translation pures, sans échelle), son inverse a une forme analytique compacte — les axes en lignes, la translation projetée sur ces axes :
simd::float4x4 invViewMatrix = simd::inverse(viewMatrix);
Contrairement à OpenGL, la matrice InvLookAt et les matrices de projection ne sont pas fournies par Metal.
Notre objet — Camera.cpp
Une position initiale, la direction vers le centre du cube dessiné, le vecteur positif haut, 60° d'angle pour commencer exprimé en radians, le rapport d'aspect approximatif, un petit polygone créé par le plan de coupe, la taille du rectangle en projection orthographique, un booléan pour recalculer tout ça uniquement lorsque la caméra bouge.
→ \( \sphericalangle = \frac{\pi}{3} = 1.0471975512 \) radians
→ \( \sphericalangle = 1.0471975512 * (\frac{180}{\pi}) = 60.0\degree \)
→ \( aspectRatio = \frac{windowWidth}{windowHeight} \)
/// C++ — Camera.cpp — update:18/05/26
} // double fermeture de sViewMatrix(), NE PAS RECOPIER
// notre classe comment ici :
Camera::Camera()
: m_position{0, 0, -3}, m_direction{0, 0, 1}, m_up{0, 1, 0}, m_viewAngle(M_PI / 3.f), m_aspectRatio(1.8),
m_nearPlane(0.1f), m_farPlane(100.0f), m_width(0), m_needRebuild(true)
{}
Camera::~Camera() {} // aucune valeur membre de la classe à détruire
// On remplit/change les paramètres de la classe — Init
Camera& Camera::initPerspectiveWithPosition(simd::float3 position, simd::float3 direction, simd::float3 up,
float viewAngle, float aspectRatio, float nearPlane, float farPlane)
{
m_direction = direction;
m_position = position;
m_up = up;
m_viewAngle = viewAngle;
m_aspectRatio = aspectRatio;
m_nearPlane = nearPlane;
m_farPlane = farPlane;
m_needRebuild = true;
return *this;
}
La liste d'initialisation : m_width(0) etc. Cela équivaut à init dans la classe : float m_width = 0; avec la priorité en supplément dans le cas où les deux sont définis !
Attention à l'initialisation pour ne pas provoquer un NaN : normalize{0, 0, 0}.
Faire/Refaire la géométrie
/// C++ — Camera.cpp — update:18/05/26
void Camera::rebuild() const
{
m_cameraUniforms.viewMatrix = sViewMatrix(m_position, m_position + m_direction, m_up);
m_cameraUniforms.projectionMatrix = math::makePerspective(m_viewAngle, m_aspectRatio, m_nearPlane, m_farPlane);
m_cameraUniforms.viewProjectionMatrix = m_cameraUniforms.projectionMatrix * m_cameraUniforms.viewMatrix;
m_cameraUniforms.invViewMatrix = simd::inverse(m_cameraUniforms.viewMatrix);
m_cameraUniforms.invProjectionMatrix = simd::inverse(m_cameraUniforms.projectionMatrix);
m_cameraUniforms.invViewProjectionMatrix = simd::inverse(m_cameraUniforms.viewProjectionMatrix);
m_cameraUniforms.invOrientationProjectionMatrix = simd::inverse(m_cameraUniforms.projectionMatrix *
sViewMatrix(simd::float3{0, 0, 0},
m_direction, m_up));
m_needRebuild = false;
}
Perspective
Une fois les vertices dans l'espace caméra, il faut simuler la perspective — le fait que les objets lointains paraissent plus petits.
C'est le rôle du field of view (FOV) : il définit l'angle d'ouverture du cône de vision. Plus \(\theta\) (angle) est grand, plus la scène paraît déformée et "fisheye" ; plus il est petit, plus l'image semble télé-objectif. À tester !
On en dérive un facteur de mise à l'échelle \( f = \frac{1}{\tan(\theta/2)} \) : quand un vertex est loin (grand \(z\)), sa coordonnée écran \( x/z \quad (\frac{width}{height}) \) diminue proportionnellement — c'est toute la mécanique de la perspective. La matrice de projection encode cette division en différant la division par \(z\) au GPU (la perspective divide) :
\[ P = \begin{bmatrix} f/\text{aspect} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & \dfrac{\text{far}}{\text{near}-\text{far}} & \dfrac{\text{near}\cdot\text{far}}{\text{near}-\text{far}} \\ 0 & 0 & -1 & 0 \end{bmatrix} \]
La ligne \( -1 \) en bas copie \( -z_{\text{view}} \) dans \( w_{\text{clip}} \), et le GPU divise ensuite tout le vecteur clip par ce \( w \) — c'est la division perspective. Résultat : les coordonnées NDC \( (x/w,\, y/w) \) sont d'autant plus petites que l'objet est loin.
En multipliant les deux, on obtient la view-projection matrix qui condense en une seule opération le passage du monde vers l'écran :
\[ ViewProjectionMatrix = ProjectionMatrix \times ViewMatrix \]
Et parce que certains effets (ray casting, déferred shading, reconstruction de position depuis le depth buffer) ont besoin du chemin inverse — repartir d'un pixel NDC pour retrouver un point monde — on précalcule les inverses une seule fois sur le CPU plutôt que de les calculer dans chaque fragment shader :
\[ VP^{-1} = (P \times V)^{-1}, \quad V^{-1} = M_{\text{cam}}, \quad P^{-1} \]
Enfin, invOrientationProjectionMatrix est une variante où la translation de la caméra est délibérément ignorée — seule l'orientation est conservée — ce qui est exactement ce dont on a besoin pour un skybox ou un environnement infini : l'observateur tourne mais ne "déplace" jamais le fond du ciel.
\[ \mathrm{invOrientationProjectionMatrix} = \textcolor{50f17F}{\mathrm{simd::inverse}} \left( P \times \textcolor{92E846}{\mathrm{sViewMatrix}} (\mathbf{0}, \mathbf{dir}, \mathbf{up}) \right) \]
Toute la chaîne — position caméra, orientation, FOV, near/far, aspect — se retrouve ainsi compressée en quelques matrices précalculées que le vertex shader consomme en une seule multiplication par vertex.
Le polygone (view fructum) contient ce que tu vois à l'écran.
/// C++ — Camera.cpp — update:18/05/26
// Retourne la copie de la structure du fichier _shared membre de notre classe Camera si besoin
CameraUniforms Camera::updateUniforms() const
{
if (m_needRebuild)
rebuild();
return m_cameraUniforms;
}
// 6 Getters — les membres sont toujours privés, on les récupère en toute sécurité
simd::float3 Camera::getPosition() const
{
return m_position;
}
simd::float3 Camera::getForward() const
{
return m_direction;
}
simd::float3 Camera::getUp() const
{
return m_up;
}
simd::float3 Camera::getRight() const
{
return simd::normalize(simd::cross(m_up, m_direction));
}
simd::float3 Camera::getLeft() const
{
return -getRight();
}
simd::float4x4 Camera::getInvViewProjectionMatrix() const
{
return (m_cameraUniforms.invViewProjectionMatrix); // ou updateUniforms().invViewProjectionMatrix
}
// 6 Setters — les membres sont toujours privés, on les modifie en toute sécurité
void Camera::setPosition(simd::float3 newPosition)
{
m_position = newPosition;
m_needRebuild = true;
}
void Camera::setViewAngle(float newAngle)
{
m_width = 0;
m_viewAngle = newAngle;
m_needRebuild = true;
}
void Camera::setWidth(float newWidth)
{
m_viewAngle = 0;
m_width = newWidth;
m_needRebuild = true;
}
void Camera::setUp(simd::float3 newUp)
{
orthogonalizeFromNewUp(newUp);
m_needRebuild = true;
}
void Camera::setDirection(simd::float3 newDirection)
{
orthogonalizeFromNewForward(newDirection);
m_needRebuild = true;
}
void Camera::setAspectRatio(float aspect)
{
m_aspectRatio = aspect;
m_needRebuild = true;
}
void Camera::lookAt(simd::float3 target)
{
simd::float3 direction = target - m_position; // ← forward (avant)
float distance = simd::length(direction);
if (distance < 0.001f)
return;
m_direction = direction / distance;
simd::float3 up = {0.f, 1.f, 0.f}; // ← haut hardcodé (temp)
simd::float3 right = simd::normalize(simd::cross(up, m_direction)); // ← droite
m_up = simd::normalize(simd::cross(m_direction, right));
// view matrix camera rows:
// right → axe X caméra ( +1, 0, 0)
// up → axe Y caméra ( 0, +1, 0)
// direction → axe Z caméra ( 0, 0, +1)
m_needRebuild = true;
}
// si direction & up passés par l'appelant (setUp(), …) ne sont pas parfaitement orthogonaux
void Camera::orthogonalizeFromNewUp(simd::float3 newUp)
{ // on stocke le newUp en le normalisant, par sécurité
m_up = simd::normalize(newUp);
simd::float3 right = simd::normalize(simd::cross(m_up, m_direction)); // m_up écrasée par newUp
m_up = simd::normalize(simd::cross(m_direction, right));
}
void Camera::orthogonalizeFromNewForward(simd::float3 newForward)
{
m_direction = simd::normalize(newForward);
simd::float3 right = simd::normalize(simd::cross(m_up, m_direction));
m_up = simd::normalize(simd::cross(m_direction, right));
}
Les matrices inverses — à quoi elles servent
| Matrice | Usage typique |
|---|---|
invViewProjectionMatrix |
Raycast depuis le curseur : clip → world. Depth picking, sélection d'objets. |
invProjectionMatrix |
Reconstruire la position view depuis le depth buffer. SSAO, deferred shading. |
invViewMatrix |
Transformer des vecteurs de view space → world space. Réflexions, éclairage. |
invOrientationProjectionMatrix |
Skybox : view matrix sans la translation (caméra à l'origine), clip → direction monde. |
/// C++ — Camera.hpp — update:18/05/26
#ifndef Camera_hpp
#define Camera_hpp
#include <functional>
#include "Math.hpp" // make projection etc.
#include "../includes/SharedGPU/Renderer_shared.h" // structure de nos matrices globales (uniforms)
static simd::float4x4 sViewMatrix(simd::float3 inEye, simd::float3 inTo, simd::float3 inUp); // retire static
class Camera
{
public:
Camera();
~Camera();
Camera& initPerspectiveWithPosition(simd::float3 position, simd::float3 direction, simd::float3 up,
float angle, float aspectRatio, float nearPlane, float farPlane);
CameraUniforms updateUniforms() const;
void rebuild() const;
simd::float3 getLeft() const;
simd::float3 getRight() const;
simd::float3 getForward() const; // target/direction
simd::float3 getUp() const;
simd::float3 getPosition() const;
simd::float4x4 getInvViewProjectionMatrix() const;
void setNearPlane(float newNearPlane);
void setFarPlane(float newFarPlane);
void setAspectRatio(float newAspectRatio);
void setViewAngle(float newAngle);
void setWidth(float newWidth);
void setPosition(simd::float3 newPosition);
void setUp(simd::float3 newUp);
void setDirection(simd::float3 newDirection);
void rotateOnAxis(simd::float3 inAxis, float inRadians);
void lookAt(simd::float3 target);
private:
simd::float3 m_position;
simd::float3 m_direction;
simd::float3 m_right;
simd::float3 m_up;
float m_nearPlane;
float m_farPlane;
float m_aspectRatio;
float m_viewAngle;
float m_width; // carré de l'ortho, ortho only
mutable bool m_needRebuild;
mutable CameraUniforms m_cameraUniforms;
void orthogonalizeFromNewUp(simd::float3 newUp);
void orthogonalizeFromNewForward(simd::float3 newForward);
};
#endif /* Camera_hpp */
Rotation
/// C++ — Camera.cpp — update:18/05/26
// formule d'Euler–Rodrigues
void Camera::rotateOnAxis(simd::float3 inAxis, float inRadians)
{
simd::float3 axis = simd::normalize(inAxis);
float ct = cosf(inRadians);
float st = sinf(inRadians);
float ci = 1 - ct;
float x = axis.x;
float y = axis.y;
float z = axis.z;
simd::float3x3 m((simd::float3){ ct + x * x * ci, y * x * ci + z * st, z * x * ci - y * st },
(simd::float3){ x * y * ci - z * st, ct + y * y * ci, z * y * ci + x * st },
(simd::float3){ x * z * ci + y * st, y * z * ci - x * st, ct + z * z * ci });
m_direction = m * m_direction;
m_up = m * m_up;
m_needRebuild = true;
}
Yaw / Pitch — vue FPS
Yaw = rotation autour de Y (gauche/droite). Pitch = rotation autour de X local (haut/bas). On les combine pour recalculer _direction.
// updateDirectionFromYawPitch() :
float cosP = cosf(_pitch);
_direction = simd::float3{
cosP * sinf(_yaw), // X
sinf(_pitch), // Y — monte/descend
cosP * cosf(_yaw) // Z
};
_direction = normalize(_direction);
// Re-ortho : recalculer _up depuis _direction et worldUp
simd::float3 worldUp = {0, 1, 0};
simd::float3 right = normalize(cross(_direction, worldUp));
_up = cross(right, _direction);
// Limiter le pitch pour éviter le flip (gimbal lock) :
// _pitchMin = -M_PI_2 + 0.001f (-89.99°)
// _pitchMax = M_PI_2 - 0.1f (+89°)
_pitch = simd::clamp(_pitch, _pitchMin, _pitchMax);
Mode orbite
En mode orbite, la caméra tourne autour d'un point fixe (_orbitTarget) à une distance constante (_orbitDistance). C'est le mode éditeur/inspection.
// updateOrbitPosition() :
float cosP = cosf(_pitch);
simd::float3 offset = {
cosP * sinf(_yaw) * _orbitDistance,
sinf(_pitch) * _orbitDistance,
cosP * cosf(_yaw) * _orbitDistance
};
_position = _orbitTarget + offset; // position sur la sphère
_direction = normalize(_orbitTarget - _position); // regarde toujours le centre
// Usage :
m_camera.setOrbitMode(true);
m_camera.setOrbitTarget({0, 0, 0});
m_camera.setOrbitDistance(5.0f);
m_camera.orbit(deltaX * 0.01f, deltaY * 0.01f); // depuis mouseMoved
m_camera.zoom(scrollDelta);
Transitions et easing
Les transitions animent la caméra entre deux snapshots avec une courbe d'easing. La direction utilise un slerp pour éviter les raccourcis non-sphériques.
// Lancer une transition :
RMDLCameraSnapshot closeup;
closeup.position = {0, 2, -3};
closeup.direction = normalize({0, -0.3f, 1});
closeup.viewAngle = M_PI / 6.0f; // 30° FOV
m_camera.transitionTo(closeup, 1.5f, RMDLCameraEase::EaseInOutCubic, [&]() {
// callback quand la transition est terminée
startCutscene();
});
// Dans draw() — chaque frame :
m_camera.updateTransition(deltaTime);
// Courbes disponibles :
// Linear · SmoothStep · SmootherStep
// EaseInQuad · EaseOutQuad · EaseInOutQuad
// EaseInCubic · EaseOutCubic · EaseInOutCubic
// EaseInOutBack ← overshoot léger, effet dynamique
Camera shake
// Déclenchement — explosion, impact, atterrissage :
m_camera.applyShake(
0.15f, // intensité (en unités monde)
0.4f, // durée en secondes
25.0f // fréquence Hz
);
// Implémentation — dans updateTransition() :
// _shakeOffset = sin(elapsed * frequency) * intensity * decay
// decay = 1 - (elapsed / duration) → s'atténue progressivement
// Appliqué à la position dans updateUniforms() :
simd::float3 pos = _position + _shakeOffset;
_uniforms.viewMatrix = sViewMatrix(pos, pos + _direction, _up);
shadowCameraUniforms[3] — une caméra par cascade de CSM (Cascaded Shadow Maps). L'implémentation fera l'objet d'un chapitre dédié au triplanar mapping.