andy@andyleclair.dev$>_

GLtest

First steps with OpenGL in Elixir

Related Listening

Posted on 2024-09-09

Inspired by this thread on Elixir Forum I wanted to mess around with graphics programming in Elixir. I haven’t really touched graphics programming since I was in college (go huskies!) but I remember it being a lot of fun. Elixir is pretty fast and I know a few performance tricks I’ve learned on the job, so let’s see how we can do.

Other people have mentioned methods to get at some of the OpenGL macros that are in Erlang, so we can start with that. We’ll need those to interact with OpenGL and wxWidgets.

mix new --sup gltest && cd gltest
mkdir src/

These files are lifted directly from here

%% src/gl_const.erl
-module(gl_const).
-compile(nowarn_export_all).
-compile(export_all).

-include_lib("wx/include/gl.hrl").

gl_depth_test() -> ?GL_DEPTH_TEST.

gl_lequal() -> ?GL_LEQUAL.
gl_color_buffer_bit() -> ?GL_COLOR_BUFFER_BIT.

gl_depth_buffer_bit() -> ?GL_DEPTH_BUFFER_BIT.

gl_triangles() -> ?GL_TRIANGLES.
gl_array_buffer() -> ?GL_ARRAY_BUFFER.
gl_element_array_buffer() -> ?GL_ELEMENT_ARRAY_BUFFER.
gl_static_draw() -> ?GL_STATIC_DRAW.

gl_vertex_shader() -> ?GL_VERTEX_SHADER.
gl_fragment_shader() -> ?GL_FRAGMENT_SHADER.

gl_compile_status() -> ?GL_COMPILE_STATUS.
gl_link_status() -> ?GL_LINK_STATUS.

gl_float() -> ?GL_FLOAT.
gl_false() -> ?GL_FALSE.
gl_true() -> ?GL_TRUE.
gl_unsigned_int() -> ?GL_UNSIGNED_INT.
gl_unsigned_byte() -> ?GL_UNSIGNED_BYTE.

gl_front_and_back() -> ?GL_FRONT_AND_BACK.
gl_line() -> ?GL_LINE.
gl_fill() -> ?GL_FILL.
gl_debug_output() -> ?GL_DEBUG_OUTPUT.
gl_texture_2d() -> ?GL_TEXTURE_2D.
gl_texture_wrap_s() -> ?GL_TEXTURE_WRAP_S.
gl_texture_wrap_t() -> ?GL_TEXTURE_WRAP_T.
gl_texture_min_filter() -> ?GL_TEXTURE_MIN_FILTER.
gl_texture_mag_filter() -> ?GL_TEXTURE_MAG_FILTER.
gl_rgb() -> ?GL_RGB.
gl_rgba() -> ?GL_RGBA.
gl_multisample() -> ?GL_MULTISAMPLE.
gl_luminance() -> ?GL_LUMINANCE.

gl_texture0() -> ?GL_TEXTURE0.

gl_cull_face() -> ?GL_CULL_FACE.
gl_back() -> ?GL_BACK.
gl_front() -> ?GL_FRONT.
gl_ccw() -> ?GL_CCW.
gl_cw() -> ?GL_CW.

gl_info_log_length() -> ?GL_INFO_LOG_LENGTH.

gl_blend() -> ?GL_BLEND.
gl_src_alpha() -> ?GL_SRC_ALPHA.
gl_one() -> ?GL_ONE.
gl_one_minus_src_alpha() -> ?GL_ONE_MINUS_SRC_ALPHA.

gl_repeat() -> ?GL_REPEAT.
gl_linear() -> ?GL_LINEAR.
gl_nearest() -> ?GL_NEAREST.

gl_framebuffer() -> ?GL_FRAMEBUFFER.
gl_renderbuffer() -> ?GL_RENDERBUFFER.
gl_color_attachment0() -> ?GL_COLOR_ATTACHMENT0.
gl_framebuffer_complete() -> ?GL_FRAMEBUFFER_COMPLETE.
gl_read_framebuffer() -> ?GL_READ_FRAMEBUFFER.
gl_draw_framebuffer() -> ?GL_DRAW_FRAMEBUFFER.

gl_texture_env() -> ?GL_TEXTURE_ENV.
gl_texture_env_mode() -> ?GL_TEXTURE_ENV_MODE.
gl_replace() -> ?GL_REPLACE.

%% src/wx_const.erl
-module(wx_const).
-compile(nowarn_export_all).
-compile(export_all).

-include_lib("wx/include/wx.hrl").

wx_id_any() -> ?wxID_ANY.
wx_gl_rgba() -> ?WX_GL_RGBA.

