Throwing Shade at the Cartographer Illuminati: Raytracing the Washington Monument in R

Throwing Shade at the Cartographer Illuminati: Raytracing the Washington Monument in R
When reading older scientific literature, I occasionally run across a statement that never fails to capture my interest. “This method is computationally expensive and should be avoided.” -A statement made around the period Chambawamba captured the zeitgeist of the 90s. These statements are of a particular interest to me in fields where computation is simply a means to an end and not the main focus of researchers--this means that potentially, the issue hasn't been revisited since it was originally declared algorithm non grata. The last couple decades have delivered us some incredible improvements in CPU power1. Algorithms that may once been seen as unworkably greedy may--especially with the help of multiple cores and GPUS--be surprisingly workable. So begins this blog post.

“This method is computationally expensive and should be avoided.” - A statement made around the period Chambawamba captured the zeitgeist of the 90s.


I was reading Twitter when I found a blog post (“How to make a shaded relief in R”, by Elio Campitelli). The post describes how to create the type of shaded relief maps used in cartography programatically in R. Before I read the full article, I read the title and thought: “Cool project! Raytracing topographic maps to simulate light falling on mountains and shade the map appropriately.” After clicking the link and reading about the method used, I was less enthused; the method used was not at all what I thought it would be.


Figure 1: Comparing a derivative-based shading model using Elio's code (left) to a custom raytracing-based shading model (right), using NOAA data of the Andes. Note that in the derivative-based model, the flat areas east of the mountains actually look like large mountains. The raytrace-based model accurately captures the flat areas by the coast and east of the mountains, while preserving the mountainous terrain.

It's not that I didn't find what he did interesting--I just had one of those moments where you think you know how something is going to be done and then are sorely disappointed when, well, it wasn't done that way. His cartographer-tested-and-approved method is calculating the local slope at each point (by taking the gradient of the data) and–depending on the direction of the slope compared to the light direction–shading the surface 2. Slopes descending towards the light source will be brighter, and slopes descending away from the light source will be darker. This method is much more computationally efficient than raytracing, but it’s disconnected from the underlying physics of what makes real shadows occur: rays of light not hitting a surface due to another surface in the way.

This is one difference between a local and a global illumination model–a local model just looks at the surface to determine shading intensity, while a global model takes into account the surrounding landscape as well. There’s a great source on all of this that Elio links to, reliefshading.com, that says the following about global illumination:

“Global models, such as ray tracing or radiosity are not well suited for cartographic relief shading, since these models are very computation intensive. ” - reliefshading.com, throwing shade.

And not a word more is said on the subject. All of the cited resources from this website all focus on these various local illumination models as well, and it seems the field of cartography decided sometime in the mid-90s that raytracing just wasn’t appropriate for shading topographic maps. And mostly because it was computationally intensive3.


Everything was computationally intensive in the 90s.


Everything was computationally intensive in the 90s. Mashing your computer’s turbo button was an effective placebo, but it was no way to overcome real algorithmic challenges. Since the cool project I expected to see didn’t actually exist–and was being actively suppressed by the most-certainly-real cartographer illuminati–I had to do it myself.

There’s a nice little dataset that comes with R that we’ll be using before moving to actual USGS geographic data: the volcano dataset. It’s a 87x61 heightmap of Maunga Whau Volcano in NZ. Here’s an image of the actual location (thanks Apple maps!), and a contour/heightmap of the data:

Maunga Whau Volcano (better known as: > volcano|)
Figure 2: Here, we have three methods for trying to convey 3D space on a 2D plane: animation, contours, and color.

And here’s a gif of the aforementioned cartographer-approved-but-not-what-I-imagined method, with the light source rotating around the image. We rotate the light source around the volcano so you can get a better understanding of how this method shades the slope. It does a pretty good job of representing the topography of the area, and in general, it's obvious that we're dealing with some sort of volcano here.

Figure 3: volcano dataset shaded with a derivative-based method.

However, we see one of the pitfalls of this method: the otherworldy fluctuating flat areas in the top right and middle of the image. This is a result of using the derivative to shade the points; when you have a large flat area the color is determined by the boundary (which dictates the derivative), and there will be no variation across the surface. Areas that otherwise look like they should be in darkness--such as the flat steps in the volcano above when the light is coming from the west--appear to be in bright light.

