Skip to content

SDL2 Touch Paint (Responsiveness Test)

Time estimate: ~30 minutes Prerequisites: SSH Login, SDL2 Rotating Cube

Learning Objectives

By the end of this tutorial you will be able to:

  • Build a touch-responsive drawing application using SDL2
  • Handle both mouse and touchscreen input with SDL2 events
  • Measure touch event rate, input-to-display latency, and dropped points
  • Compare touch responsiveness across display interfaces (HDMI + mouse, DSI + capacitive, SPI + resistive)
Input Latency and the Sensor-to-Pixel Pipeline

Touch responsiveness depends on the total latency from finger contact to visible pixel change. The pipeline has multiple stages: the touch controller hardware samples the position, the kernel driver delivers an event to /dev/input/eventN, SDL2 reads the event and fires SDL_FINGERMOTION, the application stamps pixels on a canvas texture, and SDL_RenderPresent() triggers a GPU composite and DRM page flip. Each stage adds delay. Three metrics quantify responsiveness: event rate (how often the driver reports positions — higher means smoother lines), input-to-display latency (total pipeline delay), and spatial accuracy (coordinate fidelity). Capacitive touchscreens (DSI panels) typically report 50-120 events/sec; resistive touchscreens (SPI panels) report 20-80 events/sec; USB mice can exceed 1000 events/sec.

See also: Real-Time Graphics reference


Introduction

A drawing application is the simplest way to feel and measure input latency. When you drag your finger across a touchscreen, every millisecond of delay between contact and visible ink is perceptible. This makes touch paint a diagnostic tool: if the line tracks your finger smoothly, your display and input stack are working well. If it lags, you have a measurable problem to investigate.

This tutorial builds a minimal paint app that works with mouse (HDMI), capacitive touch (DSI), or resistive touch (SPI). It shows per-frame statistics: FPS, event rate, and point count.


1. Project Setup

Concept: The paint app uses SDL2's 2D renderer (no OpenGL needed). It draws to a persistent canvas texture and composites it each frame.

mkdir -p ~/sdl2-paint && cd ~/sdl2-paint

CMakeLists.txt

cat > CMakeLists.txt << 'EOF'
cmake_minimum_required(VERSION 3.16)
project(sdl2_touch_paint C)

set(CMAKE_C_STANDARD 11)

find_package(SDL2 REQUIRED)

add_executable(sdl2_touch_paint main.c)
target_include_directories(sdl2_touch_paint PRIVATE ${SDL2_INCLUDE_DIRS})
target_link_libraries(sdl2_touch_paint PRIVATE ${SDL2_LIBRARIES} m)
EOF

main.c

cat > main.c << 'MAIN_EOF'
#include <SDL2/SDL.h>
#include <math.h>
#include <stdio.h>

/* ── Brush drawing ────────────────────────────────── */

static void draw_circle(SDL_Renderer *r, int cx, int cy, int radius)
{
    for (int dy = -radius; dy <= radius; dy++) {
        int dx = (int)sqrtf((float)(radius * radius - dy * dy));
        SDL_RenderDrawLine(r, cx - dx, cy + dy, cx + dx, cy + dy);
    }
}

static void stamp_line(SDL_Renderer *r, int x0, int y0,
                       int x1, int y1, int radius)
{
    float dx = (float)(x1 - x0), dy = (float)(y1 - y0);
    float dist = sqrtf(dx * dx + dy * dy);
    float step = (radius > 1) ? radius * 0.5f : 1.0f;
    int n = (dist < 0.001f) ? 1 : (int)ceilf(dist / step);

    for (int i = 0; i <= n; i++) {
        float t = (n == 0) ? 0.0f : (float)i / (float)n;
        int x = (int)lroundf((float)x0 + t * dx);
        int y = (int)lroundf((float)y0 + t * dy);
        draw_circle(r, x, y, radius);
    }
}

/* ── Main ─────────────────────────────────────────── */

