Introduction
I like to learn new technologies, doing things I never did before. I also like
TRON . Both films, by the way. I remember, even before I looked at them, in student dark times, I played
Armagetron and fanatel from racing on the light cycles. After watching TRON: Legacy, I suddenly wanted to make my Tron with grid and isomorphs. Without thinking twice, I launched my beloved Visual Studio Express and thought about it - and how would my creation differ from the dump site of the Tron clones? The studio closed smoothly, and my enthusiasm abated somewhat. Exactly until the moment I came across some article about WebGL. The eyes lit up again, and the hands themselves reached for the editor. The thought somehow didn’t come to my head that the last time I had done a handler for pressing a button for a test on a certain subject in JavaScript.
So, today in the program:
- Low-level WebGL programming.
- Rendering a simple three-dimensional object.
- Detailed comments on the development process.
- Lots of letters and javascript code.
- Free booze and nice music.

The article is intended for those who simply have nothing to do and want to read about how others spend their time at the computer instead of walking under the warm summer sun.
Read more about what we want to get.
As a result, you should get an HTML page, when loaded, the user will get control of the light cycle located on an infinite plane somewhere in the grid. The light cycle must be able to accelerate, brake, turn smoothly and leave behind a wall of light. I'm not a masochist, so the demo will consist of more than one file. Markup, scripts, shaders, models - everything will be pushed into directories. Even for one small CSS selector, the whole file will be highlighted. I don’t have my own dedicated server, so the demo will be distributed in the archive via file sharing.
')
Architecture
In this article I will not use ready-made libraries for rendering, for nefig. But seriously, to effectively use the same
tdl.js , as well as any other library in any language for any purpose, you must first understand how it works at a lower level. However, the article will clearly show that the development of even such a simple demo, which I ultimately want to do, by the forces of one person without the use of third-party libraries threatens to become clouded.
The logic will be in one file. In it, I will put the engine initialization code, including the loading of resources, the compilation of shaders and the main loop, as well as the scene rendering function. From the main loop, which is actually not a loop, but a callback using
requestAnimateFrame , calculation and rendering will be called up (well, or rendering and calculation, almost no difference. Almost;).
Rendering involves several steps:
- Rendering the environment, that is, the floor on which we drive and, optionally, the sky that hangs over us.
- Rendering of the light cycle, which in turn includes several substeps for rendering parts with different materials.
- Rendering of a strip of light extending beyond the light cycle.
Why not draw everything in one go, you ask? Due to the peculiarities of the OpenGL pipeline. More details below.
From this we will make a start. It makes no sense to describe in more detail, and it is time to write something about the implementation.
What is WebGL and how to use it?
A lot of useful information for a beginner can be found on
this site . But
this link leads to a page with a hint on the functions of WebGL.
In a nutshell, WebGL is a set of
OpenGL ES 2.0 bindings for JavaScript. This technology is still being actively developed, therefore, there is still no meaningful and complete documentation on it. But enthusiasts with might and main
use it .
It is very easy to use. You just need to throw the
<canvas />
tag into the body of the HTML document, execute the method of the
canvas.getContext("experimental-webgl")
element
canvas.getContext("experimental-webgl")
and thereby get the object that is used to render WebGL. Let's start writing code.
Engine Initialization
It all starts with the loading page. Set a callback to this event using jQuery, for this is the easiest way. In the callback, let's call the resource loading functions, get the GL context and start the engine.
$(function() { loadResources(); var gl = $("#viewport")[0].getContext("experimental-webgl"); engineStartup(gl); });
Then we need to compile and build the shader programs that will be used in the demo. This includes the following functions:
function buildShaders(gl, count) { var shaders = []; for (var i = 0; i < 1; i++) { shaders[i] = composeProgram(gl, localStorage.getItem("step " + i + " vertex shader"), localStorage.getItem("step " + i + " fragment shader")); } return shaders; }
Yes, yes, I use the browser's local storage to download files from the local machine. Here I am such a pervert. By the way, in order for my favorite Chrome to let me do this, you have to run it like this:
"chrome --allow-file-access-from-files"
.
But it's better to talk about the code and what it means. The first function, I think, will not arouse any interest in it - it compiles an array of shader programs. But the creation of a shader program that occurs in the second function is much more interesting. First you need to understand what a
shader in WebGL .
A shader is such a thing ...
A shader is a program that runs on the graphics processor during frame processing. In OpenGL ES 2.0, there are two types of shaders - vertex and pixel (vertex & fragment, respectively). The order of operations in the OpenGL pipeline is described in detail
here , we only need to know that the vertex shader runs before the pixel shader and operates with all the vertices in the pipeline. The pixel shader runs almost before the frame is displayed on the screen for each pixel in the pipeline and can use the data transmitted by the vertex shader. The combination of these two shaders is called a shader program. At the same time, the graphics processor can run only one shader program, but this does not mean that we are limited to two shaders for everything. Shader programs can be switched during rendering, changing the processing logic of all subsequent primitives. In order to do this quickly, you need to pre-compile all the shaders used and build them into shader programs. For storing programs, I did not find anything better than an array. The array index corresponds to the rendering step in which the shader program is used. We'll postpone the writing of the actual shaders, but for now let's consider the function of creating a shader program from the source code in more detail.
First of all, the
createProgram()
function is
createProgram()
, indicating GL that we want to create a shader program. Then we add vertex and pixel shaders to this program. Adding occurs in four steps. First, an object for the shader is created using
gl.createShader()
, then the
shaderSource()
function
shaderSource()
its source code, after which
compileShader()
compiled and the compiled shader is added to the program with
attachShader()
. Two function calls and a shader program contains two ready-to-use shaders. Now the program needs to be linked using
linkProgram()
- and it can be used.
I will not show the resource loading functions here and some auxiliary husk - it is absolutely boring. Better go ahead.
Shader Writing
You already have basic theoretical information about shaders, so here I will describe the writing of the shaders themselves.
Shaders are written in a C-like language. More precisely, two very similar languages - one for vertex and one for pixel shaders. You can read the GLSL spec
from here . The main program can transfer parameters to shaders using uniform variables. The main feature of these variables is that they cannot change their value during primitive processing, which makes them the main way of communication between the main program and the shader program. The vertex shader can accept attribute variables that are set for each vertex in the main program. For the connection between the vertex and pixel shaders, varying variables are used, which are initialized by the vertex shader, then interpolated over the area of the whole primitive being processed, and the interpolated values can be used by the pixel shader.
For example, I will give a simple shader program that uses all three types of variables.

