Overview
In the Renderer and Refactoring lab (Lab 14) you assembled six abstraction classes into a
clean main.cpp. As the number of rendering experiments grows, editing that single
file to switch between them becomes unmanageable. This lab introduces a lightweight
test harness - a plugin architecture where each experiment lives in its own
class and registers itself with one line. You will build Tests.h and
Tests.cpp, wire the dispatch loop into main.cpp, and end with a
running application whose ImGui panel is ready to host future test scenes. Concrete tests
start in the Test Template lab (Lab 16).
Learning Objectives
By the end of this lab you will be able to:
- Explain how virtual functions allow the main loop to call Update, Render, and RenderGUI on any test scene without knowing its concrete type
- Distinguish between pure virtual methods and virtual methods with default empty bodies, and explain which is appropriate here
- Describe the factory pattern and explain how RegisterTest stores a construction lambda that is called when the user selects a scene
- Implement TestMenu::RenderGUI so ImGui buttons call the matching factory and activate the selected test
- Wire the test harness into main.cpp so every frame is dispatched through the active test pointer, with correct cleanup on exit
Concept
As a graphics project grows, placing every rendering experiment in main.cpp
directly forces you to comment and uncomment large code sections each time you switch focus.
The file accumulates unrelated state, and touching one experiment risks breaking another.
What you need is a way to add new scenes without modifying existing code.
The solution combines a polymorphic interface with a factory
pattern. The Tests class declares three virtual methods -
Update, Render, and RenderGUI - each with an empty
default body so a derived scene overrides only what it uses. TestMenu holds a
std::vector of name-and-factory pairs. Each factory is a
std::function<Tests*()> lambda that constructs and returns the matching
scene object when called. When the user clicks a button in the ImGui panel,
TestMenu::RenderGUI invokes the factory and stores the result in a
Tests* pointer that lives in main.cpp. The main loop then calls
Update, Render, and RenderGUI on whatever that
pointer holds, with no knowledge of the concrete type. Adding a new scene requires only a
new class and one RegisterTest call - no existing code changes.
Two C++ features make this clean. The TestMenu constructor takes a
reference-to-pointer (Tests*&), giving the menu direct
write access to the pointer in main.cpp when a button is clicked - no global
required. Registration uses a function template:
RegisterTest<T>(name) captures the type T in a lambda at
compile time so the factory can call new T() at runtime. The variadic overload
RegisterTest<T, Args...> handles tests whose constructors need arguments:
it packs them into a std::tuple with std::make_tuple and unpacks
them later with std::apply.
Tasks
Task 1 - Read the Tests base class
This task is read-only. The complete header is provided below as a reference. Work through it section by section and consider the question in each step. You do not need to write any code yet.
-
Locate the three virtual methods on
Tests:Update,Render, andRenderGUI. Each has an empty default body{}. Explain why a default empty body is more useful here than making the methods pure virtual (declared with= 0). -
Find the
virtual ~Tests()destructor. Imagine aTests*pointer holds a derived scene object and you calldeleteon it. What resource leak occurs if~Tests()is not virtual? -
Find
RegisterTest<T>(the no-argument overload). The lambda[]() -> Tests* { return new T(); }is stored inm_Testsnow but called later. InTestMenu::RenderGUI(which you implement in Task 2), identify the exact expression that invokes it. -
Read the variadic
RegisterTest<T, Args...>overload. Why must constructor arguments be packed into astd::tuplerather than captured directly in the lambda? What doesstd::applydo with the tuple when the factory runs? -
Examine the
m_Testsmember type:std::vector<std::pair<std::string, std::function<Tests*()>>>. Why isstd::function<Tests*()>required here rather than a plain function pointer of typeTests*(*)()?
#pragma once
#include <functional>
#include <string>
#include <vector>
#include <memory>
namespace test
{
// =========================================================================
// BASE TEST CLASS
// =========================================================================
/**
* Tests - Abstract base class for all test scenes.
*
* VIRTUAL DESTRUCTOR:
* Without it, deleting a derived object through a Tests* calls only the
* base destructor, leaking the derived class's resources.
*
* DEFAULT IMPLEMENTATIONS:
* Empty {} bodies allow derived classes to override only the methods they use.
* If these were pure virtual (= 0), every derived class would be forced to
* implement all three even when some are not needed.
*/
class Tests
{
public:
Tests() {}
virtual ~Tests() {}
virtual void Update(float deltaTime) {}
virtual void Render() {}
virtual void RenderGUI() {}
};
// =========================================================================
// TEST MENU (Factory Pattern)
// =========================================================================
/**
* TestMenu - manages test registration and instantiation.
*
* FACTORY PATTERN:
* Each test is registered with a factory lambda. Adding a new test requires
* only a RegisterTest call - TestMenu itself never needs to change.
*
* TYPE ERASURE:
* std::function<Tests*()> can hold any callable that returns a Tests*.
* This allows heterogeneous factory lambdas to live in a single vector.
*
* REFERENCE-TO-POINTER (Tests*&):
* The constructor takes Tests*& so the menu can update the caller's pointer
* directly when a button is clicked - no global variable needed.
*/
class TestMenu : public Tests
{
public:
TestMenu(Tests*& currentTestPointer);
void RenderGUI() override;
// Register a test that needs no constructor arguments.
// A lambda captures the type T and constructs it on demand.
template<typename T>
void RegisterTest(const std::string& name)
{
m_Tests.push_back(std::make_pair(name, []() -> Tests* { return new T(); }));
}
// Register a test whose constructor requires arguments.
// Arguments are packed into a tuple now and unpacked with std::apply
// when the factory is called later.
template<typename T, typename... Args>
void RegisterTest(const std::string& name, Args&&... args)
{
m_Tests.push_back(std::make_pair(name,
[args = std::make_tuple(std::forward<Args>(args)...)]() -> Tests* {
return std::apply([](auto&&... unpackedArgs)
{
return new T(std::forward<decltype(unpackedArgs)>(unpackedArgs)...);
}, args);
}));
}
private:
Tests*& m_CurrentTest;
std::vector<std::pair<std::string, std::function<Tests* ()>>> m_Tests;
};
}
Task 2 - Implement TestMenu
Create src/tests/Tests.cpp in your project and fill in the one function
marked with a // TODO comment.
-
In
TestMenu::RenderGUI, theforloop already iterates overm_Tests. Inside the loop, callImGui::Button(test.first.c_str()). This renders a labelled button and returnstrueon the frame the user clicks it. When it returnstrue, calltest.second()to invoke the factory and assign the returned pointer tom_CurrentTest. -
Build the project. There should be no errors at this point. The menu will show no
buttons yet because no tests have been registered in
main.cpp.
#include "Tests.h"
#include "../vendor/imgui/imgui.h"
namespace test
{
TestMenu::TestMenu(Tests*& currentTestPointer) : m_CurrentTest(currentTestPointer)
{
}
void TestMenu::RenderGUI()
{
for (auto& test : m_Tests)
{
// TODO: Create an ImGui button labelled with test.first
// When clicked, call test.second() to invoke the factory
// and assign the returned pointer to m_CurrentTest
// Hint: ImGui::Button(test.first.c_str()) returns true on click
}
}
}
Task 3 - Wire the harness into main.cpp
Update src/Main.cpp to route every frame through the test harness. Follow
the numbered TODO comments in the skeleton below in order.
-
Declare
test::Tests* currentTest = nullptr;just afterImGui_ImplOpenGL3_Init(). This pointer is what the main loop reads every frame to know which scene to dispatch to. -
Construct the menu:
test::TestMenu* TestMenu = new test::TestMenu(currentTest);. PassingcurrentTestby reference gives the menu direct write access to the pointer in this scope so a button click can update it without a global variable. -
Set the initial active scene:
currentTest = TestMenu;. BecauseTestMenuinheritsTests, the same dispatch block that runs other scenes will render the menu itself on the first frame with no special case. -
Inside the render loop, add the dispatch block from TODO 4. The guard
if (currentTest != TestMenu)on the back button prevents the menu from deleting itself and leaving a dangling pointer. -
After the render loop, add cleanup using the two-step pattern from TODO 5:
if (currentTest != TestMenu) delete currentTest;thendelete TestMenu;. This avoids a double-free when the user closes the window while the menu is still the active scene. - Build and run. The application should open a window with an ImGui Test control panel window containing no buttons yet. The harness is ready - tests will be added from Lab 16 onward.
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include "Renderer.h"
#include "tests/Tests.h"
#include "vendor/imgui/imgui.h"
#include "vendor/imgui/imgui_impl_glfw.h"
#include "vendor/imgui/imgui_impl_opengl3.h"
#define IMGUI_IMPL_OPENGL_LOADER_GLEW
int main()
{
if (!glfwInit()) return -1;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(1280, 720, "OpenGL", NULL, NULL);
if (!window) { glfwTerminate(); return -1; }
glfwMakeContextCurrent(window);
glewExperimental = GL_TRUE;
if (glewInit() != GLEW_OK) { glfwTerminate(); return -1; }
glfwSwapInterval(1);
{
Renderer renderer;
ImGui::CreateContext();
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui::StyleColorsDark();
ImGui_ImplOpenGL3_Init();
// TODO 1: Declare the active-test pointer
// test::Tests* currentTest = nullptr;
// TODO 2: Create the TestMenu, passing currentTest by reference
// test::TestMenu* TestMenu = new test::TestMenu(currentTest);
// TODO 3: Set the menu as the initial active scene
// currentTest = TestMenu;
// Tests will be registered in future labs. The syntax is:
// TestMenu->RegisterTest<test::SomeTest>("Label");
// TestMenu->RegisterTest<test::SomeTest>("Label", arg1, arg2);
float lastFrameTime = 0.0f;
while (!glfwWindowShouldClose(window))
{
float now = (float)glfwGetTime();
float deltaTime = now - lastFrameTime;
lastFrameTime = now;
renderer.Clear();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
// TODO 4: Add the dispatch block:
//
// if (currentTest)
// {
// currentTest->Update(deltaTime);
// currentTest->Render();
// ImGui::Begin("Test control panel");
// if (currentTest != TestMenu && ImGui::Button("<-"))
// {
// delete currentTest;
// currentTest = TestMenu;
// }
// currentTest->RenderGUI();
// ImGui::End();
// }
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window);
glfwPollEvents();
}
// TODO 5: Clean up - avoid a double-free when the menu is still active
// if (currentTest != TestMenu)
// delete currentTest;
// delete TestMenu;
}
ImGui_ImplGlfw_Shutdown();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
Solution
Try the tasks above before revealing the solution.