9 2D Drawing
dxtr edited this page 2026-10-20 10:09:08 +02:00

2D Drawing

The system pyssel/2d draws rectangles, rounded rectangles, circles, lines, textures and text, in units from the top left of the window, so they keep their size on a scaled display, where edges stay sharp at the display's pixels:

(asdf:load-system :pyssel/2d)

(defpackage #:shapes
  (:use #:cl)
  (:local-nicknames (#:p2d #:pyssel/2d) (#:tr #:cl-textrender)))
(in-package #:shapes)

(defvar *renderer*)
(defvar *font* (tr:make-font "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" :size 14))

(defun scene (canvas)
  (p2d:fill-rect canvas 40 40 240 120 '(0.13 0.15 0.2) :radius 12)
  (p2d:stroke-rect canvas 40 40 240 120 '(0.4 0.6 1) :thickness 2 :radius 12)
  (p2d:fill-circle canvas 100 100 30 '(1 0.6 0.2))
  (p2d:draw-line canvas 150 80 250 120 '(1 1 1 0.8) :thickness 3)
  (p2d:draw-text canvas "Hello, Pyssel" 150 130 *font* '(1 1 1)))

(defun setup (engine)
  (setf *renderer* (p2d:make-renderer engine))
  (p2d:make-canvas *renderer* 'scene))

(defun draw (engine pass)
  (declare (ignore engine))
  (p2d:draw-canvases pass *renderer*))

(defvar *engine* (pyssel:start :title "Shapes" :width 320 :height 200 :redraw :on-demand
                               :setup 'setup :draw 'draw))

The example keeps its renderer and its font in variables, as the triangle of Getting Started keeps its pipeline: drawing with pyssel/2d itself, it has no node to keep them in. A game draws from canvas items, which keep their own canvases.

A renderer draws canvases. A canvas's draw function, here scene, fills it; it runs when the canvas is first drawn and again after queue-redraw, and otherwise the canvas keeps what it drew as a recorded render bundle. Since scene is given as a symbol, redefining it at the REPL redraws the canvas. With :redraw :on-demand the engine draws nothing at all while nothing changes. Given :x, :y, :width and :height, draw-canvases draws a renderer's canvases into that rectangle of the target as if it were the whole target, cut off at its edges, as a popup drawn inside its window is. Given :transform, a cl-gmath dmat3x2, it draws them through it, as a viewport's canvas transform zooms and pans and a canvas layer's turns: a point x, y of the renderer's coordinates, where each canvas's placement puts what it draws, is drawn where the transform takes it, in the target's units, clips and all, each clip within the box around it so taken. :scale is the zoom they record for: canvases record for the display's scale times it, so a new scale draws again those that depend on it, such as raster text, which stays sharp at every zoom; give the scale the transform zooms by. A new transform records nothing, however far it moves, turns or stretches them, a few hundred thousand units as readily as one.

A canvas's placement, canvas-placement, a cl-gmath dmat3x2 that setf sets and the canvas keeps a copy of, takes the coordinates its draw function draws in to the renderer's, as a 2D node's global transform places what the node draws: the identity by default. It too is applied as the canvas is drawn, so a canvas that moves, turns or stretches every frame records nothing again; only what was drawn on the pixels is, when they move: what was drawn for the scale, as a polygon's anti-aliased edges or an image rasterized for it, when the placement turns or stretches the canvas, and what was drawn on the target's pixels, as upright raster text and what snap-to-pixel snapped, also when it moves the canvas by other than whole pixels. The renderer works out each canvas's way to the target's pixels, its transform after its placement, in double-floats each frame, and gives the GPU single-floats near the view, so a canvas placed a million units out, under a transform that pans there, is drawn as precisely as one near the origin, as Godot's 2D renderer gives the GPU its items' final transforms.

prepare-canvases takes the same arguments, but draws nothing: it does the frame's work before drawing, running the due canvases' draw functions, uploading what they recorded and bringing the painters' data up to date, and returns true if what draw-canvases would draw differs from what the canvases were last prepared to draw: a canvas drawn anew, made, destroyed, moved to another layer or clipped anew, a pipeline or bind group made anew, a painter's data changed, a render target they draw drawn anew, or another texture, transform, scale or rectangle. draw-canvases with the same arguments later in the frame draws what was prepared. A render target's :prepare calls it, so that the target is drawn only when its canvases changed; it also draws first the render targets those canvases draw.

  • Shapes: fill-rect and stroke-rect, both with :radius for rounded corners, and stroke-rect with :thickness, or :left, :top, :right and :bottom for each side; fill-circle and stroke-circle; draw-line; and draw-texture, which draws a texture, or a region of it, tinted and filtered as asked, or a render target of the engine's, a texel to each of its pixels, as it was drawn into that frame and from its new texture once it is resized. Edges are anti-aliased.

  • Polygons: (p2d:fill-polygon canvas '(0 0 10 0 5 8) color) fills the polygon whose corners are given, x and y of each in turn, a list or a vector of reals, as Godot's draw_colored_polygon does: a simple polygon, convex or not, its corners in either order. It is cut into triangles on the CPU and drawn by a painter of its own, polygon-painter; its outline is anti-aliased over a pixel, half in and half out, as the other shapes' edges are, unless :antialiased is nil, and a canvas that fills polygons so is drawn again when its scale changes. A polygon whose sides cross fills as its triangles fall, as in Godot; fewer than three corners, or one that cannot be cut into triangles, as a figure of eight, is an error. A canvas that fills a polygon every frame allocates nothing once it has filled one with as many corners.

  • Images: (p2d:make-image width height pixels) makes an image of pixels in memory, four octets each, red, green, blue and alpha, rows top first, as cl-svgread's rasterizer gives them, and draw-image draws it, or a region of it, as draw-texture draws a texture. Its :scale, 1 unless given, is how many of its pixels make a unit: an image rasterized at a display's scale has that scale, so it is drawn and laid out at its size in units, image-size, sharp. The renderer uploads an image to a texture the first time a canvas draws it, keeps the texture with the image, as a font keeps its glyphs', and frees it once the collector takes the image, so an image needs no destroying. No side may be longer than 8192 pixels, the largest texture every device holds. An image drawn from a source, as an icon from its SVG file, may be given :rasterize, a function of a scale that returns its pixels drawn anew at that scale, with their width and height, or nil where it cannot be drawn there. Drawn on a canvas recorded at another scale than its own, as in a viewport zoomed in, it is then drawn from a copy of itself rasterized at that scale, into the same rectangle, so it stays sharp at every zoom, as Godot's DPITexture draws its SVG at each viewport's oversampling; the canvas is drawn again when its scale changes, and a copy goes once no canvas draws it. An image made from pixels alone is scaled.

  • Transforms: translate, rotate and scale change where what follows is drawn, and transform applies any 2D affine transform, a cl-gmath mat3x2; with-saved-transform keeps them to its body. snap-to-pixel moves the origin to the nearest pixel, so that what is drawn whole units from it has sharp edges, as a user interface wants.

  • Colours are sRGB, as (red green blue) or (red green blue alpha), each from 0 to 1, or a vector of them, tinted and blended as those values, as in Godot: the type p2d:color, which every drawing function declares, so a wrong colour is refused as a type-error; colorp tells a colour. Textures are cl-gfx textures with :sampled in their usage. A plain format such as :rgba8-unorm holds an image's bytes as image files do; an sRGB format such as :rgba8-srgb draws the same.

  • Text: draw-text lays out a string in a cl-textrender font, wrapping it to :width if given, and draws it with its top left at a point; draw-layout draws text laid out already with tr:layout-text, which is what to do for text drawn often. Both take :painter: :raster, the default, :slug or :sdf, and the effects :outline-width, :outline-color, :shadow-x, :shadow-y, :shadow-softness and :shadow-color, which draw with :sdf. draw-layout also takes :colors, a function of a glyph's index in the layout's text that gives the glyph's colour, or nil for the layout's, so that one layout draws in several colours, as a code editor's highlighting does:

    (p2d:draw-layout canvas layout 4 6 '(1 1 1)
                     :colors (lambda (index) (if (< index 5) '(1 0.5 0) nil)))
    
  • Canvases of a lower :layer are drawn first. Within a canvas, shapes are drawn in order, and neighbouring shapes that read the same texture are drawn together in one call.

  • (p2d:set-canvas-clip canvas x y width height) draws a canvas only within a rectangle of the renderer's coordinates, where placements put canvases, the window's units unless :transform takes them elsewhere, kept in double-floats; its edges are widened to whole pixels. clear-canvas-clip draws all of it again. A clip records nothing again, so it may change every frame.

  • (p2d:canvas-bounds canvas) gives the box around what a canvas draws, its x, y, width and height in the coordinates its draw function draws in, wherever its placement puts it, each nil for a canvas that draws nothing, as an editor picks what it shows: a 2D node's own, so the box of a turned node turns with it. Given a cl-gmath dmat3x2, it gives the box in the coordinates that takes those to, such as the renderer's through the canvas's placement. It is the box of what the canvas drew last, text's outlines and shadows included, found from its recording when it is asked for, from any thread, so recording does no more work for it.

With :painter :raster, text is drawn from a glyph atlas the font keeps: each glyph is rasterized once, by FreeType, at the display's scale and hinted to its pixels, then packed into a texture, so text is as sharp as the CPU rasterizer draws it. A canvas with such text is drawn again when the display's scale changes. Under a transform that turns or scales, glyphs are turned and scaled as images. This suits small, upright text, such as a user interface's.

An engine's renderers share these glyphs, as Godot's viewports share their fonts' caches: a popup drawn at the window's scale rasterizes nothing the window has, and draws from the same textures. Glyphs are kept by scale, the display's times the :scale draw-canvases is given, as a viewport's zoom: a renderer holds the scale it draws at, and so does each canvas's drawing that holds raster text, and once nothing holds a scale, its glyphs go, textures and all, as Godot 4.5 frees an oversampling level's glyphs with its last reference. A view zoomed in steps so keeps the glyphs of the zoom it shows only. A canvas whose draw function failed keeps what it drew before, and the glyphs of the scale it was drawn at with it.

With :painter :slug, glyphs are drawn from their curves on the GPU by the Slug algorithm, through cl-slug: sharp at any size, scale or turn, but not hinted. This suits zoomed, turned or large text:

(defun title (canvas)
  (p2d:translate canvas 160 100)
  (p2d:rotate canvas -0.2)
  (p2d:scale canvas 3)
  (p2d:draw-text canvas "Pyssel" -40 -10 *font* '(1 0.8 0.3) :painter :slug))

Each glyph's curves are added once to its face's curve and band textures, which grow as needed.

With :painter :sdf, glyphs are drawn from multi-channel distance fields, which cl-sdf makes and cl-textrender/sdf packs into an atlas: sharp over a range of sizes, not hinted, and with outlines, shadows and glows. Asking for any effect draws with :sdf:

(defvar *title-font* (tr:make-font "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
                                   :size 32))

(defun banner (canvas)
  (p2d:draw-text canvas "Game over" 40 70 *title-font* '(1 0.85 0.3)
                 :outline-width 2 :outline-color '(0.5 0.15 0)
                 :shadow-x 3 :shadow-y 4 :shadow-softness 4 :shadow-color '(0 0 0 0.6)))

;; In SETUP, before the text is first drawn:
;; (p2d:prepare-glyphs *renderer* *title-font* "Game over")

:outline-width outlines the glyphs that far outside their edges, in :outline-color, black by default. Any of the :shadow- arguments draws a shadow: the glyphs and their outline moved :shadow-x right and :shadow-y down, fading out over :shadow-softness, in :shadow-color, black by default. A glow is a shadow that is not moved. Lengths are in canvas units, so effects scale and turn with the text, and the outline's width and the shadow's softness together reach at most a quarter of the font size. A layout's shadows are drawn before its glyphs, so no shadow covers a neighbouring glyph.

A glyph's field takes milliseconds to make, so it is made on the engine's job pool the first time any text draws it, never in a draw function: text whose fields are not made yet is drawn rasterized meanwhile, or, with effects, which only fields draw, left out, and its canvas is drawn again from the fields when they are made, a few frames later. prepare-glyphs makes the fields of a string's glyphs ahead, so text known early, such as a game's interface, is drawn from them at once; glyphs-pending-p says whether any are still being made. One field serves every size of a face: the fonts tr:face-font gives of one face share them.

Fonts keep what the painters make for them, as Godot's fonts keep their glyph textures: the glyph atlases, the fields and the curves, and the GPU textures they are uploaded to. A canvas that draws a font keeps the font, and the collector frees it, its textures with it, once no canvas draws it and nothing else holds it; the textures are destroyed on the render thread. A font that outlives a renderer, as a theme's outlives an engine, keeps nothing of it once the renderer is released, which destroys the textures it made, and an image that outlives one keeps nothing of it either. Text in different fonts or sizes reads different textures, so it does not share a draw call.