A 3D tour over Lake Geneva with rayshader

A 3D tour over Lake Geneva with rayshader

If you follow the #rstats news on Twitter, you have most likely seen the impressive 3D maps produced with Tyler Morgan-Wall’s rayshader package. I’ve been wanting to try it for a long time and I finally found the time to do it last week. As a limnologist living on the shores of Lake Geneva, I didn’t hesitate for long about the location of my first map, which, in the excitement of the moment, I immediately published on twitter.

In this tweet I was promising to go deeper into the subject. That’s the purpose of this post.

First, we load some packages, functions and data. I have prepared a geotiff file for the region of interest (the surroundings of Lake Geneva). The elevation data were extracted from the SRTM 30 m dataset (thanks @tylermorganwall for the advice!) and I have completed the DEM with a low-resolution model of the lake bathymetry. There is a very high-resolution bathymetry available for lake Geneva, but sadly not released in open data. Finally, we will also need a simple shapefile of the lake shoreline.

library(raster)
library(magrittr)
library(stringr)
library(rgl)
library(rayshader)
library(magick)
library(ggplot2)

source("https://gist.github.com/fkeck/4820db83b9ff2fbf1f7fe901563ddc82/raw/")

# Some useful functions by Will Bishop from his post on rayshader
# https://wcmbishop.github.io/rayshader-demo/
source("https://raw.github.com/wcmbishop/rayshader-demo/master/R/rayshader-gif.R")

mnt <- raster("combined_leman.tif")
mnt <- aggregate(mnt, 2) # Simplification factor

ccv <- rgdal::readOGR("ccv.shp")

plot(mnt)
plot(ccv, add = T)

export_folder <- "/home/francois/Documents/rayshader"

Now, we are ready to render our first 3D scene! The rayshader API makes the task extremely easy. Starting from the elevation matrix, we successively add shadow layers and print the result on screen with OpenGL.

#### Static rendering ####
elmat <-  matrix(raster::extract(mnt, raster::extent(mnt), buffer = 1000),
                 nrow = ncol(mnt), ncol = nrow(mnt))
z_scale <- 40

ambmat <- ambient_shade(elmat)
raysha <- ray_shade(elmat, zscale = z_scale, maxsearch = 300)
hillshade <- elmat %>%
  sphere_shade(texture = "imhof1", sunangle = 35) %>%
  add_shadow(raysha, 0.5) %>%
  add_shadow(ambmat, 0.5)

plot_3d(hillshade, elmat, zscale = z_scale, windowsize = c(1400, 620))
render_camera(theta = 135, phi = 25, zoom = 0.4, fov = 75)
render_water(elmat, zscale = z_scale, wateralpha = 0.7,
             watercolor = "turquoise4", waterdepth = 372,
             waterlinecolor = "lightblue", waterlinealpha = 0.5)
render_snapshot(file.path(export_folder, "static_leman.png"))

Ok, now that we have 3D, what about adding a 4th dimension with time? I have to admit that it was the animations made with rayshader that impressed me the most in the first place. The coolest thing was probably this video of Monterey Bay showing water level progressively increasing and decreasing in an infinite, hypnotizing loop. I definitely needed to have the same thing done with the lake I see every day by my window.

#### Water filling animation ####
list.files(export_folder, pattern = "render_", full.names = TRUE) %>% 
  file.remove()

n_frames <- 300
thetas <- transition_values(from = -45, to = -225, steps = n_frames)
water_level <- transition_values(from = 372, to = 0, steps = n_frames)

plot_3d(hillshade, elmat, zscale = z_scale, windowsize = c(1400, 620))

for(i in 1:n_frames){
  
  file_img_i <- file.path(export_folder, paste0("render_", sprintf("%04d", i), ".png"))
  
  render_camera(theta = thetas[i], phi = 25, zoom = 0.4, fov = 75)
  render_water(elmat, zscale = z_scale, wateralpha = 0.7,
               watercolor = "turquoise4", waterdepth = water_level[i])
  
  render_snapshot(file_img_i)
}

paste0('cd ', export_folder, '  && ffmpeg -y -r 25 -i render_%04d.png',
       ' -c:v libx264 -vf "fps=25,format=yuv420p" filling_movie.mp4') %>%
  system()



The camera movement reinforces the 3D perception but I am a little disappointed by the « filling » of the lake. The low resolution of the bathymetry gives a poor result here.

So far, I have simply adapted bits of code found here and there. Now, it is time to try something a bit more exciting. For that I will need new data. My colleague and friend Frédéric Soulignac has developed a 3D hydrodynamic model for the lake temperature and he is kind enough to share some of his simulation outputs for my little experiments. My idea is to project the simulated surface temperatures onto the lake surface. Fred provided several days of data, so I have the opportunity to make a nice animation.

