I published another one of my projects today. I mentioned in the title that it is new, but that's not entirely true.
The 4D Labyrinth is a program that I initially wrote about 10 years ago. My high-school friend had then an idea that a 4-dimensional shooter game could be quite funny. In his vision, the world would be 4-dimensional, but out of the 4 coordinates only 3 would be chosen to render a 3-dimensional scene. The player could change this set of coordinates at any moment, leading to strange situation like a 2-dimensional slice of another player moving towards you, briefly becoming 3-dimensional (as he would temporarily "jump" into the same space), kills you and becomes flat again. I decided to create a proof-of-concept of something like that, but I resolved to use continuous rotations of the visible slice instead of discrete coordinate set switching.
The first version was written in C++ and its code is still available on GitHub. It used direct WinAPI calls to create the window and OpenGL calls to draw shapes. The code is pretty chaotic (as I wasn't a very experienced programmer back then...), but it's not very long and it works. Direct WinAPI calls tied it strongly to Windows, though.
A few years later, I decided to rewrite the Labyrinth in a more portable way by using Qt. I put a part of this attempt in a separate branch on GitHub, but I think I never finished it. The linked code even compiles, I think, but if I recall correctly, it doesn't work.
Some 2 years ago I had an idea to try and create an Android version. This is also in a repo on GitHub. I reached the point at which the app displayed a rotating hypercube and stopped. I couldn't come up with a good way of implementing controls with 9 degrees of freedom (3 directions of movement and 6 rotations, one direction of movement left out on purpose) on a touchscreen and I eventually lost interest in the project.
I felt the urge to try and rewrite the project in Rust recently. I'm interested in the language for some time now and I often feel like doing something in it, but I rarely have ideas for projects that would actually keep me interested. This one proved appropriate.
The structure of the project
You should probably read the project description linked at the beginning first. It explains what the program actually does, and without knowing that you probably won't get what I'm talking about ;)
The structure of the old C++ version
The C++ version had a simple, although not very clean design. At the heart of the program was the
Graph4D class, which contained a queue of shapes to be drawn, a matrix stack, some OpenGL settings and methods allowing for adding new shapes to the queue and changing the current transformation. The main method of this class drew the contents of the shape queue using OpenGL, doing a lot of calculations before that - primarily, each shape had to be intersected with the current hyperplane of the camera, which generated the shapes to be actually drawn using 3D techniques.
The hyperplane was described by a
Camera object. It had a position and orientation, described by 4 vectors, defining the right, up, front and "ana" directions ("ana" and "kata" are the accepted names for the directions along the 4th axis, like left/right, front/back, up/down). The "ana" vector doubled as a vector perpendicular to the currently displayed hyperplane.
All of this was based on two classes,
matrix4, representing a 4-dimensional vector and a 4-dimensional matrix respectively. The matrix actually had 5x5 components in order to be able to describe translations as well.
There were three additional classes built on that - the player (
LPlayer), the wall (
LWall) and the target (
LTarget). Each of them inherited from an
Object class, which defined a virtual
doYourJob() method, responsible for drawing the object. In addition, there was a class of an object queue, which contained multiple
Objects and could call
doYourJob() on each of them.
All objects were created in the main function (using some helpers, like a function reading a level definition from file), added to the queue and then repeatedly the queue "did its job" and a frame was drawn in a loop. All controls were also being handled in the main loop.
When rewriting all that from scratch, I decided to base a bit on the old version (after all, the previous experience has some worth), but to design it a bit cleaner.
Structure of the Rust version
The Rust version is similarly divided - into
graph4d library, containing the general logic of drawing 4-dimensional shapes, and the proper application
4d-labyrinth, which builds game objects on it.
graph4d library is designed similarly to the Graph4D part in C++, but a bit more subdivided. The geometry module contains 4-dimensional vectors and matrices, like in C++. Having now a better grasp on why matrices should be extended to 5x5, I also made the vectors consist of 5 coordinates, of which the fifth one is just a number used to divide the other four in order to get the proper coordinates. It will potentially allow to extend the library by adding the perspective projections from 4D to 3D.
The shapes available to be drawn were described in C++ by a struct containing the shape type number and 1 to 4 vertices. I Rust, I used an enum for that. This way the compiler can check whether every possibility is being handled.
The greatest difference lies in the camera implementation, I think. In C++, it was a separate object, used by the renderer to determine the positions of shapes in 3D. In Rust I used a trait. This trait provides two methods - for determining the visible hyperplane and for transforming points to the local coordinate system. Since I tied the camera to the player in 4D Labyrinth anyway, I decided to implement this trait for the player struct. In C++ each movement had to transform the player and the camera separately - in Rust there will only be one transformation, and we don't lose any functionality (the trait can be implemented for any other struct if we want a separate camera).
The rendering object calculates the intersections of the shapes with the visible hyperplane, like before, except this time it is just a call to the shape's
intersect() method with a hyperplane provided by the camera. In contrast, in C++ the renderer did all the work. The result is then given to OpenGL via the awesome Glium library.
I had to learn a bit about modern OpenGL at this point. My knowledge of this API was stuck in times when you just called
glVertex multiple times and finally
glEnd. This is still possible now, but it is recommended to use vertex buffers and shaders and that's the way Glium works. Fortunately, implementing old default transformations in shaders is very simple, and the rest is done in Glium quite painlessly (although understanding this painlessness took me some time).
This practically sums up the library. The application itself is also similar - there are
Target structs. Each of them implements the
GameObject trait, which provides a method for drawing the object. Apart from that, the walls and the target implement a
Collidable trait, which is used to detect collisions with the player.
The object queue is implemented a bit differently. A
Level struct serves as the queue, which reads the definitions of walls and the target from a file and creates appropriate objects as well. It also has two methods:
collidables(), returning iterators through all objects implementing
Collidable, respectively. The experimental
impl Trait syntax proved incredibly useful here, as the return types of those methods would be horribly complex without it. Unfortunately, using it ties the whole program to the nightly version of the compiler.
The main loop of the program also works a bit differently from the C++ version. This time I wanted the player object to be responsible for its movements. The main loop only saves the keyboard state in a special struct, which it then passes to the player. The player analyses it and moves itself. This creates problems with collisions, though.
In the C++ version it looked like this - the main loop checked the keyboard state, called appropriate methods on the player object, which moved it, and then the collisions were checked. If there were any, the movement was rolled back. All of this was possible, because the whole responsibility for object movements was on the main loop.
In Rust, when the player decides to move based on the state of the keyboard, we can't really do anything about it. We also can't check for collisions before the movement - if a collision happens, it is already too late and the previous movement shouldn't have happened. I solved the problem like this: the player's moving method returns a value that describes what the player is going to do. The collisions are checked based on this value and only if everything is OK, the player is allowed to execute the movement.
The project had a huge educational value for me. I've created a nontrivial program in Rust and its code is quite pleasing to me aesthetically. I learned a bit about modern OpenGL, which I haven't used much previously (I did use some of it when I created the Android version, but I didn't remember much). I've experienced first-hand the usefulness of the
impl Trait syntax, which I considered unnecessary before. And, most importantly, I had some exercise in writing Rust (although I will have a lot of it now, since programming in Rust is my job for about 2 weeks now ;) ).
The 4D Labyrinth can be downloaded from the project page (linked at the beginning). I encourage you to try to have some fun with it :)