Post

Ray Tracing The Rest of Your Life in OptiX

A few weeks ago I blogged about my experiences working on an OptiX version of Peter Shirley’s “Ray Tracing The Next Week”. With that done, I moved on to the third book of his series, “The Rest of Your Life”, a much more theoretical text where Shirley introduces important concepts like Monte Carlo Integration and Importance Sampling. As before, in case you still don’t know, the books are free and a great learning material for everyone trying to get into the graphics field(or just looking to have some fun while coding!).

My implementation here is a follow up from my previous post, and as before it’s completely available on GitHub, with releases separated by chapter. The excerpts of code I include in this post might be slightly different than what’s in their respective releases, as I’m writing this post after I was done with the whole project. This time, I try to keep the discussion limited to the chapters where code is implemented, which is why I omitted chapters 1-3 and 9 here. I recommend the reader to consult the book whenever needed.

My code is by no means optimized and many times the design decisions I make here might not be the best approach to take in OptiX. I tried to stay as close as possible to Peter Shirley’s book, while still using OptiX features whenever I could. If you notice any errors, or have any feedback, suggestions and critics, please do mention it!

header

Chapter 4 - Importance Sampling Materials

In the fourth chapter of “The Rest of Your Life”, the author introduces the notion of importance sampling. The general idea is that sampling certain objects or parts of the scenes, like light sources, more often can help to make the image less noisy, so we want to send more rays towards them.

As in the book, we will only use the Lambertian and Diffuse Light materials for now, adding new scattering_pdf functions and changing their current scatter functions. For the Lambertian material we have:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ...

inline __device__ bool scatter(const optix::Ray &ray_in,
                               DRand48 &rndState,
                               vec3f &scattered_origin,
                               vec3f &scattered_direction,
                               vec3f &attenuation,
                               float &pdf) {
  vec3f target = hit_rec_p + hit_rec_normal + random_in_unit_sphere(rndState);

  // return scattering event
  scattered_origin = hit_rec_p;
  scattered_direction = (target - hit_rec_p);
  attenuation = sample_texture(hit_rec_u, hit_rec_v, hit_rec_p);
  pdf = dot(hit_rec_normal, scattered_direction) / CUDART_PI_F;
  return true;
}

inline __device__ float scattering_pdf(){
  float cosine = dot(hit_rec_normal, unit_vector(prd.out.scattered_direction));
  
  if(cosine < 0.f)
    cosine = 0.f;
  
  return cosine / CUDART_PI_F;
}

RT_PROGRAM void closest_hit() {
  prd.out.emitted = emitted();
  prd.out.scatterEvent
    = scatter(ray,
              *prd.in.randState,
              prd.out.scattered_origin,
              prd.out.scattered_direction,
              prd.out.attenuation,
              prd.out.pdf)
    ? rayGotBounced
    : rayGotCancelled;
  prd.out.scattered_pdf = scattering_pdf();
}

// ...

For the Diffuse Light material, we only need to return false on both functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...

inline __device__ bool scatter(const optix::Ray &ray_in,
                                 DRand48 &rndState,
                                 vec3f &scattered_origin,
                                 vec3f &scattered_direction,
                                 vec3f &attenuation,
                                 float &pdf) {
  return false;
}

inline __device__ float scattering_pdf(){
  return false;
}

// ...

Note that we added two float variables, pdf and scattered_pdf, to the prd.out struct in the programs/prd.h file. We have also changed the raygen.cu shader:

1
2
3
4
5
6
7
8
9
10
// ...
else { // ray is still alive, and got properly bounced
  attenuation = prd.out.emitted + (prd.out.attenuation * prd.out.scattered_pdf * attenuation) / prd.out.pdf;
  ray = optix::make_Ray(/* origin   : */ prd.out.scattered_origin.as_float3(),
                        /* direction: */ prd.out.scattered_direction.as_float3(),
                        /* ray type : */ 0,
                        /* tmin     : */ 1e-3f,
                        /* tmax     : */ RT_DEFAULT_MAX);
}
// ...

Rendering the Cornell Box scene in a resolution of 1080 x 1080 with 1k spp gives us:

header

The code for this chapter is available here.

Chapter 5 - Generating Random Directions

Not much has been implemented on Chapter 5. Peter Shirley goes own to explain the underlying math used to generate random directions, especifically by using a cosine density function. We add a new ramdon_cosine_direction function in the programs/sampling.h file that will be used later on.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...

inline __device__ vec3f random_cosine_direction(DRand48 &rnd){
	float r1 = rnd();
	float r2 = rnd();

	float phi = 2 * CUDART_PI_F * r1;

	float x = cos(phi) * 2 * sqrt(r2);
	float y = sin(phi) * 2 * sqrt(r2);
	float z = sqrt(1 - r2);

	return vec3f(x, y, z);
}

