Alloy Engine & Editor

A C++20 game engine with a dockable editor.

Period
May 2022 – May 2023
Role
With a collaborator
Status
archived
commits
155
own LOC
~11.2k
surviving renderer lines mine
91%

Personal project. My father joined for part of it and wrote the original Vulkan boilerplate; I re-architected the renderer in 2023.

Open source C++20VulkanOpenGLBulletenttDear ImGuiPremake

Highlights

  • SPIR-V reflection drives the material system: single-file GLSL is split per stage, compiled with shaderc, reflected with SPIRV-Cross into a recursive type tree and descriptor set layouts, vertex input and per-material uniform buffers are all generated from it.
  • Native scripting uses a C++20 requires-expression to detect at compile time which event handlers a script defines, subscribing only to those; subscriptions are RAII handles that unsubscribe on destruction.
  • The Bullet physics library gives you persistent manifolds, not callbacks. The collision layer walks the manifolds, maps user pointers back to entities, and produces Enter/Stay/Exit events from a per-body tracker.
  • Entering play mode deep-copies the scene in two passes with an equivalence map, so cross-entity references such as physics constraints are fixed up correctly and edits revert on stop.
  • The engine has no ImGui dependency at all; the editor is a separate consumer of it.

The link points at branch experimental, which holds the final state of the code described here. It is mid-refactor and does not build the editor; main, at January 2023, is the last state that runs.

The problem

The largest of the three engines, and the one with the most design in it rather than the most code. Four things in it carry that design: a material system generated entirely from shader reflection, so that binding tables cannot drift out of sync with the GLSL; scripting that works out at compile time which events a script handles and subscribes to nothing else; a collision layer that turns a physics library's raw contact data into Enter, Stay and Exit events; and a play mode that copies the scene in two passes so cross-entity references survive. The engine also has no editor dependency at all, which is what let the next one start already separated.

The engine before this one was object-oriented and GameObject-based. This one is a return to an entity-component-system, written from memory rather than by following anything.

My father joined for part of this project and wrote the original Vulkan boilerplate. I re-architected the renderer around it in 2023. The engine is 155 commits and around 11,000 lines of my own code, and 91% of the renderer as it stands at the end is mine.

Materials come out of the shader, not out of C++

A shader is a single GLSL file with the stages marked inside it. It gets split per stage, each stage compiled to SPIR-V with shaderc, and each module then reflected with SPIRV-Cross into a recursive type tree. Descriptor set layouts, vertex input and per-material uniform buffers are all generated from that reflection.

The alternative, which is what the code did before and what the TODO in the old version says to stop doing, is to hand-write the binding table in C++ next to the shader and keep the two in sync yourself. Every buffer size, binding index and set number exists twice, and nothing checks that the copies agree. When they disagree you do not get a compile error, you get a black screen. Deriving the whole thing from the compiled shader means there is only one copy and it is the one the GPU is actually using.

Reflected samplers and buffers are sorted into three descriptor sets by convention: set zero is per-frame global data, set one belongs to the render pass and set two is the material. A material constructed from a shader reads set two out of the reflection and allocates one uniform buffer per reflected buffer and one sampler per reflected sampler, with no declarations anywhere in the engine saying what a material contains.

Scripts subscribe to what they implement, at compile time

Native scripts are C++ classes, and the question of which engine events a given script wants is answered with a C++20 requires-expression rather than at runtime:

constexpr bool implementsFunc = requires(T t, E e) { t.OnEvent(e); };

If a script does not define a handler for an event type, the subscription for it is never generated. The obvious alternative is a virtual OnEvent per event type with an empty default, which means every script pays a vtable dispatch for every event it ignores. This costs nothing at runtime for events a script does not care about, because there is nothing there.

It also enables a second optimization that matters more. For a script type used on many entities, the dispatcher can subscribe one function that iterates every instance of that component and calls the handler directly, rather than subscribing one std::function per entity. Whether that is worth doing is decided at compile time by the same expression.

Subscriptions themselves are RAII handles holding a weak reference back to the dispatcher. Dropping the handle unsubscribes, and if the dispatcher was destroyed first the handle notices and does nothing. The version of this before it required calling Unsubscribe with the right id.

Turning Bullet's manifolds into collision events

Bullet does not give you collision callbacks. It gives you persistent contact manifolds, which stay alive across every frame two bodies are touching, and leaves the question of what counts as an event to you.

The collision layer walks the manifolds after each physics step, maps the user pointers on each body back to entities, and builds contact information from each manifold point. A small fixed tracker array per rigid body remembers which entities it was overlapping last frame, so the first frame of an overlap becomes an Enter event, subsequent frames become Stay, and the falling edge becomes Exit. Bodies that nobody is listening to are skipped before any per-contact-point work happens.

The naive alternative is to compare this frame's contact set against last frame's yourself, which duplicates the broadphase work Bullet has already done. This reuses it. The cost is a hard cap of ten simultaneously tracked pairs per body.

Entering play mode copies the scene twice

Pressing play simulates a copy of the scene so that edits made while playing revert on stop. Copying an entity tree naively breaks on the first component that points at a different entity, because a physics constraint referencing a sibling will be cloned before the sibling exists.

So the copy runs in two passes. The first clones the tree and every component that refers only to itself, building a map from each original entity to its copy as it recurses. The second walks that completed map and fixes up the components that point elsewhere, so a constraint's two body references are rewritten to the copies rather than left pointing into the scene the editor is still holding.

The editor is a separate consumer

The engine has no ImGui dependency at all. Not a wrapper around it, not a conditional compile, nothing: ImGui is a submodule of the editor project and the engine source does not reference it. The editor links the engine the same way a game would, and the sample application in the repository runs the engine with no editor present. This arrangement is the reason the next engine could start already separated rather than having to be untangled.

The state it was left in

The final branch is mid-refactor and does not build the editor. The renderer changed underneath the scene, leaving a TODO inside Scene::Update where the play and simulate paths used to work. Anything demonstrating this project has to be built from the January 2023 state.