Technically, I use a height-field surface shape added to the 3D scene with the rgl.surface function. The surface is flat, the height is fixed at the altitude of the lake (372m), locations are colored according to the temperature data and the height of the points outside the lake is set to NA, so that they are not drawn. It is also important to set the lit argument on FALSE to disable the calculation of light effects on this object. To complete the graph, we will add some elements: the date and time, a color scale bar and a temperature histogram. I didn’t try to integrate these elements directly into the 3D scene. Instead, I added them after the rendering, using the magic drawing functions of the magick package.

#### Temperature animation ####

# Load the temperature data. These are raster objects imported from Matlab.
# We need to adjust projection and resolution to fit with mnt
load("/home/francois/Documents/fred_temp.RData")

temp_interp_crop <- lapply(temp_interp_list, function(x) {
  projectRaster(x, mnt) %>% extend(mnt)
})

# We prepare the color palette
all_temps <- do.call(rbind, temp_list)$temperature
pal <- viridisLite::viridis(100)
pal <- viridisLite::inferno(100)
pal_cut <- seq(min(all_temps), max(all_temps), by = (max(all_temps) - min(all_temps))/100)
names(pal) <- levels(cut(all_temps, pal_cut))

# Color matrix for the surface
colmat_list <- lapply(temp_interp_crop, function(temp_interp_crop){
  colmat <- matrix(raster::extract(temp_interp_crop, raster::extent(temp_interp_crop), buffer = 1000),
                   ncol = nrow(temp_interp_crop), nrow = ncol(temp_interp_crop))
  colmat <- matrix(pal[cut(colmat, pal_cut)], nrow = nrow(colmat), ncol = ncol(colmat))
  return(colmat)
})

# Color height for the surface
z_height <- matrix(372, nrow = nrow(colmat_list[[1]]), ncol = ncol(colmat_list[[1]]))
z_height[which(is.na(colmat_list[[1]]))] <- NA

n_frames <- 500
thetas <- transition_values(from = -45, to = -225, steps = n_frames)
zoom <- transition_values(from = 0.6, to = 0.3, steps = n_frames)
temp_idx <- transition_values(from = 1, to = length(colmat_list),
                              steps = n_frames, type = "lin", one_way = TRUE) %>% round(0)

# We load a color scale bar picture in memory
img_scale <- image_graph(width = 195, height = 50)
par(mar = c(3, 0, 0, 0))
col_bar_h(pal, min(all_temps), max(all_temps), n.tick = 2)
dev.off()


plot_3d(hillshade, elmat, zscale = z_scale, windowsize = c(1400, 620))

list.files(export_folder, pattern = "render_", full.names = TRUE) %>% 
  file.remove()

for(i in seq_len(n_frames)){
  
  file_img_i <- file.path(export_folder, paste0("render_", sprintf("%04d", i), ".png"))
  
  render_camera(theta = thetas[i], phi = 25, zoom = zoom[i], fov = 75)
  rgl.surface((1:nrow(colmat_list[[temp_idx[i]]])),
              (1:ncol(colmat_list[[temp_idx[i]]])) - ncol(colmat_list[[temp_idx[i]]]),
              z_height/z_scale,
              color = colmat_list[[temp_idx[i]]], back = "lines", lit = FALSE)
  render_snapshot2(file_img_i)
  rgl.pop()
  
  img_histo <- image_graph(width = 300*0.8, height = 150*0.8)
    print(
      temp_list[[temp_idx[i]]] %>% 
      ggplot() +
      geom_histogram(aes(temperature, fill = cut(temperature, pal_cut)), bins = 30) +
      geom_hline(yintercept = 0, size = 0.7) +
      scale_color_manual(values = pal) +
      scale_fill_manual(values = pal) +
      theme_void() +
      theme(legend.position = "none") +
      xlim(16, 24)
    )
  dev.off()
  
  img <- image_read(file_img_i)
  
  img <- image_composite(img, img_scale, offset = "+1160+50")
  img <- image_composite(img, img_histo, offset = "+20+10")
  
  img <- image_draw(img, xlim = c(0, image_info(img)[["width"]]), ylim = c(0, image_info(img)[["height"]]))
  text(1260, 580, expression(paste("Temperature (", degree, "C)")), cex = 1.2)
  text(625, 565, str_split(temp_date_list[[temp_idx[i]]], " ", simplify = TRUE)[1], cex = 2, adj = 0.5)
  text(775, 565, str_split(temp_date_list[[temp_idx[i]]], " ", simplify = TRUE)[2], cex = 2, adj = 0.5)
  dev.off()
  image_write(img, path = file_img_i)
  print(i)
}


paste0('cd ', export_folder, '  && ffmpeg -y -r 25 -i render_%04d.png',
       ' -c:v libx264 -vf "fps=25,format=yuv420p" temp_movie2.mp4') %>% 
  system()



HD version

Okay, that’s all for now. I am very satisfied with the result. And so is Fred 🙂 There are so many other things to do with rayshader but it will be for later.

Facebooktwitter

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *