Often, in a 3D scene, it is convenient to specify the transformations of scene elements relative to each-other. For instance, items sitting on a table should move along with the table; items in a box should move with the box; and the transformations of the links of a robot arm make sense to write in terms of the transformations of their parent links.
One way to implement this is to think of every object in the scene as having a Transform
that indicates its position relative to a parent Transform
.
Indeed, such a transformation hierarchy ("scene graph") is at the core of many 3D modelling packages.
This document describes a basic implementation of such a scene graph in C++.
This implementation is included as part of the 15-566-f19-base3 code in Scene.*pp
.
There are all sorts of things we might have in a 3D scene -- Objects, Lights, and Cameras, to start with -- so we'll unify the transform logic in a Transform
struct and supply everything that participates in the scene with a member Transform
:
struct Scene {
struct Transform {
//...
};
struct Camera {
Transform transform;
//...
};
struct Object {
Transform transform;
//...
};
struct Light {
Transform transform;
//...
};
//...
};
But what do we put in the Transform
?
To start with, it's straightforward -- we need to specify how the object is transformed, and what parent space that transformation is defined relative to:
struct Transform {
//3D transformation:
glm::vec3 position = glm::vec3(0.0f, 0.0f, 0.0f);
glm::quat rotation = glm::quat(0.0f, 0.0f, 0.0f, 1.0f);
glm::vec3 scale = glm::vec3(1.0f, 1.0f, 1.0f);
//Relative to parent:
Transform *parent = nullptr;
//...
};
When drawing the scene, our code will need to compute matrices that transform from world space to/from object space. These matrices can be computed recursively:
glm::mat4 Scene::Transform::make_local_to_world() const {
if (parent) {
return parent->make_local_to_world() * make_local_to_parent();
} else {
return make_local_to_parent();
}
}
glm::mat4 Scene::Transform::make_local_to_parent() const {
if (parent) {
return make_parent_to_local() * parent->make_world_to_local();
} else {
return make_parent_to_local();
}
}
Where the make_parent_to_local
and make_local_to_parent
functions
construct a glm::mat4
from the position/rotation/scale representing the given transform.
With these helper functions in place, we can write a basic render()
function that draws each element of the scene with the proper transformation:
void Scene::render() {
glm::mat4 world_to_clip =
camera.make_projection()
* world_to_camera;
for (auto const &object : objects) {
glm::mat4 local_to_clip =
world_to_clip
* object.transform.make_local_to_world();
//set up program uniforms:
glUseProgram(object.program);
if (object.program_mvp != -1U) {
glUniformMatrix4fv(object.program_mvp, 1, GL_FALSE, glm::value_ptr(world_to_clip));
}
//draw the object:
glBindVertexArray(object.vao);
glDrawArrays(GL_TRIANGLES, object.start, object.count);
}
}
Of course, this isn't perfect, but it's a good start. I address some potential concerns below.
This setup will work just fine if we set up a hierarchy once in our code.
But what happens if we start adding and removing Transform
s, or -- heaven forbid -- copying them?
If nothing else, the parent
pointer maintenance becomes a bit of hassle.
Consider:
//Create a robot arm:
Object &base = add_object( /* ... */ );
Object &link1 = add_object( /* ... */ );
Object &link2 = add_object( /* ... */ );
Object &link3 = add_object( /* ... */ );
link1.transform.parent = &base.transform;
link2.transform.parent = &link1.transform;
link3.transform.parent = &link2.transform;
//Delete the 'base' of the arm:
link1.transform.parent = nullptr; //required to avoid pointer-to-nowhere
remove_object(base);
In other words, we find ourselves in the odd situation where Object
s are allocated and managed by the Scene
, but the relationships between their Transform
s are managed by external code.
We can fix this by changing Transform
to manage its own parent
pointer; which will require a few more data members:
struct Transform {
Transform() = default; //(2)
Transform(Transform &) = delete; //(1)
//hierarchy information:
Transform *parent = nullptr;
Transform *first_child = nullptr; //(3)
Transform *next_sibling = nullptr; //(3)
Transform *prev_sibling = nullptr; //(3)
//...
};
Things to notice:
Transform
that participates in hierarchy doesn't have a clear correct behavior. (And any correct behavior will require pointer fix-up that the default copy constructor doesn't do.)
NOTE: we could have instead used a std::list< Transform * > children
, at the cost of more indirection in child iteration.
NOTE: we could have instead used a singly-linked list of child nodes at the expense of additional time to delete child elements.
Now we can define a member function to handle hierarchy re-arrangement:
//insert into child list of 'new_parent', before child 'before'
void Transform::set_parent(Transform *new_parent, Transform *before = nullptr) {
assert(before == nullptr || (new_parent != nullptr && before->parent == new_parent));
if (parent) {
//remove from existing parent:
if (prev_sibling) prev_sibling->next_sibling = next_sibling;
if (next_sibling) next_sibling->prev_sibling = prev_sibling;
else parent->last_child = prev_sibling;
next_sibling = prev_sibling = nullptr;
}
parent = new_parent;
if (parent) {
//add to new parent:
if (before) {
prev_sibling = before->prev_sibling;
next_sibling = before;
next_sibling->prev_sibling = this;
} else {
prev_sibling = parent->last_child;
parent->last_child = this;
}
if (prev_sibling) prev_sibling->next_sibling = this;
}
}
And, finally, we can make Transform
s remove themselves from the hierarchy in their destructor to prevent dangling pointers:
Transform::~Transform() {
while (last_child) {
last_child->set_parent(nullptr);
}
if (parent) {
set_parent(nullptr);
}
}
We want the parent
pointers to form a directed acyclic graph. Any cycles will result in infinite recursion when calling, e.g., make_local_to_world
.
It is straightforward to to add code to set_parent
to traverse parent
pointers and make sure that no pointer arrives back at this
.
This code makes setting parent pointers a bit more expensive, but does avoid potential debugging headaches.
Note that the current implementation of the render function will end up calling make_local_to_parent
on each Transform
for every descendant.
Instead of doing this, we could add code to traverse the relevant portions of the Transform
DAG in topological sort order, or to memoize transform matrices:
//NOTE: probably a silly idea!
std::unordered_map< const Transform *, glm::mat4 > local_to_world_cache;
std::function< glm::mat4 const &(Transform const &) > get_local_to_world =
[&get_local_to_world,&local_to_world_cache](Transform const &transform)
-> glm::mat4 const & {
auto f = local_to_world_cache.find(&transform);
if (f == local_to_world_cache.end()) {
if (transform.parent == nullptr) {
f = local_to_world_cache.insert(std::make_pair(
&transform, transform.make_local_to_parent()
)).first;
} else {
f = local_to_world_cache.insert(std::make_pair(
&transform,
get_local_to_world(*transform.parent) * transform.make_local_to_parent()
)).first;
}
}
return f->second;
};
In practice, this is probably not needed for relatively small scenes, and for large scenes it likely makes more sense to do an in-order tree traversal -- e.g., so one can skip sub-trees that are out of the camera's view.
Indeed, in this latter case one can maintain a stack of matrices and thus avoid this unordered_map
silliness.