3D rendering principle and simple JavaScript implementation [without using WebGL]

Displaying images and other flat shapes in web pages is very easy. However, when it comes to displaying 3D shapes, things become less easy because 3D geometry is more complex than 2D geometry. To do this, you can use specialized technologies and libraries such as WebGL and Three.js.

However, if you just want to display some basic shapes (such as a cube), these techniques are not needed. Furthermore, they won’t help you understand how they work and how we can display 3D shapes on a flat screen.

The purpose of this tutorial is to explain how to build a simple 3D engine for the web without WebGL. We’ll first look at how to store 3D shapes. Then we’ll see how to display these shapes in two different views.

Recommended online tools: Three.js AI texture development kit – YOLO synthetic data generator – GLTF/GLB online editing – 3D model format online conversion – Programmable 3D scene editor

1. All shapes are polyhedrons

There is one major difference between the virtual world and the real world: nothing is continuous, everything is discrete. For example, you can’t display a perfect circle on the screen, but you can achieve it by drawing a regular polygon with many sides: the more sides, the more “perfect” the circle is.

In 3D it’s the same thing, every shape has to be treated with the 3D equivalent of a polygon: a polyhedron. In this 3D shape we can only find flat surfaces instead of curved sides as in a sphere. This is not surprising when we are talking about shapes that are already polyhedral, such as a cube, but it is something to keep in mind when we want to display other shapes, such as a sphere.

2. Storage of polyhedron

In order to guess how to store a polyhedron, we have to remember how to recognize such a thing in mathematics. You must have learned some basic geometric shapes during your school days. For example, to identify a square, you might call it ABCD, where A, B, C, and D refer to the vertices that make up each corner of the square.

The same goes for our 3D engine. We’ll start by storing each vertex of the shape. The shape will then list its faces, and each face will list its vertices.

In order to represent a vertex, we need the correct structure. Here we create a class to store the coordinates of the vertices.

var Vertex = function(x, y, z) {
    this.x = parseFloat(x);
    this.y = parseFloat(y);
    this.z = parseFloat(z);
};

Vertices can now be created like any other object:

var A = new Vertex(10, 20, 0.5);

Next, we create a class that represents the polyhedron. Let’s take the cube as an example. The definition of this class is as follows, explained later.

var Cube = function(center, size) {
    // Generate the vertices
    var d = size / 2;

    this.vertices = [
        new Vertex(center.x - d, center.y - d, center.z + d),
        new Vertex(center.x - d, center.y - d, center.z - d),
        new Vertex(center.x + d, center.y - d, center.z - d),
        new Vertex(center.x + d, center.y - d, center.z + d),
        new Vertex(center.x + d, center.y + d, center.z + d),
        new Vertex(center.x + d, center.y + d, center.z - d),
        new Vertex(center.x - d, center.y + d, center.z - d),
        new Vertex(center.x - d, center.y + d, center.z + d)
    ];

    //Generate the faces
    this.faces = [
        [this.vertices[0], this.vertices[1], this.vertices[2], this.vertices[3]],
        [this.vertices[3], this.vertices[2], this.vertices[5], this.vertices[4]],
        [this.vertices[4], this.vertices[5], this.vertices[6], this.vertices[7]],
        [this.vertices[7], this.vertices[6], this.vertices[1], this.vertices[0]],
        [this.vertices[7], this.vertices[0], this.vertices[3], this.vertices[4]],
        [this.vertices[1], this.vertices[6], this.vertices[5], this.vertices[2]]
    ];
};

Using this class we can create a virtual cube by indicating its center and the length of its sides:

var cube = new Cube(new Vertex(0, 0, 0), 200);

The constructor of the Cube class first generates the vertices of the cube, calculated based on the position of the indicated center. A pattern would be clearer, so look below at the positions of the eight vertices we generated:

Then we list the appearance. Each face is a square, so we need to indicate four vertices for each face. Here, I chose to use an array to represent a face, but you can create a dedicated class for this if you need.

When we create a face, we use four vertices. We don’t need to indicate their position again since they are stored in this.vertices[i] object. This is practical, but there’s another reason we do it.

By default, JavaScript tries to use as little memory as possible. To achieve this, it does not copy objects passed as function arguments, or even objects stored into an array. For our example this is perfect behavior.

In fact, each vertex contains three numbers (their coordinates) and several methods if we need to add them. If for each face we store a copy of the vertices we will use a lot of memory which is useless. Here, all we have is a Reference: the coordinates (and other methods) are stored once and only once. Since each vertex is used by three different faces, by storing a reference instead of a copy, we divide the memory required by 3 (more or less)!

3. Do we need triangles?

