coolbutuseless / isocubes

MIT License
61 stars 3 forks source link

isocubes

R-CMD-check

The purpose of this package is to provide a 3D rendering backend for a very particular visual aesthetic.

That is, {isocubes} is an isometric rendering canvas with cubes as the only graphics primitive.

Some tools are included for creating particular scenes, but in general, if you can provide a list of (x,y,z) integer coorindates of what to render, then isocumes will create a 3d render.

What’s in the box

Coordinate system

Why isometric?

Isometric cubes have advantages over other axonometric and perspective coordinate systems:

Installation

You can install from GitHub with:

# install.package('remotes')
remotes::install_github('coolbutuseless/isocubes')

‘R’ in isocubes

library(grid)
library(purrr)
library(isocubes)

x <- c(9, 8, 7, 6, 5, 4, 3, 2, 10, 9, 3, 2, 11, 10, 3, 2, 11, 10, 
3, 2, 11, 10, 3, 2, 11, 10, 3, 2, 10, 9, 3, 2, 9, 8, 7, 6, 5, 
4, 3, 2, 10, 9, 3, 2, 11, 10, 3, 2, 11, 10, 3, 2, 11, 10, 3, 
2, 11, 10, 3, 2, 11, 10, 3, 2, 11, 10, 3, 2)

y <- c(15, 15, 15, 15, 15, 15, 15, 15, 14, 14, 14, 14, 13, 13, 13, 
13, 12, 12, 12, 12, 11, 11, 11, 11, 10, 10, 10, 10, 9, 9, 9, 
9, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 7, 7, 6, 6, 6, 6, 5, 5, 5, 5, 
4, 4, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1)

coords <- data.frame(x = x, y = y, z = 0)
cubes  <- isocubesGrob(coords, ysize = 1/25)
grid.newpage(); grid.draw(cubes)
# Colour the cubes with rainbow
cubes <- isocubesGrob(coords, fill = rainbow(nrow(coords)), ysize = 1/25)
grid.newpage(); grid.draw(cubes)
# VaporWave palette
cubes <- isocubesGrob(coords, fill = '#ff71ce', fill2 = '#01cdfe',
                      fill3 = '#05ffa1', ysize = 1/25)
grid.newpage(); grid.draw(cubes)
# Nightmare palette
cubes <- isocubesGrob(coords, 
                      fill = rainbow(nrow(coords)), 
                      fill2 = 'hotpink',
                      fill3 = viridisLite::inferno(nrow(coords)), 
                      ysize = 1/25, col = NA)
grid.newpage(); grid.draw(cubes)

Calculate isocubes within a sphere

library(grid)
library(isocubes)

N      <- 13
coords <- expand.grid(x=seq(-N, N), y = seq(-N, N), z = seq(-N, N))
keep   <- with(coords, sqrt(x * x + y * y + z * z)) < N
coords <- coords[keep,]

cubes <- isocubesGrob(coords, ysize = 1/35, xo = 0.5, yo = 0.5)
grid.newpage()
grid.draw(cubes)

Random rainbow volume of isocubes

library(isocubes)

N      <- 15
coords <- expand.grid(x=0:N, y=0:N, z=0:N)
coords <- coords[sample(nrow(coords), 0.66 * nrow(coords)),]
fill   <- rgb(red = 1 - coords$x / N, coords$y /N, 1 - coords$z/N, maxColorValue = 1)

cubes <- isocubesGrob(coords, fill, ysize = 1/40)
grid.newpage()
grid.draw(cubes)

Heightmap as isocubes

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Prepare a matrix of values
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
mat <- volcano

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# An optional matrix of colours
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
val <- as.vector(mat)
val <- round(255 * (val - min(val)) / diff(range(val)))
col <- viridisLite::viridis(256)[val + 1L]
dim(col) <- dim(mat) 

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Find the (integer) coordiinates of the cubes in the heightmap
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
coords <- coords_heightmap(mat - min(mat), col = col, scale = 0.3)

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Convert the coordinates into a grob
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
cubes  <- isocubesGrob(coords, ysize = 1/100, fill = coords$col, xo = 0.8)
grid.newpage(); grid.draw(cubes)

Image as isocubes

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Load image and convert to a matrix of heights
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
img <- png::readPNG("man/figures/Rlogo-small-blur.png")
ht        <- round( 10 * (1 - img[,,2]) ) # Use Green channel intensity as height
ht[,1]    <- 0 # image editing to remove some artefacts

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# A matrix of colours extracted from the image
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
col       <- rgb(img[,,1], img[,,2], img[,,3])
dim(col)  <- dim(ht) 

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# convert to cubes and draw
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
coords <- coords_heightmap(ht, col = col, ground = 'xy')
cubes  <- isocubesGrob(coords, ysize = 1/130, fill = coords$col, col = NA, light = 'right-top')
grid.newpage(); grid.draw(cubes)

