Qt & QML Development
2026-08-21
12 minutes reading

Qt Canvas Painter vs QPainter - New hardware-accelerated custom drawing with Qt

Mateusz Fejcher
Mateusz Fejcher Qt Developer

Qt recently introduced Qt Canvas Painter - a new module for hardware-accelerated 2D painting in Qt Quick. In short, it is a spiritual successor to QPainter, but built from the ground up to sit on top of QRhi (Qt Rendering Hardware Interface). That gives you the flexibility to choose your rendering backend - OpenGL, Metal, Vulkan, Direct3D - and more importantly, the entire rendering pipeline stays on the GPU. No CPU involvement in the loop.

To test it out I decided to port our Aero Pulse Monitor demo from QPainter to Qt Canvas Painter. I did it, and naturally the next question was: does it actually make a difference? How does the performance compare between the two approaches? That curiosity turned into a bit of a rabbit hole, and this post is the result.

What is Qt Canvas Painter - and how does it differ from QPainter? 

QPainter has been the workhorse of Qt 2D rendering for a very long time. It works by rasterizing drawing commands - lines, shapes, gradients, text - into pixels. It has multiple backends, including a software (pure CPU) backend and an OpenGL backend. Even with the OpenGL backend though, QPainter still has CPU involvement.

When you use it inside a QQuickPaintedItem, the default flow is: QPainter draws into a QImage on the CPU, and that image gets uploaded as a texture to the GPU every repaint. So while the final display is on screen via the GPU, the heavy lifting is still on the CPU side.

Qt Canvas Painter takes a different approach. It is designed exclusively for GPU rendering - there is no CPU backend at all. Drawing commands like beginPath, lineTo, circle, bezierCurveTo, fill, and stroke are translated directly into GPU commands through QRhi. On macOS that means Metal commands. On a Raspberry Pi 4B it means OpenGL ES. There is no intermediate QImage, no texture upload per frame - the geometry goes straight to the GPU.

The API itself is modeled after the HTML Canvas 2D context, which makes it feel familiar if you have ever written canvas-based web graphics. It is available for Qt Quick via QCanvasPainterItem, but also works with Qt Widgets and directly with QRhi.

As of Qt 6.11, Qt Canvas Painter is a Technology Preview - so it is not yet under Qt's binary compatibility promises, but it is already usable and under active development.

Migrating Aero Pulse Monitor from QPainter to Qt Canvas Painter 

This is our Aero Pulse Monitor demo. You might have seen it at Embedded World 2026 or Qt World Summit 2025 - we have been taking it to a lot of events. What makes it interesting from a rendering standpoint is that all the graphs are custom-painted in C++: the Pulse Rate area chart, the rolling Plethysmograph plot - all of it drawn directly with Qt framework . At the events we also connected a custom device that scans the pulse rate of the person standing next to it, wirelessly, so the numbers on screen are live.

The original rolling ECG plot used QQuickPaintedItem. That gives you a paint(QPainter*) callback, but it runs on the render thread while your data model lives on the main thread - accessing m_series inside it is a data race. We worked around it with QMetaObject::invokeMethod just to safely restart a timer from inside paint(). We also kept an incremental off-screen QImage per series to avoid redrawing historical data every frame.

And yes, we're aware that we could implement it with scene graph API's so it would be hardware accelerated at the time from the very beginning. The thing is that old good QPainter has a convenient API and we couldn't afford to spend much time on these custom plots.

QCanvasPainterItem fixes both problems at the architecture level. It enforces a two-phase contract: synchronizeData() runs on the main thread and copies whatever the renderer needs; paint() runs on the render thread and touches only that copy. No shared mutable state, no cross-thread queued invocations needed.

// Before: paint() runs on the render thread, m_series lives on the main threadvoid 
RollingPlot::paint(QPainter *painter)
{
    painter->fillRect(boundingRect(), m_backgroundColor);
    // m_series lives on the main thread - unsafe access here
    const auto &pts = m_series->dataPoints(0);
    // ...
    QMetaObject::invokeMethod(m_eraseTimer, "start",
        Qt::QueuedConnection, Q_ARG(int, m_eraseInterval.count()));
}

// After: snapshot on the main thread, draw from the snapshot on the render threadvoid
RollingPlotRenderer::synchronizeData(QCanvasPainterItem *item)
{
    auto *plot = static_cast<RollingPlot *>(item);
    m_color = plot->color();
    m_snapshots[0].points = plot->series()->dataPoints(0);
    m_snapshots[1].points = plot->series()->dataPoints(1);
    // ... compute clip boundaries ...
}

void RollingPlotRenderer::paint(QCanvasPainter *p)
{
    for (const auto &snap : m_snapshots) {
        p->save();
        p->setClipRect(clipX0, 0.f, clipX1 - clipX0, height());
        p->beginPath();
        p->moveTo(mapX(snap.points[0].x()), mapY(snap.points[0].y()));
        for (int i = 1; i < snap.points.size(); ++i)
            p->lineTo(mapX(snap.points[i].x()), mapY(snap.points[i].y()));
        p->stroke();
        p->restore();
    }
}