// ...

Chapter 6 - Ortho-normal Bases

In Chapter 6 of “The Rest of Your Life”, Shirley introduces Ortho-normal Bases as a way to generate random directions relative to a surface normal vector. We implement an onb struct in the file programs/onb.h, similar to the one in the book:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//...
struct onb {
    __device__ onb() {}

    __device__ vec3f local(float a, float b, float c) const {
        return a * u + b * v + c * w;
    }

    __device__ vec3f local(const vec3f &a) const {
        return a.x * u + a.y * v + a.z * w;
    }

    __device__ void onb::build_from_w(const vec3f& n){
        w = unit_vector(n);
        
        vec3f a;
        if(fabsf(w.x) > 0.9)
            a = vec3f(0.f, 1.f, 0.f);
        else
            a = vec3f(1.f, 0.f, 0.f);
        
        v = unit_vector(cross(w, a));
        u = cross(w, v);
    }

    vec3f axis[3], u, v, w;
};

The Lambertian material scatter function should then be updated to make use of ONBs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...
inline __device__ bool scatter(const optix::Ray &ray_in,
                               DRand48 &rndState,
                               vec3f &scattered_origin,
                               vec3f &scattered_direction,
                               vec3f &attenuation,
                               float &pdf) {
  onb uvw;
  uvw.build_from_w(hit_rec_normal);

  vec3f direction = uvw.local(random_cosine_direction(rndState));

  // return scattering event
  scattered_origin = hit_rec_p;
  scattered_direction = unit_vector(direction);
  attenuation = sample_texture(hit_rec_u, hit_rec_v, hit_rec_p);
  pdf = dot(uvw.w, scattered_direction) / CUDART_PI_F;

  return true;
}
// ...

Rendering the scene in a resolution of 1080 x 1080 with 1k spp gives us:

header

You may notice a subtle circular pattern is showing up in the left box. It’s not present in the book’s output for this chapter. This is probably related to the changes we made to the scatter function of the Lambertian material, but I couldn’t find exactly what was causing them. They will become more evident in the next chapter, but disappear later on. If I end up finding a fix, I will come back and update this chapter, but it’s not my priority for the time being.

The code for this chapter is available here.

Chapter 7 - Sampling Lights Directly

The author goes on to introduce direct light sampling in the Chapter 7 of the book. We are currently sampling emissive objects in the same way as unimportant directions. We will now try to send more rays to our light source. A different approach, as Peter Shirley mentions, would be to use Shadow Rays and treat direct lights differently.

The raygen.cu Color function should be changed to sample light sources directly - for the time being, in a hard coded way. We also added a vec3f normal variable to prd.out.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ...

else { // ray is still alive, and got properly bounced
  vec3f on_light(213.f + rnd() * (343.f - 213.f), 554.f, 227.f + rnd() * (332.f - 227.f));
  vec3f to_light(on_light - prd.out.scattered_origin);
  
  float distance_squared = to_light.squared_length();
  to_light.make_unit_vector();

  if(dot(to_light, prd.out.normal) < 0.f)
    return attenuation * prd.out.emitted;
  else{
    float light_cosine = fabsf(to_light.y);

    if(light_cosine < 0.000001)
      return attenuation * prd.out.emitted;
    else{
      float light_area = (343.f - 213.f) * (332.f - 227.f);
      float pdf = distance_squared / (light_cosine * light_area);
      attenuation = prd.out.emitted + (prd.out.attenuation * prd.out.scattered_pdf * attenuation) / pdf;
      ray = optix::make_Ray(/* origin   : */ prd.out.scattered_origin.as_float3(),
                            /* direction: */ to_light.as_float3(),
                            /* ray type : */ 0,
                            /* tmin     : */ 1e-3f,
                            /* tmax     : */ RT_DEFAULT_MAX);
    }
  }
}

// ...

We also update the emitted function of the emissive material, as in the book, making the light just emit down.

1
2
3
4
5
6
7
8
9
10
// ...

inline __device__ float3 emitted(){
  if(dot(hit_rec_normal, ray.direction) < 0.f)
    return sample_texture(hit_rec_u, hit_rec_v, hit_rec_p);
  else
    return make_float3(0.f);
}

// ...

Rendering the scene in a resolution of 1080 x 1080 with 1k spp gives us:

header

The code for this chapter is available here.

Chapter 8 - Mixture Densities

PDF classes are added in the Chapter 8 of “The Rest of Your Life”, but, as we know, we can’t use virtual functions in OptiX code. Once again, we make use of callable programs - we will need Cosine, Rectangle and Mixture PDF callable programs.

To make it easier to give the needed attributes to our PDF programs, we implement two new structs, pdf_in and pdf_rec, defined in the programs/pdfs/pdf.h file as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...

