Ollin

Sketch

A sketch is a subclass of Sketch. You override setup() and draw(), then call the bare drawing functions inside them. The loop runs draw() continuously at the display's refresh rate, so motion is the default. Temporal state such as time and frameCount is ready to read with no setup.

Example

import Ollin

@main
final class HelloCircle: Sketch {
    override func draw() {
        background(.white)
        drawCircle(width / 2, height / 2, 120 + sin(time) * 40)
    }
}

Contents

  • Lifecycle - setup, draw, mousePressed/mouseReleased, keyPressed/keyReleased, filesDropped, filesChosen, reloaded, and a file that will not load (FileError)
  • Temporal state - frameCount, time, deltaTime, frameRate
  • Canvas - width, height, canvasOnScreen, screenFrame
  • Loop control - noLoop, loop, isLooping, redraw, and isExporting
  • Extensions - extend, and writing a SketchExtension
  • Configuration - title, canvasSize, windowMode, loopDuration, installation
  • Running a sketch

Canvas sizing, scale, export resolution, and the preview window have their own page, Canvas.

Lifecycle

Override these on your subclass.

setup

setup() throws

Ollin calls this once, after the canvas size is known and before the first draw(). Overriding it is optional, and an override may leave throws off. It may throw: a loader that cannot read its file lets its FileError out with a bare try (see A file that will not load), a window reports the sentence the way the live hosts report a compile error and holds the sketch rather than drawing it half built, and a headless export fails with it.

override func setup() {
    noLoop()   // render a single still frame
}
A file that will not load

setup() is where a sketch loads what it draws, and every loader that reads a file, a bundled resource, or bytes throws a FileError when it cannot: loadImage, loadMesh, loadScene, loadSVG, loadPalette, loadTable, loadJSON, and the initializers under them, the fonts, IES and ICC profiles, and ComputeKernel. The mesh and scene writers throw one too. setup() does not throw, so choose one of two answers for each file:

final class Poster: Sketch {
    var photo: Image!
    var logo: SVG?

    override func setup() throws {
        photo = try loadImage("photo.jpg")    // stop, naming the file and why
        logo = try? loadSVG("logo.svg")       // carry on without it
    }
}

setup() may throw, so a bare try is the whole of it: the sketch stops there with the loader's sentence. In a window the sentence goes to stderr and, under OllinLive or the live-coding stage, into the error strip the way a compile error does, and the window stays open on a held sketch; a headless export fails with it. try? is the other shape, for an asset the sketch can do without.

A FileError says which file, what kind of trouble, and what was wrong, in a sentence:

  • kind is .missing (no file at that path, or no resource by that name in the bundle), .unreadable (the file is there, but its bytes are not what the loader reads), or .unwritable (a writer could not write it).
  • path is the file's path or the resource's name, and nil for bytes handed over in memory.
  • problem is the sentence, and description puts the two together: photo.jpg: no such file.

A loader of your own can throw the same shape: FileError(.unreadable, path: path, problem: "holds no rows").

draw

draw()

Ollin calls this every frame. Do your drawing here.

override func draw() {
    background(.white)
    drawCircle(width / 2, height / 2, 100)
}

mousePressed / mouseReleased

mousePressed()
mouseReleased()

Ollin calls these once each time a mouse button is pressed or released over the canvas. To respond continuously while the button is held, poll mouseIsPressed in draw() instead. See Input.

override func mousePressed() {
    randomSeed(frameCount)   // re-roll on click
}

keyPressed / keyReleased

keyPressed()
keyReleased()

Ollin calls these once each time a key is pressed or released. key and keyCode hold the key. To keep something moving while a key is held, poll isKeyDown(_:) in draw() instead. See Input.

override func keyPressed() {
    if key == " " { noLoop() }   // space pauses
}

filesDropped

filesDropped()

Ollin calls this once each time files are dropped on the window. droppedFiles() holds their paths and mouseX/mouseY where they landed. To pick them up later instead, poll droppedFiles() in draw(). See Input.

override func filesDropped() {
    for path in droppedFiles() { photo = (try? loadImage(path)) ?? photo }
}

filesChosen

filesChosen()

Ollin calls this once each time the open panel that chooseFiles(...) showed closes on a pick. chosenFiles() holds the paths. See Input.

reloaded

reloaded()

Ollin calls this once after the live-reload host hot-swaps the sketch, right after the new sketch's setup(). When the live-coding host carries the run across an edit, setup() is skipped and this runs on the first frame instead. It never runs on first launch. See live reload.

Temporal state

These properties are read-only, and they are ready in any sketch with no setup:

