AlloyEngine (Vulkan)
A ground-up Vulkan engine that turned into an experiment in what an ECS framework should look like in C++.
- Period
- May 2023 – Dec 2023
- Role
- Solo
- Status
- archived
- commits
- 100
- branches / merged PRs
- 17 / 12
- own LOC
- ~5.8k
Highlights
- A system is a free function whose parameter list declares its dependencies; the scheduler deduces at compile time whether each parameter is a query, resource or event and whether it is read or written.
- Registration fails if two systems in the same stage would conflict, and the error text tells you to move one to a different stage.
- Queries specialize over entt: one component picks a view, several pick a group, and the read/write id sets are constexpr.
- The renderer stores the scene as a RenderPass → Shader → Material → Mesh tree, so state changes are minimized structurally rather than by sorting each frame.
- Per-frame descriptor pool chains that grow on exhaustion, VMA-backed allocation, and a light manager with O(1) swap-remove and a dirty list so only moved lights re-upload.
The conflict checker establishes the invariants that make parallel execution safe; the scheduler itself is single-threaded.
Abandoned mid-refactor while extracting the renderer into a plugin.
The problem
This is the API-design project of the three engines. The renderer got built, but the work worth reading is the framework underneath it: a system is a plain function whose parameter list declares what data it touches, and registering two systems that would conflict fails at startup with an error telling you how to fix it. That is a compile-time and registration-time safety property, designed rather than documented, and it is the closest thing here to the problem of building an API that other people have to use correctly. It was also built in public as a devlog series, twelve merged pull requests across seventeen branches.
It started as a ground-up Vulkan engine and turned into something else. The renderer was the stated goal, and it got built, but the part I spent the most time on was the question underneath it: what should an ECS framework actually look like in C++?
The engine before this one had entities, components and systems, and no opinion about how systems related to each other. You registered a function and it ran. Nothing knew what data a system touched, which meant nothing could tell you that two systems were about to write the same component, and nothing could ever run them in parallel without you personally being sure it was safe.
It also got built in public. The repository is organized as a devlog series, with each episode merged into main as its own pull request: opening a window and dispatching events, shaders and SPIR-V reflection, descriptor pools and materials, swapchain and render passes, meshes, then the renderer scene. Twelve merged pull requests across seventeen branches.
A system is a function, and its parameters are its dependencies
A system in this engine is a plain function pointer. Not a class implementing an
interface, not a virtual Update, just a free function:
template <typename... Args>
using System = void(*)(Args...); What makes that useful is that the parameter list is a declaration. At registration
the framework inspects each parameter type at compile time and classifies it: is it a
query over components, a shared resource, or an event? Is it const, meaning the system
reads it, or non-const, meaning the system writes it? A system that takes a Query of one non-const component and one const component has, by
existing, told the framework it writes the first and reads the second. There is no
separate manifest to keep in sync with the code, because the signature is the
manifest.
Conflicts fail at registration, not at runtime
Once dependencies are declarable they can be checked. Each stage accumulates the read and write sets of every system registered into it, and registering a new system cross-checks its declared inputs against everything already there. If two systems in the same stage would write the same resource, or one would write what another reads, registration fails with a message that tells you what to do about it:
"System writes to resource already being read from or written to by
another system on this stage! Consider moving the system to a
different stage." This runs once, at startup, not per frame. The value is that the invariant which makes parallel execution safe is proved by construction rather than argued for in a comment.
I should be precise about what that did and did not buy me. The stage runner does hand its systems to the parallel standard library execution policy, so the intent is in the code. But nothing underneath it was ever made thread-safe: the descriptor allocator documents itself as single-threaded, and the uniform buffer and shader paths both carry TODOs saying proper synchronization is still needed. The conflict checker is real and does its job. The engine was never actually run with systems executing concurrently, and claiming otherwise would be claiming the interesting half and skipping the hard half.
Queries pick their access pattern at compile time
Queries are built over entt, and which entt facility they use depends on how many
components you asked for. A single-component query specializes to a view, and a
multi-component query to a group, because views are cheaper for iterating one
component and groups are faster for joining several. The read and write identifier
sets that the conflict checker consumes are constexpr, derived from
whether each component in the query is const.
The renderer sorts itself structurally
The scene is stored for rendering as a tree: render pass, then shader, then material, then mesh. Drawing it in tree order means the expensive state changes happen once per branch rather than once per object, without a sort at the top of every frame. The usual approach is a flat list of draw calls sorted by a packed key each frame, which works and costs you a sort over everything you can see. Storing the hierarchy instead means the ordering is a property of the data structure and cannot be got wrong.
Two smaller pieces. Descriptor pools are a chain per frame in flight rather than one large pool: allocation tries the current pool, and on exhaustion adds a new one and retries. The whole chain is reset once per frame instead of freeing individual sets, which is what makes allocation cheap enough to not think about. Lights are stored contiguously with an index map and swap-remove, so removal is constant time and the array is always in a state that can be uploaded directly, with a separate dirty list so that only lights which actually moved are recomputed.
Where it stopped
The engine was being turned into a set of plugin libraries rather than one binary. That began as separate repositories for the core, the window layer, the event system and a plugin template, created between November and mid-December 2023. In late December the split was done inside the one repository instead, as modules.
Window and input came out cleanly. The event system did not, because once extracted it turned out to need access to the application state that only the core had, and it was merged back into the core about a day later. The last commit in the project creates the renderer plugin with an empty body.