struct pdf_rec {
    float distance;
    vec3f normal;
};

struct pdf_in {
    __device__ pdf_in(const vec3f o, const vec3f d, const vec3f n) 
                                : origin(o), direction(d), normal(n) {
        uvw.build_from_w(normal);
    }

    const vec3f origin;
    const vec3f direction;
    const vec3f normal;
    vec3f light_direction;
    onb uvw;
};

// ...

For the device side of the Cosine program, we have:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...

#include "pdf.h"

RT_CALLABLE_PROGRAM float3 cosine_generate(pdf_in &in, DRand48 &rnd) {
    vec3f temp = random_cosine_direction(rnd);
    in.light_direction = in.uvw.local(temp);
    return in.light_direction.as_float3();
}

RT_CALLABLE_PROGRAM float cosine_value(pdf_in &in) {
    float cosine = dot(unit_vector(in.direction), in.uvw.w);
    if(cosine > 0.f)
        return cosine / CUDART_PI_F;
    else
        return 0.f;
}

// ...

With the Cosine program’s host side, in the host_includes/pdfs.h file, being:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...

struct Cosine_PDF : public PDF{
    Cosine_PDF() {}

    virtual optix::Program assignGenerate(optix::Context &g_context) const override {
        optix::Program generate = g_context->createProgramFromPTXString(embedded_cosine_pdf_programs, "cosine_generate");
        
        return generate;
    }

    virtual optix::Program assignValue(optix::Context &g_context) const override {
        optix::Program value = g_context->createProgramFromPTXString(embedded_cosine_pdf_programs, "cosine_value");

        return value;
    }
};

// ...

Our Rectangle program is the equivalent of putting together a hitable PDF and a Y-axis aligned rectangle boundary in the book’s implementation, which gives us, in the device side:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// ...

rtDeclareVariable(float,  a0, , );
rtDeclareVariable(float,  a1, , );
rtDeclareVariable(float,  b0, , );
rtDeclareVariable(float,  b1, , );
rtDeclareVariable(float, k, , );

inline __device__ bool hit_y(pdf_in &in, const float tmin, const float tmax, pdf_rec &rec) {
    float t = (k - in.origin.y) / in.light_direction.y;        

    float a = in.origin.x + t * in.light_direction.x;
    float b = in.origin.z + t * in.light_direction.z;
    if (a < a0 || a > a1 || b < b0 || b > b1)
        return false;
    
    if (t < tmax && t > tmin){
        rec.normal = make_float3(0.f, 1.f, 0.f);
        rec.distance = t;
        return true;
    }

    return false;
}

RT_CALLABLE_PROGRAM float rect_y_value(pdf_in &in) {
    pdf_rec rec;

    if(hit_y(in, 0.001f, FLT_MAX, rec)){
        float area = (a1 - a0) * (b1 - b0);
        float distance_squared = rec.distance * rec.distance * in.light_direction.squared_length();
        float cosine = fabs(dot(in.light_direction, rec.normal) / in.light_direction.length());
        return distance_squared / (cosine * area);
    }
    else
        return 0.f;
}

RT_CALLABLE_PROGRAM float3 rect_y_generate(pdf_in &in, DRand48 &rnd) {
    float3 random_point = make_float3(a0 + rnd() * (a1 - a0), k, b0 + rnd() * (b1 - b0));
    in.light_direction = vec3f(random_point - in.origin.as_float3());
    return in.light_direction.as_float3();
}

// ...

Its host side is given by:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ...

struct Rect_Y_PDF : public PDF {
    Rect_Y_PDF(const float aa0, const float aa1, const float bb0, const float bb1, const float kk)
                : a0(aa0), a1(aa1), b0(bb0), b1(bb1), k(kk) {}

    virtual optix::Program assignGenerate(optix::Context &g_context) const override {
        optix::Program generate = g_context->createProgramFromPTXString(embedded_rect_pdf_programs, "rect_y_generate");

        generate["a0"]->setFloat(a0);
        generate["a1"]->setFloat(a1);
        generate["b0"]->setFloat(b0);
        generate["b1"]->setFloat(b1);
        generate["k"]->setFloat(k);

        return generate;
    }

    virtual optix::Program assignValue(optix::Context &g_context) const override {
        optix::Program value = g_context->createProgramFromPTXString(embedded_rect_pdf_programs, "rect_y_value");

        value["a0"]->setFloat(a0);
        value["a1"]->setFloat(a1);
        value["b0"]->setFloat(b0);
        value["b1"]->setFloat(b1);
        value["k"]->setFloat(k);
        
        return value;
    }

