A resource can outlive the object that created it. A particle may remain in a scene after its pool facade is destroyed. A prepared frame may still hold a texture after the application replaces it. Those cases need enough retained state to finish cleanup, with clear limits on what that state permits.
In Cheryl Engine 4, CPU storage and OpenGL resources solve different parts of this problem. CPU handles retain the bookkeeping needed to return memory. Native graphics handles retain a retirement domain, while actual deletion belongs to its context owner. This article follows those contracts at revision 91d178e.
Keep the return path alive
The memory manager is a facade for checking out and returning byte ranges. Its own lifetime is a poor dependency for a buffer’s final deleter: the last buffer owner may disappear after that facade. Looking up a singleton at release time would add another lifetime dependency precisely when the program may be tearing down.
The manager retains a ByteReleaseContext. A managed byte handle captures that context and its exact HeapBlock. Its final deleter calls release_owned() through the captured context, without dereferencing the manager. The context retains shared byte bookkeeping independently of the facade.
The small make_managed_block() implementation makes the ownership visible: the pointer identifies the bytes, while the deleter owns the block metadata and return context. Creating the handle requires a live manager. Releasing an already-created handle uses what it retained.
Object lifetime is another layer
Returning storage and destroying an object are separate operations. make_managed_block() neither constructs nor destroys a T. The object pool adds a retained construction tracker and one handle per constructed slot. Consider a particle kept beyond the batch that created it:
#include <cheryl/core/resources/objects/pool.h>
#include <memory>
struct Particle {
float age = 0;
};
std::shared_ptr<Particle> retained;
{
CE::Memory::Objects::Pool<Particle> pool;
auto batch = pool.retrieve_objects(16);
retained = batch.front();
} // The batch and pool facade are gone.
retained->age = 0.25f;
retained.reset();
The batch has sixteen independently owned objects. Leaving the scope releases fifteen of them, while retained keeps the first alive. Its deleter retains PoolState<Particle>, which retains the construction tracker. Resetting the final handle destroys the tracked particle and returns its slot through that state. The facade is no longer involved.
The batch construction loop also has a partial-failure path. If construction stops partway through, completed handles clean up their objects, and the unconstructed tail returns as a range. At the backing-allocation layer, a captured byte release context eventually returns the underlying bytes. Returning a slot makes it reusable; it does not promise an immediate operating-system free.
Retention does not synchronize callers
This design protects a cleanup path, not every operation on the resource. Direct manager callers must finish before the facade is destroyed. A retained handle does not make concurrent reads and writes to a particle safe. A borrowed pointer or span does not acquire ownership merely because its storage came from a pool.
The manager’s compatibility lifetime_token() is also only an observation. Checking a token and then using a raw manager pointer would not pin the manager against teardown. Capturing the release context while the manager is alive avoids needing that check during final release.
Public invalid returns and trusted final cleanup have different error contracts. ByteReleaseContext::return_chunk() reports an invalid return with an exception. release_owned() is noexcept: a broken return invariant or cleanup failure is reported and terminates. A final deleter cannot turn an ownership error into a recoverable application result.
A texture also needs the right context
OpenGL adds a constraint beyond retaining C++ state: deletion has to run on the owning thread with the expected current context. A final logical owner can disappear on another thread, so Cheryl’s native handle release queues retirement instead of making a GL call there.
The retirement and collection paths separate those steps. retire() marks a tracked slot pending. collect() validates the owner/current context, issues the matching deletion operation, and makes the tracking slot reusable. A pending registration therefore still represents a native resource awaiting collection.
For a changing texture, imagine a frame holding generation A while the application publishes B. The frame’s geometry, material, and image bindings retain the selected resources. Replacing the application’s handle releases only that owner; A can remain until the frame and any other owners release it. The prepared-frame article follows that handoff in detail.
Shutdown closes native usability
A retained native handle does not keep a graphics context usable forever. shutdown() deletes the remaining registrations and deactivates the domain, including resources whose logical handles still exist. Later handle use checks the stopped domain; later release performs no second native deletion. abandon() is a failure fallback that deactivates tracking without GL calls, leaving native cleanup to context destruction.
That distinction connects the runtime’s shutdown sequence to retained frames and replacement text generations. CPU ownership can preserve data and the information needed to release it. Native use additionally requires the resource’s original live domain. The engine-specific work is making those responsibilities explicit across allocation, construction, publication, and cleanup; the code example illustrates how those responsibilities remain connected after the facade is gone.
Cheryl Engine 4 reading path
- Runtime ownership and lifecycle
- Prepared frames and rendering handoff
- Retained resources and cleanup (this article)
- Input routing and editable menus
- Unicode layout and retained text
- Optional modules and the installed SDK