Tutorial: Simple 3D Cube in Rust 🦀

eleftheriabatsou

Eleftheria Batsou

Posted on June 22, 2024

Tutorial: Simple 3D Cube in Rust 🦀

Hello everyone, welcome back to my blog or if you're new here, hi, I'm Eleftheria and I'm learning Rust. Today we are going to create a simple spinning 3D cube using Rust and as less as dependencies as possible. This project is beginner-friendly and at the end of this article, you can find all the code on GitHub.

Introduction

This is how the cube will look like:

//! 4    +------+  6
//!     /|     /| 
//! 5  +------+ |  7
//!    | |    | | 
//! 0  | +----|-+  2
//!    |/     |/   
//! 1  +------+    3
Enter fullscreen mode Exit fullscreen mode

Our cube is going to be a 3D object with eight vertices and six faces.

There are some decent algebra libraries for doing 3D but in our case, it's not necessary to use a library. Let's start coding:

#[derive(Debug, Clone, Copy)]
struct Matrix([[f32; 4]; 4]);

#[derive(Debug, Clone, Copy)]
struct Vector([f32; 4]);
Enter fullscreen mode Exit fullscreen mode

The matrix is an array of 4 arrays of 4 numbers. It's 4x4 matrix. Each of our inner arrays [[f32; 4] contains 4 floating point numbers which represent a column of our matrix. We tend to think of it as a convention in computer graphics to use columns based matrices.

The Vector is also relatively simple. It's an array of 4 numbers.

Let's also define the vertices:

const VERTICES : [Vector; 8] = [
    Vector([-1.0, -1.0, -1.0, 1.0]),
    Vector([-1.0, -1.0,  1.0, 1.0]),
    Vector([ 1.0, -1.0, -1.0, 1.0]),
    Vector([ 1.0, -1.0,  1.0, 1.0]),
    Vector([-1.0,  1.0, -1.0, 1.0]),
    Vector([-1.0,  1.0,  1.0, 1.0]),
    Vector([ 1.0,  1.0, -1.0, 1.0]),
    Vector([ 1.0,  1.0,  1.0, 1.0]),
];
Enter fullscreen mode Exit fullscreen mode

If you take a closer look at the table above, you'll notice we have 8 vertices, it's basically a binary truth table of vertices. The last column is always 1 and the last row is always 1 . 0 means we're representing a distance rather than a position, so positions have 1 and directions have 0 .

We have the positions (VERTICES ), now we also need to represent the FACES of the cube. Let's add an array of indices for the faces.

const FACES : [[u8; 4]; 6] = [
    [1, 5, 7, 3],
    [3, 7, 6, 2],
    [0, 4, 5, 1],
    [2, 6, 4, 0],
    [0, 1, 3, 2],
    [5, 4, 6, 7],
];
Enter fullscreen mode Exit fullscreen mode

Each face has 4 indices, these show which index represents which face of the cube. The order of these is quite important, we're going clockwise in this particular case and to understand it a little bit better, have a look again here (each row represents one face):

//! 4    +------+  6
//!     /|     /| 
//! 5  +------+ |  7
//!    | |    | | 
//! 0  | +----|-+  2
//!    |/     |/   
//! 1  +------+    3
Enter fullscreen mode Exit fullscreen mode

Now let's have a function that does: matrix x vector.

fn matrix_times_vector(m: &Matrix, v: &Vector) -> Vector {
    let [mx, my, mz, mw] = &m.0;
    let [x, y, z, w] = v.0;
    // The product is the weighted sum of the columns.
    Vector([
        x * mx[0] + y * my[0] + z * mz[0] + w * mw[0],
        x * mx[1] + y * my[1] + z * mz[1] + w * mw[1],
        x * mx[2] + y * my[2] + z * mz[2] + w * mw[2],
        x * mx[3] + y * my[3] + z * mz[3] + w * mw[3],
    ])
}
Enter fullscreen mode Exit fullscreen mode

You'll notice we have a &Matrix and a &Vector and we get a Vector. We're going to multiply the matrix by the vector. If you're wondering how this works then -> we kind of use destructuring to unpack the columns of the vector: let [mx, my, mz, mw] = &m.0;

And we also unpack the rows of the vector into: let [x, y, z, w] = v.0;

Now we have our basic cube structure! This structure will also help us spin the cube!

Render the Cube

We need a screen to render the cube. Let's start with our definition of the screen. We're going to render it in ASCII so our screen will be actually quite low resolution (40x80).

You can experiment with higher resolution but it's out of the scope of this article.

