skiagd is an experimental drawing library for R that wraps rust-skia (the Rust crate skia_safe, a binding for Skia).
Due to the limitation of the upstream prebuilt binaries, it is available only for Linux and macOS. To build from source, it requires freetype, fontconfig, and the Rust toolchains.
remotes::install_github("paithiov909/skiagd")skiagd takes a pipeline‑oriented philosophy similar to ggplot2 but directs it at pure graphics rather than data visualisation. In a skiagd pipeline, there are three main steps to draw an image:
- Create a canvas with a background colour. The call
canvas()returns a new picture object that represents the drawing state. - Compose shapes by piping calls to
add_*()functions. Eachadd_*()call takes the current picture, adds shapes to it, and returns a new picture. The picture object always remains a recipe of drawing commands, no pixels are produced at this stage. - Render the picture. To convert a picture into pixels you can
call…
draw_img()to draw to the current graphics deviceas_nativeraster()to obtain a nativeRasteras_png()to obtain a PNG as a raw vector
Despite its name, skiagd presents itself as a toy R wrapper for rust-skia and does not behave like a graphics device. The package is meant to draw images independently of R’s graphics device system.
Skia exposes a rich set of painting
attributes
that control how shapes appear when drawn. In skiagd, you can specify
these attributes via the paint() function and pass the resulting
object to the props argument of add_*(). Unspecified attributes
inherit defaults, many of which follow the current graphics device’s
settings.
The following example illustrates a basic drawing pipeline.
library(skiagd)
rad <- \(deg) deg * (pi / 180)
# Canvas size in pixels (from the current graphics device)
cv_size <- dev_size()
# Generate coordinates for a rose curve
rose <-
dplyr::tibble(
i = seq_len(360),
r = 120 * abs(sin(rad(4 * i)))
) |>
dplyr::reframe(
x = r * cos(rad(360 * i / 360)) + cv_size[1] / 2,
y = r * sin(rad(360 * i / 360)) + cv_size[2] / 2,
z = 1
)
canvas("violetred") |>
add_point(
as.matrix(rose),
props = paint(
color = "white",
width = 3,
point_mode = PointMode$Polygon,
)
) |>
draw_img()Here we first generate coordinates for a simple rose curve and then draw
it with a single call to add_point(). We supply the points as a matrix
and specify painting attributes via paint(). Here we set the colour to
"white", the line width to 3 pixels and use PointMode$Polygon to
connect successive points. And finally, we call draw_img() to render
the picture on the current graphics device.
The following example, inspired by this blog post, demonstrates how skiagd can be used to create a more practical artwork.
cv_size <- dev_size()
cv_size
#> [1] 768 576
n_frames <- 720
n_circles <- 50
radius <- runif(n_circles, min = .25, max = 2) |> sort()
trans <- matrix(c(60, 0, cv_size[1] / 2, 0, 60, cv_size[2] / 2, 0, 0, 1), ncol = 3)
circle <- \(amp, freq, phase) {
amp * 1i^(freq * seq(0, 600, length.out = n_circles) + phase)
}
dir <- tempdir()
imgs <- purrr::imap_chr(seq(0, 4 * pi, length.out = n_frames + 1)[-1], \(a, i) {
# Compute a stack of circles with changing amplitudes and phases
l <- sin(pi * (2 * a - .5)) + 1
z <- circle(pi / 6, -pi, 0) +
circle(l, ceiling(a), -9 * cos(a) + 1) +
circle(l / 2 - 1, ceiling((-a + (7 / 2)) %% 7) - 7, -7 * cos(a) + 1)
hue <- (a + (Re(z / pi))) %% 1
colours <- grDevices::hsv(hue, .66, .75, alpha = 1)
# Build one frame
png <- canvas("#04010F") |>
add_circle(
cbind(Re(z), Im(z), 1) %*% trans,
radius = log(max(cv_size), exp(.5)) * radius,
color = col2rgba(colours),
props = paint(
style = Style$Fill,
blend_mode = BlendMode$Plus,
)
) |>
as_png()
fp <- file.path(dir, sprintf("%04d.png", i))
writeBin(png, fp)
fp
})The animation consists of multiple rotating circles whose radii and
phases change over time. You don’t need to understand the details of the
trigonometry here, the key takeaway is that you can compute complex
coordinates in R, pipe them into add_circle(), and then assemble the
frames into a GIF using gifski.
# Combine frames into a GIF (30 fps)
gifski::gifski(
imgs,
"mystery-circles.gif",
width = cv_size[1],
height = cv_size[2],
delay = 1 / 30
)The frames in the GIF look like this:
MIT License.


