Raytracer docs 🌐 FR

Home / Rendering pipeline

Rendering pipeline

The engine produces an image by applying the same sequence to every pixel : generate a camera ray, find the closest intersection (accelerated by a BVH), evaluate shading, then accumulate the color into a buffer that is exported as a PNG. This page walks through each stage from the actual code (srcs/common/render/RenderKernel.hpp, srcs/core/scene/camera/Camera.cpp, srcs/plugins/renderer/*).

Overview

The BVH is built once before rendering (scene.buildBvh()). Then, for every pixel, a primary ray traverses the scene. The heart of the shading is the recursive function render_kernel::trace_ray(ray, scene, depth), started with a depth of 10.

                       ┌──────────────────────────────────────────────┐
   scene.json ─► Scene ─►  buildBvh()  (global BVH + local/mesh BVH)     │
                       └───────────────────────┬──────────────────────┘
                                               │
                    for each pixel (x, y)      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │  1. camera.generateRay(x, y)        → primary ray P(t)=O+t·D        │
   │  2. scene.intersect(ray, EPS, INF)  → BVH traversal, closest hit    │
   │  3. no hit ? → color (0,0,0) (black, no skybox)                    │
   │  4. hit ?    → shading: ambient + Σ lights (diffuse + specular)     │
   │             + shadows (ray H+ε·N → light)                           │
   │             + secondary rays (reflection / refraction), depth-1     │
   │  5. accumulated color (average of samples) → Render buffer          │
   └───────────────────────────────────────────────────────────────────┘
                                               │
              all pixels computed              ▼
   Render buffer ─► RenderExporter::saveToFile() ─► render.png (hand-written)

There is no background color and no skybox : a ray that hits nothing returns black (0,0,0). Metallic or glassy surfaces without direct light therefore look dark.

Rays

A ray is an origin / direction pair (srcs/common/Ray.hpp) and follows the canonical parametric equation :

P(t) = O + t · D        with  t ∈ [t_min, t_max]
class Ray {
    Vector3f origin;      // O
    Vector3f direction;   // D (normalized for primary rays)
    Vector3f at(float t) const { return origin + t * direction; }
};

The primary ray starts at the camera position and passes through the target pixel. Secondary rays (shadow, reflection, refraction) are spawned at the hit point, always offset by a small ε along the normal to avoid self-intersection (see Numerical robustness).

Camera model

The camera (srcs/core/scene/camera/Camera.cpp) is described by a position, a rotation (Euler angles in degrees), a vertical field of view (fieldOfView) and a resolution. On every change, update() recomputes the virtual image plane.

The camera basis is obtained by rotating the base axes by the rotation : forward = rotate((0,1,0)), right = rotate((1,0,0)), up = rotate((0,0,1)). The viewport dimensions derive from the FOV :

theta            = fov × π / 180
viewport_height  = 2 · tan(theta / 2)
viewport_width   = viewport_height · (resolution.x / resolution.y)   (aspect ratio)
focal_length     = 1

The upper-left corner and per-pixel steps are precomputed once :

viewport_u   = viewport_width  * right;
viewport_v   = -viewport_height * up;
pixel_delta_u = viewport_u / resolution.x;
pixel_delta_v = viewport_v / resolution.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);

The direction for pixel (i, j) is computed by generateRay(i, j) : it targets the pixel center (with a random offset for anti-aliasing, see below), the origin is the camera position and the direction is normalized :

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();

Sampling & anti-aliasing

The camera exposes samplesPerPixel (default 5 ; clamped to at least 1). Anti-aliasing is done by supersampling : for each sample, sample_square() adds a random offset within the pixel square :

Vector3f Camera::sample_square() const {
    return Vector3f(random_double() - 0.5, random_double() - 0.5, 0);
}

The DefaultRenderer loops over samples and accumulates colors, then divides by the number of samples computed so far — giving a preview that converges (progressive average) :

for (int sample = 0; sample < spp; sample++) {
    // ... for each pixel: accum[i] += trace_ray(camera.generateRay(x, y), scene, 10);
    float scale = 1.0f / (sample + 1);
    pixels[i] = (accum[i] * scale).toColor();   // average of samples seen
}

Because each sample uses a different sub-pixel offset, the average smooths staircase edges (aliasing). Rendering is split into 64×64 tiles handled by a thread pool (std::thread::hardware_concurrency()), with the tile order shuffled for a more even fill.

Intersection & BVH acceleration

Finding what a ray hits amounts to keeping the closest valid hit — the smallest t in [t_min, t_max]. A naive search would test every primitive ; the engine instead uses a BVH (Bounding Volume Hierarchy, srcs/core/scene/bvh/BVHNode.cpp) built before rendering.

At build time, primitives are split along the longest axis of the box enclosing their centroids, halved by std::nth_element (median), recursively. Each node stores its bounding box (AABB).

Traversal first tests the node box ; on a hit it descends into both children while tightening t_max with the best t found so far, which prunes farther subtrees :

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;
}

The search starts from trace_ray with scene.intersect(ray, EPS, INF, hit)t_min = EPS = 0.001 discards self-intersections, t_max = ∞ leaves the whole scene visible. Each .obj mesh additionally owns its own local BVH, nested inside the global BVH. See BVH acceleration for build details.

Shading

At the hit point H, the color is built by combining several contributions. The ambient term is always present :

ambient = baseColor · scene_ambient_coefficient        (× ao × (1-metallic) in PBR)

Then, for each visible light, a direct contribution is added. Two illumination models coexist depending on the material (material.model) — see Materials :

PHONG model — Lambertian diffuse + Phong specular :

NdotL    = max(0, N · L)
diffuse  = baseColor · lightColor · (intensity · NdotL / π)
R        = reflection of -L about N
specular = material.specular · lightColor · (intensity · max(0, V·R)^shininess)
contribution = diffuse + specular

PBR model — a Cook-Torrance / Disney-style BRDF : GGX distribution (distribution_ggx), Smith geometry term (geometry_smith), Fresnel-Schlick (fresnel_schlick), plus optional clearcoat, sheen and transmission layers. Diffuse and specular are weighted by kD/kS based on metallic and 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)
specular = F · D · G / (4 · (N·V) · (N·L))
diffuse  = baseColor · (backscatter factors) / π
layer    = (kD·diffuse + specular + sheen) · radiance · NdotL + clearcoat

In both cases, a point light’s contribution is attenuated by 1/dist² ; a directional light is not attenuated (distance treated as infinite).

Shadows

Before counting a light’s contribution, its visibility from the hit point is tested with a shadow ray. It starts from the slightly offset point H + ε·N, toward the light, over the distance between them :

// Point light: L points toward the light, finite distance
delta = light.position - H;  dist = |delta|;  L = delta / dist;
attenuation = 1 / (dist * dist);
// Directional light: fixed direction, distance ~ infinite
L = light.rotation.unit_vector();  dist = 1e9;

ColorF trans = shadow_transmittance(scene, H + N*EPS, L, dist);

Rather than a plain occluded / visible boolean, shadow_transmittance walks the occluders along the ray : an opaque occluder blocks the light entirely (zero transmittance), while a transparent occluder tints and attenuates it according to its transparency and base color — producing colored shadows under glass. The light is counted only if the resulting transmittance is non-zero.

Reflections & refractions

After direct shading, trace_ray spawns secondary rays and recurses with depth - 1 (initial depth 10 ; depth ≤ 0 returns black and stops the recursion).

PHONG material. Reflection is weighted by material.reflectivity, transparency by material.transparency (or its effective equivalent) :

if (reflectivity > 0) {
    Vector3f reflected = reflect_dir(ray.direction, N);          // mirror about 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);                            // straight-through ray
    ColorF through = trace_ray(through_ray, scene, depth - 1);
    return surface * (1 - transparency) + through * transparency;
}

PBR material. The reflection weight comes directly from the Fresnel seen by the camera ; refraction uses the index of refraction ior. The eta ratio depends on the traversal direction (front_face), and total internal reflection is handled when refract_dir fails :

eta = front_face ? 1/ior : ior
if refract_dir(dir, N, eta) succeeds → refracted ray (H - N·ε), tinted by baseColor
otherwise                            → fall back to reflection (total internal reflection)

The roughness perturbs the reflected direction through GGX sampling (perturb_direction_ggx) for glossy reflections. The reflection / refraction / diffuse weights are renormalized so no energy is created (weight_sum).

The two renderers

Rendering is provided by .so plugins (see Plugin system). Both share the same trace_ray shading kernel, but pursue different goals :

renderer_defaultrenderer_viewport
PurposeFull, final-quality renderInteractive preview in the interface
Name“Default”“Viewport”
StrategyLoop over samplesPerPixel, accumulation + progressive averageProgressive quality passes (draft → full → anti-aliasing)
Parallelism64×64 tiles, shuffled order, thread pool64×64 tiles, thread pool, base-color cache
ReactivityRenders the whole image then stopsRe-renders on camera move / selection / hover ; interruptible

The ViewportRenderer picks a quality based on state : Draft (coarse block rendering while the camera moves, to stay fluid), Full (one pass per pixel), then Aa (an anti-aliasing pass targeted at detected edges, comparing primitive identity and color distance of neighboring pixels). It keeps a _baseColors cache and per-pixel primitive identity to avoid recomputing everything, and honors _stopRequested to interrupt a stale render. The DefaultRenderer, on the other hand, computes the final image directly and then writes it out as PNG.

Hand-written PNG export

Once the Render buffer (width, height, pixels) is filled, RenderExporter::saveToFile(render, path) (srcs/core/utils/RenderExporter.cpp) encodes it as PNG without any external image library — the file is built byte by byte.

  • PNG signature — the 8 bytes 137 80 78 71 13 10 26 10.
  • IHDR chunk — width and height in big-endian, bit depth 8, color type 2 (truecolor RGB), compression / filter / interlace all 0.
  • Raw data — each scanline is prefixed with a filter byte 0 (None) followed by R G B triplets.
  • IDAT chunk — the data goes through a zlib stream in “stored” mode (zlibStore) : header 0x78 0x01, uncompressed blocks of at most 65535 bytes (with LEN/NLEN), then an Adler-32 checksum.
  • IEND chunk — empty end marker.
  • CRC32 — every chunk ends with a CRC32 (polynomial 0xEDB88320) computed over its type + data.
writeChunk(png, "IHDR", ihdr);          // dimensions + format
writeChunk(png, "IDAT", zlibStore(raw)); // filtered scanlines, zlib "stored"
writeChunk(png, "IEND", {});             // end of file

A failure to open the file raises ExportRenderException. The result is a valid 8-bit RGB PNG readable by any viewer, produced without external dependencies.

Numerical robustness

Several safeguards avoid the classic ray-tracing artifacts :

  • Anti-acne (epsilon). Secondary and shadow rays start from H + ε·N (with EPS = 0.001), and scene.intersect enforces t > EPS : this prevents a surface from shadowing itself (shadow acne) through self-intersection.
  • Clamping. Coefficients and colors are bounded to [0, 1] via clamp01 before conversion to bytes, avoiding color overflow.
  • Degenerate cases. BRDF denominators are guarded with small constants (+ 1e-7), refraction detects total internal reflection (refract_dir returns false) and falls back to reflection, and the recursion depth (depth) bounds the number of bounces.
  • Normalization. Vectors used in shading dot products are normalized to stay physically meaningful.
  • t bounds. The t ∈ [t_min, t_max] test is applied consistently, including when tightening t_max during BVH traversal.