const SCREEN_WIDTH : usize = 80;
const SCREEN_HEIGHT : usize = 40;
Enter fullscreen mode Exit fullscreen mode

I'm also going to define an offset and a scale. These will help me position the cube in the center of my screen.

const OFFSET_X : f32 = SCREEN_WIDTH as f32 * 0.5;
const OFFSET_Y : f32 = SCREEN_HEIGHT as f32 * 0.5;
const SCALE_X : f32 = SCREEN_WIDTH as f32 * 0.5;
const SCALE_Y : f32 = SCREEN_HEIGHT as f32 * 0.5;
Enter fullscreen mode Exit fullscreen mode

Write a Rendering Loop

Let's write a rendering look for our animated cube:

for frame_number in 0.. {
        let mut frame = [[b' ';SCREEN_WIDTH]; SCREEN_HEIGHT];

        let t = frame_number as f32 * 0.01;
        let (c, s) = (t.cos(), t.sin());

        let cube_to_world = Matrix([
            // Each row is a column of a matrix.
            [  c, 0.0,   s, 0.0],
            [0.0, 1.0, 0.0, 0.0],
            [ -s, 0.0,   c, 0.0],
            [0.0, 0.0,-2.5, 1.0],
        ]);
.
.
}
Enter fullscreen mode Exit fullscreen mode

frame_number represent the time we're rendering and typically we tend to render at a fixed interval, so the frame number will be representing our time. We also need time to render into, so let's make a frame buffer: let mut frame = [[b' ';SCREEN_WIDTH]; SCREEN_HEIGHT]; . Width and height are bytes, these bytes are going to be our ASCII characters which represent our ASCII screen which we're going to render.

The next thing we need to do is to transform our cube because we're rotating it. Firstly, we need to create a time: let t = frame_number as f32 * 0.01; . Here we're taking the frame_number and multiplying it by a small number so this gives us gives a relatively slowly incrementing time.

The next thing is to define a cos and a sin , in a plot graph, the cos is the x direction and the sin is the y.

let (c, s) = (t.cos(), t.sin());

On the matrix, we have the x, y and z directions, and the last one has the position that represents the origin of our matrix. So this matrix is going to spin our cube around a vertical axis.

        let cube_to_world = Matrix([
            // Each row is a column of a matrix.
            [  c, 0.0,   s, 0.0],
            [0.0, 1.0, 0.0, 0.0],
            [ -s, 0.0,   c, 0.0],
            [0.0, 0.0,-2.5, 1.0],
        ]);
Enter fullscreen mode Exit fullscreen mode

Now, let's use this matrix to transform the coordinates into screen positions which have a few components...

        .
        let mut screen_pos = [[0.0, 0.0]; 8];
        for (v, s) in VERTICES.iter().zip(screen_pos.iter_mut()) {
            let world_pos = matrix_times_vector(&cube_to_world, v);
            let recip_z = 1.0 /  world_pos.0[2];
            let screen_x = world_pos.0[0] * recip_z * SCALE_X + OFFSET_X;
            let screen_y = world_pos.0[1] * recip_z * SCALE_Y + OFFSET_Y;
            *s = [screen_x, screen_y];
        }
.
.
Enter fullscreen mode Exit fullscreen mode

As you see above, we'll need to create another loop! With this, we'll build an array of screen positions (screen_pos ). In our case, they're two-dimensional coordinates so there are 8 screen positions to our corresponding 8 VERTICES. Firstly, we're going to loop over all the vertices, and we also need to create a world_pos to read from v , hence we're going to use the matrix_times_vector(&cube_to_world, v) function. This will transform our cube into world_pos . Then we're going to calculate the z coordinates and the screen position (screen_x, screen_y) ~ as we mentioned earlier, we want the cube to be near the center of our screen. In the end, I take the screen_x, screen_y coordinates and throw them into a temporary array *s = [screen_x, screen_y];.

Right now, we're at a very good point with the cube, but let's keep going!

Cube's Faces

Time to draw some lines to represent the cube's faces.

        for face in FACES {
            if !cull(screen_pos[face[0] as usize], screen_pos[face[1] as usize], screen_pos[face[2] as usize]) {
                let mut end = face[3];
                for start in face {
                    draw_line(&mut frame, screen_pos[start as usize], screen_pos[end as usize]);
                    end = start;
                }
            }
        }
Enter fullscreen mode Exit fullscreen mode

We'll need another loop! I'll take the FACES from our cube which represents the indexes and draw some lines for the FACES . To do that, I'll start by creating another function (draw_line). What this function does is draw some slashes (we'll write it just below). To show what a line looks like we're going to use the screen position at the start and end of our line.

