Calcolo numerico per la generazione di immagini fotorealistiche
Maurizio Tomasi maurizio.tomasi@unimi.it
Today we will finally implement our path tracer, which concludes the discussion on the rendering equation
Even though there are still two weeks until the end of the course, we can already evaluate what we should have learned from the course
Let’s do it with a question…
We have always implemented one feature at a time (saving images, tone mapping, projections…)
For each feature, we implemented a series of tests
This is why every week we had the confidence that when we relied on code written in previous weeks, it was probably “correct”
It is impossible to write code as long as the one you produced so far without proceeding this way
This is the most important lesson provided by this course!
It is convenient to use the algorithm from Duff et al. 2017:
def create_onb_from_z(normal: Union[Vec, Normal]) -> Tuple[Vec, Vec, Vec]:
# In Python there isn't a `copysign` function 🙁
sign = 1.0 if (normal.z > 0.0) else -1.0
a = -1.0 / (sign + normal.z)
b = normal.x * normal.y * a
e1 = Vec(1.0 + sign * normal.x * normal.x * a, sign * b, -sign * normal.x)
e2 = Vec(b, sign + normal.y * normal.y * a, -normal.y)
return e1, e2, Vec(normal.x, normal.y, normal.z)
When calling this function, pay close attention to the fact that
the normal
parameter must already be normalized!
To verify the correct operation of
create_onb_from_z
, we can use random testing (an
idea apparently originating in the world of the Haskell language, see quickcheck).
The idea is to generate a large number of random vectors, pass
them as parameters to create_onb_from_z
, and verify that
the return type is indeed an ONB. (Apparently Duff et al. discovered the
bug in the algorithm from Frisvad,
2012 this way).
The general approach of random testing is to generate random inputs and verify that the outputs satisfy certain properties. It is especially convenient for mathematical functions, but it is not always the best solution.
pcg = PCG() # Use the default seed, so if the test fails you can debug it
expected_zero, expected_one = pytest.approx(0.0), pytest.approx(1.0)
# As Python is slow, we just test 100 times the function. You can use
# larger numbers, as far as the time required to run the test is kept short
for i in range(100):
# We could use a fancier code that samples uniformly over the 4π sphere…
normal = Vec(*[pcg.random_float() for i in range(3)]).normalize()
e1, e2, e3 = create_onb_from_z(normal)
# Verify that the z axis is aligned with the normal
assert e3.is_close(normal)
# Verify that the base is orthogonal
assert expected_zero == e1.dot(e2)
assert expected_zero == e2.dot(e3)
assert expected_zero == e3.dot(e1)
# Verify that each component is normalized
assert expected_one == e1.squared_norm()
assert expected_one == e2.squared_norm()
assert expected_one == e3.squared_norm()
# You could also check that e₁×e₂=e₃, e₂×e₃=e₁, e₃×e₁=e₂
We anticipated that our path tracer will use the Monte Carlo method to estimate the integral of the rendering equation:
\int_{2\pi} f_r(x, \Psi \rightarrow \Theta)\,L(x \leftarrow \Psi)\,\cos\theta\,\mathrm{d}\omega_\Psi.
To improve variance we will use importance sampling, employing the PDF
p(\omega) \propto f_r \cdot \cos\theta.
BRDF
TypeWe therefore need to add a method that applies to the types
derived from BRDF
and that has this
signature:
Each type derived from BRDF
(e.g.,
DiffuseBRDF
, SpecularBRDF
, etc.) will have to
redefine scatter_ray
.
DiffuseBRDF
For the diffuse BRDF, we deduced that p(\omega) \propto \cos\theta.
The implementation of DiffuseBRDF.scatter_ray
must therefore use the algorithm we
derived for the Phong distribution with n
= 1:
def scatter_ray(self, pcg: PCG, incoming_dir: Vec,
interaction_point: Point, normal: Normal, depth: int) -> Ray:
e1, e2, e3 = create_onb_from_z(normal)
cos_theta_sq = pcg.random_float()
cos_theta, sin_theta = sqrt(cos_theta_sq), sqrt(1.0 - cos_theta_sq)
phi = 2.0 * pi * pcg.random_float()
return Ray(origin=interaction_point,
dir=e1 * cos(phi) * cos_theta + e2 * sin(phi) * cos_theta + e3 * sin_theta,
tmin=1.0e-3, # Be generous here
tmax=inf,
depth=depth)
SpecularBRDF
Ray generation for the specular BRDF is perfectly deterministic, and there is no \cos\theta term to weight the ray’s contribution.
The implementation of SpecularBRDF.scatter_ray
is therefore particularly simple:
def scatter_ray(self, pcg: PCG, incoming_dir: Vec,
interaction_point: Point, normal: Normal, depth: int) -> Ray:
ray_dir = Vec(incoming_dir.x, incoming_dir.y, incoming_dir.z).normalize()
normal = normal.to_vec().normalize()
return Ray(origin=interaction_point,
dir=ray_dir - normal * 2 * normal.dot(ray_dir),
tmin=1e-3,
tmax=inf,
depth=depth)
PathTracer
TypeWe have defined two renderers so far: OnOffRenderer
and FlatRenderer
. Today we implement
PathTracer
.
The constructor parameters are as follows:
World
object to render;PCG
type);def __call__(self, ray: Ray) -> Color:
return Color(0.0, 0.0, 0.0) if ray.depth > self.max_depth
hit_record = self.world.ray_intersection(ray)
return self.background_color if not hit_record
hit_material = hit_record.material
hit_color = hit_material.brdf.pigment.get_color(hit_record.surface_point)
emitted_radiance = hit_material.emitted_radiance.get_color(hit_record.surface_point)
hit_color_lum = max(hit_color.r, hit_color.g, hit_color.b)
if ray.depth >= self.russian_roulette_limit: # Russian roulette
q = max(0.05, 1 - hit_color_lum)
if self.pcg.random_float() > q:
# Keep the recursion going, but compensate for other potentially discarded rays
hit_color *= 1.0 / (1.0 - q)
else:
# Terminate prematurely
return emitted_radiance
# Monte Carlo integration
cum_radiance = Color(0.0, 0.0, 0.0)
# Only do costly recursions if it's worth it
if hit_color_lum > 0.0:
for ray_index in range(self.num_of_rays):
new_ray = hit_material.brdf.scatter_ray(
pcg=self.pcg,
incoming_dir=hit_record.ray.dir,
interaction_point=hit_record.world_point,
normal=hit_record.normal,
depth=ray.depth + 1,
)
new_radiance = self(new_ray) # Recursive call
cum_radiance += hit_color * new_radiance
return emitted_radiance + cum_radiance * (1.0 / self.num_of_rays)
A simple method to verify the functioning of a path tracer is the so-called furnace test.
It consists of casting a ray inside an arbitrarily shaped object with diffuse BRDF and constant luminosity L_e and reflectance \rho_d.
The generic ray will follow a path trapped within the object, and thus each ray will keep hitting the same sphere over and over again. This is a situation where the rendering equation can be solved analytically.
\begin{aligned} L &= L_e + \rho_d \Bigl(L_e + \rho_d \bigl(L_e + \rho_d(L_e + \dots)\bigr)\Bigr) =\\ &= L_e + \rho_d L_e + \rho_d^2 L_e + \rho_d^3 L_e + \ldots =\\ &= L_e \bigl(1 + \rho_d + \rho_d^2 + \rho_d^3 + \ldots \bigr) =\\ &= L_e \sum_{n=0}^\infty \rho_d^n =\\ &= \frac{L_e}{1 - \rho_d}. \end{aligned}
The pytracer code implements the test testFurnace
which uses these assumptions:
PathTracer
setting max_depth=100
and ensuring that the Russian roulette algorithm is not
used;The test is repeated a number of times using random values of L_e and \rho_d (avoiding choosing \rho_d \approx 1!).
pcg = PCG()
# Run the furnace test several times using random values for L_e and ρ_d
for i in range(5):
emitted_radiance = pcg.random_float()
reflectance = pcg.random_float() * 0.9 # Avoid numbers that are too close to 1
world = World()
enclosure_material = Material(
brdf=DiffuseBRDF(pigment=UniformPigment(Color(1.0, 1.0, 1.0) * reflectance)),
emitted_radiance=UniformPigment(Color(1.0, 1.0, 1.0) * emitted_radiance),
)
world.add(Sphere(material=enclosure_material))
path_tracer = PathTracer(pcg=pcg, num_of_rays=1, world=world, max_depth=100, russian_roulette_limit=101)
ray = Ray(origin=Point(0, 0, 0), dir=Vec(1, 0, 0))
color = path_tracer(ray)
expected = emitted_radiance / (1.0 - reflectance)
assert pytest.approx(expected, 1e-3) == color.r
assert pytest.approx(expected, 1e-3) == color.g
assert pytest.approx(expected, 1e-3) == color.b
main
MethodNow that we have a path tracer, it’s good to modify our
demo
command so that it creates an image that better
highlights how the algorithm works.
You can define any scene you like, using the shapes you have implemented in your code (spheres, planes, triangles, CSG, etc.).
The important thing is that you define a large diffuse surface (a plane or a large radius sphere), which acts as a light source and therefore has a non-negligible L_e component.
In pytracer I defined a plane for the ground, a sphere for the sky, and two spheres in the foreground: the first has a diffuse BRDF, the second a reflective BRDF.
Make sure that the two numbers that initialize the
PCG
generator can be passed from the command line.
The most important number is init_seq
, because it
uniquely identifies the sequence of numbers generated by the
PCG
, and therefore allows the production of completely
decorrelated images (which can then be averaged together without
producing bias).
Averaging different images (produced concurrently using GNU Parallel) is a good way to reduce variance without increasing the actual computation time (wall-clock time).
From today, your code will have to do a lot of calculations!
Compare the code speeds of different groups in generating similar images, to understand if there are bottlenecks: with the languages you use, in theory there shouldn’t be significant differences.
If you notice differences, you need to measure which part of the code is slowing it down: this is what is called profiling.
It is not enough to know how long your entire code takes to produce an image: you need to know how much time it spends in each function.
There are various ways to measure it, and many tools available: it’s impossible to be exhaustive!
I’ll list some possibilities, and offer some tips and tricks.
The simplest method is to surround the functions «suspected» of being bottlenecks with time measurements:
There are profilers that measure the cumulative time spent by each line of code: be careful, because they can slow down the code a lot!
Statistical profilers are also available, which are less accurate but do not significantly slow down the code.
Modern compilers are so smart that they can make benchmark results worthless.
For instance, the following code will likely report that the cross product of two vectors takes no time to run:
Never test mathematical functions on variables set to zero, as compilers usually optimize these calls out
If you initialize your variables to random numbers, be sure not to include random number generator in the profiling! Good practice: initialize a vector of random numbers before the test:
Other stuff might happen while your benchmark code is running:
It might be that you are measuring the time spent to run your program and to do something else.
It’s a good idea to run a benchmark a few times (3? 5?) and then consider the minimum time alongside other statistical parameters (average, median, etc.).
Many benchmark libraries provide detailed statistics and consider JIT warm-up times and other nuisances: Google Benchmark (C++), BenchmarkTools.jl (Julia), BenchmarkDotNet (C#, F#), etc.
Don’t measure the time of every function, just focus on the ones that could be problematic. (For example, it doesn’t matter if the function that interprets the command line is fast!)
Often the output of profilers is quite unreadable and too detailed: however, there are tools to extract summary graphs such as flamegraphs, which are easier to read.
The speedscope site provides a way to produce navigable flamegraphs through a browser, starting from the output of various profilers, and does not require installation.
pathtracing
branch;ONB
type);scatter_ray
method that works on BRDF
types, and implement the specular BRDF;0.3.0
and
update the CHANGELOG.md
file.