PropertyTypeMeaning
deltaTimeDoubleseconds since the previous frame
frameCountIntframes drawn so far (1 during the first draw())
frameRateDoublesmoothed frames per second
timeDoubleseconds since the sketch started
let r = 120 + sin(time) * 40            // animate against the clock
drawCircle(width / 2, height / 2, r)

When a frame takes longer than one display refresh, Ollin drops the refreshes it cannot serve rather than queueing them. It does not call draw() for a dropped refresh, so frameCount counts only the frames drawn, and deltaTime reports the real gap between them. The window keeps answering the mouse while the picture runs slow, and motion scaled by deltaTime still runs at the right speed.

Three dotted strips comparing one second of motion: a fixed per-frame step at 60 fps, the same step at 120 fps reaching twice as far, and a deltaTime-scaled step landing back in line

For repeating motion on a fixed period, loopProgress(over:) and pingPong(over:) wrap the clock into looping 0...1 progress.

Beside the clock, the Int property variation names the seed this run's randomness came from. Ollin rolls it fresh unless the sketch calls seed(_:). It is also the value the inspector's Variation card steps through, and the value every export's recipe records. See Variations.

Canvas

The canvas coordinate system: origin at the top left, x right, y down, with the point (380, 240) marked
PropertyTypeMeaning
width / heightDoublecanvas size in logical points, updated live on resize
centerVector2the canvas middle, Vector2(width / 2, height / 2), for the calls that take a point
canvasOnScreenRectangle?where this canvas sits on the desk, in screen points, or nil with no window
screenFrameRectangle?the screen it sits on, in the same coordinates
drawCircle(width / 2, height / 2, min(width, height) / 4)   // centered, proportional
drawCircle(center: center, radius: min(width, height) / 4)  // the same, through the value

canvasOnScreen is measured the same way the canvas is. The origin is the top-left corner of the main screen, and y grows downward. Every window on the desk reports its place in those same numbers, so several windows can show one shared world.

The rectangle covers the canvas alone, so a title bar or a sidebar falls outside it. It follows the window, which means a drag shows up in the next frame. An export has no window, so both properties read nil there. The Installation/ManyWindows example is built on these two properties.

To keep a sketch looking the same at every canvas size, write it relative to the canvas with scale and fractions of width and height. The Canvas page covers that, along with the canvasSize export presets and the preview window. For how this coordinate frame compares with the others a sketch meets, see Where a point is.

Loop control

noLoop / loop

noLoop()
loop()

noLoop() stops the continuous draw loop and loop() resumes it. isLooping reads the current state. Motion is on by default, so call noLoop() when you want a still image.

override func setup() {
    noLoop()   // one frame, then hold
}

Every export holds the frame the same way the window does. Called in setup(), the first frame is the picture, whatever frame the export asks for. Called inside a later draw(), that frame is the picture from then on. A still export writes it, and a video, a GIF, or a PNG sequence writes it for every frame after the loop stopped. Nothing is drawn again. So a draw that rolls the sketch's dice exports the picture the window shows, and a slow still costs one draw, not one per frame. A replayed take is the exception. A take is written down only on frames the window drew, so its replay draws every frame it holds.

redraw

redraw()

Draws one more frame of a sketch that has stopped looping, then holds again. An input hook is the usual caller. A still sketch that changes on a click or a key asks for the one frame that shows the change, and costs nothing between them. Several calls before that frame is drawn ask for it once. While the loop runs it does nothing, since the next frame is coming anyway. Inside setup() or draw() it does nothing either, because that frame is the one being drawn. An export ignores it, because nothing in an export asks for a frame. It holds the last frame drawn, and a replayed take draws every frame the window drew.

var marks: [Vector2] = []

override func setup() { noLoop() }

override func mousePressed() {
    marks.append(mouse)
    redraw()   // one frame shows the new mark
}

override func draw() {
    background(.white)
    for mark in marks { drawCircle(center: mark, radius: 12) }
}

isExporting

var isExporting: Bool { get }

True for the whole of an export: setup(), the frames run up to the first one written, and every frame written. That holds for every kind of export: a picture, a sequence, a video, a GIF, a vector file, a web page, and a widget's frames. It is false in a window, and false in a --bench run, whose frames stand in for a window's.

A sketch that spreads slow work over live frames reads it to do all of that work at once in an export. A written frame cannot be improved by the frames after it. This one places two hundred marks a frame in a window, so the frames stay smooth, and every mark at once in an export:

var pending: [Vector2] = []   // marks still to be placed
var placed: [Vector2] = []

override func draw() {
    let batch = isExporting ? pending.count : 200
    placed += pending.prefix(batch)
    pending.removeFirst(min(batch, pending.count))
    for mark in placed { drawCircle(center: mark, radius: 2) }
}

