Hello! Welcome to the final lesson in our module on integrating and polishing the telemetry engine.
In the last lesson, you successfully packaged the entire application into a single, self-contained executable using static linking. This was a crucial step in turning your project into a professional, distributable piece of software. You now have a working, feature-complete plotter that you can share and run anywhere.
This lesson addresses the learning outcome: Refactor code to improve modularity, separating data capture, processing, and visualization logic. While our application works, its internal structure can be significantly improved. Refactoring for modularity is not just about "tidying up"; it's a fundamental practice that enhances maintainability, testability, and scalability. For our project, it's also a core tenet of the Data-Oriented Design philosophy we've been following.
The Data-Oriented Pipeline
At its heart, Data-Oriented Design (DOD) is about focusing on the data and its transformations. A well-designed system makes this data flow explicit. Instead of thinking about objects that "do things," we think about systems that transform data from one form to another.
Our telemetry engine has three clear transformation stages:
- Capture: Raw events (function calls, variable changes) are generated by the main application and pushed into lock-free ring buffers.
- Processing: A background thread consumes these raw events, consolidates them, and transforms them into a format optimized for visualization (our min-max tree).
- Visualization: The UI thread reads the optimized data and transforms it into pixels on the screen (our ImGui plot).
This structure is often called a pipeline. Each stage takes data from the previous one, transforms it, and passes it to the next.

By explicitly separating our code into modules that match these stages, we make the data pipeline the central organizing principle of our application. This is a hallmark of Data-Oriented Design.

