An interface for a BSDF class #
The goal of path tracing is to solve the rendering equation:
\(L_o(x,\omega_o)= L_e(x,\omega_o) + \int_{\Omega^{+}} f_r(x, \omega_i, \omega_o) L_i(x,\omega_i) (\omega_i \cdot n) d\omega_i\)Using the generalized Monte Carlo estimator we can write:
\(L_o(x,\omega_o)= L_e(x,\omega_o) + \frac{1}{N} \sum_{i=0}^{N-1}{\frac{f_r(x, \omega_i, \omega_o) L_i(x,\omega_i) (\omega_i \cdot n)}{p(\omega_i)}}\)The idea of path tracing is to set \(N\) to \(1\) to follow only one path of light:
\(L_o(x,\omega_o)= L_e(x,\omega_o) + {\frac{f_r(x, \omega_i, \omega_o) L_i(x,\omega_i) (\omega_i \cdot n)}{p(\omega_i)}}\)By repeating to compute \(L_o(x,\omega_o)\) this way (following only one light path), and averaging this value we will, get an approximation of the “real” \(L_o(x,\omega_o)\) value and elminate the need to evaluated a recursive integral.
If we want to formulate this as code, we will come up with something like this:
Li(...) {
...
return = L_e + brdf(...) * Li(...) * cos(theta) / pdf(w_i)
}
// integrator
for(int sample = 0; sample < samples_per_pixel; ++sample)
avg_Li += Li(...);
avg_Li = avg_Li/samples_per_pixel;
In the next section, we will have a more detailed look at how this pseudocode can be extended to get it working with diffuse surfaces.
A Diffuse BSDF class #
A more elaborate pseudocode for a diffuse surface can look like this (taken from here with minor modifications):
Li(Scene scene, Ray ray, int depth) {
Intersection its;
if (!scene.intersection(ray, its)) return 0;
// Take care of emittance
Color emitted = 0;
if (its.is_light_source()) emitted = its.get_radiance();
if(depth >= maxDepth) return emitted;
// BRDF should decide on the next ray
BRDF brdf = its.get_brdf();
Ray wi = brdf.sample(-ray);
float pdf = brdf.pdf(wi);
Color brdfValue = brdf.ealuate(wo, wi);
// Call recursively for indirect lighting
Color indirect = Li(scene, -wi, depth + 1);
return emitted + brdfValue * indirect * cosTheta(wi) / pdf;
}
The idea of the code is to have a BSDF class that provides the methods sample
, pdf
, and evaluate
.
Let’s have a closer look at this.
We will try now to derive a BSDF interface from this that can at least handle diffuse BRDFs.
We start with the pdf
member function and add the other functions later.
The pdf
member function returns the probability that a given direction wi
is sampled:
class BSDF {
public:
[[nodiscard]] virtual float pdf(const Vector3f& wi) const = 0;
};
The corresponding UML class diagram:
classDiagram BSDF: +float pdf(Vector3f wi)
The pdf
member function function expects that wi
is a normalized vector on the unit sphere.
Furthermore, wi
is expected to be given in the reflectance coordinate system.
Actually, all vectors handed into the BSDF interface or returned from it use as a basis
coordinate system the reflection coordinate system:
Please note that the reflection coordinate system can be different from the world space coordinate system you are using. I will always make use of the left-handed world space coordinate shown in the image.
When first considering what is a prober pdf
function you might think for a diffuse BSDF the pdf
member function
must return a constant value
since the probability that an incoming light right ray is reflected in some direction is the same for all directions.
This is correct, but we have the freedom to choose any PDF we want.
The question is not how a perfect diffuse reflection is distributed,
but what is the best strategy to sample it efficiently.
Something that is missing in our BSDF class is a sample
member function.
The sample
member function picks a random direction wi
for us
and the pdf
method is expected to reflect the probability density function of those
\(\omega_i\)
samples.
And this does not mean that our samples are uniformly distributed.
We could make it that way, but we will go for another distribution (cosine-weighted hemisphere sampling).
Now let us extend the BSDF class with a sample method.
The sample
function member takes a 2D sample point as input and returns a random sample direction
\(\omega_i\)
:
class BSDF {
public:
[[nodiscard]] virtual Vector3f sample(const Point2f& sample_point) const = 0;
[[nodiscard]] virtual float pdf(const Vector3f& wi) const = 0;
};
The corresponding UML class diagram:
classDiagram BSDF: +Vector3f sample(Point2f sample_point) BSDF: +float pdf(Vector3f wi) BSDF: +Color3f evaluate(Vector3f wi, Vector3f wo)
If we are handing in only a sampling point to the sample
method the BSDF is independent of the normal vector
\(n\)
at the intersection point.
Is this desirable?
Yes, it is.
Since we are assuming that the normal vector at our intersection point is always aligned with the z-axis of our used reflection coordinate system.
At some point in time this has to be transformed back from the reflection coordinate system to the world space coordinate system and this is where the world space normal is considered - but this happens not within the BSDF interface.
Once more: The BSDF class only considers the reflection coordinate system.
For the pure consideration of only diffuse reflections this is not needed.
There might be reasons to consider also
\(w_o\)
, such as hitting the backside of a diffuse surface but this will be ignored for now.
To evaluate the BRDF for given directions
\(\omega_i\)
and
\(\omega_o\)
we introduce a new method named evaluate
.
Again all vectors are assumed to be in reflection space and the shown interface has only the requirement to support diffuse BRDFs.
class BSDF {
public:
[[nodiscard]]
virtual Vector3f sample(const Point2f& sample_point) const = 0;
[[nodiscard]]
virtual float pdf(const Vector3f& wi) const = 0;
[[nodiscard]]
virtual Color3f evaluate(const Vector3f& wi, const Vector3f& wo) const = 0;
};
The corresponding UML class diagram:
classDiagram BSDF: +Vector3f sample(Point2f sample_point) BSDF: +float pdf(Vector3f wi) BSDF: +Color3f evaluate(Vector3f wi, Vector3f wo)
Now we have the interface I leave it as an exercise for you to implement a derived Diffuse
class based on this interface.
Here are some test cases for the Diffuse BRDF:
TEST(Diffuse, ctor) {
auto color = Color3f{1.f, 1.f, 1.f};
Diffuse diffuse{color};
EXPECT_THAT(diffuse.reflectance(), color);
}
TEST(Diffuse, GivenInvalidReflectanceWhenCtorThenThrowException) {
auto color = Color3f{2.f, 1.f, 1.f};
EXPECT_THROW((Diffuse{color}), std::runtime_error);
}
TEST(Diffuse, GivenWiAndWoEqualToZVectorWhenEveluationExpectOneOverPi) {
Diffuse diffuse{Color3f{1.f, 1.f, 1.f}};
Vector3f wi{0.f, 0.f, 1.f};
Vector3f wo{0.f, 0.f, 1.f};
auto value = diffuse.evaluate(wi, wo);
auto expected_color = Color3f{1.f} * (1.f / pi_v<float>);
EXPECT_THAT(value, expected_color);
}
TEST(Diffuse, GivenWiAndWoOnOppositeHemispheresWhenEvaluationExpectZero) {
Diffuse diffuse{Color3f{1.f, 1.f, 1.f}};
Vector3f wi{0.f, 0.f, 1.f};
Vector3f wo{0.f, 0.f, -1.f};
auto value = diffuse.evaluate(wi, wo);
auto expected_color = Color3f{0.f};
EXPECT_THAT(value, expected_color);
}
TEST(Diffuse, GivenWiAndWoOnNegativeHemispheresWhenEvaluationExpectZero) {
Diffuse diffuse{Color3f{1.f, 1.f, 1.f}};
Vector3f wi{0.f, 0.f, -1.f};
Vector3f wo{0.f, 0.f, -1.f};
auto value = diffuse.evaluate(wi, wo);
auto expected_color = Color3f{0.f};
EXPECT_THAT(value, expected_color);
}
Depending on the rest of your ray tracer there might be situations where wo want to handle situations where \(w_i\) and \(w_o\) lay on opposite sides in a different way. In this case, feel free to implement your interfaces/classes in a slightly different way.
The member function sample
takes a 2D sample point.
If you plot the frequency of each 2D sample on a 2D diagram you should get something like this:
I show you here also my implementation of the diffuse BSDF class:
class Diffuse : public BSDF {
public:
explicit Diffuse(const Color3f& reflectance) : reflectance_(reflectance) {
check_reflectance_value();
}
[[nodiscard]]
Vector3f sample(/*const Vector3f& wo,*/ const Point2f &sample_point) const override {
Vector3f wi = square_to_cosine_hemisphere(sample_point);
return wi;
}
[[nodiscard]]
float pdf(const Vector3f &wi) const override {
if(cos_theta(wi) <= 0 || cos_theta(wi) <= 0) {
return 0.f;
}
return pi_v<float> * cos_theta(wi);
}
[[nodiscard]]
Color3f evaluate(const Vector3f &wi, const Vector3f &wo) const override {
//if(wi.dot(wo) < 0)
// return Color3f{0.f};
if(cos_theta(wo) <= 0 || cos_theta(wi) <= 0) {
return Color3f{0.f};
}
return reflectance_ * (1.f / pi_v<float>);
}
const Color3f &reflectance() const {
return reflectance_;
}
private:
void check_reflectance_value() const {
for(int i = 0; i < 3; ++i) {
if(reflectance_[i] < 0 || reflectance_[i] > 1) {
throw std::runtime_error("Invalid reflectance value - must be in the range [0,1]");
}
}
}
private:
Color3f reflectance_;
};
Now we assume that you have implemented Diffuse
BSDF class.
How do we integrate it into our renderer?
In my toy ray tracer, it looks like this:
Color trace(
const Scene *scene,
Sampler* sampler,
Ray &ray,
const int depth,
Scalar *aovs = nullptr) const override {
MediumEvent me;
if (!scene->intersect(ray, me)) {
return Color{0};
}
LOG_ASSERT(me.shape, "If there was a hit a valid shape is expected");
LOG_ASSERT(me.shape->bsdf(), "Every shape should have a BSDF");
// take care of emittance
Color emitted_radiance{0};
if(me.shape->is_emitter()) {
emitted_radiance += me.shape->emitter()->evaluate();
}
// recursion limit
if(depth >= max_depth_) return emitted_radiance;
auto bsdf = me.shape->bsdf();
auto sample_position = sampler->next_2d();
Vector wo = me.sh_frame.to_local(-ray.direction);
Vector wi = bsdf->sample(sample_position);
assert(wi.norm() < Scalar{1.1} && wi.norm() > Scalar{.9});
Scalar pdf = bsdf->pdf(wi);
Color brdf_value = bsdf->evaluate(wi, wo);
const Scalar EPSILON = Scalar{0.0001};
Ray rn(me.p, me.sh_frame.to_world(wi), EPSILON, std::numeric_limits<Scalar>::infinity());
Color indirect = trace(scene, sampler, rn, depth + 1);
return emitted_radiance + indirect * brdf_value * cos_theta(wi) / pdf;
}
The code is similar to the pseudocode shown before. Additionally, you will find some conversion from world space to reflection space and back again.
In this section, we gained the following insights:
- A proper interface for a BSDF that supports diffuse materials can consist of
sample
,pdf
, andevaluate
functions - The BSDF interface works only in a reflection coordinate system and is independent of the world space coordinate system
A Perfect Specular BSDF class #
In the following image you can see two spheres with a perfect specular reflection:
A perfect specular reflection (mirror reflection) has the property that light with some incident angle \(\alpha\) is reflected around the normal with exactly the same angle. The angle of incidence is equal to the angle of reflection:
The BRDF of a perfect mirror reflection looks like this, where \(\delta(x)\) denotes the Dirac Delta function:
\(f_r(x, \omega_i, \omega_o) = \left\{ \begin{array}{ll} 0 & \omega_o \neq \omega_i \\ \delta(x) & \, \omega_o = \omega_i \\ \end{array} \right. \)Now lets consider how we can implement a BSDF class for perfect specular reflections (i.e. a perfect mirror material). Given the previously described interface, we will get into some problems:
- If we consider our BSDF interface again,
we can see that the job of the
sample
method is to generate a sample \(\omega_i\) given two uniform random numbers within the domain \([0,1(\) . The probability that we hit exactly the mirror direction is almost 0. Therefore, a render that uses our BRDF interface will have a hard time rendering mirror reflections. - The
pdf
will be always zero except if the sample is exactly in the mirror direction. In this case the probability will by \(\infty\) . This value will bring more problems when it comes to the division by this value in ourtrace
function.
To work around those issues one option is to change BSDF interface.
In the code for the diffuse BSDF interface, the trace
function looked like this:
We now modify it this way:
Color value = bsdf->sample(bsdf_sample, sample_position);
...
Color indirect = trace(scene, sampler, rn, depth + 1);
return emitted_radiance + indirect * value;
The red ellipse is replaced by the magic variable value
.
The variable value
is determined by the function sample
.
That means that the determination of the BRDF value,
generating
\(\omega_i\)
, and computing the pdf
is all done by the sample function now.
So to say the sample
function now generates only a multiplier for
\(L_i\)
.
So we change the sample
method:
We do not anymore return the sampled direction, but a multiplier for
\(L_i\)
,
i.e. we change:
Vector3f sample(const Point2f& sample_point) const = 0;
to:
Color3f sample(const Point2f& sample_point) const = 0;
Some sample methods will need access to
\(w_i\)
or other things - to not get crazy about modifying always the interface we introduce a common struct BSDFSample
that is handed over to each method in the BSDF interface:
strct BSDFSample {
Vector3f wi;
Vector3f wo;
}
class BSDF {
[[nodiscard]] virtual Color3f sample(BSDFSample& sample, const Point2f& sample_point) const = 0;
[[nodiscard]] virtual Scalar pdf(const BSDFSample& sample) const = 0;
[[nodiscard]] virtual Color3f evaluate(const BSDFSample& sample) const = 0;
};
This new interface allows us to simplify the computation for perfect diffuse reflections as shown in Naive diffuse path tracing.