Skip to contents

The surfaces article covers surfaces with a single z value for each (x, y) position, like terrain. This article covers volumes: regions of 3D space, drawn as the surfaces that bound them. Unlike terrain, these surfaces can fold over, nest inside one another, and close on themselves.

ggcube draws most volumes as isosurfaces: the surfaces on which some quantity defined throughout 3D space equals a given value. They’re the 3D analog of contour lines. The quantity can come from three kinds of source, each of which has a lower-dimensional counterpart among the surface layers:

Source Surface: z = f(x, y) Volume: f(x, y, z) = c
Gridded data geom_surface_3d() geom_isosurface_3d()
A function geom_function_3d() geom_isofunction_3d()
The density of points geom_density_3d() geom_isodensity_3d()

geom_hull_3d(), which wraps point clouds in convex or alpha hulls, is covered here too.

The isosurface layers triangulate surfaces with the marching cubes algorithm, from the {rmarchingcubes} package, which must be installed:

install.packages("rmarchingcubes")

Data sources

Gridded data

Where geom_surface_3d() takes a grid of heights over x and y, geom_isosurface_3d() takes a grid of values over x, y, and z, mapped to the value aesthetic: a 3D raster, model output, a medical scan, or the like. The data need a row for every combination of x, y, and z values, such as from expand.grid(), though the spacing doesn’t have to be regular. The levels argument gives the values at which to draw surfaces, defaulting to the midpoint of the range of value.

Here’s the probability density of an electron in a hydrogen atom’s 3d orbital, the square of its wave function, which is largest in two lobes and a ring:

s <- seq(-20, 20, length.out = 40)
orbital <- expand.grid(x = s, y = s, z = s)
orbital$psi <- with(orbital, (3 * z^2 - (x^2 + y^2 + z^2)) *
                          exp(-sqrt(x^2 + y^2 + z^2) / 3))

ggplot(orbital, aes(x, y, z, value = psi^2)) +
      geom_isosurface_3d(levels = 0.02 * max(orbital$psi^2), fill = "darkorchid") +
      coord_3d(scales = "fixed", roll = -70)

Functions

Where geom_function_3d() draws the surface z = f(x, y) of a function of two variables, geom_isofunction_3d() draws the surfaces f(x, y, z) = c of a function of three. The function is evaluated over a grid spanning the domain given by xlim, ylim, and zlim, at a resolution set by n. By default it draws the implicit surface f(x, y, z) = 0, enclosing the region where the function is negative, which is how implicit surfaces are usually written. A torus, for example, is the set of points at a given distance from a circle:

torus <- function(x, y, z) (sqrt(x^2 + y^2) - 2)^2 + z^2 - 0.6^2

ggplot() +
      geom_isofunction_3d(fun = torus, n = 40,
                          xlim = c(-3, 3), ylim = c(-3, 3), zlim = c(-1, 1),
                          fill = "goldenrod") +
      coord_3d(scales = "fixed")

Point clouds

Whereas geom_density_3d() estimates the density of points in two dimensions and draws it as a surface, geom_isodensity_3d() estimates their density in three dimensions and draws surfaces enclosing given fractions of it, by default the densest half. Its h and adjust arguments control the bandwidth of the kernel density estimate, and n its resolution.

geom_hull_3d() takes a different approach to point clouds, wrapping them in convex hulls or, with method = "alpha", alpha hulls. Hulls show the full extent of the points, outliers included, while density surfaces show where they concentrate:

p <- ggplot(iris, aes(Sepal.Length, Sepal.Width, Petal.Length,
                      fill = Species, color = Species)) +
      coord_3d() +
      theme(legend.position = "none")

(p + geom_hull_3d() + ggtitle("geom_hull_3d()")) +
      (p + geom_isodensity_3d(probs = 0.75) + ggtitle("geom_isodensity_3d(probs = 0.75)"))

Inside and outside

Each isosurface divides space in two, and faces outward from the region it encloses, its inside. geom_isosurface_3d() and geom_isodensity_3d() enclose the regions where values exceed the level, which suits densities and concentrations, while geom_isofunction_3d() encloses those where they’re below it, following the convention for implicit surfaces. geom_isosurface_3d() and geom_isofunction_3d() take an inside argument to choose the other side.

The wave function of the orbital above is positive in its lobes and negative in its ring. To draw both, enclose the region above a positive level in one layer, and below a negative one in another:

level <- 0.15 * max(abs(orbital$psi))

ggplot(orbital, aes(x, y, z, value = psi)) +
      geom_isosurface_3d(levels = level, fill = "tomato") +
      geom_isosurface_3d(levels = -level, inside = "below", fill = "steelblue") +
      coord_3d(scales = "fixed")