    float a0, a1, b0, b1, k;

// ...

The Mixture PDF callable program is defined, in device code, as:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...

rtDeclareVariable(rtCallableProgramId<float(pdf_in&)>, p0_value, , );
rtDeclareVariable(rtCallableProgramId<float(pdf_in&)>, p1_value, , );

RT_CALLABLE_PROGRAM float mixture_value(pdf_in &in) {
    return 0.5f * p0_value(in) + 0.5f * p1_value(in);
}

rtDeclareVariable(rtCallableProgramId<float3(pdf_in&, DRand48&)>, p0_generate, , );
rtDeclareVariable(rtCallableProgramId<float3(pdf_in&, DRand48&)>, p1_generate, , );

RT_CALLABLE_PROGRAM float3 mixture_generate(pdf_in &in, DRand48 &rnd) {
    if (rnd() < 0.5f)
        return p0_generate(in, rnd);
    else
        return p1_generate(in, rnd);
}

with its host side being:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ...

struct Mixture_PDF : public PDF {
    Mixture_PDF(const PDF *p00, const PDF *p11) : p0(p00), p1(p11) {}

    virtual optix::Program assignGenerate(optix::Context &g_context) const override {
        optix::Program generate = g_context->createProgramFromPTXString(embedded_mixture_pdf_programs, "mixture_generate");

        generate["p0_generate"]->setProgramId(p0->assignGenerate(g_context));
        generate["p1_generate"]->setProgramId(p1->assignGenerate(g_context));

        return generate;
    }

    virtual optix::Program assignValue(optix::Context &g_context) const override {
        optix::Program value = g_context->createProgramFromPTXString(embedded_mixture_pdf_programs, "mixture_value");

        value["p0_value"]->setProgramId(p0->assignValue(g_context));
        value["p1_value"]->setProgramId(p1->assignValue(g_context));
        
        return value;
    }