int main(int argc, char **argv)
{
    (void)argc; (void)argv;

    if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_TIMER) != 0) {
        fprintf(stderr, "SDL_Init: %s\n", SDL_GetError());
        return 1;
    }

    int w = 800, h = 480;
    SDL_Window *win = SDL_CreateWindow("Touch Paint",
        SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
        w, h, SDL_WINDOW_FULLSCREEN_DESKTOP);
    if (!win) { fprintf(stderr, "Window: %s\n", SDL_GetError()); return 1; }

    SDL_GetWindowSize(win, &w, &h);

    SDL_Renderer *ren = SDL_CreateRenderer(win, -1,
        SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
    if (!ren) ren = SDL_CreateRenderer(win, -1, SDL_RENDERER_SOFTWARE);
    if (!ren) { fprintf(stderr, "Renderer: %s\n", SDL_GetError()); return 1; }

    /* Canvas texture — we draw strokes here and keep them */
    SDL_Texture *canvas = SDL_CreateTexture(ren, SDL_PIXELFORMAT_RGBA8888,
        SDL_TEXTUREACCESS_TARGET, w, h);
    SDL_SetRenderTarget(ren, canvas);
    SDL_SetRenderDrawColor(ren, 20, 20, 24, 255);
    SDL_RenderClear(ren);
    SDL_SetRenderTarget(ren, NULL);

    int running = 1, drawing = 0;
    int last_x = 0, last_y = 0;
    int brush = 6;

    /* Measurement state */
    Uint64 freq = SDL_GetPerformanceFrequency();
    Uint64 t_start = SDL_GetPerformanceCounter();
    Uint64 t_report = t_start;
    int frame_count = 0;
    int event_count = 0;         /* touch/mouse motion events per interval */
    unsigned long long points = 0;

    /* Colors: cycle with 'n' key */
    SDL_Color colors[] = {
        {240,240,240,255}, {255,80,80,255}, {80,255,80,255},
        {80,80,255,255}, {255,255,80,255}, {255,80,255,255},
    };
    int ci = 0;
    int num_colors = sizeof(colors) / sizeof(colors[0]);

    while (running) {
        SDL_Event e;
        while (SDL_PollEvent(&e)) {
            switch (e.type) {
            case SDL_QUIT:
                running = 0; break;
            case SDL_KEYDOWN:
                if (e.key.keysym.sym == SDLK_ESCAPE) running = 0;
                if (e.key.keysym.sym == SDLK_c) {
                    SDL_SetRenderTarget(ren, canvas);
                    SDL_SetRenderDrawColor(ren, 20, 20, 24, 255);
                    SDL_RenderClear(ren);
                    SDL_SetRenderTarget(ren, NULL);
                    points = 0;
                }
                if (e.key.keysym.sym == SDLK_LEFTBRACKET && brush > 1) brush--;
                if (e.key.keysym.sym == SDLK_RIGHTBRACKET && brush < 40) brush++;
                if (e.key.keysym.sym == SDLK_n) ci = (ci + 1) % num_colors;
                break;
            case SDL_WINDOWEVENT:
                if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
                    w = e.window.data1; h = e.window.data2;
                    SDL_DestroyTexture(canvas);
                    canvas = SDL_CreateTexture(ren, SDL_PIXELFORMAT_RGBA8888,
                        SDL_TEXTUREACCESS_TARGET, w, h);
                    SDL_SetRenderTarget(ren, canvas);
                    SDL_SetRenderDrawColor(ren, 20, 20, 24, 255);
                    SDL_RenderClear(ren);
                    SDL_SetRenderTarget(ren, NULL);
                }
                break;

            /* Mouse input */
            case SDL_MOUSEBUTTONDOWN:
                if (e.button.button == SDL_BUTTON_LEFT) {
                    drawing = 1; last_x = e.button.x; last_y = e.button.y;
                } break;
            case SDL_MOUSEBUTTONUP:
                if (e.button.button == SDL_BUTTON_LEFT) drawing = 0;
                break;
            case SDL_MOUSEMOTION:
                if (drawing) {
                    SDL_SetRenderTarget(ren, canvas);
                    SDL_SetRenderDrawColor(ren,
                        colors[ci].r, colors[ci].g, colors[ci].b, 255);
                    stamp_line(ren, last_x, last_y, e.motion.x, e.motion.y, brush);
                    SDL_SetRenderTarget(ren, NULL);
                    last_x = e.motion.x; last_y = e.motion.y;
                    points++; event_count++;
                } break;

            /* Touch input (normalized 0..1 coordinates) */
            case SDL_FINGERDOWN:
                drawing = 1;
                last_x = (int)(e.tfinger.x * w);
                last_y = (int)(e.tfinger.y * h);
                break;
            case SDL_FINGERUP:
                drawing = 0; break;
            case SDL_FINGERMOTION:
                if (drawing) {
                    int tx = (int)(e.tfinger.x * w);
                    int ty = (int)(e.tfinger.y * h);
                    SDL_SetRenderTarget(ren, canvas);
                    SDL_SetRenderDrawColor(ren,
                        colors[ci].r, colors[ci].g, colors[ci].b, 255);
                    stamp_line(ren, last_x, last_y, tx, ty, brush);
                    SDL_SetRenderTarget(ren, NULL);
                    last_x = tx; last_y = ty;
                    points++; event_count++;
                } break;
            }
        }

        /* Present canvas + crosshair */
        SDL_SetRenderTarget(ren, NULL);
        SDL_RenderCopy(ren, canvas, NULL, NULL);
        SDL_SetRenderDrawColor(ren, 255, 80, 80, 200);
        SDL_RenderDrawLine(ren, last_x - 14, last_y, last_x + 14, last_y);
        SDL_RenderDrawLine(ren, last_x, last_y - 14, last_x, last_y + 14);
        SDL_RenderPresent(ren);

        frame_count++;
        Uint64 now = SDL_GetPerformanceCounter();
        double dt = (double)(now - t_report) / (double)freq;
        if (dt >= 1.0) {
            printf("FPS: %d | Events/sec: %d | Total points: %llu | "
                   "Brush: %dpx\n",
                   frame_count, event_count, points, brush);
            frame_count = 0; event_count = 0;
            t_report = now;
        }
    }

    SDL_DestroyTexture(canvas);
    SDL_DestroyRenderer(ren);
    SDL_DestroyWindow(win);
    SDL_Quit();
    return 0;
}
MAIN_EOF
Checkpoint