The result is predictable - primitive with gradient. Delve into the process of processing.
Data Types and Variables in Shaders
At first glance, you can see strange data types for variables. There are many basic types in shader languages - boolean, integer, floating point, vector and matrix of several dimensions, handler for texture (for internal use only), structure and array. For now, only vectors and matrices are of interest to us, handlers are not particularly useful to us.
Vectors can be two-, three- or four-component, each component is a floating-point number. The first and second types are familiar to us from the school course of mathematics, but the latter makes us wonder - is it defined in the four-dimensional space? In general, if you drive the processing logic of four-dimensional coordinates into a shader, it can. When applied to three-dimensional space, the fourth component of the vector sets the depth value for the vertex. About what the depth in the scene - later. In general, vectors in shaders are used not only to determine coordinates in space. They can be used to store color values, normals, texture coordinates, dates of your birth ... what your heart desires. Moreover, it is worth noting the importance of the vector for transferring values to shaders - memory in buffers for transferring these values is a multiple of a four-component vector. So, if you need to transfer 2-3-4 floating-point numbers that are not related to each other in the shader, it will be most economical and right to shove them into one vector. We will come back to this.
Matrices are a much more interesting data type. Probably you have heard more than once that matrix computations are performed by a graphics processor an order of magnitude faster than universal ones? This is it. Matrix we will use very intensively. In more detail about operations with matrixes and their effects when rendering slightly below. Now you only need to know that in the given vertex shader two matrices are used - perspectives and displacements. The perspective matrix determines the position of the camera and the angle of view. The move matrix indicates where to move the vertex. Since the shader is executed for the primitive, as a result, the entire primitive is moved, optionally turning at any angle.
You
gl_Position
noticed the variables
gl_Position
and
gl_FragColor
. In these variables, the shader inserts the result of its execution.
gl_Position
determines the position of the vertex in three-dimensional space.
gl_FragColor
sets the color of the pixel that we see on the screen. Remember that uniform variables are interpolated throughout the primitive, before getting into the pixel shader? That is why there is a gradient on the primitive. I hope everything is more or less clear, because the shaders that will be used in the demo are not an example more complicated than the ones given.
Rendering, matrices and all-all-all
You may have already realized that the graphics processor does not know anything about the camera and even about the position of the vertices before the vertex shader executes. Therefore, it is very important to be able to teach him this. The camera, as mentioned earlier, is set by the matrix. The other matrix determines the movement of a point in space. To understand the principle of operation of these two matrices, plunge into mathematics with the head.
Theory
First you need to understand how the projection of the scene. Details in English are
here . The main idea is that a point in space is projected using matrix operations to a point on the display plane (this part of the conveyor is hidden from us and the glory of Gd;). Those of you who have carefully read the article on the link, will be indignant - they say, but why about the camera did not say anything? The fact is that the camera in OpenGL is always at one point — the origin of coordinates — and is aligned with the negative part of the Z axis. Now that everyone else is stretching their fingers to tear the author into small polygons in the comments, I hasten to explain “why then Quake can you turn your head? ". The fact is that it is easier to work with the graphics processor - the formulas for the projection of a three-dimensional point are greatly simplified, and as a result, the number of required calculations decreases. Well, or so the manufacturers of graphics accelerators simplify the lives of programmers, giving them complete freedom in the implementation of their camera. That is, when you jerk your mouse in a quake - this is actually not a camera spinning, but the whole scene rotates around the origin. Inquisitors would have liked that. So there is no difference between the perspective matrix and the displacement matrix - they both move a point in space. But the displacement matrix does this only to move the object in space, and the perspective matrix changes the resulting space so that only the desired part of the scene is in the frame. Knowing that to display a primitive in space, you need to specify two matrices, you can ask the question - does this mean that more complex models, for example, a teapot, can be saved once and then to move the whole object, simply recalculate the matrices? The answer is yes. Moreover, OpenGL gives us the opportunity to save a set of object vertices directly in the video card's memory using vertex buffers, saving bandwidth of the video card bus.
Practice
And here we smoothly approached the description of the scene rendering process. For example, let's take a triangle and a square with a gradient, but we will do this not just by outputting a static picture on the screen, but let the user move the camera.
We take something simple and complicate
Before rendering, you need to prepare - build the necessary shader programs, initialize the vertex buffers, adjust the projection plane, specify the initial settings of the camera and the position of the objects. So far, I have shown only the first item from the list. Correct.
Buffers, buffers ...
Let's start with working with buffers. The concept itself is very simple - there are attributes of the vertex shader and there are arrays with the values of these attributes. The number of elements in the arrays for one primitive must match. Using the
createBuffer()
function,
createBuffer()
declare the GL subsystem that we want to allocate space for an array of values. Then, using
bindBuffer()
select the newly created buffer. This is very important, because only one buffer can be selected at a time, so if you need to process several buffers, you need to consistently select them and take the necessary actions, just like that. But something must be stored in the buffer, so call
bufferData()
and specify the values of the array and its size. In code, it looks like this (a piece of the buffer creation function):
var buffers = []; buffers[0] = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffers[0]); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, -1.0, 0.0]), gl.STATIC_DRAW); buffers[0].itemSize = 3; buffers[0].itemCount = 3; buffers[0].attributeLocation = gl.getAttribLocation(shaders[0], "aVertexPosition"); gl.enableVertexAttribArray(buffers[0].attributeLocation);
The variables
itemSize
,
itemCount
and
attributeLocation
are used during rendering. We will not focus on them yet. Using the
getAttribLocation()
function, the positions of two attributes of the vertex shader are saved for later use in rendering. The function
enableVertexAttribArray()
does exactly what you expect from it.
Maybe draw something already?
Actually, at the moment we need one shader program and two buffers - one contains the position of the primitive vertices, and the other their colors in the RGBA format float32 (I was not mistaken, a 32-bit number with a floating point per channel). You can score on all these matrices and viewports and just draw a triangle. The result, of course, will not be so hot.

