SDL3 for Scala Native

sdl3 — core

The core artifact: lifecycle, a window, the 2D renderer, textures and surfaces, filled geometry, events, and live input state. Everything here lives in the io.github.edadma.sdl3 package object.

import io.github.edadma.sdl3.*

The core artifact: lifecycle, a window, the 2D renderer, textures and surfaces, filled geometry, events, and live input state. Everything here lives in the io.github.edadma.sdl3 package object.

Lifecycle

setMainReady()                  // tell SDL you provide main (call before init)
val ok: Boolean = init(INIT_VIDEO)
val msg: String = error         // last SDL error message
delay(16)                       // sleep, milliseconds
setHint(HINT_RENDER_VSYNC, "1")
quit()

init takes a bitmask of subsystem flags: INIT_TIMER, INIT_AUDIO, INIT_VIDEO, INIT_EVENTS (default INIT_VIDEO). It returns true on success; on failure read error.

Window

val window = createWindow("title", 640, 480)          // flags default to 0
val window = createWindow("title", 640, 480, WINDOW_RESIZABLE)

Window flags: WINDOW_FULLSCREEN, WINDOW_OPENGL, WINDOW_HIDDEN, WINDOW_BORDERLESS, WINDOW_RESIZABLE, WINDOW_HIGH_PIXEL_DENSITY. A Window is an AnyVal over the SDL handle:

window.isNull                       // creation failed?
window.createRenderer()             // or createRenderer("metal") to pick a driver
window.setPosition(x, y)            // SDL3 has no creation-time position
window.pixelFormat                  // Int
window.size            : (Int, Int) // logical size, in points
window.sizeInPixels    : (Int, Int) // backbuffer size, in pixels (≠ size on HiDPI)
window.destroy()

A window is not high-DPI unless created with WINDOW_HIGH_PIXEL_DENSITY; otherwise sizeInPixels == size.

Displays

Query the desktop so a window opens fully on-screen rather than spilling off a panel smaller than the requested size:

val display = getPrimaryDisplay                 // primary display id (0 if none)
getDisplayForWindow(window)                     // the display a window is mostly on
displayUsableBounds(display)                    // Option[(x, y, w, h)]

The usable bounds are the desktop area minus space the system reserves — the menu bar, a taskbar or dock — so clamping a createWindow size to the returned w×h (and placing the window at (x, y)) keeps the whole window, including content at its bottom and right edges, on-screen. None means SDL could not report the bounds.

Renderer

The renderer is the 2D drawing context. Coordinates are Double.

val r = window.createRenderer()
r.setVSync(true)                    // sync present to the display refresh

r.setDrawColor(Color(247, 103, 7))  // or setDrawColor(r, g, b, a)
r.setBlendMode(BLENDMODE_BLEND)     // NONE / BLEND / ADD / MOD / MUL

r.clear()                           // clear to the current draw colour
r.clear(Color(24, 24, 28))          // set colour and clear

r.drawPoint(x, y)
r.drawLine(x1, y1, x2, y2)
r.drawRect(x, y, w, h)              // outline
r.fillRect(x, y, w, h)              // filled

r.present()                         // show the frame
r.destroy()

Filled geometry

Shapes SDL has no primitive for are drawn as triangle meshes via SDL_RenderGeometry:

r.fillCircle(cx, cy, radius, Color.White)
r.thickLine(x1, y1, x2, y2, width = 4.0, Color(247, 103, 7))
r.fillConvexPolygon(Array(x0, y0, x1, y1, x2, y2, /* … */), Color.White)

fillConvexPolygon takes a flat Array[Double] of x, y pairs (a triangle fan from the first vertex). The mesh builders are pure and unit-tested.

Render targets

Draw into an off-screen texture, then blit it to the window — useful for supersampling or flicker-free compositing:

val target = r.createTexture(window.pixelFormat, TEXTUREACCESS_TARGET, w, h)
r.setTarget(target)
// … draw …
r.resetTarget()
r.copy(target)                      // blit the whole texture across the window
r.present()

Texture access modes: TEXTUREACCESS_STATIC, TEXTUREACCESS_STREAMING, TEXTUREACCESS_TARGET.

Uploading a CPU pixel buffer

To put pixels produced on the CPU — by a 2D engine such as Cairo, or any code that fills a raw buffer — onto the screen, create a STREAMING texture and replace its contents each frame:

val tex = r.createTexture(PIXELFORMAT_ARGB8888, TEXTUREACCESS_STREAMING, w, h)

// each frame, after rendering into `buffer` (a Ptr[Byte]) whose rows are `pitch` bytes:
tex.update(buffer, pitch)
r.copy(tex)
r.present()

PIXELFORMAT_ARGB8888 is laid out B, G, R, A on a little-endian host — byte-for-byte identical to a Cairo Format.ARGB32 image surface — so a Cairo buffer (its getData and getStride) uploads with no conversion. update replaces the whole texture; pitch is the source buffer’s row length in bytes, which may exceed width * 4 if the producer pads rows.

Textures and surfaces

A surface is CPU pixels; a texture is GPU pixels. Upload a surface (from sdl3_ttf or sdl3_image) to a texture, then draw it:

val tex = r.createTextureFromSurface(surface)
tex.setScaleMode(SCALEMODE_LINEAR)  // NEAREST / LINEAR / PIXELART
val (w, h) = tex.size

