Getting Started with Rayrender: Forging the R Sword

In mid-February, I found myself staring at the computer screen with a weekend free and a choice to make. After three months working on rayshader to put together and practice a presentation for RStudio::conf(2019), build the package a website, and bring life to an interactive rayshading Twitterbot--I found myself feeling the smallest tinge of “burn out”. Not enough to say ik ben op, as the Dutch so poetically put it–but I had been working on the same project for a while and needed something new and exciting in my life 1. I wanted to take a little break and tackle a new problem--one that might not provide me with any immediate benefit, but might help rayshader in the long run.

So I decided to build a raytracer.

Figure 1: When you raytrace your own universe, ALL the hits can be corner hits.

Although “rayshader” has “ray” in the name, it only uses “ray tracing” in the most basic sense: it traces rays from points on an elevation matrix to bake2 a shadow map onto the surface of an object. The simplicity of the method means implementing techniques like ambient occlusion is trivial, and baking the shadow map into the texture is great for fast, interactive plotting–but you would never confuse the output of rayshader for a pathtracer 3. So I had been meaning to dive deeper into real raytracing in order to one day bring a higher quality renderer to rayshader.

And luckily for me, there’s never been a better time to learn about raytracing!

Last year, two resources were released free-of-charge that made learning about raytracing immensely easier: Matt Pharr’s Physically Based Rendering: From Theory To Implementation textbook, and Peter Shirley’s Ray Tracing in One Weekend three book series. The former was great as a reference, but a bit unwieldy as a way to introduce yourself to the subject. Peter Shirley’s books, however, hit the pedagogical sweet spot (in my opinion): easily understandable and concise code, plain English explanations of what the code is doing, and a built-in system of student feedback by immediately diving into producing cool output. But more importantly, I could see right away how I could use what he taught to build a raytracer in R.


And luckily for me, there’s never been a better time to learn about raytracing!


Enter: rayrender.

Rayrender is an R package that uses raytracing to render scenes consisting of spheres, cubes, and 2D planes. The “scene” is just a tibble where each row contains all the information required to draw on object, and the collection of such objects is passed to the render_scene() function to draw an image (or save an image to file). Similarly to how you build a map in rayshader, you build the scene in layers and compose them all together with the add_object() function. Here, we generate a large sphere as the “ground” and then place a grey sphere on top of it.

#Install the package if you haven't already
#remotes::install_github("tylermorganwall/rayrender")
library(rayrender)

scene = sphere(y=-1001,radius=1000,material = lambertian(color = "#ccff00")) %>%
  add_object(sphere(material=lambertian(color="grey50")))
render_scene(scene)
Figure 2: A single sphere (well, technically, two spheres).

Not very exciting? Okay, let’s replace the ground with something a little more interesting. How about a checker pattern? And let’s make the ball a light reddish color. The API is designed so that each object accepts a material argument, to which you pass the output of one of the material functions: lambertian, metal, or dielectric.

scene = sphere(y=-1001,radius=1000,
               material = lambertian(color = "#ccff00",checkercolor="grey50")) %>%
  add_object(sphere(material=lambertian(color="#dd4444")))
render_scene(scene, width=500, height=500, samples=500)
Figure 3: Checkers as far as the eye can see.

You might be thinking: "Wait. This scene doesn't look like anything special. 🤔Aren’t raytracers supposed to have floating metallic spheres and glass balls everywhere?"


scene = sphere(y=-1001,radius=1000,
               material = lambertian(color = "#ccff00",
                                     checkercolor="grey50")) %>%
  add_object(sphere(material=lambertian(color="#dd4444"))) %>%
  add_object(sphere(z=-2,material=metal())) %>%
  add_object(sphere(z=2,material=dielectric()))
render_scene(scene,width=500, height=500, samples=500, 
             fov=40,lookfrom=c(12,4,0))
Figure 4: Glass and metal balls: The raytracing "Hello World"

You need not worry. Glass to the left, and metal to the right. Here, I changed the sphere from the default diffuse material to glass and metal by specifying a different material argument in the object function.


🤔 "Aren’t raytracers supposed to have floating metallic spheres and glass balls everywhere?"


Now, one of the coolest thing about raytracers is how easy it is to add realistic lighting to the scene–and we can do that simply by setting the lightintensity argument in a lambertian material to a positive number. The greater the intensity, the brighter the light. Adding an emissive object turns off the ambient lighting, although the user can override this by setting ambient_light = TRUE in render_scene(). Here, the scene is plotted twice: once far away to show the overhead position of the light, and once up close to show its effect on the spheres.

scene = sphere(y=-1001,radius=1000,
               material = lambertian(color = "#ccff00",
                                     checkercolor="grey50")) %>%
  add_object(sphere(material=lambertian(color="#dd4444"))) %>%
  add_object(sphere(z=-2,material=metal())) %>%
  add_object(sphere(z=2,material=dielectric())) %>%
  add_object(sphere(x=-20,y=30,radius=20,material=lambertian(lightintensity = 3)))

