Chapitre -115 · Caméra

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);
Pourquoi transposer ?
La view matrix est l'inverse de la transform de la caméra. Plutôt que d'inverser une matrice 4×4 (coûteux), on exploite le fait que la partie rotation est orthonormale — son inverse est sa transposée. Les lignes deviennent colonnes, et la translation est projetée sur chaque axe.

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.

+Z +Z +Y +X +Z FOV near plane far plane right up left back (0,0,-3) camera perspective & orthographic projection near plane far plane orthographique

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);
Shadow camera — à venir
La structure CameraUniforms contiendra une data 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.