//////////////////////////////////////////////////////////////////////
// Provides the framework for a raytracer.
////////////////////////////////////////////////////////////////////////
#include "raytrace.h"
#define STB_IMAGE_IMPLEMENTATION
#define STBI_FAILURE_USERMSG
#include "stb_image.h"
// A good quality *thread-safe* Mersenne Twister random number generator.
#include <random>
std::random_device device;
std::mt19937_64 RNGen(device());
std::uniform_real_distribution<> myrandom(0.0, 1.0);
// Call myrandom(RNGen) to get a uniformly distributed random number in [0,1].
void Scene::Finit()
{
this->accelStruc = new AccelerationBvh(this->shapes);
}
void Scene::triangleMesh(MeshData* mesh)
{
// Use the currentMat from the scene file (set by brdf command)
// This allows the scene file to control mesh appearance
Material* meshMat = currentMat;
// Convert mesh data into individual Triangle shapes and add to scene
for (const auto& tri : mesh->triangles) {
// tri is an ivec3 containing three vertex indices
vec3 v0 = mesh->vertices[tri[0]].pnt;
vec3 v1 = mesh->vertices[tri[1]].pnt;
vec3 v2 = mesh->vertices[tri[2]].pnt;
// Create a Triangle shape and add it to the scene with the scene file's material
this->shapes.push_back(new Triangle(v0, v1, v2, meshMat));
}
}
Texture::Texture(const std::string &bpath) : id(0)
{
// Replace backslashes with forward slashes -- Good for Linux, and maybe Windows?
std::string path=bpath;
std::string bs="\\";
std::string fs="/";
while (path.find(bs) != std::string::npos) {
path.replace(path.find(bs), 1, fs);}
// Does the file exist?
std::ifstream find_it(path.c_str());
if (find_it.fail()) {
std::cerr << "Texture file not found: " << path << std::endl;
exit(-1); }
else {
// Read image, and check for success
stbi_set_flip_vertically_on_load(true);
image = stbi_load(path.c_str(), &width, &height, &depth, 4);
printf("%d %d %d %s\n", depth, width, height, path.c_str());
if (!image) {
printf("\nRead error on file %s:\n %s\n\n", path.c_str(), stbi_failure_reason());
exit(-1); } }
}
quat Orientation(int i,
const std::vector<std::string>& strings,
const std::vector<float>& f)
{
quat q(1,0,0,0); // Unit quaternion
while (i<strings.size()) {
std::string c = strings[i++];
if (c == "x")
q *= angleAxis(f[i++]*Radians, Xaxis());
else if (c == "y")
q *= angleAxis(f[i++]*Radians, Yaxis());
else if (c == "z")
q *= angleAxis(f[i++]*Radians, Zaxis());
else if (c == "q") {
q *= quat(f[i+0], f[i+1], f[i+2], f[i+3]);
i+=4; }
else if (c == "a") {
q *= angleAxis(f[i+0]*Radians, normalize(vec3(f[i+1], f[i+2], f[i+3])));
i+=4; } }
return q;
}
void Scene::Command(const std::vector<std::string>& strings,
const std::vector<float>& f)
{
if (strings.size() == 0) return;
std::string c = strings[0];
if (c == "screen") {
// syntax: screen width height
width = int(f[1]);
height = int(f[2]);
}
else if (c == "camera") {
// syntax: camera x y z ry <orientation spec>
// Eye position (x,y,z), view orientation (qw qx qy qz), frustum height ratio ry
this->camera = new Camera(vec3(f[1], f[2], f[3]), Orientation(5, strings, f), f[4]);
}
else if (c == "ambient") {
// syntax: ambient r g b
// Sets the ambient color. Note: This parameter is temporary.
// It will be ignored once your raytracer becomes capable of
// accurately *calculating* the true ambient light. ;
this->ambientLight = vec3(f[1], f[2], f[3]);
}
else if (c == "brdf") {
// syntax: brdf r g b r g b alpha
// later: brdf r g b r g b alpha r g b ior
// First rgb is Diffuse reflection, second is specular reflection.
// third is beer's law transmission followed by index of refraction.
// Creates a Material instance to be picked up by successive shapes
currentMat = new Material(vec3(f[1], f[2], f[3]), vec3(f[4], f[5], f[6]), f[7]); }
else if (c == "light") {
// syntax: light x y z
// The position of the light source
// Light color defaults to white
currentMat = new Light(vec3(f[1], f[2], f[3]));
}
else if (c == "sphere") {
// syntax: sphere x y z r
// Creates a Shape instance for a sphere defined by a center and radius
this->shapes.push_back(new Sphere(vec3(f[1], f[2], f[3]), f[4], currentMat));
if (currentMat->isLight()) {
this->light = this->shapes.back(); // Store reference to light shape for sampling
lightPos = vec3(f[1], f[2], f[3]);
}
}
else if (c == "box") {
// syntax: box bx by bz dx dy dz
// Creates a Shape instance for a box defined by a corner point and diagonal vector
this->shapes.push_back( new Box(vec3(f[1], f[2], f[3]), vec3(f[4], f[5], f[6]), currentMat)); }
else if (c == "cylinder") {
// syntax: cylinder bx by bz ax ay az r
// Creates a Shape instance for a cylinder defined by a base point, axis vector, and radius
this->shapes.push_back(new Cylinder(vec3(f[1], f[2], f[3]), vec3(f[4], f[5], f[6]), f[7], currentMat)); }
else if (c == "mesh") {
// syntax: mesh filename tx ty tz s <orientation>
// Creates many Shape instances (one per triangle) by reading
// model(s) from filename. All triangles are rotated by a
// quaternion (qw qx qy qz), uniformly scaled by s, and
// translated by (tx ty tz) .
mat4 modelTr = translate(vec3(f[2],f[3],f[4]))
*scale(vec3(f[5],f[5],f[5]))
*toMat4(Orientation(6,strings,f));
ReadAssimpFile(strings[1], modelTr); }
else {
fprintf(stderr, "\n*********************************************\n");
fprintf(stderr, "* Unknown command: %s\n", c.c_str());
fprintf(stderr, "*********************************************\n\n");
}
}
glm::vec3 Scene::SampleLobe(vec3 A, float c, float phi)
{
float s = sqrt(1 - c * c);
// Create vector K centered around Z-Axis and rotate to A-axis
vec3 K = vec3(s * cos(phi), s * sin(phi), c);
if (abs(A.z - 1) < 1e-3) {
return K; // No rotation needed if A is already aligned with Z-axis
}
else if (abs(A.z + 1) < 1e-3) {
return vec3(K.x, -K.y, -K.z); // A = - Z, rotate 180 degrees around X-axis
}
else {
// Compute rotation axis and angle
vec3 B = normalize(vec3(-A.y, A.x, 0)); // Perpendicular to A in XY plane
vec3 C = cross(A, B); // Third orthogonal vector
return K.x * B + K.y * C + K.z * A; // Rotate K to align with A
}
}
glm::vec3 Scene::SampleBRDF(const glm::vec3& normal)
{
float e1 = sqrt(myrandom(RNGen)); // Cosine-weighted sampling
float e2 = myrandom(RNGen) * 2.0f * PI; // Uniformly sample azimuthal angle
return SampleLobe(normal, e1, e2);
}
Intersection Scene::SampleLight()
{
// Replace with correct referecne from Shape vector
std::pair<vec3, vec3> lightSample = SampleSpehere(this->lightPos, 2.0f); // Sample a small sphere around the light position to get a point on the light source
Intersection intersection;
intersection.t = INF;
intersection.position = lightSample.first;
intersection.normal = lightSample.second;
intersection.shape = this->light; // Associate the sampled point with the light shape for radiance evaluation
return intersection;
}
std::pair<vec3, vec3> Scene::SampleSpehere(glm::vec3 center, float radius)
{
float e1 = myrandom(RNGen);
float e2 = myrandom(RNGen);
float z = 2 * e1 - 1;
float r = sqrt(1 - z * z);
float a = 2 * PI * e2;
glm::vec3 N = vec3(r * cos(a), r * sin(a), z); // Sample point on unit sphere
glm::vec3 P = center + radius * N; // Scale and translate to sphere surface
return std::make_pair(P , N);
}
float Scene::EvalScattering(glm::vec3& nomral, glm::vec3& inDir)
{
return abs(dot(normalize(nomral), normalize(inDir))) / PI;
}
float Scene::pdfBrdf(const glm::vec3& normal, const glm::vec3& inDir)
{
return abs(dot(normalize(normal), normalize(inDir))) / PI;
}
float Scene::PdfLight(const Intersection& L)
{
float radius = 2.0f; // Radius of the sphere used for sampling the light source
return 1.0f / (4.0f * PI * radius * radius); // Uniform sampling over a sphere of radius 2 around the light position
}
float Scene::GeometryFactor(Intersection A, Intersection B)
{
glm::vec3 D = A.position - B.position;
float numerator = dot((A.nor
float numerator = dot((A.nor
glm::vec3 D = A.position - B.position;
{
float Scene::GeometryFactor(Intersection A, Intersection B)
}
return 1.0f / (4.0f * PI * radius * radius); // Uniform sampling over a sphere of radius 2 around the light position
float radius = 2.0f; // Radius of the sphere used for sampling the light source
{
float Scene::PdfLight(const Intersection& L)
}
return abs(dot(normalize(normal), normalize(inDir))) / PI;
{
float Scene::pdfBrdf(const glm::vec3& normal, const glm::vec3& inDir)
}
return abs(dot(normalize(nomral), normalize(inDir))) / PI;
{
float Scene::EvalScattering(glm::vec3& nomral, glm::vec3& inDir)
}
return std::make_pair(P , N);
glm::vec3 P = center + radius * N; // Scale and translate to sphere surface
glm::vec3 N = vec3(r * cos(a), r * sin(a), z); // Sample point on unit sphere
float a = 2 * PI * e2;
float r = sqrt(1 - z * z);
float z = 2 * e1 - 1;
float e2 = myrandom(RNGen);
float e1 = myrandom(RNGen);
{
std::pair<vec3, vec3> Scene::SampleSpehere(glm::vec3 center, float radius)
}
return intersection;
intersection.shape = this->light; // Associate the sampled point with the light shape for radiance evaluation
intersection.normal = lightSample.second;
intersection.position = lightSample.first;
intersection.t = INF;
Intersection intersection;
std::pair<vec3, vec3> lightSample = SampleSpehere(this->lightPos, 2.0f); // Sample a small sphere around the light position to get a point on the light source
// Replace with correct referecne from Shape vector
{
Intersection Scene::SampleLight()
}
return SampleLobe(normal, e1, e2);
float e2 = myrandom(RNGen) * 2.0f * PI; // Uniformly sample azimuthal angle
float e1 = sqrt(myrandom(RNGen)); // Cosine-weighted sampling
{
glm::vec3 Scene::SampleBRDF(const glm::vec3& normal)
}
}
return K.x * B + K.y * C + K.z * A; // Rotate K to align with A
vec3 C = cross(A, B); // Third orthogonal vector
vec3 B = normalize(vec3(-A.y, A.x, 0)); // Perpendicular to A in XY plane
// Compute rotation axis and angle
else {
}
return vec3(K.x, -K.y, -K.z); // A = - Z, rotate 180 degrees around X-axis
else if (abs(A.z + 1) < 1e-3) {
}
return K; // No rotation needed if A is already aligned with Z-axis
if (abs(A.z - 1) < 1e-3) {
vec3 K = vec3(s * cos(phi), s * sin(phi), c);
// Create vector K centered around Z-Axis and rotate to A-axis
float s = sqrt(1 - c * c);
{
glm::vec3 Scene::SampleLobe(vec3 A, float c, float phi)
}
}
fprintf(stderr, "*********************************************\n\n");
fprintf(stderr, "* Unknown command: %s\n", c.c_str());
fprintf(stderr, "\n*********************************************\n");
else {
ReadAssimpFile(strings[1], modelTr); }
*toMat4(Orientation(6,strings,f));
*scale(vec3(f[5],f[5],f[5]))
mat4 modelTr = translate(vec3(f[2],f[3],f[4]))
// translated by (tx ty tz) .
// quaternion (qw qx qy qz), uniformly scaled by s, and
// model(s) from filename. All triangles are rotated by a
// Creates many Shape instances (one per triangle) by reading
// syntax: mesh filename tx ty tz s <orientation>
else if (c == "mesh") {
this->shapes.push_back(new Cylinder(vec3(f[1], f[2], f[3]), vec3(f[4], f[5], f[6]), f[7], currentMat)); }
// Creates a Shape instance for a cylinder defined by a base point, axis vector, and radius
// syntax: cylinder bx by bz ax ay az r
else if (c == "cylinder") {
this->shapes.push_back( new Box(vec3(f[1], f[2], f[3]), vec3(f[4], f[5], f[6]), currentMat)); }
// Creates a Shape instance for a box defined by a corner point and diagonal vector
// syntax: box bx by bz dx dy dz
else if (c == "box") {
}
}
lightPos = vec3(f[1], f[2], f[3]);
this->light = this->shapes.back(); // Store reference to light shape for sampling
if (currentMat->isLight()) {
this->shapes.push_back(new Sphere(vec3(f[1], f[2], f[3]), f[4], currentMat));
// Creates a Shape instance for a sphere defined by a center and radius
// syntax: sphere x y z r
else if (c == "sphere") {
}
currentMat = new Light(vec3(f[1], f[2], f[3]));
// Light color defaults to white
// The position of the light source
// syntax: light x y z
else if (c == "light") {
currentMat = new Material(vec3(f[1], f[2], f[3]), vec3(f[4], f[5], f[6]), f[7]); }
// Creates a Material instance to be picked up by successive shapes
// third is beer's law transmission followed by index of refraction.
// First rgb is Diffuse reflection, second is specular reflection.
// later: brdf r g b r g b alpha r g b ior
// syntax: brdf r g b r g b alpha
else if (c == "brdf") {
}
this->ambientLight = vec3(f[1], f[2], f[3]);
// accurately *calculating* the true ambient light. ;
// It will be ignored once your raytracer becomes capable of
// Sets the ambient color. Note: This parameter is temporary.
// syntax: ambient r g b
else if (c == "ambient") {
}
this->camera = new Camera(vec3(f[1], f[2], f[3]), Orientation(5, strings, f), f[4]);
// Eye position (x,y,z), view orientation (qw qx qy qz), frustum height ratio ry
// syntax: camera x y z ry <orientation spec>
else if (c == "camera") {
}
height = int(f[2]);
width = int(f[1]);
// syntax: screen width height
if (c == "screen") {
std::string c = strings[0];
if (strings.size() == 0) return;
{
const std::vector<float>& f)
void Scene::Command(const std::vector<std::string>& strings,
}
return q;
i+=4; } }
q *= angleAxis(f[i+0]*Radians, normalize(vec3(f[i+1], f[i+2], f[i+3])));
else if (c == "a") {
i+=4; }
q *= quat(f[i+0], f[i+1], f[i+2], f[i+3]);
else if (c == "q") {
q *= angleAxis(f[i++]*Radians, Zaxis());
else if (c == "z")
q *= angleAxis(f[i++]*Radians, Yaxis());
else if (c == "y")
q *= angleAxis(f[i++]*Radians, Xaxis());
if (c == "x")
std::string c = strings[i++];
while (i<strings.size()) {
quat q(1,0,0,0); // Unit quaternion
{
const std::vector<float>& f)
const std::vector<std::string>& strings,
quat Orientation(int i,
}
exit(-1); } }
printf("\nRead error on file %s:\n %s\n\n", path.c_str(), stbi_failure_reason());
if (!image) {
printf("%d %d %d %s\n", depth, width, height, path.c_str());
image = stbi_load(path.c_str(), &width, &height, &depth, 4);
stbi_set_flip_vertically_on_load(true);
// Read image, and check for success
else {
exit(-1); }
std::cerr << "Texture file not found: " << path << std::endl;
if (find_it.fail()) {
std::ifstream find_it(path.c_str());
// Does the file exist?
path.replace(path.find(bs), 1, fs);}
while (path.find(bs) != std::string::npos) {
std::string fs="/";
std::string bs="\\";
std::string path=bpath;
// Replace backslashes with forward slashes -- Good for Linux, and maybe Windows?
{
Texture::Texture(const std::string &bpath) : id(0)
}
}
this->shapes.push_back(new Triangle(v0, v1, v2, meshMat));
// Create a Triangle shape and add it to the scene with the scene file's material
vec3 v2 = mesh->vertices[tri[2]].pnt;
vec3 v1 = mesh->vertices[tri[1]].pnt;
vec3 v0 = mesh->vertices[tri[0]].pnt;
// tri is an ivec3 containing three vertex indices
for (const auto& tri : mesh->triangles) {
// Convert mesh data into individual Triangle shapes and add to scene
Material* meshMat = currentMat;
// This allows the scene file to control mesh appearance
// Use the currentMat from the scene file (set by brdf command)
{
void Scene::triangleMesh(MeshData* mesh)
}
this->accelStruc = new AccelerationBvh(this->shapes);
{
void Scene::Finit()
// Call myrandom(RNGen) to get a uniformly distributed random number in [0,1].
std::uniform_real_distribution<> myrandom(0.0, 1.0);
std::mt19937_64 RNGen(device());
std::random_device device;
#include <random>
// A good quality *thread-safe* Mersenne Twister random number generator.
#include "stb_image.h"
#define STBI_FAILURE_USERMSG
#define STB_IMAGE_IMPLEMENTATION
#include "raytrace.h"
////////////////////////////////////////////////////////////////////////
// Provides the framework for a raytracer.
//////////////////////////////////////////////////////////////////////
Project 2
Path Tracing
A CPU path tracer with implicit and explicit light paths, Russian Roulette termination and jittered sub-pixel sampling, compared side by side from 8 to 4096 passes.
Course
CS 500, Ray Tracing
Submitted
1 March 2026
Status
Complete
Project overview
A CPU path tracer that renders physically based lighting with both implicit and explicit path tracing. Global illumination comes from simulating light bounces, with Russian Roulette terminating paths and explicit light connections speeding up convergence.
The framework is modified to toggle between implicit and explicit path tracing, and writes each mode’s output images into its own folder for side by side comparison.
Natural anti-aliasing via jittered sampling
Both modes sample pixels stochastically, which improves output quality on its own. Instead of shooting a ray through the exact center of every pixel (x + 0.5f), the ray’s position inside the pixel is randomized with sub-pixel jitter on every pass:
float u = (float)(x + myrandom(RNGen)) / width;float v = (float)(y + myrandom(RNGen)) / height;
Varying the primary ray’s origin within the pixel’s bounds on each pass makes the final pixel color an average of several slightly different scene samples. That smooths jagged edges (aliasing) on geometry boundaries and removes the stair-stepping artifacts, so no separate post-process anti-aliasing step is needed.
Implicit path tracing
Purpose
Compute global illumination by bouncing rays randomly through the scene according to the surface BRDF. A ray’s contribution is recorded only if it happens to intersect a light source along its traversal.
Implementation overview
The implicit path tracing logic lives in Scene::TracePath. At each intersection the path is extended by sampling a new direction from the surface’s BRDF.
That distributes the scattered rays according to Lambert’s cosine law, so the probability is concentrated at angles perpendicular to the diffuse surface rather than at grazing ones.
Scattering evaluation and probability
When a path bounces, the light’s contribution has to be modulated by the material’s BRDF, which is evaluated in EvalScattering:
The probability density function (pdfBrdf) follows the same Lambert cosine law as the diffuse reflection, so it exactly matches the scattering term and the two cancel out completely. The scattering evaluation is purely diffuse for now, but keeping EvalScattering and pdfBrdf as separate functions leaves room to extend the framework with other material components, through more complex BRDF calculations, later.
Russian Roulette path termination
The implicit path tracing loop has no hard-coded maximum bounce depth. Paths span as many bounces as the Russian Roulette statistical method lets them:
At every bounce iteration the ray has an 80% chance of continuing and a 20% chance of being terminated artificially. To keep the result mathematically unbiased when a path is terminated early, the accumulated weight W is divided by the probability of continuation, so over a high number of passes the surviving paths scale out to the value the infinite-bouncing paths would have given in the exact global illumination equation.
Interpretation
Implicit path tracing samples light in a mathematically correct, unbiased way. Because light sources are only found by pure chance, though, it has high variance (noise), especially for small light sources. In the resulting renders that reliance on chance makes the computation far less efficient than an explicit connection. The path tracer does converge to the correct global illumination solution eventually, but a clean image takes a massive number of passes.
Test images
Rendered with implicit paths only, growing in powers of 2 from 8 to 4096 passes. Convergence is very slow, with significant noise remaining even at the higher pass counts.
8 Passes
16 Passes
Click to zoom
Click to zoom
32 Passes
64 Passes
Click to zoom
Click to zoom
128 Passes
256 Passes
Click to zoom
Click to zoom
512 Passes
1024 Passes
Click to zoom
Click to zoom
2048 Passes
4096 Passes
Click to zoom
Click to zoom
Explicit path tracing
Purpose
Accelerate convergence by explicitly tracing a shadow ray toward the light source at every bounce. Unless the ray is occluded, every step then evaluates a light contribution, which cuts variance sharply compared to implicit sampling.
Implementation overview
In Scene::TracePath, if TRACE_EXPLICIT_PATH is enabled, a specific point on the light source is sampled before extending the ray randomly.
First, we sample the light’s position and compute its PDF alongside the geometry factor relative to the current intersection:
Intersection L = SampleLight();float p = PdfLight(L) / GeometryFactor(P, L);glm::vec3 inDir = normalize(L.position - P.position);
Next, a shadow ray is explicitly cast toward the light to evaluate occlusion:
Ray shadowRay(P.position + EPSILON * P.normal, inDir);Intersection hit = accelStruc->intersect(shadowRay);if (p > 0 && hit.t < INF && hit.shape->mat->isLight()) { float eval = EvalScattering(P.normal, inDir); vec3 f = P.shape->mat->Kd * eval; C += 0.5f * W * (f / p) * hit.shape->mat->Kd;}
Explicit light sampling
Where the implicit mode has to strike the light through raw, globally randomized bounces, the explicit mode queries the light bounds deterministically, on demand. Each path evaluation selects a uniformly distributed point on the sphere surrounding the light’s origin:
std::pair<vec3, vec3> Scene::SampleSpehere(glm::vec3 center, float radius) { float e1 = myrandom(RNGen); float e2 = myrandom(RNGen); float z = 2 * e1 - 1; float r = sqrt(1 - z * z); float a = 2 * PI * e2; glm::vec3 N = vec3(r * cos(a), r * sin(a), z); glm::vec3 P = center + radius * N; return std::make_pair(P, N);}
The routine converts a random spherical coordinate sample onto the actual bounds of a sphere in the scene, here the light. Because the sample lands on a fixed point of the spherical light surface, an unoccluded explicit shadow ray evaluates valid, physical direct lighting every time it is cast.
Convergence analysis
Explicit path tracing guarantees that every unoccluded surface immediately receives direct light, so the image converges in far fewer passes. Implicit paths waste evaluation time whenever a ray wanders into dark space or misses the light.
Comparing the output images shows the speedup. An explicit render at 16 passes exhibits a noise level visually comparable to an implicit render at 32 passes. Because shadow rays deterministically gather the light contribution at each vertex, the variance drops far faster per pass.
By 128 passes the explicit method produces a nearly noise-free image in the directly lit areas, while the implicit method still shows severe high-frequency noise. As the number of passes keeps increasing, towards 512 and beyond, both methods converge mathematically toward the exact same correct solution, and at those high sample counts the visual difference between strict implicit and explicit path tracing becomes almost negligible.
When both implicit and explicit paths are computed (a Multiple Importance Sampling-like approach), the weights are blended, here by multiplying by 0.5f, to keep the result unbiased.
Interpretation
Explicit calculation accounts for the light’s visible contribution directly, which gives smoother areas of direct illumination and sharper shadow definition at much lower pass counts. In the rendered outputs the geometry faintly casts soft shadows, specifically the rabbit, the dwarf and the sphere. Those shadows blend into the environment’s global illumination, and they are the occlusion the explicit shadow rays estimated during evaluation.
Test images
Rendered with explicit path tracing (Next Event Estimation). The noise clears up much faster than in the implicit renders.
8 Passes
16 Passes
Click to zoom
Click to zoom
32 Passes
64 Passes
Click to zoom
Click to zoom
128 Passes
256 Passes
Click to zoom
Click to zoom
512 Passes
1024 Passes
Click to zoom
Click to zoom
2048 Passes
4096 Passes
Click to zoom
Click to zoom
Side-by-side comparison
The implicit and explicit rendering methods at identical pass counts. The explicit method reaches a smoother, more converged image significantly earlier than the implicit one.
Passes
Implicit Path Tracing
Explicit Path Tracing
8
Click to zoom
Click to zoom
16
Click to zoom
Click to zoom
32
Click to zoom
Click to zoom
64
Click to zoom
Click to zoom
128
Click to zoom
Click to zoom
256
Click to zoom
Click to zoom
512
Click to zoom
Click to zoom
1024
Click to zoom
Click to zoom
2048
Click to zoom
Click to zoom
4096
Click to zoom
Click to zoom
Summary
Implicit path tracing: relies purely on random chance to intersect light sources. It is mathematically sound and guaranteed to converge to the correct global illumination solution, but it suffers from severe variance (noise) at lower pass counts, and resolving intricate shapes and soft shadows takes a massive computational effort.
Explicit path tracing: incorporates next-event estimation, deterministically casting a shadow ray directly to the light source at every bounce. Visual convergence is drastically quicker, giving a much cleaner, nearly noise-free image at a fraction of the required passes.
Convergence parity: in the side-by-side comparison, explicit rendering at 16 passes has visual clarity comparable to an implicit render at 32 passes or more. As the pass count approaches high numbers (512 and beyond), both algorithms converge to the identical, true rendering of the lighted scene.
Framework extensibility: the scattering evaluation and probability density terms mathematically cancel out for now, but the framework deliberately maintains separate, generic calls for EvalScattering and pdfBrdf. That leaves the extensibility to implement and evaluate complex, non-diffuse materials (such as Specular or Microfacet BRDFs) later.