Why This Matters: The Benefits of a Modular DOD Approach
Separating logic into distinct systems isn't just an abstract design goal. It brings concrete benefits that are especially powerful in high-performance applications.
To understand these advantages, let's start with a foundational article on Data-Oriented Design.
Data-Oriented Design (Or Why You Might Be Shooting Yourself in The Foot With OOP)
The article 'Data-Oriented Design' by Noel Llopis is a classic introduction to the topic. It explains why traditional OOP can fall short and what benefits a data-first approach offers.
Please read the sections 'Advantages of Data-Oriented Design' and 'Applying Data-Oriented Design'. Focus on the points about parallelization, cache utilization, modularity, and ease of testing. These directly justify the refactoring work we are about to do.
As the article highlights, structuring our code this way yields several key advantages:
- Clarity and Maintainability: Anyone reading your code can immediately understand the flow of data.
main.cppbecomes a simple coordinator, not a monolith of mixed logic. - Testability: Each system can be tested in isolation. You can write a unit test for the
Processorby feeding it mock data and checking its output (the min-max tree), without ever initializing a graphics context or an ImGui window. - Performance: By separating concerns, you often improve cache performance. The
Processor's code and data stay hot in the cache on its thread, while theVisualizer's code and data stay hot on the main thread, reducing conflicts. - Flexibility: Want to add a new visualization, like a histogram? You only need to add a new
Visualizerclass that reads from theProcessor's data. Want to send telemetry over the network? Add a new consumer that reads from the capture buffers. The core logic remains unchanged.
A Blueprint for Refactoring
Let's define a clean architecture for our application. We'll create three primary components, likely as C++ classes or structs, each with a single responsibility.
-
TelemetryCapture(The Capture System)- Responsibility: Manages the creation and access to the per-thread, SPSC lock-free ring buffers.
- Interface: Provides a static method, e.g.,
LogEvent(...), which will be called by instrumentation macros. This method finds the correct buffer for the current thread and pushes data into it. It also provides a way for the processor to get access to all the buffers. - Data Owned: A collection of SPSC ring buffers.
-
TelemetryProcessor(The Processing System)- Responsibility: Consolidate events from all capture buffers and transform them into the visualization data structure.
- Interface: A
Run()orProcessEvents()method that runs on a dedicated consumer thread. This method continuously polls the ring buffers, drains them of events, and rebuilds/updates the min-max tree. - Data Owned: The min-max tree and any other data structures needed for visualization. It gets read-only access to the ring buffers from the
TelemetryCapturesystem.
-
TelemetryVisualizer(The Visualization System)- Responsibility: All rendering and UI interaction logic.
- Interface: A
Render()method that is called every frame from the main application loop. This method contains all theImGui::calls. - Data Owned: UI state (e.g., zoom/pan levels). It takes a
constreference to the visualization data (the min-max tree) from theTelemetryProcessor.
The main.cpp file will then be responsible only for creating these systems, wiring them together, and running the main application loop.
Test your understanding!
Question: In the proposed architecture, the TelemetryVisualizer has const (read-only) access to the min-max tree owned by the TelemetryProcessor. Why is this const qualification important for ensuring a clean, modular design and preventing bugs?
Show answer
The const qualification is crucial because it enforces the one-way data flow of the pipeline. The Processor's job is to produce the visualization data, and the Visualizer's job is to consume it. By making the data read-only for the Visualizer, we prevent the UI code from accidentally modifying the data structure. This is critical in a multithreaded context. The Processor is modifying the tree on its own thread; if the Visualizer (on the main thread) also tried to modify it, we would have a data race. The const contract makes this separation of concerns explicit and compiler-enforced.
From Theory to Practice: Applying the Refactor
The idea of separating an "update" function that mixes logic into distinct data transformation stages is a cornerstone of DOD. The following video gives a fantastic, practical demonstration of this exact process.
Intro to Data Oriented Design for Games
This video, 'Intro to Data Oriented Design for Games' by Nic Barker, provides an excellent practical example of refactoring traditional game code into a data-oriented style. The first major optimization shown is a direct parallel to our goal.
Watch the section from 25:53 to 28:26. Notice how he replaces the individual update() function on each Enemy with a single, bulk ProcessEnemies() function. This is conceptually identical to us moving our mixed logic from the main loop into dedicated Processor and Visualizer systems.
Now, it's time to apply this to your project. Here is a step-by-step guide to refactoring your code:
-
Create New Files: Start by creating the header and source files for your new systems:
telemetry_processor.h/telemetry_processor.cpptelemetry_visualizer.h/telemetry_visualizer.cpp- You might also create
telemetry_capture.h/telemetry_capture.cppto house the instrumentation logic.
-
Define the Class Skeletons: In the new header files, define the classes as described in the blueprint above. Think carefully about the public interface (
publicmethods) versus the internal implementation (privatemembers and methods). -
Migrate the Code:
- Go through your
main.cppand other files. - Move all the ImGui rendering code (the
ImGui::Begin,ImGui::PlotLines,ImDrawListusage, etc.) into theTelemetryVisualizer::Render()method. - Move the logic from your consumer thread (polling queues, updating the min-max tree) into the
TelemetryProcessorclass. - Move the management of the ring buffers and the
LogEventmacro/function into theTelemetryCapturesystem.
- Go through your
-
Wire Everything Together in
main(): Yourmainfunction should now become much cleaner. It will look something like this:#include "telemetry_capture.h" #include "telemetry_processor.h" #include "telemetry_visualizer.h" #include <thread> int main() { // ... GLFW/ImGui initialization ... // 1. Create the systems auto& captureSystem = TelemetryCapture::GetInstance(); // Assuming singleton for global access TelemetryProcessor processor(captureSystem.GetBuffers()); TelemetryVisualizer visualizer(processor.GetPlotData()); // 2. Start the background processing thread std::thread processorThread(&TelemetryProcessor::Run, &processor); // 3. Main application loop while (!glfwWindowShouldClose(window)) { // ... ImGui new frame boilerplate ... // Simulate some data capture TelemetryCapture::LogEvent("Frame Start"); // The visualizer handles all UI visualizer.Render(); // ... ImGui rendering boilerplate ... } // Cleanup processor.Stop(); processorThread.join(); // ... GLFW/ImGui shutdown ... return 0; }
This refactoring separates your application's concerns, making the entire project easier to understand, maintain, and extend. It transforms your code from a simple script into a well-architected system.
Conclusion
Congratulations on completing this crucial refactoring step! You've taken a working application and elevated its internal quality to a professional standard. This modular structure not only makes your code better but also makes the data-oriented design philosophy tangible in your project's architecture.
Key Takeaways:
- Modularity in DOD is about defining a clear data pipeline. Your systems should be organized around the flow and transformation of data.
- The primary stages in our pipeline are Capture, Processing, and Visualization.
- Separating code into these modules improves maintainability, testability, flexibility, and performance.
- The interfaces between modules are the data structures they pass to each other. Using
constreferences is a powerful way to enforce one-way data flow.
What's Next?
Your telemetry engine is now feature-complete, packaged, and well-structured. The foundation is solid. In the next module, Benchmarking and Performance Verification, we will finally get to measure how well our system performs under stress. We'll start by designing a microbenchmark to measure the raw throughput of the lock-free SPSC queue you built, putting our performance claims to the test.