Skip to main content

Welcome to a Grasp lesson

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

Lesson 68

Rendering with Min-Max Trees

Recommended resources

Hello! Let's get our high-performance plotter up and running on the screen.

In the last lesson, we designed the core decimation algorithm. You learned how to iterate through the pixel columns of your plot, query the MinMaxTree for the corresponding data range, and generate a std::vector of PlotPrimitive structs. This vector is an optimized summary of our massive dataset, containing only one {screen_x, min_val, max_val} entry for each pixel column.

Today, we will turn that abstract list of primitives into a visual reality. This lesson directly addresses the learning outcome: Render the plot using the min-max tree, drawing single vertical bars when data density exceeds pixel density. We'll use ImGui's low-level drawing API to render these primitives, completing the visual component of our Level-of-Detail plotter.

From Primitives to Pixels: The ImDrawList API

ImGui's standard widgets like ImGui::Button or ImGui::Text are great, but they don't give us the fine-grained control needed for a custom, high-performance plot. For that, we need to go a level deeper and use the ImDrawList API.

Every ImGui window has an associated ImDrawList, which is essentially a command buffer for drawing 2D shapes like lines, rectangles, and text. We can get a pointer to the current window's draw list and add our own custom drawing commands to it.

ImDrawList* draw_list = ImGui::GetWindowDrawList();

The most important function for us will be AddLine():

void AddLine(const ImVec2& p1, const ImVec2& p2, ImU32 col, float thickness = 1.0f);

This function adds a line-drawing command to the list. p1 and p2 are the start and end points in screen coordinates, col is the color packed into a 32-bit integer, and thickness is the line width in pixels.

This is the tool we will use to render both the vertical bars for our decimated data and the connected lines for when we are zoomed in.


Rendering the Decimated View (Zoomed Out)

This is the primary use case for our LOD plotter. We have our vector of PlotPrimitive structs, and our goal is to draw a vertical line for each one.

The process for each primitive is:

  1. The primitive's screen_x is already in screen coordinates.
  2. The min_val and max_val are in "world" (data) coordinates. We need to convert them to screen-space Y coordinates. This involves the worldToScreen transformation we developed in a previous lesson.
  3. Call draw_list->AddLine() with the calculated screen coordinates.

Let's revisit the worldToScreen transformation, focusing on the Y-axis. Remember that screen coordinates typically have Y=0 at the top, while our plot's data has its minimum Y-value at the bottom.

// A helper function to transform a single world-space point to screen-space
ImVec2 worldToScreen(ImVec2 world_pos, ImVec2 pan, ImVec2 zoom, ImVec2 canvas_pos, ImVec2 canvas_size) {
    // Apply pan and zoom
    float transformed_x = (world_pos.x - pan.x) * zoom.x;
    float transformed_y = (world_pos.y - pan.y) * zoom.y;

    // Convert to screen coordinates
    // Y is inverted: a smaller world y-value should result in a larger screen y-value.
    float screen_x = canvas_pos.x + transformed_x;
    float screen_y = canvas_pos.y + canvas_size.y - transformed_y;

    return {screen_x, screen_y};
}

// A simplified version just for the Y-coordinate of our primitives
float worldYToScreenY(float world_y, float pan_y, float zoom_y, ImVec2 canvas_pos, ImVec2 canvas_size) {
    float transformed_y = (world_y - pan_y) * zoom_y;
    return canvas_pos.y + canvas_size.y - transformed_y;
}

Now, we can loop through our primitives and draw them:

// Assumes 'primitives' vector is already populated from the last lesson
ImDrawList* draw_list = ImGui::GetWindowDrawList();
const ImU32 plot_color = IM_COL32(255, 100, 100, 255); // A reddish color

for (const auto& prim : primitives) {
    // prim.screen_x is already correct
    float screen_y_min = worldYToScreenY(prim.min_val, ...); // Pass pan, zoom, etc.
    float screen_y_max = worldYToScreenY(prim.max_val, ...);

    draw_list->AddLine(
        { prim.screen_x, screen_y_min },
        { prim.screen_x, screen_y_max },
        plot_color,
        1.0f
    );
}

This simple loop takes our highly optimized data summary and efficiently renders it. The number of draw calls is now proportional to the plot's pixel width, not the underlying data size. This is the core idea described in the article we reviewed.

A Speed-Up for Charting on Embedded - Introducing Min– ...

The article 'A Speed-Up for Charting on Embedded' perfectly describes the visual outcome we are now achieving. It's worth re-reading to connect the theory to our implementation.