Fake Terrain with ambient

library(grid)
library(ggplot2)
library(dplyr)
library(ambient)

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Create some perline noise on an NxN grid
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
set.seed(3)
N <- 60

dat <- long_grid(x = seq(0, 10, length.out = N), y = seq(0, 10, length.out = N)) %>% 
  mutate(
    noise = 
      gen_perlin(x, y, frequency = 0.3) + 
      gen_perlin(x, y, frequency = 2) / 10
  ) 

hm <- dat %>%
  mutate(
    x = x * 4,
    z = y * 4,
    y = noise * 4
  )

pal  <- topo.colors(11)
sy   <- as.integer(10 * (hm$y - min(hm$y)) / diff(range(hm$y))) + 1
cols <- pal[sy]

cubes  <- isocubesGrob(hm, ysize = 1/45, xo = 0.7, fill = cols, col = NA)

grid.newpage(); grid.draw(cubes)

Bitmap font rendering

library(grid)
library(isocubes)
library(bdftools)

bdf <- bdftools::read_bdf_builtin("spleen-32x64.bdf")
single_word <- bdftools::bdf_create_df(bdf, "#RStats!")

N    <- 10
cols <- rainbow(N)

multiple_words <- purrr::map_dfr(seq(N), function(i) {
  single_word$z <- i
  single_word$col <- cols[i]
  single_word
}) 

cubes  <- isocubesGrob(multiple_words, ysize = 1/170, xo = 0.1, fill = multiple_words$col, light = 'right-top', col = NA)
grid.newpage(); grid.draw(cubes)

Signed Distance Fields - Simple

library(grid)
library(dplyr)
library(isocubes)

# Create a scene that consists of a scaled torus
scene <- sdf_torus(3, 1) %>% 
  sdf_scale(5)

# Render the scene into a list of coordinates of voxels inside objects
coords <- sdf_render(scene, N = 30)

# Create cubes, and draw
cubes  <- isocubesGrob(coords, ysize = 1/50, xo = 0.5, yo = 0.5, fill = 'lightblue')
grid.newpage(); grid.draw(cubes)

Signed Distance Fields - More Complex

library(dplyr)
library(grid)
library(isocubes)

sphere <- sdf_sphere() %>%
  sdf_scale(40)

box <- sdf_box() %>%
  sdf_scale(32)

cyl <- sdf_cyl() %>%
  sdf_scale(16)

scene <- sdf_subtract_smooth(
  sdf_intersect(box, sphere),
  sdf_union(
    cyl,
    sdf_rotatey(cyl, pi/2),
    sdf_rotatex(cyl, pi/2)
  )
)

coords <- sdf_render(scene, 50)
cubes  <- isocubesGrob(coords, ysize = 1/100, xo = 0.5, yo = 0.5, fill = 'lightseagreen')
grid.newpage(); grid.draw(cubes)

Signed Distance Fields - Animated

Unfortunately this isn’t fast enough to animate in realtime, so I’ve stitched together individuall saved frames to create an animation.

thetas <- seq(0, pi, length.out = 45)

for (i in seq_along(thetas)) {
  cat('.')
  theta <- thetas[i]

  rot_scene <- scene %>% 
    sdf_rotatey(theta) %>%
    sdf_rotatex(theta * 2) %>%
    sdf_rotatez(theta / 2)

  coords <- sdf_render(rot_scene, 50)
  cubes  <- isocubesGrob(coords, ysize = 1/110, xo = 0.5, yo = 0.5)

  png_filename <- sprintf("working/anim/%03i.png", i)
  png(png_filename, width = 800, height = 800)
  grid.draw(cubes)
  dev.off()
}

# ffmpeg -y -framerate 20 -pattern_type glob -i 'anim/*.png' -c:v libx264 -pix_fmt yuv420p -s 800x800 'anim.mp4'

Technical Bits

Cube sort

Arrange cubes by -x, -z then y to ensure cubes are drawn in the correct ordering such that cubes in front are drawn over the top of cubes which are behind.

grob

All the faces of all the cubes are then calculated as polygons - each with 4 vertices.

The data for all polygons is then concatenated into a single polygonGrob() call with an appropiate vector for id.lengths to split the data.

Prototyping

Most of the prototyping for this package was done with {ingrid} - a package I wrote which I find makes working iteratively/at-the-console with base grid graphics a bit easier.

Acknowledgements