algorithm3dgraphics

Raycasting

La naissance de la 3D bon marché : comment John Carmack a révolutionné le rendu 3D avec des processeurs limités.

Janvier 202618 min de lecture
🍌
Nouveau : Mode Monkey 🐵
Certaines parties de cet article peuvent être complexes ou juste trop nerd. Activez le switch "Monkey Mode" à côté des titres pour voir une version simplifiée et vulgarisée !

Avant la Révolution : Le Jeu Vidéo en 1991

En 1991, le jeu vidéo PC était dominé par deux approches : les jeux 2D vue de dessus (comme Commander Keen) et les simulateurs 3D ultra-lents (comme Ultima Underworld). Les rares tentatives de 3D temps réel se heurtaient à un mur physique : les processeurs Intel 386 et 486 de l'époque, cadencés à 25-33 MHz, n'avaient aucune unité de calcul dédiée aux graphismes.

Le ray tracing, la technique de rendu 3D réaliste, existait déjà depuis les années 70 mais nécessitait des minutes, voire des heures, pour calculer une seule image sur les supercalculateurs de l'époque. L'idée de l'utiliser pour un jeu en temps réel à 30 images par seconde semblait pure science-fiction.

C'est dans ce contexte qu'id Software, un petit studio de quatre personnes, a commencé à travailler sur un prototype qui allait tout changer. Leur ambition ? Créer un jeu d'action à la première personne fluide sur des machines grand public. Leur arme secrète ? Un jeune programmeur de 21 ans nommé John Carmack.

La naissance de la 3D bon marché

En 1992, John Carmack a accompli l'impossible : créer une illusion de 3D fluide sur des processeurs Intel 286 et 386, des machines qui n'avaient aucune capacité de rendu 3D matériel. Son secret ? Le raycasting, une technique astucieuse qui simule la 3D en ne calculant que ce qui est strictement nécessaire.

« Le raycasting n'est pas de la vraie 3D, c'est une illusion brillante qui exploite les limitations de notre perception. » — John Carmack

La beauté du raycasting réside dans sa dualité 2D/3D : tous les calculs se font dans un plan 2D (la carte vue de dessus), mais le résultat crée une illusion de profondeur saisissante. Le processus transforme une simple distance (1D) en hauteur de mur à l'écran (2D), qui elle-même génère la perception de perspective (3D). Cette cascade de transformations — distance → hauteur → perspective — est le cœur de l'illusion.

Contrairement au ray tracing qui lance des rayons capables de rebondir (réflexion, réfraction, ombres), le raycasting s'arrête au premier obstacle rencontré. C'est précisément cette absence de calcul de rebond qui permet la performance : on lance un rayon par colonne de pixels, on trouve le premier mur touché, et on dessine une bande verticale proportionnelle à la distance. Le résultat ? Wolfenstein 3D, le jeu qui a lancé l'ère des FPS.

La Géométrie du Monde

Le monde du raycasting est fondamentalement une grille 2D vue de dessus. Chaque cellule de cette grille (appelée map) est soit vide, soit occupée par un mur. Cette simplification est la clé de la performance.

Représentation de la Map

La map est un simple tableau 2D d'entiers. La valeur 0 représente un espace vide, et toute valeur positive représente un type de mur (utile pour les textures) :

map.jsjavascript
const map = [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 2, 0, 1],
[1, 0, 1, 1, 0, 0, 0, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 3, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1]
];

Le Joueur et la Caméra

Le joueur est défini par sa position (x,y)(x, y) dans la grille et sa direction de regard. La direction est représentée par un vecteur unitaire dir\vec{dir}, et le champ de vision par un vecteur perpendiculaire plane\vec{plane} (le plan de la caméra) :

dir=(cos(θ),sin(θ))\vec{dir} = (\cos(\theta), \sin(\theta))
plane=(sin(θ)fov,cos(θ)fov)\vec{plane} = (-\sin(\theta) \cdot fov, \cos(\theta) \cdot fov)

Le facteur fovfov contrôle l'amplitude du champ de vision. Une valeur de 0.66 donne un FOV d'environ 66°, similaire à la vision humaine.