Other than the fluctuating areas, this method seems perfectly fine when trying to image topographic areas. That is, until we look at some more detailed, real data. The fluctuating effect gets even more pronounced when we look at real data with large flat areas, and we run into issues when dealing with irregular man-made objects. Here’s a view of the National Mall in Washington DC through this algorithm:


Figure 4: View of the National Mall in Washington, DC through the local shading method.

It’s like someone coated the National Mall with a thin layer of aluminum foil. The reflecting pool's flat surface creates a fluctuating portal in the center of the image that is 180 degrees out of phase with the water on the edges. Since this real data has lots of low, relatively flat areas with some surface roughness, we also see a lot of “noise” from the small fluctuations in flat areas. Worst of all, there’s no sense of scale–the fact that there’s a large, tall obelisk in the center of the hill to the right is barely noticeable compared to the slight hill it rests on. It’s not that this method isn’t reasonably effective at conveying topology–it's just not that natural looking.

For natural looking shadows, let's simulate nature. Let’s build a raytracer.


For natural looking shadows, let's simulate nature. Let’s build a raytracer.


Our data is an evenly spaced square grid of elevation data entered at each point. Our output data will be a grid of light intensities hitting each of those points. We are going to implement what are called “shadow rays”, where we cast a ray in the direction of the light source for a certain distance, and if they intercept a surface, reduce that amount of light at that point. Specifically, we will start with a grid where every point has a value of 1 (indicating full illumination), and cast N rays out at slightly different angles. If the ray intercepts a surface, we will subtract 1/N from that value. If all of the rays intersect a surface, that point on the grid will have an illumination value of 0 (indicating full darkness). By using multiple rays, we will simulate a finite sized light source, which will give us softer shadows than if we only used one ray.

Figure 5: We cast out multiple rays and shade the grid based on the number of intercepted rays. Here, we cast out 4 rays at different angles, which gives us 5 levels of illumination: Full (\(I = 1\)), 1/5 rays intercepted (\(I = 1-1/5 = 0.8\)), 2/5 rays intercepted (\(I = 1-2/5 = 0.6\)), 3/5 rays intercepted (\(I = 1-3/5 = 0.4\)), 4/5 rays intercepted (\(I = 1-4/5 = 0.2\)), or all rays intercepted (\(I = 0\)).

Now, I know there are some technical minded people who started this article thinking: "Cool project! Raytracing topographic maps to simulate light falling on mountains and shade the map appropriately... wait, that's not a real raytracer." I look forward to reading your blog post Throwing Shade at the Shade Throwers: Realistically Raytracing the Washington Monument... in Python. But for now, just bear with me--a full complexity raytracer isn't needed to do what we want to do.

The main complication of this method is our elevation data is defined only at each grid point, while we want to send out rays in more than just the four grid directions. So, we need to be able to check if we are above or below the surface for an arbitrary point on the surface, even between grid points. This immediately presents a challenge from basic geometry–we want to check if we are above or below the surface, but a plane is defined by three points, and on a grid we have four. Thus, linear interpolation doesn’t work, as most of the time there isn’t going to be a single plane that will intercept all four of the points. Thankfully, there is a technique called bilinear interpolation, which allows us to interpolate a surface between four points. We propogate our rays along the grid in the direction of the light source, and then test at each point whether it’s above or below the surface. If it’s below, we immediately stop propogating the ray and subtract one unit of brightness from the origin of the ray.


Figure 6: We cast out multiple rays and then propogate them across the surface. At each point, we calculate the height of the surface using bilinear interpolation and compare it to the height of the ray. If the ray is above the surface, we continue the process. If it's below, we stop the search and remove one unit of illumination from the origin of the ray.

Luckily, there’s an R function that does bilinear interpolation for us already, so expand the code below to see the basic R implementation.