r.copy(tex)                         // fill the whole target
r.copy(tex, x, y)                   // at (x, y), the texture's own size
r.copy(tex, x, y, w, h)            // into a destination rect

tex.destroy()

surface.width; surface.height
surface.free()

Events

pollEvent() returns Option[Event]; drain it each frame. An Event is a view over a reusable union buffer, so read kind first, then only the fields valid for that kind:

var e = pollEvent()
while e.isDefined do
  val ev = e.get
  ev.kind match
    case QUIT              => running = false
    case KEY_DOWN          => onKey(ev.keyScancode, ev.keyRepeat)
    case MOUSE_BUTTON_DOWN => onClick(ev.mouseX, ev.mouseY, ev.mouseButton)
    case MOUSE_MOTION      => onMove(ev.mouseX, ev.mouseY)
    case MOUSE_WHEEL       => onScroll(ev.wheelX, ev.wheelY)
    case _                 => ()
  e = pollEvent()

Event kinds: QUIT, KEY_DOWN, KEY_UP, TEXT_INPUT, MOUSE_MOTION, MOUSE_BUTTON_DOWN, MOUSE_BUTTON_UP, MOUSE_WHEEL, WINDOW_RESIZED (logical size changed), and WINDOW_PIXEL_SIZE_CHANGED (backbuffer pixel size changed — the same moment on a 1× display, and also when a window moves between displays of differing density; re-query window.size / window.sizeInPixels and rebuild any sized backbuffer). Field accessors: keyScancode, keyRepeat, keyMod (the active modifier bitmask — test with KMOD_SHIFT / KMOD_CTRL / KMOD_ALT / KMOD_GUI), mouseX, mouseY, mouseButton (1 = left, 2 = middle, 3 = right), wheelX, wheelY (positive y = away from the user), text (for TEXT_INPUT).

Text input

Physical key events (KEY_DOWN) give you scancodes; to receive the actual text a user types — respecting their keyboard layout, and the IME on platforms that have one — enable text input on the window, then read TEXT_INPUT events:

window.startTextInput()          // begin delivering TEXT_INPUT events
// ... in the event loop:
if ev.kind == TEXT_INPUT then field += ev.text   // ev.text is the typed UTF-8 string
// ...
window.stopTextInput()           // when the field loses focus

A text field typically calls startTextInput() when focused and stopTextInput() on blur. ev.text is the UTF-8 string for that event (often a single character, but a composed sequence under an IME).

Event watches

Register a callback fired for every event as it is pumped (handy for resize/expose without restructuring the loop):

val id = addEventWatch { ev => if ev.kind == QUIT then save() }
removeEventWatch(id)

Live input state

Instead of (or alongside) events, read the current device state directly:

val keys = Keyboard.state
if keys(Scancode.Space) then jump()
if keys(Scancode.Escape) then running = false

val m = Mouse.state          // MouseState(buttons, x, y)
if m.left then paint(m.x, m.y)
m.middle; m.right

Scancode names the physical keys: letters AZ, digits Num0Num9, Return, Escape, Backspace, Tab, Space, Minus, Equals, LeftBracket, RightBracket, and the arrows Left, Right, Up, Down. These are the standard USB-HID scancodes SDL reports.

Audio

PCM playback with no callback and no thread of your own. You open a stream, then push finished buffers of float32 samples; SDL runs its own audio thread that pulls from the stream’s queue and feeds the device. This suits short, fully-known sounds — synthesised effects, decoded one-shots — where you can hand over a complete buffer at the moment you need it. (For continuous generation you would instead pass a callback to SDL; this layer exposes the simpler push model.)

Audio is a separate subsystem from video, so bring it up after the window exists:

if initAudio() then              // SDL_InitSubSystem(SDL_INIT_AUDIO)
  val voice = openAudioStream(44100)        // float32, mono (channels defaults to 1)
  if !voice.isNull then
    voice.put(samples)           // samples: Array[Float], each in [-1, 1]

openAudioStream(freq, channels = 1) opens the default playback device for AUDIO_F32 samples and starts it; the returned AudioStream is an AnyVal over the SDL handle:

voice.isNull                     // open failed?
voice.put(samples: Array[Float]) // queue samples (SDL copies them; reuse the array freely)
voice.queued     : Int           // bytes still to play — 0 means idle
voice.resume(); voice.pause()    // device-side play/pause
voice.clear()                    // drop anything queued but not yet played
voice.destroy()

Each put appends to the stream’s queue, so successive sounds on one stream play back-to-back. To overlap effects, open several streams on the default device — SDL mixes them — and send each new sound to the most idle one (queued == 0):

val voices = Array.fill(8)(openAudioStream(44100))
def play(buf: Array[Float]): Unit =
  voices.minBy(_.queued).put(buf)           // lands on a free voice, doesn't cut one off

A minimal synth — a 0.1 s sine “beep”:

val rate = 44100
val n    = rate / 10
val beep = Array.tabulate(n) { i =>
  val t = i.toDouble / rate
  (math.sin(2 * math.Pi * 440 * t) * math.exp(-6 * t)).toFloat   // 440 Hz, decaying
}
play(beep)

Constants: AUDIO_F32 (32-bit little-endian float samples) and AUDIO_DEVICE_DEFAULT_PLAYBACK (the default output device id). Call quitAudio() to tear the subsystem down, or just let quit() do it.

Search

Esc
to navigate to open Esc to close