Contents
Contents
The project is fully open source. Please refer to the following link https://github.com/RohitRTdev/cubeSpawner.git. Before we get into it, here's what it actually looks like running.
cubeSpawner rendering wireframe cubes with nothing but '*' characters
cubeSpawner is a small Windows console application that renders wireframe cubes in 3D, using only the asterisk character. There's no graphics API involved anywhere - no OpenGL, no DirectX, not even a GPU. Every cube is just 8 points floating in an abstract 3D space, and the entire renderer is a few hundred lines of C++ that does the two jobs a real graphics pipeline does, just by hand: move the points around with some matrix math, then figure out which characters on a 2D grid of console cells those points land on.
In this blog we'll go through the code and also understand the simple math behind how this works.
Once you've cloned and built it (it's a Visual Studio project, so open rotatingCube.sln and hit run),
here's what the controls do:
z and x - push the cube forwards and backwards (along z)a and d - rotate the cube about its y-axisw and s - rotate the cube about its x-axisq and e - rotate the cube about its z-axisf and g - zoom the whole scene in and outm - spawn a brand new cube at the center of the worldt - toggle which cube is "active" (the one arrows/rotation keys control next)
A cube, as far as this program is concerned, is nothing more than 8 points - its corners - each described
by an (x, y, z) coordinate. Everything cubeSpawner does to a cube boils down to two kinds of
operations on those 8 points:
Let's take these one at a time.
The building block is about as simple as it gets:
C++
struct vertex {
double x;
double y;
double z;
};
And the cube itself starts out as 8 of these, centered on the origin, each coordinate either -0.5
or 0.5 - a unit cube, one unit wide in every direction:
C++
vertex vertices[] = { { -0.5,-0.5,0.5 }, { -0.5,0.5,0.5 }, { 0.5,0.5,0.5 }, { 0.5,-0.5,0.5 },
{ -0.5,-0.5,-0.5 }, { -0.5,0.5,-0.5 }, { 0.5,0.5,-0.5 }, { 0.5,-0.5, -0.5 } };
The first four points are the front face (z = 0.5), the last four are the back face
(z = -0.5), and moving/rotating/zooming a cube is just a matter of running every one of these 8
points through some math, frame after frame.
To rotate a point, we need a formula that takes a point at some angle from the origin and produces a new
point at that angle plus however much we want to rotate by. Let's derive it for the simplest case first: a
point P sitting in a 2D plane, at angle θ from the x-axis, at distance r
from the origin. By definition of sine and cosine, its coordinates are x = r·cosθ and
y = r·sinθ.
Rotating P by an angle φ sweeps it to a new angle θ+φ
Now rotate P by an extra angle φ. The rotated point P′ sits at angle
θ+φ, still at the same distance r from the origin
, so x′ = r·cos(θ+φ) and
y′ = r·sin(θ+φ). Expanding those with the angle-addition identities:
x′ = r·cos(θ+φ) = r·cosθ·cosφ - r·sinθ·sinφ
y′ = r·sin(θ+φ) = r·sinθ·cosφ + r·cosθ·sinφ
Since r·cosθ = x and r·sinθ = y (they're just the coordinates of
the point before rotation), we can substitute those straight in:
x′ = x·cosφ - y·sinφ
y′ = x·sinφ + y·cosφ
That's the standard 2D rotation formula, rotating counter-clockwise by φ. cubeSpawner's
rotate_about_z does the same thing, just with the sign on the sine terms flipped:
C++
void rotate_about_z(double angle) {
for (auto& vert : vertices) {
double new_x = vert.x*cos(angle) + vert.y * sin(angle);
double new_y = vert.y * cos(angle) - vert.x * sin(angle);
vert.x = new_x;
vert.y = new_y;
}
}
It's the exact same formula with
φ replaced by -angle, i.e. positive angles rotate clockwise instead of counter-clockwise.
Since nothing else in the program cares which direction "positive" points, either convention works fine.
The derivation above only rotated x and y about the z-axis, treating z as
untouched. But this works for rotation around any axis: pick the axis to leave alone, and apply
the exact same 2D rotation formula to the other two coordinates. Rotating about x leaves x fixed and
spins y/z; rotating about y leaves y fixed and spins x/z:
C++
void rotate_about_x(double angle) {
for (auto& vert : vertices) {
double new_y = vert.y * cos(angle) + vert.z * sin(angle);
double new_z = vert.z * cos(angle) - vert.y * sin(angle);
vert.y = new_y;
vert.z = new_z;
}
}
void rotate_about_y(double angle) {
for (auto& vert : vertices) {
double new_x = vert.x * cos(angle) - vert.z * sin(angle);
double new_z = vert.x * sin(angle) + vert.z * cos(angle);
vert.x = new_x;
vert.z = new_z;
}
}
Pressing w/s, a/d and q/e
just calls these three functions one after another on every frame the key is held. Worth knowing as an
aside: applying three separate rotations like this one axis at a time (rather than combining them into a
single matrix beforehand) means the order you apply them in changes the result - rotate about x then y and
you get a different orientation than y then x. That's a real phenomenon in 3D graphics (it's part of what
causes gimbal lock).
Rotating and moving points is only half the job. However the 8 corners of a cube are arranged in 3D space, we still need to answer a more basic question: which character cell on a flat, 2D console grid does each point correspond to? That's what projection means here - collapsing a 3D point down onto a 2D surface.
There are many ways to do this, and cubeSpawner implements 2 of them (there's a compile-time flag,
cube::projection, that switches between them):
The simplest possible approach: just throw away the z coordinate and keep x and y as the screen coordinates. Two points with the same x and y project to the exact same spot on screen, no matter how far apart they are in z. This is easy, fast, and used all the time in CAD software and technical drawings, where you specifically don't want depth distorting the measurements - but it looks visually flat, because it throws away exactly the cue our eyes use to judge distance: things farther away should look smaller.
A camera - or an eye - doesn't work that way. Light from a point travels in a straight line into the eye, and the farther away that point is, the shallower the angle it arrives at, so it takes up less of your field of view. We can derive the formula from the concept of similar triangles. Picture the scene from above (looking straight down the y-axis), with an eye sitting behind a projection screen:
Parallel rays (left) vs. rays converging on an eye (right)
On the left, orthographic projection: the two dashed rays are parallel, so both the near and far point land at the same spot on the screen. On the right, perspective projection: both rays converge on the eye instead, and because the far point's ray has to travel a more vertical path to reach the same eye, it crosses the screen much closer to the center line than the near point's ray does. That's the whole effect in one picture - the farther a point is, the closer to the center it projects.
Turning that picture into a formula is one more similar-triangles argument: if the eye sits a distance
d behind the screen along the z-axis, and a point is at depth z (with the screen
itself at z = 0), the two similar triangles - one from the eye to the point, one from the eye
to the point's projection on the screen - give us:
x′ = x · d / (d - z)
y′ = y · d / (d - z)
Our version of this is the same, just with d fixed at 2:
C++
void perspective_projection(double& x, double& y, double& z) {
x = x / (2 - z);
y = y / (2 - z);
}
One last step remains. The formulas above give us x/y values that are still in an abstract, centered space
- the projected cube lives somewhere around [-1, 1] once you account for the
scale divide that happens first (that's also how zooming works: f/g
just make scale bigger or smaller, stretching or shrinking that range before projection ever
sees it). Console cells, on the other hand, are indexed from (0, 0) at the top-left
corner, growing right and down. So draw() ends with a small change of coordinate
system: shift the centered range into [0, 1], then scale by the console's actual width and
height - flipping y along the way, since screen rows grow downward while our math so far assumed y grows
upward:
C++
//Normalize to [-1,1] space
x_vert /= scale;
y_vert /= scale;
z_vert /= scale;
//Otherwise defaults to orthogonal projection
if(projection)
perspective_projection(x_vert, y_vert, z_vert);
//Switch to 4th quadrant
x_vert += 0.5;
y_vert -= 0.5;
//Switch to screen coordinates
screen_coord[i].x = x_vert * con.cols;
screen_coord[i].y = -y_vert * con.rows;
And that's the entire math of the program: rotate points with a 2D rotation formula applied one axis at a time, project them down to 2D with a division that mimics how an eye sees depth, and remap that abstract 2D space onto the console's actual rows and columns.
There are four
pieces: console, which owns the screen buffer and knows how to light up individual
characters; cube, which holds a set of vertices and knows how to transform, project and draw
itself; Scene, which owns a collection of cubes and decides which one is active; and
main(), which polls the keyboard every frame and calls into all of the above.
Every frame, the whole screen gets redrawn from scratch - there's no incremental updating of individual
characters. If you did that directly against the visible console buffer, you'd see flicker: the previous
frame's characters getting erased and redrawn one at a time, visibly, while the eye is looking right at it.
The fix is double buffering - draw the entire next frame into an off-screen buffer that's never
shown, then hand the whole thing to the console driver in one shot once it's complete. console
does exactly this, using the Win32 console API:
C++
struct console {
int rows;
int cols;
int m_color;
HANDLE screenHandle;
console() {
m_color = FOREGROUND_BLUE | FOREGROUND_GREEN | FOREGROUND_RED;
screenHandle = CreateConsoleScreenBuffer(GENERIC_WRITE | GENERIC_READ, 0, NULL, CONSOLE_TEXTMODE_BUFFER, NULL);
SetConsoleActiveScreenBuffer(screenHandle);
CONSOLE_SCREEN_BUFFER_INFO screen_info{};
GetConsoleScreenBufferInfo(screenHandle, &screen_info);
cols = screen_info.dwSize.X;
rows = screen_info.dwSize.Y;
buf.resize(rows * cols);
clear();
write();
}
void fill_char(int x, int y, WCHAR fill = L'*') {
if (x >= 0 && y >= 0 && x < cols && y < rows) {
buf[y*cols + x].Char.UnicodeChar = fill;
buf[y*cols + x].Attributes = m_color;
}
}
void write() {
COORD start = { 0, 0 };
SMALL_RECT rect{ 0, 0, cols - 1, rows - 1 };
COORD bufSize{ cols, rows };
WriteConsoleOutput(screenHandle, buf.data(), bufSize, start, &rect);
}
private:
std::vector<CHAR_INFO> buf;
};
Windows consoles can actually own more than one screen buffer at a time, and
CreateConsoleScreenBuffer is what creates that hidden second one - same shape as the terminal
window, just not the buffer currently being displayed. SetConsoleActiveScreenBuffer is the
flip side: it swaps which buffer the terminal is actually showing. And WriteConsoleOutput is a
bulk write - one call that blits an entire grid of characters and colors into a buffer at once, rather than
positioning a cursor and printing character by character the way an ordinary console write works.
A cube isn't drawn as 8 isolated points - it's drawn as 12 edges, each a straight line between two of its
projected vertices. draw_line(x1, y1, x2, y2) is a small line rasterizer: given two endpoints
in screen space, it has to decide which character cells between them to light up.
C++
void draw_line(int x1, int y1, int x2, int y2) {
int x_start, y_start, x_end, y_end;
if (x1 == x2 && y1 == y2) {
con.fill_char(x1, y1);
return;
}
if (x1 < x2) {
x_start = x1; y_start = y1; x_end = x2; y_end = y2;
}
else if (x1 == x2) {
x_start = x_end = x1;
y_start = min(y1, y2);
y_end = max(y1, y2);
}
else {
x_start = x2; y_start = y2; x_end = x1; y_end = y1;
}
bool is_inf = false;
double slope;
if (x_start == x_end) {
is_inf = true;
}
else {
slope = (y_start - (double)y_end) / (x_end - (double)x_start);
}
if (is_inf) {
for (int y = y_start; y < y_end; y++) {
con.fill_char(x_start, y);
}
}
else if (slope < 1 && slope > -1) {
for (int x = x_start; x < x_end; x++) {
int y_calc = slope * (x - x_start) - y_start;
con.fill_char(x, -y_calc);
}
}
else {
for (int y = min(y_start, y_end); y < max(y_end, y_start); y++) {
int x_calc = x_start + (y_start - y) / slope;
con.fill_char(x_calc, y);
}
}
}
Underneath the setup, draw_line is just walking the line's equation,
y = y_start + m·(x - x_start), one small step at a time, lighting up whichever character
cell each step lands on. The one real design choice is which axis to step along: a terminal's rows and
columns are roughly square, so stepping along x works fine for a line lesser than 45 degrees, but a steep line
(|slope| > 1) would skip past several rows between consecutive x steps and leave visible
gaps. So the function picks its stepping axis from the line's slope - x for more horizontal lines, y for steep
ones, solving for the other coordinate at each step.
With that in hand, drawing a cube is just calling draw_line once per edge. Looking back at
how the 8 vertices were laid out earlier - the first four form the front face, the last four the back
face - the 12 edges are the 4 sides of the front face, the 4 sides of the back face, and the 4 edges
connecting the two faces together:
C++
draw_line(screen_coord[0].x, screen_coord[0].y, screen_coord[1].x, screen_coord[1].y);
draw_line(screen_coord[0].x, screen_coord[0].y, screen_coord[3].x, screen_coord[3].y);
draw_line(screen_coord[0].x, screen_coord[0].y, screen_coord[4].x, screen_coord[4].y);
draw_line(screen_coord[1].x, screen_coord[1].y, screen_coord[2].x, screen_coord[2].y);
draw_line(screen_coord[1].x, screen_coord[1].y, screen_coord[5].x, screen_coord[5].y);
draw_line(screen_coord[2].x, screen_coord[2].y, screen_coord[6].x, screen_coord[6].y);
draw_line(screen_coord[2].x, screen_coord[2].y, screen_coord[3].x, screen_coord[3].y);
draw_line(screen_coord[3].x, screen_coord[3].y, screen_coord[7].x, screen_coord[7].y);
draw_line(screen_coord[4].x, screen_coord[4].y, screen_coord[5].x, screen_coord[5].y);
draw_line(screen_coord[4].x, screen_coord[4].y, screen_coord[7].x, screen_coord[7].y);
draw_line(screen_coord[5].x, screen_coord[5].y, screen_coord[6].x, screen_coord[6].y);
draw_line(screen_coord[6].x, screen_coord[6].y, screen_coord[7].x, screen_coord[7].y);
The code is deliberately simplified. In real engines, there will usually be a separate model and camera class. Any transformation done is stored in a special transformation matrix and vertex positions themselves are untouched. However, in this code we simply apply the transformations directly to the vertices.
Pressing m spawns another cube, and Scene is what keeps track of all of them.
It's a thin wrapper around a std::vector<cube>, plus an active_cube index
that decides which one the arrow/rotation/zoom keys currently affect:
C++
void render() {
con.clear();
int pos = 0;
for (auto& obj: cubes) {
if (pos == active_cube) {
con.set_color(color_palette[pos % 6]);
}
else {
con.set_color(FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE);
}
obj.draw();
pos++;
}
con.write();
}
void add_cube() {
cubes.push_back(cube(starting_mesh.data(), starting_mesh.size(), con));
active_cube = cubes.size() - 1;
}
void toggle_cube() {
active_cube = (active_cube + 1) % cubes.size();
}
A newly spawned cube starts life as a fresh copy of the same unit-cube mesh we saw earlier, and immediately becomes the active one. Every render, the active cube gets picked out of a small color palette while every other cube is drawn in plain white.
Finally, everything is driven from one loop in main(): render the scene, check which key (if
any) is currently held down, apply the corresponding transform, then sleep until the next frame:
C++
const int frame_time_ms = 20;
bool t_key_pressed = false, m_key_pressed = false;
while (1) {
scene.render();
if (is_key_pressed(VK_LEFT)) {
scene.fetch_active_cube().shift(step_size, 0);
}
// ... the rest of the movement, rotation and zoom keys ...
else if (is_key_pressed('m') || is_key_pressed('M')) { //Spawns a new cube
m_key_pressed = true;
}
else if (is_key_pressed('t') || is_key_pressed('T')) { //Selects a different cube
t_key_pressed = true;
}
else {
if (m_key_pressed) {
m_key_pressed = false;
scene.add_cube();
}
else if (t_key_pressed) {
t_key_pressed = false;
scene.toggle_cube();
}
}
con.update_screen_size();
Sleep(frame_time_ms);
}
is_key_pressed is a thin wrapper over GetKeyState, and at 20ms per frame the loop
runs at a steady 50 frames a second. Most keys apply their effect continuously, once per frame, for as long
as they're held.
And that's it. A handful of vertices, three rotation functions built from one angle- addition identity, a projection formula lifted straight from similar triangles, a hand-rolled line rasterizer, and a loop that polls the keyboard 50 times a second. Hope this was helpful.