Batched, Elixir-native drawing API backed by Rustler + skia-safe.

The public API builds immutable Elixir documents. Rendering crosses the native boundary once with a normalized command batch instead of calling a NIF for every canvas operation.

Example

document =
  Skia.canvas(1200, 630)
  |> Skia.clear("#020617")
  |> Skia.group([translate: {80, 80}], fn doc ->
    doc
    |> Skia.text("Launch Week", x: 48, y: 90, size: 56, weight: 700, fill: :white)
    |> Skia.rect(x: 48, y: 140, width: 320, height: 96, radius: 24, fill: "#3b82f6")
  end)

{:ok, png} = Skia.to_png(document)
{:ok, png} = Skia.render(document, format: :png)
{:ok, %{width: width, height: height, stride: stride, data: rgba}} = Skia.render(document, Skia.RenderOptions.new(format: :raw))

Paint sources and shaders

fill =
  Skia.Shader.two_point_conical_gradient({16, 16}, 0, {48, 48}, 32, [
    Skia.Shader.stop(:red, 0),
    Skia.Shader.stop(:blue, 1)
  ], tile: :clamp, matrix: Skia.Matrix.rotate(0.2))

solid_shader = Skia.Shader.color(:green)

Image shaders support tile modes and rich sampling:

Skia.Shader.image(image,
  tile: {:repeat, :mirror},
  sampling: Skia.SamplingOptions.cubic(:catmull_rom),
  matrix: Skia.Matrix.scale(2, 2)
)

Filters and effects

Paints can carry image filters, color filters, mask filters, blend modes, and path effects. You can pass options directly or build a reusable %Skia.Paint{}:

paint = Skia.Paint.new(fill: :red, image_filter: Skia.ImageFilter.blur(2), blend_mode: :src_over)
Skia.rect(doc, x: 0, y: 0, width: 100, height: 100, paint: paint)

color_filter =
  Skia.ColorFilter.blend(:blue, :src_in)
  |> Skia.ColorFilter.compose(Skia.ColorFilter.matrix([
    1, 0, 0, 0, 0,
    0, 1, 0, 0, 0,
    0, 0, 1, 0, 0,
    0, 0, 0, 1, 0
  ]))

path_effect =
  Skia.PathEffect.trim(0.05, 0.95)
  |> Skia.PathEffect.compose(Skia.PathEffect.dash([8, 4]))
  |> Skia.PathEffect.sum(Skia.PathEffect.discrete(6, 1.5, seed: 42))

stamp = Skia.Path.new() |> Skia.Path.move_to(0, 0) |> Skia.Path.line_to(1, 1)
advanced_effect = Skia.PathEffect.path_1d(stamp, 8, style: :rotate)

Skia.path(doc, path,
  stroke: :red,
  stroke_width: 2,
  blend_mode: :src_over,
  color_filter: color_filter,
  image_filter: Skia.ImageFilter.blur(2),
  mask_filter: Skia.MaskFilter.blur(1.5, style: :normal),
  path_effect: path_effect
)

Layer and image-filter graphs are composable:

filter =
  Skia.ImageFilter.blur(2)
  |> Skia.ImageFilter.compose(Skia.ImageFilter.offset(4, 2))
  |> Skia.ImageFilter.compose(Skia.ImageFilter.matrix_transform(Skia.Matrix.scale(1.2, 1.2)))

advanced = Skia.ImageFilter.merge([
  Skia.ImageFilter.magnifier({0, 0, 100, 100}, 1.5, 4),
  Skia.ImageFilter.tile({0, 0, 20, 20}, {0, 0, 100, 100}),
  Skia.ImageFilter.matrix_convolution({1, 1}, [1.0])
])

Skia.layer(doc, [image_filter: filter], fn layer ->
  Skia.rect(layer, x: 0, y: 0, width: 80, height: 80, fill: :red)
end)

Paths

Paths are immutable Elixir structs and support absolute, relative, conic, cubic, SVG import, and SVG export:

path =
  Skia.Path.new()
  |> Skia.Path.move_to(0, 0)
  |> Skia.Path.r_line_to(40, 0)
  |> Skia.Path.conic_to(60, 20, 40, 40, 0.5)
  |> Skia.Path.arc_to({0, 0, 80, 80}, 0, 180, force_move_to: false)
  |> Skia.Path.rrect({10, 10, 40, 24}, 6)
  |> Skia.Path.close()

svg_path = Skia.Path.from_svg("M0 0L100 0L100 100Z")
{:ok, svg} = Skia.Path.to_svg(svg_path)

Pictures

Record reusable drawing subtrees as Skia pictures and replay them later:

