Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Dynamic Graphics

dynamic-graphics.livemd

Dynamic Graphics

Mix.install([
  {:nx, "~> 0.4.1"},
  {:stb_image, "~> 0.6.0"},
  {:kino, "~> 0.12.0"}
])

Introduction

This document demonstrates one method of dynamically updating graphics (be it SVG vector graphics or bitmapped) within a livebook. It works by replacing the contents of a Kino.Frame.

While it certainly serves a purpose, if does flicker when updated.

References:

Vector Graphics with SVG

defmodule VectorGraphics do
  use GenServer

  @dim 256
  @shrink 0.7

  # interface

  def start_link(widget) do
    GenServer.start_link(__MODULE__, {widget, 0})
  end

  def render(pid, widget) do
    GenServer.cast(pid, {:render, widget})
  end

  # callbacks

  @impl true
  def init(state) do
    delay_message({:tick})
    {:ok, state}
  end

  @impl true
  def handle_info({:tick}, {widget, i} = _state) do
    Kino.Frame.render(widget, generate_svg(i))
    delay_message({:tick})
    {:noreply, {widget, i + 1}}
  end

  @impl true
  def handle_cast({:render, widget}, {_widget, i} = state) do
    Kino.Frame.render(widget, generate_svg(i))
    {:noreply, state}
  end

  # helpers

  defp generate_svg(i) do
    """
    
      
    
    """
    |> Kino.Image.new(:svg)
  end

  def delay_message(message, time \\ 1000) do
    Process.send_after(self(), message, time)
  end
end

Bitmapped Graphics with PNG

defmodule BitmappedGraphics do
  use GenServer

  # interface

  def start_link(widget) do
    GenServer.start_link(__MODULE__, {widget, 0})
  end

  def render(pid, widget) do
    GenServer.cast(pid, {:render, widget})
  end

  # callbacks

  @impl true
  def init(state) do
    delay_message({:tick})
    {:ok, state}
  end

  @impl true
  def handle_info({:tick}, {widget, i} = _state) do
    Kino.Frame.render(widget, generate_png(i))
    delay_message({:tick})
    {:noreply, {widget, rem(i + 1, 256)}}
  end

  @impl true
  def handle_cast({:render, widget}, {_widget, i} = state) do
    Kino.Frame.render(widget, generate_png(i))
    {:noreply, state}
  end

  # helpers

  defp generate_png(i) do
    data =
      0..255
      |> Enum.map(fn y ->
        0..255
        |> Enum.map(fn x ->
          [x, y, i]
        end)
      end)

    image =
      Nx.tensor(
        data,
        type: {:u, 8},
        names: [:height, :width, :channels]
      )

    StbImage.from_nx(image)
    |> StbImage.to_binary(:png)
    |> Kino.Image.new("image/png")
  end

  def delay_message(message, time \\ 200) do
    Process.send_after(self(), message, time)
  end
end

Config

type_input =
  Kino.Input.select("Graphic type",
    vector: "Vector (SVG)",
    bitmapped: "Bitmapped (PNG)"
  )
type =
  case Kino.Input.read(type_input) do
    :vector -> VectorGraphics
    :bitmapped -> BitmappedGraphics
  end

Push-Based Animation

push_widget = Kino.Frame.new() |> Kino.render()
nil

Start the chosen GenServer:

{:ok, pid} = type.start_link(push_widget)

Pull-Based Animation

pull_widget = Kino.Frame.new() |> Kino.render()
nil
type.render(pid, pull_widget)

Conclusion

There are some problems in this way of animating, namely:

  • Full SVGs or PNGs are being replaced. That is a heavy operation.
  • When replacing such an element there is a moment when no element is present (previous element has been removed, but new element is not yet there). This causes a flickering effect.
  • I seem to have to make cells that produce Kino.Frame evaluate to nil to avoid duplicating the rendered contents.