4 Resizing and Scaling
dxtr edited this page 2026-10-14 04:26:09 +02:00

Resizing and Scaling

If you draw with cl-gfx, through cl-gfx/windowing, its surfaces do everything on this page for you. Read on to understand what they do, to size your own render targets, or to draw with Vulkan directly.

Units and pixels

A window has a size in units and a scale.

  • Units are pixels at scale 1. window-size and pointer positions are in units. A window of 800 by 600 units covers the same part of the screen on every display, whatever its density.
  • The scale is how many pixels a frame has per unit on the display the window is on: 1 on an ordinary display, 2 on a high-density one, 3/2 on a display scaled to 150%. It is an exact rational, of type scale, so sizes in pixels follow from it without rounding errors creeping in.
  • The pixel size, window-pixel-size, is the size times the scale: the size of the frames to draw. 800 by 600 units at scale 3/2 is 1200 by 900 pixels. Before the window system has given the window its first size, nothing may be drawn, and the pixel size is 0 by 0.

Pixel sizes are rounded to the nearest whole pixel, halves up, as Wayland rounds them.

To look the same size on every display, draw in units and multiply by the scale when you turn them into pixels. A 12-unit font is drawn at (* 12 (win:window-scale window)) pixels: 12 at scale 1, 18 at scale 3/2, 24 at scale 2. Its edges stay sharp, because it is drawn at the display's own pixels instead of being stretched.

The resize handshake

The window system may propose a new size at any time: when the user drags an edge, maximises the window, or tiles it. It may also propose a new scale, when the window moves to another display or the user changes the display's scaling.

The window does not take a proposal at once. It goes through a handshake with the code that renders:

  1. The window system proposes a size, a scale, or both.
  2. The proposal becomes the window's pending size. A :resize event announces it, with event-width, event-height and event-scale.
  3. The code that renders sees the pending size, and recreates its swapchain and its other render targets at the pending pixel size.
  4. Just before it presents the first frame drawn at the new size, it calls adopt-window-size.
  5. The window takes the size, and the window system is told that the next frame follows its proposal. On Wayland, this is when xdg_surface.ack_configure is sent.

So the window system never sees a frame whose size disagrees with what it was told, and a window being resized does not flicker or stretch.

Some proposals need no handshake, and are taken at once:

  • the first size, which arrives before anything has been drawn. It still sends a :resize event;
  • a proposal that keeps the window's size and scale.

If a proposal leaves out a dimension, as Wayland does with 0, the window keeps its own.

If nothing calls adopt-window-size, the window keeps its old size and scale, and its frames stay at the old size. Nothing breaks, but the window does not follow the user.

The functions

Function Returns
(window-size window) The width and height in units, as last adopted.
(window-scale window) The scale, as last adopted.
(window-pixel-size window) The width and height in pixels: size times scale; 0 and 0 before the first size.
(window-pending-size window) The proposed width and height in units and scale, or nil, nil, nil when nothing is pending.
(window-pending-pixel-size window) The proposed width and height in pixels, or nil, nil.
(adopt-window-size window) Takes the pending size and scale, if any, and returns the width and height in units.

The pending functions read the size and the scale together under the window system's lock, so they always describe one proposal. They are cheap and allocate nothing: call them every frame.

adopt-window-size with nothing pending does nothing and returns the size, so it is safe to call before every present.

In a render loop

Each frame, on the render thread:

(multiple-value-bind (pending-width pending-height) (win:window-pending-pixel-size window)
  (let ((adopt (and pending-width t)))
    (when adopt
      (recreate-swapchain pending-width pending-height))
    (draw-frame)
    (when adopt
      ;; The frame is drawn at the new size: let the window system know.
      (win:adopt-window-size window))
    (present)))

Only recreate the swapchain when the pixel size changed: a proposal may change the size in units and the scale while the pixel size stays the same. Recreate it too when Vulkan reports it out of date.

While a size is pending, draw at the pending size and scale. Code that lays things out in units should use the pending scale for that frame:

(or (nth-value 2 (win:window-pending-size window))
    (win:window-scale window))

Your own render targets

cl-gfx recreates its swapchain for you, but not your depth buffers, offscreen targets or anything else sized to the window. Compare the size of the surface texture with theirs every frame and recreate them when they differ. See cl-gfx's Surfaces and Windows.

Asking for a size

The program asks for a size with set-window-size, in units, and the size comes the same way as one the window system proposes, with a :resize event, through the handshake:

(win:set-window-size window 1280 720)
;; => T

window-size stays the old size until the new one is adopted. The window system may give the window another size, as one that fits the screen. While it sizes the window itself, nothing is asked and set-window-size returns nil:

  • on Windows, while the window is maximised, minimised or fullscreen;
  • on Wayland, while it is maximised, fullscreen or tiled, or the user is resizing it.

Until the window system has proposed the window's first size, the size asked for replaces create-window's. On Wayland a window sizes itself by drawing at the size, which xdg-shell allows, so a configure that comes before the compositor has seen a frame at it, and proposes the old size, does not undo it. A popup keeps its placement, as reposition-popup with only a size places it.

A fullscreen window is sized by the window system; see Window Systems and Windows.

The :resize event

The :resize event is a notification: use it to schedule a redraw when you draw only on demand, or to relayout. Do not use its fields as the window's size. They describe one proposal, and the window system may send another before you redraw. The window's pending size always holds the latest.

Scaling on Wayland

Where the scale comes from depends on what the compositor offers:

The compositor offers Scales Notes
wp_fractional_scale_v1 and wp_viewporter Any multiple of 1/120, such as 3/2 or 5/4 :fractional-scale is in window-system-capabilities. Frames are drawn at the pixel size and shown at the window's size through a viewport.
wl_surface version 6 or later Whole numbers The compositor's preferred buffer scale for the window.
Neither Whole numbers The largest scale of the outputs the window is on, displays plugged in later included. Weston 16 is such a compositor.

Which compositors offer what is on Wayland Backend.

On a compositor of the last kind, a window is on an output only once it has been drawn. Its first frame goes out at scale 1, and the right scale follows as a :resize event.

Scaling on Windows

A window's scale is the DPI of the display it is on over 96: 3/2 at 150%. Windows sizes windows in pixels, so a window's size in its coordinates is its pixel size over its scale, rounded. At a fractional scale, window-pixel-size can then differ from the window's real pixel size by a pixel, which the swapchain takes from Windows. A window moved to a display of another scale keeps its size in its coordinates, and the new scale comes as a :resize event. :fractional-scale is always in window-system-capabilities.

In the Pyssel engine

Pyssel works in units throughout. engine-size is in units, engine-scale and engine-pixel-size say the rest, and the 2D renderer turns units into pixels with the display's scale, so its anti-aliasing works in pixels and edges stay sharp.