5 Engines
dxtr edited this page 2026-10-19 19:36:27 +02:00

Engines

An engine runs a window, or an offscreen target, and the frame loop that draws into it, on threads of its own, so the REPL stays free; the triangle of Getting Started starts one.

(pyssel:start ...) opens a window and returns an engine once it runs. It takes these options:

  • :title, :width, :height: the window.
  • :display-name: a Wayland display other than the default.
  • :offscreen t: draw into a texture of :width by :height instead of a window. read-pixels returns what was drawn, for tests and screenshots.
  • :setup: called once with the engine, before the first frame.
  • :draw: called for each frame with the engine and a cl-gfx render pass over the window, cleared to :clear-color (red, green, blue and alpha, from 0 to 1).
  • :redraw: :continuous, the default, draws every frame, as a game does; :on-demand draws only when asked, as an editor does (see below).
  • :workers: how many threads the engine's job pool has, by default one for each processor beyond two.
  • :scale: for an offscreen engine, how many pixels it draws to a unit, such as 2 for a picture of what a display at 200% shows. A window's scale follows its display.
  • :frame-interval: for an offscreen engine, the seconds between its frames, 1/60 by default. A window's frames are paced by its display.
  • :validation: check every use of Vulkan with its validation layer.
  • :frames-in-flight: how many frames the GPU may work on at once.
  • :bindings: a list of (symbol value) that every thread of the engine binds for its whole life, its render thread, its event thread and its job pool's workers, as a game played in the editor's image binds *session* (see Sessions). The threads also take the standard output, error output, trace output and package of the thread that calls start, unless :bindings binds them, so the engine's output shows where you started it.

start declares each option's type, so a wrong one is refused as a type-error before anything starts, and at compile time where SBCL can tell. A clear colour is the type pyssel:clear-color: a list of red, green, blue and alpha, each from 0 to 1, as '(0 0 0 1).

Colours are sRGB, as image editors and CSS give them: '(0.5 0.5 0.5 1) is the grey #808080. As in Godot, they are stored and blended as the values they are, so half-transparent white over black is that same grey, as an image editor or a browser shows it. A window and an offscreen target both work this way, so read-pixels returns what a window would show.

Sizes are in units: pixels of a display at scale 1. On a scaled display, such as one at 150%, a window's frames have more pixels than units: engine-size is the size in units, engine-scale the pixels to a unit (3/2), and engine-pixel-size the size in pixels. When the window moves to a display of another scale, the engine draws a frame at the new one.

(pyssel:set-engine-size engine 1280 720) asks for another size. A window asks its window system, which may give it another size, and engine-size follows once a frame is drawn at it; while the window system sizes the window itself, as while it is maximised, nothing is asked and it returns nil. An offscreen target takes the size at the next frame.

Give setup and draw as symbols: then redefining the function changes what the next frame does. engine-frame-number, engine-time (in seconds, for animation) and engine-size describe a running engine; engine-job-pool is its cl-threads job pool. During draw, engine-frame and engine-frame-texture are the cl-gfx frame and the texture it draws into, and engine-frame-scale the scale it is drawn at. engine-window is the cl-windowing window it draws into, for what the engine leaves to the window, such as the pointer's cursor and input methods, or nil offscreen. with-engine starts an engine around a body and stops it afterwards.

If draw signals an error, the debugger opens on the render thread with the restarts skip-drawing, which finishes the frame without the rest of its drawing, and stop-engine. Fix draw at the REPL, choose skip-drawing, and the next frame uses the fix.

Drawing on demand

An engine started with :redraw :on-demand draws a frame only when something asks for one, and otherwise sleeps:

  • (request-redraw engine) asks for a frame, from any thread. Call it in draw to keep animating.
  • A redefined shader, and a window that changes size, ask by themselves.
  • (add-redraw-check engine function) has the engine call function at each sync point, and draw when it returns true. A sleeping engine looks every :idle-interval seconds, an option of start, 1/20 by default.