par(mfrow=c(1,2))

render_scene(scene,fov=40, width=500, height=500, samples=500,
             lookfrom=c(50,10,0),parallel=TRUE)
render_scene(scene,fov=30, width=500, height=500, samples=500,
             lookfrom=c(12,4,0),parallel=TRUE)
Figure 5: The light is also implicitly sampled, which improves noise convergence (most of the time).

rayrender also supports motion blur for spheres. Let’s give each sphere a velocity (each sphere here is traveling in a different direction):

scene = sphere(y=-1001,radius=1000,
               material = lambertian(color = "#ccff00",
                                     checkercolor="grey50")) %>%
  add_object(sphere(material=lambertian(color="#dd4444"),
                    velocity=c(-2,0,0))) %>%
  add_object(sphere(z=-2,material=metal(),
                    velocity=c(0,1,0))) %>%
  add_object(sphere(z=2,material=dielectric(),
                    velocity=c(0,0,0.5))) %>%
  add_object(sphere(x=-20,y=30,radius=20,
                    material=lambertian(lightintensity = 3)))

par(mfrow=c(1,1))

render_scene(scene,fov=40, width=500, height=500, samples=500,
             lookfrom=c(12,4,0),parallel=TRUE)
Figure 6: Motion blur improves the perception of continuous motion at lower frame rates when objects are moving quickly. Check it out here: https://twitter.com/tylermorganwall/status/1110880693897302016

Okay, spheres are nice–how about cubes? Let’s turn the glass object green and lift all the objects off the ground to highlight the light transmission.

scene = sphere(y=-1001,radius=1000,
               material = lambertian(color = "#ccff00",
                                     checkercolor="grey50")) %>%
  add_object(cube(width=2,y=0.1,
                  material=lambertian(color="#dd4444"))) %>%
  add_object(cube(z=-2.2,y=0.1,width=2,
                  material=metal())) %>%
  add_object(cube(z=2.2,y=0.1,width=2,
                  material=dielectric(color="green"))) %>%
  add_object(sphere(x=-20,y=30,radius=20,
                  material=lambertian(lightintensity = 5)))

render_scene(scene,fov=40, width=500, height=500, samples=500,
             parallel=TRUE,lookfrom=c(12,4,0))
Figure 7: Cubes.

Now let’s build something more substantial: how about a pyramid, with a golden block on top? We’ll also change the ground to be a procedurally generated dirt pattern, using the generate_ground() function (which just wraps the sphere function in a nice interface). We’ll view the pyramid from all four cardinal angles and create a grid of images.


scene = generate_ground(depth=-0.5,spheresize=1000, 
                        material=lambertian(color="#000000",noise=1/10,
                                            noisecolor = "#654321")) %>%
  add_object(sphere(x=-20,y=30,radius=20,
                    material=lambertian(lightintensity = 3)))
firstlayer = c(-1.5,-0.5,0.5,1.5)
secondlayer = c(-1,0,1)
thirdlayer = c(-0.5,0.5)

firstlayerdf = expand.grid(x=firstlayer,z=firstlayer,y=0)
secondlayerdf = expand.grid(x=secondlayer,z=secondlayer,y=1)
thirdlayerdf = expand.grid(x=thirdlayer,z=thirdlayer,y=2)

pyramid_positions = rbind(firstlayerdf,secondlayerdf,thirdlayerdf)

for(i in 1:nrow(pyramid_positions)) {
  scene = add_object(scene,cube(x=pyramid_positions$x[i],
                                y=pyramid_positions$y[i],
                                z=pyramid_positions$z[i],
                                material = lambertian(color="tan")))
}

scene = scene %>% add_object(cube(y=3,material = metal(color="gold",fuzz=0.2)))

par(mfrow=c(2,2))
render_scene(scene,fov=40, width=500, height=500, samples=500, 
             parallel=TRUE, lookfrom=c(-12,6,0),lookat = c(0,1,0))
render_scene(scene,fov=40, width=500, height=500, samples=500, 
             parallel=TRUE, lookfrom=c(0,6,12),lookat = c(0,1,0))
render_scene(scene,fov=40, width=500, height=500, samples=500, 
             parallel=TRUE, lookfrom=c(12,6,0),lookat = c(0,1,0))
render_scene(scene,fov=40, width=500, height=500, samples=500, 
             parallel=TRUE, lookfrom=c(0,6,-12),lookat = c(0,1,0))
Figure 8: If the ancient Egyptians gave up after a few hours work.

