The CPU runtime

While easl is designed primarily to be used a shader language, it also has a CPU runtime that can do window management, dispatch a file's shaders via simple higher-order functions, take user input from the mouse and keyboard, and even play audio. Things like buffers, pipelines, and data synchronization, which often need hundreds or thousands of lines of code in traditional graphics programming frameworks, are all handled automatically and seamlessly by easl's CPU runtime, so you never need to worry about them or write any code to make them work.

The following minimal example program shows how to spawn a window, dispatch shaders, and read info about the window like the resolution and time, all of which will be explained in more detail later on in this page:

@cpu
(defn main []
  (spawn-window
    (fn []
      (dispatch-render-shaders
        (fn [] (match (vertex-index)
                 0 (vec4f -1. -1. 0. 1.)
                 1 (vec4f -1. 3. 0. 1.)
                 _ (vec4f 3. -1. 0. 1.)))
        (fn [] (vec4f (/ (.xy (position)) (vec2f (window-resolution)))
                      (+ 0.5 (* 0.5 (sin (window-time))))
                      1.))
        3))))

Open in the playground

Run it with the easl CLI: easl run program.easl.

The frame loop#

spawn-window opens a new window when called. spawn-window accepts a function as an argument, and calls that function once per frame to act as an update loop. Inside that function you do whatever you want to do: set GPU-bound variables, dispatch render and compute shaders, read user input, read GPU results, and end the loop with close-window. The function is a closure, so it can capture variables from the surrounding code, and changes to captured @vars persist between frames.

Each frame's window image starts out black. Render dispatches draw into it in program order, each seeing the variables as they are at that point in the frame, and the image appears once the frame ends. The frame that calls close-window isn't shown.

CPU and GPU see each other's writes#

Writes on either side are visible to the other immediately, with no synchronization calls. A compute shader can write a variable that the next line prints:

(var results: [64: f32])

@compute
(defn simulate []
  (= (results (.x (global-invocation-id))) 1.))

@cpu
(defn main []
  (dispatch-compute-shader simulate (vec3u 64u))
  (print (results 0u))) ; 1.

Open in the playground

The runtime tracks which side last wrote each variable. CPU writes are uploaded before the next dispatch that uses them, and a CPU read of GPU-written data runs the pending GPU work, then reads it back. You'll never need to worry about whether your data is properly synchronized when writing easl, everything evaluates in program-order, and is available wherever it's needed.

Input#

Easl has several functions to get input about the state of the window, and for gathering user input, such as: window-resolution, window-time, window-delta-time, window-frame-index, mouse-coords, mouse-delta, mouse-scroll-delta, mouse-present?, mouse-down?, mouse-just-down?, mouse-right-down?, mouse-right-just-down?, mouse-captured?, key-down?, and key-just-down?. capture-mouse and release-mouse hide and lock the cursor for first-person-style controls. These functions can be called either on the CPU or GPU and work the same in both cases. See the reference for more details on these functions.

Drawing with vertex data#