Review the section 'Problem: Waveform Graphs of Large Data Sets', noting the sentence: 'we draw each vertical pixel column as one line reaching from the minimum sample to the maximum sample.' This is exactly what our code now does. Then, look at the 'Results' section to see the performance benefits of this technique, which is the entire motivation for our project.


Rendering the Raw View (Zoomed In)

What happens when the user zooms in so far that the number of visible data points is less than the number of pixels on the canvas? Drawing vertical bars would look sparse and incorrect. In this case, we should switch to drawing connected lines between each consecutive data point for a smooth, accurate view.

This will be the else branch of the logic we sketched out in the previous lesson.

The algorithm is:

  1. Determine the visible range of data indices, [data_idx_start, data_idx_end].
  2. Loop from i = data_idx_start to data_idx_end - 1.
  3. In each iteration, get two consecutive points: p1_world = {i, data[i]} and p2_world = {i+1, data[i+1]}.
  4. Transform both points from world space to screen space using our full worldToScreen helper function.
  5. Call draw_list->AddLine() to draw a line between the two resulting screen-space points.
// Inside the 'else' block where decimation is not needed
ImDrawList* draw_list = ImGui::GetWindowDrawList();
const ImU32 plot_color = IM_COL32(100, 255, 100, 255); // A greenish color for contrast

// Assume data_idx_start and data_idx_end are calculated
for (size_t i = data_idx_start; i < data_idx_end; ++i) {
    ImVec2 p1_world = { static_cast<float>(i),   all_data[i] };
    ImVec2 p2_world = { static_cast<float>(i+1), all_data[i+1] };

    ImVec2 p1_screen = worldToScreen(p1_world, ...); // Pass pan, zoom, etc.
    ImVec2 p2_screen = worldToScreen(p2_world, ...);

    draw_list->AddLine(p1_screen, p2_screen, plot_color, 1.0f);
}

By implementing both rendering paths, your plotter will gracefully handle any zoom level, always choosing the most appropriate and performant visualization strategy.

Test your understanding!

Imagine your plot canvas y ranges from 100 (top) to 600 (bottom) pixels on the screen, a total height of 500 pixels. Your visible data y range (after panning and zooming) is [-5.0, 5.0].

Question: You need to draw a primitive whose max_val is 2.5. What is the final screen-space Y coordinate for this value? Assume for simplicity that pan.y is 0 and zoom.y maps the [-5, 5] range to the full canvas height.

Show answer
  1. Normalize the value: The data range is 5.0 - (-5.0) = 10.0 units. The value 2.5 is 7.5 units from the bottom (2.5 - (-5.0)), which is 7.5 / 10.0 = 0.75 or 75% of the way up from the bottom of the data range.

  2. Calculate screen position (inverted): The canvas height is 500 pixels. We want to be 75% of the way up from the bottom edge (y=600). This means we are 25% of the way down from the top edge (y=100).

    • Pixel offset from top: 0.25 * 500 = 125 pixels.
    • Final screen Y: 100 (top) + 125 = 225.

Alternatively, using a formula:
screen_y = top_y + (1.0 - normalized_val) * height
screen_y = 100 + (1.0 - 0.75) * 500 = 100 + 0.25 * 500 = 100 + 125 = 225.


Conclusion

Congratulations! You have now implemented the complete rendering pipeline for the LOD plotter. You can take a massive dataset, efficiently summarize it using a min-max tree, and render that summary in a way that guarantees high performance regardless of data size or zoom level.

Key Takeaways:

  • The ImDrawList API is ImGui's tool for custom, low-level 2D rendering.
  • We use ImGui::GetWindowDrawList() to get the drawing context and AddLine() to draw our plot.
  • When zoomed out (high data density): We iterate our PlotPrimitive vector and draw vertical lines representing the min/max range for each pixel column.
  • When zoomed in (low data density): We switch to drawing connected lines between individual data points for a smooth, detailed plot.
  • Coordinate transformations (worldToScreen) are critical to correctly position the data on the canvas, especially handling the inverted Y-axis.

What's Next?

Our plotter is now functional and fast. In the next lesson, we will add a final layer of optimization: culling. Currently, our decimation loop iterates over all pixels in the canvas, even those that might correspond to data indices outside our dataset's bounds (e.g., if you pan far to the left or right). We'll implement culling logic to ensure we only query and draw within the valid data range, making our plotter even more robust and efficient.