If you’ve played in 3D (for example using software like Blender, or libraries like WebGL), you’ve probably heard that we should use triangles. Here I chose not to use triangles.

The reason behind this choice is that this article is an introduction to the topic and we will show basic shapes such as cubes. In our case, using triangles to display squares is more complicated than anything else.

However, if you plan to build a more complete renderer, you need to know that, in general, triangles are preferred. There are two main reasons for this:

  • Texture: For some mathematical reasons, in order to display an image on a surface, we need triangles;
  • Weird faces: three vertices are always on the same plane. However, you can add a fourth vertex that is not in the same plane and create faces that connect the four vertices. In this case, to draw it we have no choice: we have to divide it into two triangles (just try it with a piece of paper!). By using triangles you can maintain control and choose where the split occurs).

4. Acting on polyhedron

Storing a reference instead of a copy has another advantage. When we want to modify a polyhedron, using such a system also divides the number of operations required by three.

To understand why, let’s recall our math class again. When you want to translate a square, you don’t really translate it. In fact, you translate four vertices and then add the translation.

Here, we do the same thing: we don’t touch our faces. We apply the required operations to each vertex and we’re done. When a face uses a reference, the face’s coordinates are automatically updated. For example, look at how we translated the cube we created earlier:

for (var i = 0; i < 8; + + i) {
    cube.vertices[i].x + = 50;
    cube.vertices[i].y + = 20;
    cube.vertices[i].z + = 15;
}

We know how to store 3D objects and how to operate on them. Now it’s time to see how to view them! But first we need a little theoretical background in order to understand what we are trying to do.

5. Projection

Currently, we store 3D coordinates. However, the screen can only display 2D coordinates, so we need a way to convert the 3D coordinates into 2D coordinates: this is what we in mathematics call a projection. 3D to 2D projection is an abstract operation performed by a new object called a virtual camera. The camera takes the 3D object and converts its coordinates into 2D coordinates, sending them to the renderer, which displays them on the screen. We assume that our camera is placed at the origin of 3D space, so its coordinates are (0,0,0).

From the beginning of this article, we have discussed coordinates, represented by three numbers: x, y, and z. But to define coordinates, we need a basis: is z a vertical coordinate? Does it go to the top or to the bottom? There are no universal answers and no conventions because the truth is you can choose whatever you want. The only thing to remember is that when you operate on 3D objects, you have to be consistent because the formula will change based on it. In this article, I chose the basics that can be seen in the cube architecture above: x from left to right, y from back to front, z from bottom to top.

Now, we know what to do: we have coordinates in (x,y,z) basis, and in order to display them we need to convert them to coordinates in (x,z) basis: since it is a plane, we will be able to display them.

There is not just one projection. To make matters worse, there are countless different projections! In this article we will look at two different types of projections, which are the most commonly used in practice.

6. How to render our scene

Before projecting our 3D objects, let’s write the function that displays them. This function accepts as argument an array listing the object to be rendered, the canvas context that must be used to display the object, and other details needed to draw the object in the correct location.

The array can contain multiple objects to render. These objects must respect one thing: there is a public property called faces which is an array listing all the faces of the object (like the cube we created earlier). The faces can be anything (squares, triangles, even dodecagons, if you like): they just need to be arrays listing their vertices.

Let’s take a look at the code for this function, followed by an explanation:

function render(objects, ctx, dx, dy) {
    // For each object
    for (var i = 0, n_obj = objects.length; i < n_obj; + + i) {
        // For each face
        for (var j = 0, n_faces = objects[i].faces.length; j < n_faces; + + j) {
            // Current face
            var face = objects[i].faces[j];

            // Draw the first vertex
            var P = project(face[0]);
            ctx.beginPath();
            ctx.moveTo(P.x + dx, -P.y + dy);

            // Draw the other vertices
            for (var k = 1, n_vertices = face.length; k < n_vertices; + + k) {
                P = project(face[k]);
                ctx.lineTo(P.x + dx, -P.y + dy);
            }

            // Close the path and draw the face
            ctx.closePath();
            ctx.stroke();
            ctx.fill();
        }
    }
}

This function deserves some explanation. More precisely, we need to explain what this project() function is, and what these dx and dy parameters are. All that’s left is basically making a list of the objects and then drawing each face.

As the name suggests, the project() function converts 3D coordinates into 2D coordinates. It accepts vertices in 3D space and returns vertices in a 2D plane defined as follows:

var Vertex2D = function(x, y) {
    this.x = parseFloat(x);
    this.y = parseFloat(y);
};

Instead of naming the coordinates x and z, I chose to rename the z coordinate to y to keep with the classic convention we see in 2D geometry, but you can keep z if you prefer.

The exact contents of project() is what we’ll see in the next section: it depends on the type of projection you choose. But no matter what the type is, the render() function can remain as is.

Once we have the coordinates on the plane, we can display them on the canvas, and that’s what we do… There’s a little trick: we don’t actually draw the actual coordinates returned by the project() function.

In fact, the project() function returns coordinates on a virtual 2D plane, but with the same origin as the coordinate origin we defined for the 3D space. However, we want the origin to be at the center of the canvas, which is why we translate the coordinates: the vertex (0,0) is not at the center of the canvas, but (0 + dx,0 + dy) is, if we choose dx and dy wisely. Since we want (dx,dy) to be in the center of the canvas, we have no real choice, so we define dx = canvas.width / 2 and dy = canvas.height / 2.

Finally, one last detail: why do we use -y instead of y directly? The answer lies in the basis of our choice: the z-axis points toward the top. Then, in our scene, vertices with positive z-coordinates will move upward. On the canvas, however, the y-axis points to the bottom: vertices with positive y-coordinates will move downward. That’s why we need to define the y coordinate on the canvas as the inverse of the z coordinate of the scene.

Now that the render() function is clear, it’s time to look at project().

7. Orthogonal view

Let’s start with Orthographic Projection. Because it’s the simplest, it’s easy to understand what we’re trying to do.

We have three coordinates, but we only need two. What’s the simplest thing to do in this situation? Delete one of the coordinates. This is what we do in orthographic view. We’ll remove the coordinate that represents depth: the y coordinate.

function project(M) {
    return new Vertex2D(M.x, M.z);
}

You can now test all the code we’ve written since the beginning of this article: it works! Congratulations, you just displayed a 3D object on a flat screen!

A demo of the functionality can be viewed in this CodePen, and you can interact with the cube by rotating it with your mouse:

Sometimes an orthographic view is just what we want because it has the advantage of preserving parallel lines. However, this isn’t the most natural sight: that’s not how our eyes see it. That’s why we see the second projection: perspective.

6. Perspective view

Perspective projection is slightly more complicated than orthographic projection because we need to do some calculations. However, these calculations are not that complicated, you only need to know one thing: how to use the Intercept Theorem.

To understand why, let’s look at a pattern that represents an orthographic view. We project the points orthogonally onto the plane:

However, in real life, our eyes behave more like the following pattern:

Basically we have two steps:

  • Connect the original vertex to the camera origin;
  • The projection is the intersection of this line with the plane.
  • Unlike orthographic views, in perspective views the exact position of the projected plane is important: if you place the plane far away from the camera, you will not get the same effect as if you place it close to the camera. Here we place it at a distance d from the camera.

Starting from a vertex M(x,y,z) in 3D space, we want to calculate the coordinates (x’,z’) of the projection M’ on the plane.

To guess how we would calculate these coordinates, let’s look at the same pattern as above from another perspective, but from the top:

We can identify the configurations used in the intercept theorem. In the pattern above, we know some values: x, y, d, etc. We want to calculate x’, so we apply the intercept theorem and get this equation: x’ = d / y * x.

Now, if you look at the same scene from the side, you get a similar pattern that allows you to get the value of z’ from z, y, and d: z’ = d / y * z.

We can now write the project() function using perspective:

function project(M) {
    // Distance between the camera and the plane
    var d = 200;
    var r = d / M.y;

    return new Vertex2D(r * M.x, r * M.z);
}

This functionality can be tested in this CodePen instance, where you can interact with the cube again:

7. Conclusion

Our (very basic) 3D engine is now ready to display any 3D shape we want. There are some things you can do to enhance it. For example, we can see every facet of our shape, even the face behind it. To hide them, you can implement back-face culling.

Also, we didn’t discuss texture. Here, all our shapes have the same color. For example, you can change it by adding color properties to objects to see how they are drawn. You can even choose a color for each face without having to change much. You can also try displaying images on the surface. However, this is more difficult, and detailing how to do such a thing would require an entire article.

Other things can change. We place the camera at the origin of the space, but you can move it (the base needs to be changed before projecting the vertices). Also, here we are drawing vertices placed behind the camera, which is not what we want. Clipping planes solve this problem (easy to understand, but not so easy to implement).

As you can see, the 3D engine we’re building here is far from complete, and that’s my own interpretation. You can add other classes of your own: Three.js, for example, uses dedicated classes to manage cameras and drop shadows. Also, we use basic math to store coordinates, but if you want to create a more complex application and for example need to rotate a lot of vertices during a frame, you won’t get a smooth experience. To optimize it, you’ll need some more complex math: homogeneous coordinates and quaternions.

Original link: 3D rendering principle and JS implementation – BimAnt