dispatch-render-shaders draws a number of vertices, and with a fourth argument, a number of instances of them (told apart by (instance-index)). In place of either count it can take an array, held by a GPU-bound variable: the array's length is the count, and its elements are vertex data, feeding the vertex shader's data parameters (the ones that aren't builtins) in order: the per-vertex array, then the per-instance array.

(struct Particle
  position: vec2f
  color: vec4f)

(var quad: [6: vec2f])
(var particles: [Particle])

@vertex
(defn particle-vertex [corner: vec2f p: Particle]: Varyings
  (Varyings (vec4f (+ p.position (* 0.01 corner)) 0. 1.) p.color))

@vertex
(defn pulled-vertex [@{builtin vertex-index} i: u32 p: Particle]: Varyings
  (Varyings (vec4f (+ p.position (* 0.01 (corner i))) 0. 1.) p.color))

; in the frame loop: 6 vertices from `quad`, an instance per particle
(dispatch-render-shaders particle-vertex shade quad particles)
; 6 counted vertices: `p`, the only data parameter, gets the instance data
(dispatch-render-shaders pulled-vertex shade 6u particles)

dispatch-indexed-render-shaders draws the vertices an index array ([u32] or [N: u32]) picks, in its order: (vertex-index) is the index read, and vertex data comes from that element. Its arguments are the shaders, the indices, then optionally vertex data, then optionally an instance count or instance data (instance data comes after vertex data). Indices let a mesh share vertices between triangles, which the GPU then transforms once.

The arrays stay ordinary variables, so CPU writes are uploaded before the draw, and a compute shader can write vertex data the next draw uses, with nothing copied or read back. One draw can't write a variable in its fragment shader while reading it as vertex data or indices.

Textures and Render targets#

Render dispatches draw to the window unless set-render-target points them at a texture. After you've set a target, you can call reset-render-target to send later draws back to the window. Textures come from blank-texture or load-image, later dispatches in the same frame can sample what was drawn, and save-png saves any texture, including one just rendered. A texture keeps what's drawn into it until clear-target clears it; the window starts each frame black. How draws blend, depth-test, and cull is set with the render settings.

(var canvas: (Texture2D f32))

@cpu
(defn main []
  (= canvas (blank-texture (vec2u 512u)))
  (spawn-window (fn []
                  (set-render-target canvas)
                  (clear-target (vec4f 0. 0. 0. 1.))
                  (dispatch-render-shaders vertex draw 3)
                  (reset-render-target)
                  (dispatch-render-shaders vertex show-canvas 3))))

Audio#

start-audio calls a function once per sample on a real-time audio thread. The function takes no arguments ((audio-time) gives the time in seconds) and returns a sample between -1. and 1.. It's usually a closure, whose variables carry state from one sample to the next:

(defn sine [freq: f32]: (Fn [] f32)
  (let [@var phase 0.]
    (fn []
      (= phase (% (+ phase (/ freq (sample-rate))) 1.))
      (sin (* 6.283185 phase)))))

@cpu
(defn main []
  (start-audio (sine 220.))
  (spawn-window (fn [] ())))

Open in the playground

(sample-rate) works anywhere. In audio code, (audio-time) is the stream's clock and (audio-input) the latest microphone or line-in sample. MIDI input works in audio, CPU, and shader code, and MIDI output in audio and CPU code.

Each call to start-audio hands over the function it's given, so a later call can switch to a different sound. Call it once, or when the sound should change, rather than every frame: calling it again with a freshly made closure starts that closure over.

Variables shared with audio#

A global used by both the audio function and the rest of the program is shared between them. Each thread works on its own copy, and they trade changes at the end of each window frame and each audio buffer. Within one frame, or one buffer, a shared variable doesn't change underneath you. When audio starts, it receives the current value of every shared variable, so a load-waved sample assigned beforehand is ready to play.

Sharing works both ways: a frequency set by the frame loop reaches the audio at its next buffer, and a level meter written by the audio reaches the frame loop at its next frame. Shaders take part through the frame loop.

Variables only one side uses aren't shared, and avoid all overhead associated with synchronization.

Runtime-sized arrays and strings#

In CPU and audio code, runtime-sized arrays ([f32], [Material], [[f32]]) are ordinary values: you can bind them, pass and return them, and store them in structs and enums. Copying one is cheap, and changing a copy never affects the original. push, insert, remove, concat, reverse, and subarray return new arrays, so growing one is (= arr (push arr x)). Shaders can use runtime-sized storage buffers, but can't create, pass, or return runtime-sized values.

Strings work in CPU code only. (string x) turns any value into one, and concat, substr, and length work on them.

String literals take the usual escapes: \n (newline), \t (tab), \r, \", and \\. A \ at the end of a line drops the line break and the whitespace that follows, so a long string can continue on the next line, indented to fit (the formatter lines such continuations up after the opening quote):

(print "a long message that \
        continues here")   ; "a long message that continues here"

(print x) prints any value: 1u for a u32, 1 for an i32, 2. for a whole f32, and strings in double quotes.

Embedding: @external variables#

(This section is mainly for rust developers interested in integrating easl into a larger project, you can safely ignore it if you're just using easl as an indepdent language)

A program hosted inside another application, such as a live-coding editor, can mark globals @external for the host to read and write while it runs:

@external (var gain: f32)

The host creates an easl::external::ExternalVars handle from the compiled program, passes it to the runner, and calls read_external_var and write_external_var (or the _index and _raw variants) from any thread. The host is one more participant in the sharing above: its writes arrive at the program's next frame or audio buffer, including on the GPU. An @external variable has no initial value; the host sets it before the program starts. A variable holding a String, or a runtime-sized array nested inside a struct or another array, has no flat word layout, so the host reaches it only through the Value methods, not the _raw ones.