Stop paying for pixel format conversion with cairo_surface_create_similar
Drawing directly to a window surface can be slow because of per-pixel format conversion. A similar off-screen surface matches the target format, so you draw once and blit once.
22 Sept 2026, 20:43 UTC

Drawing straight to a window surface feels fast until you animate on a high-resolution display. The stutter is usually not the shapes you draw — it is the per-pixel format translation Cairo performs when your drawing commands have to land in whatever pixel format the display backend expects. The practical fix is to draw off-screen into a surface that already matches the target, then blit once.
cairo_surface_create_similar is the API that gives you that format match.
The stutter is format translation, not drawing
A Cairo surface is a drawing target with a pixel format and a memory layout. A backend surface — an X11 window, a Wayland buffer, a Win32 surface — is owned by the windowing system and often uses a device-specific layout.
When you draw directly to the backend surface, Cairo may translate each operation into that device format. When you draw to an off-screen buffer whose format differs and then copy it, Cairo pays for a conversion during the copy. On large surfaces that conversion can dominate CPU time and show up as dropped frames.
What cairo_surface_create_similar guarantees
The signature is cairo_surface_create_similar(target, content, width, height). It returns a new surface that shares the same surface type and target as target. The property that matters here is format matching: the new surface is created with the same pixel format and memory layout as the surface you passed in.
Drawing into that similar surface therefore uses the representation the target already expects, so copying it back can be a straight memory blit rather than a per-pixel conversion. The function does not promise zero-copy — only format compatibility, which is what makes the blit cheap.
Off-screen buffer pattern with a similar surface
The usual double-buffer pattern is: create one similar surface, keep it for the lifetime of the window, draw the frame into it, then paint it onto the screen surface.
/* target is the backend surface your toolkit gives you, e.g. a window surface */
cairo_surface_t *target = ...;
int w = cairo_image_surface_get_width(target);
int h = cairo_image_surface_get_height(target);
/* Off-screen buffer that matches the target's format */
cairo_surface_t *buffer = cairo_surface_create_similar(
target, CAIRO_CONTENT_COLOR_ALPHA, w, h);
if (cairo_surface_status(buffer) != CAIRO_STATUS_SUCCESS) {
/* handle creation failure before using the surface */
}
cairo_t *cr_buf = cairo_create(buffer);
cairo_t *cr_target = cairo_create(target);
/* Per frame: clear and draw the scene into the buffer */
cairo_set_source_rgba(cr_buf, 1, 1, 1, 1);
cairo_paint(cr_buf);
cairo_rectangle(cr_buf, 10, 10, 100, 50);
cairo_set_source_rgb(cr_buf, 0.2, 0.6, 0.9);
cairo_fill(cr_buf);
/* One blit to the screen */
cairo_set_source_surface(cr_target, buffer, 0, 0);
cairo_paint(cr_target);
/* Cleanup */
cairo_destroy(cr_buf);
cairo_destroy(cr_target);
cairo_surface_destroy(buffer);
Compile on a development host with the Cairo development package installed, for example gcc -o demo demo.c $(pkg-config --cflags --libs cairo). You need ordinary user write access to the output directory; no elevated permissions are required. The example above is illustrative and has not been run as part of writing this post — treat it as a starting skeleton and verify it against your own toolkit's surface type.
Two checks are worth doing before you trust the change. First, call cairo_surface_status(buffer) right after creation and confirm it is CAIRO_STATUS_SUCCESS; a failed surface silently accepts drawing commands that go nowhere. Second, confirm the content model you requested matches how you draw. CAIRO_CONTENT_COLOR_ALPHA gives you alpha, CAIRO_CONTENT_COLOR does not, and asking for alpha when the target has none forces Cairo to synthesize a channel on every blit.
Trade-offs and where it helps less
Memory is the direct cost. Each similar surface owns its own pixel buffer, roughly width * height * 4 bytes for a 32-bit format. Allocate one per window and reuse it across frames rather than creating a new surface per frame; a per-frame allocation both wastes memory and adds allocation cost to the frame budget.
Backend support varies. Some older or more constrained backends may not be able to avoid conversion even when the formats match, so test on each platform you ship to rather than assuming desktop Linux behaviour carries over.
Format matching also does not help every workload. If your bottleneck is complex path tessellation, too many draw calls, or a software rasterizer doing the heavy lifting, removing a format conversion will not move the needle much. Profile first: measure frame time before and after, and check whether the copy operation is actually a significant share of it. Tools such as perf on Linux, or a platform GPU profiler, can show whether time is going into the blit or into drawing.
A reasonable next step is to pick one animated or scrolling region that is fully redrawn each frame, move its drawing into a similar surface created once at window setup, and compare frame times against the direct-to-window version. If the gain is real, extend the pattern to the other redrawn regions; if it is not, you have learned that your bottleneck lives somewhere else and you have not paid much to find out.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.