Which side is inside makes no difference to how a closed surface looks with ggcube’s default lighting, but it does set which way surfaces face. That determines which side gets a cap where surfaces are cut (see below), which triangles cull_backfaces = TRUE removes, and the direction of the computed normal_x, normal_y, and normal_z variables.

Nested surfaces

Specifying multiple values for levels, or for probs in geom_isodensity_3d(), gives nested surfaces. These surfaces hide one another with the default opaque fill, so you can use wireframes or translucent fills to view the internal structure. Opaque surfaces are outlined in their fill color by default, to hide seams between triangles, while translucent ones aren’t outlined. These stats produce computed variables level or prob that you can map aesthetics to, to tell them apart.

Here’s the atomic orbital, with multiple levels shown simultaneously, drawn as a wireframe with line opacity mapped to point density:

ggplot(orbital, aes(x, y, z, value = psi^2)) +
      geom_isosurface_3d(aes(alpha = after_stat(level)),
                         levels = c(0.02, 0.10, .50, .90) * max(orbital$psi^2), 
                         fill = NA, color = "darkblue", show.legend = FALSE) +
      coord_3d(scales = "fixed", roll = -70) +
      light("none")

And here are the earthquakes in the quakes dataset, whose densest regions trace the Tonga slab subducting under the Pacific plate:

ggplot(quakes, aes(long, lat, -depth)) +
      geom_isodensity_3d(alpha = .4, aes(fill = after_stat(prob)),
                         probs = c(0.25, 0.5, 0.75)) +
      geom_point_3d(size = 0.2) +
      scale_fill_viridis_c() +
      coord_3d(yaw = 180, roll = -70, scales = "fixed", ratio = c(1, 1, .01)) +
      labs(z = "depth (km)") +
      zlim(NA, 0) +
      theme_dark() + 
      theme(panel.border = element_rect(color = "gray70"))

Cutaways

Limits on the plot’s scales cut isosurfaces cleanly where they cross them, leaving them open there. With cap = TRUE, they’re closed with flat caps instead, showing solid cross sections. geom_isosurface_3d() and geom_isofunction_3d() also cap surfaces where they meet the edges of their grid or domain. The computed cap variable identifies caps, e.g. to color them differently.

Here’s the gyroid function, shown with and without caps where it exits the cube:

gyroid <- function(x, y, z) sin(x) * cos(y) + sin(y) * cos(z) + sin(z) * cos(x)
p <- ggplot() + coord_3d() + theme_void() +
      scale_fill_manual(values = c("goldenrod", "slateblue"))
cut_torus <- function(cap) {
      geom_isofunction_3d(aes(fill = after_stat(cap)), fun = gyroid, cap = cap, n = 40,
                          xlim = c(0, 4 * pi), ylim = c(0, 4 * pi), zlim = c(0, 4 * pi))
}

(p + cut_torus(FALSE) + ggtitle("cap = FALSE")) +
      (p + cut_torus(TRUE) + ggtitle("cap = TRUE"))

Caps of nested surfaces tile the cut, each spanning the band between its level and the next, so a cutaway through nested surfaces shows their cross sections like a 2D density contour map. Here’s the orbital’s probability density at three levels, cut through its lobes and ring:

ggplot(orbital, aes(x, y, z, value = psi^2, fill = after_stat(level))) +
      geom_isosurface_3d(levels = c(0.02, 0.1, 0.4) * max(orbital$psi^2), cap = TRUE) +
      scale_fill_viridis_c(trans = "log10") +
      coord_3d(scales = "fixed", yaw = -130) +
      xlim(2, NA)

As elsewhere in ggplot2, limits remove data outside them before the layer’s stat is computed. For point clouds, that changes the density. For gridded data like the orbital’s, it just trims the grid, though ggplot2 warns about rows removed by x and y limits.

Practicalities

Resolution and speed

Isosurfaces are made of triangles, roughly in proportion to the square of the grid’s resolution, and drawing time grows with the number of triangles. n sets the resolution of geom_isofunction_3d() and geom_isodensity_3d(), defaulting to 30 points along each axis; for geom_isosurface_3d(), it’s the resolution of the data. Coarse grids give faceted surfaces, and fine ones smooth but slow ones:

p <- ggplot() +
      coord_3d(scales = "fixed", panels = "none") +
      theme_void()
resolution <- function(n) {
      p + geom_isofunction_3d(fun = torus, n = n, fill = "goldenrod",
                              xlim = c(-3, 3), ylim = c(-3, 3), zlim = c(-1, 1)) +
            ggtitle(paste("n =", n))
}

resolution(10) + resolution(40)

For opaque surfaces that close on themselves, cull_backfaces = TRUE removes the triangles facing away from the viewer, which are hidden anyway, saving some drawing time.

Lighting

Isosurfaces are lit like other ggcube surfaces; see the lighting article. Since lighting is computed per triangle, coarse surfaces show their facets, especially under strong or hard lighting.