QCanvasPainter keeps no persistent backing store, so each frame is a full redraw: iterate the points, stroke the polyline, fill under the curve with a vertical gradient.

The threading model comes out of the API shape. synchronizeData() runs on the main thread with the GUI thread blocked, so that's where the snapshot of m_series is taken; paint() works only on that snapshot. No shared access to guard, no timer restart routed through the event loop.

RollingPlot went from ~300 lines to ~140 and holds no rendering state.

Benchmarks 

So after porting the Aero Pulse Monitor, I was obviously curious about the performance. For benchmarking I used my Raspberry Pi 4B on Raspberry Pi OS 64-bit. I didn't want to spend hours setting up a Yocto image , and I also didn't want to configure the RPi to launch a Qt app bare-metal with EGLFS. I just ran the application and collected data from the process PID.

And with a lot of hope, a fire in the heart, and a pinch of excitement - the data was identical. No difference. Margin of error.

 

cpu_pct (%)

v3d_irqs_per_sec (1/s)

temp_c (*C)

QPainter

8,59

139,1

56,91

Qt Canvas Painter

7,57

151,67

57,01

The original VitalMonitors demo worked on old, vendor-closed medical tablet from Advantech and there high CPU usage was a visible problem. Looks like my RPI was way more powerful than this tablet.

I was a little disappointed. All that work: building Qt 6.11 from source, cross-compiling the application for the RPi, and it showed nothing. So I said - this can't end like that.

QPainter vs Qt Canvas Painter - showcase application 

So I thought, so I did. I created two identical applications, each using a different class for drawing things on screen - one with QPainter, one with QCanvasPainterItem. Same scene, same geometry, same animation loop. Let the renderers fight.

What the applications draw 

Both applications render an identical animated scene at 800x600 pixels, designed to put consistent, measurable load on the renderer every frame.

The scene consists of six layers drawn on top of each other:

1. Background - a solid dark navy fill (`#1a1a2e`) that clears the canvas each frame.

2. Color grid - a 20x15 grid of 300 small rectangles tiled across the whole window, each filled with a semi-transparent color cycling through a six-color palette. This layer is static and serves as a busy background that forces the renderer to composite many overlapping translucent shapes.

3. Radial burst - 90 lines radiating from the center of the window, evenly distributed across a full 360°. The entire burst rotates continuously, driven by the animation phase.

4. Orbiting circles - 16 filled circles arranged in a ring at radius 160px from the center. They rotate in the opposite direction to the burst, so the two layers always move against each other.

5. Central gradient pulse - a single large circle at the center filled with a radial gradient that fades from warm yellow to violet. Its radius oscillates between 40 and 80 pixels, pulsing three times per animation cycle.

6. Bezier curves - 5 cubic bezier curves sweeping across the full width of the window. Their control points shift with the phase, so the curves wave and flex on every frame.

On top of all geometry, each app draws a small FPS counter, total frame count, and renderer label in the top-left corner - rendered as part of the scene itself so the text drawing is included in the benchmark workload.

The stress level 

Both apps share a compile-time constant:

static constexpr int kStressLevel = 10;

This multiplier scales the three most expensive draw-call groups - radial lines (x90), orbiting circles (x16), and bezier curves (x5) - by the same factor. At kStressLevel = 1 the scene is lightweight. At kStressLevel = 10 (the default) each frame contains 900 lines, 160 circles, and 50 bezier curves, which is enough to meaningfully stress both renderers on a Raspberry Pi 4B. Raising it further makes the scene heavier; lowering it toward 1 brings both renderers closer to their ceiling.

Here is what that looks like at stress level 10:

The two renderers 

PainterPro uses QPainter via QQuickPaintedItem. Every frame, Qt's software rasterizer runs entirely on the CPU and produces a QImage. That image is then uploaded to GPU memory as a texture, and the Qt Quick scene graph composites it onto the screen. The CPU does all the geometry work; the GPU only composites the finished image.

CanvasPainterPro uses the Qt Canvas Painter module (`QtCanvasPainter`, technology preview in Qt 6.11) via QCanvasPainterItem and QCanvasPainterItemRenderer. The drawing API - beginPath, circle, bezierCurveTo, fill, stroke - is translated directly into GPU commands through Qt's Rendering Hardware Interface (QRhi). On macOS this means Metal commands; on Raspberry Pi 4B it means OpenGL ES. There is no CPU rasterization step and no texture upload - the geometry goes straight to the GPU.

Both apps expose a phase property that a NumberAnimation in QML advances from 0 to 2π over four seconds in a continuous loop. Each time phase changes, the C++ class requests a repaint, and the scene is redrawn with every animated element at its new position. The FPS counter shows how many redraws the renderer can sustain per second under that load.

See a Real-Time Patient Monitor Built with Qt

Discover how sensors, MCU programming, serial communication, custom C++ plots, QML, and Qt Safe Renderer became an end-to-end vital-sign monitoring prototype.

Read the Patient Monitor Case

Results 

