This week I did more work on the demo engine/editor of course :)
This includes nailing down more architecture-related stuff; basically I think I have enough now to call the architecture stuff "finished" and say "the rest is details", more or less. So, I thought it'd be a good time to finally write some of this stuff out.
Note: I wrote this writeup initially in the repo's internal wiki, and I'm basically going to just copy/paste it here. Unfortunately this means you don't have the code to go with it (probably), so some things might seem a bit vague, but there's enough there to describe it back to myself in the future I think, and that's what matters. And of course I've made some small edits here where in the wiki there were links to other pages, etc.
So, here we go :)
Before diving into the project's architecture, it's necessary to understand the following high-level terms:
Nodes are the fundamental building blocks of everything we're making in the tool. Really, the various nodes we have, the way they're put together, and animation data for certain node properties represent the entire value of the project. The tool's sole existence is to make these things easy to put together and tweak.
Each node has a list of properties, a list of children (represented as indexes into a list, which we'll get to in a moment), and a
render function that will be used to draw the actual node.
Nodes are expected to be stored in an indexed list. This reduces references to specific nodes to integer indexes, as well as makes the model a bit more 64k-friendly. The
NodeList type exists specifically for this use case. The root of the scene graph is always the first node in this list (node 0).
Additionally, node names are expected to be unique. There are cases where, for example, an editor might need to find a node by name. If there are ambiguities, this can't work.
Each node property has a type (currently only represented by the actual data type itself), a string name, its current value, and a list of decorators. These decorators help the editor display the properties properly; for example, the
DisplayName decorator represents a prettified name to be displayed in the editor for the property rather than its normal name (which follows the Rust snake-case naming convention; more on that later). All decorators are optional and are not checked for compatibility with a particular property. The editor will simply look for certain decorators in contexts where it makes sense; that's it :) .
The engine project is a standalone project that contains just about everything needed to represent and draw nodes, minus lower-level things like window setup. It contains GL bindings and abstractions the nodes can use (such as textures, vertex buffers, etc), the actual node representation, and an asset manager that can be used to resolve resources at runtime. This allows assets to be reloaded if changed on disk primarily.
This is a small data object that's passed to each node as the scene graph is traversed. It contains information like which render target is bound, the current world/view matrices, current time, etc. These values are maintained as stacks, and nodes can push/pop new values here as they please. Really, this is just a very basic mechanism to keep track of state that's not node-specific as the scene is being rendered/traversed.
This is a tool to edit actual projects. It contains editors for specific parts of the project (the scene graph, various properties, etc) and a render window to show the current output as these values are changed. In the future it will also likely contain additional render windows, an animation suite, etc.
Most of the work put in thus far has been in finding the right architecture to fit the various parts of the editor together nicely. The rest of this writeup will deal with the editor architecture specifically.
Because the editor will deal with almost all parts of project creation/editing (in fact the only thing it doesn't touch is actual text editing), nailing the architecture of this is paramount to making sure the project never gets out of hand and changes/additions are always possible. A great deal of effort has been put forward to come up with an architecture that satisfies all of our design constraints, while at the same time trying to be elegant (though there are some cases where elegance is dropped in favor of actually working, but these aren't terribly common and I wouldn't call any part of the project a "mess" [yet :P]).
These constraints must always be upheld when working on the tool:
These should probably be covered first, as they're the decisions that will affect most everything else in the project.
What follows is a somewhat high-level overview of the most important components in the editor.
These are the actual specific editors used to modify various parts of the domain model. This includes the inspector, the animation editor, etc.
The domain model contains basically all state the editor exists to modify. This includes the nodes, animation data, history, and some editor state such as currently selected node index.
The domain model is made up of a few different parts:
This is the actual owner of all of the domain model state, minus some immutable data objects that might be floating around (such as strings and change items). Generally, there should only be one editor context instance for the entire tool. This is implemented in Rust with a C++ abstraction (through a C API). Almost everything the tool can see or can do will eventually go through this object.
While this object by definition is a "God object", it makes sense to have as it represents a hard boundary between the editor and the data parts of the tool. This not only helps enforce certain editor policies, but also allows us to swap our either the tool or the domain model while keeping the other side in-tact, given this object's API doesn't change.
Because this object contains the project's nodes, it's necessary that all method calls on the object are synchronous and happen on the same thread. There is no internal sync provided for this. Generally, this is ok though, as most UI frameworks are single-threaded by default anyways (including Qt).
This represents the actual state of the editor context. All introspection and mutation of the state eventually gets delegated to this object. It includes the project's node list, history, and some editor state such as which node is selected.
Change items are immutable POD's that describe specific changes to the data model, such as a change of a float property, selection of a node, etc.
The primary reason for having change items is to represent the project's history so that everything can support undo/redo.
These change items are also used to notify the UI when changes occur. In most tool architectures, this is handled by using the observer pattern to have the UI components observe changes on the model. However, in the past I've found managing observer subscriptions to be somewhat difficult in C++, and when considering the fact that our data model is in Rust, implementing the observer pattern would mean at the very least having to pass handlers and managing subscriptions over the C boundary, which I assumed would be complex and error prone. Instead, we can take advantage of data structures we already have in our system to notify the UI of the same data. While it could be argued that we may have more propagation overhead, since all controls will be notified for all changes (instead of just the subscribed controls for particular changes in the observer pattern), the amount of controls in the editor should never be enormous and the actual handling of change items by the editors isn't expensive.
They can also be inverted; for example a change item representing a change of a float property's inverse should represent changing the property to the value it was previously. This means that wherever a change item would be propagated, we only have to care about the change item's new values; a change item being undone for example is simply a propagation of the change item's inverse.
On the C++ side, there exists an
IHandleChangeItem interface that implementors can use to signify they handle incoming change items. All views are expected to implement this to handle incoming data model changes, either from the current editor, other editors, or undo/redo.
The history is simply a list of change items that have occurred in the project while the tool's been running. This allows for undo/redo.
In order to ensure that all changes on the model play nicely with the undo/redo and editor update mechanisms, certain policies must be enforced.
Luckily, there are actually only a few:
All of these policies except #4 can be enforced by simply providing abstractions to the editors whose implementations ensure correct operation. Primarily, this is done with the following objects:
ISceneGraph: Represents an immutable view of the scene graph.
SceneGraphMutator: Exposes an interface for making changes to the scene graph.
Under the hood,
SceneGraphMutator also handles change item propagation to the root of the UI. Therefore, if all the editors/views require an
ISceneGraph and a
SceneGraphMutator (not the editor context directly), expect the
SceneGraphMutator's implementation to update the data model wholly, and they implement
IHandleChangeItem to handle all relevant change items (and possibly route them to any child editors/views they might have) without making any additional data model changes in response to them, all of our policies are upheld and the editor works smoothly and stays fast.
I figured I should mention some implemention details, particularly regarding some GL-specific things and memory management.
Ownership of the editor context is managed only by the tool C++-style with no additional mechanisms necessary. This is because there will likely only ever be one per editor instance; this means we can always just take a pointer to the context and assume it will be live for the entire lifetime of the app. This works in most cases, however, one thing to be aware of is that our GL context is created by the viewport's GL control instance. So, while we can assume this will be around basically until our app closes, we do have to wait to actually feed this context to some editors until it's actually created. This is handled very easily, but important to write a note about I think.
The C bridge really only consists of some functions that take and/or return simple data types, such as integers, floats, etc. However, there are a couple higher-level data types they can accept/return, such as strings and change items. These higher-level data types are all managed on the Rust side using reference-counting, which is safe because they're immutable. In C++, they're wrapped in simple classes that are meant to be passed around by value that will increment/decrement the reference count automatically, so management of these objects is trivial.
There are a few cases where interfaces and objects are used in mixed company. Generally, with a small codebase like this, I don't like to introduce interfaces until we actually will get some reuse or abstraction out of them, as otherwise they would really just introduce more things I need to touch when they change (which so far has been somewhat often, as I've been working towards this architecture in the first place). At the end of the day, less code is less code, and that's usually better than more code, even if that more code follows more rules :) . I mean, really "don't be an idiot" is always implied, and that works out pretty well.
All of these small bits together keep the project relatively small and maintainable, while still satisfying all of our initial constraints. This architecture so far has been pretty straightforward to implement and quite performant, sometimes at the cost of being a bit verbose (mainly because of the C bridge), but even the verbose code is straightforward and manageable enough.
Last Edited on Sat Apr 16 2016 20:11:13 GMT-0400 (EDT)