A Transformation Hierarchy

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.

Basic Idea: Everyone Gets A Transform

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.

First Concern: Pointer Maintenance

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 Transforms, 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 Objects are allocated and managed by the Scene, but the relationships between their Transforms 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:

  1. The copy constructor is explicitly deleted. This is important because copying a 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.)
  2. Because the copy constructor has been deleted, we need to explicitly indicate that the default constructor is still valid.
  3. We add three additional pointers to allow a parent transform to iterate through its children.

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 Transforms 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);
	}
}

Second Concern: DAG Cycles

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.

Third Concern: Inefficiency

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.