Case study · October 2026
Putting a robot's touchscreen UI on the GPU
The cleaning robot I support at work had a laggy touchscreen. I got shell access, found out why, proved a full rewrite could hit 60 fps, and then chose the fix the team could actually ship.
01The problem
The robot's touchscreen runs an Electron + React app on an NXP i.MX8 board (aarch64, Vivante GC7000 GPU, Weston compositor), inside a Docker container. It felt sluggish: taps lagged and animations stuttered. The people using that screen are hotel housekeeping staff, every day, so it is a large part of how customers experience the product.
I'm on the customer success team, not engineering. I asked if I could take a look, and got SSH access to a dev robot.
02Measure before touching anything
The board has no FPS counter for Electron, so I wrote one. Every GPU job raises an interrupt, so a small read-only script samples /proc/interrupts for the GPU driver's lines every 2 ms and turns the timestamps into a frame-cadence estimate. Combined with per-thread CPU from top -H, that gave a baseline:
- Frame spacing was 16–17 ms when it was smooth, but the 95th-percentile gap was 100–240 ms. Those were the stalls you could feel.
- The UI threads used about 83% of a CPU core just to tap a menu open and closed.
- Only the compositor and Xwayland held GPU contexts. Electron wasn't touching the GPU at all; it was rendering in software.
03The ambitious path: prove what the hardware can do
Before fixing the old stack, I wanted to know the ceiling. I rebuilt the main screens in Rust with Slint, a GPU-native UI toolkit, matching the existing design pixel for pixel (0.4–3% pixel difference on most screens against the component library).
- The robot has no compiler and no package manager, so I cross-compiled for aarch64 with
cargo-zigbuildagainst the right glibc version, loading every GPU and Wayland library at runtime so no sysroot was needed. - I wired it to live robot state through a read-only bridge into the robot's ROS 2 graph, then added a run-control channel with safety gates: stop always goes through and is retried once, start is refused while a run is active or the robot is picked up, and everything has a dry-run mode.
- It ran on the robot as a native Wayland client and started, paused and stopped real cleaning runs.
Frames per second, estimated from GPU interrupt timing. The loads differ (manual taps vs. a scripted loop), so treat this as indicative, not a controlled A/B.
It was butter smooth. It also would have meant rewriting a whole production UI, which no team takes on lightly.
04The pragmatic path: fix the app they already have
The obvious move, turning hardware acceleration back on, doesn't help by itself. I found three independent blockers, and each one alone keeps the GPU off:
- The app turns the GPU off itself. Its entry point disables GPU acceleration unconditionally, so no launch flag can override it.
- Wrong display path. The UI ran as an X11 app through Xwayland, and the Vivante driver only accelerates Wayland (and GBM), not X11.
- The container can't reach the GPU. The UI's user isn't in the
videogroup that owns the GPU device, and the container only ships generic Mesa libraries, not the vendor driver.
| Change | Why |
|---|---|
| Neutralize the two GPU kill-switch calls | Nothing else matters until they're gone |
Mount the Wayland socket, run with --ozone-platform=wayland | The driver only speaks Wayland |
Map /dev/galcore, add the video group, load the vendor EGL/GLES libraries first | The container's Mesa can't drive this GPU |
--use-gl=egl --in-process-gpu --ignore-gpu-blocklist --enable-gpu-rasterization | A separate GPU process has no Wayland display, so the GPU work has to run in-process |
| Turn off MSAA and stencil buffers for rasterization | Without it, SVG icons silently vanish on this driver |
Traps worth knowing: mapping /dev/dri makes Chromium pick GBM buffers and crash; the missing icons only showed up on certain screens; and the app crashes at launch without a writable config directory.
I tested everything in a separate, throwaway, network-isolated container, and production containers were never modified. The result runs the unmodified UI code on the GPU, side by side with the normal UI on the live robot.
05Results
| Check | Result |
|---|---|
| Frame rate (GPU test page) | 17.7 → 42–45 fps |
| Real UI on the live robot | Start, pause, stop and the run summary all worked with no errors; noticeably smoother |
| 80-minute idle soak | Memory flat throughout; no JS, GPU or kernel errors; no crashes; temperature peaked at 84 °C and settled to 72 °C |
| CPU and memory at idle | ~6–7% CPU and ~340 MB vs. ~8.5% and 430–510 MB stock (a rough read, not a controlled test) |
06What I'd still verify before rolling it out
- GPU failure. With the GPU in-process, a GPU crash takes down the whole UI instead of falling back. It needs a supervised restart and a deliberate crash test.
- Full-shift endurance during real cleaning runs, while the GPU is shared with the compositor and the cameras.
- A controlled A/B on a real run using the same interrupt sampler, plus every screen I haven't exercised yet (map, OTA update, diagnostics).
07What I took from it
The rewrite was the more fun build, and it proved the hardware could hit 60 fps. But the job was to make the robot better for the people using it, and a handful of mounts and flags gets most of that win at a fraction of the risk. Building the ambitious version first is what made me confident the small fix was the right one.