Fundamentals 15 min read

How to Add Importance‑Sampling PDFs to a Rust Ray Tracer

This article walks through implementing probability‑density‑function (PDF) based importance sampling in a Rust ray‑tracing renderer, covering trait definitions, concrete PDF types for spheres, cosine distributions, hittable objects, quad geometry, material adjustments, and integration into the rendering loop to achieve faster convergence and higher image quality.

Architecture Development Notes
Architecture Development Notes
Architecture Development Notes
How to Add Importance‑Sampling PDFs to a Rust Ray Tracer

I recently read a book on ray tracing that guides the reader to build a ray tracer in C++. In this series of articles I will re‑implement the concepts using Rust and explore the details.

We will add importance sampling to our renderer by using probability density functions (PDFs). These PDFs model the distribution of "important" elements in the scene, allowing the renderer to emit more informative scattered rays and converge to acceptable images with fewer pixel samples.

Adding PDFs to the Renderer

To apply PDFs in the rendering process we first need to add a PDF property to shapes. We start by encapsulating PDF logic in a struct.

Probability Density Function Trait

We create a trait for the struct to implement and generate the necessary PDF values.

<code>pub trait Pdf {
    fn value(&self, direction: Vec3) -> f64;
    fn generate(&self) -> Vec3;
}
</code>

These functions let us generate a random vector following the PDF distribution and determine the probability (value) of a given direction under that PDF.

The first PDF is a sphere PDF that samples rays uniformly inside a sphere. Since every vector has equal probability, the value function returns a constant.

<code>pub struct SpherePdf {}

impl SpherePdf {
    pub fn new() -> SpherePdf {
        SpherePdf {}
    }
}

impl Pdf for SpherePdf {
    fn value(&self, _direction: Vec3) -> f64 {
        1.0 / (4.0 * std::f64::consts::PI)
    }

    fn generate(&self) -> Vec3 {
        random_unit_vector()
    }
}
</code>

Our cosine PDF samples a random vector in cosine space, transforms it to world space, and computes the probability by dot‑product with the orthonormal basis vector w , ensuring a non‑negative value and dividing by π to keep it between 0 and 1.

<code>pub struct CosinePdf {
    uvw: Onb,
}

impl CosinePdf {
    pub fn new(w: Vec3) -> CosinePdf {
        CosinePdf { uvw: Onb::new(&w) }
    }
}

impl Pdf for CosinePdf {
    fn value(&self, direction: Vec3) -> f64 {
        let cos_theta = dot(unit_vector(direction), self.uvw.w());
        f64::max(0.0, cos_theta / std::f64::consts::PI)
    }

    fn generate(&self) -> Vec3 {
        self.uvw.transform(Vec3::random_cosine_direction())
    }
}
</code>

The final PDF— HittablePdf —delegates probability calculation and vector generation to its underlying object.

<code>pub struct HittablePdf {
    origin: Point3,
    objects: Arc<dyn Hittable>,
}

impl HittablePdf {
    pub fn new(origin: Point3, objects: Arc<dyn Hittable>) -> HittablePdf {
        HittablePdf { origin, objects }
    }
}

impl Pdf for HittablePdf {
    fn value(&self, direction: Vec3) -> f64 {
        self.objects.pdf_value(self.origin, direction)
    }

    fn generate(&self) -> Vec3 {
        self.objects.random(self.origin)
    }
}
</code>

This means the Hittable trait now must implement a few new functions to assist PDF calculations.

