Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 22

Storing Single Stroke Data

Recommended resources

Welcome back! In our previous lesson, we built a robust event loop to capture a high-frequency stream of input from SDL2, including the crucial position and pressure data from a stylus. We now have a firehose of raw data, but it's just a sequence of unrelated numbers. To make it useful, we need to give it structure.

This lesson is all about turning that raw data stream into a meaningful representation of a user's action. You will learn how to design and implement a data structure to store a single user stroke. This is the foundational data model for all drawing in your application. It's what we will render, save, undo, and optimize. For a high-performance system, getting this core data structure right from the start is paramount.

What is a "Stroke"?

In the digital world, a stroke is the digital equivalent of a single, continuous line made with a pen or brush. It begins when the stylus touches the canvas, continues as it moves, and ends when it is lifted. Our task is to model this concept in C++.

From the previous lesson, we know that as the user draws, SDL provides us with a series of events. Each SDL_FINGERMOTION event gives us a snapshot in time: a position (x, y) and a pressure value. A stroke is simply the ordered collection of all these snapshots from the moment the finger goes down to the moment it comes up.

The most straightforward way to represent this is to define a structure for a single point in the stroke, and then a larger structure for the stroke itself, which will contain a collection of these points.


The Anatomy of a Point

Let's start with the smallest component: a single point. What information does each point along the stroke need to carry?

A conceptual diagram illustrating the various attributes that could be stored for a single point within a stroke.

The diagram above gives us a great starting point. Let's analyze it:

  • x, y: The 2D coordinates are the most fundamental piece of data.
  • pressure: Essential for our goal of pressure-sensitive rendering. We get this from event.tfinger.pressure.
  • Color, Eraser, point stroke (size), opacity: These are interesting. Should every single point store its own color and size? While possible, it's more common for these properties to be constant for an entire stroke. For example, the user selects a red pen and then draws a line. The entire line is red. Storing these properties on a per-stroke basis is more memory-efficient.
  • new_stroke: This is a flag indicating the start of a new stroke. This is more of a state in our event-processing logic rather than data to be stored with every single point.

Based on this, a minimal, efficient C++ structure for a single point captured from our input stream would look like this. Since time is a critical factor in smoothing algorithms and performance analysis, we'll include a timestamp as well.




#include <cstdint>

struct InputPoint {
    float x;
    float y;
    float pressure;
    uint64_t timestamp; // Time of the event, e.g., from SDL_GetTicks64()
};

This InputPoint struct will be the building block of our stroke.


The Anatomy of a Stroke

With our InputPoint defined, we can now define the Stroke itself. A stroke consists of a sequence of these points, along with the properties that apply to the entire stroke, like color and brush size.

The natural C++ container for a dynamically growing sequence of elements is std::vector. It stores its elements in a contiguous block of memory, which is generally beneficial for cache performance during iteration—a topic we'll explore in depth in Module 6.

Here is a robust starting point for our Stroke structure:




#include <vector>
#include "InputPoint.h" // Assuming the struct above is in this file
#include <SkColor.h>    // Include from the Skia library for color types

struct Stroke {
    // Per-point data
    std::vector<InputPoint> points;

    // Per-stroke properties
    SkColor4f color;
    float baseWidth;
    bool isEraser;

    // You could add other properties here later, like the brush type, etc.
};

This design clearly separates the data that changes at every point (InputPoint) from the data that is constant for the whole stroke (color, baseWidth). This is a classic Array-of-Structs (AoS) layout. While this is a great default choice, we will challenge this assumption later in the course when we discuss high-throughput data structures and Struct-of-Arrays (SoA) layouts for maximum performance.


Industry Perspectives on Stroke Data

This design pattern is not just theoretical; it's used in real-world, high-performance graphics applications. Let's look at how others have approached this problem.

First, consider Google's ink-stroke-modeler, a library purpose-built for high-quality, low-latency handwriting input. Its design is highly relevant to your project goals.

GitHub - google/ink-stroke-modeler: C++ library for beautifully smoothing freehand (touch/stylus/pointer) input. · GitHub

This document provides a detailed, mathematical breakdown of how the library models a stroke. The definitions it uses for input data are an excellent example of professional-grade API design.

In the "Implementation Details" section, find the subsection titled "Math Stuff". Read the definitions for raw input, tip state, and stylus state. Notice how their "raw input" struct is nearly identical to our InputPoint, including position, time, and pressure (σ), plus tilt and orientation. This confirms our design is on the right track. The "tip state" and "stylus state" show how they separate the positional/kinetic data from the stylus property data, similar to our InputPoint vs. Stroke separation.

For another perspective, let's look at a code snippet from an open-source painting application. This shows a more direct, imperative style of building the point list within the event handlers.

Learning C++ and achieving a decent brush - valdyas.org