Frames and input

Before each frame it draws, an engine hands the input its window and its popups received, from the keyboard, the pointer and input methods, to the functions given to add-input-function, one cl-windowing event at a time, then calls those given to add-frame-function with the seconds since the previous frame, all on its render thread, at the sync point. This is where game logic runs; a scene tree uses both. Input asks an engine that draws on demand for a frame. The motions of one frame arrive as one, and a frame keeps at most +input-capacity+ events. remove-frame-function and remove-input-function take effect at once: a function that removes another, as a scene tree's that destroys another tree, keeps it from being called later in the same frame.

Stopping

stop, or closing the window, stops an engine and releases what it holds: its device and everything made from it, its render targets among them, its threads, its popups and its window. An engine started with :auto-accept-quit nil, as Godot's auto_accept_quit, does not stop as its window closes, but hands the closing to its input functions as a :close event, which a scene tree passes to its root's close-requested: for a program that asks first, as an editor asks whether to save. (stop engine :wait nil) asks it to stop and returns at once, for code that must not wait on an engine that may never stop, and wait-for-stop returns once an engine has stopped, whatever stopped it. Code that makes things for an engine, such as the 2D renderer, adds a function with add-stop-function, which the engine calls on its render thread as it stops, while the device is still open, newest first.

A system above the engine that keeps one object for each engine, as the 2D renderer keeps the glyphs an engine's renderers share, keeps it with (engine-cache engine key make): the object make returned the first time, under key, a symbol of the system's own. make may return as a second value a function that releases the object, which the engine calls on its render thread as it stops, after the stop functions, while the device is still open; the engine forgets what it kept then. make runs under a lock of the engine's, so it makes no GPU objects and waits for no other thread. Finding what was made before allocates nothing.

An engine stuck in a loop of game code never reaches its sync point, so it does not stop when asked. break-into-engine interrupts its render thread into the debugger, SLY's while it is connected, which shows the thread's stack and offers its restarts: stop-engine stops the engine, and continue lets it go on. Nothing is unwound until you choose; a restart that unwinds leaves any foreign code on the stack above the frame loop unfinished, holding what it held, so choose it when the stack shows Lisp code down to the frame loop.

Threads and the sync point

An engine runs on threads of its own, so the REPL stays free. The render thread makes every GPU call, and the event thread handles the window. The engine's device, and everything made from it, belong to the render thread: use them in setup and draw.

From any other thread, such as the REPL's, use call-at-sync-point. It runs a function on the render thread at the start of the next frame and returns its values, or signals its error, as for the triangle's engine:

(pyssel:call-at-sync-point *engine*
  (lambda () (gfx:device-name (pyssel:engine-device *engine*))))
;; => "llvmpipe (LLVM 21.1.8, 256 bits)", or your GPU's name

Never wait for the sync point on a worker of the engine's own job pool, such as in a 2D canvas's draw function: the render thread may be waiting for that worker, so it would never reach the sync point. There call-at-sync-point signals a pyssel-error rather than wait forever, and so do the functions that wait for it, such as make-pipeline, make-render-target, read-pixels and pyssel/2d's make-renderer and destroy-canvas. A worker of another engine's pool may call it.

Errors

The errors Pyssel signals itself, in each of its systems, are of the type pyssel:pyssel-error, whose message, pyssel-error-message, says what went wrong and what to do about it. An argument of the wrong type is a type-error instead.

(pyssel:signal-pyssel-error control &rest arguments) signals a pyssel-error whose message is the format control string control applied to arguments, as format applies it. Pyssel's systems report their errors with it, and a game may too, to report an error in Pyssel's terms, which a handler of pyssel-error takes as it takes the engine's own:

(pyssel:signal-pyssel-error "The level ~S has no start." "moon")
;; signals a pyssel-error: The level "moon" has no start.