isVectorExporting is the narrower question, true only while a frame is recorded as vector line work (see G-code).

Extensions

extend(_:) registers a SketchExtension. That is a reusable object whose hooks the loop calls around each frame. Behavior that spans a whole sketch, such as overlays, guides, and recorders, then lives outside draw(). Every hook is optional:

HookWhenFor
setup(_:)once, after the sketch's setup()one-time prep
beforeDraw(_:)each frame, before draw()set up per-frame state
afterDraw(_:)each frame, after draw(), before the renderdraw over the sketch through the bare API
afterFrame(_:_:)each frame, after the render, with FrameInfo timing; in an export too, once per rendered frame, with the export clock's numbersobserve (fps, frame time, geometry counts) without drawing
frameRendered(_:image:)each frame, after the render, with the rendered CGImagegrab the rendered pixels to save, record, or snapshot
final class Guides: SketchExtension {
    func afterDraw(_ sketch: Sketch) {
        sketch.withState {
            sketch.stroke(Color(white: 0, alpha: 0.25))
            sketch.drawLine(sketch.width / 2, 0, sketch.width / 2, sketch.height)
        }
    }
}

final class MySketch: Sketch {
    override func setup() { extend(Guides()) }
}

Extensions belong to one sketch instance, so register them in setup(). A fresh instance starts with none, and each live-reload swap makes a fresh instance. Examples/Basic/Guides is a worked example.

frameRendered(_:image:) hands over the rendered frame as a CGImage. Reading those pixels back from the GPU to the CPU costs time, so the hook stays off by default. An extension opts in by returning true from wantsRenderedFrame. Ollin reads that property every frame, so an extension can arm and disarm capture while the sketch runs. Examples/Export/Capture uses it to save a frame to a PNG on a keypress. For a one-off capture without a window, OllinApp.image(of: sketch, frame:) renders a sketch headlessly and returns the CGImage directly. That is the same capture --export writes to disk, and the one the render-correctness snapshot tests compare against committed references.

Configuration

Override these on your subclass to change the window and the size.

title

title: String

The window title. It defaults to "Ollin - <SketchType>", for example "Ollin - HelloCircle".

override var title: String { "Flow field" }

canvasSize / windowMode

canvasSize: CanvasSize
windowMode: WindowMode

canvasSize sets the resolution used to render and to export, and windowMode sets how the preview window behaves. The Canvas page documents both, with the presets and the modes.

override var canvasSize: CanvasSize { .uhd4K }        // 4K master
override var canvasSize: CanvasSize { .square(1000) } // a custom square
override var windowMode: WindowMode { .fixed(0.5) }   // preview at half size

loopDuration

loopDuration: Double?

The length of the sketch's loop in seconds. The default is nil, which means the sketch declares no loop. If the motion repeats exactly, declare its period here, and --export-loop then renders exactly one lap for a seamless GIF or video. See perfect loops.

override var loopDuration: Double? { 6 }   // repeats every 6 seconds

installation

installation: Installation

What the sketch needs to run by itself for days. The default is .off, which suits a sketch you run at a desk. .on fills the screen, hides the pointer, and keeps the display awake. Add checkpoint: and Ollin writes down every @Saved property, so a relaunch resumes where the run left off. Add restarts: and Ollin starts the run again if it crashes or stops answering. Add schedule: to set the hours the sketch is on screen, and it reads back the parts of the day with scheduledPeriod. Add displays: and one canvas spans every display the machine drives. See running unattended.

override var installation: Installation { .on }

Running a sketch

OllinApp.run(MySketch()) opens a window and runs the sketch. With @main on the subclass, the inherited Sketch.main() does that for you, so a single file is the whole program. To iterate with live reload, run the sketch through the host instead with swift run OllinLive path/to/Sketch.swift, described under live reload.

Any sketch can also render headlessly. --export writes a single frame (--frame N picks it, or --at S names the moment in seconds, so nobody multiplies by the frame rate), and --export-exr writes that frame in linear light for a compositor. --export-sequence <dir> (--frames N | --seconds D) [--fps F] [--skip S] writes a deterministic numbered PNG sequence that assembles into a video, rendered at a fixed timestep. --skip S runs the sketch for a while first, so a stateful sketch settles before capture. See export.

The same flags work on a loose watched file through the live host, as in swift run OllinLive path/to/Sketch.swift --export-gif loop.gif --seconds 4. That command compiles the file and exports instead of opening a window. The full flag set covers video, GIF, perfect loops, SVG, and render quality, and it is listed in Export.

↑↓ move ↩ open