Expand Raytracing Code
library(ggplot2) library(reshape2) library(viridis) volcanoshadow = matrix(1, ncol = ncol(volcano), nrow = nrow(volcano)) volc = list(x=1:nrow(volcano), y=1:ncol(volcano), z=volcano) sunangle = 45 / 180*pi angle = -90 / 180 * pi diffangle = 90 / 180 * pi numberangles = 25 anglebreaks = seq(angle, diffangle, length.out = numberangles) maxdistance = floor(sqrt(ncol(volcano)^2 + nrow(volcano)^2)) for (i in 1:nrow(volcano)) { for (j in 1:ncol(volcano)) { for (anglei in anglebreaks) { for (k in 1:maxdistance) { xcoord = (i + sin(sunangle)*k) ycoord = (j + cos(sunangle)*k) if(xcoord > nrow(volcano) || ycoord > ncol(volcano) || xcoord < 0 || ycoord < 0) { break } else { tanangheight = volcano[i, j] + tan(anglei) * k if (all(c(volcano[ceiling(xcoord), ceiling(ycoord)], volcano[floor(xcoord), ceiling(ycoord)], volcano[ceiling(xcoord), floor(ycoord)], volcano[floor(xcoord), floor(ycoord)]) < tanangheight)) next if (tanangheight < akima::bilinear(volc$x,volc$y,volc$z,x0=xcoord,y0=ycoord)$z) { volcanoshadow[i, j] = volcanoshadow[i, j] - 1 / length(anglebreaks) break } } } } } } ggplot() + geom_tile(data=melt(volcanoshadow), aes(x=Var1,y=Var2,fill=value)) + scale_x_continuous("X coord",expand=c(0,0)) + scale_y_continuous("Y coord",expand=c(0,0)) + scale_fill_viridis() + theme_void() + theme(legend.position = "none")

And here is the volcano dataset, shaded by this method. Note there are no odd fluctuating areas like there are with a derivative based method--it looks much more realistic.

Figure 7: volcano dataset shaded with a custom raytracing-based method. Featuring the viridis color palette.

This method works, but it’s un-usably slow for all but small datasets like volcano. I’ve written a R package, rayshader, available at a link at the bottom of the page, that implements a much faster, C++ based version of this algorithm that can quickly shade much larger datasets. It's much faster than any implementation in R, and has built-in multicore support (that can be activated simply by setting multicore=TRUE) to speed up the rendering process. Many R packages that deal with spatial data require you to convert to a special data format before you can start using them, but all rayshader needs is a base R matrix of elevation points. Feed in the matrix, tell it from what angle(s) the light is coming from, and presto--you've got a globally raytraced relief map. Below is some sample code form the rayshader package.

Expand `rayshader` Package Code
library(rayshader) library(magrittr) usgs_data = raster::raster("usgs.img") #Turn USGS data into a matrix dc_topographic_map = usgs_data %>% extract(.,raster::extent(.),buffer=1000) %>% matrix(ncol=10012,nrow=10012) #Need to add Washington Monument into the data--load heightmap of #monument and add to topographic map. washington_monument <- read.csv("washmon.csv", header=FALSE) lowvals = 3486:3501 highvals = 3519:3534 usgs_data[lowvals,highvals] = usgs_data[lowvals,highvals] + as.matrix(washington_monument) national_mall_map = dc_topographic_map[1700:3700,3000:4000] sunang = 45*pi/180 angle = 0 / 180 * pi diffangle = 10 / 180 * pi numberangles = 30 anglebreaks = seq(angle, diffangle, length.out = numberangles) single_angle = rayshade(sunangle = sunang, anglebreaks = anglebreaks, heightmap = national_mall_map, zscale = 1, maxsearch = 200, multicore=TRUE) #image function and ggplot is slow; save with PNG function #from PNG package. png::writePNG(t(single_angle), "national_mall_shaded.png")

Applying this function to the same USGS data of the National Mall above, we get this light intensity map:

Figure 8: Raytraced shadow map of the National Mall.

We can overlay this onto an actual image of the area to get the video at the top of the page (see the code below for the Imagemagick script used to combine the satellite imagery with the shadow map). The raytraced shadows add a much more naturalistic shading to the topography than the local model. The actual length and depth of the shadows can be adjusted by playing with the angles and number of light rays, so this technique can be adapted for many different types of terrain. It also has the additional benefit of playing well with man-made structures; unlike a gradient-based model, this method will more clearly display buildings and extremely sharply changing terrain.

Figure 9: Transitioning from flat to shaded. Bridges don't cast shadows because they are not a part of this dataset.