<code>pub trait Hittable: Send + Sync {
    fn hit(&self, ray: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord>;
    fn pdf_value(&self, origin: Point3, direction: Vec3) -> f64 { 0.0 }
    fn random(&self, origin: Point3) -> Vec3 { Vec3::new(1.0, 0.0, 0.0) }
}
</code>

Quad PDF

We start by implementing a PDF for the most prominent object—a quadrilateral. We need to compute the quad's area.

<code>pub fn new(q: Point3, u: Vec3, v: Vec3, mat: Arc<dyn Material>) -> Self {
    let n = cross(u, v);
    // ...
    let area = n.length();
    Quad { q, u, v, w, mat, d, normal, area }
}
</code>

Now we let the quad implement the PDF functions.

<code>fn pdf_value(&self, origin: Point3, direction: Vec3) -> f64 {
    let rec = self.hit(&Ray::new(origin, direction, 0.0), 0.001, common::INFINITY);
    if let None = rec { return 0.0; }
    let rec = rec.expect("Should have a hit record");
    let distance_squared = rec.t * rec.t * direction.length_squared();
    let cosine = f64::abs(dot(direction, rec.normal) / direction.length());
    distance_squared / (cosine * self.area)
}

fn random(&self, origin: Point3) -> Vec3 {
    let p = self.q + (random_double() * self.u) + (random_double() * self.v);
    unit_vector(p - origin)
}
</code>

The random vector picks a point p on the quad surface and returns the direction from the origin to that point.

The probability calculation first checks whether the ray hits the quad; if not, it returns 0.0 because such a ray cannot be sampled from the quad PDF.

Distance‑squared accounts for how the PDF changes with distance: the farther the quad, the less likely a ray will intersect it, so the PDF grows proportionally with the square of the distance.

The cosine term accounts for the angle at which the ray hits the surface; grazing angles (cosine near 0) reduce the probability.

Material Cleanup

Before adding the PDF system to our renderer we make two small changes to the materials.

First, we add an empty material so it can be attached to importance‑sampling shapes.

<code>pub struct Empty {}

impl Empty {
    pub fn new() -> Empty { Empty {} }
}

impl Material for Empty {
    fn scatter(&self, _r_in: &Ray, _rec: &HitRecord) -> Option<ScatterRecord> {
        None
    }
}
</code>

Then we fix an error so that our diffuse‑light material only emits light when the hit is on the front face.

<code>impl Material for DiffuseLight {
    fn scatter(&self, _r_in: &Ray, _rec: &HitRecord) -> Option<ScatterRecord> {
        None
    }

    fn emitted(&self, hit_rec: &HitRecord, u: f64, v: f64, p: &Point3) -> Color {
        if hit_rec.front_face {
            self.albedo.get_color(u, v, p)
        } else {
            Color::new(0.0, 0.0, 0.0)
        }
    }
}
</code>

Rendering with Importance Sampling

Now we update the rendering system to accept important shapes that guide our rays and converge faster to good images.

Adding a Light Array

We modify the camera render function to accept a Hittable representing scene lights and pass it to ray_color , whose signature is also updated.

<code>pub fn render(&self, world: &HittableList, lights: Arc<dyn Hittable>) {
    print!("P3\n{} {}\n255\n", self.image_width, self.image_height);
    for j in (0..self.image_height).rev() {
        eprint!("\rScanlines remaining: {}", j);
        let pixel_colors: Vec<_> = (0..self.image_width)
            .into_par_iter()
            .map(|i| {
                let mut pixel_color = Color::new(0.0, 0.0, 0.0);
                for s_i in 0..self.sqrt_samples {
                    for s_j in 0..self.sqrt_samples {
                        let u = (i as f64 + random_double()) / (self.image_width - 1) as f64;
                        let v = (j as f64 + random_double()) / (self.image_height - 1) as f64;
                        let r = self.get_ray(u, v, s_i, s_j);
                        pixel_color += self.ray_color(&r, world, lights.clone(), self.max_depth);
                    }
                }
                pixel_color
            })
            .collect();
        for pixel_color in pixel_colors {
            color::write_color(&mut io::stdout(), pixel_color, self.samples_per_pixel);
        }
    }
    eprint!("\nDone.\n");
}
</code>

In our world construction we can create a shape representing an important light source. Currently only the quad implements a PDF, so our light will be a quad; later we will add PDF support to a HittableList to handle multiple lights.

<code>let lights = Arc::new(Quad::new(
    Point3::new(343.0, 554.0, 332.0),
    Vec3::new(-130.0, 0.0, 0.0),
    Vec3::new(0.0, 0.0, -105.0),
    Arc::new(Empty::new())
));
// ...
camera.render(&world, lights);
</code>

Updating the Rendering Logic

We now update the ray_color function. All changes occur in the block that handles a hit and determines how the ray scatters.

<code>fn ray_color(&self, ray: &Ray, world: &dyn Hittable, lights: Arc<dyn Hittable>, depth: i32) -> Color {
    if depth <= 0 { return Color::new(0.0, 0.0, 0.0); }

    if let Some(hit_rec) = world.hit(ray, 0.001, common::INFINITY) {
        let color_from_emission = hit_rec.mat.emitted(&hit_rec, hit_rec.u, hit_rec.v, &hit_rec.p);
        return match hit_rec.mat.scatter(ray, &hit_rec) {
            Some(scatter_rec) => {
                let light_pdf = HittablePdf::new(hit_rec.p, lights.clone());
                let scattered_ray = Ray::new(hit_rec.p, light_pdf.generate(), ray.time());
                let pdf_value = light_pdf.value(scattered_ray.direction());
                let scattered_pdf = hit_rec.mat.scatter_pdf(ray, &hit_rec, &scattered_ray);
                let sample_color = self.ray_color(&scattered_ray, world, lights.clone(), depth - 1);
                let color_from_scatter = (scatter_rec.attenuation * scattered_pdf * sample_color) / pdf_value;
                color_from_emission + color_from_scatter
            },
            None => color_from_emission,
        };
    } else {
        return self.background;
    }
}
</code>

We create a light_pdf using the light Hittable , generate a scattered ray direction from it, obtain the PDF probability, and combine it with the material's scatter PDF to compute the final pixel color.

By sampling from the light distribution we can converge to good rendering results much faster. The rendered image below used only ten samples per pixel because we accurately modeled the positions of important scene features, reducing noise and computational cost.

Conclusion

In this article we applied PDFs to our rendering logic and demonstrated how they dramatically speed up convergence to high‑quality images. Importance sampling in ray tracing focuses computation on light paths that significantly affect the scene appearance, improving efficiency, image quality, and reducing noise and cost.

graphicsRenderingRustPDFImportance SamplingRay Tracing
Architecture Development Notes
Written by

Architecture Development Notes

Focused on architecture design, technology trend analysis, and practical development experience sharing.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.