For benchmarking I wrote a small shell script that runs on the Pi directly. It launches each application, grabs its PID, and samples four metrics every 200 ms for 15 seconds - then does the whole thing 5 times per app, interleaved, with a 10-second cooldown between runs to keep temperature conditions fair.

The four metrics tracked per sample:

  • cpu usage - process CPU usage, derived from /proc/<pid>/stat against total system jiffies

  • gpu usage - process GPU usage, derived from V3D render-queue busy time in /proc/<pid>/fdinfo against wall-clock elapsed time

  • fps - taken straight from the app's stderr, where both renderers print FPS:<value> every 500 ms from the render thread - same number shown on screen

  • CPU temp - CPU temperature via vcgencmd measure_temp

The numbers below are averages across all 5 runs.

Stress level 1 

 

cpu usage (%)

gpu usage (%)

fps

CPU temp (*C)

QPainter

35,49

6,55

47,2

69,09

Qt Canvas Painter

22,43

28,04

45,08

67,74

At first glance this looks like a draw. The FPS numbers are nearly identical, and you might think all that GPU work didn't buy much. But look at the CPU column. Canvas Painter is doing the same job, with the same scene, animation and frame count, while using about 37% less CPU. QPainter uses the CPU to turn every shape into pixels, then hands the finished image to the GPU just to put it on screen. Canvas Painter skips that entirely and lets the GPU work out the pixels itself, which is why its GPU usage is four times higher (the GPU is genuinely busier) while its CPU is far more relaxed.

Stress level 10 

 

cpu usage (%)

gpu usage (%)

fps

CPU temp (*C)

QPainter

36,14

1,26

8,96

68,87

Qt Canvas Painter

21,35

30,83

28,13

66,92

This is where it gets interesting. Canvas Painter delivers roughly 3x the frame rate, 28.13 vs 8.96 fps. And here is the detail I find most telling: QPainter's CPU usage barely moved between stress level 1 and stress level 10, from 35.48% to 36.14%. But its FPS collapsed from 47.20 to 8.96. QPainter had already hit its limit at stress level 1, which is why the frame rate falls so hard. It doesn't show 100% CPU usage because that column measures its share of all four cores, and its drawing only runs on about one and a half of them. So 36% is its ceiling, not a sign that it has room left. The CPU is running flat out; it simply cannot work out the pixels for 900 lines, 160 circles and 50 curves fast enough to keep up. Each frame takes far longer, so fewer frames make it out.

That also explains the one number here that looks backwards. QPainter's GPU usage dropped, from 6.55% to 1.26%, on the scene that is five times heavier. Its GPU work is not drawing anything, though: it is one copy of the finished image across to the screen, and that copy costs the same no matter how much detail is in it. So five times fewer frames means five times fewer copies, and the GPU spends most of the interval idle, waiting for the CPU to finish the next one. The figure fell because the frames did.

Canvas Painter's CPU stays low and stable, at 22.42% for stress 1 and 21.26% for stress 10. The extra work goes to the GPU, whose usage rises slightly from 28.04% to 30.83%. The GPU absorbs the load, and while FPS does drop from 45.08 to 28.13 under the heavier scene, it doesn't fall off a cliff.

Conclusions

The result at stress level 1 might feel anticlimactic. Same FPS, so why bother? But that framing misses the point. Delivering the same output at 37% lower CPU cost is genuinely valuable on embedded hardware. That freed up CPU headroom goes to your application logic, your data processing, your UI thread responsiveness. On a Raspberry Pi 4B with a shared thermal envelope and four cores doing real work, that gap matters. Not to mention that a lot of hardware has less to offer than my Raspberry.

There is a second reading of stress level 1 that is easy to miss. The two renderers were not both comfortable at 45 fps. QPainter was already at its ceiling there, holding about one and a half cores, which is exactly why it had nothing left when the scene got heavier. Canvas Painter reached the same frame rate while using well under one core and about a quarter of the GPU. They looked equal, but only one of them was actually trying.

And when the scene gets heavy, the difference is not subtle. Three times the frame rate under the same load is the kind of number that changes what you can put on screen. Worth noting too that Canvas Painter still is not saturating anything at stress level 10, sitting at 0.85 cores and 31% GPU, so 28 fps may not be its limit either.

Looking back at the Aero Pulse Monitor port, the win from Canvas Painter there is architectural: correct threading by construction, cleaner code, less state to manage, rather than raw throughput. The raw throughput win only shows up when you push the renderer. And on embedded targets, where you often want to push it, it shows up clearly.

Qt Canvas Painter is still a technology preview in Qt 6.11, but the model is solid, the performance improvement is real, and it is already worth taking seriously.

Contact us

Lukas Kosiński

Lukas Kosiński

CEO, Somco Software

Write a few sentences about your project and it lands directly in my inbox. I usually answer within one business day, and when it's a fit, we can get started in as little as two weeks.

Connect with me on LinkedIn

The administrator of the personal data is Somco Software sp. z o.o., 13 Gen. Ottokara Brzoza-Brzeziny St., 05-220 Zielonka, KRS: 855688. The personal data are processed in order to answer the question contained in the contact form. More information, including a description of data subjects rights, is available in the information clause .