I look into different approaches of storing larger objects in my path tracing renderer. With ease of use, safety and expressiveness in mind I unify the different approaches I have taken throughout the project.
The setup
In my path tracer there are multiple types that have similar properties, though their actual use is different. These types include the Mesh, that holds triangle and vertex data for a mesh in RAM, the Bounding Box Hierarchy (BBH), the fast discrete sampling tables (AliasTable) and others.
These objects are costly to construct (files need to be parsed or complex structures built), occupy sizable RAM and VRAM and the data loaded/generated by them often need to be accessed both on GPU and CPU.
As per good C++ practices I should use RAII (Resource acquisition is initialization) to manage the lifetime of resources occupied by these objects.
There is a crucial question that makes this a bit more complicated: loading/computing these resources should be deferrable, and these objects should be able to hold an “empty” state.
Let’s look at a simplified example from my path tracer:
struct Mesh;
struct BBH
{
int depth;
DeviceVector<BBHNode> nodes;
BBH(const Mesh &mesh);
};
struct Mesh
{
DeviceVector<Triangle> triangles;
BBH bbh;
explicit Mesh(const std::filesystem::path &meshFile);
Mesh();
};
The plain RAII approach calls for constructors that set up the Mesh object into a valid state, either to the empty state, in case of the default constructor, or load a file in the other constructor. When I want to defer the mesh loading until, say, the user provides a path, the stored Mesh object can be overwritten with a new one, constructed with the object path.
Deferring is possible, so this works well enough at first glance.
The issue with pure RAII
Pure RAII forces the objects to internally have a special “empty” or “unloaded” state.
Imagine rendering a mesh object. What does an empty mesh look like? Probably has no triangles. Do draw calls handle drawing zero vertices? OpenGL for example explicitly allows calling glDrawArrays with zero vertex count, but it disallows empty buffers. Some other APIs might break when drawing zero vertices. My rendering function happens to handle an empty array of triangles just fine, but looking deeper into the BBH, what should an empty Bounding Box Hierarchy look like? My code actually expects there to be at least one box, the root box. The constructor could take care and create an infinitely sized box or a zero sized box to accommodate for that.
The point here is that both the constructor and the algorithm have to be careful with this “empty” state, and forgetting about it or handling it incorrectly causes runtime issues. Most of these mistakes could be caught by thorough testing.
Making the empty state external
The C++ standard library has two wrapper types that can be used to store a value that might be present or not.
std::unique_ptr<BBH> bbh_uptr;
std::optional<BBH> bbh_opt;
Both can store an empty/missing value, and both force the algorithms to deal with these values explicitly before accessing the underlying object.
Functions that need to handle a potentially missing value take the wrapper type, functions that assume the existence of valid data work on values or references directly.
void functionThatCanHandleEmptyBBH(const std::optional<BBH> &bbh);
void functionThatAssumesBBHIsValid(const BBH &bbh);
An actual object of BBH always contains valid, non-empty data. This distinction makes the code easier to follow and less error prone.
The price
There is no free lunch, what is the cost of these wrapper types? Does it matter in my case?
The unique_ptr allocates dynamically, the optional reserves space inline. This difference shows up in memory footprint and cache locality. Let’s look at how the meshes will be stored:
struct Mesh
{
DeviceVector<Triangle> tris;
std::optional<BBH> bbh;
// or std::unique_ptr<BBH> bbh;
};
std::vector<Mesh> meshes;
The optional reserves enough space for an entire BBH object inline, so even meshes with no bounding box still pay that cost. unique_ptr on the other hand only occupies a pointer per Mesh and allocates the BBH separately when needed, though that comes with a pointer indirection on every access.
For my use case this is a non-issue. At worst I render thousands of meshes, and a BBH object is only around 32 bytes (some metadata plus a DeviceVector, which itself is a thin handle to a dynamic allocation). The inline storage of optional is a fine tradeoff given that meshes rarely have a missing BBH at runtime anyway, and avoiding the extra heap allocation keeps things simple.
What I landed on in my code
For my path tracer the sweet spot is std::optional<T>, combined with these objects not having an empty state. This way it’s easy to express the contract each function has (i.e. whether they handle empty objects), while keeping ownership clean too through reference/value semantics.
As a showcase, the BBH struct I landed on looks like this:
struct BBH
{
int depth;
DeviceVector<BBHNode> nodes;
static BBH buildFromMesh(const Mesh &mesh);
};
I like to use static factory functions, that act like “named constructors” and make object creation more explicit and easier to differentiate between different constructors.
Because DeviceVector is inherently non-copyable and move-only, it naturally propagates those safety guarantees up to BBH. This prevents accidental copying footguns while still allowing BBH to remain a pure C++20 aggregate type. A key benefit of keeping it an aggregate, meaning no user-provided constructors, is that C++20 designated initializers become available, giving the factory function a clean, self-documenting return syntax:
BBH BBH::buildFromMesh(const Mesh &mesh)
{
auto nodes = buildBBHLayersCPU(mesh);
const int depth = findBBHDepth(nodes);
return BBH{
.depth = depth,
.nodes = std::move(nodes),
};
}
While this allows outside code to use the designated initializers and construct the object directly, I just don’t do that outside the struct’s implementation. We are all consenting adults here.
Then functions and objects can choose to expect a valid BBH or allow it to be missing with std::optional.
Conclusion
This post was more C++-focused than most, though the problem it addresses (managing deferred, resource-oriented objects) comes up constantly in the renderer.
What I found most interesting about this topic is how much cleaner the code becomes once the empty state is pushed out of the objects themselves. It’s a small conceptual shift, but it removes an entire class of subtle bugs and makes function signatures self-documenting. In a larger codebase or a team setting I’d probably lean even more heavily on this pattern.
Explicitness scales well.