{:ok, picture} =
  Skia.canvas(64, 64)
  |> Skia.rect(x: 0, y: 0, width: 64, height: 64, fill: :red)
  |> Skia.Picture.record()

{:ok, info} = Skia.Picture.info(picture)
{:ok, bytes} = Skia.Picture.encode(picture)
{:ok, picture} = Skia.Picture.decode(bytes, width: 64, height: 64)
{:ok, image} = Skia.Image.from_picture(picture)

Skia.canvas(256, 64)
|> Skia.picture(picture, x: 96, y: 0, opacity: 0.75)

Skia.rect(doc, x: 0, y: 0, width: 128, height: 128, fill: Skia.Shader.picture(picture))

Fonts and typefaces

Skia.Typeface represents the reusable font face; Skia.Font adds size for drawing and measurement.

{:ok, families} = Skia.Typeface.families()
{:ok, typeface} = Skia.Typeface.match_family("Inter", weight: 700, slant: :upright)
font = Skia.Font.new(typeface, size: 24)
{:ok, info} = Skia.Typeface.info(typeface)
{:ok, metrics} = Skia.Font.metrics(font)
{:ok, glyphs} = Skia.Font.glyph_ids(font, "Hello")
{:ok, measurement} = Skia.measure_text("Hello", font: font)

Loaded images, fonts, and pictures keep decoded Skia handles in their native resources for repeated drawing, while pictures still retain serialized bytes for Skia.Picture.encode/1.

Text

Use direct options for convenience or reusable style structs for paragraph text:

style = Skia.TextStyle.new(size: 16, fill: :black, font_family: "Arial", line_height: 20)
paragraph = Skia.ParagraphStyle.new(width: 320, align: :center, direction: :ltr)

spans = [
  Skia.TextSpan.new("Hello ", fill: :red, size: 18),
  Skia.TextSpan.new("Skia", fill: :blue, size: 24)
]

Skia.text(doc, "Hello", x: 0, y: 0, style: style, paragraph_style: paragraph)
Skia.text(doc, "", x: 0, y: 32, paragraph_style: paragraph, spans: spans)

{:ok, blob} = Skia.TextBlob.new("Cached", font: font)
{:ok, bounds} = Skia.TextBlob.bounds(blob)
Skia.text_blob(doc, blob, x: 0, y: 64, fill: :black)

Runtime effects / SkSL

Compile reusable SkSL shader effects and pass uniforms from Elixir:

{:ok, effect} =
  Skia.RuntimeEffect.compile("""
  uniform float time;
  uniform vec2 resolution;

  half4 main(vec2 p) {
    vec2 uv = p / resolution;
    return half4(uv.x, 0.5 + 0.5 * sin(time + uv.x * 20.0), uv.y, 1.0);
  }
  """)

shader = Skia.RuntimeEffect.shader(effect, uniforms: %{time: 1.2, resolution: {800, 600}})
Skia.rect(doc, x: 0, y: 0, width: 800, height: 600, fill: shader)

Runtime effects also support int uniforms, child shaders, and one-off compile helpers:

shader =
  Skia.Shader.sksl!("""
  uniform shader child;
  uniform int enabled;

  half4 main(vec2 p) {
    return enabled == 1 ? child.eval(p) : half4(0, 0, 0, 1);
  }
  """,
  uniforms: %{enabled: Skia.RuntimeEffect.int(1)},
  children: %{child: Skia.Shader.color(:red)})

Vertices

Draw triangle meshes with per-vertex colors:

vertices = Skia.Vertices.new([{0, 0}, {100, 0}, {50, 100}], colors: [:red, :green, :blue])
Skia.vertices(doc, vertices)

Compact batches and benchmarking

Skia.to_batch/1 returns the normal map/struct batch consumed by the native renderer. Encode normal batches directly with :erlang.term_to_binary(Skia.to_batch(document)). Skia.Compact.encode/1 uses stable operation ids and compact color/path values, and Skia.Compact.encode_binary/1 writes compressed ETF. Compact batches can also render through native compact decode:

{:ok, raw} = Skia.Compact.to_raw(document)
{:ok, png} = Skia.Compact.render(document, format: :png)

{:ok, stats} = Skia.Benchmark.compare(document, iterations: 20)
stats.normal_batch_bytes
stats.compact_batch_bytes
stats.compact_render_us
stats.picture_replay_us

Native operations return tagged errors such as :invalid_image, :invalid_picture, :invalid_path, and :invalid_command instead of leaking raw NIF badarg failures through public helpers.

Development

mix deps.get
mix ci

# Force local Rustler build instead of downloading a precompiled NIF.
SKIA_EX_BUILD=1 mix compile

See docs/commands.md for the generated command reference.