La longueur du vecteur plane\vec{plane} détermine directement l'angle de vue : un vecteur court produit un effet de zoom (FOV étroit), tandis qu'un vecteur long crée un effet grand-angle (FOV large). Mathématiquement, l'angle de vue est 2×arctan(plane/dir)2 \times \arctan(|\vec{plane}| / |\vec{dir}|). Avec dir=1|\vec{dir}| = 1 et plane=0.66|\vec{plane}| = 0.66, on obtient environ 66°. Doubler la longueur du plane doublerait presque l'angle de vue, mais introduirait une distorsion aux bords.

player.jsjavascript
const player = {
x: 4.5, // Position X (au centre d'une cellule)
y: 4.5, // Position Y
angle: 0, // Direction en radians
fov: 0.66 // Champ de vision
};

// Ces vecteurs doivent être recalculés à chaque frame
// car ils dépendent de l'angle du joueur (qui change quand il tourne)
function updateCamera(player) {
return {
dirX: Math.cos(player.angle),
dirY: Math.sin(player.angle),
planeX: -Math.sin(player.angle) * player.fov,
planeY: Math.cos(player.angle) * player.fov
};
}

DDA : L'art de sauter de case en case

Le cœur du raycasting est l'algorithme DDA. L'approche naïve serait d'avancer le rayon pixel par pixel jusqu'à toucher un mur, mais c'est terriblement inefficace.

Le Problème de l'Approche Naïve

Imaginez un rayon qui doit parcourir 100 unités avant de toucher un mur. Avec un pas de 0.01, il faudrait 10 000 itérations ! L'astuce du DDA est de sauter directement d'une ligne de grille à la suivante.

Le Principe du DDA

Au lieu de petits pas, on calcule la distance jusqu'à la prochaine ligne verticale ET la prochaine ligne horizontale, puis on avance vers la plus proche. On répète jusqu'à toucher un mur.

Pour un rayon de direction (rayDirX,rayDirY)(rayDirX, rayDirY), la distance entre deux lignes verticales consécutives est :

Δdistx=1rayDirX\Delta dist_x = \left| \frac{1}{rayDirX} \right|

Et pour les lignes horizontales :

Δdisty=1rayDirY\Delta dist_y = \left| \frac{1}{rayDirY} \right|

Intuitivement, Δdistx\Delta dist_x représente l'hypoténuse du triangle rectangle formé par le rayon lorsqu'il traverse exactement une unité de grille horizontalement. Si le rayon est presque horizontal (rayDirX1rayDirX \approx 1), cette distance est proche de 1. Si le rayon est presque vertical (rayDirX0rayDirX \approx 0), cette distance tend vers l'infini — logique, car un rayon vertical ne croisera jamais de ligne verticale !

L'efficacité du DDA vient de sa simplicité arithmétique : la boucle principale n'utilise que des additions. Une fois les valeurs Δdistx\Delta dist_x et Δdisty\Delta dist_y précalculées (une seule division par rayon), chaque itération se résume à comparer deux nombres et en incrémenter un. Sur les processeurs des années 90, où une multiplication coûtait des dizaines de cycles contre un seul pour une addition, cette optimisation était cruciale pour atteindre des framerates jouables.

Démo Interactive

Implémentation du DDA