This blog post details a developer's journey implementing a brush tool. The provided C++ code, while using the Qt framework, demonstrates the exact logic of capturing and storing points for a stroke.

Scan through the code for the following methods: mousePress: Notice the line m_points = new QPointArray(1);. This is where a new list of points is created when the stroke begins. QPointArray is Qt's equivalent of std::vector<QPoint>. mouseMove: Here, the code appends a new point to the m_points array with each movement event. tabletEvent: This is the most relevant part. The code checks if (pressure >= 5 && m_mode == PAINT_STYLUS) and then calls paint, passing in the position and pressure. This shows the same pattern: on a motion event with pressure, the point data is captured and used.


Implementation in Practice

Now, let's integrate our new Stroke and InputPoint structures into the SDL2 event loop from the last lesson. We'll need a way to manage the "current" stroke being drawn and a place to store all the completed strokes.

A std::vector<Stroke> can hold all completed strokes, and a std::optional<Stroke> is a perfect modern C++ tool to represent the currently active stroke, which may or may not exist.

Here’s how the logic would fit into your main loop:




#include <SDL.h>
#include <vector>
#include <optional>
#include "Stroke.h" // Our new header containing Stroke and InputPoint

// Global state for our application
std::vector<Stroke> completedStrokes;
std::optional<Stroke> currentStroke;

// Assume 'window' is initialized and we have its dimensions
// int windowWidth, windowHeight;

// Inside your main application loop
while (running) {
    while (SDL_PollEvent(&event)) {
        switch (event.type) {
            case SDL_QUIT:
                running = false;
                break;
            
            case SDL_FINGERDOWN: {
                // Start a new stroke
                currentStroke.emplace(); // Construct a new Stroke in-place
                currentStroke->color = {0.1f, 0.1f, 0.1f, 1.0f}; // Default: black
                currentStroke->baseWidth = 5.0f;
                currentStroke->isEraser = false;
                
                // Add the first point
                InputPoint p = {
                    .x = event.tfinger.x * windowWidth,
                    .y = event.tfinger.y * windowHeight,
                    .pressure = event.tfinger.pressure,
                    .timestamp = SDL_GetTicks64()
                };
                currentStroke->points.push_back(p);
                break;
            }

            case SDL_FINGERMOTION: {
                if (currentStroke) { // Only if a stroke is in progress
                    InputPoint p = {
                        .x = event.tfinger.x * windowWidth,
                        .y = event.tfinger.y * windowHeight,
                        .pressure = event.tfinger.pressure,
                        .timestamp = SDL_GetTicks64()
                    };
                    currentStroke->points.push_back(p);
                }
                break;
            }
            
            case SDL_FINGERUP: {
                if (currentStroke) {
                    // Optional: Add the final point to ensure the line ends where the user lifted.
                    // This can sometimes be a duplicate of the last motion event, but is often necessary.
                    InputPoint p = {
                        .x = event.tfinger.x * windowWidth,
                        .y = event.tfinger.y * windowHeight,
                        .pressure = 0.0f, // Pressure is zero on 'up'
                        .timestamp = SDL_GetTicks64()
                    };
                    currentStroke->points.push_back(p);

                    // Move the completed stroke to our list and clear the current one
                    completedStrokes.push_back(std::move(*currentStroke));
                    currentStroke.reset();
                }
                break;
            }
        }
    }

    // --- Rendering logic would go here ---
    // For now, you could print the number of strokes and points to verify:
    // std::cout << "Completed Strokes: " << completedStrokes.size() << "\n";
    // if (currentStroke) {
    //     std::cout << "Current Stroke Points: " << currentStroke->points.size() << "\n";
    // }
}

This code provides a complete, logical cycle: SDL_FINGERDOWN creates a stroke, SDL_FINGERMOTION adds points to it, and SDL_FINGERUP finalizes it and adds it to our permanent list of completedStrokes. We use std::move to efficiently transfer the finished stroke's data without making a full copy.


Conclusion

In this lesson, you've designed and implemented the fundamental data model for user input in your whiteboard application. This is a critical step that bridges the gap between raw hardware events and a structured, usable representation of the user's drawing.

Key Takeaways:

  • A user stroke can be modeled as a Stroke struct containing a std::vector of InputPoints.
  • The InputPoint struct should contain per-point data like position, pressure, and a timestamp.
  • The Stroke struct should contain properties for the entire stroke, such as color and brush width, to be memory-efficient.
  • The event loop logic for managing strokes involves creating a new stroke on a DOWN event, adding points on MOTION events, and finalizing the stroke on an UP event.
  • Using std::optional is an effective way to manage the state of the "current" stroke being drawn.

We now have all the data we need to draw something. But a list of points is not yet a line. In our next lesson, you will take this Stroke data structure and use it to generate a smooth, variable-width SkPath that can be rendered beautifully by Skia.