A Raspberry Pi 3B+ at 1080p: what was slow, why, what was changed (2026-10-19)
Owner’s goal (2026-10-18): show that even a Pi 3B+ is fast at 1080p when the engine is clever and the compromises are useful and well placed, and
that what is learnt on old hardware improves everything else (causes fixed by capability, measured on every device).
Measured with tools/zbench (one launch, 19 scenes, a report file the app writes itself; docs/reports/render-bench-design.md), Pi 3B+ at
1400 MHz (throttled=0x0), KMS mode 1920x1080, display-gl, GLES 2 on VideoCore IV (Mesa). Artifacts: docs/reports/bench/zbench-render-pi-*.json
(-final is the end state, 3 runs of 240 frames; the file without a tag the start). * = the scene was closed after 12 s with fewer than 200 frames.
Result (frames per second, median of two runs)
| scene | 1080p start | 1080p now | 720p start | 720p now |
|---|---|---|---|---|
| fill_opaque (8 opaque screens) | 40 | 60 | 60 | 60 |
| fill_blend (8 translucent screens) | 8* | 30 | 20* | 60 |
| quads_10k | 60 | 59 | 60 | 60 |
| rounded_shadow | 30 | 60 | 30 | 60 |
| gradients (40 large gradients) | 3* | 15* | 6* | 60 |
| text_static / text_churn / poly / scroll_list | 60 | 60 | 60 | 60 |
| clip_nested (24 rounded clips) | 3* | 60 | 7* | 60 |
| image_update / upload | 60 | 60 | 60 | 60 |
| damage_small (a square moves over a decoration) | 24 | 60 | 60 | 60 |
| ui_scroll_fling / ui_scroll_wheel | 60 | 60 | 60 | 60 |
| ui_anim_transform (150 views, transform + opacity) | 0* (one frame in 4 s) | 60 | 60 | 60 |
| ui_anim_layout | 60 | 60 | 60 | 60 |
| ui_list_update (100 live rows) | 24 | 60 | 60 | 60 |
At 720p every scene is at 60 fps. At 1080p every scene of an app (lists, scrolling, animations, text, shadows, cards with rounded corners) is at 60 fps; what stays under 60 are two stress scenes made on purpose: eight full-screen translucent layers (30) and forty large gradients, about ten screens of them (15).
Causes found, in the order they were found (each is a cause, not a Pi special case)
- A framebuffer read in the middle of a frame, once per rounded clip (
clip_nested3 fps). A tiling GPU (VideoCore, Adreno, Mali, PowerVR, Apple) that is asked to read the framebuffer draws everything recorded so far and loads it back: a whole pass per rounded clip (300 ms a frame at 1080p). The corners of a rounded CLIP were restored from a copy of the framebuffer. Now the shader multiplies the coverage by the rounded clip (two open at once), with the distance RESTORE used, in the corner squares only (the straight sides stay whole pixels like the software raster).ZINC_GL_RCLIP=0|1, default on for a tiler (caps.tiler). Against the software frame: 0.001 % of the pixels differ by more than 24 levels (the copy: 0.000 %). Rounded corners are kept, as asked. - Rendering into a texture and copying it to the window for nothing (
damage_small,ui_list_update,ui_anim_transform). The adaptive path rendered into the surface texture, then drew the texture into the window (3 GPU jobs a frame,VC4_DEBUG=perf“Blocking on seqno … for fence wait”) whenever less than half the surface changed, to replay only the damage. But the replay is never limited to the damage by default (ZINC_GL_DMG=0: suspected of hanging the VC4). So the texture kept nothing the next frame needed. Nowglr::damage_limited()tells the driver: a whole-frame replay goes straight into the window. - The occlusion pass where it does not pay. It writes then stores a depth buffer and draws the rectangles twice: 300 small rectangles that hardly touch run 24 fps with it, 30
without; eight full-screen panels run 40 with it and 15 without. It starts now when the opaque square rectangles, summed, cover at least half the surface
(
ZINC_GL_OCCLUDE_RATIO, default 0.5) instead of at 64 commands. - The full fragment programs on a GPU that fills slowly. The lean programs (a plain premultiplied blend, no integer blend, no branch) were off for VideoCore IV; measured
at 1080p they are 2 to 6 times faster (gradients 3 -> 20, translucent layers 8 -> 30, shadows 30 -> 60) and equal the software frame within 23 levels (0 pixels over 24
on gradients, shadows, text and a list).
{VC4, tiler, lean}inruntime/gl_caps.h. - Blending and coverage maths for pixels that need neither. A square box on whole pixels, opaque, is written without blending (the GPU neither reads nor merges the
destination) and with a program that has no distance (
LEAN_FILLSQ); the interior of a rounded gradient is drawn the same way with only its rim computed. A translucent box does NOT take that program: measured slower than the general lean one on both GPUs (Tegra 3 16 fps against 22, VideoCore IV 20 against 30 for eight translucent screens), soZINC_GL_WHOLE_TRANSLUCENT=1stays a switch to compare (ZINC_GL_WHOLE=0turns the whole path off). Nexus 7 in GL: opaque gradients 6 -> 13.
Two tooling defects found on the way and fixed, because they cost days: the display plugin’s cache did not hash gl_caps.h (a change of the tuning table was not built
for the device, src/tc/plugin_build.cpp), and the lean programs were never compiled by the GLSL ES test (tests/native/gl_replay_es2.cpp now checks them and the clipped variants).
Checked on the other devices (same suite, before and after)
Nexus 7 (software and GL), TouchPad: no scene is slower beyond the noise of a run (gradients: TouchPad 22 -> 32, Nexus 7 GL 6 -> 13). Mac: every scene reads about 9 % lower
in frames per second, uniformly, with the scenes this lot cannot touch included (the load of the machine moved the compositor’s floor from 9.5 to 10.8 ms); its GPU times,
which are the real figure there, went down (fill_opaque 3.7 -> 0.5 ms, clip_nested 5.4 -> 1.2 ms). The iPad draws with the software raster and is not touched by the GL changes.
What is left, and the ideas that follow
- 720p drawn and scaled to 1080p is still 30 fps on most scenes: the GPU does the scaling in an extra pass. The Pi’s HVS can scale a plane at no GPU cost (DRM planes
scale;
docs/reports/low-end-gpu-perf-ideas.md): scanning out a 1280x720 buffer on the primary plane with a 1920x1080 destination rectangle. Not done. - Translucent full-screen layers stay expensive on VideoCore IV (about 330 Mpixel/s blended): the rule for the Pi is few, small translucent layers; a shadow is a quad no bigger than its visible blur.
- Tiers (docs/reports/low-end-gpu-perf-ideas.md section 3: cheap shadow while moving, ramp textures for gradients, 16-bit target with dither) are not needed to reach 60 fps
on these scenes; they stay the next lever for heavier apps, chosen by capability and measured with
zbench.
Reproduce
tools/zbench run pi --backend pi1080 --runs 2 --frames 240 # 1080p; --backend pi720, pi720up for the others
tools/zbench run pi --no-build --backend pi1080 --set ZINC_GL_RCLIP=0 # a switch of the engine, to see its effect
tools/zbench show docs/reports/bench/zbench-render-pi-*-opt5.json