    const PDF* p0;
    const PDF* p1;
};

// ...

Note that the pdf_in parameters are references. If you don’t pass a reference to pdf_in in the rtDeclareVariable definition, OptiX will pass a copy of the variable each time the program is executed, which is both slow and incorrect in this context.

Also note that the PDF assignTo functions return an optix::Program just so we may be able to assign the generated programs to other programs, in a chained attribution. We will use this idea once more later in the project.

Once again, we should change the raygen.cu Color function accordingly to reflect the new PDF callable programs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// PDF callable programs
rtDeclareVariable(rtCallableProgramId<float(pdf_in&)>, value, , );
rtDeclareVariable(rtCallableProgramId<float3(pdf_in&, DRand48&)>, generate, , );

// ...

else { // ray is still alive, and got properly bounced      
      pdf_in in(prd.out.scattered_origin, prd.out.scattered_direction, prd.out.normal);
      float3 pdf_direction = generate(in, rnd);
      float pdf_val = value(in);

      attenuation = prd.out.emitted + (prd.out.attenuation * prd.out.scattered_pdf * attenuation) / pdf_val;
      ray = optix::make_Ray(/* origin   : */ in.origin.as_float3(),
                            /* direction: */ pdf_direction,
                            /* ray type : */ 0,
                            /* tmin     : */ 1e-3f,
                            /* tmax     : */ RT_DEFAULT_MAX);
}

// ...

Considering that the sampling should now be set on a scene by scene basis, we moved the definitions of our Ray Generation, Miss and Exception programs to the scene functions.

We also added a de_nan function, which originally happens in the Chapter 10 of the book, to eliminate eventual dead pixels from our output right away.

1
2
3
4
5
6
7
8
9
10
inline __device__ vec3f de_nan(const vec3f& c) {
  vec3f temp = c;
  if(!(temp.x == temp.x)) temp.x = 0.f;
  if(!(temp.y == temp.y)) temp.y = 0.f;
  if(!(temp.z == temp.z)) temp.z = 0.f;

  return temp;
}

Rendering the scene in a resolution of 1080 x 1080 with 1k spp gives us:

header

The code for this chapter is available here.

Chapter 10 - Cleaning Up PDF Management

The scene is slightly different when compared to the book. That’s because the scattered_pdf functions should now take the directions generated by the PDF callable programs from the raygen.cu shader. The easiest way to fix this is to make them callable programs as well and set them on a buffer of callable programs from the host.

The scattered_pdf function from the Lambertian material is changed to a callable program as follows:

1
2
3
4
5
6
7
8
RT_CALLABLE_PROGRAM float scattering_pdf(pdf_in &in){
  float cosine = dot(unit_vector(in.normal), unit_vector(in.scattered_direction));
  
  if(cosine < 0.f)
    cosine = 0.f;
  
  return cosine / CUDART_PI_F;
}

with an analogous change being made to the Diffuse Light scattered_pdf. The raygen.cu color function should be changed to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...

rtBuffer< rtCallableProgramId<float(pdf_in&)> > scattering_pdf;

// ...

else{
    pdf_in in(prd.out.origin, prd.out.normal);
    float3 pdf_direction = generate(in, rnd);
    float pdf_val = value(in);

    current_color = prd.out.emitted + (prd.out.attenuation * scattering_pdf[prd.out.type](in) * current_color) / pdf_val;

    ray = optix::make_Ray(/* origin   : */ in.origin.as_float3(),
                            /* direction: */ pdf_direction,
                            /* ray type : */ 0,
                            /* tmin     : */ 1e-3f,
                            /* tmax     : */ RT_DEFAULT_MAX);
}

// ...

Note that we renamed the attenuation local variable from the function to current_color. We also need to add a type variable to our prd.out struct. type is an enum, defined in programs/prd.h as follows:

1
2
3
4
5
6
7
8
9
10
11
// ...

typedef enum {
  Lambertian,
  Diffuse_Light,
  Metal,
  Dielectric,
  Isotropic
} Material_Type;

// ...

The host side, in the scene functions, is updated as follows:

1
2
3
4
5
6
7
8
9
10
// ...

// add material PDFs
optix::Buffer material_pdfs = g_context->createBuffer(RT_BUFFER_INPUT, RT_FORMAT_PROGRAM_ID, 2);
optix::callableProgramId<int(int)>* f_data = static_cast<optix::callableProgramId<int(int)>*>(material_pdfs->map());
f_data[ 0 ] = optix::callableProgramId<int(int)>(Lambertian_PDF(g_context)->getId());
f_data[ 1 ] = optix::callableProgramId<int(int)>(Diffuse_Light_PDF(g_context)->getId());
material_pdfs->unmap();

// ...

We are currently passing too many variables between the materials’ closest hit and geometries’ intersection programs. Let’s add a Hit_Record struct to the hitables and materials, defined in programs/prd.h as follows:

1
2
3
4
5
6
7
8
9
struct Hit_Record {
  vec3f normal;
  vec3f p;
  float distance;
  float u;
  float v;
};

Updating our closest hit and intersection programs to support the Hit_Record struct, we get:

1
2
/*! the attributes we use to communicate between intersection programs and hit program */
rtDeclareVariable(Hit_Record, hit_rec, attribute hit_rec, );

We can also simplify the definition of the scatter functions. We don’t need to pass so many arguments when they are already available externally to the scope of said function, whose call on each material shader becomes:

1
inline __device__ bool scatter(const optix::Ray &ray_in);

We should now update our prd.out struct, removing some variables and adding a couple new ones, to prepare for some changes the book introduces next:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ...

struct PerRayData {
  struct {
    DRand48 *randState;
    float time;
  } in;
  struct {
    ScatterEvent  scatterEvent;
    vec3f         origin;
    vec3f         direction;
    vec3f         normal;
    vec3f         emitted;
    vec3f         attenuation;
    bool          is_specular;
    Material_Type type;
  } out;
};

// ...

We then updated the scatter function of the Lambertian shader, changing it to:

1
2
3
4
5
6
7
8
inline __device__ bool scatter(const optix::Ray &ray_in) {
  prd.out.is_specular = false;
  prd.out.attenuation = sample_texture(hit_rec.u, hit_rec.v, hit_rec.p.as_float3());
  prd.out.origin = hit_rec.p;
  prd.out.normal = hit_rec.normal;

  return true;
}

We should now fix our Specular materials, Metal and Dielectric. Our Metal scatter function becomes:

1
2
3
4
5
6
7
8
9
inline __device__ bool scatter(const optix::Ray &ray_in) {
  vec3f reflected = reflect(unit_vector(ray_in.direction), hit_rec.normal);
  prd.out.is_specular = true;
  prd.out.origin = hit_rec.p;
  prd.out.direction = reflected + fuzz * random_in_unit_sphere((*prd.in.randState));
  prd.out.attenuation = sample_texture(hit_rec.u, hit_rec.v, hit_rec.p.as_float3());
  prd.out.normal = hit_rec.normal;
  return true;
}

Finally, our Dielectric scatter function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
inline __device__ bool scatter(const optix::Ray &ray_in) {
  prd.out.is_specular = true;
  prd.out.origin = hit_rec.p;
  prd.out.attenuation = vec3f(1.f);
  prd.out.normal = hit_rec.normal;
  
  vec3f outward_normal;
  float ni_over_nt;
  float cosine;
  if (dot(ray_in.direction, hit_rec.normal) > 0.f) {
    outward_normal = -1 * hit_rec.normal;
    ni_over_nt = ref_idx;
    cosine = ref_idx * dot(ray_in.direction, hit_rec.normal) / vec3f(ray_in.direction).length();
  }
  else {
    outward_normal = hit_rec.normal;
    ni_over_nt = 1.f / ref_idx;
    cosine = -dot(ray_in.direction, hit_rec.normal) / vec3f(ray_in.direction).length();
  }
  
  vec3f refracted;
  float reflect_prob;
  if (refract(ray_in.direction, outward_normal, ni_over_nt, refracted)) 
    reflect_prob = schlick(cosine, ref_idx);
  else 
    reflect_prob = 1.f;

  vec3f reflected = reflect(ray_in.direction, hit_rec.normal);
  if ((*prd.in.randState)() < reflect_prob) 
    prd.out.direction = reflected;
  else 
    prd.out.direction = refracted;
  
  return true;
}

Next, we should add a Sphere_PDF to sample a glass sphere newly added to the scene. The device side is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
RT_CALLABLE_PROGRAM float sphere_value(pdf_in &in) {
    pdf_rec rec;

    if(hit_boundary(in, 0.001f, FLT_MAX, rec)){
        float cos_theta_max = sqrtf(1.f - radius * radius / vec3f(center - in.origin).squared_length());
        float solid_angle = 2.f * CUDART_PI_F * (1.f - cos_theta_max);
        return 1.f / solid_angle;
    }
    else
        return 0.f;
}

inline __device__ float3 random_to_sphere(float distance_squared, DRand48 &rnd) {
    float r1 = rnd();
    float r2 = rnd();
    
    float z = 1.f + r2 * (sqrtf(1.f - radius * radius / distance_squared) - 1.f);
    
    float phi = 2.f * CUDART_PI_F * r1;

    float x = cosf(phi) * sqrtf(1.f - z * z);
    float y = sinf(phi) * sqrtf(1.f - z * z);

    return make_float3(x, y, z);
}

RT_CALLABLE_PROGRAM float3 sphere_generate(pdf_in &in, DRand48 &rnd) {
    vec3f direction(center - in.origin);
    float distance_squared = direction.squared_length();
    
    in.uvw.build_from_w(direction);

    vec3f temp(random_to_sphere(distance_squared, rnd));
    in.scattered_direction = in.uvw.local(temp);

    return in.scattered_direction.as_float3();
}

with hit_boundary being analogous to a common sphere intersection function. The host side is given by:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Sphere_PDF : public PDF {
    Sphere_PDF(const vec3f c, const float r) : center(c), radius(r) {}

    virtual optix::Program assignGenerate(optix::Context &g_context) const override {
        optix::Program generate = g_context->createProgramFromPTXString(embedded_sphere_pdf_programs, "sphere_generate");

        generate["center"]->set3fv(&center.x);
        generate["radius"]->setFloat(radius);

        return generate;
    }

    virtual optix::Program assignValue(optix::Context &g_context) const override {
        optix::Program value = g_context->createProgramFromPTXString(embedded_sphere_pdf_programs, "sphere_value");

        value["center"]->set3fv(&center.x);
        value["radius"]->setFloat(radius);
        
        return value;
    }

    float radius;
    vec3f center;
};

Since we now want to sample multiple hitables, we need to once again use buffers of callable programs. Our Buffer_PDF works similarly to the hitable_list PDF functions from the book. The device side is given by:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "pdf.h"
rtDeclareVariable(int, size, , );

rtBuffer< rtCallableProgramId<float(pdf_in&)> > values;

RT_CALLABLE_PROGRAM float buffer_value(pdf_in &in) {
    float sum = 0.f;
    
    for(int i = 0; i < size; i++)
        sum += values[i](in);
    sum /= size;

    return sum;
}

rtBuffer< rtCallableProgramId<float3(pdf_in&, DRand48&)> > generators;

RT_CALLABLE_PROGRAM float3 buffer_generate(pdf_in &in, DRand48 &rnd) {
    int index = int(rnd() * size);
    return generators[index](in, rnd);
}

With the host side given by:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct Buffer_PDF : public PDF {
    Buffer_PDF(const std::vector<PDF*> &b) : buffer_vector(b) {}

    virtual optix::Program assignGenerate(optix::Context &g_context) const override {
        optix::Program generate = g_context->createProgramFromPTXString(embedded_buffer_pdf_programs, "buffer_generate");

        optix::Buffer pdfs = g_context->createBuffer(RT_BUFFER_INPUT, RT_FORMAT_PROGRAM_ID, buffer_vector.size());
        optix::callableProgramId<int(int)>* f_data = static_cast<optix::callableProgramId<int(int)>*>(pdfs->map());
        
        for(int i = 0; i < buffer_vector.size(); i++)
            f_data[i] = optix::callableProgramId<int(int)> (buffer_vector[i]->assignGenerate(g_context)->getId());
        
        pdfs->unmap();

        generate["generators"]->setBuffer(pdfs);
        generate["size"]->setInt((int)buffer_vector.size());

        return generate;
    }

    virtual optix::Program assignValue(optix::Context &g_context) const override {
        optix::Program value = g_context->createProgramFromPTXString(embedded_buffer_pdf_programs, "buffer_value");

        optix::Buffer pdfs = g_context->createBuffer(RT_BUFFER_INPUT, RT_FORMAT_PROGRAM_ID, buffer_vector.size());
        optix::callableProgramId<int(int)>* f_data = static_cast<optix::callableProgramId<int(int)>*>(pdfs->map());
        
        for(int i = 0; i < buffer_vector.size(); i++)
            f_data[i] = optix::callableProgramId<int(int)> (buffer_vector[i]->assignValue(g_context)->getId());
        
        pdfs->unmap();

        value["values"]->setBuffer(pdfs);
        value["size"]->setInt((int)buffer_vector.size());
        
        return value;
    }

    std::vector<PDF*> buffer_vector;
};

and the Scene function setup defined by:

1
2
3
4
5
6
7
8
// ...

std::vector<PDF*> buffer;
buffer.push_back(new Rect_Y_PDF(213.f, 343.f, 227.f, 332.f, 554.f));
buffer.push_back(new Sphere_PDF(vec3f(190.f, 90.f, 190.f), 90.f));
Mixture_PDF mixture(new Cosine_PDF(), new Buffer_PDF(buffer));

// ...

Rendering the scene in a resolution of 1080 x 1080 with 10k spp gives us:

header

Wait, this doesn’t seem right, what are these white dots?

After searching for a bit, these are most likely rendering artifacts known as ‘fireflies’. There are several ways to get rid of them, some are fairly elaborate and would require considerably big changes in our raygen.cu shader. A way easier approach would be to simply clamp the radiance/attenuation of the color, which is what we will do for the time being. In the raygen.cu shader we add the following changes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Clamp color values
inline __device__ vec3f clamp(const vec3f& c) {
  vec3f temp = c;
  if(temp.x > 1.f) temp.x = 1.f;
  if(temp.y > 1.f) temp.y = 1.f;
  if(temp.z > 1.f) temp.z = 1.f;

  return temp;
}

// ...

// in the color function, we add clamp() to the current_color of the else statement
else{
    pdf_in in(prd.out.origin, prd.out.normal);
    float3 pdf_direction = generate(in, rnd);
    float pdf_val = value(in);
        
    current_color = clamp(prd.out.emitted + (prd.out.attenuation * scattering_pdf[prd.out.type](in) * current_color) / pdf_val);
    
    // ...
}

// ...

You may notice this changes the general appearance of the results slightly, but it seems decent for now. If we render the scene once more, again in a resolution of 1080 x 1080 with 10k spp, we get:

header

Much better! This image took about 55mins to render. The code for this chapter is available here.

The Rest of Your Life, Further Reading & Final Remarks

Before we get to the final remarks of this post, let’s fix the other scenes.

We add the following code to the beginning of the InOneWeekend scene function. We don’t have a light source, so let’s sample our big metal sphere:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...

Mixture_PDF mixture(new Cosine_PDF(), new Sphere_PDF(vec3f(4.f, 1.f, 0.f), 1.f));

// add material PDFs
optix::Buffer material_pdfs = g_context->createBuffer(RT_BUFFER_INPUT, RT_FORMAT_PROGRAM_ID, 2);
optix::callableProgramId<int(int)>* f_data = static_cast<optix::callableProgramId<int(int)>*>(material_pdfs->map());
f_data[ 0 ] = optix::callableProgramId<int(int)>(Lambertian_PDF(g_context)->getId());
f_data[ 1 ] = optix::callableProgramId<int(int)>(Diffuse_Light_PDF(g_context)->getId());
material_pdfs->unmap();

// Set the exception, ray generation and miss shader programs
setRayGenerationProgram(g_context, mixture, material_pdfs);
setMissProgram(g_context);
setExceptionProgram(g_context);

// ...

Rendering the scene in a resolution of 1080 x 1080 with 10k spp gives us:

header

This image took about 25 mins to render.

We have a light source in our MovingSpheres scene, so the changes we introduce to the beginning of the function are:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...

// configure sampling
std::vector<PDF*> buffer;
buffer.push_back(new Rect_Z_PDF(3.f, 5.f, 1.f, 3.f, -2.f));
buffer.push_back(new Sphere_PDF(vec3f(4.f, 1.f, 0.f), 1.f));
Mixture_PDF mixture(new Cosine_PDF(), new Buffer_PDF(buffer));

// add material PDFs
optix::Buffer material_pdfs = g_context->createBuffer(RT_BUFFER_INPUT, RT_FORMAT_PROGRAM_ID, 2);
optix::callableProgramId<int(int)>* f_data = static_cast<optix::callableProgramId<int(int)>*>(material_pdfs->map());
f_data[ 0 ] = optix::callableProgramId<int(int)>(Lambertian_PDF(g_context)->getId());
f_data[ 1 ] = optix::callableProgramId<int(int)>(Diffuse_Light_PDF(g_context)->getId());
material_pdfs->unmap();

// Set the exception, ray generation and miss shader programs
setRayGenerationProgram(g_context, mixture, material_pdfs);
setMissProgram(g_context);
setExceptionProgram(g_context);

// ...

With Rect_Z_PDF being defined in an analogous way to its Y-axis counterpart. Rendering the scene in a resolution of 1080 x 1080 with 10k spp gives us:

header

This image took about 20 mins to render.

Last but not least, for the Final scene of “The Next Week”, we have:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...

// configure sampling
std::vector<PDF*> buffer;
buffer.push_back(new Rect_Y_PDF(213.f, 343.f, 227.f, 332.f, 554.f));
buffer.push_back(new Sphere_PDF(vec3f(190.f, 90.f, 190.f), 90.f));
Mixture_PDF mixture(new Cosine_PDF(), new Buffer_PDF(buffer));

// add material PDFs
optix::Buffer material_pdfs = g_context->createBuffer(RT_BUFFER_INPUT, RT_FORMAT_PROGRAM_ID, 2);
optix::callableProgramId<int(int)>* f_data = static_cast<optix::callableProgramId<int(int)>*>(material_pdfs->map());
f_data[ 0 ] = optix::callableProgramId<int(int)>(Lambertian_PDF(g_context)->getId());
f_data[ 1 ] = optix::callableProgramId<int(int)>(Diffuse_Light_PDF(g_context)->getId());
material_pdfs->unmap();

// Set the exception, ray generation and miss shader programs
setRayGenerationProgram(g_context, mixture, material_pdfs);
setMissProgram(g_context);
setExceptionProgram(g_context);

// ...

There’s one remaining material that has yet to be fixed before rendering the scene, our Isotropic one used in volumetric surfaces. It’s not a specular material, but let’s treat it as one for the time being, at least as of now it will work in the same way as before. Its scatter function becomes:

1
2
3
4
5
6
7
8
9
10
11
12
inline __device__ bool scatter(const optix::Ray &ray_in) {
  prd.out.is_specular = true; 
  // Note that it's not a specular surface, but here, as of now, it will work in the same way.

  prd.out.origin = hit_rec.p;
  prd.out.direction = random_in_unit_sphere(*prd.in.randState);
  prd.out.normal = hit_rec.normal;
  prd.out.attenuation = sample_texture(hit_rec.u, hit_rec.v, hit_rec.p.as_float3());
  prd.out.type = Isotropic;

  return true;
}

Rendering the scene in a resolution of 1080 x 1080 with 10k spp gives us:

header

This image took about 90 mins to render.

Our Chapter 10 Git release already has the corrected/updated versions of these functions.

And that’s it for “The Rest of Your Life”! I really enjoyed to port this book to OptiX as well, as I managed to learn even more about the CG concepts it talks about and the API as a whole. Some of OptiX’s intrinsics may look tricky at first, but it makes a lot of things easier than doing everything on your own.

If, moving forward, you want to learn real time rendering, check the 4th edition of Haine’s Real Time Rendering. For physically based offline rendering, the 3rd edition of PBRT is available for free and definitely the best material on the area. For general Computer Graphics concepts, I would recommend getting McGuire’s Graphics Codex for $10, which is a steal for so much good quality content. There’s also Shirley’s Fundamentals of Computer Graphics and McGuire’s Principles and Practice, usually referenced as the ‘bible of CG’.

I want to keep expanding this Path Tracer and add support to other features. I will definitely go back and try to simplify some things, maybe making the implementation closer to some OptiX conventions, like implementing a proper miss shader, and various features showcased in the the OptiX advanced samples, like the use of shadow rays and triangle indexed bounding boxes. I’m also currently planning to implement support to OBJ models and some more complex materials(using the Material Definition Language and the Disney BRDF). Eventually, I plan to make it spectral. Would be interesting to look into Ray Marching, BDPT and Metropolis methods as well.

I can say for sure, possibly even more than before, that Ray Tracing is fun, and so is the Computer Graphics field as a whole. I’m in love with the area and that’s definitely what I want to do for a living moving forward.

Peter Shirley is amazing for being such a nice and welcoming person and for making this great book series available for free. The impact in the community has been huge, and will continue to be moving forward. If you want to get in contact with him, check his Twitter profile, I’m sure he would love to help when needed and see the beautiful images you may get.

This project and the post itself are bound to have errors, so let me know if you find anything that should be fixed. I would also be glad to hear any kind of feedback, critics and suggestions. Just let me know through Twitter or in the comments section below.

Huge thanks for reading! I really appreciate it!

This post is licensed under CC BY 4.0 by the author.