draw_line(&mut frame, screen_pos[start as usize], screen_pos[end as usize]);

We're going to draw 4 lines in total for the face (let mut end = face[3];) and then at the end we set end = start; so we always end up with start. The next thing we need to do is to render our screen. There's going to be a blank screen to start with but:

        for l in 0..SCREEN_HEIGHT {
            let row = std::str::from_utf8(&frame[l]).unwrap();
            println!("{}", row);
        }
Enter fullscreen mode Exit fullscreen mode

with the code you see above, we can iterate over our screen and convert each of the lines of our frame into a string: let row = std::str::from_utf8(&frame[l]).unwrap();

Printing the row is how we're rendering it.

We're also going to add beneath our for loop a sleep so we can see things slowly. (There are more ways to achieve this, but I think this is one of the simplest tricks)

std::thread::sleep(std::time::Duration::from_millis(30));
Enter fullscreen mode Exit fullscreen mode

Another thing we want to do is to clear the screen or at least reset the cursor. This is how we can achieve this:

print!("\x1b[{}A;", SCREEN_HEIGHT);

draw_line Function

draw_line takes 3 parameters:

  • frame: &mut [[u8; SCREEN_WIDTH]; SCREEN_HEIGHT]

  • start: [f32; 2]

  • end: [f32; 2]

fn draw_line(frame: &mut [[u8; SCREEN_WIDTH]; SCREEN_HEIGHT], start: [f32; 2], end: [f32; 2]) {

    if dy.abs() > dx.abs() {
        .
        .
        .
        }
    } else {
        .
        .
        .
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we need to deconstruct our start and end into:

    let [x0, y0] = start;
    let [x1, y1] = end;
    let [dx, dy] = [x1 - x0, y1 - y0];
Enter fullscreen mode Exit fullscreen mode

d stands for our delta direction and we're doing the end minus the start ([x1 - x0, y1 - y0]). If we've got a vertical line this: x1 - x0 will be 0 , and if we've got a horizontal line this: y1 - y0 would be 0.

We've 2 different types of lines to draw: horizontal and vertical lines. When we're drawing vertical lines we only want to draw one character per row and when we draw near horizontal lines we want to draw only one character per column. As you can see from the code above we've split the lines up into 2 drawing loops.

if dy.abs() > dx.abs() {
        let ymin = y0.min(y1);
        let ymax = y0.max(y1);
        let iymin = ymin.ceil() as usize;
        let iymax = ymax.ceil() as usize;
        let dxdy = dx / dy;
        for iy in iymin..iymax {
            let ix = ((iy as f32 - y0) * dxdy + x0) as usize;
            frame[iy][ix] = b'|';
        }
Enter fullscreen mode Exit fullscreen mode

If dy.abs() > dx.abs() then this is a largely vertical line, else if it's a largely horizontal line then run this piece of code:

else {
        let xmin = x0.min(x1);
        let xmax = x0.max(x1);
        let ixmin = xmin.ceil() as usize;
        let ixmax = xmax.ceil() as usize;
        let dydx = dy / dx;
        for ix in ixmin..ixmax {
            let iy = ((ix as f32 - x0) * dydx + y0) as usize;
            frame[iy][ix] = b'-';
        }
    }
Enter fullscreen mode Exit fullscreen mode

We're going to draw from the minimum to the maximum, which is actually from the top to the bottom because our y coordinate is sort of 0 downwards in this particular coordinate system and we're going to kind of make ymin and ymax in floating point and iymin and iymax in integer, this is because we need to draw a specific number of lines in our specific number of integer cells.

The horizontal lines are similar. I'm not going to explain the code in detail but if you have any questions feel free to ask me in the comments.

Run It

Are you still here?! Congrats! 🥳

We're ready to run it. Just type on your terminal cargo run and you'll see it running. To stop, type ctrl+c.

Image description

Conclusion

I hope you liked this project, I know it was different than what I usually do (and this one has more maths than my previous projects), but if you find it interesting, you can also have a look at my GitHub profile, where you will find all the code.

Spinning Cube in Rust with ASCII and No Dependencies.

Run it: cargo run

cube-better

Credits: You can find the original idea here.






Happy Rust Coding! 🤞🦀


👋 Hello, I'm Eleftheria, Community Manager, developer, public speaker, and content creator.

🥰 If you liked this article, consider sharing it.

🔗 All links | X | LinkedIn

💖 💪 🙅 🚩
eleftheriabatsou
Eleftheria Batsou

Posted on June 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related