Tutorial: Simple 3D Cube in Rust 🦀
Tags: rust, ascii
Published: 2024-06-22 09:26:18

---
title: Tutorial: Simple 3D Cube in Rust 🦀
published: true
date: 2024-06-21 18:19:40 UTC
tags: Rust, ASCII
canonical_url: https://eleftheriabatsou.hashnode.dev/tutorial-simple-3d-cube-in-rust
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c0ehztytenqey9f1roc9.jpeg
---

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:

```rust
//! 4    +------+  6
//!     /|     /| 
//! 5  +------+ |  7
//!    | |    | | 
//! 0  | +----|-+  2
//!    |/     |/   
//! 1  +------+    3
```

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:**

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

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

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:

```rust
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]),
];
```

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.

```rust
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],
];
```

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):

```rust
//! 4    +------+  6
//!     /|     /| 
//! 5  +------+ |  7
//!    | |    | | 
//! 0  | +----|-+  2
//!    |/     |/   
//! 1  +------+    3
```

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

```rust
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],
    ])
}
```

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 -&gt; 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.*

```rust
const SCREEN_WIDTH : usize = 80;
const SCREEN_HEIGHT : usize = 40;
```

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

```rust
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;
```

## Write a Rendering Loop

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

```rust
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],
        ]);
.
.
}
```

`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.

```rust
        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],
        ]);
```

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

```rust
        .
        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];
        }
.
.
```

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.

```rust
        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;
                }
            }
        }
```

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:

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

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)

```rust
std::thread::sleep(std::time::Duration::from_millis(30));
```

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]`
    

```rust
fn draw_line(frame: &mut [[u8; SCREEN_WIDTH]; SCREEN_HEIGHT], start: [f32; 2], end: [f32; 2]) {
    
    if dy.abs() > dx.abs() {
        .
        .
        .
        }
    } else {
        .
        .
        .
    }
}
```

Then we need to deconstruct our start and end into:

```rust
    let [x0, y0] = start;
    let [x1, y1] = end;
    let [dx, dy] = [x1 - x0, y1 - y0];
```

`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.

```rust
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'|';
        }
```

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:

```rust
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'-';
        }
    }
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xaxyh2lw9zm77kj5pf0m.png)

## 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](https://github.com/EleftheriaBatsou/3d-cube-rust).

{% embed https://github.com/EleftheriaBatsou/3d-cube-rust %} 

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**](https://limey.io/batsouelef) | [**X**](https://twitter.com/BatsouElef) | [**LinkedIn**](https://www.linkedin.com/in/eleftheriabatsou/)