An inquisitive eye will immediately notice the monstrous aliasing on the left sides of the triangle. Moreover, when the browser window is resized, the triangle will also change its proportions, which is completely unacceptable. We will cope with all these problems, however, the code of the example can be taken
here if someone is interested. We proceed to the addition of transformations and setting sane display.
Matrices again
For matrix operations, I use the
glMatrix.js library, because I don’t want to debug errors in matrix calculations.
First you need to understand what is the movement of a point in space. For example, take point A (1; 1; 1) and try to move it to +1 along the Z axis. After long and painful attempts to count something in my mind, I used a calculator and got point A (1; 1; 2). This is a very simple operation, since when you move a point along one or more axes, you only need to add a number to the corresponding component of the point. And now let's try to rotate point A (1; 1; 0) by 45 degrees in the XY plane relative to the positive direction of the X axis. Crushing our brains painfully, we can recall something
similar from higher mathematics. According to the provided link, quaternions and their application are described in sufficient detail, I recommend reading. English readers can go to
Wikipedia to learn more theory. But for work we only need to know that the 4x4 matrix can contain information about the rotation of a point relative to each of the three axes and the movement of a point in space. We don’t need any more. With a combination of these four transformations, you can move any point as we like. And not only a point, but a whole primitive and even an object.
Let's turn the ill-fated point A (1; 1; 0), but do it in the code. First you need to create a transformation matrix. This process consists of several stages. First, create an
identity matrix , which means that no transformations will be applied to the point. This matrix contains units in the main diagonal and zeros in the remaining positions. Then, the required transformations — the movement (the
translate()
function), the rotation (the
rotate()
function, respectively) and the scaling (the
scale()
function
scale()
must be applied successively to the identity matrix. As a result, we obtain a matrix combining all the transformations applied to it. In code, it looks like this:
var matrix = mat4.create(); mat4.identity(matrix); matrix.rotate(matrix, Math.PI / 4, [1, 0, 0]);
As a result, we obtain a matrix whose multiplication by a vector, which is the initial point in space, will result in the desired point. This action is performed in the vertex shader, and it is as easy as multiplying two numbers. Seriously, the multiplication of a matrix by a vector (and the matrix by a matrix, and a vector by a vector too) in GLSL is represented by a simple operator "*":
attribute vec3 aVertexPosition; attribute vec4 aVertexColor; uniform mat4 matrix; varying vec4 vColor; void main() { gl_Position = matrix * vec4(aVertexPosition, 1.0); vColor = aVertexColor; }
Do not forget that the transformation matrix is transferred to the vertex shader as a uniform variable.
In principle, it is possible to use one matrix to transfer the primitive transformation parameters to the vertex shader both for the camera and for directly moving an object in space. But these are logically completely different things. The camera may not change its characteristics, and objects on the scene can move, and vice versa, but when calculating each frame, we will have to re-combine both types of transformations each time. Plus, there may be more than one object on the scene, which will automatically increase the number of calculations. So we will have to keep one matrix for the camera and one for each object, and the graphics processor will multiply them - it is in order for the computer to be installed.
More about the camera
If the movement of primitives is not so surprising, then working with the camera threatens to finish off your brain completely. But the devil is not so bad as he is painted. In fact, these are all the same native transformations, but from a different point of view (forgive the pun). As mentioned above, the camera in OpenGL is at the origin. In order for a certain object to appear on the screen, you need to move it in space to the negative part of the Z axis. To rotate the camera 90 degrees up (X, I mean), you need to rotate the scene 90 degrees down relative to the origin along the same X axis. Imitation of movement from an object is made by simply moving it from the origin of coordinates along the Z axis. We will not need scaling yet.
The most important property of the camera is that the camera matrix is applied to all objects in the scene.glMatrix.js helps us in creating the matrix for the camera. There are already many as three functions for different projections: perspective()
, ortho()
and frustrum()
. We will use it for now perspective()
. But no one forbids creating your own matrix with Neo and Trinity with arbitrary parameters.And yet he spins!
The following code will make our triangle rotate. At the same time, you will notice that it has become somewhat smaller - this is the effect of perspective.
Preparatory actions have already been described, except for one - using getUniformLocation()
our shader to obtain the location of the uniform-variables, but since its use is simple, we will not slow down. The source, by the way, can be found here .Let's sort the code. The first function call viewport()
sets the size of the image display area on our canvas. Functions clearColor()
and clear()
clears the output area in the specified color. Matrix operations are described earlier; I will not describe the parameters of the functions - they can be found on the library page. Using the familiar function, we bindBuffer()
select arrays with coordinates and vertex colors, which are then set with the help of the vertexAttribPointer()
source for the vertex shader attribute variables. Then two calls follow.uniformMatrix4fv()
that define two uniform-matrices for shaders: the first is the camera matrix, the second is the primitive transformations. Well, in the end, drawArrays()
displays our triangle.Final chord
The article has accidentally grown much more than I expected, so it will have to be divided into at least two parts. However, so that the reader does not waste his time reading about the next rotating triangle on OpenGL, at the end a small model of the light cycle will be displayed on the screen.Model View in OpenGL
Actually, OpenGL doesn't know what a model is. It operates only with primitives and their arrays. The task of the programmer is to present a model of any format as a set of primitives that can be fed to the GL pipeline and get the expected result in the form of a projection of this model on the screen. You will not believe it, but to display a complex mesh, we almost do not need to change the rendering code. In fact, you only need to add some actions to display the model as a frame. But first, take a closer look at the functiondrawArrays()
. It takes three arguments — the first determines how to interpret the selected vertex buffer, the second indicates the index of the element from which to start processing, and the third specifies the number of processed vertices. Simply put, filling the vertex buffer with the coordinates of the vertices of all the triangles of the model, we just need to increase the number of processed vertices - and everything will be a bundle.An experienced developer will immediately point out a lack of approach - excessive memory usage for storing duplicate vertices. The fact is that in OpenGL there are two ways to reduce memory usage and speed up the output of primitives. The first is another function for outputting primitives -drawElements()
. Its main difference is that it operates not just with the coordinates of the vertices, but with an array of indexes of the vertices and an array of the actual vertices. Thus, in order to output two triangles with two common vertices, you will need to first save in the memory of the video card an array of vertices, and then transfer the drawElements()
array of vertex indices that we want to use to display the primitive. Given that the size of the index can be as much as two bytes, memory consumption will fall. This method is good, but only for a very large number of duplicate vertices in different models. In our case, it would be best to use the parameter TRIANGLE_STRIP
instead of TRIANGLES
in the call.drawArrays()
. The strip of triangles is a regular array with vertices, but it is interpreted differently. The first triangle will consist of vertices with indices [0,1,2], as in the case of and TRIANGLES
. However, the vertices of the second triangle will be [1,2,3], whereas in the case of the TRIANGLES
second triangle it is given by the vertices with indices [3,4,5]. Also a good memory saving. The model that I use (taken, by the way, from here ) consists of an array of vertices and an array of triangles, with the vertices of the triangles being addressed by indices in the first array. Therefore, the most appropriate option would be to use drawElements()
with the parameter TRIANGLES
. For the sake of simplifying the task, I will not sort the triangles so that they make up a strip, but in the future this is necessary.Rendering model
The model in JSON format consists of an array of vertices, an array of indices, the number of vertices and the number of triangles. Parsing is done using jQuery. The rendering code has not changed much, the only difference is at the very end - when the object is displayed. Shaders haven't changed that at all. gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers[2]); gl.drawElements(gl.TRIANGLES, buffers[2].itemCount, gl.UNSIGNED_SHORT, 0);
The third buffer contains an array of indices. It must be selected in GL before calling drawElements()
. It should be noted that at least the vertex buffer and the vertex color buffer are filled with parsed JSON, but this has almost no effect on the code. The colors of the vertices are considered randomly, it is fun to color the model if all components are set to random values. The source can be obtained from here . But what can he show:
Misadventure
I began to write this article as a way to streamline my thoughts during development. You should not consider it as a tutorial or theoretical calculations - both can be found by reference in the article. However, if someone described will seem interesting or even prove useful - I will be glad. Fair.
If the topic is interesting, I will write a sequel. It is possible that not soon - now I do not have a lot of free time for a hobby - but I will write. In the next series:
- Depth buffer and stencil buffer.
- Reflections and textures.
- Control.
- Explore a higher-level WebGL library.
If JavaScript gurus say in comments, I’ll be happy to hear tips on improving code and further development.There will be no references to the materials used, because they are in the body of the article, but to pull out laziness.