P L A N E T O R U S Retrospective
"Wait, how did you ...?"
My approach to making games has always been focused on solving problems I consider interesting. In that same vein, this post is focused on those aspects of my entry P L A N E T O R U S that I found interesting and enjoyed solving. As a disclaimer, a lot of the code in this post can no doubt be optimized or in some cases replaced with analytic solutions; but that's how game jams go.
Before reading on, I suggest you take a chance to play my entry so this post has context.
https://ldjam.com/events/ludum-dare/38/planetorus
How to Address the Torus Surface
The surface of a torus can be represented as a 2D surface which wraps around at the seams. Towards this end we can define a rectangular map with an arbitrary MAP_WIDTH and MAP_HEIGHT. These lead to a MAJOR_RADIUS and MINOR_RADIUS for the torus as calculated below.
MAJOR_RADIUS = MAP_WIDTH * Mathf.PI * 2
MINOR_RADIUS = MAP_HEIGHT * Mathf.PI * 2
A 2D location on this map can be converted to a location in 3D on the surface of the torus with the following procedure.
``` public static Vector3 get3DLocationFor2DLocation (Vector2 mapLocation) {
float vertAsRad = (mapLocation.y / MAP_HEIGHT) * (2 * Mathf.PI);
Vector2 minorCircle = new Vector2 (Mathf.Cos(vertAsRad), Mathf.Sin(vertAsRad));
minorCircle *= MINOR_RADIUS;
float horzAsRad = (mapLocation.x / MAP_WIDTH) * (2 * Mathf.PI);
Vector2 majorCircle = new Vector2 (Mathf.Cos (horzAsRad), Mathf.Sin (horzAsRad));
majorCircle *= MAJOR_RADIUS;
Vector3 result = new Vector3
( majorCircle.x + (Mathf.Cos(horzAsRad) * minorCircle.x),
minorCircle.y,
majorCircle.y + (Mathf.Sin(horzAsRad) * minorCircle.x)
);
return result;
} ```
Essentially, we find the x,y map location as a ratio of the map width and height; which are then converted to radians, horzAsRad and vertAsRad. These two ratios can then be turned into separate 2D locations on the major circle and minor circle respectively. These two 2D circle locations are then added together. The major circle is treated as laying flat in the x⨯y plane. Note that the x component of the minor circle is transformed before addition, as the position along the major circle determines its orientation in 3D space.

To correctly orient objects along the surface of the torus we also need to know the normal vector for a given location on the torus map. This was achieved by normalizing the offset from the 3D location retrieved from the function above and a 3D location on the major circle; though in retrospect there are more efficient methods by which to calculate the surface normal.
Moving objects along the surface of the was achieved using a simple 2D RK4 integrator; the details of which I won’t include here, but see the link below for more details.
http://gafferongames.com/game-physics/integration-basics/
This however lead to an issue: moving horizontally on the torus map would give different 3D velocities depending on the vertical component of the 2D location. Mapping from the 2D rectangle to the 3D torus introduced distortion which affected the horizontal components of distances; luckily this distortion could be represented and corrected in the evaluation step of the RK4 integrator by scaling the x component of the entity’s torus map velocity by a factor given by the following function:
``` public static float getHorizontalMapDistortion (float verticalPosition) { float vertAsRad = (verticalPosition / MAP_HEIGHT) * (2 * Mathf.PI); float offset = Mathf.Cos(vertAsRad);
return 1 - (offset * (MINOR_RADIUS / MAJOR_RADIUS));
} ```
In effect, this function returns the ratio between the radius of the major circle and the radius of the circle created by a horizontal line placed at the given vertical position on the torus map.
With these functions available it became easy to orient entities on the torus surface along their direction of travel. This was handled by the inbuilt Unity function Transform.LookAt(Transform target, Vector3 worldUp), which rotates the operand transform so its forward vector aims at the target transform and then rotates the operand transform so its up vector aims in the direction given by the worldUp parameter. The target transform was an empty object whose position was set by the get3DLocationFor2DLocation function; the parameter to which was the entity's 2D position plus its normalized velocity. The value of the worldUp parameter is the surface normal for the entity's position on the torus surface.
Positioning the camera above the player object was done by directly setting the camera’s position to be some distance along the normal vector from the player’s current position. The camera’s orientation was set by the Transform.LookAt() function; though the camera’s own up vector, transformed into world space, was used as the worldUp parameter. This allows the camera to smoothly follow the player around the surface without any jarring discontinuities in orientation or position.
Controlling Entities on the Torus Surface
One problem which gave me considerable trouble was that of relating camera local directions to directions on the torus surface. After a few dead ends, the procedure that is present in the final game is as follows: find a local 3D basis for the torus surface in world space, take a camera local direction and transform it into world space, and find the projection of this camera local direction onto the local 3D basis for the torus surface.
This local 3D basis for the torus surface is constructed for a given location on the torus map and is comprised of the surface normal at the given location, and two 3D offsets found by adding to the horizontal and vertical components of the given torus map location. This set of vectors is then orthonormalized with the inbuilt Vector3.Orthonormalize() function. In effect, this set is used to describe the 3D directions which correspond to the up and right directions on the torus map.
There is a useful assumption that can be made; the camera is positioned along the surface normal and is oriented so that its forward vector is parallel with the surface normal. Due to this assumption, we can ignore the z direction of the camera and the vector in the local 3D torus surface basis which corresponds to the surface normal. This means that our directional input is constrained to the camera local x⨯y plane. We use the inbuilt Transform.TransformDirection() function, which maps the input direction from camera local space to world space. From here we can project this direction onto the two remaining vectors in the local 3D torus surface basis by taking the dot products of this world space vector against the right and up vectors respectively.
``` Vector3 torusHorz = ToroidMap.getTorusMapRightDirection (this.torusMapPos); Vector3 torusVert = ToroidMap.getTorusMapUpDirection (this.torusMapPos); Vector3 torusNorm = ToroidMap.getNormalVectorForMapLocation (this.torusMapPos);
Vector3.OrthoNormalize (ref torusNorm, ref torusVert, ref torusHorz);
Vector2 torusMapDirection = new Vector2
( Vector3.Dot (worldSpaceDirection, torusHorz),
Vector3.Dot (worldSpaceDirection, torusVert));
```
This procedure allows the player to indicate directions for movement and weapons fire in 2D intuitively. In retrospect, there is an analytic solution to finding the local 3D surface basis.
Summary
I very much enjoyed LD 38, and I was pleased with the end result of my entry. As with any game jam, there are always things I would have approached differently if I were to create this again. I hope this post has answered any questions about the implementation of this game you may have had.










