Spinning Cube in Rust with ASCII and No Dependencies.
Run it: cargo run
Credits: You can find the original idea here.
Posted on June 22, 2024
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.
This is how the cube will look like:
//! 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:
#[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:
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.
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):
//! 4 +------+ 6
//! /| /|
//! 5 +------+ | 7
//! | | | |
//! 0 | +----|-+ 2
//! |/ |/
//! 1 +------+ 3
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],
])
}
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!
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;
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;
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],
]);
.
.
}
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],
]);
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];
}
.
.
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!
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;
}
}
}
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);
}
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));
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
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 {
.
.
.
}
}
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];
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'|';
}
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'-';
}
}
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.
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
.
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.
Run it: cargo run
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.
Posted on June 22, 2024
Sign up to receive the latest update from our blog.