Now that we have onboarded all our platform targets we can start concentrating more on authoring our C++ application code. As hinted at in the previous article, the code we have written so far will get our app up and running, but some parts are actually a bit smelly.
The goals of this article are:
application
class which knows how to run the main loop we made but doesn’t prescribe the graphics technology to use.application
concept to model an OpenGL application - with a view that at a later point we would also have a Vulkan application.main.cpp
class into an engine
class, which will be responsible for resolving the correct application
and running it.Note: I will use the term
class
a lot, but in fact most of the objects we will write will syntactically bestructs
.
So, crack open Visual Studio Code, open our a-simple-triangle-workspace
and hop into the CMake view. We can make all of these changes using our console
application which is one of the main reasons we wrote it. Using our console application will make the C++ coding quite fast to iterate compared to other platforms which have heavy startup overheads to see code changes.
Note: Don’t forget, whenever we add new source files we need to press the small CMake icon (next to the little hammer icon) in Visual Studio Code so it refreshes and picks up the new files. Burn this action into your grey matter because I will be assuming that you do this as we add source files. Remember also when revisiting the iOS and MacOS platforms that we need to run their
setup.sh
scripts to pick up file additions or removals as well. Android and Emscripten don’t need to do this because theirCMakeLists.txt
rules should automatically pick up new code.
In our main.cpp
class, we had a simple std::cout
statement to print some output to the console. There are a couple of problems with this:
release
builds - only debug
builds; andstd::cout
on its own doesn’t even show up by default on Android.Let’s add a new class that does the following:
debug
build, emit the output as normal, otherwise don’t do anything.debug
mode, use the Android logging APIs to print to the Android Logcat system.Add the following new files:
main/src/core/log.hpp
main/src/core/log.cpp
Remember in Visual Studio Code:
File -> New
thenSave
.
Edit the log.hpp
header:
#pragma once
#include <string>
namespace ast
{
void log(const std::string& tag, const std::string& message);
void log(const std::string& tag, const std::string& message, const std::exception& error);
} // namespace ast
This header gives us two logging methods in the ast
namespace. They are free functions (are not member functions of any object) so we don’t need an instance of any type to call them. For example, any code that has imported the header file would log a message like this:
ast::log("TAG", "Some output message");
Edit the ‘log.cpp’ source:
#ifndef NDEBUG
#ifdef __ANDROID__
#include <android/log.h>
#endif
#endif
#include "log.hpp"
#include <iostream>
void ast::log(const std::string& tag, const std::string& message)
{
#ifndef NDEBUG
#ifdef __ANDROID__
__android_log_print(ANDROID_LOG_DEBUG, "a-simple-triangle", "%s: %s", tag.c_str(), message.c_str());
#else
std::cout << tag << ": " << message << std::endl;
#endif
#endif
}
void ast::log(const std::string& tag, const std::string& message, const std::exception& error)
{
#ifndef NDEBUG
std::string output = message + " Exception message was: " + std::string{error.what()};
ast::log(tag, output);
#endif
}
Consider the conditional we are using in this code:
#ifndef NDEBUG
...
#endif
The conditional will only evaluate if we are in a debug build. By wrapping blocks of code with this conditional, it won’t even be compiled into the application if it is not in debug mode.
As you can see, by using this conditional the log methods actually don’t do anything if we are not in a debug build.
This code also detects __ANDROID__
and routes the logging text through the Android logging system instead of std::cout
, because by default std::cout
messages don’t appear in Logcat
.
For some of the non trivial classes, I will be using a basic form of the Pointer to IMPLementation
, or PIMPL
pattern. Here are some articles about it:
We will write a simple move semantic only PIMPL implementation that is similar to one demonstrated in the last web link above. We will name this internal_ptr
- I just really can’t stand seeing the word pimpl
in my code.
Using this pattern allows us to have an internal object that forms the private implementation of a class, hiding all the private guts of a class from its public API. Our approach will use the basic functionality of the std::unique_ptr
type - so only move semantics will be possible. Add a new file main/src/core/internal_ptr.hpp
with the following code:
#pragma once
#include <memory>
namespace ast
{
namespace internal_ptr_deleter
{
// 1. Custom deleter.
template <class T>
void deleter(T* victim)
{
delete victim;
}
} // namespace internal_ptr_deleter
// 2. Template definition, alias of std::unique_ptr.
template <class T, class Deleter = void (*)(T*)>
using internal_ptr = std::unique_ptr<T, Deleter>;
// 3. Factory to create an internal_ptr instance.
template <class T, class... Args>
inline internal_ptr<T> make_internal_ptr(Args&&... args)
{
return internal_ptr<T>(new T(std::forward<Args>(args)...), &internal_ptr_deleter::deleter<T>);
}
} // namespace ast
internal_ptr
based object will automatically self destroy upon going out of scope.internal_ptr
type. Basically it is just an alias to a std::unique_ptr
with a custom deleter signature.internal_ptr
type. The factory method will forward all its arguments on to the target object constructor.We’ll make use of the internal_ptr
quite liberally as we go forward.
By the end of this series we would like to use OpenGL or Vulkan depending on which technology is available at run time. At the moment, our code base is fairly tightly coupled to use OpenGL. This won’t be too helpful when we want to use Vulkan down the track.
We will model a basic application
which can then have different implementations - an OpenGL implementation, and later when we get to it, a Vulkan implementation. Once we have these applications
, our code can try to pick the best one it can. For now, we will focus on moving our OpenGL code into an OpenGL application, starting with a base application class.
Create two new source files in main/src/application
(you will need to create the application
folder under main/src
):
Edit the application.hpp
file with:
#pragma once
namespace ast
{
struct Application
{
Application() = default;
virtual ~Application() = default;
void startApplication();
bool runMainLoop();
virtual void render() = 0;
};
} // namespace ast
The header defines some basic functions that would be common to both OpenGL or Vulkan (or probably other technologies too). By declaring an abstract render()
function (because it is marked virtual
and is assigned a value of 0
), we have effectively made this a base class.
Notice also that we are declaring the startApplication()
and runMainLoop()
functions - we are going to shift all the non OpenGL code in our main.cpp
file into the implementation of the application class. This will include the conditional logic to correctly start Emscripten, as well as the main event looping code. We will also make a tweak to how we start the Emscripten main loop.
Edit the application.cpp
file with:
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
#include "../core/sdl-wrapper.hpp"
#include "application.hpp"
using ast::Application;
namespace
{
#ifdef EMSCRIPTEN
void emscriptenMainLoop(ast::Application* application)
{
application->runMainLoop();
}
#endif
} // namespace
void Application::startApplication()
{
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop_arg((em_arg_callback_func) ::emscriptenMainLoop, this, 60, 1);
#else
while (runMainLoop())
{
// Just waiting for the main loop to end.
}
#endif
}
bool Application::runMainLoop()
{
SDL_Event event;
// Each loop we will process any events that are waiting for us.
while (SDL_PollEvent(&event))
{
switch (event.type)
{
case SDL_QUIT:
return false;
case SDL_KEYDOWN:
if (event.key.keysym.sym == SDLK_ESCAPE)
{
return false;
}
break;
default:
break;
}
}
// Perform our rendering for this frame.
render();
return true;
}
Points of interest:
Notice that we have the conditional Emscripten header include at the top.
We have declared the emscriptenMainLoop
function as a free function in an anonymous namespace private to this file. Notice also that it now takes an argument of type ast::Application* application
. This is how we can give our Emscripten main loop something contextual, in this case the instance of the application that should be controlled by the main loop.
To invoke the emscriptenMainLoop
we are now using a slightly different variant of the emscripten_set_main_loop
function:
emscripten_set_main_loop_arg((em_arg_callback_func) ::emscriptenMainLoop, this, 60, 1);
The first argument specifies which method Emscripten should invoke on every main loop iteration, cast to the em_arg_callback_func
type which is defined as:
typedef void (*em_arg_callback_func)(void*)
This means that the function we pass in, must take one argument of type void*
which pretty much means it can be a pointer to whatever we like.
Note: The
::
preceeding the::emscriptenMainLoop
argument is a way to explicitly call a method in the anonymous namespace in the current file. It isn’t strictly needed but I like using it to make it clear about where the function is located and disambiguate it from a class member function.
The second argument represents the argument we want to pass into the function, so we specify this
to send our current Application
instance to the function. This is how we are allowed to pass the argument ast::Application* application
then call the runMainLoop()
method upon it:
void emscriptenMainLoop(ast::Application* application)
{
application->runMainLoop();
}
The third and fourth arguments are the same as before.
Read this Emscripten documentation to learn more.
Now that we have a base application we can create our OpenGL application variant and move all our tightly coupled OpenGL code into it.
Create the following new files in main/src/application/opengl
(you will need to create the opengl
folder):
The opengl-application
will subclass our new application
class, meaning it will inherit the existing main loop code and basic event handling. The only thing our OpenGL variant needs to do is implement the ability to render
something each frame.
Edit opengl-application.hpp
to:
Note: This will be our first use of the
internal_ptr
we made earlier.
#pragma once
#include "../../core/internal-ptr.hpp"
#include "../application.hpp"
namespace ast
{
struct OpenGLApplication : public ast::Application
{
OpenGLApplication();
void render() override;
private:
struct Internal;
ast::internal_ptr<Internal> internal;
};
} // namespace ast
The header is fairly straight forward, we are extending the ast::Application
base class, declaring that we are implementing the render
function and applying our internal_ptr
pattern to hide the implementation from the public API.
Edit opengl-application.cpp
to:
#include "opengl-application.hpp"
#include "../../core/graphics-wrapper.hpp"
#include "../../core/log.hpp"
#include "../../core/sdl-wrapper.hpp"
#include <string>
using ast::OpenGLApplication;
namespace
{
SDL_GLContext createContext(SDL_Window* window)
{
static const std::string logTag{"ast::OpenGLApplication::createContext"};
SDL_GLContext context{SDL_GL_CreateContext(window)};
#ifdef WIN32
glewInit();
#endif
int viewportWidth;
int viewportHeight;
SDL_GL_GetDrawableSize(window, &viewportWidth, &viewportHeight);
ast::log(logTag, "Created OpenGL context with viewport size: " + std::to_string(viewportWidth) + " x " + std::to_string(viewportHeight));
glClearDepthf(1.0f);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glEnable(GL_CULL_FACE);
glViewport(0, 0, viewportWidth, viewportHeight);
return context;
}
} // namespace
struct OpenGLApplication::Internal
{
SDL_Window* window;
SDL_GLContext context;
Internal() : window(ast::sdl::createWindow(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI)),
context(::createContext(window)) {}
void render()
{
SDL_GL_MakeCurrent(window, context);
glClearColor(0.3f, 0.7f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
SDL_GL_SwapWindow(window);
}
~Internal()
{
SDL_GL_DeleteContext(context);
SDL_DestroyWindow(window);
}
};
OpenGLApplication::OpenGLApplication() : internal(ast::make_internal_ptr<Internal>()) {}
void OpenGLApplication::render()
{
internal->render();
}
Let’s step through this implementation a piece at a time. A lot of it looks very similar to the code in our main.cpp
file so parts of it should feel familiar.
Create OpenGL context function
We have a free function createContext
that simply creates the OpenGL context we need and does some basic OpenGL setup. This code is somewhat similar to the code in the runApplication
function of main.cpp
, except that it returns the context to the caller and we have changed the approach to computing the viewport size.
The viewport size calculation has changed because on some platforms such as iOS the actual renderable size might be at a higher DPI than the reported display size. Typically on iOS retina devices the renderable size would be twice the size of the display size. If we didn’t change this then our application would only render at a quarter of the screen size on iOS retina devices. The SDL_GL_GetDrawableSize
function gives us the true size which our viewport needs in order to fill the screen.
namespace
{
SDL_GLContext createContext(SDL_Window* window)
{
static const std::string logTag{"ast::OpenGLApplication::createContext"};
SDL_GLContext context{SDL_GL_CreateContext(window)};
#ifdef WIN32
glewInit();
#endif
int viewportWidth;
int viewportHeight;
SDL_GL_GetDrawableSize(window, &viewportWidth, &viewportHeight);
ast::log(logTag, "Created OpenGL context with viewport size: " + std::to_string(viewportWidth) + " x " + std::to_string(viewportHeight));
glClearDepthf(1.0f);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glEnable(GL_CULL_FACE);
glViewport(0, 0, viewportWidth, viewportHeight);
return context;
}
} // namespace
Internal implementation
This is the implementation of our Internal
struct that was forward declared in the header file and encapsulated within our internal_ptr
mechanism. Notice that it has the window
and context
member fields which means they are now nicely tucked away instead of sitting in global scope like we had them in main.cpp
.
The constructor will initialise both the window and the context. Note we have added a new window flag named SDL_WINDOW_ALLOW_HIGHDPI
. If we didn’t add this flag then iOS retina devices would only render at half their actual renderable size which results in an upscaled low resolution image. This flag is related to the change we made to calculate the viewport correctly and is necessary on high DPI devices.
The destructor will clean up our OpenGL window and context.
The render
function will simply do the same thing we were already doing - displaying a (yes very boring, I promise we will start showing something more interesting soon enough!) green screen.
struct OpenGLApplication::Internal
{
SDL_Window* window;
SDL_GLContext context;
Internal() : window(ast::sdl::createWindow(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI)),
context(::createContext(window)) {}
void render()
{
SDL_GL_MakeCurrent(window, context);
glClearColor(0.3f, 0.7f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
SDL_GL_SwapWindow(window);
}
~Internal()
{
SDL_GL_DeleteContext(context);
SDL_DestroyWindow(window);
}
};
Public API implementation
The public functions in our header that we must implement are:
internal
member field.render
function, which will simply defer to our internal implementation.OpenGLApplication::OpenGLApplication() : internal(ast::make_internal_ptr<Internal>()) {}
void OpenGLApplication::render()
{
internal->render();
}
Notice how the constructor is initialising the internal
member field like this:
internal(ast::make_internal_ptr<Internal>())
The make_internal_ptr
is our factory method we wrote in the internal_ptr
template file. In this class our Internal
struct doesn’t need any constructor arguments, but if it did, they would be passed into the make_internal_ptr
function which would forward them on.
So we have a new OpenGL application class, but we haven’t yet touched our main.cpp
file to use it. Instead of our main.cpp
file directly creating a new instance of an OpenGLApplication
, we will write another class whose responsibility is to selectively bootstrap the correct application, then run it and own its lifecycle including its destruction.
For this we will write a new class named Engine
, after which we will clean up our main.cpp
file such that all it does is create an instance of our Engine
and ask it to run.
Create new source files under main/src/core
named:
engine.hpp
engine.cpp
First, the header file. There isn’t much in the header, which if nothing else illustrates the internal_ptr
pattern we are using. We just want a constructor and one public method named run
:
Edit the engine.hpp
with the following:
#pragma once
#include "internal-ptr.hpp"
namespace ast
{
struct Engine
{
Engine();
void run();
private:
struct Internal;
ast::internal_ptr<Internal> internal;
};
} // namespace ast
Edit the engine.cpp
with the following:
#include "engine.hpp"
#include "../application/application.hpp"
#include "../application/opengl/opengl-application.hpp"
#include "log.hpp"
#include "sdl-wrapper.hpp"
#include <stdexcept>
#include <string>
using ast::Engine;
struct Engine::Internal
{
const std::string classLogTag;
Internal() : classLogTag("ast::Engine::") {}
void run()
{
static const std::string logTag{classLogTag + "run"};
ast::log(logTag, "Starting engine ...");
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS);
ast::log(logTag, "SDL2 initialized successfully ...");
resolveApplication()->startApplication();
}
std::unique_ptr<ast::Application> resolveApplication()
{
static const std::string logTag{classLogTag + "resolveApplication"};
try
{
ast::log(logTag, "Creating OpenGL application ...");
return std::make_unique<ast::OpenGLApplication>();
}
catch (const std::exception& error)
{
ast::log(logTag, "OpenGL application failed to initialize.", error);
}
throw std::runtime_error(logTag + " No applications can run in the current environment");
}
~Internal()
{
SDL_Quit();
}
};
Engine::Engine() : internal(ast::make_internal_ptr<Internal>()) {}
void Engine::run()
{
internal->run();
}
Let’s walk through the interesting parts of the struct Engine::Internal
:
Run function
The run
function simply initialises the basic SDL system, then proceeds to obtain an instance of an application
to start by invoking the resolveApplication
function.
void run()
{
static const std::string logTag{classLogTag + "run"};
ast::log(logTag, "Starting engine ...");
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS);
ast::log(logTag, "SDL2 initialized successfully ...");
resolveApplication()->startApplication();
}
Resolve application function
The resolveApplication
function will attempt to initialise each application
that we want to support - to begin with we only have OpenGL but later we will introduce the Vulkan application as well.
We are using exception handling to give us a non fatal control flow if a desired application cannot be initialised. This will become far more important when we integrate Vulkan, where it is generally not possible to know ahead of time if the myriad of Vulkan configuration steps will succeed.
Of course, if none of our supported application variants can be initialised then we don’t have a lot of options so we will throw a meaningful exception instead and bomb out.
Note also that we are using a unique_ptr
smart pointer as a return value so it will self destruct upon leaving its containing scope. We are also returning a type of ast::Application
although the actual selected implementation is ast::OpenGLApplication
(and in the future could be a Vulkan application).
There is also a smattering of logging with our logging mechanism, including the use case of passing an exception through for logging.
std::unique_ptr<ast::Application> resolveApplication()
{
static const std::string logTag{classLogTag + "resolveApplication"};
try
{
ast::log(logTag, "Creating OpenGL application ...");
return std::make_unique<ast::OpenGLApplication>();
}
catch (const std::exception& error)
{
ast::log(logTag, "OpenGL application failed to initialize.", error);
}
throw std::runtime_error(logTag + " No applications can run in the current environment");
}
Internal destructor
The internal destructor is pretty simple, it just tries to be a good citizen and shut down the SDL system gracefully.
~Internal()
{
SDL_Quit();
}
Public API - constructor
The public constructor is quite basic and you will see this pattern repeated a lot. It’s pretty much same as before - our public constructor simply creates a new instance of our Internal
struct via our internal_ptr
mechanism.
Engine::Engine() : internal(ast::make_internal_ptr<Internal>()) {}
Public API - run function
The public run
function simply defers to the internal implementation. This is how most of our public functions will operate - by deferring to the internal implementation.
void Engine::run()
{
internal->run();
}
After all that we can finally revisit the main.cpp
file and remove almost everything from it, leaving it with the simple responsibility of creating an instance of our Engine
and running it.
Edit the main.cpp
file to this:
#include "core/engine.hpp"
#include "core/sdl-wrapper.hpp"
int main(int, char* [])
{
ast::Engine().run();
return 0;
}
Pretty massive change yeah? An instance of our engine is both created and run with this line:
ast::Engine().run();
There is one very important detail in this new main.cpp
file: the #include "core/sdl-wrapper.hpp"
MUST be included here, as it will reconfigure the main
entry point of the application to actually bootstrap the SDL foundation automatically on certain platforms. If the header is not included here, you will find that platforms such as Android will fail. The related code can be found in the SDL_main.h
from the SDL library and looks like this:
#if defined(SDL_MAIN_NEEDED) || defined(SDL_MAIN_AVAILABLE)
#define main SDL_main
#endif
Run your application again and you should see the familiar window filled with green. In the DEBUG CONSOLE
area you will see our logging statements (amongst all the framework logs) that would look something like:
ast::Engine::run: Starting engine ...
ast::Engine::run: SDL2 initialized successfully ...
ast::Engine::resolveApplication: Creating OpenGL application ...
ast::OpenGLApplication::createContext: Created OpenGL context with display size: 640 x 480
At this point you could go and run each of our target platforms to see that they are all still working. As a reminder, here is how to do each (of course we just ran the console
application so no need to explain that).
Emscripten
emscripten
folder in Terminal.setup.sh
unless you’ve never done that before../build.sh
.Android
setup.sh
in the android
folder unless you’ve never done it before.MacOS
macos
folder in Terminal../setup.sh
- remember this is needed to regenerate the Xcode project files.iOS
ios
folder in Terminal../setup.sh
- remember this is needed to regenerate the Xcode project files.Windows
setup.ps1
unless you’ve never done that before.The refactored code feels a bit cleaner than the mash of code we had before and gives us a few handy mechanisms to start building out some more interesting parts of our engine.
I’m not sure about you but I am getting rather tired of the plain green screen, so next up we will learn a way to load a 3D model from storage and with the goal of rendering it.
The code for this article can be found here.
Continue to Part 9: Load a 3D model.
End of part 8