Project directory has CMakeLists.txt and main.c.


2. Build and Run

cd ~/sdl2-paint
cmake -S . -B build
cmake --build build -j$(nproc)

Launch

# Direct KMS/DRM (no desktop):
export SDL_VIDEODRIVER=kmsdrm
./build/sdl2_touch_paint

# Or on desktop session:
./build/sdl2_touch_paint

Controls

Key Action
Draw Left mouse button or finger
C Clear canvas
[ / ] Decrease / increase brush size
N Next color
Escape Quit
Checkpoint

Drawing with mouse or touch produces smooth lines. The terminal prints FPS and event rate every second.


3. Understanding Touch Events

Concept: SDL2 provides two input paths — mouse events (screen coordinates) and finger events (normalized 0..1 coordinates). Touchscreens generate finger events; USB mice generate mouse events.

Event Types

Event Source Coordinates Multi-touch
SDL_MOUSEMOTION Mouse, trackpad Pixel (0..width) No
SDL_FINGERMOTION Touchscreen Normalized (0.0..1.0) Yes

The code handles both: mouse events use pixel coordinates directly, while finger events are multiplied by the window dimensions to get pixel positions.

Multi-Touch

Finger events include a fingerId field. Each simultaneous contact gets a unique ID. The paint app tracks only the first finger — extending to multi-touch is a challenge exercise.


4. Measure Touch Responsiveness

Concept: Three metrics matter for touch responsiveness: event rate (how often the driver reports positions), input-to-display latency (time from finger movement to visible pixel change), and spatial accuracy (do coordinates match where you actually touched).

Event Rate

The terminal output shows events per second. Higher is better — it means more position samples, resulting in smoother lines.

Display + Input Expected Event Rate
HDMI + USB mouse 100-1000 events/sec
DSI + capacitive touch (GT911) 50-120 events/sec
SPI + resistive touch (XPT2046) 20-80 events/sec

Visual Latency Test

Draw a rapid circle or zig-zag. Watch the red crosshair — the distance between the crosshair and your finger tip is a visual measure of latency:

  • Small gap (1-3 mm): good latency, <30 ms
  • Large gap (5+ mm): noticeable lag, >50 ms
  • Line breaks/gaps: events being dropped or rate is too low

Fill In Your Measurements

