Aurora Engine
A modular C++23 game engine built around a custom RHI, multi-queue render graph, ECS modules, and asynchronous rendering, compute, and transfers.
R.H.I.
Bifrost is Aurora's render hardware interface. Vulkan 1.4 is as of now the only implemented backend, however, I am actively working on a re-design to allow d3d12 -and potentially metal- as well.
Buffers, textures, pipelines, command encoders, queues, surfaces, and synchronization are expressed through backend-neutral descriptors and handles. Backend-specific objects remain internal inside the implementation, with an explicit native escape hatch for integration work that cannot be portable, or may not be currently available (for example raytracing). The gpu resources use intrusive reference counting, and an abstract base-class. They have certain commonly used fields cached locally, for example the size of a buffer, so you can avoid a virtual dispatch, and pointer load, when you just need to know the size of the buffer.
struct IDevice
{
virtual IBindlessTable *bindless_table() noexcept = 0;
virtual Result<Buffer, ErrorInfo>
create_buffer(BufferDescriptor desc) = 0;
virtual Result<Texture, ErrorInfo>
create_texture(TextureDescriptor desc) = 0;
virtual Result<RenderPipeline, ErrorInfo>
create_render_pipeline(const RenderPipelineDescriptor &desc) = 0;
virtual IQueue *get_queue(QueueType type) = 0;
virtual bool has_dedicated_queue(QueueType type) const = 0;
virtual Result<bool, ErrorInfo> poll(SubmissionToken token) = 0;
virtual Result<void, ErrorInfo>
wait(SubmissionToken token, uint64_t timeout_ns = UINT64_MAX) = 0;
virtual MemoryBudget get_memory_budget() const = 0;
virtual DeviceState device_state() const = 0;
virtual Result<void, ErrorInfo> recover() = 0;
};class Buffer final
{
public:
Buffer() = default;
// To avoid an implicit conversion to Ref<IBuffer>
Buffer(std::nullopt_t /*unused*/) {}
Buffer(Ref<IBuffer> buffer) noexcept : impl_(std::move(buffer))
{
const BufferDescriptor &desc = descriptor();
size_ = desc.size;
usage_ = desc.usage;
}
operator bool() const noexcept { return is_valid(); }
[[nodiscard]] bool
is_valid() const noexcept
{
return static_cast<bool>(impl_);
}
[[nodiscard]] Result<BufferMapping, ErrorInfo>
map(uint64_t offset = 0, uint64_t size = 0) const
{
assert(is_valid());
return impl_->map(offset, size == 0 ? size_ - offset : size);
}
[[nodiscard]] uint64_t
size() const noexcept
{
return size_;
}
[[nodiscard]] BufferUsage
usage() const noexcept
{
return usage_;
}
[[nodiscard]] const BufferDescriptor &
descriptor() const noexcept
{
assert(is_valid());
return impl_->descriptor();
}
[[nodiscard]] const Ref<IBuffer> &
impl() const noexcept
{
return impl_;
}
private:
Ref<IBuffer> impl_;
uint64_t size_{};
BufferUsage usage_{};
};
Lifetime follows GPU work
GPU resources use intrusive reference counting, but a zero CPU reference count does not normally entail that the device has finished using an object- as vulkan functions are mostly asynchronous. Because of this, recorded and submitted command work retains strong references to the resources it references. Submission tokens identify completion, and garbage collection releases deferred objects only after the corresponding GPU work retires, it does so by dropping that reference. This way it is still guaranteed, that -submitted gpu resources- will not be cleaned up until the gpu is done with them.
The same completion model supports non-blocking polling and explicit waits. Aurora can submit work, continue preparing later frames, and reclaim memory at a controlled point instead of scattering device-idle waits through higher-level systems.
Submission Tokens
Bifrost works with submission tokens, these are remniscient of timeline semaphores. When submitting a queue, it will return a submission token, you can poll this for completion, or wait on it. Additionally, you can join and chain tokens together, for example have token B wait on token A, then if you wait for token B to complete, it will first wait until A is completed. This can be used to minimize any waits, and allow the gpu to efficiently schedule the work.
Queues and capabilities
Devices expose queues by operation type. Bifrost prefers dedicated queues when they exist and falls back to a broader-capability queue when they do not. Aurora can consequently request asynchronous work without assuming a particular physical queue layout. Queues are internally synchronized, so a queue can safely be shared between threads (though ideally this should, and generally is, avoided, for optimal performance.).
The device surface also exposes optional bindless tables, memory budgets, validation state, and device-loss reporting and recovery. These capabilities let the engine adapt its strategy while keeping backend policy out of render features and asset code.

Hindsight
I started out with a Handle based design, where a handle is an index and a generation counter, with the actual gpu resources stored in a thread-safe segmented array (to keep pointers/indexing valid while re-sizing), and indices re-used using a lock-free free-list. Using a templated thread-safe deletion queue instance for each type for safe deferred deletion. Thinking it would be more efficient, and potentially handle deferred deletion -alongside deferred creation- better. In the end however, I realized I was just overcomplicating things; it made the api more cumbersome as now any query for resource information required the resource manager (and often two virtual dispatch calls, when called from outside of the internal implementation), on the maintenance side as well, as now the resource manager must have methods for each object destroy/create (the x macro trick helps here, but it's still not ideal), lifetimes become more difficult, thread-safety required more synchronization, and became a lot more complex, as now the resources are not isolated objects, but part of a segmented array, and in the end it gave no measurable performance difference, at least in my testing. As for the deferred creation, I still want to keep that, but instead of having it in the rhi layer, where it isn't really pratical, I believe it should be higher up, in the asset layer, for example the user reachable meshes.