Here's the bash script using Imagemagick that combines all 144 shadow maps with the satellite image to get the video you see at the top of the page.

#Bash script using Imagemagick to multiply shadow map with background satellite image
for i in {1..144}; 
  do convert background.png \( washmon_$i.png -normalize +level 0,100% \) -compose multiply -composite washmonmultiply_$i.png; 
done

Now, this method does has some downsides that make it less well-suited for actual cartographic work. Firstly, tall objects that cast long shadows will obscure the smaller topolographic features that lie in the shadow. There's a tradeoff between realism and actual topographic information--real shadows can tell you about the relative height of two objects, but nothing about the slope of the land in the shadow itself. Secondly, it can be difficult to choose a suitable light elevation if the topography of the area has sharp transitions between flat and elevated areas. A low-elevation light (like the sun at dawn or dusk) is better for emphasizing less dramatic topographic features–like the gentle hills and valleys present in the beautiful swampland of DC–but results in very long shadows if a tall object is near. This long aspect ratio increases computation requirements, since one of the arguments is the maximum distance you wish to propagate the ray4. Thirdly, cartography has an artistic and practical component, and it’s true what reliefshading.com said in the non-computational shade it threw at global models–sometimes, you need to tweak the direction of the light hitting a particular mountain or valley because it looks better.

And yes, it’s also slower. But computation time is cheap, and CPU cores and GHz are plentiful. To prove my point, here is a 10,012 x 10,012 (100 million point) 10km by 10km (1 meter resolution) map of the DC region, shaded with this method. Each frame took about 30 minutes to compute on my laptop, and consists of five separate light sources to create a nicer gradient.

Figure 10: Raytraced shadow map of downtown DC. 10km by 10km, with 1 meter resolution.

On the other hand, you can actually adapt this algorithm to produce a local, non-derivative-based shadow map that closely matches the cartographer-approved method, and runs quickly. If you take the maximum propogated distance for each ray down to one unit, you end up sampling how many rays at that point directly intercept the surface. If you send out rays in all directions towards the light--including straight down--the number of rays hitting the surface tells us about the local slope in the direction of the light 5. You can then use this method to create a locally-shaded model, as seen below.

Figure 11: Local raytraced shadow map of the National Mall. By truncating the ray distance to one unit, we only sample the local slope in the direction of the light source. The odd appearances of the buildings at the top are because the roofs of buildings around DC are likely being obscured for national security.

So, we get the best of both worlds: we can easily produce a local map to show topographic features, or just tweak a few parameters and have a more realistic shadow map that emphasizes tall objects and accurately calculates their shadows. And all of this despite the (ok, mostly imagined) words of a few crusty cartographers. Or, as best put in the immortal words of the baby boomers: Don't trust anyone [paper] over thirty.


Here's a link to the rayshader github repo with installation and basic usage instructions:

Link: github.com/tylermorganwall/rayshader

X
Come back for more! Subscribe to get updated when new content arrives.

Tyler is an avid R user, physicist, comedian, writer, programmer, and data enthusiast.

9 Comments

  1. May 15, 2018
    Reply

    Amazing work and write up! I really enjoyed reading and will reference again the future.

  2. June 17, 2018
    Reply

    Thanks for a nice write-up and an interesting topic. I’ve been looking at the rayshader package’s code and I wonder why it declares the suncalc package as a dependency since I couldn’t find any usage of its public functions listed in https://cran.r-project.org/web/packages/suncalc/suncalc.pdf. Did I miss anything?

    • June 17, 2018
      Reply

      That dependency is due to a function I haven’t included in the public repository yet (because it isn’t finalized). It allows you to enter in a date/time and latitude/longitude, and rayshader will use that information to calculate the location of the sun and shade the map appropriately.

  3. June 20, 2018
    Reply

    Great work!
    It really looks much better than my lousy implementation. After that blogpost I had also implemented a primitive raytracer to drop shadows, but yours in undeniable better.
    I’m happy my humble post inspired you to make this great contribution!

  4. stem
    July 1, 2018
    Reply

    Looks awesome man.
    Have you considered adding a blue ambient light into the shadows for the ambient sky light?

Leave a Reply

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