Metric HDMI + Mouse DSI + Touch SPI + Touch
FPS _ _ _
Events/sec (drawing) _ _ _
Visual lag (subjective) _ _ _
Line smoothness _ _ _
Checkpoint

You have measured event rate on at least one input device and can see the crosshair tracking your input.


5. Run on Different Displays

Concept: The same application binary runs on HDMI, DSI, and SPI displays. The input and rendering behavior changes based on the display stack.

SDL2 Video Backends and What They Do

SDL2 can render through different backends depending on the display hardware. The SDL_VIDEODRIVER environment variable selects which one:

Backend How It Works Display Path
kmsdrm Opens /dev/dri/card0, creates a GBM surface, uses DRM atomic commits for page flipping GPU → DRM → HDMI or DSI
wayland Connects to a Wayland compositor (Weston), renders into shared buffers GPU → compositor → display
x11 Creates an X11 window, renders via GLX or EGL GPU → X server → display
fbcon Opens /dev/fbN directly, uses software rendering, writes to framebuffer memory CPU → fbdev → display

For embedded Linux without a desktop, kmsdrm is the standard choice — it gives you GPU-accelerated rendering with hardware VSync and no compositor overhead. The fbcon backend is the fallback for displays without DRM support (like SPI displays using fbtft).

SDL2 also auto-detects the input subsystem: it opens /dev/input/eventN devices and maps them to SDL events. Mouse, keyboard, and touchscreen devices are all discovered via the Linux input layer, regardless of the video backend.

HDMI (Default)

export SDL_VIDEODRIVER=kmsdrm
./build/sdl2_touch_paint

Use a USB mouse to draw. This is the fastest path — high event rate, GPU-accelerated rendering.

DSI Display

If you have a DSI display connected:

export SDL_VIDEODRIVER=kmsdrm
./build/sdl2_touch_paint

Draw with your finger. The capacitive touch should feel responsive with smooth lines.

SPI Display

The SPI display uses fbdev (via the fbtft driver), not DRM. SDL2 must use the fbcon backend:

export SDL_VIDEODRIVER=fbcon
export SDL_FBDEV=/dev/fb1
./build/sdl2_touch_paint

SDL_FBDEV=/dev/fb1 tells SDL2 to open the SPI display's framebuffer instead of the HDMI framebuffer at /dev/fb0. SDL2 renders in software and writes the pixel buffer to /dev/fb1, which the fbtft kernel driver then transfers over SPI to the panel.

Warning

On the SPI display, expect significantly lower FPS because every frame must be pushed through the SPI bus. The paint app will feel laggy — this is the SPI bandwidth limit, not a software bug. The GPU is not involved in this path at all.

Checkpoint

You have run the paint app on at least one display and observed how input responsiveness differs between interfaces.


What Just Happened?

You built a diagnostic tool for measuring input-to-display performance. The paint app exercises every layer of the embedded input/display stack:

Finger touches screen
    → Touch controller (hardware) samples position
    → Kernel driver delivers event to /dev/input/eventN
    → SDL2 reads the event and fires SDL_FINGERMOTION
    → App stamps brush pixels on the canvas texture
    → SDL_RenderPresent() → GPU composites → DRM page flip
    → Display shows the new pixels

Every step adds latency. The measurement table shows where the bottlenecks are — event rate (input side) and FPS (display side) together determine how responsive the touch feels.


Challenges

Challenge 1: Multi-Touch Drawing

Extend the app to track multiple fingers simultaneously. Use e.tfinger.fingerId to assign a different color to each finger. This tests the touchscreen's multi-touch capability.

Challenge 2: Pressure-Sensitive Brush

SDL2 finger events include e.tfinger.pressure (0.0 to 1.0). Map pressure to brush size for a more natural drawing feel. Does your touchscreen report varying pressure?

Challenge 3: Latency Measurement with LED

Add a GPIO LED that turns on when a touch event is received and off when the frame is presented. Use an oscilloscope or high-speed camera to measure the time between finger contact and LED toggle. This gives you a hardware-verified latency number.


Deliverable

  • [ ] Touch paint app running on at least one display
  • [ ] Event rate and FPS measurements recorded
  • [ ] Comparison table filled in (if multiple displays available)
  • [ ] Brief note: which display interface felt most responsive, and why

Course Overview | Previous: ← Rotating Cube | Next: IMU Controller →