Accueil / Pipeline de rendu
Pipeline de rendu
Le moteur produit une image en appliquant la même séquence à chaque pixel : génération d’un rayon depuis la caméra, recherche de l’intersection la plus proche (accélérée par un BVH), évaluation de l’ombrage, puis accumulation de la couleur dans un tampon exporté en PNG. Cette page détaille chaque étape à partir du code réel (srcs/common/render/RenderKernel.hpp, srcs/core/scene/camera/Camera.cpp, srcs/plugins/renderer/*).
Vue d’ensemble
Le BVH est construit une seule fois avant le rendu (scene.buildBvh()). Ensuite, pour chaque pixel, un rayon primaire traverse la scène. Le cœur de l’ombrage est la fonction récursive render_kernel::trace_ray(ray, scene, depth) avec une profondeur initiale de 10.
┌──────────────────────────────────────────────┐
scene.json ─► Scène ─► buildBvh() (BVH global + BVH local/maillage) │
└───────────────────────┬──────────────────────┘
│
pour chaque pixel (x, y) ▼
┌───────────────────────────────────────────────────────────────────┐
│ 1. camera.generateRay(x, y) → rayon primaire P(t)=O+t·D │
│ 2. scene.intersect(ray, EPS, INF) → parcours BVH, hit le + proche │
│ 3. pas de hit ? → couleur (0,0,0) (noir, pas de skybox) │
│ 4. hit ? → ombrage : ambiant + Σ lumières (diffus + spéculaire) │
│ + ombres (rayon H+ε·N → lumière) │
│ + rayons secondaires (réflexion / réfraction), depth-1 │
│ 5. couleur accumulée (moyenne des échantillons) → tampon Render │
└───────────────────────────────────────────────────────────────────┘
│
tous les pixels calculés ▼
tampon Render ─► RenderExporter::saveToFile() ─► render.png (écrit à la main)
Il n’y a pas de couleur de fond ni de skybox : un rayon qui ne touche rien renvoie du noir (0,0,0). Les surfaces métalliques ou vitreuses sans lumière directe apparaissent donc sombres.
Rayons
Un rayon est un couple origine / direction (srcs/common/Ray.hpp) et suit l’équation paramétrique canonique :
P(t) = O + t · D avec t ∈ [t_min, t_max]
class Ray {
Vector3f origin; // O
Vector3f direction; // D (normalisée pour les rayons primaires)
Vector3f at(float t) const { return origin + t * direction; }
};
Le rayon primaire part de la position de la caméra et traverse le pixel visé. Les rayons secondaires (ombre, réflexion, réfraction) sont générés au point d’impact, toujours décalés d’un petit ε le long de la normale pour éviter l’auto-intersection (voir Robustesse numérique).
Modèle de caméra
La caméra (srcs/core/scene/camera/Camera.cpp) est décrite par une position, une rotation (angles d’Euler en degrés), un champ de vision vertical (fieldOfView) et une résolution. À chaque changement, update() recalcule le plan image virtuel.
Le repère caméra est obtenu en faisant tourner les axes de base par la rotation : forward = rotate((0,1,0)), right = rotate((1,0,0)), up = rotate((0,0,1)). Les dimensions du viewport dérivent du FOV :
theta = fov × π / 180
viewport_height = 2 · tan(theta / 2)
viewport_width = viewport_height · (résolution.x / résolution.y) (ratio d’aspect)
focal_length = 1
Le coin supérieur gauche et les pas par pixel sont précalculés une fois :
viewport_u = viewport_width * right;
viewport_v = -viewport_height * up;
pixel_delta_u = viewport_u / résolution.x;
pixel_delta_v = viewport_v / résolution.y;
viewport_upper_left = position + focal_length*forward - viewport_u/2 - viewport_v/2;
pixel00_loc = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
La direction pour le pixel (i, j) est calculée par generateRay(i, j) : on cible le centre du pixel (avec un décalage aléatoire pour l’anti-aliasing, voir plus bas), l’origine est la position de la caméra et la direction est normalisée :
pixel_sample = pixel00_loc + (i + jitter.x)*pixel_delta_u + (j + jitter.y)*pixel_delta_v;
ray_origin = position;
ray_direction = (pixel_sample - ray_origin).unit_vector();
Échantillonnage & anti-aliasing
La caméra expose samplesPerPixel (par défaut 5 ; forcé à au moins 1). L’anti-aliasing se fait par sur-échantillonnage : à chaque échantillon, sample_square() ajoute un décalage aléatoire dans le carré du pixel :
Vector3f Camera::sample_square() const {
return Vector3f(random_double() - 0.5, random_double() - 0.5, 0);
}
Le DefaultRenderer boucle sur les échantillons et accumule les couleurs, puis divise par le nombre d’échantillons déjà calculés — ce qui donne un aperçu qui converge (moyenne progressive) :
for (int sample = 0; sample < spp; sample++) {
// ... pour chaque pixel : accum[i] += trace_ray(camera.generateRay(x, y), scene, 10);
float scale = 1.0f / (sample + 1);
pixels[i] = (accum[i] * scale).toColor(); // moyenne des échantillons vus
}
Comme chaque échantillon utilise un décalage sous-pixel différent, la moyenne lisse les bords en escalier (aliasing). Le rendu est réparti en tuiles de 64×64 traitées par un pool de threads (std::thread::hardware_concurrency()), l’ordre des tuiles étant mélangé pour un remplissage plus homogène.
Intersection & accélération BVH
Trouver ce qu’un rayon touche revient à conserver le hit valide le plus proche — le plus petit t dans [t_min, t_max]. Une recherche naïve testerait toutes les primitives ; le moteur utilise à la place un BVH (Bounding Volume Hierarchy, srcs/core/scene/bvh/BVHNode.cpp) construit avant le rendu.
À la construction, les primitives sont réparties selon l’axe le plus long de la boîte englobant leurs centroïdes, coupées en deux par std::nth_element (médiane), récursivement. Chaque nœud stocke sa boîte englobante (AABB).
Le parcours teste d’abord la boîte du nœud ; en cas d’intersection, il descend dans les deux enfants en resserrant t_max avec le meilleur t déjà trouvé, ce qui élague les sous-arbres plus lointains :
bool BVHNode::intersect(const Ray &ray, float tMin, float tMax, Intersection &hit) const {
if (!_bbox.hit(ray, tMin, tMax)) return false;
bool hit_left = _left->intersect(ray, tMin, tMax, hit);
bool hit_right = _right->intersect(ray, tMin, hit_left ? hit.t : tMax, hit);
return hit_left || hit_right;
}
La recherche démarre depuis trace_ray avec scene.intersect(ray, EPS, INF, hit) — t_min = EPS = 0.001 écarte les auto-intersections, t_max = ∞ laisse la scène entière visible. Chaque maillage .obj possède en plus son propre BVH local, imbriqué dans le BVH global. Voir Accélération BVH pour les détails de construction.
Ombrage
Au point d’impact H, on calcule la couleur en combinant plusieurs contributions. Le terme ambiant est toujours présent :
ambiant = baseColor · coefficient_ambiant_de_la_scène (× ao × (1-metallic) en PBR)
Puis, pour chaque lumière visible, on ajoute une contribution directe. Deux modèles d’illumination coexistent selon le matériau (material.model) — voir Matériaux :
Modèle PHONG — diffus lambertien + spéculaire de Phong :
NdotL = max(0, N · L)
diffus = baseColor · couleurLumière · (intensité · NdotL / π)
R = réflexion de -L autour de N
spéculaire = material.specular · couleurLumière · (intensité · max(0, V·R)^shininess)
contribution = diffus + spéculaire
Modèle PBR — BRDF de type Cook-Torrance / Disney : distribution GGX (distribution_ggx), terme géométrique de Smith (geometry_smith), Fresnel-Schlick (fresnel_schlick), plus des couches optionnelles clearcoat, sheen et transmission. Le diffus et le spéculaire sont pondérés par kD/kS selon le métallique et la transmission :
H = normalize(V + L)
F = fresnel_schlick(V·H, F0)
D = distribution_ggx(N·H, roughness)
G = geometry_smith(N·V, N·L, roughness)
spéculaire = F · D · G / (4 · (N·V) · (N·L))
diffus = baseColor · (facteurs de rétrodiffusion) / π
couche = (kD·diffus + spéculaire + sheen) · radiance · NdotL + clearcoat
Dans les deux cas, la contribution d’une lumière ponctuelle est atténuée en 1/dist² ; une lumière directionnelle n’est pas atténuée (distance considérée infinie).
Ombres
Avant de compter la contribution d’une lumière, on teste la visibilité depuis le point d’impact via un rayon d’ombre. Il part du point légèrement décalé, H + ε·N, en direction de la lumière, sur la distance qui les sépare :
// Lumière ponctuelle : L pointe vers la lumière, distance finie
delta = light.position - H; dist = |delta|; L = delta / dist;
attenuation = 1 / (dist * dist);
// Lumière directionnelle : direction fixe, distance ~ infinie
L = light.rotation.unit_vector(); dist = 1e9;
ColorF trans = shadow_transmittance(scene, H + N*EPS, L, dist);
Plutôt qu’un simple booléen occlus / visible, shadow_transmittance parcourt les occluseurs le long du rayon : un occluseur opaque coupe entièrement la lumière (transmittance nulle), tandis qu’un occluseur transparent la teinte et atténue selon sa transparence et sa couleur de base — ce qui produit des ombres colorées sous le verre. La lumière n’est comptée que si la transmittance résultante est non nulle.
Réflexions & réfractions
Après l’ombrage direct, trace_ray lance des rayons secondaires et se rappelle récursivement avec depth - 1 (profondeur initiale 10 ; depth ≤ 0 renvoie du noir et stoppe la récursion).
Matériau PHONG. La réflexion est pondérée par material.reflectivity, la transparence par material.transparency (ou l’équivalent effectif) :
if (reflectivity > 0) {
Vector3f reflected = reflect_dir(ray.direction, N); // miroir autour de N
Ray reflected_ray(H + N*EPS, reflected);
surface += trace_ray(reflected_ray, scene, depth - 1) * reflectivity;
}
if (transparency > 0) {
Ray through_ray(H + dir*EPS, dir); // rayon qui traverse
ColorF through = trace_ray(through_ray, scene, depth - 1);
return surface * (1 - transparency) + through * transparency;
}
Matériau PBR. Le poids de réflexion vient directement du Fresnel vu par la caméra ; la réfraction utilise l’indice de réfraction ior. Le rapport eta dépend du sens de traversée (front_face), et une réflexion totale interne est gérée quand refract_dir échoue :
eta = front_face ? 1/ior : ior
si refract_dir(dir, N, eta) réussit → rayon réfracté (H - N·ε), teinté par baseColor
sinon → repli en réflexion (réflexion totale interne)
La rugosité roughness perturbe la direction réfléchie via un échantillonnage GGX (perturb_direction_ggx) pour des réflexions floues. Les poids réflexion / réfraction / diffus sont renormalisés pour ne pas créer d’énergie (weight_sum).
Les deux renderers
Le rendu est fourni par des plugins .so (voir Système de plugins). Tous deux partagent le même noyau d’ombrage trace_ray, mais poursuivent des objectifs différents :
renderer_default | renderer_viewport | |
|---|---|---|
| But | Rendu complet, qualité finale | Aperçu interactif dans l’interface |
| Nom | « Default » | « Viewport » |
| Stratégie | Boucle sur samplesPerPixel, accumulation + moyenne progressive | Passes progressives par qualité (brouillon → complet → anti-aliasing) |
| Parallélisme | Tuiles 64×64, ordre mélangé, pool de threads | Tuiles 64×64, pool de threads, cache de couleurs de base |
| Réactivité | Rend l’image entière puis s’arrête | Re-rend selon caméra bougée / sélection / survol ; interruptible |
Le ViewportRenderer choisit une qualité selon l’état : Draft (rendu grossier par blocs quand la caméra bouge, pour rester fluide), Full (une passe par pixel) puis Aa (passe d’anti-aliasing ciblée sur les bords détectés, en comparant l’identité de primitive et la distance de couleur des pixels voisins). Il conserve un cache _baseColors et l’identité des primitives par pixel pour éviter de tout recalculer, et respecte _stopRequested pour interrompre un rendu obsolète. Le DefaultRenderer, lui, calcule directement l’image finale et l’écrit ensuite en PNG.
Export PNG écrit à la main
Une fois le tampon Render (largeur, hauteur, pixels) rempli, RenderExporter::saveToFile(render, chemin) (srcs/core/utils/RenderExporter.cpp) l’encode en PNG sans aucune bibliothèque d’image externe — le fichier est construit octet par octet.
- Signature PNG — les 8 octets
137 80 78 71 13 10 26 10. - Chunk IHDR — largeur et hauteur en big-endian, profondeur
8bits, type couleur2(truecolor RGB), compression / filtre / entrelacement à0. - Données brutes — chaque ligne est préfixée d’un octet de filtre
0(None) suivi des tripletsR G B. - Chunk IDAT — les données passent par un flux
zliben mode « stored » (zlibStore) : en-tête0x78 0x01, blocs non compressés de 65535 octets max (avecLEN/NLEN), puis une somme Adler-32. - Chunk IEND — marqueur de fin, vide.
- CRC32 — chaque chunk se termine par un CRC32 (polynôme
0xEDB88320) calculé sur son type + ses données.
writeChunk(png, "IHDR", ihdr); // dimensions + format
writeChunk(png, "IDAT", zlibStore(raw)); // scanlines filtrées, zlib « stored »
writeChunk(png, "IEND", {}); // fin de fichier
Un échec d’ouverture du fichier lève ExportRenderException. Le résultat est un PNG RGB 8 bits valide et lisible par n’importe quel visualiseur, produit sans dépendance externe.
Robustesse numérique
Plusieurs garde-fous évitent les artefacts classiques du lancer de rayons :
- Anti-acné (epsilon). Les rayons secondaires et d’ombre partent de
H + ε·N(avecEPS = 0.001), etscene.intersectimposet > EPS: cela évite qu’une surface se fasse de l’ombre à elle-même (shadow acne) par auto-intersection. - Écrêtage (clamp). Les coefficients et couleurs sont bornés dans
[0, 1]viaclamp01avant d’être convertis en octets, évitant les débordements de couleur. - Cas dégénérés. Les dénominateurs des BRDF sont protégés par de petites constantes (
+ 1e-7), la réfraction détecte la réflexion totale interne (refract_dirrenvoiefalse) et bascule en réflexion, et la profondeur de récursion (depth) borne le nombre de rebonds. - Normalisation. Les vecteurs utilisés dans les produits scalaires de l’ombrage sont normalisés pour rester physiquement cohérents.
- Bornes de
t. Le testt ∈ [t_min, t_max]est appliqué de façon cohérente, y compris lors du resserrement det_maxpendant le parcours du BVH.
Matériaux
Paramètres PHONG et PBR utilisés par l’ombrage : couleur, spéculaire, réflectivité, IOR, métallique, rugosité…
Système de plugins
Comment les renderers .so sont chargés et sélectionnés à l’exécution.
Accélération BVH
Construction de la hiérarchie de volumes englobants et BVH local par maillage.