Finally, now that we know how to compose a scene: Let’s forge the R sword, modeled after Link's wooden sword in the original NES Zelda. We’ll start by creating a matrix that uses a number to represent each separate material/color on the sword. There will be three different materials: a bronze metal for the blade, a green lambertian material for the guard and hilt, and a yellow lambertian for the stripes on the hilt. We will then loop through the matrix and add the appropriate material to the scene when there is a non-zero entry in the matrix.


scene = generate_ground(depth=0,spheresize=1000, 
                        material=lambertian(color="#000000",
                                            noise=1/10,
                                            noisecolor = "#654321")) %>%
  add_object(sphere(x=-60,y=55,radius=40,
                    material=lambertian(lightintensity = 8)))

sword = matrix(
c(0,0,0,1,0,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  0,0,1,1,1,0,0,
  2,2,2,2,2,2,2,
  2,0,3,3,3,0,2,
  0,0,2,2,2,0,0,
  0,0,3,3,3,0,0,
  0,0,2,2,2,0,0),
ncol = 7,byrow=TRUE)

metalcolor = "#be2e1b"
hilt1 = "#7bc043"
hilt2 = "#f68f1e"

for(i in 1:ncol(sword)) {
  for(j in 1:nrow(sword)) {
    if(sword[j,i] != 0) {
      if(sword[j,i] == 1) {
        colorval = metalcolor
        material = metal(color=colorval,fuzz=0.1)
      } else if (sword[j,i] == 2) {
        colorval = hilt1
        material = lambertian(color=colorval)
      } else {
        colorval = hilt2
        material = lambertian(color=colorval)
      }
      scene = add_object(scene,cube(y=16-j,z=i-4, material=material))
    }
  }
}

par(mfrow=c(1,1))
render_scene(scene,fov=30, width=500, height=500, samples=500,
             parallel=TRUE, lookfrom=c(-25,25,0), lookat = c(0,9,0))
Figure 9: A sword created with voxels.

Now we add the R to the blade, using the same process but offseting it slightly towards the front of the blade.

scene2 = scene
rlogo = matrix(
c(1,1,1,0,
  1,0,0,1,
  1,1,1,0,
  1,0,1,0,
  1,0,0,1),
ncol = 4,byrow=TRUE)

material = metal(color="#be8d1b")

for(i in 1:ncol(rlogo)) {
  for(j in 1:nrow(rlogo)) {
    if(rlogo[j,i] != 0) {
      scene2 = add_object(scene2,cube(x=-0.4,y=8-j/2, z=-1.25+i/2,width=0.5, material=material))
    }
  }
}

render_scene(scene2,fov=30,width=500, height=500, samples=500,
             parallel=TRUE,lookfrom=c(-25,25,0),lookat = c(0,9,0))
Figure 10: Now show me a Python trident and we can make the next R vs Python debate really interesting.

Finally, we rotate the camera around the sword and then turn all the images into a movie. To do this, we just specify the lookfrom coordinate to move in a circle centered around the lookat point. The odd subsetting I have included with the frames variable ensures you have a perfect loop, for extra Twitter points:

frames = 360

camerax=-25*cos(seq(0,360,length.out = frames+1)[-frames-1]*pi/180)
cameraz=25*sin(seq(0,360,length.out = frames+1)[-frames-1]*pi/180)

for(i in 1:frames) {
  render_scene(scene2, width=500, height=500, fov=35,
               lookfrom = c(camerax[i],25,cameraz[i]),
               lookat = c(0,9,0), samples = 1000, parallel = TRUE,
               filename=glue::glue("swordtest{i}"))
}

av::av_encode_video(glue::glue("swordtest{1:(frames-1)}.png"), framerate=60, output = "rswordfast.mp4")
file.remove(glue::glue("swordtestfast{1:(frames-1)}.png"))

Alternatively, you could just capture the plots directly using the av package and avoid saving any images to disk. I prefer to write the images to disk because you can then interrupt the process and not lose your progress, but for short animations this can be convenient:

av::av_capture_graphics(expr = {
  for(i in 1:frames) {
    render_scene(scene2, width=500, height=500, fov=35,
                 lookfrom = c(camerax[i], 25, cameraz[i]),
                 lookat = c(0,9,0),samples = 1000, parallel = TRUE)
  }
}, width=500,height=500, framerate = 60, output = "rsword2.mp4")
Figure 11: It's not safe to analyze data alone. Take this.

It's that easy! I hope you enjoyed this short introduction to the (very nascent) rayrender package. Support for more complex objects, materials, and rendering options are in the works. I'm going to push out a series of tutorials on more advanced topics as time goes on (implicit sampling, grouping of objects, animating). Be sure to sign up for my email list to learn more! You can also see the code and see more examples at the Github page below, as well as the package website:

X
Love maps and data? Subscribe to my newsletter to learn more!

rayrender on Github

Or just check out the examples and the documentation on the package’s website:

www.rayrender.net

If you liked this post, be sure to sign up for my newsletter so you don’t miss future developments!

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *