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_default | renderer_viewport | |
|---|---|---|
| Purpose | Full, final-quality render | Interactive preview in the interface |
| Name | “Default” | “Viewport” |
| Strategy | Loop over samplesPerPixel, accumulation + progressive average | Progressive quality passes (draft → full → anti-aliasing) |
| Parallelism | 64×64 tiles, shuffled order, thread pool | 64×64 tiles, thread pool, base-color cache |
| Reactivity | Renders the whole image then stops | Re-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 type2(truecolor RGB), compression / filter / interlace all0. - Raw data — each scanline is prefixed with a filter byte
0(None) followed byR G Btriplets. - IDAT chunk — the data goes through a
zlibstream in “stored” mode (zlibStore) : header0x78 0x01, uncompressed blocks of at most 65535 bytes (withLEN/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(withEPS = 0.001), andscene.intersectenforcest > EPS: this prevents a surface from shadowing itself (shadow acne) through self-intersection. - Clamping. Coefficients and colors are bounded to
[0, 1]viaclamp01before conversion to bytes, avoiding color overflow. - Degenerate cases. BRDF denominators are guarded with small constants (
+ 1e-7), refraction detects total internal reflection (refract_dirreturnsfalse) 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.
tbounds. Thet ∈ [t_min, t_max]test is applied consistently, including when tighteningt_maxduring BVH traversal.