dda.jsjavascript
function castRay(playerX, playerY, rayDirX, rayDirY, map) {
// Position actuelle dans la grille
let mapX = Math.floor(playerX);
let mapY = Math.floor(playerY);
// Distance entre lignes de grille
const deltaDistX = Math.abs(1 / rayDirX);
const deltaDistY = Math.abs(1 / rayDirY);
// Direction du pas et distance initiale
let stepX, stepY, sideDistX, sideDistY;
if (rayDirX < 0) {
stepX = -1;
sideDistX = (playerX - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1 - playerX) * deltaDistX;
}
if (rayDirY < 0) {
stepY = -1;
sideDistY = (playerY - mapY) * deltaDistY;
} else {

Mathématiques et Projection

Une fois le mur trouvé, il faut calculer sa hauteur à l'écran. C'est ici que la magie de la perspective entre en jeu.

Distance Perpendiculaire

La hauteur du mur est inversement proportionnelle à la distance. Mais attention : on utilise la distance perpendiculaire au plan de la caméra, pas la distance euclidienne directe. C'est le secret de la correction fisheye.

perpDist={mapXplayerX+1stepX2rayDirXsi side = 0mapYplayerY+1stepY2rayDirYsi side = 1perpDist = \begin{cases} \frac{mapX - playerX + \frac{1 - stepX}{2}}{rayDirX} & \text{si side = 0} \\ \frac{mapY - playerY + \frac{1 - stepY}{2}}{rayDirY} & \text{si side = 1} \end{cases}

Calcul de la Hauteur du Mur

La hauteur du mur à l'écran est simplement :

lineHeight=screenHeightperpDistlineHeight = \frac{screenHeight}{perpDist}

Plus le mur est loin, plus perpDistperpDist est grand, donc plus lineHeightlineHeight est petit. C'est la perspective !

projection.jsjavascript
function calculateWallHeight(side, mapX, mapY, playerX, playerY,
rayDirX, rayDirY, stepX, stepY, screenHeight) {
// Distance perpendiculaire (corrige le fisheye)
let perpWallDist;
if (side === 0) {
perpWallDist = (mapX - playerX + (1 - stepX) / 2) / rayDirX;
} else {
perpWallDist = (mapY - playerY + (1 - stepY) / 2) / rayDirY;
}
// Hauteur de la ligne à dessiner
const lineHeight = Math.floor(screenHeight / perpWallDist);
// Points de début et fin (centrés verticalement)
let drawStart = Math.floor(-lineHeight / 2 + screenHeight / 2);
let drawEnd = Math.floor(lineHeight / 2 + screenHeight / 2);
// Clipping
if (drawStart < 0) drawStart = 0;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;

La Correction Fisheye

Sans correction, les rayons aux bords de l'écran parcourent une distance plus longue que ceux au centre, créant une distorsion en "œil de poisson". La distance perpendiculaire résout ce problème en projetant la distance sur l'axe de la caméra.

Pourquoi la distance euclidienne échoue-t-elle ? Imaginez un mur parfaitement plat face à vous. Les rayons au centre de l'écran le touchent perpendiculairement, mais les rayons aux bords doivent parcourir un chemin plus long pour atteindre le même mur — ils voyagent en diagonale. Si on utilisait cette distance euclidienne (l'hypoténuse) pour calculer la hauteur, les bords du mur apparaîtraient plus petits que le centre, créant une courbure artificielle. La distance perpendiculaire "annule" cet effet en mesurant uniquement la composante de la distance parallèle à la direction de regard, comme si tous les rayons étaient parallèles.

Visuellement, imaginez que vous mesurez la distance non pas en ligne droite vers le mur, mais perpendiculairement au plan de votre écran. C'est cette astuce qui donne des murs parfaitement droits.

Prenons un exemple concret : un rayon au bord de l'écran qui touche un mur à 10 unités en distance euclidienne (hypoténuse). Si ce rayon fait un angle de 30° avec l'axe central, sa distance perpendiculaire est 10×cos(30°)8.6610 \times \cos(30°) \approx 8.66 unités. C'est cette valeur plus courte qu'on utilise pour calculer la hauteur du mur, évitant ainsi qu'il paraisse plus petit qu'il ne devrait.

Démo : Fisheye vs Corrigé

Utilisez le toggle pour basculer entre les deux modes. En rouge (distance euclidienne), observez comment les murs aux bords semblent courbés vers l'intérieur. En vert (distance perpendiculaire), les murs restent parfaitement droits.

Démo Interactive

Le Rendu Complet

Pour chaque colonne de l'écran, on lance un rayon et on dessine une bande verticale. Voici la boucle principale :

render.jsjavascript
function render(ctx, player, map, screenWidth, screenHeight) {
for (let x = 0; x < screenWidth; x++) {
// Position du rayon sur le plan caméra (-1 à 1)
const cameraX = 2 * x / screenWidth - 1;
// Direction du rayon
const rayDirX = dirX + planeX * cameraX;
const rayDirY = dirY + planeY * cameraX;
// Lancer le rayon (DDA)
const hit = castRay(player.x, player.y, rayDirX, rayDirY, map);
// Calculer la hauteur du mur
const wall = calculateWallHeight(
hit.side, hit.mapX, hit.mapY,
player.x, player.y, rayDirX, rayDirY,
hit.stepX, hit.stepY, screenHeight
);
// Choisir la couleur (plus sombre pour les murs N/S)
let color = getWallColor(map[hit.mapX][hit.mapY]);

Performances et Optimisations

Sur un processeur 386 cadencé à 33 MHz sans FPU (unité de calcul flottant), chaque cycle compte. Voici les optimisations utilisées par John Carmack pour atteindre 30 FPS sur ces machines antédiluviennes.

Arithmétique Entière vs Flottante

Les processeurs 386 sans coprocesseur mathématique (FPU) calculaient les nombres à virgule flottante de manière logicielle, rendant chaque opération 50 à 100 fois plus lente qu'avec des entiers. La solution ? Utiliser des entiers à virgule fixe.

fixed-point.jsjavascript
// Virgule fixe : 16 bits entiers + 16 bits décimaux
const FIXED_SHIFT = 16;
const ONE = 1 << FIXED_SHIFT; // 65536 représente 1.0

// Multiplication en virgule fixe
function fixedMul(a, b) {
return (a * b) >> FIXED_SHIFT;
}

// Division en virgule fixe
function fixedDiv(a, b) {
return (a << FIXED_SHIFT) / b;
}

// Exemple : 2.5 * 1.75
const a = Math.floor(2.5 * ONE); // 163840
const b = Math.floor(1.75 * ONE); // 114688
const result = fixedMul(a, b); // 4.375 en fixe
const decimal = result / ONE; // 4.375

Tables de Précalcul

Calculer sin\sin et cos\cos à chaque frame est prohibitif. L'astuce : précalculer toutes les valeurs possibles dans un tableau et les lire instantanément.

lookup.jsjavascript
// Précalcul au démarrage
const ANGLE_STEPS = 360;
const sinTable = new Array(ANGLE_STEPS);
const cosTable = new Array(ANGLE_STEPS);

for (let i = 0; i < ANGLE_STEPS; i++) {
const rad = (i * Math.PI * 2) / ANGLE_STEPS;
sinTable[i] = Math.sin(rad);
cosTable[i] = Math.cos(rad);
}

// Usage : O(1) au lieu de ~100 cycles
function fastSin(angle) {
const index = Math.floor(angle) % ANGLE_STEPS;
return sinTable[index];
}

function fastCos(angle) {
const index = Math.floor(angle) % ANGLE_STEPS;
return cosTable[index];
}

Écriture Directe en VRAM

Le Mode 13h (320×200, 256 couleurs) permettait d'écrire directement dans la VRAM, évitant les appels système coûteux. Chaque pixel = 1 octet à l'adresse correspondante.

vram.cjavascript
// Mode 13h : accès direct VRAM (C/assembleur)
unsigned char *VGA = (unsigned char *)0xA0000;

// Dessiner un pixel en (x, y) avec la couleur c
void putPixel(int x, int y, unsigned char color) {
VGA[y * 320 + x] = color; // 1 instruction !
}

// Dessiner une colonne verticale (optimisé)
void drawVerticalLine(int x, int y1, int y2, unsigned char color) {
unsigned char *ptr = VGA + y1 * 320 + x;
int height = y2 - y1;

// Copie mémoire brute (très rapide)
while (height-- > 0) {
*ptr = color;
ptr += 320; // Ligne suivante
}
}

Démo Interactive

Explorez le raycasting en action ! La vue de gauche montre la map 2D avec les rayons, la vue de droite montre le rendu 3D. Utilisez ZQSD ou les flèches pour vous déplacer.

Démo Interactive

Vers l'infini et au-delà

Le raycasting de base peut être enrichi de nombreuses façons. Voici quelques extensions classiques :

Texture Mapping

Pour ajouter des textures, on calcule la position exacte où le rayon touche le mur (entre 0 et 1), puis on utilise cette valeur pour échantillonner une colonne de la texture :

texture.jsjavascript
// Position exacte du hit sur le mur (0 à 1)
let wallX;
if (side === 0) {
wallX = playerY + perpWallDist * rayDirY;
} else {
wallX = playerX + perpWallDist * rayDirX;
}
wallX -= Math.floor(wallX); // Partie fractionnaire

// Colonne de texture à utiliser
const texX = Math.floor(wallX * textureWidth);

Ombrage selon l'Orientation

Un effet simple mais efficace : assombrir les murs orientés Nord/Sud (ou Est/Ouest). Cela donne une impression de profondeur et aide à distinguer les coins :

shading.jsjavascript
// side = 0 : mur vertical (Est/Ouest) - pleine lumière
// side = 1 : mur horizontal (Nord/Sud) - ombré
if (side === 1) {
// Réduire la luminosité de 30%
r = Math.floor(r * 0.7);
g = Math.floor(g * 0.7);
b = Math.floor(b * 0.7);
}

Sprites et Objets

Les sprites (ennemis, objets, items) sont des images 2D qui restent toujours face à la caméra. Contrairement aux murs, ils nécessitent un tri en profondeur et un z-buffer pour l'occlusion.

sprites.jsjavascript
const sprites = [
{ x: 3.5, y: 2.5, texture: enemyTexture },
{ x: 5.2, y: 6.7, texture: barrelTexture }
];

function renderSprites(player, sprites, zBuffer, screenWidth, screenHeight) {
// Calculer distance et trier (du + loin au + proche)
const spriteData = sprites.map(s => ({
...s,
dx: s.x - player.x,
dy: s.y - player.y,
distance: Math.sqrt((s.x - player.x) ** 2 + (s.y - player.y) ** 2)
})).sort((a, b) => b.distance - a.distance);

for (const sprite of spriteData) {
// Transformer en coordonnées caméra
const invDet = 1.0 / (planeX * dirY - dirX * planeY);
const transformX = invDet * (dirY * sprite.dx - dirX * sprite.dy);
const transformY = invDet * (-planeY * sprite.dx + planeX * sprite.dy);

// Position à l'écran

Sol et Plafond Texturés

Texturer le sol et le plafond est plus coûteux que les murs : au lieu d'un rayon par colonne, on doit calculer la position 3D pour chaque pixel. On utilise le raycasting horizontal pour optimiser.

floor-ceiling.jsjavascript
function renderFloorCeiling(player, screenWidth, screenHeight, floorTexture, ceilingTexture) {
for (let y = screenHeight / 2; y < screenHeight; y++) {
// Distance au sol pour cette ligne
const rayDirX0 = dirX - planeX;
const rayDirY0 = dirY - planeY;
const rayDirX1 = dirX + planeX;
const rayDirY1 = dirY + planeY;

// Position verticale actuelle par rapport à l'horizon
const p = y - screenHeight / 2;

// Hauteur verticale de la caméra
const posZ = 0.5 * screenHeight;

// Distance horizontale du sol pour cette ligne
const rowDistance = posZ / p;

// Calculer le pas horizontal entre chaque pixel
const floorStepX = rowDistance * (rayDirX1 - rayDirX0) / screenWidth;
const floorStepY = rowDistance * (rayDirY1 - rayDirY0) / screenWidth;

Le saviez-vous ?

Dans Wolfenstein 3D, le joueur ne peut pas regarder en haut ou en bas. Ce n'est pas une limitation technique arbitraire : le raycasting classique projette tous les rayons sur un plan horizontal. Permettre de regarder vers le haut ou le bas nécessiterait de recalculer la projection verticale de chaque colonne, introduisant une distorsion majeure ou un coût de calcul prohibitif pour l'époque. DOOM a contourné ce problème avec un astucieux "Y-shearing" qui décale simplement l'image verticalement — une illusion qui fonctionne tant qu'on ne regarde pas trop haut !

Limitations du Raycasting

Le raycasting, malgré son ingéniosité, impose des contraintes strictes qui définissent son esthétique distinctive. Ces limitations ne sont pas des défauts, mais des compromis délibérés pour maximiser les performances.

Monde Orthogonal Strict

Le raycasting classique exige une grille parfaitement alignée. Pas de murs diagonaux, pas de courbes, pas de pièces non-rectangulaires. Chaque cellule est soit vide, soit occupée. Cette contrainte découle directement de l'algorithme DDA qui parcourt les lignes de grille horizontales et verticales.

Murs Strictement Verticaux

Tous les murs montent du sol au plafond à la verticale parfaite. Pas de pentes, pas d'escaliers, pas de plafonds à hauteur variable. La hauteur du mur est calculée par une simple division (hauteur écran / distance), ce qui suppose une projection cylindrique verticale.

Pas de Vision Verticale

Impossible de regarder en haut ou en bas — la caméra est verrouillée sur l'horizon. Cette limitation n'est pas arbitraire : permettre le pitch nécessiterait de recalculer les hauteurs de murs pour chaque colonne avec une projection en perspective, multipliant le coût par un facteur prohibitif.

Un Seul Niveau de Hauteur

Le raycasting pur ne peut gérer qu'un seul étage. Pas de ponts, pas de pièces au-dessus d'autres pièces, pas de balcons. Le rayon s'arrête au premier mur rencontré — il ne peut pas "voir à travers" pour détecter un deuxième niveau.

Raycasting vs Autres Techniques

Plaçons le raycasting dans le contexte des techniques de rendu 3D disponibles à l'époque et aujourd'hui.

Raycasting vs Ray Tracing

Le ray tracing est la technique de rendu 3D photoréaliste par excellence. Il simule physiquement le trajet de la lumière en lançant des rayons qui rebondissent sur les surfaces, calculant réflexions, réfractions, ombres portées et éclairage indirect. Le raycasting en est une version ultra-simplifiée : un rayon par colonne, pas de rebond, juste "premier mur touché".

  • Ray Tracing : Rayons multiples par pixel, calcul de l'éclairage global, réflexions/réfractions infinies. Coût : O(millions de rayons × profondeur de rebond).
  • Raycasting : Un rayon par colonne de pixels, pas de rebond, pas d'éclairage. Coût : O(largeur d'écran × distance moyenne au mur).

Raycasting vs BSP Tree (DOOM)

DOOM (1993) a abandonné le raycasting pur au profit des BSP trees. Cette approche permet des murs non-orthogonaux, des secteurs de hauteurs différentes, et des formes géométriques arbitraires. Le coût ? Une complexité algorithmique bien supérieure.

Raycasting vs Rasterisation Moderne

Les GPU modernes utilisent la rasterisation, l'inverse du raycasting : au lieu de lancer des rayons depuis l'œil, on projette des triangles 3D sur l'écran. C'est l'approche dominante depuis les années 2000 car elle parallélise parfaitement sur les architectures GPU.

Conclusion

Le raycasting est un exemple brillant d'optimisation créative. En exploitant les contraintes (monde en grille, murs verticaux, pas de pentes), John Carmack a créé une illusion de 3D qui a révolutionné le jeu vidéo.

Aujourd'hui, avec les GPU modernes, le ray tracing temps réel est devenu possible. Mais comprendre le raycasting reste essentiel : c'est une leçon magistrale sur comment contourner les limitations techniques par l'ingéniosité algorithmique.

Jeux Historiques Utilisant le Raycasting

  • Wolfenstein 3D (1992) : Le premier FPS commercial utilisant le raycasting. A lancé le genre FPS et prouvé qu'un jeu 3D fluide était possible sur PC.
  • DOOM (1993) : Évolution avec le BSP tree pour des niveaux non-orthogonaux. Conserve l'esprit du raycasting mais ajoute la flexibilité géométrique.
  • Duke Nukem 3D (1996) : Moteur Build Engine avec secteurs inclinés, miroirs, pièces au-dessus d'autres pièces (portails).
  • Comanche (1992) : Raycasting pour le terrain en voxels. Au lieu de murs, chaque rayon échantillonne une heightmap pour créer des paysages 3D.
  • Ken's Labyrinth (1993) : Raycasting pur avec textures de sol/plafond.