wx_gl_doublebuffer() -> ?WX_GL_DOUBLEBUFFER.
wx_gl_depth_size() -> ?WX_GL_DEPTH_SIZE.
wx_gl_forward_compat() -> ?WX_GL_FORWARD_COMPAT.

wxk_left() -> ?WXK_LEFT.
wxk_right() -> ?WXK_RIGHT.
wxk_up() -> ?WXK_UP.
wxk_down() -> ?WXK_DOWN.
wxk_space() -> ?WXK_SPACE.
wxk_raw_control() -> ?WXK_RAW_CONTROL.

wx_gl_major_version() -> ?WX_GL_MAJOR_VERSION.

wx_gl_minor_version() -> ?WX_GL_MINOR_VERSION.

wx_gl_core_profile() -> ?WX_GL_CORE_PROFILE.
wx_gl_sample_buffers() -> ?WX_GL_SAMPLE_BUFFERS.

wx_gl_samples() -> ?WX_GL_SAMPLES.

wx_null_cursor() -> ?wxNullCursor.
wx_cursor_blank() -> ?wxCURSOR_BLANK.
wx_cursor_cross() -> ?wxCURSOR_CROSS.

wx_fontfamily_default() -> ?wxFONTFAMILY_DEFAULT.
wx_fontfamily_teletype() -> ?wxFONTFAMILY_TELETYPE.
wx_normal() -> ?wxNORMAL.
wx_fontstyle_normal() -> ?wxFONTSTYLE_NORMAL.
wx_fontweight_bold() -> ?wxFONTWEIGHT_BOLD.
wx_fontweight_normal() -> ?wxFONTWEIGHT_NORMAL.

Also add wx to extra_applications in mix.exs:

  def application do
    [
      extra_applications: [:logger, :wx]
    ]
  end

For reference, this is the link to the Erlang WX documentation: https://www.erlang.org/doc/apps/wx/chapter.html

Following along here with Ian’s Triangle post, we will also add the records from wx.hrl to our project:

defmodule WxRecords do
  require Record

  for {type, record} <- Record.extract_all(from_lib: "wx/include/wx.hrl") do
    Record.defrecord(type, record)
  end
end

Finally, we can add a module to render our window!

defmodule GlTest.Window do
  import WxRecords

  @behaviour :wx_object

  def start_link(_) do
    :wx_object.start_link(__MODULE__, [], [])
    {:ok, self()}
  end

  @impl :wx_object
  def init(_) do
    opts = [size: {800, 600}]
    wx = :wx.new()
    frame = :wxFrame.new(wx, :wx_const.wx_id_any(), ~c"Hello", opts)
    :wxWindow.connect(frame, :close_window)

    :wxFrame.show(frame)

    gl_attrib = [
      attribList: [
        :wx_const.wx_gl_core_profile(),
        :wx_const.wx_gl_major_version(),
        3,
        :wx_const.wx_gl_minor_version(),
        3,
        :wx_const.wx_gl_doublebuffer(),
        0
      ]
    ]

    canvas = :wxGLCanvas.new(frame, opts ++ gl_attrib)
    ctx = :wxGLContext.new(canvas)
    :wxGLCanvas.setCurrent(canvas, ctx)

    send(self(), :update)

    {frame,
     %{
       frame: frame,
       canvas: canvas
     }}
  end

  @impl :wx_object
  def handle_event(wx(event: wxClose()), state) do
    {:stop, :normal, state}
  end

  @impl :wx_object
  def handle_info(:stop, %{canvas: canvas} = state) do
    :wxGLCanvas.destroy(canvas)

    {:stop, :normal, state}
  end

  @impl :wx_object
  def handle_info(:update, state) do
    render(state)

    {:noreply, state}
  end

  defp render(%{canvas: canvas} = state) do
    draw(state)
    :wxGLCanvas.swapBuffers(canvas)
    send(self(), :update)

    :ok
  end

  defp draw(%{canvas: _canvas} = _state) do
    :gl.clearColor(0.2, 0.1, 0.3, 1.0)
    :gl.clear(:gl_const.gl_color_buffer_bit())

    :ok
  end

  def child_spec(opts) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [opts]},
      restart: :permanent
    }
  end
end

And then we can add it to our application.ex and fire up iex -S mix:

defmodule Gltest.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      GlTest.Window
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Gltest.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

It’s important to note that we’re returning {:ok, self()} from start_link/1, because if you just return the value of :wx_object.start_link/3 the process will crash, because what that returns does not match what Supervisor.start_link/2 expects. That said, this should get a nice, Elixir-y purple window to appear on your screen. I’m running this code under WSL 2, but it should work on any platform that supports Erlang and WX, which should be most of them. I know that Scenic has a specific driver for Nerves devices, so I wouldn’t expect that would work out of the box, but I’d guess it works on MacOS.

A working purple window